Move tests into test/unit

This makes room for a sibling directory for nodepool functional tests.

Change-Id: Iace94d313edb04192ac23a533ed967f076410980
diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/tests/unit/__init__.py
diff --git a/tests/unit/test_change_matcher.py b/tests/unit/test_change_matcher.py
new file mode 100644
index 0000000..0585322
--- /dev/null
+++ b/tests/unit/test_change_matcher.py
@@ -0,0 +1,154 @@
+# Copyright 2015 Red Hat, Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+
+from zuul import change_matcher as cm
+from zuul import model
+
+from tests.base import BaseTestCase
+
+
+class BaseTestMatcher(BaseTestCase):
+
+    project = 'project'
+
+    def setUp(self):
+        super(BaseTestMatcher, self).setUp()
+        self.change = model.Change(self.project)
+
+
+class TestAbstractChangeMatcher(BaseTestMatcher):
+
+    def test_str(self):
+        matcher = cm.ProjectMatcher(self.project)
+        self.assertEqual(str(matcher), '{ProjectMatcher:project}')
+
+    def test_repr(self):
+        matcher = cm.ProjectMatcher(self.project)
+        self.assertEqual(repr(matcher), '<ProjectMatcher project>')
+
+
+class TestProjectMatcher(BaseTestMatcher):
+
+    def test_matches_returns_true(self):
+        matcher = cm.ProjectMatcher(self.project)
+        self.assertTrue(matcher.matches(self.change))
+
+    def test_matches_returns_false(self):
+        matcher = cm.ProjectMatcher('not_project')
+        self.assertFalse(matcher.matches(self.change))
+
+
+class TestBranchMatcher(BaseTestMatcher):
+
+    def setUp(self):
+        super(TestBranchMatcher, self).setUp()
+        self.matcher = cm.BranchMatcher('foo')
+
+    def test_matches_returns_true_on_matching_branch(self):
+        self.change.branch = 'foo'
+        self.assertTrue(self.matcher.matches(self.change))
+
+    def test_matches_returns_true_on_matching_ref(self):
+        self.change.branch = 'bar'
+        self.change.ref = 'foo'
+        self.assertTrue(self.matcher.matches(self.change))
+
+    def test_matches_returns_false_for_no_match(self):
+        self.change.branch = 'bar'
+        self.change.ref = 'baz'
+        self.assertFalse(self.matcher.matches(self.change))
+
+    def test_matches_returns_false_for_missing_attrs(self):
+        delattr(self.change, 'branch')
+        # ref is by default not an attribute
+        self.assertFalse(self.matcher.matches(self.change))
+
+
+class TestFileMatcher(BaseTestMatcher):
+
+    def setUp(self):
+        super(TestFileMatcher, self).setUp()
+        self.matcher = cm.FileMatcher('filename')
+
+    def test_matches_returns_true(self):
+        self.change.files = ['filename']
+        self.assertTrue(self.matcher.matches(self.change))
+
+    def test_matches_returns_false_when_no_files(self):
+        self.assertFalse(self.matcher.matches(self.change))
+
+    def test_matches_returns_false_when_files_attr_missing(self):
+        delattr(self.change, 'files')
+        self.assertFalse(self.matcher.matches(self.change))
+
+
+class TestAbstractMatcherCollection(BaseTestMatcher):
+
+    def test_str(self):
+        matcher = cm.MatchAll([cm.FileMatcher('foo')])
+        self.assertEqual(str(matcher), '{MatchAll:{FileMatcher:foo}}')
+
+    def test_repr(self):
+        matcher = cm.MatchAll([])
+        self.assertEqual(repr(matcher), '<MatchAll>')
+
+
+class TestMatchAllFiles(BaseTestMatcher):
+
+    def setUp(self):
+        super(TestMatchAllFiles, self).setUp()
+        self.matcher = cm.MatchAllFiles([cm.FileMatcher('^docs/.*$')])
+
+    def _test_matches(self, expected, files=None):
+        if files is not None:
+            self.change.files = files
+        self.assertEqual(expected, self.matcher.matches(self.change))
+
+    def test_matches_returns_false_when_files_attr_missing(self):
+        delattr(self.change, 'files')
+        self._test_matches(False)
+
+    def test_matches_returns_false_when_no_files(self):
+        self._test_matches(False)
+
+    def test_matches_returns_false_when_not_all_files_match(self):
+        self._test_matches(False, files=['/COMMIT_MSG', 'docs/foo', 'foo/bar'])
+
+    def test_matches_returns_false_when_commit_message_matches(self):
+        self._test_matches(False, files=['/COMMIT_MSG'])
+
+    def test_matches_returns_true_when_all_files_match(self):
+        self._test_matches(True, files=['/COMMIT_MSG', 'docs/foo'])
+
+
+class TestMatchAll(BaseTestMatcher):
+
+    def test_matches_returns_true(self):
+        matcher = cm.MatchAll([cm.ProjectMatcher(self.project)])
+        self.assertTrue(matcher.matches(self.change))
+
+    def test_matches_returns_false_for_missing_matcher(self):
+        matcher = cm.MatchAll([cm.ProjectMatcher('not_project')])
+        self.assertFalse(matcher.matches(self.change))
+
+
+class TestMatchAny(BaseTestMatcher):
+
+    def test_matches_returns_true(self):
+        matcher = cm.MatchAny([cm.ProjectMatcher(self.project)])
+        self.assertTrue(matcher.matches(self.change))
+
+    def test_matches_returns_false(self):
+        matcher = cm.MatchAny([cm.ProjectMatcher('not_project')])
+        self.assertFalse(matcher.matches(self.change))
diff --git a/tests/unit/test_clonemapper.py b/tests/unit/test_clonemapper.py
new file mode 100644
index 0000000..b7814f8
--- /dev/null
+++ b/tests/unit/test_clonemapper.py
@@ -0,0 +1,84 @@
+# Copyright 2014 Antoine "hashar" Musso
+# Copyright 2014 Wikimedia Foundation Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+
+import logging
+import testtools
+from zuul.lib.clonemapper import CloneMapper
+
+logging.basicConfig(level=logging.DEBUG,
+                    format='%(asctime)s %(name)-17s '
+                    '%(levelname)-8s %(message)s')
+
+
+class TestCloneMapper(testtools.TestCase):
+
+    def test_empty_mapper(self):
+        """Given an empty map, the slashes in project names are directory
+           separators"""
+        cmap = CloneMapper(
+            {},
+            [
+                'project1',
+                'plugins/plugin1'
+            ])
+
+        self.assertEqual(
+            {'project1': '/basepath/project1',
+             'plugins/plugin1': '/basepath/plugins/plugin1'},
+            cmap.expand('/basepath')
+        )
+
+    def test_map_to_a_dot_dir(self):
+        """Verify we normalize path, hence '.' refers to the basepath"""
+        cmap = CloneMapper(
+            [{'name': 'mediawiki/core', 'dest': '.'}],
+            ['mediawiki/core'])
+        self.assertEqual(
+            {'mediawiki/core': '/basepath'},
+            cmap.expand('/basepath'))
+
+    def test_map_using_regex(self):
+        """One can use regex in maps and use \\1 to forge the directory"""
+        cmap = CloneMapper(
+            [{'name': 'plugins/(.*)', 'dest': 'project/plugins/\\1'}],
+            ['plugins/PluginFirst'])
+        self.assertEqual(
+            {'plugins/PluginFirst': '/basepath/project/plugins/PluginFirst'},
+            cmap.expand('/basepath'))
+
+    def test_map_discarding_regex_group(self):
+        cmap = CloneMapper(
+            [{'name': 'plugins/(.*)', 'dest': 'project/'}],
+            ['plugins/Plugin_1'])
+        self.assertEqual(
+            {'plugins/Plugin_1': '/basepath/project'},
+            cmap.expand('/basepath'))
+
+    def test_cant_dupe_destinations(self):
+        """We cant clone multiple projects in the same directory"""
+        cmap = CloneMapper(
+            [{'name': 'plugins/(.*)', 'dest': 'catchall/'}],
+            ['plugins/plugin1', 'plugins/plugin2']
+        )
+        self.assertRaises(Exception, cmap.expand, '/basepath')
+
+    def test_map_with_dot_and_regex(self):
+        """Combining relative path and regex"""
+        cmap = CloneMapper(
+            [{'name': 'plugins/(.*)', 'dest': './\\1'}],
+            ['plugins/PluginInBasePath'])
+        self.assertEqual(
+            {'plugins/PluginInBasePath': '/basepath/PluginInBasePath'},
+            cmap.expand('/basepath'))
diff --git a/tests/unit/test_cloner.py b/tests/unit/test_cloner.py
new file mode 100644
index 0000000..67b5303
--- /dev/null
+++ b/tests/unit/test_cloner.py
@@ -0,0 +1,624 @@
+#!/usr/bin/env python
+
+# Copyright 2012 Hewlett-Packard Development Company, L.P.
+# Copyright 2014 Wikimedia Foundation Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+
+import logging
+import os
+import shutil
+import time
+
+import git
+
+import zuul.lib.cloner
+
+from tests.base import ZuulTestCase
+
+logging.basicConfig(level=logging.DEBUG,
+                    format='%(asctime)s %(name)-32s '
+                    '%(levelname)-8s %(message)s')
+
+
+class TestCloner(ZuulTestCase):
+
+    log = logging.getLogger("zuul.test.cloner")
+    workspace_root = None
+
+    def setUp(self):
+        self.skip("Disabled for early v3 development")
+
+        super(TestCloner, self).setUp()
+        self.workspace_root = os.path.join(self.test_root, 'workspace')
+
+        self.updateConfigLayout(
+            'tests/fixtures/layout-cloner.yaml')
+        self.sched.reconfigure(self.config)
+        self.registerJobs()
+
+    def getWorkspaceRepos(self, projects):
+        repos = {}
+        for project in projects:
+            repos[project] = git.Repo(
+                os.path.join(self.workspace_root, project))
+        return repos
+
+    def getUpstreamRepos(self, projects):
+        repos = {}
+        for project in projects:
+            repos[project] = git.Repo(
+                os.path.join(self.upstream_root, project))
+        return repos
+
+    def test_cache_dir(self):
+        projects = ['org/project1', 'org/project2']
+        cache_root = os.path.join(self.test_root, "cache")
+        for project in projects:
+            upstream_repo_path = os.path.join(self.upstream_root, project)
+            cache_repo_path = os.path.join(cache_root, project)
+            git.Repo.clone_from(upstream_repo_path, cache_repo_path)
+
+        self.worker.hold_jobs_in_build = True
+        A = self.fake_gerrit.addFakeChange('org/project1', 'master', 'A')
+        A.addApproval('CRVW', 2)
+        self.fake_gerrit.addEvent(A.addApproval('APRV', 1))
+
+        self.waitUntilSettled()
+
+        self.assertEquals(1, len(self.builds), "One build is running")
+
+        B = self.fake_gerrit.addFakeChange('org/project2', 'master', 'B')
+        B.setMerged()
+
+        upstream = self.getUpstreamRepos(projects)
+        states = [{
+            'org/project1': self.builds[0].parameters['ZUUL_COMMIT'],
+            'org/project2': str(upstream['org/project2'].commit('master')),
+        }]
+
+        for number, build in enumerate(self.builds):
+            self.log.debug("Build parameters: %s", build.parameters)
+            cloner = zuul.lib.cloner.Cloner(
+                git_base_url=self.upstream_root,
+                projects=projects,
+                workspace=self.workspace_root,
+                zuul_branch=build.parameters['ZUUL_BRANCH'],
+                zuul_ref=build.parameters['ZUUL_REF'],
+                zuul_url=self.git_root,
+                cache_dir=cache_root,
+            )
+            cloner.execute()
+            work = self.getWorkspaceRepos(projects)
+            state = states[number]
+
+            for project in projects:
+                self.assertEquals(state[project],
+                                  str(work[project].commit('HEAD')),
+                                  'Project %s commit for build %s should '
+                                  'be correct' % (project, number))
+
+        work = self.getWorkspaceRepos(projects)
+        upstream_repo_path = os.path.join(self.upstream_root, 'org/project1')
+        self.assertEquals(
+            work['org/project1'].remotes.origin.url,
+            upstream_repo_path,
+            'workspace repo origin should be upstream, not cache'
+        )
+
+        self.worker.hold_jobs_in_build = False
+        self.worker.release()
+        self.waitUntilSettled()
+
+    def test_one_branch(self):
+        self.worker.hold_jobs_in_build = True
+
+        projects = ['org/project1', 'org/project2']
+        A = self.fake_gerrit.addFakeChange('org/project1', 'master', 'A')
+        B = self.fake_gerrit.addFakeChange('org/project2', 'master', 'B')
+        A.addApproval('CRVW', 2)
+        B.addApproval('CRVW', 2)
+        self.fake_gerrit.addEvent(A.addApproval('APRV', 1))
+        self.fake_gerrit.addEvent(B.addApproval('APRV', 1))
+
+        self.waitUntilSettled()
+
+        self.assertEquals(2, len(self.builds), "Two builds are running")
+
+        upstream = self.getUpstreamRepos(projects)
+        states = [
+            {'org/project1': self.builds[0].parameters['ZUUL_COMMIT'],
+             'org/project2': str(upstream['org/project2'].commit('master')),
+             },
+            {'org/project1': self.builds[0].parameters['ZUUL_COMMIT'],
+             'org/project2': self.builds[1].parameters['ZUUL_COMMIT'],
+             },
+        ]
+
+        for number, build in enumerate(self.builds):
+            self.log.debug("Build parameters: %s", build.parameters)
+            cloner = zuul.lib.cloner.Cloner(
+                git_base_url=self.upstream_root,
+                projects=projects,
+                workspace=self.workspace_root,
+                zuul_branch=build.parameters['ZUUL_BRANCH'],
+                zuul_ref=build.parameters['ZUUL_REF'],
+                zuul_url=self.git_root,
+            )
+            cloner.execute()
+            work = self.getWorkspaceRepos(projects)
+            state = states[number]
+
+            for project in projects:
+                self.assertEquals(state[project],
+                                  str(work[project].commit('HEAD')),
+                                  'Project %s commit for build %s should '
+                                  'be correct' % (project, number))
+
+            shutil.rmtree(self.workspace_root)
+
+        self.worker.hold_jobs_in_build = False
+        self.worker.release()
+        self.waitUntilSettled()
+
+    def test_multi_branch(self):
+        self.worker.hold_jobs_in_build = True
+        projects = ['org/project1', 'org/project2',
+                    'org/project3', 'org/project4']
+
+        self.create_branch('org/project2', 'stable/havana')
+        self.create_branch('org/project4', 'stable/havana')
+        A = self.fake_gerrit.addFakeChange('org/project1', 'master', 'A')
+        B = self.fake_gerrit.addFakeChange('org/project2', 'stable/havana',
+                                           'B')
+        C = self.fake_gerrit.addFakeChange('org/project3', 'master', 'C')
+        A.addApproval('CRVW', 2)
+        B.addApproval('CRVW', 2)
+        C.addApproval('CRVW', 2)
+        self.fake_gerrit.addEvent(A.addApproval('APRV', 1))
+        self.fake_gerrit.addEvent(B.addApproval('APRV', 1))
+        self.fake_gerrit.addEvent(C.addApproval('APRV', 1))
+
+        self.waitUntilSettled()
+
+        self.assertEquals(3, len(self.builds), "Three builds are running")
+
+        upstream = self.getUpstreamRepos(projects)
+        states = [
+            {'org/project1': self.builds[0].parameters['ZUUL_COMMIT'],
+             'org/project2': str(upstream['org/project2'].commit('master')),
+             'org/project3': str(upstream['org/project3'].commit('master')),
+             'org/project4': str(upstream['org/project4'].
+                                 commit('master')),
+             },
+            {'org/project1': self.builds[0].parameters['ZUUL_COMMIT'],
+             'org/project2': self.builds[1].parameters['ZUUL_COMMIT'],
+             'org/project3': str(upstream['org/project3'].commit('master')),
+             'org/project4': str(upstream['org/project4'].
+                                 commit('stable/havana')),
+             },
+            {'org/project1': self.builds[0].parameters['ZUUL_COMMIT'],
+             'org/project2': str(upstream['org/project2'].commit('master')),
+             'org/project3': self.builds[2].parameters['ZUUL_COMMIT'],
+             'org/project4': str(upstream['org/project4'].
+                                 commit('master')),
+             },
+        ]
+
+        for number, build in enumerate(self.builds):
+            self.log.debug("Build parameters: %s", build.parameters)
+            cloner = zuul.lib.cloner.Cloner(
+                git_base_url=self.upstream_root,
+                projects=projects,
+                workspace=self.workspace_root,
+                zuul_branch=build.parameters['ZUUL_BRANCH'],
+                zuul_ref=build.parameters['ZUUL_REF'],
+                zuul_url=self.git_root,
+            )
+            cloner.execute()
+            work = self.getWorkspaceRepos(projects)
+            state = states[number]
+
+            for project in projects:
+                self.assertEquals(state[project],
+                                  str(work[project].commit('HEAD')),
+                                  'Project %s commit for build %s should '
+                                  'be correct' % (project, number))
+            shutil.rmtree(self.workspace_root)
+
+        self.worker.hold_jobs_in_build = False
+        self.worker.release()
+        self.waitUntilSettled()
+
+    def test_upgrade(self):
+        # Simulates an upgrade test
+        self.worker.hold_jobs_in_build = True
+        projects = ['org/project1', 'org/project2', 'org/project3',
+                    'org/project4', 'org/project5', 'org/project6']
+
+        self.create_branch('org/project2', 'stable/havana')
+        self.create_branch('org/project3', 'stable/havana')
+        self.create_branch('org/project4', 'stable/havana')
+        self.create_branch('org/project5', 'stable/havana')
+        A = self.fake_gerrit.addFakeChange('org/project1', 'master', 'A')
+        B = self.fake_gerrit.addFakeChange('org/project2', 'master', 'B')
+        C = self.fake_gerrit.addFakeChange('org/project3', 'stable/havana',
+                                           'C')
+        D = self.fake_gerrit.addFakeChange('org/project3', 'master', 'D')
+        E = self.fake_gerrit.addFakeChange('org/project4', 'stable/havana',
+                                           'E')
+        A.addApproval('CRVW', 2)
+        B.addApproval('CRVW', 2)
+        C.addApproval('CRVW', 2)
+        D.addApproval('CRVW', 2)
+        E.addApproval('CRVW', 2)
+        self.fake_gerrit.addEvent(A.addApproval('APRV', 1))
+        self.fake_gerrit.addEvent(B.addApproval('APRV', 1))
+        self.fake_gerrit.addEvent(C.addApproval('APRV', 1))
+        self.fake_gerrit.addEvent(D.addApproval('APRV', 1))
+        self.fake_gerrit.addEvent(E.addApproval('APRV', 1))
+
+        self.waitUntilSettled()
+
+        self.assertEquals(5, len(self.builds), "Five builds are running")
+
+        # Check the old side of the upgrade first
+        upstream = self.getUpstreamRepos(projects)
+        states = [
+            {'org/project1': self.builds[0].parameters['ZUUL_COMMIT'],
+             'org/project2': str(upstream['org/project2'].commit(
+                                 'stable/havana')),
+             'org/project3': str(upstream['org/project3'].commit(
+                                 'stable/havana')),
+             'org/project4': str(upstream['org/project4'].commit(
+                                 'stable/havana')),
+             'org/project5': str(upstream['org/project5'].commit(
+                                 'stable/havana')),
+             'org/project6': str(upstream['org/project6'].commit('master')),
+             },
+            {'org/project1': self.builds[0].parameters['ZUUL_COMMIT'],
+             'org/project2': str(upstream['org/project2'].commit(
+                                 'stable/havana')),
+             'org/project3': str(upstream['org/project3'].commit(
+                                 'stable/havana')),
+             'org/project4': str(upstream['org/project4'].commit(
+                                 'stable/havana')),
+             'org/project5': str(upstream['org/project5'].commit(
+                                 'stable/havana')),
+             'org/project6': str(upstream['org/project6'].commit('master')),
+             },
+            {'org/project1': self.builds[0].parameters['ZUUL_COMMIT'],
+             'org/project2': str(upstream['org/project2'].commit(
+                                 'stable/havana')),
+             'org/project3': self.builds[2].parameters['ZUUL_COMMIT'],
+             'org/project4': str(upstream['org/project4'].commit(
+                                 'stable/havana')),
+
+             'org/project5': str(upstream['org/project5'].commit(
+                                 'stable/havana')),
+             'org/project6': str(upstream['org/project6'].commit('master')),
+             },
+            {'org/project1': self.builds[0].parameters['ZUUL_COMMIT'],
+             'org/project2': str(upstream['org/project2'].commit(
+                                 'stable/havana')),
+             'org/project3': self.builds[2].parameters['ZUUL_COMMIT'],
+             'org/project4': str(upstream['org/project4'].commit(
+                                 'stable/havana')),
+             'org/project5': str(upstream['org/project5'].commit(
+                                 'stable/havana')),
+             'org/project6': str(upstream['org/project6'].commit('master')),
+             },
+            {'org/project1': self.builds[0].parameters['ZUUL_COMMIT'],
+             'org/project2': str(upstream['org/project2'].commit(
+                                 'stable/havana')),
+             'org/project3': self.builds[2].parameters['ZUUL_COMMIT'],
+             'org/project4': self.builds[4].parameters['ZUUL_COMMIT'],
+             'org/project5': str(upstream['org/project5'].commit(
+                                 'stable/havana')),
+             'org/project6': str(upstream['org/project6'].commit('master')),
+             },
+        ]
+
+        for number, build in enumerate(self.builds):
+            self.log.debug("Build parameters: %s", build.parameters)
+            cloner = zuul.lib.cloner.Cloner(
+                git_base_url=self.upstream_root,
+                projects=projects,
+                workspace=self.workspace_root,
+                zuul_branch=build.parameters['ZUUL_BRANCH'],
+                zuul_ref=build.parameters['ZUUL_REF'],
+                zuul_url=self.git_root,
+                branch='stable/havana',  # Old branch for upgrade
+            )
+            cloner.execute()
+            work = self.getWorkspaceRepos(projects)
+            state = states[number]
+
+            for project in projects:
+                self.assertEquals(state[project],
+                                  str(work[project].commit('HEAD')),
+                                  'Project %s commit for build %s should '
+                                  'be correct on old side of upgrade' %
+                                  (project, number))
+            shutil.rmtree(self.workspace_root)
+
+        # Check the new side of the upgrade
+        states = [
+            {'org/project1': self.builds[0].parameters['ZUUL_COMMIT'],
+             'org/project2': str(upstream['org/project2'].commit('master')),
+             'org/project3': str(upstream['org/project3'].commit('master')),
+             'org/project4': str(upstream['org/project4'].commit('master')),
+             'org/project5': str(upstream['org/project5'].commit('master')),
+             'org/project6': str(upstream['org/project6'].commit('master')),
+             },
+            {'org/project1': self.builds[0].parameters['ZUUL_COMMIT'],
+             'org/project2': self.builds[1].parameters['ZUUL_COMMIT'],
+             'org/project3': str(upstream['org/project3'].commit('master')),
+             'org/project4': str(upstream['org/project4'].commit('master')),
+             'org/project5': str(upstream['org/project5'].commit('master')),
+             'org/project6': str(upstream['org/project6'].commit('master')),
+             },
+            {'org/project1': self.builds[0].parameters['ZUUL_COMMIT'],
+             'org/project2': self.builds[1].parameters['ZUUL_COMMIT'],
+             'org/project3': str(upstream['org/project3'].commit('master')),
+             'org/project4': str(upstream['org/project4'].commit('master')),
+             'org/project5': str(upstream['org/project5'].commit('master')),
+             'org/project6': str(upstream['org/project6'].commit('master')),
+             },
+            {'org/project1': self.builds[0].parameters['ZUUL_COMMIT'],
+             'org/project2': self.builds[1].parameters['ZUUL_COMMIT'],
+             'org/project3': self.builds[3].parameters['ZUUL_COMMIT'],
+             'org/project4': str(upstream['org/project4'].commit('master')),
+             'org/project5': str(upstream['org/project5'].commit('master')),
+             'org/project6': str(upstream['org/project6'].commit('master')),
+             },
+            {'org/project1': self.builds[0].parameters['ZUUL_COMMIT'],
+             'org/project2': self.builds[1].parameters['ZUUL_COMMIT'],
+             'org/project3': self.builds[3].parameters['ZUUL_COMMIT'],
+             'org/project4': str(upstream['org/project4'].commit('master')),
+             'org/project5': str(upstream['org/project5'].commit('master')),
+             'org/project6': str(upstream['org/project6'].commit('master')),
+             },
+        ]
+
+        for number, build in enumerate(self.builds):
+            self.log.debug("Build parameters: %s", build.parameters)
+            cloner = zuul.lib.cloner.Cloner(
+                git_base_url=self.upstream_root,
+                projects=projects,
+                workspace=self.workspace_root,
+                zuul_branch=build.parameters['ZUUL_BRANCH'],
+                zuul_ref=build.parameters['ZUUL_REF'],
+                zuul_url=self.git_root,
+                branch='master',  # New branch for upgrade
+            )
+            cloner.execute()
+            work = self.getWorkspaceRepos(projects)
+            state = states[number]
+
+            for project in projects:
+                self.assertEquals(state[project],
+                                  str(work[project].commit('HEAD')),
+                                  'Project %s commit for build %s should '
+                                  'be correct on old side of upgrade' %
+                                  (project, number))
+            shutil.rmtree(self.workspace_root)
+
+        self.worker.hold_jobs_in_build = False
+        self.worker.release()
+        self.waitUntilSettled()
+
+    def test_project_override(self):
+        self.worker.hold_jobs_in_build = True
+        projects = ['org/project1', 'org/project2', 'org/project3',
+                    'org/project4', 'org/project5', 'org/project6']
+
+        self.create_branch('org/project3', 'stable/havana')
+        self.create_branch('org/project4', 'stable/havana')
+        self.create_branch('org/project6', 'stable/havana')
+        A = self.fake_gerrit.addFakeChange('org/project1', 'master', 'A')
+        B = self.fake_gerrit.addFakeChange('org/project1', 'master', 'B')
+        C = self.fake_gerrit.addFakeChange('org/project2', 'master', 'C')
+        D = self.fake_gerrit.addFakeChange('org/project3', 'stable/havana',
+                                           'D')
+        A.addApproval('CRVW', 2)
+        B.addApproval('CRVW', 2)
+        C.addApproval('CRVW', 2)
+        D.addApproval('CRVW', 2)
+        self.fake_gerrit.addEvent(A.addApproval('APRV', 1))
+        self.fake_gerrit.addEvent(B.addApproval('APRV', 1))
+        self.fake_gerrit.addEvent(C.addApproval('APRV', 1))
+        self.fake_gerrit.addEvent(D.addApproval('APRV', 1))
+
+        self.waitUntilSettled()
+
+        self.assertEquals(4, len(self.builds), "Four builds are running")
+
+        upstream = self.getUpstreamRepos(projects)
+        states = [
+            {'org/project1': self.builds[0].parameters['ZUUL_COMMIT'],
+             'org/project2': str(upstream['org/project2'].commit('master')),
+             'org/project3': str(upstream['org/project3'].commit('master')),
+             'org/project4': str(upstream['org/project4'].commit('master')),
+             'org/project5': str(upstream['org/project5'].commit('master')),
+             'org/project6': str(upstream['org/project6'].commit('master')),
+             },
+            {'org/project1': self.builds[1].parameters['ZUUL_COMMIT'],
+             'org/project2': str(upstream['org/project2'].commit('master')),
+             'org/project3': str(upstream['org/project3'].commit('master')),
+             'org/project4': str(upstream['org/project4'].commit('master')),
+             'org/project5': str(upstream['org/project5'].commit('master')),
+             'org/project6': str(upstream['org/project6'].commit('master')),
+             },
+            {'org/project1': self.builds[1].parameters['ZUUL_COMMIT'],
+             'org/project2': self.builds[2].parameters['ZUUL_COMMIT'],
+             'org/project3': str(upstream['org/project3'].commit('master')),
+             'org/project4': str(upstream['org/project4'].commit('master')),
+             'org/project5': str(upstream['org/project5'].commit('master')),
+             'org/project6': str(upstream['org/project6'].commit('master')),
+             },
+            {'org/project1': self.builds[1].parameters['ZUUL_COMMIT'],
+             'org/project2': self.builds[2].parameters['ZUUL_COMMIT'],
+             'org/project3': self.builds[3].parameters['ZUUL_COMMIT'],
+             'org/project4': str(upstream['org/project4'].commit('master')),
+             'org/project5': str(upstream['org/project5'].commit('master')),
+             'org/project6': str(upstream['org/project6'].commit(
+                                 'stable/havana')),
+             },
+        ]
+
+        for number, build in enumerate(self.builds):
+            self.log.debug("Build parameters: %s", build.parameters)
+            cloner = zuul.lib.cloner.Cloner(
+                git_base_url=self.upstream_root,
+                projects=projects,
+                workspace=self.workspace_root,
+                zuul_branch=build.parameters['ZUUL_BRANCH'],
+                zuul_ref=build.parameters['ZUUL_REF'],
+                zuul_url=self.git_root,
+                project_branches={'org/project4': 'master'},
+            )
+            cloner.execute()
+            work = self.getWorkspaceRepos(projects)
+            state = states[number]
+
+            for project in projects:
+                self.assertEquals(state[project],
+                                  str(work[project].commit('HEAD')),
+                                  'Project %s commit for build %s should '
+                                  'be correct' % (project, number))
+            shutil.rmtree(self.workspace_root)
+
+        self.worker.hold_jobs_in_build = False
+        self.worker.release()
+        self.waitUntilSettled()
+
+    def test_periodic(self):
+        self.worker.hold_jobs_in_build = True
+        self.create_branch('org/project', 'stable/havana')
+        self.updateConfigLayout(
+            'tests/fixtures/layout-timer.yaml')
+        self.sched.reconfigure(self.config)
+        self.registerJobs()
+
+        # The pipeline triggers every second, so we should have seen
+        # several by now.
+        time.sleep(5)
+        self.waitUntilSettled()
+
+        builds = self.builds[:]
+
+        self.worker.hold_jobs_in_build = False
+        # Stop queuing timer triggered jobs so that the assertions
+        # below don't race against more jobs being queued.
+        self.updateConfigLayout(
+            'tests/fixtures/layout-no-timer.yaml')
+        self.sched.reconfigure(self.config)
+        self.registerJobs()
+        self.worker.release()
+        self.waitUntilSettled()
+
+        projects = ['org/project']
+
+        self.assertEquals(2, len(builds), "Two builds are running")
+
+        upstream = self.getUpstreamRepos(projects)
+        states = [
+            {'org/project':
+                str(upstream['org/project'].commit('stable/havana')),
+             },
+            {'org/project':
+                str(upstream['org/project'].commit('stable/havana')),
+             },
+        ]
+
+        for number, build in enumerate(builds):
+            self.log.debug("Build parameters: %s", build.parameters)
+            cloner = zuul.lib.cloner.Cloner(
+                git_base_url=self.upstream_root,
+                projects=projects,
+                workspace=self.workspace_root,
+                zuul_branch=build.parameters.get('ZUUL_BRANCH', None),
+                zuul_ref=build.parameters.get('ZUUL_REF', None),
+                zuul_url=self.git_root,
+                branch='stable/havana',
+            )
+            cloner.execute()
+            work = self.getWorkspaceRepos(projects)
+            state = states[number]
+
+            for project in projects:
+                self.assertEquals(state[project],
+                                  str(work[project].commit('HEAD')),
+                                  'Project %s commit for build %s should '
+                                  'be correct' % (project, number))
+
+            shutil.rmtree(self.workspace_root)
+
+        self.worker.hold_jobs_in_build = False
+        self.worker.release()
+        self.waitUntilSettled()
+
+    def test_post_checkout(self):
+        project = "org/project"
+        path = os.path.join(self.upstream_root, project)
+        repo = git.Repo(path)
+        repo.head.reference = repo.heads['master']
+        commits = []
+        for i in range(0, 3):
+            commits.append(self.create_commit(project))
+        newRev = commits[1]
+
+        cloner = zuul.lib.cloner.Cloner(
+            git_base_url=self.upstream_root,
+            projects=[project],
+            workspace=self.workspace_root,
+            zuul_branch=None,
+            zuul_ref='master',
+            zuul_url=self.git_root,
+            zuul_project=project,
+            zuul_newrev=newRev,
+        )
+        cloner.execute()
+        repos = self.getWorkspaceRepos([project])
+        cloned_sha = repos[project].rev_parse('HEAD').hexsha
+        self.assertEqual(newRev, cloned_sha)
+
+    def test_post_and_master_checkout(self):
+        project = "org/project1"
+        master_project = "org/project2"
+        path = os.path.join(self.upstream_root, project)
+        repo = git.Repo(path)
+        repo.head.reference = repo.heads['master']
+        commits = []
+        for i in range(0, 3):
+            commits.append(self.create_commit(project))
+        newRev = commits[1]
+
+        cloner = zuul.lib.cloner.Cloner(
+            git_base_url=self.upstream_root,
+            projects=[project, master_project],
+            workspace=self.workspace_root,
+            zuul_branch=None,
+            zuul_ref='master',
+            zuul_url=self.git_root,
+            zuul_project=project,
+            zuul_newrev=newRev
+        )
+        cloner.execute()
+        repos = self.getWorkspaceRepos([project, master_project])
+        cloned_sha = repos[project].rev_parse('HEAD').hexsha
+        self.assertEqual(newRev, cloned_sha)
+        self.assertEqual(
+            repos[master_project].rev_parse('HEAD').hexsha,
+            repos[master_project].rev_parse('master').hexsha)
diff --git a/tests/unit/test_cloner_cmd.py b/tests/unit/test_cloner_cmd.py
new file mode 100644
index 0000000..9cbb5b8
--- /dev/null
+++ b/tests/unit/test_cloner_cmd.py
@@ -0,0 +1,56 @@
+#!/usr/bin/env python
+
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+
+import logging
+import os
+
+import testtools
+import zuul.cmd.cloner
+
+logging.basicConfig(level=logging.DEBUG,
+                    format='%(asctime)s %(name)-32s '
+                    '%(levelname)-8s %(message)s')
+
+
+class TestClonerCmdArguments(testtools.TestCase):
+
+    def setUp(self):
+        super(TestClonerCmdArguments, self).setUp()
+        self.app = zuul.cmd.cloner.Cloner()
+
+    def test_default_cache_dir_empty(self):
+        self.app.parse_arguments(['base', 'repo'])
+        self.assertEqual(None, self.app.args.cache_dir)
+
+    def test_default_cache_dir_environ(self):
+        try:
+            os.environ['ZUUL_CACHE_DIR'] = 'fromenviron'
+            self.app.parse_arguments(['base', 'repo'])
+            self.assertEqual('fromenviron', self.app.args.cache_dir)
+        finally:
+            del os.environ['ZUUL_CACHE_DIR']
+
+    def test_default_cache_dir_override_environ(self):
+        try:
+            os.environ['ZUUL_CACHE_DIR'] = 'fromenviron'
+            self.app.parse_arguments(['--cache-dir', 'argument',
+                                      'base', 'repo'])
+            self.assertEqual('argument', self.app.args.cache_dir)
+        finally:
+            del os.environ['ZUUL_CACHE_DIR']
+
+    def test_default_cache_dir_argument(self):
+        self.app.parse_arguments(['--cache-dir', 'argument',
+                                  'base', 'repo'])
+        self.assertEqual('argument', self.app.args.cache_dir)
diff --git a/tests/unit/test_connection.py b/tests/unit/test_connection.py
new file mode 100644
index 0000000..f8d1bf5
--- /dev/null
+++ b/tests/unit/test_connection.py
@@ -0,0 +1,74 @@
+# Copyright 2014 Rackspace Australia
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+
+from tests.base import ZuulTestCase
+
+
+class TestConnections(ZuulTestCase):
+    config_file = 'zuul-connections-same-gerrit.conf'
+    tenant_config_file = 'config/zuul-connections-same-gerrit/main.yaml'
+
+    def test_multiple_connections(self):
+        "Test multiple connections to the one gerrit"
+
+        A = self.fake_review_gerrit.addFakeChange('org/project', 'master', 'A')
+        self.addEvent('review_gerrit', A.getPatchsetCreatedEvent(1))
+
+        self.waitUntilSettled()
+
+        self.assertEqual(len(A.patchsets[-1]['approvals']), 1)
+        self.assertEqual(A.patchsets[-1]['approvals'][0]['type'], 'verified')
+        self.assertEqual(A.patchsets[-1]['approvals'][0]['value'], '1')
+        self.assertEqual(A.patchsets[-1]['approvals'][0]['by']['username'],
+                         'jenkins')
+
+        B = self.fake_review_gerrit.addFakeChange('org/project', 'master', 'B')
+        self.launch_server.failJob('project-test2', B)
+        self.addEvent('review_gerrit', B.getPatchsetCreatedEvent(1))
+
+        self.waitUntilSettled()
+
+        self.assertEqual(len(B.patchsets[-1]['approvals']), 1)
+        self.assertEqual(B.patchsets[-1]['approvals'][0]['type'], 'verified')
+        self.assertEqual(B.patchsets[-1]['approvals'][0]['value'], '-1')
+        self.assertEqual(B.patchsets[-1]['approvals'][0]['by']['username'],
+                         'civoter')
+
+
+class TestMultipleGerrits(ZuulTestCase):
+    def setUp(self):
+        self.skip("Disabled for early v3 development")
+
+    def setup_config(self,
+                     config_file='zuul-connections-multiple-gerrits.conf'):
+        super(TestMultipleGerrits, self).setup_config(config_file)
+        self.self.updateConfigLayout(
+            'layout-connections-multiple-gerrits.yaml')
+
+    def test_multiple_project_separate_gerrits(self):
+        self.worker.hold_jobs_in_build = True
+
+        A = self.fake_another_gerrit.addFakeChange(
+            'org/project', 'master', 'A')
+        self.fake_another_gerrit.addEvent(A.getPatchsetCreatedEvent(1))
+
+        self.waitUntilSettled()
+
+        self.assertEqual(1, len(self.builds))
+        self.assertEqual('project-another-gerrit', self.builds[0].name)
+        self.assertTrue(self.job_has_changes(self.builds[0], A))
+
+        self.worker.hold_jobs_in_build = False
+        self.worker.release()
+        self.waitUntilSettled()
diff --git a/tests/unit/test_daemon.py b/tests/unit/test_daemon.py
new file mode 100644
index 0000000..689d4f7
--- /dev/null
+++ b/tests/unit/test_daemon.py
@@ -0,0 +1,63 @@
+#!/usr/bin/env python
+
+# Copyright 2014 Hewlett-Packard Development Company, L.P.
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+
+import daemon
+import logging
+import os
+import sys
+
+import extras
+import fixtures
+import testtools
+
+from tests.base import iterate_timeout
+
+# as of python-daemon 1.6 it doesn't bundle pidlockfile anymore
+# instead it depends on lockfile-0.9.1 which uses pidfile.
+pid_file_module = extras.try_imports(['daemon.pidlockfile', 'daemon.pidfile'])
+
+
+def daemon_test(pidfile, flagfile):
+    pid = pid_file_module.TimeoutPIDLockFile(pidfile, 10)
+    with daemon.DaemonContext(pidfile=pid):
+        for x in iterate_timeout(30, "flagfile to be removed"):
+            if not os.path.exists(flagfile):
+                break
+    sys.exit(0)
+
+
+class TestDaemon(testtools.TestCase):
+    log = logging.getLogger("zuul.test.daemon")
+
+    def setUp(self):
+        super(TestDaemon, self).setUp()
+        self.test_root = self.useFixture(fixtures.TempDir(
+            rootdir=os.environ.get("ZUUL_TEST_ROOT"))).path
+
+    def test_daemon(self):
+        pidfile = os.path.join(self.test_root, "daemon.pid")
+        flagfile = os.path.join(self.test_root, "daemon.flag")
+        open(flagfile, 'w').close()
+        if not os.fork():
+            self._cleanups = []
+            daemon_test(pidfile, flagfile)
+        for x in iterate_timeout(30, "daemon to start"):
+            if os.path.exists(pidfile):
+                break
+        os.unlink(flagfile)
+        for x in iterate_timeout(30, "daemon to stop"):
+            if not os.path.exists(pidfile):
+                break
diff --git a/tests/unit/test_gerrit.py b/tests/unit/test_gerrit.py
new file mode 100644
index 0000000..999e55d
--- /dev/null
+++ b/tests/unit/test_gerrit.py
@@ -0,0 +1,81 @@
+#!/usr/bin/env python
+
+# Copyright 2015 BMW Car IT GmbH
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+import os
+
+try:
+    from unittest import mock
+except ImportError:
+    import mock
+
+import tests.base
+from tests.base import BaseTestCase
+from zuul.driver.gerrit.gerritconnection import GerritConnection
+
+FIXTURE_DIR = os.path.join(tests.base.FIXTURE_DIR, 'gerrit')
+
+
+def read_fixture(file):
+    with open('%s/%s' % (FIXTURE_DIR, file), 'r') as fixturefile:
+        lines = fixturefile.readlines()
+        command = lines[0].replace('\n', '')
+        value = ''.join(lines[1:])
+        return command, value
+
+
+def read_fixtures(files):
+    calls = []
+    values = []
+    for fixture_file in files:
+        command, value = read_fixture(fixture_file)
+        calls.append(mock.call(command))
+        values.append([value, ''])
+    return calls, values
+
+
+class TestGerrit(BaseTestCase):
+
+    @mock.patch('zuul.driver.gerrit.gerritconnection.GerritConnection._ssh')
+    def run_query(self, files, expected_patches, _ssh_mock):
+        gerrit_config = {
+            'user': 'gerrit',
+            'server': 'localhost',
+        }
+        gerrit = GerritConnection(None, 'review_gerrit', gerrit_config)
+
+        calls, values = read_fixtures(files)
+        _ssh_mock.side_effect = values
+
+        result = gerrit.simpleQuery('project:openstack-infra/zuul')
+
+        _ssh_mock.assert_has_calls(calls)
+        self.assertEquals(len(calls), _ssh_mock.call_count,
+                          '_ssh should be called %d times' % len(calls))
+        self.assertIsNotNone(result, 'Result is not none')
+        self.assertEquals(len(result), expected_patches,
+                          'There must be %d patches.' % expected_patches)
+
+    def test_simple_query_pagination_new(self):
+        files = ['simple_query_pagination_new_1',
+                 'simple_query_pagination_new_2']
+        expected_patches = 5
+        self.run_query(files, expected_patches)
+
+    def test_simple_query_pagination_old(self):
+        files = ['simple_query_pagination_old_1',
+                 'simple_query_pagination_old_2',
+                 'simple_query_pagination_old_3']
+        expected_patches = 5
+        self.run_query(files, expected_patches)
diff --git a/tests/unit/test_layoutvalidator.py b/tests/unit/test_layoutvalidator.py
new file mode 100644
index 0000000..38c8e29
--- /dev/null
+++ b/tests/unit/test_layoutvalidator.py
@@ -0,0 +1,81 @@
+#!/usr/bin/env python
+
+# Copyright 2013 OpenStack Foundation
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+
+from six.moves import configparser as ConfigParser
+import os
+import re
+
+import testtools
+import voluptuous
+import yaml
+
+import zuul.layoutvalidator
+import zuul.lib.connections
+
+FIXTURE_DIR = os.path.join(os.path.dirname(__file__),
+                           'fixtures')
+LAYOUT_RE = re.compile(r'^(good|bad)_.*\.yaml$')
+
+
+class TestLayoutValidator(testtools.TestCase):
+    def setUp(self):
+        self.skip("Disabled for early v3 development")
+
+    def test_layouts(self):
+        """Test layout file validation"""
+        print()
+        errors = []
+        for fn in os.listdir(os.path.join(FIXTURE_DIR, 'layouts')):
+            m = LAYOUT_RE.match(fn)
+            if not m:
+                continue
+            print(fn)
+
+            # Load any .conf file by the same name but .conf extension.
+            config_file = ("%s.conf" %
+                           os.path.join(FIXTURE_DIR, 'layouts',
+                                        fn.split('.yaml')[0]))
+            if not os.path.isfile(config_file):
+                config_file = os.path.join(FIXTURE_DIR, 'layouts',
+                                           'zuul_default.conf')
+            config = ConfigParser.ConfigParser()
+            config.read(config_file)
+            connections = zuul.lib.connections.configure_connections(config)
+
+            layout = os.path.join(FIXTURE_DIR, 'layouts', fn)
+            data = yaml.load(open(layout))
+            validator = zuul.layoutvalidator.LayoutValidator()
+            if m.group(1) == 'good':
+                try:
+                    validator.validate(data, connections)
+                except voluptuous.Invalid as e:
+                    raise Exception(
+                        'Unexpected YAML syntax error in %s:\n  %s' %
+                        (fn, str(e)))
+            else:
+                try:
+                    validator.validate(data, connections)
+                    raise Exception("Expected a YAML syntax error in %s." %
+                                    fn)
+                except voluptuous.Invalid as e:
+                    error = str(e)
+                    print('  ', error)
+                    if error in errors:
+                        raise Exception("Error has already been tested: %s" %
+                                        error)
+                    else:
+                        errors.append(error)
+                    pass
diff --git a/tests/unit/test_merger_repo.py b/tests/unit/test_merger_repo.py
new file mode 100644
index 0000000..5062c14
--- /dev/null
+++ b/tests/unit/test_merger_repo.py
@@ -0,0 +1,80 @@
+#!/usr/bin/env python
+
+# Copyright 2012 Hewlett-Packard Development Company, L.P.
+# Copyright 2014 Wikimedia Foundation Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+
+import logging
+import os
+
+import git
+
+from zuul.merger.merger import Repo
+from tests.base import ZuulTestCase
+
+logging.basicConfig(level=logging.DEBUG,
+                    format='%(asctime)s %(name)-32s '
+                    '%(levelname)-8s %(message)s')
+
+
+class TestMergerRepo(ZuulTestCase):
+
+    log = logging.getLogger("zuul.test.merger.repo")
+    tenant_config_file = 'config/single-tenant/main.yaml'
+    workspace_root = None
+
+    def setUp(self):
+        super(TestMergerRepo, self).setUp()
+        self.workspace_root = os.path.join(self.test_root, 'workspace')
+
+    def test_ensure_cloned(self):
+        parent_path = os.path.join(self.upstream_root, 'org/project1')
+
+        # Forge a repo having a submodule
+        parent_repo = git.Repo(parent_path)
+        parent_repo.git.submodule('add', os.path.join(
+            self.upstream_root, 'org/project2'), 'subdir')
+        parent_repo.index.commit('Adding project2 as a submodule in subdir')
+        # git 1.7.8 changed .git from being a directory to a file pointing
+        # to the parent repository /.git/modules/*
+        self.assertTrue(os.path.exists(
+            os.path.join(parent_path, 'subdir', '.git')),
+            msg='.git file in submodule should be a file')
+
+        work_repo = Repo(parent_path, self.workspace_root,
+                         'none@example.org', 'User Name')
+        self.assertTrue(
+            os.path.isdir(os.path.join(self.workspace_root, 'subdir')),
+            msg='Cloned repository has a submodule placeholder directory')
+        self.assertFalse(os.path.exists(
+            os.path.join(self.workspace_root, 'subdir', '.git')),
+            msg='Submodule is not initialized')
+
+        sub_repo = Repo(
+            os.path.join(self.upstream_root, 'org/project2'),
+            os.path.join(self.workspace_root, 'subdir'),
+            'none@example.org', 'User Name')
+        self.assertTrue(os.path.exists(
+            os.path.join(self.workspace_root, 'subdir', '.git')),
+            msg='Cloned over the submodule placeholder')
+
+        self.assertEquals(
+            os.path.join(self.upstream_root, 'org/project1'),
+            work_repo.createRepoObject().remotes[0].url,
+            message="Parent clone still point to upstream project1")
+
+        self.assertEquals(
+            os.path.join(self.upstream_root, 'org/project2'),
+            sub_repo.createRepoObject().remotes[0].url,
+            message="Sub repository points to upstream project2")
diff --git a/tests/unit/test_model.py b/tests/unit/test_model.py
new file mode 100644
index 0000000..0189340
--- /dev/null
+++ b/tests/unit/test_model.py
@@ -0,0 +1,404 @@
+# Copyright 2015 Red Hat, Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+
+
+import os
+import random
+
+import fixtures
+import testtools
+
+from zuul import model
+from zuul import configloader
+
+from tests.base import BaseTestCase
+
+
+class TestJob(BaseTestCase):
+
+    @property
+    def job(self):
+        layout = model.Layout()
+        job = configloader.JobParser.fromYaml(layout, {
+            'name': 'job',
+            'irrelevant-files': [
+                '^docs/.*$'
+            ]})
+        return job
+
+    def test_change_matches_returns_false_for_matched_skip_if(self):
+        change = model.Change('project')
+        change.files = ['/COMMIT_MSG', 'docs/foo']
+        self.assertFalse(self.job.changeMatches(change))
+
+    def test_change_matches_returns_true_for_unmatched_skip_if(self):
+        change = model.Change('project')
+        change.files = ['/COMMIT_MSG', 'foo']
+        self.assertTrue(self.job.changeMatches(change))
+
+    def test_job_sets_defaults_for_boolean_attributes(self):
+        self.assertIsNotNone(self.job.voting)
+
+    def test_job_inheritance(self):
+        layout = model.Layout()
+
+        pipeline = model.Pipeline('gate', layout)
+        layout.addPipeline(pipeline)
+        queue = model.ChangeQueue(pipeline)
+        project = model.Project('project')
+
+        base = configloader.JobParser.fromYaml(layout, {
+            '_source_project': project,
+            'name': 'base',
+            'timeout': 30,
+        })
+        layout.addJob(base)
+        python27 = configloader.JobParser.fromYaml(layout, {
+            '_source_project': project,
+            'name': 'python27',
+            'parent': 'base',
+            'timeout': 40,
+        })
+        layout.addJob(python27)
+        python27diablo = configloader.JobParser.fromYaml(layout, {
+            '_source_project': project,
+            'name': 'python27',
+            'branches': [
+                'stable/diablo'
+            ],
+            'timeout': 50,
+        })
+        layout.addJob(python27diablo)
+
+        project_config = configloader.ProjectParser.fromYaml(layout, {
+            'name': 'project',
+            'gate': {
+                'jobs': [
+                    'python27'
+                ]
+            }
+        })
+        layout.addProjectConfig(project_config, update_pipeline=False)
+
+        change = model.Change(project)
+        change.branch = 'master'
+        item = queue.enqueueChange(change)
+        item.current_build_set.layout = layout
+
+        self.assertTrue(base.changeMatches(change))
+        self.assertTrue(python27.changeMatches(change))
+        self.assertFalse(python27diablo.changeMatches(change))
+
+        item.freezeJobTree()
+        self.assertEqual(len(item.getJobs()), 1)
+        job = item.getJobs()[0]
+        self.assertEqual(job.name, 'python27')
+        self.assertEqual(job.timeout, 40)
+
+        change.branch = 'stable/diablo'
+        item = queue.enqueueChange(change)
+        item.current_build_set.layout = layout
+
+        self.assertTrue(base.changeMatches(change))
+        self.assertTrue(python27.changeMatches(change))
+        self.assertTrue(python27diablo.changeMatches(change))
+
+        item.freezeJobTree()
+        self.assertEqual(len(item.getJobs()), 1)
+        job = item.getJobs()[0]
+        self.assertEqual(job.name, 'python27')
+        self.assertEqual(job.timeout, 50)
+
+    def test_job_auth_inheritance(self):
+        layout = model.Layout()
+        project = model.Project('project')
+
+        base = configloader.JobParser.fromYaml(layout, {
+            '_source_project': project,
+            'name': 'base',
+            'timeout': 30,
+        })
+        layout.addJob(base)
+        pypi_upload_without_inherit = configloader.JobParser.fromYaml(layout, {
+            '_source_project': project,
+            'name': 'pypi-upload-without-inherit',
+            'parent': 'base',
+            'timeout': 40,
+            'auth': {
+                'secrets': [
+                    'pypi-credentials',
+                ]
+            }
+        })
+        layout.addJob(pypi_upload_without_inherit)
+        pypi_upload_with_inherit = configloader.JobParser.fromYaml(layout, {
+            '_source_project': project,
+            'name': 'pypi-upload-with-inherit',
+            'parent': 'base',
+            'timeout': 40,
+            'auth': {
+                'inherit': True,
+                'secrets': [
+                    'pypi-credentials',
+                ]
+            }
+        })
+        layout.addJob(pypi_upload_with_inherit)
+        pypi_upload_with_inherit_false = configloader.JobParser.fromYaml(
+            layout, {
+                '_source_project': project,
+                'name': 'pypi-upload-with-inherit-false',
+                'parent': 'base',
+                'timeout': 40,
+                'auth': {
+                    'inherit': False,
+                    'secrets': [
+                        'pypi-credentials',
+                    ]
+                }
+            })
+        layout.addJob(pypi_upload_with_inherit_false)
+        in_repo_job_without_inherit = configloader.JobParser.fromYaml(layout, {
+            '_source_project': project,
+            'name': 'in-repo-job-without-inherit',
+            'parent': 'pypi-upload-without-inherit',
+        })
+        layout.addJob(in_repo_job_without_inherit)
+        in_repo_job_with_inherit = configloader.JobParser.fromYaml(layout, {
+            '_source_project': project,
+            'name': 'in-repo-job-with-inherit',
+            'parent': 'pypi-upload-with-inherit',
+        })
+        layout.addJob(in_repo_job_with_inherit)
+        in_repo_job_with_inherit_false = configloader.JobParser.fromYaml(
+            layout, {
+                '_source_project': project,
+                'name': 'in-repo-job-with-inherit-false',
+                'parent': 'pypi-upload-with-inherit-false',
+            })
+        layout.addJob(in_repo_job_with_inherit_false)
+
+        self.assertNotIn('auth', in_repo_job_without_inherit.auth)
+        self.assertIn('secrets', in_repo_job_with_inherit.auth)
+        self.assertEquals(in_repo_job_with_inherit.auth['secrets'],
+                          ['pypi-credentials'])
+        self.assertNotIn('auth', in_repo_job_with_inherit_false.auth)
+
+    def test_job_inheritance_job_tree(self):
+        layout = model.Layout()
+
+        pipeline = model.Pipeline('gate', layout)
+        layout.addPipeline(pipeline)
+        queue = model.ChangeQueue(pipeline)
+        project = model.Project('project')
+
+        base = configloader.JobParser.fromYaml(layout, {
+            '_source_project': project,
+            'name': 'base',
+            'timeout': 30,
+        })
+        layout.addJob(base)
+        python27 = configloader.JobParser.fromYaml(layout, {
+            '_source_project': project,
+            'name': 'python27',
+            'parent': 'base',
+            'timeout': 40,
+        })
+        layout.addJob(python27)
+        python27diablo = configloader.JobParser.fromYaml(layout, {
+            '_source_project': project,
+            'name': 'python27',
+            'branches': [
+                'stable/diablo'
+            ],
+            'timeout': 50,
+        })
+        layout.addJob(python27diablo)
+
+        project_config = configloader.ProjectParser.fromYaml(layout, {
+            'name': 'project',
+            'gate': {
+                'jobs': [
+                    {'python27': {'timeout': 70}}
+                ]
+            }
+        })
+        layout.addProjectConfig(project_config, update_pipeline=False)
+
+        change = model.Change(project)
+        change.branch = 'master'
+        item = queue.enqueueChange(change)
+        item.current_build_set.layout = layout
+
+        self.assertTrue(base.changeMatches(change))
+        self.assertTrue(python27.changeMatches(change))
+        self.assertFalse(python27diablo.changeMatches(change))
+
+        item.freezeJobTree()
+        self.assertEqual(len(item.getJobs()), 1)
+        job = item.getJobs()[0]
+        self.assertEqual(job.name, 'python27')
+        self.assertEqual(job.timeout, 70)
+
+        change.branch = 'stable/diablo'
+        item = queue.enqueueChange(change)
+        item.current_build_set.layout = layout
+
+        self.assertTrue(base.changeMatches(change))
+        self.assertTrue(python27.changeMatches(change))
+        self.assertTrue(python27diablo.changeMatches(change))
+
+        item.freezeJobTree()
+        self.assertEqual(len(item.getJobs()), 1)
+        job = item.getJobs()[0]
+        self.assertEqual(job.name, 'python27')
+        self.assertEqual(job.timeout, 70)
+
+    def test_inheritance_keeps_matchers(self):
+        layout = model.Layout()
+
+        pipeline = model.Pipeline('gate', layout)
+        layout.addPipeline(pipeline)
+        queue = model.ChangeQueue(pipeline)
+        project = model.Project('project')
+
+        base = configloader.JobParser.fromYaml(layout, {
+            '_source_project': project,
+            'name': 'base',
+            'timeout': 30,
+        })
+        layout.addJob(base)
+        python27 = configloader.JobParser.fromYaml(layout, {
+            '_source_project': project,
+            'name': 'python27',
+            'parent': 'base',
+            'timeout': 40,
+            'irrelevant-files': ['^ignored-file$'],
+        })
+        layout.addJob(python27)
+
+        project_config = configloader.ProjectParser.fromYaml(layout, {
+            'name': 'project',
+            'gate': {
+                'jobs': [
+                    'python27',
+                ]
+            }
+        })
+        layout.addProjectConfig(project_config, update_pipeline=False)
+
+        change = model.Change(project)
+        change.branch = 'master'
+        change.files = ['/COMMIT_MSG', 'ignored-file']
+        item = queue.enqueueChange(change)
+        item.current_build_set.layout = layout
+
+        self.assertTrue(base.changeMatches(change))
+        self.assertFalse(python27.changeMatches(change))
+
+        item.freezeJobTree()
+        self.assertEqual([], item.getJobs())
+
+    def test_job_source_project(self):
+        layout = model.Layout()
+        base_project = model.Project('base_project')
+        base = configloader.JobParser.fromYaml(layout, {
+            '_source_project': base_project,
+            'name': 'base',
+        })
+        layout.addJob(base)
+
+        other_project = model.Project('other_project')
+        base2 = configloader.JobParser.fromYaml(layout, {
+            '_source_project': other_project,
+            'name': 'base',
+        })
+        with testtools.ExpectedException(
+                Exception,
+                "Job base in other_project is not permitted "
+                "to shadow job base in base_project"):
+            layout.addJob(base2)
+
+
+class TestJobTimeData(BaseTestCase):
+    def setUp(self):
+        super(TestJobTimeData, self).setUp()
+        self.tmp_root = self.useFixture(fixtures.TempDir(
+            rootdir=os.environ.get("ZUUL_TEST_ROOT"))
+        ).path
+
+    def test_empty_timedata(self):
+        path = os.path.join(self.tmp_root, 'job-name')
+        self.assertFalse(os.path.exists(path))
+        self.assertFalse(os.path.exists(path + '.tmp'))
+        td = model.JobTimeData(path)
+        self.assertEqual(td.success_times, [0, 0, 0, 0, 0, 0, 0, 0, 0, 0])
+        self.assertEqual(td.failure_times, [0, 0, 0, 0, 0, 0, 0, 0, 0, 0])
+        self.assertEqual(td.results, [0, 0, 0, 0, 0, 0, 0, 0, 0, 0])
+
+    def test_save_reload(self):
+        path = os.path.join(self.tmp_root, 'job-name')
+        self.assertFalse(os.path.exists(path))
+        self.assertFalse(os.path.exists(path + '.tmp'))
+        td = model.JobTimeData(path)
+        self.assertEqual(td.success_times, [0, 0, 0, 0, 0, 0, 0, 0, 0, 0])
+        self.assertEqual(td.failure_times, [0, 0, 0, 0, 0, 0, 0, 0, 0, 0])
+        self.assertEqual(td.results, [0, 0, 0, 0, 0, 0, 0, 0, 0, 0])
+        success_times = []
+        failure_times = []
+        results = []
+        for x in range(10):
+            success_times.append(int(random.random() * 1000))
+            failure_times.append(int(random.random() * 1000))
+            results.append(0)
+            results.append(1)
+        random.shuffle(results)
+        s = f = 0
+        for result in results:
+            if result:
+                td.add(failure_times[f], 'FAILURE')
+                f += 1
+            else:
+                td.add(success_times[s], 'SUCCESS')
+                s += 1
+        self.assertEqual(td.success_times, success_times)
+        self.assertEqual(td.failure_times, failure_times)
+        self.assertEqual(td.results, results[10:])
+        td.save()
+        self.assertTrue(os.path.exists(path))
+        self.assertFalse(os.path.exists(path + '.tmp'))
+        td = model.JobTimeData(path)
+        td.load()
+        self.assertEqual(td.success_times, success_times)
+        self.assertEqual(td.failure_times, failure_times)
+        self.assertEqual(td.results, results[10:])
+
+
+class TestTimeDataBase(BaseTestCase):
+    def setUp(self):
+        super(TestTimeDataBase, self).setUp()
+        self.tmp_root = self.useFixture(fixtures.TempDir(
+            rootdir=os.environ.get("ZUUL_TEST_ROOT"))
+        ).path
+        self.db = model.TimeDataBase(self.tmp_root)
+
+    def test_timedatabase(self):
+        self.assertEqual(self.db.getEstimatedTime('job-name'), 0)
+        self.db.update('job-name', 50, 'SUCCESS')
+        self.assertEqual(self.db.getEstimatedTime('job-name'), 50)
+        self.db.update('job-name', 100, 'SUCCESS')
+        self.assertEqual(self.db.getEstimatedTime('job-name'), 75)
+        for x in range(10):
+            self.db.update('job-name', 100, 'SUCCESS')
+        self.assertEqual(self.db.getEstimatedTime('job-name'), 100)
diff --git a/tests/unit/test_nodepool.py b/tests/unit/test_nodepool.py
new file mode 100644
index 0000000..6462f9a
--- /dev/null
+++ b/tests/unit/test_nodepool.py
@@ -0,0 +1,122 @@
+# Copyright 2017 Red Hat, Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+
+
+import time
+
+import zuul.zk
+import zuul.nodepool
+from zuul import model
+
+from tests.base import BaseTestCase, ChrootedKazooFixture, FakeNodepool
+
+
+class TestNodepool(BaseTestCase):
+    # Tests the Nodepool interface class using a fake nodepool and
+    # scheduler.
+
+    def setUp(self):
+        super(BaseTestCase, self).setUp()
+
+        self.zk_chroot_fixture = self.useFixture(ChrootedKazooFixture())
+        self.zk_config = zuul.zk.ZooKeeperConnectionConfig(
+            self.zk_chroot_fixture.zookeeper_host,
+            self.zk_chroot_fixture.zookeeper_port,
+            self.zk_chroot_fixture.zookeeper_chroot)
+
+        self.zk = zuul.zk.ZooKeeper()
+        self.zk.connect([self.zk_config])
+
+        self.provisioned_requests = []
+        # This class implements the scheduler methods zuul.nodepool
+        # needs, so we pass 'self' as the scheduler.
+        self.nodepool = zuul.nodepool.Nodepool(self)
+
+        self.fake_nodepool = FakeNodepool(self.zk_config.host,
+                                          self.zk_config.port,
+                                          self.zk_config.chroot)
+
+    def waitForRequests(self):
+        # Wait until all requests are complete.
+        while self.nodepool.requests:
+            time.sleep(0.1)
+
+    def onNodesProvisioned(self, request):
+        # This is a scheduler method that the nodepool class calls
+        # back when a request is provisioned.
+        self.provisioned_requests.append(request)
+
+    def test_node_request(self):
+        # Test a simple node request
+
+        nodeset = model.NodeSet()
+        nodeset.addNode(model.Node('controller', 'ubuntu-xenial'))
+        nodeset.addNode(model.Node('compute', 'ubuntu-xenial'))
+        job = model.Job('testjob')
+        job.nodeset = nodeset
+        request = self.nodepool.requestNodes(None, job)
+        self.waitForRequests()
+        self.assertEqual(len(self.provisioned_requests), 1)
+        self.assertEqual(request.state, 'fulfilled')
+
+        # Accept the nodes
+        self.nodepool.acceptNodes(request)
+        nodeset = request.nodeset
+
+        for node in nodeset.getNodes():
+            self.assertIsNotNone(node.lock)
+            self.assertEqual(node.state, 'ready')
+
+        # Mark the nodes in use
+        self.nodepool.useNodeSet(nodeset)
+        for node in nodeset.getNodes():
+            self.assertEqual(node.state, 'in-use')
+
+        # Return the nodes
+        self.nodepool.returnNodeSet(nodeset)
+        for node in nodeset.getNodes():
+            self.assertIsNone(node.lock)
+            self.assertEqual(node.state, 'used')
+
+    def test_node_request_disconnect(self):
+        # Test that node requests are re-submitted after disconnect
+
+        nodeset = model.NodeSet()
+        nodeset.addNode(model.Node('controller', 'ubuntu-xenial'))
+        nodeset.addNode(model.Node('compute', 'ubuntu-xenial'))
+        job = model.Job('testjob')
+        job.nodeset = nodeset
+        self.fake_nodepool.paused = True
+        request = self.nodepool.requestNodes(None, job)
+        self.zk.client.stop()
+        self.zk.client.start()
+        self.fake_nodepool.paused = False
+        self.waitForRequests()
+        self.assertEqual(len(self.provisioned_requests), 1)
+        self.assertEqual(request.state, 'fulfilled')
+
+    def test_node_request_canceled(self):
+        # Test that node requests can be canceled
+
+        nodeset = model.NodeSet()
+        nodeset.addNode(model.Node('controller', 'ubuntu-xenial'))
+        nodeset.addNode(model.Node('compute', 'ubuntu-xenial'))
+        job = model.Job('testjob')
+        job.nodeset = nodeset
+        self.fake_nodepool.paused = True
+        request = self.nodepool.requestNodes(None, job)
+        self.nodepool.cancelRequest(request)
+
+        self.waitForRequests()
+        self.assertEqual(len(self.provisioned_requests), 0)
diff --git a/tests/unit/test_openstack.py b/tests/unit/test_openstack.py
new file mode 100644
index 0000000..175b4bd
--- /dev/null
+++ b/tests/unit/test_openstack.py
@@ -0,0 +1,62 @@
+#!/usr/bin/env python
+
+# Copyright 2012 Hewlett-Packard Development Company, L.P.
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+
+import logging
+
+from tests.base import AnsibleZuulTestCase
+
+logging.basicConfig(level=logging.DEBUG,
+                    format='%(asctime)s %(name)-32s '
+                    '%(levelname)-8s %(message)s')
+
+
+class TestOpenStack(AnsibleZuulTestCase):
+    # A temporary class to experiment with how openstack can use
+    # Zuulv3
+
+    tenant_config_file = 'config/openstack/main.yaml'
+
+    def test_nova_master(self):
+        A = self.fake_gerrit.addFakeChange('openstack/nova', 'master', 'A')
+        A.addApproval('code-review', 2)
+        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
+        self.waitUntilSettled()
+        self.assertEqual(self.getJobFromHistory('python27').result,
+                         'SUCCESS')
+        self.assertEqual(self.getJobFromHistory('python35').result,
+                         'SUCCESS')
+        self.assertEqual(A.data['status'], 'MERGED')
+        self.assertEqual(A.reported, 2,
+                         "A should report start and success")
+        self.assertEqual(self.getJobFromHistory('python27').node,
+                         'ubuntu-xenial')
+
+    def test_nova_mitaka(self):
+        self.create_branch('openstack/nova', 'stable/mitaka')
+        A = self.fake_gerrit.addFakeChange('openstack/nova',
+                                           'stable/mitaka', 'A')
+        A.addApproval('code-review', 2)
+        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
+        self.waitUntilSettled()
+        self.assertEqual(self.getJobFromHistory('python27').result,
+                         'SUCCESS')
+        self.assertEqual(self.getJobFromHistory('python35').result,
+                         'SUCCESS')
+        self.assertEqual(A.data['status'], 'MERGED')
+        self.assertEqual(A.reported, 2,
+                         "A should report start and success")
+        self.assertEqual(self.getJobFromHistory('python27').node,
+                         'ubuntu-trusty')
diff --git a/tests/unit/test_requirements.py b/tests/unit/test_requirements.py
new file mode 100644
index 0000000..1ea0b2e
--- /dev/null
+++ b/tests/unit/test_requirements.py
@@ -0,0 +1,420 @@
+#!/usr/bin/env python
+
+# Copyright 2012-2014 Hewlett-Packard Development Company, L.P.
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+
+import logging
+import time
+
+from tests.base import ZuulTestCase
+
+logging.basicConfig(level=logging.DEBUG,
+                    format='%(asctime)s %(name)-32s '
+                    '%(levelname)-8s %(message)s')
+
+
+class TestRequirementsApprovalNewerThan(ZuulTestCase):
+    """Requirements with a newer-than comment requirement"""
+
+    tenant_config_file = 'config/requirements/newer-than/main.yaml'
+
+    def test_pipeline_require_approval_newer_than(self):
+        "Test pipeline requirement: approval newer than"
+        return self._test_require_approval_newer_than('org/project1',
+                                                      'project1-job')
+
+    def test_trigger_require_approval_newer_than(self):
+        "Test trigger requirement: approval newer than"
+        return self._test_require_approval_newer_than('org/project2',
+                                                      'project2-job')
+
+    def _test_require_approval_newer_than(self, project, job):
+        A = self.fake_gerrit.addFakeChange(project, 'master', 'A')
+        # A comment event that we will keep submitting to trigger
+        comment = A.addApproval('code-review', 2, username='nobody')
+        self.fake_gerrit.addEvent(comment)
+        self.waitUntilSettled()
+        # No +1 from Jenkins so should not be enqueued
+        self.assertEqual(len(self.history), 0)
+
+        # Add a too-old +1, should not be enqueued
+        A.addApproval('verified', 1, username='jenkins',
+                      granted_on=time.time() - 72 * 60 * 60)
+        self.fake_gerrit.addEvent(comment)
+        self.waitUntilSettled()
+        self.assertEqual(len(self.history), 0)
+
+        # Add a recent +1
+        self.fake_gerrit.addEvent(A.addApproval('verified', 1,
+                                                username='jenkins'))
+        self.fake_gerrit.addEvent(comment)
+        self.waitUntilSettled()
+        self.assertEqual(len(self.history), 1)
+        self.assertEqual(self.history[0].name, job)
+
+
+class TestRequirementsApprovalOlderThan(ZuulTestCase):
+    """Requirements with a older-than comment requirement"""
+
+    tenant_config_file = 'config/requirements/older-than/main.yaml'
+
+    def test_pipeline_require_approval_older_than(self):
+        "Test pipeline requirement: approval older than"
+        return self._test_require_approval_older_than('org/project1',
+                                                      'project1-job')
+
+    def test_trigger_require_approval_older_than(self):
+        "Test trigger requirement: approval older than"
+        return self._test_require_approval_older_than('org/project2',
+                                                      'project2-job')
+
+    def _test_require_approval_older_than(self, project, job):
+        A = self.fake_gerrit.addFakeChange(project, 'master', 'A')
+        # A comment event that we will keep submitting to trigger
+        comment = A.addApproval('code-review', 2, username='nobody')
+        self.fake_gerrit.addEvent(comment)
+        self.waitUntilSettled()
+        # No +1 from Jenkins so should not be enqueued
+        self.assertEqual(len(self.history), 0)
+
+        # Add a recent +1 which should not be enqueued
+        A.addApproval('verified', 1)
+        self.fake_gerrit.addEvent(comment)
+        self.waitUntilSettled()
+        self.assertEqual(len(self.history), 0)
+
+        # Add an old +1 which should be enqueued
+        A.addApproval('verified', 1, username='jenkins',
+                      granted_on=time.time() - 72 * 60 * 60)
+        self.fake_gerrit.addEvent(comment)
+        self.waitUntilSettled()
+        self.assertEqual(len(self.history), 1)
+        self.assertEqual(self.history[0].name, job)
+
+
+class TestRequirementsUserName(ZuulTestCase):
+    """Requirements with a username requirement"""
+
+    tenant_config_file = 'config/requirements/username/main.yaml'
+
+    def test_pipeline_require_approval_username(self):
+        "Test pipeline requirement: approval username"
+        return self._test_require_approval_username('org/project1',
+                                                    'project1-job')
+
+    def test_trigger_require_approval_username(self):
+        "Test trigger requirement: approval username"
+        return self._test_require_approval_username('org/project2',
+                                                    'project2-job')
+
+    def _test_require_approval_username(self, project, job):
+        A = self.fake_gerrit.addFakeChange(project, 'master', 'A')
+        # A comment event that we will keep submitting to trigger
+        comment = A.addApproval('code-review', 2, username='nobody')
+        self.fake_gerrit.addEvent(comment)
+        self.waitUntilSettled()
+        # No approval from Jenkins so should not be enqueued
+        self.assertEqual(len(self.history), 0)
+
+        # Add an approval from Jenkins
+        A.addApproval('verified', 1, username='jenkins')
+        self.fake_gerrit.addEvent(comment)
+        self.waitUntilSettled()
+        self.assertEqual(len(self.history), 1)
+        self.assertEqual(self.history[0].name, job)
+
+
+class TestRequirementsEmail(ZuulTestCase):
+    """Requirements with a email requirement"""
+
+    tenant_config_file = 'config/requirements/email/main.yaml'
+
+    def test_pipeline_require_approval_email(self):
+        "Test pipeline requirement: approval email"
+        return self._test_require_approval_email('org/project1',
+                                                 'project1-job')
+
+    def test_trigger_require_approval_email(self):
+        "Test trigger requirement: approval email"
+        return self._test_require_approval_email('org/project2',
+                                                 'project2-job')
+
+    def _test_require_approval_email(self, project, job):
+        A = self.fake_gerrit.addFakeChange(project, 'master', 'A')
+        # A comment event that we will keep submitting to trigger
+        comment = A.addApproval('code-review', 2, username='nobody')
+        self.fake_gerrit.addEvent(comment)
+        self.waitUntilSettled()
+        # No approval from Jenkins so should not be enqueued
+        self.assertEqual(len(self.history), 0)
+
+        # Add an approval from Jenkins
+        A.addApproval('verified', 1, username='jenkins')
+        self.fake_gerrit.addEvent(comment)
+        self.waitUntilSettled()
+        self.assertEqual(len(self.history), 1)
+        self.assertEqual(self.history[0].name, job)
+
+
+class TestRequirementsVote1(ZuulTestCase):
+    """Requirements with a voting requirement"""
+
+    tenant_config_file = 'config/requirements/vote1/main.yaml'
+
+    def test_pipeline_require_approval_vote1(self):
+        "Test pipeline requirement: approval vote with one value"
+        return self._test_require_approval_vote1('org/project1',
+                                                 'project1-job')
+
+    def test_trigger_require_approval_vote1(self):
+        "Test trigger requirement: approval vote with one value"
+        return self._test_require_approval_vote1('org/project2',
+                                                 'project2-job')
+
+    def _test_require_approval_vote1(self, project, job):
+        A = self.fake_gerrit.addFakeChange(project, 'master', 'A')
+        # A comment event that we will keep submitting to trigger
+        comment = A.addApproval('code-review', 2, username='nobody')
+        self.fake_gerrit.addEvent(comment)
+        self.waitUntilSettled()
+        # No approval from Jenkins so should not be enqueued
+        self.assertEqual(len(self.history), 0)
+
+        # A -1 from jenkins should not cause it to be enqueued
+        A.addApproval('verified', -1, username='jenkins')
+        self.fake_gerrit.addEvent(comment)
+        self.waitUntilSettled()
+        self.assertEqual(len(self.history), 0)
+
+        # A +1 should allow it to be enqueued
+        A.addApproval('verified', 1, username='jenkins')
+        self.fake_gerrit.addEvent(comment)
+        self.waitUntilSettled()
+        self.assertEqual(len(self.history), 1)
+        self.assertEqual(self.history[0].name, job)
+
+
+class TestRequirementsVote2(ZuulTestCase):
+    """Requirements with a voting requirement"""
+
+    tenant_config_file = 'config/requirements/vote2/main.yaml'
+
+    def test_pipeline_require_approval_vote2(self):
+        "Test pipeline requirement: approval vote with two values"
+        return self._test_require_approval_vote2('org/project1',
+                                                 'project1-job')
+
+    def test_trigger_require_approval_vote2(self):
+        "Test trigger requirement: approval vote with two values"
+        return self._test_require_approval_vote2('org/project2',
+                                                 'project2-job')
+
+    def _test_require_approval_vote2(self, project, job):
+        A = self.fake_gerrit.addFakeChange(project, 'master', 'A')
+        # A comment event that we will keep submitting to trigger
+        comment = A.addApproval('code-review', 2, username='nobody')
+        self.fake_gerrit.addEvent(comment)
+        self.waitUntilSettled()
+        # No approval from Jenkins so should not be enqueued
+        self.assertEqual(len(self.history), 0)
+
+        # A -1 from jenkins should not cause it to be enqueued
+        A.addApproval('verified', -1, username='jenkins')
+        self.fake_gerrit.addEvent(comment)
+        self.waitUntilSettled()
+        self.assertEqual(len(self.history), 0)
+
+        # A -2 from jenkins should not cause it to be enqueued
+        A.addApproval('verified', -2, username='jenkins')
+        self.fake_gerrit.addEvent(comment)
+        self.waitUntilSettled()
+        self.assertEqual(len(self.history), 0)
+
+        # A +1 from jenkins should allow it to be enqueued
+        A.addApproval('verified', 1, username='jenkins')
+        self.fake_gerrit.addEvent(comment)
+        self.waitUntilSettled()
+        self.assertEqual(len(self.history), 1)
+        self.assertEqual(self.history[0].name, job)
+
+        # A +2 from nobody should not cause it to be enqueued
+        B = self.fake_gerrit.addFakeChange(project, 'master', 'B')
+        # A comment event that we will keep submitting to trigger
+        comment = B.addApproval('code-review', 2, username='nobody')
+        self.fake_gerrit.addEvent(comment)
+        self.waitUntilSettled()
+        self.assertEqual(len(self.history), 1)
+
+        # A +2 from jenkins should allow it to be enqueued
+        B.addApproval('verified', 2, username='jenkins')
+        self.fake_gerrit.addEvent(comment)
+        self.waitUntilSettled()
+        self.assertEqual(len(self.history), 2)
+        self.assertEqual(self.history[1].name, job)
+
+
+class TestRequirementsState(ZuulTestCase):
+    """Requirements with simple state requirement"""
+
+    tenant_config_file = 'config/requirements/state/main.yaml'
+
+    def test_pipeline_require_current_patchset(self):
+        # Create two patchsets and let their tests settle out. Then
+        # comment on first patchset and check that no additional
+        # jobs are run.
+        A = self.fake_gerrit.addFakeChange('current-project', 'master', 'A')
+        self.fake_gerrit.addEvent(A.addApproval('code-review', 1))
+        self.waitUntilSettled()
+        A.addPatchset()
+        self.fake_gerrit.addEvent(A.addApproval('code-review', 1))
+        self.waitUntilSettled()
+
+        self.assertEqual(len(self.history), 2)  # one job for each ps
+        self.fake_gerrit.addEvent(A.getChangeCommentEvent(1))
+        self.waitUntilSettled()
+
+        # Assert no new jobs ran after event for old patchset.
+        self.assertEqual(len(self.history), 2)
+
+        # Make sure the same event on a new PS will trigger
+        self.fake_gerrit.addEvent(A.getChangeCommentEvent(2))
+        self.waitUntilSettled()
+        self.assertEqual(len(self.history), 3)
+
+    def test_pipeline_require_open(self):
+        A = self.fake_gerrit.addFakeChange('open-project', 'master', 'A',
+                                           status='MERGED')
+        self.fake_gerrit.addEvent(A.addApproval('code-review', 2))
+        self.waitUntilSettled()
+        self.assertEqual(len(self.history), 0)
+
+        B = self.fake_gerrit.addFakeChange('open-project', 'master', 'B')
+        self.fake_gerrit.addEvent(B.addApproval('code-review', 2))
+        self.waitUntilSettled()
+        self.assertEqual(len(self.history), 1)
+
+    def test_pipeline_require_status(self):
+        A = self.fake_gerrit.addFakeChange('status-project', 'master', 'A',
+                                           status='MERGED')
+        self.fake_gerrit.addEvent(A.addApproval('code-review', 2))
+        self.waitUntilSettled()
+        self.assertEqual(len(self.history), 0)
+
+        B = self.fake_gerrit.addFakeChange('status-project', 'master', 'B')
+        self.fake_gerrit.addEvent(B.addApproval('code-review', 2))
+        self.waitUntilSettled()
+        self.assertEqual(len(self.history), 1)
+
+
+class TestRequirementsRejectUsername(ZuulTestCase):
+    """Requirements with reject username requirement"""
+
+    tenant_config_file = 'config/requirements/reject-username/main.yaml'
+
+    def _test_require_reject_username(self, project, job):
+        "Test negative username's match"
+        # Should only trigger if Jenkins hasn't voted.
+        # add in a change with no comments
+        A = self.fake_gerrit.addFakeChange(project, 'master', 'A')
+        self.waitUntilSettled()
+        self.assertEqual(len(self.history), 0)
+
+        # add in a comment that will trigger
+        self.fake_gerrit.addEvent(A.addApproval('code-review', 1,
+                                                username='reviewer'))
+        self.waitUntilSettled()
+        self.assertEqual(len(self.history), 1)
+        self.assertEqual(self.history[0].name, job)
+
+        # add in a comment from jenkins user which shouldn't trigger
+        self.fake_gerrit.addEvent(A.addApproval('verified', 1,
+                                                username='jenkins'))
+        self.waitUntilSettled()
+        self.assertEqual(len(self.history), 1)
+
+        # Check future reviews also won't trigger as a 'jenkins' user has
+        # commented previously
+        self.fake_gerrit.addEvent(A.addApproval('code-review', 1,
+                                                username='reviewer'))
+        self.waitUntilSettled()
+        self.assertEqual(len(self.history), 1)
+
+    def test_pipeline_reject_username(self):
+        "Test negative pipeline requirement: no comment from jenkins"
+        return self._test_require_reject_username('org/project1',
+                                                  'project1-job')
+
+    def test_trigger_reject_username(self):
+        "Test negative trigger requirement: no comment from jenkins"
+        return self._test_require_reject_username('org/project2',
+                                                  'project2-job')
+
+
+class TestRequirementsReject(ZuulTestCase):
+    """Requirements with reject requirement"""
+
+    tenant_config_file = 'config/requirements/reject/main.yaml'
+
+    def _test_require_reject(self, project, job):
+        "Test no approval matches a reject param"
+        A = self.fake_gerrit.addFakeChange(project, 'master', 'A')
+        self.waitUntilSettled()
+        self.assertEqual(len(self.history), 0)
+
+        # First positive vote should not queue until jenkins has +1'd
+        comment = A.addApproval('verified', 1, username='reviewer_a')
+        self.fake_gerrit.addEvent(comment)
+        self.waitUntilSettled()
+        self.assertEqual(len(self.history), 0)
+
+        # Jenkins should put in a +1 which will also queue
+        comment = A.addApproval('verified', 1, username='jenkins')
+        self.fake_gerrit.addEvent(comment)
+        self.waitUntilSettled()
+        self.assertEqual(len(self.history), 1)
+        self.assertEqual(self.history[0].name, job)
+
+        # Negative vote should not queue
+        comment = A.addApproval('verified', -1, username='reviewer_b')
+        self.fake_gerrit.addEvent(comment)
+        self.waitUntilSettled()
+        self.assertEqual(len(self.history), 1)
+
+        # Future approvals should do nothing
+        comment = A.addApproval('verified', 1, username='reviewer_c')
+        self.fake_gerrit.addEvent(comment)
+        self.waitUntilSettled()
+        self.assertEqual(len(self.history), 1)
+
+        # Change/update negative vote should queue
+        comment = A.addApproval('verified', 1, username='reviewer_b')
+        self.fake_gerrit.addEvent(comment)
+        self.waitUntilSettled()
+        self.assertEqual(len(self.history), 2)
+        self.assertEqual(self.history[1].name, job)
+
+        # Future approvals should also queue
+        comment = A.addApproval('verified', 1, username='reviewer_d')
+        self.fake_gerrit.addEvent(comment)
+        self.waitUntilSettled()
+        self.assertEqual(len(self.history), 3)
+        self.assertEqual(self.history[2].name, job)
+
+    def test_pipeline_require_reject(self):
+        "Test pipeline requirement: rejections absent"
+        return self._test_require_reject('org/project1', 'project1-job')
+
+    def test_trigger_require_reject(self):
+        "Test trigger requirement: rejections absent"
+        return self._test_require_reject('org/project2', 'project2-job')
diff --git a/tests/unit/test_scheduler.py b/tests/unit/test_scheduler.py
new file mode 100755
index 0000000..821629c
--- /dev/null
+++ b/tests/unit/test_scheduler.py
@@ -0,0 +1,4760 @@
+#!/usr/bin/env python
+
+# Copyright 2012 Hewlett-Packard Development Company, L.P.
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+
+import json
+import logging
+import os
+import re
+import shutil
+import time
+from unittest import skip
+
+import git
+from six.moves import urllib
+import testtools
+
+import zuul.change_matcher
+import zuul.scheduler
+import zuul.rpcclient
+import zuul.model
+
+from tests.base import (
+    ZuulTestCase,
+    repack_repo,
+)
+
+logging.basicConfig(level=logging.DEBUG,
+                    format='%(asctime)s %(name)-32s '
+                    '%(levelname)-8s %(message)s')
+
+
+class TestScheduler(ZuulTestCase):
+    tenant_config_file = 'config/single-tenant/main.yaml'
+
+    def test_jobs_launched(self):
+        "Test that jobs are launched and a change is merged"
+
+        A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
+        A.addApproval('code-review', 2)
+        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
+        self.waitUntilSettled()
+        self.assertEqual(self.getJobFromHistory('project-merge').result,
+                         'SUCCESS')
+        self.assertEqual(self.getJobFromHistory('project-test1').result,
+                         'SUCCESS')
+        self.assertEqual(self.getJobFromHistory('project-test2').result,
+                         'SUCCESS')
+        self.assertEqual(A.data['status'], 'MERGED')
+        self.assertEqual(A.reported, 2)
+        self.assertEqual(self.getJobFromHistory('project-test1').node,
+                         'image1')
+
+        # TODOv3(jeblair): we may want to report stats by tenant (also?).
+        self.assertReportedStat('gerrit.event.comment-added', value='1|c')
+        self.assertReportedStat('zuul.pipeline.gate.current_changes',
+                                value='1|g')
+        self.assertReportedStat('zuul.pipeline.gate.job.project-merge.SUCCESS',
+                                kind='ms')
+        self.assertReportedStat('zuul.pipeline.gate.job.project-merge.SUCCESS',
+                                value='1|c')
+        self.assertReportedStat('zuul.pipeline.gate.resident_time', kind='ms')
+        self.assertReportedStat('zuul.pipeline.gate.total_changes',
+                                value='1|c')
+        self.assertReportedStat(
+            'zuul.pipeline.gate.org.project.resident_time', kind='ms')
+        self.assertReportedStat(
+            'zuul.pipeline.gate.org.project.total_changes', value='1|c')
+
+        for build in self.builds:
+            self.assertEqual(build.parameters['ZUUL_VOTING'], '1')
+
+    def test_initial_pipeline_gauges(self):
+        "Test that each pipeline reported its length on start"
+        self.assertReportedStat('zuul.pipeline.gate.current_changes',
+                                value='0|g')
+        self.assertReportedStat('zuul.pipeline.check.current_changes',
+                                value='0|g')
+
+    def test_job_branch(self):
+        "Test the correct variant of a job runs on a branch"
+        self.create_branch('org/project', 'stable')
+        A = self.fake_gerrit.addFakeChange('org/project', 'stable', 'A')
+        A.addApproval('code-review', 2)
+        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
+        self.waitUntilSettled()
+        self.assertEqual(self.getJobFromHistory('project-test1').result,
+                         'SUCCESS')
+        self.assertEqual(self.getJobFromHistory('project-test2').result,
+                         'SUCCESS')
+        self.assertEqual(A.data['status'], 'MERGED')
+        self.assertEqual(A.reported, 2,
+                         "A should report start and success")
+        self.assertIn('gate', A.messages[1],
+                      "A should transit gate")
+        self.assertEqual(self.getJobFromHistory('project-test1').node,
+                         'image2')
+
+    def test_parallel_changes(self):
+        "Test that changes are tested in parallel and merged in series"
+
+        self.launch_server.hold_jobs_in_build = True
+        A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
+        B = self.fake_gerrit.addFakeChange('org/project', 'master', 'B')
+        C = self.fake_gerrit.addFakeChange('org/project', 'master', 'C')
+        A.addApproval('code-review', 2)
+        B.addApproval('code-review', 2)
+        C.addApproval('code-review', 2)
+
+        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
+        self.fake_gerrit.addEvent(B.addApproval('approved', 1))
+        self.fake_gerrit.addEvent(C.addApproval('approved', 1))
+
+        self.waitUntilSettled()
+        self.assertEqual(len(self.builds), 1)
+        self.assertEqual(self.builds[0].name, 'project-merge')
+        self.assertTrue(self.builds[0].hasChanges(A))
+
+        self.launch_server.release('.*-merge')
+        self.waitUntilSettled()
+        self.assertEqual(len(self.builds), 3)
+        self.assertEqual(self.builds[0].name, 'project-test1')
+        self.assertTrue(self.builds[0].hasChanges(A))
+        self.assertEqual(self.builds[1].name, 'project-test2')
+        self.assertTrue(self.builds[1].hasChanges(A))
+        self.assertEqual(self.builds[2].name, 'project-merge')
+        self.assertTrue(self.builds[2].hasChanges(A, B))
+
+        self.launch_server.release('.*-merge')
+        self.waitUntilSettled()
+        self.assertEqual(len(self.builds), 5)
+        self.assertEqual(self.builds[0].name, 'project-test1')
+        self.assertTrue(self.builds[0].hasChanges(A))
+        self.assertEqual(self.builds[1].name, 'project-test2')
+        self.assertTrue(self.builds[1].hasChanges(A))
+
+        self.assertEqual(self.builds[2].name, 'project-test1')
+        self.assertTrue(self.builds[2].hasChanges(A, B))
+        self.assertEqual(self.builds[3].name, 'project-test2')
+        self.assertTrue(self.builds[3].hasChanges(A, B))
+
+        self.assertEqual(self.builds[4].name, 'project-merge')
+        self.assertTrue(self.builds[4].hasChanges(A, B, C))
+
+        self.launch_server.release('.*-merge')
+        self.waitUntilSettled()
+        self.assertEqual(len(self.builds), 6)
+        self.assertEqual(self.builds[0].name, 'project-test1')
+        self.assertTrue(self.builds[0].hasChanges(A))
+        self.assertEqual(self.builds[1].name, 'project-test2')
+        self.assertTrue(self.builds[1].hasChanges(A))
+
+        self.assertEqual(self.builds[2].name, 'project-test1')
+        self.assertTrue(self.builds[2].hasChanges(A, B))
+        self.assertEqual(self.builds[3].name, 'project-test2')
+        self.assertTrue(self.builds[3].hasChanges(A, B))
+
+        self.assertEqual(self.builds[4].name, 'project-test1')
+        self.assertTrue(self.builds[4].hasChanges(A, B, C))
+        self.assertEqual(self.builds[5].name, 'project-test2')
+        self.assertTrue(self.builds[5].hasChanges(A, B, C))
+
+        self.launch_server.hold_jobs_in_build = False
+        self.launch_server.release()
+        self.waitUntilSettled()
+        self.assertEqual(len(self.builds), 0)
+
+        self.assertEqual(len(self.history), 9)
+        self.assertEqual(A.data['status'], 'MERGED')
+        self.assertEqual(B.data['status'], 'MERGED')
+        self.assertEqual(C.data['status'], 'MERGED')
+        self.assertEqual(A.reported, 2)
+        self.assertEqual(B.reported, 2)
+        self.assertEqual(C.reported, 2)
+
+    def test_failed_changes(self):
+        "Test that a change behind a failed change is retested"
+        self.launch_server.hold_jobs_in_build = True
+
+        A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
+        B = self.fake_gerrit.addFakeChange('org/project', 'master', 'B')
+        A.addApproval('code-review', 2)
+        B.addApproval('code-review', 2)
+
+        self.launch_server.failJob('project-test1', A)
+
+        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
+        self.fake_gerrit.addEvent(B.addApproval('approved', 1))
+        self.waitUntilSettled()
+        self.assertBuilds([dict(name='project-merge', changes='1,1')])
+
+        self.launch_server.release('.*-merge')
+        self.waitUntilSettled()
+        # A/project-merge is complete
+        self.assertBuilds([
+            dict(name='project-test1', changes='1,1'),
+            dict(name='project-test2', changes='1,1'),
+            dict(name='project-merge', changes='1,1 2,1'),
+        ])
+
+        self.launch_server.release('.*-merge')
+        self.waitUntilSettled()
+        # A/project-merge is complete
+        # B/project-merge is complete
+        self.assertBuilds([
+            dict(name='project-test1', changes='1,1'),
+            dict(name='project-test2', changes='1,1'),
+            dict(name='project-test1', changes='1,1 2,1'),
+            dict(name='project-test2', changes='1,1 2,1'),
+        ])
+
+        # Release project-test1 for A which will fail.  This will
+        # abort both running B jobs and relaunch project-merge for B.
+        self.builds[0].release()
+        self.waitUntilSettled()
+
+        self.orderedRelease()
+        self.assertHistory([
+            dict(name='project-merge', result='SUCCESS', changes='1,1'),
+            dict(name='project-merge', result='SUCCESS', changes='1,1 2,1'),
+            dict(name='project-test1', result='FAILURE', changes='1,1'),
+            dict(name='project-test1', result='ABORTED', changes='1,1 2,1'),
+            dict(name='project-test2', result='ABORTED', changes='1,1 2,1'),
+            dict(name='project-test2', result='SUCCESS', changes='1,1'),
+            dict(name='project-merge', result='SUCCESS', changes='2,1'),
+            dict(name='project-test1', result='SUCCESS', changes='2,1'),
+            dict(name='project-test2', result='SUCCESS', changes='2,1'),
+        ], ordered=False)
+
+        self.assertEqual(A.data['status'], 'NEW')
+        self.assertEqual(B.data['status'], 'MERGED')
+        self.assertEqual(A.reported, 2)
+        self.assertEqual(B.reported, 2)
+
+    def test_independent_queues(self):
+        "Test that changes end up in the right queues"
+
+        self.launch_server.hold_jobs_in_build = True
+        A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
+        B = self.fake_gerrit.addFakeChange('org/project1', 'master', 'B')
+        C = self.fake_gerrit.addFakeChange('org/project2', 'master', 'C')
+        A.addApproval('code-review', 2)
+        B.addApproval('code-review', 2)
+        C.addApproval('code-review', 2)
+
+        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
+        self.fake_gerrit.addEvent(B.addApproval('approved', 1))
+        self.fake_gerrit.addEvent(C.addApproval('approved', 1))
+
+        self.waitUntilSettled()
+
+        # There should be one merge job at the head of each queue running
+        self.assertBuilds([
+            dict(name='project-merge', changes='1,1'),
+            dict(name='project-merge', changes='2,1'),
+        ])
+
+        # Release the current merge builds
+        self.builds[0].release()
+        self.waitUntilSettled()
+        self.builds[0].release()
+        self.waitUntilSettled()
+        # Release the merge job for project2 which is behind project1
+        self.launch_server.release('.*-merge')
+        self.waitUntilSettled()
+
+        # All the test builds should be running:
+        self.assertBuilds([
+            dict(name='project-test1', changes='1,1'),
+            dict(name='project-test2', changes='1,1'),
+            dict(name='project-test1', changes='2,1'),
+            dict(name='project-test2', changes='2,1'),
+            dict(name='project1-project2-integration', changes='2,1'),
+            dict(name='project-test1', changes='2,1 3,1'),
+            dict(name='project-test2', changes='2,1 3,1'),
+        ])
+
+        self.orderedRelease()
+        self.assertHistory([
+            dict(name='project-merge', result='SUCCESS', changes='1,1'),
+            dict(name='project-merge', result='SUCCESS', changes='2,1'),
+            dict(name='project-merge', result='SUCCESS', changes='2,1 3,1'),
+            dict(name='project-test1', result='SUCCESS', changes='1,1'),
+            dict(name='project-test2', result='SUCCESS', changes='1,1'),
+            dict(name='project-test1', result='SUCCESS', changes='2,1'),
+            dict(name='project-test2', result='SUCCESS', changes='2,1'),
+            dict(
+                name='project1-project2-integration',
+                result='SUCCESS',
+                changes='2,1'),
+            dict(name='project-test1', result='SUCCESS', changes='2,1 3,1'),
+            dict(name='project-test2', result='SUCCESS', changes='2,1 3,1'),
+        ])
+
+        self.assertEqual(A.data['status'], 'MERGED')
+        self.assertEqual(B.data['status'], 'MERGED')
+        self.assertEqual(C.data['status'], 'MERGED')
+        self.assertEqual(A.reported, 2)
+        self.assertEqual(B.reported, 2)
+        self.assertEqual(C.reported, 2)
+
+    def test_failed_change_at_head(self):
+        "Test that if a change at the head fails, jobs behind it are canceled"
+
+        self.launch_server.hold_jobs_in_build = True
+        A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
+        B = self.fake_gerrit.addFakeChange('org/project', 'master', 'B')
+        C = self.fake_gerrit.addFakeChange('org/project', 'master', 'C')
+        A.addApproval('code-review', 2)
+        B.addApproval('code-review', 2)
+        C.addApproval('code-review', 2)
+
+        self.launch_server.failJob('project-test1', A)
+
+        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
+        self.fake_gerrit.addEvent(B.addApproval('approved', 1))
+        self.fake_gerrit.addEvent(C.addApproval('approved', 1))
+
+        self.waitUntilSettled()
+
+        self.assertBuilds([
+            dict(name='project-merge', changes='1,1'),
+        ])
+
+        self.launch_server.release('.*-merge')
+        self.waitUntilSettled()
+        self.launch_server.release('.*-merge')
+        self.waitUntilSettled()
+        self.launch_server.release('.*-merge')
+        self.waitUntilSettled()
+
+        self.assertBuilds([
+            dict(name='project-test1', changes='1,1'),
+            dict(name='project-test2', changes='1,1'),
+            dict(name='project-test1', changes='1,1 2,1'),
+            dict(name='project-test2', changes='1,1 2,1'),
+            dict(name='project-test1', changes='1,1 2,1 3,1'),
+            dict(name='project-test2', changes='1,1 2,1 3,1'),
+        ])
+
+        self.release(self.builds[0])
+        self.waitUntilSettled()
+
+        # project-test2, project-merge for B
+        self.assertBuilds([
+            dict(name='project-test2', changes='1,1'),
+            dict(name='project-merge', changes='2,1'),
+        ])
+        # Unordered history comparison because the aborts can finish
+        # in any order.
+        self.assertHistory([
+            dict(name='project-merge', result='SUCCESS',
+                 changes='1,1'),
+            dict(name='project-merge', result='SUCCESS',
+                 changes='1,1 2,1'),
+            dict(name='project-merge', result='SUCCESS',
+                 changes='1,1 2,1 3,1'),
+            dict(name='project-test1', result='FAILURE',
+                 changes='1,1'),
+            dict(name='project-test1', result='ABORTED',
+                 changes='1,1 2,1'),
+            dict(name='project-test2', result='ABORTED',
+                 changes='1,1 2,1'),
+            dict(name='project-test1', result='ABORTED',
+                 changes='1,1 2,1 3,1'),
+            dict(name='project-test2', result='ABORTED',
+                 changes='1,1 2,1 3,1'),
+        ], ordered=False)
+
+        self.launch_server.release('.*-merge')
+        self.waitUntilSettled()
+        self.launch_server.release('.*-merge')
+        self.waitUntilSettled()
+        self.orderedRelease()
+
+        self.assertBuilds([])
+        self.assertHistory([
+            dict(name='project-merge', result='SUCCESS',
+                 changes='1,1'),
+            dict(name='project-merge', result='SUCCESS',
+                 changes='1,1 2,1'),
+            dict(name='project-merge', result='SUCCESS',
+                 changes='1,1 2,1 3,1'),
+            dict(name='project-test1', result='FAILURE',
+                 changes='1,1'),
+            dict(name='project-test1', result='ABORTED',
+                 changes='1,1 2,1'),
+            dict(name='project-test2', result='ABORTED',
+                 changes='1,1 2,1'),
+            dict(name='project-test1', result='ABORTED',
+                 changes='1,1 2,1 3,1'),
+            dict(name='project-test2', result='ABORTED',
+                 changes='1,1 2,1 3,1'),
+            dict(name='project-merge', result='SUCCESS',
+                 changes='2,1'),
+            dict(name='project-merge', result='SUCCESS',
+                 changes='2,1 3,1'),
+            dict(name='project-test2', result='SUCCESS',
+                 changes='1,1'),
+            dict(name='project-test1', result='SUCCESS',
+                 changes='2,1'),
+            dict(name='project-test2', result='SUCCESS',
+                 changes='2,1'),
+            dict(name='project-test1', result='SUCCESS',
+                 changes='2,1 3,1'),
+            dict(name='project-test2', result='SUCCESS',
+                 changes='2,1 3,1'),
+        ], ordered=False)
+
+        self.assertEqual(A.data['status'], 'NEW')
+        self.assertEqual(B.data['status'], 'MERGED')
+        self.assertEqual(C.data['status'], 'MERGED')
+        self.assertEqual(A.reported, 2)
+        self.assertEqual(B.reported, 2)
+        self.assertEqual(C.reported, 2)
+
+    def test_failed_change_in_middle(self):
+        "Test a failed change in the middle of the queue"
+
+        self.launch_server.hold_jobs_in_build = True
+        A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
+        B = self.fake_gerrit.addFakeChange('org/project', 'master', 'B')
+        C = self.fake_gerrit.addFakeChange('org/project', 'master', 'C')
+        A.addApproval('code-review', 2)
+        B.addApproval('code-review', 2)
+        C.addApproval('code-review', 2)
+
+        self.launch_server.failJob('project-test1', B)
+
+        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
+        self.fake_gerrit.addEvent(B.addApproval('approved', 1))
+        self.fake_gerrit.addEvent(C.addApproval('approved', 1))
+
+        self.waitUntilSettled()
+
+        self.launch_server.release('.*-merge')
+        self.waitUntilSettled()
+        self.launch_server.release('.*-merge')
+        self.waitUntilSettled()
+        self.launch_server.release('.*-merge')
+        self.waitUntilSettled()
+
+        self.assertEqual(len(self.builds), 6)
+        self.assertEqual(self.builds[0].name, 'project-test1')
+        self.assertEqual(self.builds[1].name, 'project-test2')
+        self.assertEqual(self.builds[2].name, 'project-test1')
+        self.assertEqual(self.builds[3].name, 'project-test2')
+        self.assertEqual(self.builds[4].name, 'project-test1')
+        self.assertEqual(self.builds[5].name, 'project-test2')
+
+        self.release(self.builds[2])
+        self.waitUntilSettled()
+
+        # project-test1 and project-test2 for A
+        # project-test2 for B
+        # project-merge for C (without B)
+        self.assertEqual(len(self.builds), 4)
+        self.assertEqual(self.countJobResults(self.history, 'ABORTED'), 2)
+
+        self.launch_server.release('.*-merge')
+        self.waitUntilSettled()
+
+        # project-test1 and project-test2 for A
+        # project-test2 for B
+        # project-test1 and project-test2 for C
+        self.assertEqual(len(self.builds), 5)
+
+        tenant = self.sched.abide.tenants.get('tenant-one')
+        items = tenant.layout.pipelines['gate'].getAllItems()
+        builds = items[0].current_build_set.getBuilds()
+        self.assertEqual(self.countJobResults(builds, 'SUCCESS'), 1)
+        self.assertEqual(self.countJobResults(builds, None), 2)
+        builds = items[1].current_build_set.getBuilds()
+        self.assertEqual(self.countJobResults(builds, 'SUCCESS'), 1)
+        self.assertEqual(self.countJobResults(builds, 'FAILURE'), 1)
+        self.assertEqual(self.countJobResults(builds, None), 1)
+        builds = items[2].current_build_set.getBuilds()
+        self.assertEqual(self.countJobResults(builds, 'SUCCESS'), 1)
+        self.assertEqual(self.countJobResults(builds, None), 2)
+
+        self.launch_server.hold_jobs_in_build = False
+        self.launch_server.release()
+        self.waitUntilSettled()
+
+        self.assertEqual(len(self.builds), 0)
+        self.assertEqual(len(self.history), 12)
+        self.assertEqual(A.data['status'], 'MERGED')
+        self.assertEqual(B.data['status'], 'NEW')
+        self.assertEqual(C.data['status'], 'MERGED')
+        self.assertEqual(A.reported, 2)
+        self.assertEqual(B.reported, 2)
+        self.assertEqual(C.reported, 2)
+
+    def test_failed_change_at_head_with_queue(self):
+        "Test that if a change at the head fails, queued jobs are canceled"
+
+        self.gearman_server.hold_jobs_in_queue = True
+        A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
+        B = self.fake_gerrit.addFakeChange('org/project', 'master', 'B')
+        C = self.fake_gerrit.addFakeChange('org/project', 'master', 'C')
+        A.addApproval('code-review', 2)
+        B.addApproval('code-review', 2)
+        C.addApproval('code-review', 2)
+
+        self.launch_server.failJob('project-test1', A)
+
+        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
+        self.fake_gerrit.addEvent(B.addApproval('approved', 1))
+        self.fake_gerrit.addEvent(C.addApproval('approved', 1))
+
+        self.waitUntilSettled()
+        queue = self.gearman_server.getQueue()
+        self.assertEqual(len(self.builds), 0)
+        self.assertEqual(len(queue), 1)
+        self.assertEqual(queue[0].name, 'launcher:launch')
+        job_args = json.loads(queue[0].arguments)
+        self.assertEqual(job_args['job'], 'project-merge')
+        self.assertEqual(job_args['items'][0]['number'], '%d' % A.number)
+
+        self.gearman_server.release('.*-merge')
+        self.waitUntilSettled()
+        self.gearman_server.release('.*-merge')
+        self.waitUntilSettled()
+        self.gearman_server.release('.*-merge')
+        self.waitUntilSettled()
+        queue = self.gearman_server.getQueue()
+
+        self.assertEqual(len(self.builds), 0)
+        self.assertEqual(len(queue), 6)
+
+        self.assertEqual(
+            json.loads(queue[0].arguments)['job'], 'project-test1')
+        self.assertEqual(
+            json.loads(queue[1].arguments)['job'], 'project-test2')
+        self.assertEqual(
+            json.loads(queue[2].arguments)['job'], 'project-test1')
+        self.assertEqual(
+            json.loads(queue[3].arguments)['job'], 'project-test2')
+        self.assertEqual(
+            json.loads(queue[4].arguments)['job'], 'project-test1')
+        self.assertEqual(
+            json.loads(queue[5].arguments)['job'], 'project-test2')
+
+        self.release(queue[0])
+        self.waitUntilSettled()
+
+        self.assertEqual(len(self.builds), 0)
+        queue = self.gearman_server.getQueue()
+        self.assertEqual(len(queue), 2)  # project-test2, project-merge for B
+        self.assertEqual(self.countJobResults(self.history, 'ABORTED'), 0)
+
+        self.gearman_server.hold_jobs_in_queue = False
+        self.gearman_server.release()
+        self.waitUntilSettled()
+
+        self.assertEqual(len(self.builds), 0)
+        self.assertEqual(len(self.history), 11)
+        self.assertEqual(A.data['status'], 'NEW')
+        self.assertEqual(B.data['status'], 'MERGED')
+        self.assertEqual(C.data['status'], 'MERGED')
+        self.assertEqual(A.reported, 2)
+        self.assertEqual(B.reported, 2)
+        self.assertEqual(C.reported, 2)
+
+    @skip("Disabled for early v3 development")
+    def _test_time_database(self, iteration):
+        self.launch_server.hold_jobs_in_build = True
+        A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
+        A.addApproval('code-review', 2)
+        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
+        self.waitUntilSettled()
+        time.sleep(2)
+
+        data = json.loads(self.sched.formatStatusJSON())
+        found_job = None
+        for pipeline in data['pipelines']:
+            if pipeline['name'] != 'gate':
+                continue
+            for queue in pipeline['change_queues']:
+                for head in queue['heads']:
+                    for item in head:
+                        for job in item['jobs']:
+                            if job['name'] == 'project-merge':
+                                found_job = job
+                                break
+
+        self.assertIsNotNone(found_job)
+        if iteration == 1:
+            self.assertIsNotNone(found_job['estimated_time'])
+            self.assertIsNone(found_job['remaining_time'])
+        else:
+            self.assertIsNotNone(found_job['estimated_time'])
+            self.assertTrue(found_job['estimated_time'] >= 2)
+            self.assertIsNotNone(found_job['remaining_time'])
+
+        self.launch_server.hold_jobs_in_build = False
+        self.launch_server.release()
+        self.waitUntilSettled()
+
+    @skip("Disabled for early v3 development")
+    def test_time_database(self):
+        "Test the time database"
+
+        self._test_time_database(1)
+        self._test_time_database(2)
+
+    def test_two_failed_changes_at_head(self):
+        "Test that changes are reparented correctly if 2 fail at head"
+
+        self.launch_server.hold_jobs_in_build = True
+        A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
+        B = self.fake_gerrit.addFakeChange('org/project', 'master', 'B')
+        C = self.fake_gerrit.addFakeChange('org/project', 'master', 'C')
+        A.addApproval('code-review', 2)
+        B.addApproval('code-review', 2)
+        C.addApproval('code-review', 2)
+
+        self.launch_server.failJob('project-test1', A)
+        self.launch_server.failJob('project-test1', B)
+
+        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
+        self.fake_gerrit.addEvent(B.addApproval('approved', 1))
+        self.fake_gerrit.addEvent(C.addApproval('approved', 1))
+        self.waitUntilSettled()
+
+        self.launch_server.release('.*-merge')
+        self.waitUntilSettled()
+        self.launch_server.release('.*-merge')
+        self.waitUntilSettled()
+        self.launch_server.release('.*-merge')
+        self.waitUntilSettled()
+
+        self.assertEqual(len(self.builds), 6)
+        self.assertEqual(self.builds[0].name, 'project-test1')
+        self.assertEqual(self.builds[1].name, 'project-test2')
+        self.assertEqual(self.builds[2].name, 'project-test1')
+        self.assertEqual(self.builds[3].name, 'project-test2')
+        self.assertEqual(self.builds[4].name, 'project-test1')
+        self.assertEqual(self.builds[5].name, 'project-test2')
+
+        self.assertTrue(self.builds[0].hasChanges(A))
+        self.assertTrue(self.builds[2].hasChanges(A))
+        self.assertTrue(self.builds[2].hasChanges(B))
+        self.assertTrue(self.builds[4].hasChanges(A))
+        self.assertTrue(self.builds[4].hasChanges(B))
+        self.assertTrue(self.builds[4].hasChanges(C))
+
+        # Fail change B first
+        self.release(self.builds[2])
+        self.waitUntilSettled()
+
+        # restart of C after B failure
+        self.launch_server.release('.*-merge')
+        self.waitUntilSettled()
+
+        self.assertEqual(len(self.builds), 5)
+        self.assertEqual(self.builds[0].name, 'project-test1')
+        self.assertEqual(self.builds[1].name, 'project-test2')
+        self.assertEqual(self.builds[2].name, 'project-test2')
+        self.assertEqual(self.builds[3].name, 'project-test1')
+        self.assertEqual(self.builds[4].name, 'project-test2')
+
+        self.assertTrue(self.builds[1].hasChanges(A))
+        self.assertTrue(self.builds[2].hasChanges(A))
+        self.assertTrue(self.builds[2].hasChanges(B))
+        self.assertTrue(self.builds[4].hasChanges(A))
+        self.assertFalse(self.builds[4].hasChanges(B))
+        self.assertTrue(self.builds[4].hasChanges(C))
+
+        # Finish running all passing jobs for change A
+        self.release(self.builds[1])
+        self.waitUntilSettled()
+        # Fail and report change A
+        self.release(self.builds[0])
+        self.waitUntilSettled()
+
+        # restart of B,C after A failure
+        self.launch_server.release('.*-merge')
+        self.waitUntilSettled()
+        self.launch_server.release('.*-merge')
+        self.waitUntilSettled()
+
+        self.assertEqual(len(self.builds), 4)
+        self.assertEqual(self.builds[0].name, 'project-test1')  # B
+        self.assertEqual(self.builds[1].name, 'project-test2')  # B
+        self.assertEqual(self.builds[2].name, 'project-test1')  # C
+        self.assertEqual(self.builds[3].name, 'project-test2')  # C
+
+        self.assertFalse(self.builds[1].hasChanges(A))
+        self.assertTrue(self.builds[1].hasChanges(B))
+        self.assertFalse(self.builds[1].hasChanges(C))
+
+        self.assertFalse(self.builds[2].hasChanges(A))
+        # After A failed and B and C restarted, B should be back in
+        # C's tests because it has not failed yet.
+        self.assertTrue(self.builds[2].hasChanges(B))
+        self.assertTrue(self.builds[2].hasChanges(C))
+
+        self.launch_server.hold_jobs_in_build = False
+        self.launch_server.release()
+        self.waitUntilSettled()
+
+        self.assertEqual(len(self.builds), 0)
+        self.assertEqual(len(self.history), 21)
+        self.assertEqual(A.data['status'], 'NEW')
+        self.assertEqual(B.data['status'], 'NEW')
+        self.assertEqual(C.data['status'], 'MERGED')
+        self.assertEqual(A.reported, 2)
+        self.assertEqual(B.reported, 2)
+        self.assertEqual(C.reported, 2)
+
+    def test_patch_order(self):
+        "Test that dependent patches are tested in the right order"
+        A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
+        B = self.fake_gerrit.addFakeChange('org/project', 'master', 'B')
+        C = self.fake_gerrit.addFakeChange('org/project', 'master', 'C')
+        A.addApproval('code-review', 2)
+        B.addApproval('code-review', 2)
+        C.addApproval('code-review', 2)
+
+        M2 = self.fake_gerrit.addFakeChange('org/project', 'master', 'M2')
+        M1 = self.fake_gerrit.addFakeChange('org/project', 'master', 'M1')
+        M2.setMerged()
+        M1.setMerged()
+
+        # C -> B -> A -> M1 -> M2
+        # M2 is here to make sure it is never queried.  If it is, it
+        # means zuul is walking down the entire history of merged
+        # changes.
+
+        C.setDependsOn(B, 1)
+        B.setDependsOn(A, 1)
+        A.setDependsOn(M1, 1)
+        M1.setDependsOn(M2, 1)
+
+        self.fake_gerrit.addEvent(C.addApproval('approved', 1))
+
+        self.waitUntilSettled()
+
+        self.assertEqual(A.data['status'], 'NEW')
+        self.assertEqual(B.data['status'], 'NEW')
+        self.assertEqual(C.data['status'], 'NEW')
+
+        self.fake_gerrit.addEvent(B.addApproval('approved', 1))
+        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
+
+        self.waitUntilSettled()
+        self.assertEqual(M2.queried, 0)
+        self.assertEqual(A.data['status'], 'MERGED')
+        self.assertEqual(B.data['status'], 'MERGED')
+        self.assertEqual(C.data['status'], 'MERGED')
+        self.assertEqual(A.reported, 2)
+        self.assertEqual(B.reported, 2)
+        self.assertEqual(C.reported, 2)
+
+    def test_needed_changes_enqueue(self):
+        "Test that a needed change is enqueued ahead"
+        #          A      Given a git tree like this, if we enqueue
+        #         / \     change C, we should walk up and down the tree
+        #        B   G    and enqueue changes in the order ABCDEFG.
+        #       /|\       This is also the order that you would get if
+        #     *C E F      you enqueued changes in the order ABCDEFG, so
+        #     /           the ordering is stable across re-enqueue events.
+        #    D
+
+        A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
+        B = self.fake_gerrit.addFakeChange('org/project', 'master', 'B')
+        C = self.fake_gerrit.addFakeChange('org/project', 'master', 'C')
+        D = self.fake_gerrit.addFakeChange('org/project', 'master', 'D')
+        E = self.fake_gerrit.addFakeChange('org/project', 'master', 'E')
+        F = self.fake_gerrit.addFakeChange('org/project', 'master', 'F')
+        G = self.fake_gerrit.addFakeChange('org/project', 'master', 'G')
+        B.setDependsOn(A, 1)
+        C.setDependsOn(B, 1)
+        D.setDependsOn(C, 1)
+        E.setDependsOn(B, 1)
+        F.setDependsOn(B, 1)
+        G.setDependsOn(A, 1)
+
+        A.addApproval('code-review', 2)
+        B.addApproval('code-review', 2)
+        C.addApproval('code-review', 2)
+        D.addApproval('code-review', 2)
+        E.addApproval('code-review', 2)
+        F.addApproval('code-review', 2)
+        G.addApproval('code-review', 2)
+        self.fake_gerrit.addEvent(C.addApproval('approved', 1))
+
+        self.waitUntilSettled()
+
+        self.assertEqual(A.data['status'], 'NEW')
+        self.assertEqual(B.data['status'], 'NEW')
+        self.assertEqual(C.data['status'], 'NEW')
+        self.assertEqual(D.data['status'], 'NEW')
+        self.assertEqual(E.data['status'], 'NEW')
+        self.assertEqual(F.data['status'], 'NEW')
+        self.assertEqual(G.data['status'], 'NEW')
+
+        # We're about to add approvals to changes without adding the
+        # triggering events to Zuul, so that we can be sure that it is
+        # enqueing the changes based on dependencies, not because of
+        # triggering events.  Since it will have the changes cached
+        # already (without approvals), we need to clear the cache
+        # first.
+        for connection in self.connections.connections.values():
+            connection.maintainCache([])
+
+        self.launch_server.hold_jobs_in_build = True
+        A.addApproval('approved', 1)
+        B.addApproval('approved', 1)
+        D.addApproval('approved', 1)
+        E.addApproval('approved', 1)
+        F.addApproval('approved', 1)
+        G.addApproval('approved', 1)
+        self.fake_gerrit.addEvent(C.addApproval('approved', 1))
+
+        for x in range(8):
+            self.launch_server.release('.*-merge')
+            self.waitUntilSettled()
+        self.launch_server.hold_jobs_in_build = False
+        self.launch_server.release()
+        self.waitUntilSettled()
+
+        self.assertEqual(A.data['status'], 'MERGED')
+        self.assertEqual(B.data['status'], 'MERGED')
+        self.assertEqual(C.data['status'], 'MERGED')
+        self.assertEqual(D.data['status'], 'MERGED')
+        self.assertEqual(E.data['status'], 'MERGED')
+        self.assertEqual(F.data['status'], 'MERGED')
+        self.assertEqual(G.data['status'], 'MERGED')
+        self.assertEqual(A.reported, 2)
+        self.assertEqual(B.reported, 2)
+        self.assertEqual(C.reported, 2)
+        self.assertEqual(D.reported, 2)
+        self.assertEqual(E.reported, 2)
+        self.assertEqual(F.reported, 2)
+        self.assertEqual(G.reported, 2)
+        self.assertEqual(self.history[6].changes,
+                         '1,1 2,1 3,1 4,1 5,1 6,1 7,1')
+
+    def test_source_cache(self):
+        "Test that the source cache operates correctly"
+        self.launch_server.hold_jobs_in_build = True
+
+        A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
+        B = self.fake_gerrit.addFakeChange('org/project', 'master', 'B')
+        X = self.fake_gerrit.addFakeChange('org/project', 'master', 'X')
+        A.addApproval('code-review', 2)
+        B.addApproval('code-review', 2)
+
+        M1 = self.fake_gerrit.addFakeChange('org/project', 'master', 'M1')
+        M1.setMerged()
+
+        B.setDependsOn(A, 1)
+        A.setDependsOn(M1, 1)
+
+        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
+        self.fake_gerrit.addEvent(X.getPatchsetCreatedEvent(1))
+
+        self.waitUntilSettled()
+
+        for build in self.builds:
+            if build.parameters['ZUUL_PIPELINE'] == 'check':
+                build.release()
+        self.waitUntilSettled()
+        for build in self.builds:
+            if build.parameters['ZUUL_PIPELINE'] == 'check':
+                build.release()
+        self.waitUntilSettled()
+
+        self.fake_gerrit.addEvent(B.addApproval('approved', 1))
+        self.waitUntilSettled()
+
+        self.log.debug("len %s" % self.fake_gerrit._change_cache.keys())
+        # there should still be changes in the cache
+        self.assertNotEqual(len(self.fake_gerrit._change_cache.keys()), 0)
+
+        self.launch_server.hold_jobs_in_build = False
+        self.launch_server.release()
+        self.waitUntilSettled()
+
+        self.assertEqual(A.data['status'], 'MERGED')
+        self.assertEqual(B.data['status'], 'MERGED')
+        self.assertEqual(A.queried, 2)  # Initial and isMerged
+        self.assertEqual(B.queried, 3)  # Initial A, refresh from B, isMerged
+
+    def test_can_merge(self):
+        "Test whether a change is ready to merge"
+        # TODO: move to test_gerrit (this is a unit test!)
+        A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
+        tenant = self.sched.abide.tenants.get('tenant-one')
+        source = tenant.layout.pipelines['gate'].source
+
+        # TODO(pabelanger): As we add more source / trigger APIs we should make
+        # it easier for users to create events for testing.
+        event = zuul.model.TriggerEvent()
+        event.trigger_name = 'gerrit'
+        event.change_number = '1'
+        event.patch_number = '2'
+
+        a = source.getChange(event)
+        mgr = tenant.layout.pipelines['gate'].manager
+        self.assertFalse(source.canMerge(a, mgr.getSubmitAllowNeeds()))
+
+        A.addApproval('code-review', 2)
+        a = source.getChange(event, refresh=True)
+        self.assertFalse(source.canMerge(a, mgr.getSubmitAllowNeeds()))
+
+        A.addApproval('approved', 1)
+        a = source.getChange(event, refresh=True)
+        self.assertTrue(source.canMerge(a, mgr.getSubmitAllowNeeds()))
+
+    @skip("Disabled for early v3 development")
+    def test_build_configuration_conflict(self):
+        "Test that merge conflicts are handled"
+
+        self.gearman_server.hold_jobs_in_queue = True
+        A = self.fake_gerrit.addFakeChange('org/conflict-project',
+                                           'master', 'A')
+        A.addPatchset(['conflict'])
+        B = self.fake_gerrit.addFakeChange('org/conflict-project',
+                                           'master', 'B')
+        B.addPatchset(['conflict'])
+        C = self.fake_gerrit.addFakeChange('org/conflict-project',
+                                           'master', 'C')
+        A.addApproval('code-review', 2)
+        B.addApproval('code-review', 2)
+        C.addApproval('code-review', 2)
+        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
+        self.fake_gerrit.addEvent(B.addApproval('approved', 1))
+        self.fake_gerrit.addEvent(C.addApproval('approved', 1))
+        self.waitUntilSettled()
+
+        self.assertEqual(A.reported, 1)
+        self.assertEqual(B.reported, 1)
+        self.assertEqual(C.reported, 1)
+
+        self.gearman_server.release('.*-merge')
+        self.waitUntilSettled()
+        self.gearman_server.release('.*-merge')
+        self.waitUntilSettled()
+        self.gearman_server.release('.*-merge')
+        self.waitUntilSettled()
+
+        self.assertEqual(len(self.history), 2)  # A and C merge jobs
+
+        self.gearman_server.hold_jobs_in_queue = False
+        self.gearman_server.release()
+        self.waitUntilSettled()
+
+        self.assertEqual(A.data['status'], 'MERGED')
+        self.assertEqual(B.data['status'], 'NEW')
+        self.assertEqual(C.data['status'], 'MERGED')
+        self.assertEqual(A.reported, 2)
+        self.assertEqual(B.reported, 2)
+        self.assertEqual(C.reported, 2)
+        self.assertEqual(len(self.history), 6)
+
+    def test_post(self):
+        "Test that post jobs run"
+
+        e = {
+            "type": "ref-updated",
+            "submitter": {
+                "name": "User Name",
+            },
+            "refUpdate": {
+                "oldRev": "90f173846e3af9154517b88543ffbd1691f31366",
+                "newRev": "d479a0bfcb34da57a31adb2a595c0cf687812543",
+                "refName": "master",
+                "project": "org/project",
+            }
+        }
+        self.fake_gerrit.addEvent(e)
+        self.waitUntilSettled()
+
+        job_names = [x.name for x in self.history]
+        self.assertEqual(len(self.history), 1)
+        self.assertIn('project-post', job_names)
+
+    def test_post_ignore_deletes(self):
+        "Test that deleting refs does not trigger post jobs"
+
+        e = {
+            "type": "ref-updated",
+            "submitter": {
+                "name": "User Name",
+            },
+            "refUpdate": {
+                "oldRev": "90f173846e3af9154517b88543ffbd1691f31366",
+                "newRev": "0000000000000000000000000000000000000000",
+                "refName": "master",
+                "project": "org/project",
+            }
+        }
+        self.fake_gerrit.addEvent(e)
+        self.waitUntilSettled()
+
+        job_names = [x.name for x in self.history]
+        self.assertEqual(len(self.history), 0)
+        self.assertNotIn('project-post', job_names)
+
+    def test_post_ignore_deletes_negative(self):
+        "Test that deleting refs does trigger post jobs"
+
+        self.updateConfigLayout('layout-dont-ignore-ref-deletes')
+        self.sched.reconfigure(self.config)
+
+        e = {
+            "type": "ref-updated",
+            "submitter": {
+                "name": "User Name",
+            },
+            "refUpdate": {
+                "oldRev": "90f173846e3af9154517b88543ffbd1691f31366",
+                "newRev": "0000000000000000000000000000000000000000",
+                "refName": "master",
+                "project": "org/project",
+            }
+        }
+        self.fake_gerrit.addEvent(e)
+        self.waitUntilSettled()
+
+        job_names = [x.name for x in self.history]
+        self.assertEqual(len(self.history), 1)
+        self.assertIn('project-post', job_names)
+
+    @skip("Disabled for early v3 development")
+    def test_build_configuration_branch(self):
+        "Test that the right commits are on alternate branches"
+
+        self.gearman_server.hold_jobs_in_queue = True
+        A = self.fake_gerrit.addFakeChange('org/project', 'mp', 'A')
+        B = self.fake_gerrit.addFakeChange('org/project', 'mp', 'B')
+        C = self.fake_gerrit.addFakeChange('org/project', 'mp', 'C')
+        A.addApproval('code-review', 2)
+        B.addApproval('code-review', 2)
+        C.addApproval('code-review', 2)
+        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
+        self.fake_gerrit.addEvent(B.addApproval('approved', 1))
+        self.fake_gerrit.addEvent(C.addApproval('approved', 1))
+        self.waitUntilSettled()
+
+        self.gearman_server.release('.*-merge')
+        self.waitUntilSettled()
+        self.gearman_server.release('.*-merge')
+        self.waitUntilSettled()
+        self.gearman_server.release('.*-merge')
+        self.waitUntilSettled()
+        queue = self.gearman_server.getQueue()
+        ref = self.getParameter(queue[-1], 'ZUUL_REF')
+        self.gearman_server.hold_jobs_in_queue = False
+        self.gearman_server.release()
+        self.waitUntilSettled()
+
+        path = os.path.join(self.git_root, "org/project")
+        repo = git.Repo(path)
+        repo_messages = [c.message.strip() for c in repo.iter_commits(ref)]
+        repo_messages.reverse()
+        correct_messages = ['initial commit', 'mp commit', 'A-1', 'B-1', 'C-1']
+        self.assertEqual(repo_messages, correct_messages)
+
+    @skip("Disabled for early v3 development")
+    def test_build_configuration_branch_interaction(self):
+        "Test that switching between branches works"
+        self.test_build_configuration()
+        self.test_build_configuration_branch()
+        # C has been merged, undo that
+        path = os.path.join(self.upstream_root, "org/project")
+        repo = git.Repo(path)
+        repo.heads.master.commit = repo.commit('init')
+        self.test_build_configuration()
+
+    @skip("Disabled for early v3 development")
+    def test_build_configuration_multi_branch(self):
+        "Test that dependent changes on multiple branches are merged"
+
+        self.gearman_server.hold_jobs_in_queue = True
+        A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
+        B = self.fake_gerrit.addFakeChange('org/project', 'mp', 'B')
+        C = self.fake_gerrit.addFakeChange('org/project', 'master', 'C')
+        A.addApproval('code-review', 2)
+        B.addApproval('code-review', 2)
+        C.addApproval('code-review', 2)
+        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
+        self.fake_gerrit.addEvent(B.addApproval('approved', 1))
+        self.fake_gerrit.addEvent(C.addApproval('approved', 1))
+        self.waitUntilSettled()
+        queue = self.gearman_server.getQueue()
+        job_A = None
+        for job in queue:
+            if 'project-merge' in job.name:
+                job_A = job
+        ref_A = self.getParameter(job_A, 'ZUUL_REF')
+        commit_A = self.getParameter(job_A, 'ZUUL_COMMIT')
+        self.log.debug("Got Zuul ref for change A: %s" % ref_A)
+        self.log.debug("Got Zuul commit for change A: %s" % commit_A)
+
+        self.gearman_server.release('.*-merge')
+        self.waitUntilSettled()
+        queue = self.gearman_server.getQueue()
+        job_B = None
+        for job in queue:
+            if 'project-merge' in job.name:
+                job_B = job
+        ref_B = self.getParameter(job_B, 'ZUUL_REF')
+        commit_B = self.getParameter(job_B, 'ZUUL_COMMIT')
+        self.log.debug("Got Zuul ref for change B: %s" % ref_B)
+        self.log.debug("Got Zuul commit for change B: %s" % commit_B)
+
+        self.gearman_server.release('.*-merge')
+        self.waitUntilSettled()
+        queue = self.gearman_server.getQueue()
+        for job in queue:
+            if 'project-merge' in job.name:
+                job_C = job
+        ref_C = self.getParameter(job_C, 'ZUUL_REF')
+        commit_C = self.getParameter(job_C, 'ZUUL_COMMIT')
+        self.log.debug("Got Zuul ref for change C: %s" % ref_C)
+        self.log.debug("Got Zuul commit for change C: %s" % commit_C)
+        self.gearman_server.hold_jobs_in_queue = False
+        self.gearman_server.release()
+        self.waitUntilSettled()
+
+        path = os.path.join(self.git_root, "org/project")
+        repo = git.Repo(path)
+
+        repo_messages = [c.message.strip()
+                         for c in repo.iter_commits(ref_C)]
+        repo_shas = [c.hexsha for c in repo.iter_commits(ref_C)]
+        repo_messages.reverse()
+        correct_messages = ['initial commit', 'A-1', 'C-1']
+        # Ensure the right commits are in the history for this ref
+        self.assertEqual(repo_messages, correct_messages)
+        # Ensure ZUUL_REF -> ZUUL_COMMIT
+        self.assertEqual(repo_shas[0], commit_C)
+
+        repo_messages = [c.message.strip()
+                         for c in repo.iter_commits(ref_B)]
+        repo_shas = [c.hexsha for c in repo.iter_commits(ref_B)]
+        repo_messages.reverse()
+        correct_messages = ['initial commit', 'mp commit', 'B-1']
+        self.assertEqual(repo_messages, correct_messages)
+        self.assertEqual(repo_shas[0], commit_B)
+
+        repo_messages = [c.message.strip()
+                         for c in repo.iter_commits(ref_A)]
+        repo_shas = [c.hexsha for c in repo.iter_commits(ref_A)]
+        repo_messages.reverse()
+        correct_messages = ['initial commit', 'A-1']
+        self.assertEqual(repo_messages, correct_messages)
+        self.assertEqual(repo_shas[0], commit_A)
+
+        self.assertNotEqual(ref_A, ref_B, ref_C)
+        self.assertNotEqual(commit_A, commit_B, commit_C)
+
+    def test_dependent_changes_dequeue(self):
+        "Test that dependent patches are not needlessly tested"
+
+        A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
+        B = self.fake_gerrit.addFakeChange('org/project', 'master', 'B')
+        C = self.fake_gerrit.addFakeChange('org/project', 'master', 'C')
+        A.addApproval('code-review', 2)
+        B.addApproval('code-review', 2)
+        C.addApproval('code-review', 2)
+
+        M1 = self.fake_gerrit.addFakeChange('org/project', 'master', 'M1')
+        M1.setMerged()
+
+        # C -> B -> A -> M1
+
+        C.setDependsOn(B, 1)
+        B.setDependsOn(A, 1)
+        A.setDependsOn(M1, 1)
+
+        self.launch_server.failJob('project-merge', A)
+
+        self.fake_gerrit.addEvent(C.addApproval('approved', 1))
+        self.fake_gerrit.addEvent(B.addApproval('approved', 1))
+        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
+
+        self.waitUntilSettled()
+
+        self.assertEqual(A.data['status'], 'NEW')
+        self.assertEqual(A.reported, 2)
+        self.assertEqual(B.data['status'], 'NEW')
+        self.assertEqual(B.reported, 2)
+        self.assertEqual(C.data['status'], 'NEW')
+        self.assertEqual(C.reported, 2)
+        self.assertEqual(len(self.history), 1)
+
+    def test_failing_dependent_changes(self):
+        "Test that failing dependent patches are taken out of stream"
+        self.launch_server.hold_jobs_in_build = True
+        A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
+        B = self.fake_gerrit.addFakeChange('org/project', 'master', 'B')
+        C = self.fake_gerrit.addFakeChange('org/project', 'master', 'C')
+        D = self.fake_gerrit.addFakeChange('org/project', 'master', 'D')
+        E = self.fake_gerrit.addFakeChange('org/project', 'master', 'E')
+        A.addApproval('code-review', 2)
+        B.addApproval('code-review', 2)
+        C.addApproval('code-review', 2)
+        D.addApproval('code-review', 2)
+        E.addApproval('code-review', 2)
+
+        # E, D -> C -> B, A
+
+        D.setDependsOn(C, 1)
+        C.setDependsOn(B, 1)
+
+        self.launch_server.failJob('project-test1', B)
+
+        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
+        self.fake_gerrit.addEvent(D.addApproval('approved', 1))
+        self.fake_gerrit.addEvent(C.addApproval('approved', 1))
+        self.fake_gerrit.addEvent(B.addApproval('approved', 1))
+        self.fake_gerrit.addEvent(E.addApproval('approved', 1))
+
+        self.waitUntilSettled()
+        self.launch_server.release('.*-merge')
+        self.waitUntilSettled()
+        self.launch_server.release('.*-merge')
+        self.waitUntilSettled()
+        self.launch_server.release('.*-merge')
+        self.waitUntilSettled()
+        self.launch_server.release('.*-merge')
+        self.waitUntilSettled()
+        self.launch_server.release('.*-merge')
+        self.waitUntilSettled()
+
+        self.launch_server.hold_jobs_in_build = False
+        for build in self.builds:
+            if build.parameters['ZUUL_CHANGE'] != '1':
+                build.release()
+                self.waitUntilSettled()
+
+        self.launch_server.release()
+        self.waitUntilSettled()
+
+        self.assertEqual(A.data['status'], 'MERGED')
+        self.assertEqual(A.reported, 2)
+        self.assertIn('Build succeeded', A.messages[1])
+        self.assertEqual(B.data['status'], 'NEW')
+        self.assertEqual(B.reported, 2)
+        self.assertIn('Build failed', B.messages[1])
+        self.assertEqual(C.data['status'], 'NEW')
+        self.assertEqual(C.reported, 2)
+        self.assertIn('depends on a change', C.messages[1])
+        self.assertEqual(D.data['status'], 'NEW')
+        self.assertEqual(D.reported, 2)
+        self.assertIn('depends on a change', D.messages[1])
+        self.assertEqual(E.data['status'], 'MERGED')
+        self.assertEqual(E.reported, 2)
+        self.assertIn('Build succeeded', E.messages[1])
+        self.assertEqual(len(self.history), 18)
+
+    def test_head_is_dequeued_once(self):
+        "Test that if a change at the head fails it is dequeued only once"
+        # If it's dequeued more than once, we should see extra
+        # aborted jobs.
+
+        self.launch_server.hold_jobs_in_build = True
+        A = self.fake_gerrit.addFakeChange('org/project1', 'master', 'A')
+        B = self.fake_gerrit.addFakeChange('org/project1', 'master', 'B')
+        C = self.fake_gerrit.addFakeChange('org/project1', 'master', 'C')
+        A.addApproval('code-review', 2)
+        B.addApproval('code-review', 2)
+        C.addApproval('code-review', 2)
+
+        self.launch_server.failJob('project-test1', A)
+        self.launch_server.failJob('project-test2', A)
+        self.launch_server.failJob('project1-project2-integration', A)
+
+        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
+        self.fake_gerrit.addEvent(B.addApproval('approved', 1))
+        self.fake_gerrit.addEvent(C.addApproval('approved', 1))
+
+        self.waitUntilSettled()
+
+        self.assertEqual(len(self.builds), 1)
+        self.assertEqual(self.builds[0].name, 'project-merge')
+        self.assertTrue(self.builds[0].hasChanges(A))
+
+        self.launch_server.release('.*-merge')
+        self.waitUntilSettled()
+        self.launch_server.release('.*-merge')
+        self.waitUntilSettled()
+        self.launch_server.release('.*-merge')
+        self.waitUntilSettled()
+
+        self.assertEqual(len(self.builds), 9)
+        self.assertEqual(self.builds[0].name, 'project-test1')
+        self.assertEqual(self.builds[1].name, 'project-test2')
+        self.assertEqual(self.builds[2].name, 'project1-project2-integration')
+        self.assertEqual(self.builds[3].name, 'project-test1')
+        self.assertEqual(self.builds[4].name, 'project-test2')
+        self.assertEqual(self.builds[5].name, 'project1-project2-integration')
+        self.assertEqual(self.builds[6].name, 'project-test1')
+        self.assertEqual(self.builds[7].name, 'project-test2')
+        self.assertEqual(self.builds[8].name, 'project1-project2-integration')
+
+        self.release(self.builds[0])
+        self.waitUntilSettled()
+
+        self.assertEqual(len(self.builds), 3)  # test2,integration, merge for B
+        self.assertEqual(self.countJobResults(self.history, 'ABORTED'), 6)
+
+        self.launch_server.hold_jobs_in_build = False
+        self.launch_server.release()
+        self.waitUntilSettled()
+
+        self.assertEqual(len(self.builds), 0)
+        self.assertEqual(len(self.history), 20)
+
+        self.assertEqual(A.data['status'], 'NEW')
+        self.assertEqual(B.data['status'], 'MERGED')
+        self.assertEqual(C.data['status'], 'MERGED')
+        self.assertEqual(A.reported, 2)
+        self.assertEqual(B.reported, 2)
+        self.assertEqual(C.reported, 2)
+
+    def test_nonvoting_job(self):
+        "Test that non-voting jobs don't vote."
+
+        A = self.fake_gerrit.addFakeChange('org/nonvoting-project',
+                                           'master', 'A')
+        A.addApproval('code-review', 2)
+        self.launch_server.failJob('nonvoting-project-test2', A)
+        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
+
+        self.waitUntilSettled()
+
+        self.assertEqual(A.data['status'], 'MERGED')
+        self.assertEqual(A.reported, 2)
+        self.assertEqual(
+            self.getJobFromHistory('nonvoting-project-merge').result,
+            'SUCCESS')
+        self.assertEqual(
+            self.getJobFromHistory('nonvoting-project-test1').result,
+            'SUCCESS')
+        self.assertEqual(
+            self.getJobFromHistory('nonvoting-project-test2').result,
+            'FAILURE')
+
+        for build in self.builds:
+            self.assertEqual(build.parameters['ZUUL_VOTING'], '0')
+
+    def test_check_queue_success(self):
+        "Test successful check queue jobs."
+
+        A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
+        self.fake_gerrit.addEvent(A.getPatchsetCreatedEvent(1))
+
+        self.waitUntilSettled()
+
+        self.assertEqual(A.data['status'], 'NEW')
+        self.assertEqual(A.reported, 1)
+        self.assertEqual(self.getJobFromHistory('project-merge').result,
+                         'SUCCESS')
+        self.assertEqual(self.getJobFromHistory('project-test1').result,
+                         'SUCCESS')
+        self.assertEqual(self.getJobFromHistory('project-test2').result,
+                         'SUCCESS')
+
+    def test_check_queue_failure(self):
+        "Test failed check queue jobs."
+
+        A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
+        self.launch_server.failJob('project-test2', A)
+        self.fake_gerrit.addEvent(A.getPatchsetCreatedEvent(1))
+
+        self.waitUntilSettled()
+
+        self.assertEqual(A.data['status'], 'NEW')
+        self.assertEqual(A.reported, 1)
+        self.assertEqual(self.getJobFromHistory('project-merge').result,
+                         'SUCCESS')
+        self.assertEqual(self.getJobFromHistory('project-test1').result,
+                         'SUCCESS')
+        self.assertEqual(self.getJobFromHistory('project-test2').result,
+                         'FAILURE')
+
+    @skip("Disabled for early v3 development")
+    def test_dependent_behind_dequeue(self):
+        "test that dependent changes behind dequeued changes work"
+        # This complicated test is a reproduction of a real life bug
+        self.sched.reconfigure(self.config)
+
+        self.launch_server.hold_jobs_in_build = True
+        A = self.fake_gerrit.addFakeChange('org/project1', 'master', 'A')
+        B = self.fake_gerrit.addFakeChange('org/project1', 'master', 'B')
+        C = self.fake_gerrit.addFakeChange('org/project2', 'master', 'C')
+        D = self.fake_gerrit.addFakeChange('org/project2', 'master', 'D')
+        E = self.fake_gerrit.addFakeChange('org/project2', 'master', 'E')
+        F = self.fake_gerrit.addFakeChange('org/project3', 'master', 'F')
+        D.setDependsOn(C, 1)
+        E.setDependsOn(D, 1)
+        A.addApproval('code-review', 2)
+        B.addApproval('code-review', 2)
+        C.addApproval('code-review', 2)
+        D.addApproval('code-review', 2)
+        E.addApproval('code-review', 2)
+        F.addApproval('code-review', 2)
+
+        A.fail_merge = True
+
+        # Change object re-use in the gerrit trigger is hidden if
+        # changes are added in quick succession; waiting makes it more
+        # like real life.
+        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
+        self.waitUntilSettled()
+        self.fake_gerrit.addEvent(B.addApproval('approved', 1))
+        self.waitUntilSettled()
+
+        self.launch_server.release('.*-merge')
+        self.waitUntilSettled()
+        self.launch_server.release('.*-merge')
+        self.waitUntilSettled()
+
+        self.fake_gerrit.addEvent(C.addApproval('approved', 1))
+        self.waitUntilSettled()
+        self.fake_gerrit.addEvent(D.addApproval('approved', 1))
+        self.waitUntilSettled()
+        self.fake_gerrit.addEvent(E.addApproval('approved', 1))
+        self.waitUntilSettled()
+        self.fake_gerrit.addEvent(F.addApproval('approved', 1))
+        self.waitUntilSettled()
+
+        self.launch_server.release('.*-merge')
+        self.waitUntilSettled()
+        self.launch_server.release('.*-merge')
+        self.waitUntilSettled()
+        self.launch_server.release('.*-merge')
+        self.waitUntilSettled()
+        self.launch_server.release('.*-merge')
+        self.waitUntilSettled()
+
+        # all jobs running
+
+        # Grab pointers to the jobs we want to release before
+        # releasing any, because list indexes may change as
+        # the jobs complete.
+        a, b, c = self.builds[:3]
+        a.release()
+        b.release()
+        c.release()
+        self.waitUntilSettled()
+
+        self.launch_server.hold_jobs_in_build = False
+        self.launch_server.release()
+        self.waitUntilSettled()
+
+        self.assertEqual(A.data['status'], 'NEW')
+        self.assertEqual(B.data['status'], 'MERGED')
+        self.assertEqual(C.data['status'], 'MERGED')
+        self.assertEqual(D.data['status'], 'MERGED')
+        self.assertEqual(E.data['status'], 'MERGED')
+        self.assertEqual(F.data['status'], 'MERGED')
+
+        self.assertEqual(A.reported, 2)
+        self.assertEqual(B.reported, 2)
+        self.assertEqual(C.reported, 2)
+        self.assertEqual(D.reported, 2)
+        self.assertEqual(E.reported, 2)
+        self.assertEqual(F.reported, 2)
+
+        self.assertEqual(self.countJobResults(self.history, 'ABORTED'), 15)
+        self.assertEqual(len(self.history), 44)
+
+    def test_merger_repack(self):
+        "Test that the merger works after a repack"
+
+        A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
+        A.addApproval('code-review', 2)
+        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
+        self.waitUntilSettled()
+        self.assertEqual(self.getJobFromHistory('project-merge').result,
+                         'SUCCESS')
+        self.assertEqual(self.getJobFromHistory('project-test1').result,
+                         'SUCCESS')
+        self.assertEqual(self.getJobFromHistory('project-test2').result,
+                         'SUCCESS')
+        self.assertEqual(A.data['status'], 'MERGED')
+        self.assertEqual(A.reported, 2)
+        self.assertEmptyQueues()
+        self.build_history = []
+
+        path = os.path.join(self.git_root, "org/project")
+        print(repack_repo(path))
+
+        A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
+        A.addApproval('code-review', 2)
+        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
+        self.waitUntilSettled()
+        self.assertEqual(self.getJobFromHistory('project-merge').result,
+                         'SUCCESS')
+        self.assertEqual(self.getJobFromHistory('project-test1').result,
+                         'SUCCESS')
+        self.assertEqual(self.getJobFromHistory('project-test2').result,
+                         'SUCCESS')
+        self.assertEqual(A.data['status'], 'MERGED')
+        self.assertEqual(A.reported, 2)
+
+    def test_merger_repack_large_change(self):
+        "Test that the merger works with large changes after a repack"
+        # https://bugs.launchpad.net/zuul/+bug/1078946
+        # This test assumes the repo is already cloned; make sure it is
+        tenant = self.sched.abide.tenants.get('tenant-one')
+        url = self.fake_gerrit.getGitUrl(
+            tenant.layout.project_configs.get('org/project1'))
+        self.merge_server.merger.addProject('org/project1', url)
+        A = self.fake_gerrit.addFakeChange('org/project1', 'master', 'A')
+        A.addPatchset(large=True)
+        path = os.path.join(self.upstream_root, "org/project1")
+        print(repack_repo(path))
+        path = os.path.join(self.git_root, "org/project1")
+        print(repack_repo(path))
+
+        A.addApproval('code-review', 2)
+        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
+        self.waitUntilSettled()
+        self.assertEqual(self.getJobFromHistory('project-merge').result,
+                         'SUCCESS')
+        self.assertEqual(self.getJobFromHistory('project-test1').result,
+                         'SUCCESS')
+        self.assertEqual(self.getJobFromHistory('project-test2').result,
+                         'SUCCESS')
+        self.assertEqual(A.data['status'], 'MERGED')
+        self.assertEqual(A.reported, 2)
+
+    def test_new_patchset_dequeues_old(self):
+        "Test that a new patchset causes the old to be dequeued"
+        # D -> C (depends on B) -> B (depends on A) -> A -> M
+        self.launch_server.hold_jobs_in_build = True
+        M = self.fake_gerrit.addFakeChange('org/project', 'master', 'M')
+        M.setMerged()
+
+        A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
+        B = self.fake_gerrit.addFakeChange('org/project', 'master', 'B')
+        C = self.fake_gerrit.addFakeChange('org/project', 'master', 'C')
+        D = self.fake_gerrit.addFakeChange('org/project', 'master', 'D')
+        A.addApproval('code-review', 2)
+        B.addApproval('code-review', 2)
+        C.addApproval('code-review', 2)
+        D.addApproval('code-review', 2)
+
+        C.setDependsOn(B, 1)
+        B.setDependsOn(A, 1)
+        A.setDependsOn(M, 1)
+
+        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
+        self.fake_gerrit.addEvent(B.addApproval('approved', 1))
+        self.fake_gerrit.addEvent(C.addApproval('approved', 1))
+        self.fake_gerrit.addEvent(D.addApproval('approved', 1))
+        self.waitUntilSettled()
+
+        B.addPatchset()
+        self.fake_gerrit.addEvent(B.getPatchsetCreatedEvent(2))
+        self.waitUntilSettled()
+
+        self.launch_server.hold_jobs_in_build = False
+        self.launch_server.release()
+        self.waitUntilSettled()
+
+        self.assertEqual(A.data['status'], 'MERGED')
+        self.assertEqual(A.reported, 2)
+        self.assertEqual(B.data['status'], 'NEW')
+        self.assertEqual(B.reported, 2)
+        self.assertEqual(C.data['status'], 'NEW')
+        self.assertEqual(C.reported, 2)
+        self.assertEqual(D.data['status'], 'MERGED')
+        self.assertEqual(D.reported, 2)
+        self.assertEqual(len(self.history), 9)  # 3 each for A, B, D.
+
+    def test_new_patchset_check(self):
+        "Test a new patchset in check"
+
+        self.launch_server.hold_jobs_in_build = True
+
+        A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
+        B = self.fake_gerrit.addFakeChange('org/project', 'master', 'B')
+        tenant = self.sched.abide.tenants.get('tenant-one')
+        check_pipeline = tenant.layout.pipelines['check']
+
+        # Add two git-dependent changes
+        B.setDependsOn(A, 1)
+        self.fake_gerrit.addEvent(B.getPatchsetCreatedEvent(1))
+        self.waitUntilSettled()
+        self.fake_gerrit.addEvent(A.getPatchsetCreatedEvent(1))
+        self.waitUntilSettled()
+
+        # A live item, and a non-live/live pair
+        items = check_pipeline.getAllItems()
+        self.assertEqual(len(items), 3)
+
+        self.assertEqual(items[0].change.number, '1')
+        self.assertEqual(items[0].change.patchset, '1')
+        self.assertFalse(items[0].live)
+
+        self.assertEqual(items[1].change.number, '2')
+        self.assertEqual(items[1].change.patchset, '1')
+        self.assertTrue(items[1].live)
+
+        self.assertEqual(items[2].change.number, '1')
+        self.assertEqual(items[2].change.patchset, '1')
+        self.assertTrue(items[2].live)
+
+        # Add a new patchset to A
+        A.addPatchset()
+        self.fake_gerrit.addEvent(A.getPatchsetCreatedEvent(2))
+        self.waitUntilSettled()
+
+        # The live copy of A,1 should be gone, but the non-live and B
+        # should continue, and we should have a new A,2
+        items = check_pipeline.getAllItems()
+        self.assertEqual(len(items), 3)
+
+        self.assertEqual(items[0].change.number, '1')
+        self.assertEqual(items[0].change.patchset, '1')
+        self.assertFalse(items[0].live)
+
+        self.assertEqual(items[1].change.number, '2')
+        self.assertEqual(items[1].change.patchset, '1')
+        self.assertTrue(items[1].live)
+
+        self.assertEqual(items[2].change.number, '1')
+        self.assertEqual(items[2].change.patchset, '2')
+        self.assertTrue(items[2].live)
+
+        # Add a new patchset to B
+        B.addPatchset()
+        self.fake_gerrit.addEvent(B.getPatchsetCreatedEvent(2))
+        self.waitUntilSettled()
+
+        # The live copy of B,1 should be gone, and it's non-live copy of A,1
+        # but we should have a new B,2 (still based on A,1)
+        items = check_pipeline.getAllItems()
+        self.assertEqual(len(items), 3)
+
+        self.assertEqual(items[0].change.number, '1')
+        self.assertEqual(items[0].change.patchset, '2')
+        self.assertTrue(items[0].live)
+
+        self.assertEqual(items[1].change.number, '1')
+        self.assertEqual(items[1].change.patchset, '1')
+        self.assertFalse(items[1].live)
+
+        self.assertEqual(items[2].change.number, '2')
+        self.assertEqual(items[2].change.patchset, '2')
+        self.assertTrue(items[2].live)
+
+        self.builds[0].release()
+        self.waitUntilSettled()
+        self.builds[0].release()
+        self.waitUntilSettled()
+        self.launch_server.hold_jobs_in_build = False
+        self.launch_server.release()
+        self.waitUntilSettled()
+
+        self.assertEqual(A.reported, 1)
+        self.assertEqual(B.reported, 1)
+        self.assertEqual(self.history[0].result, 'ABORTED')
+        self.assertEqual(self.history[0].changes, '1,1')
+        self.assertEqual(self.history[1].result, 'ABORTED')
+        self.assertEqual(self.history[1].changes, '1,1 2,1')
+        self.assertEqual(self.history[2].result, 'SUCCESS')
+        self.assertEqual(self.history[2].changes, '1,2')
+        self.assertEqual(self.history[3].result, 'SUCCESS')
+        self.assertEqual(self.history[3].changes, '1,1 2,2')
+
+    def test_abandoned_gate(self):
+        "Test that an abandoned change is dequeued from gate"
+
+        self.launch_server.hold_jobs_in_build = True
+
+        A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
+        A.addApproval('code-review', 2)
+        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
+        self.waitUntilSettled()
+        self.assertEqual(len(self.builds), 1, "One job being built (on hold)")
+        self.assertEqual(self.builds[0].name, 'project-merge')
+
+        self.fake_gerrit.addEvent(A.getChangeAbandonedEvent())
+        self.waitUntilSettled()
+
+        self.launch_server.release('.*-merge')
+        self.waitUntilSettled()
+
+        self.assertBuilds([])
+        self.assertHistory([
+            dict(name='project-merge', result='ABORTED', changes='1,1')],
+            ordered=False)
+        self.assertEqual(A.reported, 1,
+                         "Abandoned gate change should report only start")
+
+    def test_abandoned_check(self):
+        "Test that an abandoned change is dequeued from check"
+
+        self.launch_server.hold_jobs_in_build = True
+
+        A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
+        B = self.fake_gerrit.addFakeChange('org/project', 'master', 'B')
+        tenant = self.sched.abide.tenants.get('tenant-one')
+        check_pipeline = tenant.layout.pipelines['check']
+
+        # Add two git-dependent changes
+        B.setDependsOn(A, 1)
+        self.fake_gerrit.addEvent(B.getPatchsetCreatedEvent(1))
+        self.waitUntilSettled()
+        self.fake_gerrit.addEvent(A.getPatchsetCreatedEvent(1))
+        self.waitUntilSettled()
+        # A live item, and a non-live/live pair
+        items = check_pipeline.getAllItems()
+        self.assertEqual(len(items), 3)
+
+        self.assertEqual(items[0].change.number, '1')
+        self.assertFalse(items[0].live)
+
+        self.assertEqual(items[1].change.number, '2')
+        self.assertTrue(items[1].live)
+
+        self.assertEqual(items[2].change.number, '1')
+        self.assertTrue(items[2].live)
+
+        # Abandon A
+        self.fake_gerrit.addEvent(A.getChangeAbandonedEvent())
+        self.waitUntilSettled()
+
+        # The live copy of A should be gone, but the non-live and B
+        # should continue
+        items = check_pipeline.getAllItems()
+        self.assertEqual(len(items), 2)
+
+        self.assertEqual(items[0].change.number, '1')
+        self.assertFalse(items[0].live)
+
+        self.assertEqual(items[1].change.number, '2')
+        self.assertTrue(items[1].live)
+
+        self.launch_server.hold_jobs_in_build = False
+        self.launch_server.release()
+        self.waitUntilSettled()
+
+        self.assertEqual(len(self.history), 4)
+        self.assertEqual(self.history[0].result, 'ABORTED',
+                         'Build should have been aborted')
+        self.assertEqual(A.reported, 0, "Abandoned change should not report")
+        self.assertEqual(B.reported, 1, "Change should report")
+
+    @skip("Disabled for early v3 development")
+    def test_abandoned_not_timer(self):
+        "Test that an abandoned change does not cancel timer jobs"
+
+        self.launch_server.hold_jobs_in_build = True
+
+        # Start timer trigger - also org/project
+        self.updateConfigLayout(
+            'tests/fixtures/layout-idle.yaml')
+        self.sched.reconfigure(self.config)
+        self.registerJobs()
+        # The pipeline triggers every second, so we should have seen
+        # several by now.
+        time.sleep(5)
+        self.waitUntilSettled()
+        # Stop queuing timer triggered jobs so that the assertions
+        # below don't race against more jobs being queued.
+        self.updateConfigLayout(
+            'tests/fixtures/layout-no-timer.yaml')
+        self.sched.reconfigure(self.config)
+        self.registerJobs()
+        self.assertEqual(len(self.builds), 2, "Two timer jobs")
+
+        A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
+        self.fake_gerrit.addEvent(A.getPatchsetCreatedEvent(1))
+        self.waitUntilSettled()
+        self.assertEqual(len(self.builds), 3, "One change plus two timer jobs")
+
+        self.fake_gerrit.addEvent(A.getChangeAbandonedEvent())
+        self.waitUntilSettled()
+
+        self.assertEqual(len(self.builds), 2, "Two timer jobs remain")
+
+        self.launch_server.release()
+        self.waitUntilSettled()
+
+    def test_zuul_url_return(self):
+        "Test if ZUUL_URL is returning when zuul_url is set in zuul.conf"
+        self.assertTrue(self.sched.config.has_option('merger', 'zuul_url'))
+        self.launch_server.hold_jobs_in_build = True
+
+        A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
+        A.addApproval('code-review', 2)
+        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
+        self.waitUntilSettled()
+
+        self.assertEqual(len(self.builds), 1)
+        for build in self.builds:
+            self.assertTrue('ZUUL_URL' in build.parameters)
+
+        self.launch_server.hold_jobs_in_build = False
+        self.launch_server.release()
+        self.waitUntilSettled()
+
+    def test_new_patchset_dequeues_old_on_head(self):
+        "Test that a new patchset causes the old to be dequeued (at head)"
+        # D -> C (depends on B) -> B (depends on A) -> A -> M
+        self.launch_server.hold_jobs_in_build = True
+        M = self.fake_gerrit.addFakeChange('org/project', 'master', 'M')
+        M.setMerged()
+        A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
+        B = self.fake_gerrit.addFakeChange('org/project', 'master', 'B')
+        C = self.fake_gerrit.addFakeChange('org/project', 'master', 'C')
+        D = self.fake_gerrit.addFakeChange('org/project', 'master', 'D')
+        A.addApproval('code-review', 2)
+        B.addApproval('code-review', 2)
+        C.addApproval('code-review', 2)
+        D.addApproval('code-review', 2)
+
+        C.setDependsOn(B, 1)
+        B.setDependsOn(A, 1)
+        A.setDependsOn(M, 1)
+
+        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
+        self.fake_gerrit.addEvent(B.addApproval('approved', 1))
+        self.fake_gerrit.addEvent(C.addApproval('approved', 1))
+        self.fake_gerrit.addEvent(D.addApproval('approved', 1))
+        self.waitUntilSettled()
+
+        A.addPatchset()
+        self.fake_gerrit.addEvent(A.getPatchsetCreatedEvent(2))
+        self.waitUntilSettled()
+
+        self.launch_server.hold_jobs_in_build = False
+        self.launch_server.release()
+        self.waitUntilSettled()
+
+        self.assertEqual(A.data['status'], 'NEW')
+        self.assertEqual(A.reported, 2)
+        self.assertEqual(B.data['status'], 'NEW')
+        self.assertEqual(B.reported, 2)
+        self.assertEqual(C.data['status'], 'NEW')
+        self.assertEqual(C.reported, 2)
+        self.assertEqual(D.data['status'], 'MERGED')
+        self.assertEqual(D.reported, 2)
+        self.assertEqual(len(self.history), 7)
+
+    def test_new_patchset_dequeues_old_without_dependents(self):
+        "Test that a new patchset causes only the old to be dequeued"
+        self.launch_server.hold_jobs_in_build = True
+        A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
+        B = self.fake_gerrit.addFakeChange('org/project', 'master', 'B')
+        C = self.fake_gerrit.addFakeChange('org/project', 'master', 'C')
+        A.addApproval('code-review', 2)
+        B.addApproval('code-review', 2)
+        C.addApproval('code-review', 2)
+
+        self.fake_gerrit.addEvent(C.addApproval('approved', 1))
+        self.fake_gerrit.addEvent(B.addApproval('approved', 1))
+        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
+        self.waitUntilSettled()
+
+        B.addPatchset()
+        self.fake_gerrit.addEvent(B.getPatchsetCreatedEvent(2))
+        self.waitUntilSettled()
+
+        self.launch_server.hold_jobs_in_build = False
+        self.launch_server.release()
+        self.waitUntilSettled()
+
+        self.assertEqual(A.data['status'], 'MERGED')
+        self.assertEqual(A.reported, 2)
+        self.assertEqual(B.data['status'], 'NEW')
+        self.assertEqual(B.reported, 2)
+        self.assertEqual(C.data['status'], 'MERGED')
+        self.assertEqual(C.reported, 2)
+        self.assertEqual(len(self.history), 9)
+
+    def test_new_patchset_dequeues_old_independent_queue(self):
+        "Test that a new patchset causes the old to be dequeued (independent)"
+        self.launch_server.hold_jobs_in_build = True
+        A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
+        B = self.fake_gerrit.addFakeChange('org/project', 'master', 'B')
+        C = self.fake_gerrit.addFakeChange('org/project', 'master', 'C')
+        self.fake_gerrit.addEvent(A.getPatchsetCreatedEvent(1))
+        self.fake_gerrit.addEvent(B.getPatchsetCreatedEvent(1))
+        self.fake_gerrit.addEvent(C.getPatchsetCreatedEvent(1))
+        self.waitUntilSettled()
+
+        B.addPatchset()
+        self.fake_gerrit.addEvent(B.getPatchsetCreatedEvent(2))
+        self.waitUntilSettled()
+
+        self.launch_server.hold_jobs_in_build = False
+        self.launch_server.release()
+        self.waitUntilSettled()
+
+        self.assertEqual(A.data['status'], 'NEW')
+        self.assertEqual(A.reported, 1)
+        self.assertEqual(B.data['status'], 'NEW')
+        self.assertEqual(B.reported, 1)
+        self.assertEqual(C.data['status'], 'NEW')
+        self.assertEqual(C.reported, 1)
+        self.assertEqual(len(self.history), 10)
+        self.assertEqual(self.countJobResults(self.history, 'ABORTED'), 1)
+
+    def test_noop_job(self):
+        "Test that the internal noop job works"
+        A = self.fake_gerrit.addFakeChange('org/noop-project', 'master', 'A')
+        A.addApproval('code-review', 2)
+        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
+        self.waitUntilSettled()
+
+        self.assertEqual(len(self.gearman_server.getQueue()), 0)
+        self.assertTrue(self.sched._areAllBuildsComplete())
+        self.assertEqual(len(self.history), 0)
+        self.assertEqual(A.data['status'], 'MERGED')
+        self.assertEqual(A.reported, 2)
+
+    def test_no_job_project(self):
+        "Test that reports with no jobs don't get sent"
+        A = self.fake_gerrit.addFakeChange('org/no-jobs-project',
+                                           'master', 'A')
+        self.fake_gerrit.addEvent(A.getPatchsetCreatedEvent(1))
+        self.waitUntilSettled()
+
+        # Change wasn't reported to
+        self.assertEqual(A.reported, False)
+
+        # Check queue is empty afterwards
+        tenant = self.sched.abide.tenants.get('tenant-one')
+        check_pipeline = tenant.layout.pipelines['check']
+        items = check_pipeline.getAllItems()
+        self.assertEqual(len(items), 0)
+
+        self.assertEqual(len(self.history), 0)
+
+    def test_zuul_refs(self):
+        "Test that zuul refs exist and have the right changes"
+        self.launch_server.hold_jobs_in_build = True
+        M1 = self.fake_gerrit.addFakeChange('org/project1', 'master', 'M1')
+        M1.setMerged()
+        M2 = self.fake_gerrit.addFakeChange('org/project2', 'master', 'M2')
+        M2.setMerged()
+
+        A = self.fake_gerrit.addFakeChange('org/project1', 'master', 'A')
+        B = self.fake_gerrit.addFakeChange('org/project1', 'master', 'B')
+        C = self.fake_gerrit.addFakeChange('org/project2', 'master', 'C')
+        D = self.fake_gerrit.addFakeChange('org/project2', 'master', 'D')
+        A.addApproval('code-review', 2)
+        B.addApproval('code-review', 2)
+        C.addApproval('code-review', 2)
+        D.addApproval('code-review', 2)
+        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
+        self.fake_gerrit.addEvent(B.addApproval('approved', 1))
+        self.fake_gerrit.addEvent(C.addApproval('approved', 1))
+        self.fake_gerrit.addEvent(D.addApproval('approved', 1))
+
+        self.waitUntilSettled()
+        self.launch_server.release('.*-merge')
+        self.waitUntilSettled()
+        self.launch_server.release('.*-merge')
+        self.waitUntilSettled()
+        self.launch_server.release('.*-merge')
+        self.waitUntilSettled()
+        self.launch_server.release('.*-merge')
+        self.waitUntilSettled()
+
+        a_zref = b_zref = c_zref = d_zref = None
+        a_build = b_build = c_build = d_build = None
+        for x in self.builds:
+            if x.parameters['ZUUL_CHANGE'] == '3':
+                a_zref = x.parameters['ZUUL_REF']
+                a_build = x
+            elif x.parameters['ZUUL_CHANGE'] == '4':
+                b_zref = x.parameters['ZUUL_REF']
+                b_build = x
+            elif x.parameters['ZUUL_CHANGE'] == '5':
+                c_zref = x.parameters['ZUUL_REF']
+                c_build = x
+            elif x.parameters['ZUUL_CHANGE'] == '6':
+                d_zref = x.parameters['ZUUL_REF']
+                d_build = x
+            if a_build and b_build and c_build and d_build:
+                break
+
+        # There are... four... refs.
+        self.assertIsNotNone(a_zref)
+        self.assertIsNotNone(b_zref)
+        self.assertIsNotNone(c_zref)
+        self.assertIsNotNone(d_zref)
+
+        # And they should all be different
+        refs = set([a_zref, b_zref, c_zref, d_zref])
+        self.assertEqual(len(refs), 4)
+
+        # should have a, not b, and should not be in project2
+        self.assertTrue(a_build.hasChanges(A))
+        self.assertFalse(a_build.hasChanges(B, M2))
+
+        # should have a and b, and should not be in project2
+        self.assertTrue(b_build.hasChanges(A, B))
+        self.assertFalse(b_build.hasChanges(M2))
+
+        # should have a and b in 1, c in 2
+        self.assertTrue(c_build.hasChanges(A, B, C))
+        self.assertFalse(c_build.hasChanges(D))
+
+        # should have a and b in 1, c and d in 2
+        self.assertTrue(d_build.hasChanges(A, B, C, D))
+
+        self.launch_server.hold_jobs_in_build = False
+        self.launch_server.release()
+        self.waitUntilSettled()
+
+        self.assertEqual(A.data['status'], 'MERGED')
+        self.assertEqual(A.reported, 2)
+        self.assertEqual(B.data['status'], 'MERGED')
+        self.assertEqual(B.reported, 2)
+        self.assertEqual(C.data['status'], 'MERGED')
+        self.assertEqual(C.reported, 2)
+        self.assertEqual(D.data['status'], 'MERGED')
+        self.assertEqual(D.reported, 2)
+
+    def test_rerun_on_error(self):
+        "Test that if a worker fails to run a job, it is run again"
+        self.launch_server.hold_jobs_in_build = True
+        A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
+        A.addApproval('code-review', 2)
+        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
+        self.waitUntilSettled()
+
+        self.builds[0].run_error = True
+        self.launch_server.hold_jobs_in_build = False
+        self.launch_server.release()
+        self.waitUntilSettled()
+        self.assertEqual(self.countJobResults(self.history, 'RUN_ERROR'), 1)
+        self.assertEqual(self.countJobResults(self.history, 'SUCCESS'), 3)
+
+    def test_statsd(self):
+        "Test each of the statsd methods used in the scheduler"
+        import extras
+        statsd = extras.try_import('statsd.statsd')
+        statsd.incr('test-incr')
+        statsd.timing('test-timing', 3)
+        statsd.gauge('test-gauge', 12)
+        self.assertReportedStat('test-incr', '1|c')
+        self.assertReportedStat('test-timing', '3|ms')
+        self.assertReportedStat('test-gauge', '12|g')
+
+    @skip("Disabled for early v3 development")
+    def test_stuck_job_cleanup(self):
+        "Test that pending jobs are cleaned up if removed from layout"
+        # This job won't be registered at startup because it is not in
+        # the standard layout, but we need it to already be registerd
+        # for when we reconfigure, as that is when Zuul will attempt
+        # to run the new job.
+        self.worker.registerFunction('build:gate-noop')
+        self.gearman_server.hold_jobs_in_queue = True
+        A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
+        A.addApproval('code-review', 2)
+        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
+        self.waitUntilSettled()
+        self.assertEqual(len(self.gearman_server.getQueue()), 1)
+
+        self.updateConfigLayout(
+            'tests/fixtures/layout-no-jobs.yaml')
+        self.sched.reconfigure(self.config)
+        self.waitUntilSettled()
+
+        self.gearman_server.release('gate-noop')
+        self.waitUntilSettled()
+        self.assertEqual(len(self.gearman_server.getQueue()), 0)
+        self.assertTrue(self.sched._areAllBuildsComplete())
+
+        self.assertEqual(len(self.history), 1)
+        self.assertEqual(self.history[0].name, 'gate-noop')
+        self.assertEqual(self.history[0].result, 'SUCCESS')
+
+    def test_file_head(self):
+        # This is a regression test for an observed bug.  A change
+        # with a file named "HEAD" in the root directory of the repo
+        # was processed by a merger.  It then was unable to reset the
+        # repo because of:
+        #   GitCommandError: 'git reset --hard HEAD' returned
+        #       with exit code 128
+        #   stderr: 'fatal: ambiguous argument 'HEAD': both revision
+        #       and filename
+        #   Use '--' to separate filenames from revisions'
+
+        A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
+        A.addPatchset({'HEAD': ''})
+        B = self.fake_gerrit.addFakeChange('org/project', 'master', 'B')
+
+        self.fake_gerrit.addEvent(A.getPatchsetCreatedEvent(2))
+        self.waitUntilSettled()
+
+        self.fake_gerrit.addEvent(B.getPatchsetCreatedEvent(1))
+        self.waitUntilSettled()
+
+        self.assertIn('Build succeeded', A.messages[0])
+        self.assertIn('Build succeeded', B.messages[0])
+
+    @skip("Disabled for early v3 development")
+    def test_file_jobs(self):
+        "Test that file jobs run only when appropriate"
+        A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
+        A.addPatchset(['pip-requires'])
+        B = self.fake_gerrit.addFakeChange('org/project', 'master', 'B')
+        A.addApproval('code-review', 2)
+        B.addApproval('code-review', 2)
+        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
+        self.fake_gerrit.addEvent(B.addApproval('approved', 1))
+        self.waitUntilSettled()
+
+        testfile_jobs = [x for x in self.history
+                         if x.name == 'project-testfile']
+
+        self.assertEqual(len(testfile_jobs), 1)
+        self.assertEqual(testfile_jobs[0].changes, '1,2')
+        self.assertEqual(A.data['status'], 'MERGED')
+        self.assertEqual(A.reported, 2)
+        self.assertEqual(B.data['status'], 'MERGED')
+        self.assertEqual(B.reported, 2)
+
+    def _test_irrelevant_files_jobs(self, should_skip):
+        "Test that jobs with irrelevant-files filter run only when appropriate"
+        self.updateConfigLayout('layout-irrelevant-files')
+        self.sched.reconfigure(self.config)
+
+        if should_skip:
+            files = {'ignoreme': 'ignored\n'}
+        else:
+            files = {'respectme': 'please!\n'}
+
+        change = self.fake_gerrit.addFakeChange('org/project',
+                                                'master',
+                                                'test irrelevant-files',
+                                                files=files)
+        self.fake_gerrit.addEvent(change.getPatchsetCreatedEvent(1))
+        self.waitUntilSettled()
+
+        tested_change_ids = [x.changes[0] for x in self.history
+                             if x.name == 'project-test-irrelevant-files']
+
+        if should_skip:
+            self.assertEqual([], tested_change_ids)
+        else:
+            self.assertIn(change.data['number'], tested_change_ids)
+
+    def test_irrelevant_files_match_skips_job(self):
+        self._test_irrelevant_files_jobs(should_skip=True)
+
+    def test_irrelevant_files_no_match_runs_job(self):
+        self._test_irrelevant_files_jobs(should_skip=False)
+
+    def test_inherited_jobs_keep_matchers(self):
+        self.updateConfigLayout('layout-inheritance')
+        self.sched.reconfigure(self.config)
+
+        files = {'ignoreme': 'ignored\n'}
+
+        change = self.fake_gerrit.addFakeChange('org/project',
+                                                'master',
+                                                'test irrelevant-files',
+                                                files=files)
+        self.fake_gerrit.addEvent(change.getPatchsetCreatedEvent(1))
+        self.waitUntilSettled()
+
+        run_jobs = set([build.name for build in self.history])
+
+        self.assertEqual(set(['project-test-nomatch-starts-empty',
+                              'project-test-nomatch-starts-full']), run_jobs)
+
+    @skip("Disabled for early v3 development")
+    def test_test_config(self):
+        "Test that we can test the config"
+        self.sched.testConfig(self.config.get('zuul', 'tenant_config'),
+                              self.connections)
+
+    @skip("Disabled for early v3 development")
+    def test_queue_names(self):
+        "Test shared change queue names"
+        project1 = self.sched.layout.projects['org/project1']
+        project2 = self.sched.layout.projects['org/project2']
+        q1 = self.sched.layout.pipelines['gate'].getQueue(project1)
+        q2 = self.sched.layout.pipelines['gate'].getQueue(project2)
+        self.assertEqual(q1.name, 'integration')
+        self.assertEqual(q2.name, 'integration')
+
+        self.updateConfigLayout(
+            'tests/fixtures/layout-bad-queue.yaml')
+        with testtools.ExpectedException(
+            Exception, "More than one name assigned to change queue"):
+            self.sched.reconfigure(self.config)
+
+    def test_queue_precedence(self):
+        "Test that queue precedence works"
+
+        self.gearman_server.hold_jobs_in_queue = True
+        self.launch_server.hold_jobs_in_build = True
+        A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
+        self.fake_gerrit.addEvent(A.getPatchsetCreatedEvent(1))
+        A.addApproval('code-review', 2)
+        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
+
+        self.waitUntilSettled()
+        self.gearman_server.hold_jobs_in_queue = False
+        self.gearman_server.release()
+        self.waitUntilSettled()
+
+        # Run one build at a time to ensure non-race order:
+        self.orderedRelease()
+        self.launch_server.hold_jobs_in_build = False
+        self.waitUntilSettled()
+
+        self.log.debug(self.history)
+        self.assertEqual(self.history[0].pipeline, 'gate')
+        self.assertEqual(self.history[1].pipeline, 'check')
+        self.assertEqual(self.history[2].pipeline, 'gate')
+        self.assertEqual(self.history[3].pipeline, 'gate')
+        self.assertEqual(self.history[4].pipeline, 'check')
+        self.assertEqual(self.history[5].pipeline, 'check')
+
+    @skip("Disabled for early v3 development")
+    def test_json_status(self):
+        "Test that we can retrieve JSON status info"
+        self.launch_server.hold_jobs_in_build = True
+        A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
+        A.addApproval('code-review', 2)
+        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
+        self.waitUntilSettled()
+
+        self.launch_server.release('project-merge')
+        self.waitUntilSettled()
+
+        port = self.webapp.server.socket.getsockname()[1]
+
+        req = urllib.request.Request("http://localhost:%s/status.json" % port)
+        f = urllib.request.urlopen(req)
+        headers = f.info()
+        self.assertIn('Content-Length', headers)
+        self.assertIn('Content-Type', headers)
+        self.assertIsNotNone(re.match('^application/json(; charset=UTF-8)?$',
+                                      headers['Content-Type']))
+        self.assertIn('Access-Control-Allow-Origin', headers)
+        self.assertIn('Cache-Control', headers)
+        self.assertIn('Last-Modified', headers)
+        self.assertIn('Expires', headers)
+        data = f.read()
+
+        self.launch_server.hold_jobs_in_build = False
+        self.launch_server.release()
+        self.waitUntilSettled()
+
+        data = json.loads(data)
+        status_jobs = []
+        for p in data['pipelines']:
+            for q in p['change_queues']:
+                if p['name'] in ['gate', 'conflict']:
+                    self.assertEqual(q['window'], 20)
+                else:
+                    self.assertEqual(q['window'], 0)
+                for head in q['heads']:
+                    for change in head:
+                        self.assertTrue(change['active'])
+                        self.assertEqual(change['id'], '1,1')
+                        for job in change['jobs']:
+                            status_jobs.append(job)
+        self.assertEqual('project-merge', status_jobs[0]['name'])
+        self.assertEqual('https://server/job/project-merge/0/',
+                         status_jobs[0]['url'])
+        self.assertEqual('http://logs.example.com/1/1/gate/project-merge/0',
+                         status_jobs[0]['report_url'])
+
+        self.assertEqual('project-test1', status_jobs[1]['name'])
+        self.assertEqual('https://server/job/project-test1/1/',
+                         status_jobs[1]['url'])
+        self.assertEqual('http://logs.example.com/1/1/gate/project-test1/1',
+                         status_jobs[1]['report_url'])
+
+        self.assertEqual('project-test2', status_jobs[2]['name'])
+        self.assertEqual('https://server/job/project-test2/2/',
+                         status_jobs[2]['url'])
+        self.assertEqual('http://logs.example.com/1/1/gate/project-test2/2',
+                         status_jobs[2]['report_url'])
+
+    @skip("Disabled for early v3 development")
+    def test_merging_queues(self):
+        "Test that transitively-connected change queues are merged"
+        self.updateConfigLayout(
+            'tests/fixtures/layout-merge-queues.yaml')
+        self.sched.reconfigure(self.config)
+        self.assertEqual(len(self.sched.layout.pipelines['gate'].queues), 1)
+
+    @skip("Disabled for early v3 development")
+    def test_mutex(self):
+        "Test job mutexes"
+        self.config.set('zuul', 'layout_config',
+                        'tests/fixtures/layout-mutex.yaml')
+        self.sched.reconfigure(self.config)
+
+        self.launch_server.hold_jobs_in_build = True
+        A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
+        B = self.fake_gerrit.addFakeChange('org/project', 'master', 'B')
+        self.assertFalse('test-mutex' in self.sched.mutex.mutexes)
+
+        self.fake_gerrit.addEvent(A.getPatchsetCreatedEvent(1))
+        self.fake_gerrit.addEvent(B.getPatchsetCreatedEvent(1))
+        self.waitUntilSettled()
+        self.assertEqual(len(self.builds), 3)
+        self.assertEqual(self.builds[0].name, 'project-test1')
+        self.assertEqual(self.builds[1].name, 'mutex-one')
+        self.assertEqual(self.builds[2].name, 'project-test1')
+
+        self.launch_server.release('mutex-one')
+        self.waitUntilSettled()
+
+        self.assertEqual(len(self.builds), 3)
+        self.assertEqual(self.builds[0].name, 'project-test1')
+        self.assertEqual(self.builds[1].name, 'project-test1')
+        self.assertEqual(self.builds[2].name, 'mutex-two')
+        self.assertTrue('test-mutex' in self.sched.mutex.mutexes)
+
+        self.launch_server.release('mutex-two')
+        self.waitUntilSettled()
+
+        self.assertEqual(len(self.builds), 3)
+        self.assertEqual(self.builds[0].name, 'project-test1')
+        self.assertEqual(self.builds[1].name, 'project-test1')
+        self.assertEqual(self.builds[2].name, 'mutex-one')
+        self.assertTrue('test-mutex' in self.sched.mutex.mutexes)
+
+        self.launch_server.release('mutex-one')
+        self.waitUntilSettled()
+
+        self.assertEqual(len(self.builds), 3)
+        self.assertEqual(self.builds[0].name, 'project-test1')
+        self.assertEqual(self.builds[1].name, 'project-test1')
+        self.assertEqual(self.builds[2].name, 'mutex-two')
+        self.assertTrue('test-mutex' in self.sched.mutex.mutexes)
+
+        self.launch_server.release('mutex-two')
+        self.waitUntilSettled()
+
+        self.assertEqual(len(self.builds), 2)
+        self.assertEqual(self.builds[0].name, 'project-test1')
+        self.assertEqual(self.builds[1].name, 'project-test1')
+        self.assertFalse('test-mutex' in self.sched.mutex.mutexes)
+
+        self.launch_server.hold_jobs_in_build = False
+        self.launch_server.release()
+
+        self.waitUntilSettled()
+        self.assertEqual(len(self.builds), 0)
+
+        self.assertEqual(A.reported, 1)
+        self.assertEqual(B.reported, 1)
+        self.assertFalse('test-mutex' in self.sched.mutex.mutexes)
+
+    @skip("Disabled for early v3 development")
+    def test_node_label(self):
+        "Test that a job runs on a specific node label"
+        self.worker.registerFunction('build:node-project-test1:debian')
+
+        A = self.fake_gerrit.addFakeChange('org/node-project', 'master', 'A')
+        A.addApproval('code-review', 2)
+        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
+        self.waitUntilSettled()
+
+        self.assertIsNone(self.getJobFromHistory('node-project-merge').node)
+        self.assertEqual(self.getJobFromHistory('node-project-test1').node,
+                         'debian')
+        self.assertIsNone(self.getJobFromHistory('node-project-test2').node)
+
+    def test_live_reconfiguration(self):
+        "Test that live reconfiguration works"
+        self.launch_server.hold_jobs_in_build = True
+        A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
+        A.addApproval('code-review', 2)
+        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
+        self.waitUntilSettled()
+
+        self.sched.reconfigure(self.config)
+
+        self.launch_server.hold_jobs_in_build = False
+        self.launch_server.release()
+        self.waitUntilSettled()
+        self.assertEqual(self.getJobFromHistory('project-merge').result,
+                         'SUCCESS')
+        self.assertEqual(self.getJobFromHistory('project-test1').result,
+                         'SUCCESS')
+        self.assertEqual(self.getJobFromHistory('project-test2').result,
+                         'SUCCESS')
+        self.assertEqual(A.data['status'], 'MERGED')
+        self.assertEqual(A.reported, 2)
+
+    @skip("Disabled for early v3 development")
+    def test_live_reconfiguration_merge_conflict(self):
+        # A real-world bug: a change in a gate queue has a merge
+        # conflict and a job is added to its project while it's
+        # sitting in the queue.  The job gets added to the change and
+        # enqueued and the change gets stuck.
+        self.worker.registerFunction('build:project-test3')
+        self.launch_server.hold_jobs_in_build = True
+
+        # This change is fine.  It's here to stop the queue long
+        # enough for the next change to be subject to the
+        # reconfiguration, as well as to provide a conflict for the
+        # next change.  This change will succeed and merge.
+        A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
+        A.addPatchset(['conflict'])
+        A.addApproval('code-review', 2)
+
+        # This change will be in merge conflict.  During the
+        # reconfiguration, we will add a job.  We want to make sure
+        # that doesn't cause it to get stuck.
+        B = self.fake_gerrit.addFakeChange('org/project', 'master', 'B')
+        B.addPatchset(['conflict'])
+        B.addApproval('code-review', 2)
+
+        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
+        self.fake_gerrit.addEvent(B.addApproval('approved', 1))
+
+        self.waitUntilSettled()
+
+        # No jobs have run yet
+        self.assertEqual(A.data['status'], 'NEW')
+        self.assertEqual(A.reported, 1)
+        self.assertEqual(B.data['status'], 'NEW')
+        self.assertEqual(B.reported, 1)
+        self.assertEqual(len(self.history), 0)
+
+        # Add the "project-test3" job.
+        self.updateConfigLayout(
+            'tests/fixtures/layout-live-reconfiguration-add-job.yaml')
+        self.sched.reconfigure(self.config)
+        self.waitUntilSettled()
+
+        self.launch_server.hold_jobs_in_build = False
+        self.launch_server.release()
+        self.waitUntilSettled()
+
+        self.assertEqual(A.data['status'], 'MERGED')
+        self.assertEqual(A.reported, 2)
+        self.assertEqual(B.data['status'], 'NEW')
+        self.assertEqual(B.reported, 2)
+        self.assertEqual(self.getJobFromHistory('project-merge').result,
+                         'SUCCESS')
+        self.assertEqual(self.getJobFromHistory('project-test1').result,
+                         'SUCCESS')
+        self.assertEqual(self.getJobFromHistory('project-test2').result,
+                         'SUCCESS')
+        self.assertEqual(self.getJobFromHistory('project-test3').result,
+                         'SUCCESS')
+        self.assertEqual(len(self.history), 4)
+
+    @skip("Disabled for early v3 development")
+    def test_live_reconfiguration_failed_root(self):
+        # An extrapolation of test_live_reconfiguration_merge_conflict
+        # that tests a job added to a job tree with a failed root does
+        # not run.
+        self.worker.registerFunction('build:project-test3')
+        self.launch_server.hold_jobs_in_build = True
+
+        # This change is fine.  It's here to stop the queue long
+        # enough for the next change to be subject to the
+        # reconfiguration.  This change will succeed and merge.
+        A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
+        A.addPatchset(['conflict'])
+        A.addApproval('code-review', 2)
+        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
+        self.waitUntilSettled()
+        self.launch_server.release('.*-merge')
+        self.waitUntilSettled()
+
+        B = self.fake_gerrit.addFakeChange('org/project', 'master', 'B')
+        self.launch_server.failJob('project-merge', B)
+        B.addApproval('code-review', 2)
+        self.fake_gerrit.addEvent(B.addApproval('approved', 1))
+        self.waitUntilSettled()
+
+        self.launch_server.release('.*-merge')
+        self.waitUntilSettled()
+
+        # Both -merge jobs have run, but no others.
+        self.assertEqual(A.data['status'], 'NEW')
+        self.assertEqual(A.reported, 1)
+        self.assertEqual(B.data['status'], 'NEW')
+        self.assertEqual(B.reported, 1)
+        self.assertEqual(self.history[0].result, 'SUCCESS')
+        self.assertEqual(self.history[0].name, 'project-merge')
+        self.assertEqual(self.history[1].result, 'FAILURE')
+        self.assertEqual(self.history[1].name, 'project-merge')
+        self.assertEqual(len(self.history), 2)
+
+        # Add the "project-test3" job.
+        self.updateConfigLayout(
+            'tests/fixtures/layout-live-reconfiguration-add-job.yaml')
+        self.sched.reconfigure(self.config)
+        self.waitUntilSettled()
+
+        self.launch_server.hold_jobs_in_build = False
+        self.launch_server.release()
+        self.waitUntilSettled()
+
+        self.assertEqual(A.data['status'], 'MERGED')
+        self.assertEqual(A.reported, 2)
+        self.assertEqual(B.data['status'], 'NEW')
+        self.assertEqual(B.reported, 2)
+        self.assertEqual(self.history[0].result, 'SUCCESS')
+        self.assertEqual(self.history[0].name, 'project-merge')
+        self.assertEqual(self.history[1].result, 'FAILURE')
+        self.assertEqual(self.history[1].name, 'project-merge')
+        self.assertEqual(self.history[2].result, 'SUCCESS')
+        self.assertEqual(self.history[3].result, 'SUCCESS')
+        self.assertEqual(self.history[4].result, 'SUCCESS')
+        self.assertEqual(len(self.history), 5)
+
+    @skip("Disabled for early v3 development")
+    def test_live_reconfiguration_failed_job(self):
+        # Test that a change with a removed failing job does not
+        # disrupt reconfiguration.  If a change has a failed job and
+        # that job is removed during a reconfiguration, we observed a
+        # bug where the code to re-set build statuses would run on
+        # that build and raise an exception because the job no longer
+        # existed.
+        self.launch_server.hold_jobs_in_build = True
+
+        A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
+
+        # This change will fail and later be removed by the reconfiguration.
+        self.launch_server.failJob('project-test1', A)
+
+        self.fake_gerrit.addEvent(A.getPatchsetCreatedEvent(1))
+        self.waitUntilSettled()
+        self.launch_server.release('.*-merge')
+        self.waitUntilSettled()
+        self.launch_server.release('project-test1')
+        self.waitUntilSettled()
+
+        self.assertEqual(A.data['status'], 'NEW')
+        self.assertEqual(A.reported, 0)
+
+        self.assertEqual(self.getJobFromHistory('project-merge').result,
+                         'SUCCESS')
+        self.assertEqual(self.getJobFromHistory('project-test1').result,
+                         'FAILURE')
+        self.assertEqual(len(self.history), 2)
+
+        # Remove the test1 job.
+        self.updateConfigLayout(
+            'tests/fixtures/layout-live-reconfiguration-failed-job.yaml')
+        self.sched.reconfigure(self.config)
+        self.waitUntilSettled()
+
+        self.launch_server.hold_jobs_in_build = False
+        self.launch_server.release()
+        self.waitUntilSettled()
+
+        self.assertEqual(self.getJobFromHistory('project-test2').result,
+                         'SUCCESS')
+        self.assertEqual(self.getJobFromHistory('project-testfile').result,
+                         'SUCCESS')
+        self.assertEqual(len(self.history), 4)
+
+        self.assertEqual(A.data['status'], 'NEW')
+        self.assertEqual(A.reported, 1)
+        self.assertIn('Build succeeded', A.messages[0])
+        # Ensure the removed job was not included in the report.
+        self.assertNotIn('project-test1', A.messages[0])
+
+    @skip("Disabled for early v3 development")
+    def test_live_reconfiguration_shared_queue(self):
+        # Test that a change with a failing job which was removed from
+        # this project but otherwise still exists in the system does
+        # not disrupt reconfiguration.
+
+        self.launch_server.hold_jobs_in_build = True
+
+        A = self.fake_gerrit.addFakeChange('org/project1', 'master', 'A')
+
+        self.launch_server.failJob('project1-project2-integration', A)
+
+        self.fake_gerrit.addEvent(A.getPatchsetCreatedEvent(1))
+        self.waitUntilSettled()
+        self.launch_server.release('.*-merge')
+        self.waitUntilSettled()
+        self.launch_server.release('project1-project2-integration')
+        self.waitUntilSettled()
+
+        self.assertEqual(A.data['status'], 'NEW')
+        self.assertEqual(A.reported, 0)
+
+        self.assertEqual(self.getJobFromHistory('project1-merge').result,
+                         'SUCCESS')
+        self.assertEqual(self.getJobFromHistory(
+            'project1-project2-integration').result, 'FAILURE')
+        self.assertEqual(len(self.history), 2)
+
+        # Remove the integration job.
+        self.updateConfigLayout(
+            'tests/fixtures/layout-live-reconfiguration-shared-queue.yaml')
+        self.sched.reconfigure(self.config)
+        self.waitUntilSettled()
+
+        self.launch_server.hold_jobs_in_build = False
+        self.launch_server.release()
+        self.waitUntilSettled()
+
+        self.assertEqual(self.getJobFromHistory('project1-merge').result,
+                         'SUCCESS')
+        self.assertEqual(self.getJobFromHistory('project1-test1').result,
+                         'SUCCESS')
+        self.assertEqual(self.getJobFromHistory('project1-test2').result,
+                         'SUCCESS')
+        self.assertEqual(self.getJobFromHistory(
+            'project1-project2-integration').result, 'FAILURE')
+        self.assertEqual(len(self.history), 4)
+
+        self.assertEqual(A.data['status'], 'NEW')
+        self.assertEqual(A.reported, 1)
+        self.assertIn('Build succeeded', A.messages[0])
+        # Ensure the removed job was not included in the report.
+        self.assertNotIn('project1-project2-integration', A.messages[0])
+
+    @skip("Disabled for early v3 development")
+    def test_double_live_reconfiguration_shared_queue(self):
+        # This was a real-world regression.  A change is added to
+        # gate; a reconfigure happens, a second change which depends
+        # on the first is added, and a second reconfiguration happens.
+        # Ensure that both changes merge.
+
+        # A failure may indicate incorrect caching or cleaning up of
+        # references during a reconfiguration.
+        self.launch_server.hold_jobs_in_build = True
+
+        A = self.fake_gerrit.addFakeChange('org/project1', 'master', 'A')
+        B = self.fake_gerrit.addFakeChange('org/project1', 'master', 'B')
+        B.setDependsOn(A, 1)
+        A.addApproval('code-review', 2)
+        B.addApproval('code-review', 2)
+
+        # Add the parent change.
+        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
+        self.waitUntilSettled()
+        self.launch_server.release('.*-merge')
+        self.waitUntilSettled()
+
+        # Reconfigure (with only one change in the pipeline).
+        self.sched.reconfigure(self.config)
+        self.waitUntilSettled()
+
+        # Add the child change.
+        self.fake_gerrit.addEvent(B.addApproval('approved', 1))
+        self.waitUntilSettled()
+        self.launch_server.release('.*-merge')
+        self.waitUntilSettled()
+
+        # Reconfigure (with both in the pipeline).
+        self.sched.reconfigure(self.config)
+        self.waitUntilSettled()
+
+        self.launch_server.hold_jobs_in_build = False
+        self.launch_server.release()
+        self.waitUntilSettled()
+
+        self.assertEqual(len(self.history), 8)
+
+        self.assertEqual(A.data['status'], 'MERGED')
+        self.assertEqual(A.reported, 2)
+        self.assertEqual(B.data['status'], 'MERGED')
+        self.assertEqual(B.reported, 2)
+
+    @skip("Disabled for early v3 development")
+    def test_live_reconfiguration_del_project(self):
+        # Test project deletion from layout
+        # while changes are enqueued
+
+        self.launch_server.hold_jobs_in_build = True
+        A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
+        B = self.fake_gerrit.addFakeChange('org/project1', 'master', 'B')
+        C = self.fake_gerrit.addFakeChange('org/project1', 'master', 'C')
+
+        # A Depends-On: B
+        A.data['commitMessage'] = '%s\n\nDepends-On: %s\n' % (
+            A.subject, B.data['id'])
+        self.fake_gerrit.addEvent(B.addApproval('approved', 1))
+
+        self.fake_gerrit.addEvent(A.getPatchsetCreatedEvent(1))
+        self.fake_gerrit.addEvent(C.getPatchsetCreatedEvent(1))
+        self.waitUntilSettled()
+        self.launch_server.release('.*-merge')
+        self.waitUntilSettled()
+        self.assertEqual(len(self.builds), 5)
+
+        # This layout defines only org/project, not org/project1
+        self.updateConfigLayout(
+            'tests/fixtures/layout-live-reconfiguration-del-project.yaml')
+        self.sched.reconfigure(self.config)
+        self.waitUntilSettled()
+
+        # Builds for C aborted, builds for A succeed,
+        # and have change B applied ahead
+        job_c = self.getJobFromHistory('project1-test1')
+        self.assertEqual(job_c.changes, '3,1')
+        self.assertEqual(job_c.result, 'ABORTED')
+
+        self.launch_server.hold_jobs_in_build = False
+        self.launch_server.release()
+        self.waitUntilSettled()
+
+        self.assertEqual(self.getJobFromHistory('project-test1').changes,
+                         '2,1 1,1')
+
+        self.assertEqual(A.data['status'], 'NEW')
+        self.assertEqual(B.data['status'], 'NEW')
+        self.assertEqual(C.data['status'], 'NEW')
+        self.assertEqual(A.reported, 1)
+        self.assertEqual(B.reported, 0)
+        self.assertEqual(C.reported, 0)
+
+        self.assertEqual(len(self.sched.layout.pipelines['check'].queues), 0)
+        self.assertIn('Build succeeded', A.messages[0])
+
+    @skip("Disabled for early v3 development")
+    def test_live_reconfiguration_functions(self):
+        "Test live reconfiguration with a custom function"
+        self.worker.registerFunction('build:node-project-test1:debian')
+        self.worker.registerFunction('build:node-project-test1:wheezy')
+        A = self.fake_gerrit.addFakeChange('org/node-project', 'master', 'A')
+        A.addApproval('code-review', 2)
+        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
+        self.waitUntilSettled()
+
+        self.assertIsNone(self.getJobFromHistory('node-project-merge').node)
+        self.assertEqual(self.getJobFromHistory('node-project-test1').node,
+                         'debian')
+        self.assertIsNone(self.getJobFromHistory('node-project-test2').node)
+
+        self.updateConfigLayout(
+            'tests/fixtures/layout-live-reconfiguration-functions.yaml')
+        self.sched.reconfigure(self.config)
+        self.worker.build_history = []
+
+        B = self.fake_gerrit.addFakeChange('org/node-project', 'master', 'B')
+        B.addApproval('code-review', 2)
+        self.fake_gerrit.addEvent(B.addApproval('approved', 1))
+        self.waitUntilSettled()
+
+        self.assertIsNone(self.getJobFromHistory('node-project-merge').node)
+        self.assertEqual(self.getJobFromHistory('node-project-test1').node,
+                         'wheezy')
+        self.assertIsNone(self.getJobFromHistory('node-project-test2').node)
+
+    @skip("Disabled for early v3 development")
+    def test_delayed_repo_init(self):
+        self.updateConfigLayout(
+            'tests/fixtures/layout-delayed-repo-init.yaml')
+        self.sched.reconfigure(self.config)
+
+        self.init_repo("org/new-project")
+        A = self.fake_gerrit.addFakeChange('org/new-project', 'master', 'A')
+
+        A.addApproval('code-review', 2)
+        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
+        self.waitUntilSettled()
+        self.assertEqual(self.getJobFromHistory('project-merge').result,
+                         'SUCCESS')
+        self.assertEqual(self.getJobFromHistory('project-test1').result,
+                         'SUCCESS')
+        self.assertEqual(self.getJobFromHistory('project-test2').result,
+                         'SUCCESS')
+        self.assertEqual(A.data['status'], 'MERGED')
+        self.assertEqual(A.reported, 2)
+
+    def test_repo_deleted(self):
+        self.updateConfigLayout('layout-repo-deleted')
+        self.sched.reconfigure(self.config)
+
+        self.init_repo("org/delete-project")
+        A = self.fake_gerrit.addFakeChange('org/delete-project', 'master', 'A')
+
+        A.addApproval('code-review', 2)
+        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
+        self.waitUntilSettled()
+        self.assertEqual(self.getJobFromHistory('project-merge').result,
+                         'SUCCESS')
+        self.assertEqual(self.getJobFromHistory('project-test1').result,
+                         'SUCCESS')
+        self.assertEqual(self.getJobFromHistory('project-test2').result,
+                         'SUCCESS')
+        self.assertEqual(A.data['status'], 'MERGED')
+        self.assertEqual(A.reported, 2)
+
+        # Delete org/new-project zuul repo. Should be recloned.
+        shutil.rmtree(os.path.join(self.git_root, "org/delete-project"))
+
+        B = self.fake_gerrit.addFakeChange('org/delete-project', 'master', 'B')
+
+        B.addApproval('code-review', 2)
+        self.fake_gerrit.addEvent(B.addApproval('approved', 1))
+        self.waitUntilSettled()
+        self.assertEqual(self.getJobFromHistory('project-merge').result,
+                         'SUCCESS')
+        self.assertEqual(self.getJobFromHistory('project-test1').result,
+                         'SUCCESS')
+        self.assertEqual(self.getJobFromHistory('project-test2').result,
+                         'SUCCESS')
+        self.assertEqual(B.data['status'], 'MERGED')
+        self.assertEqual(B.reported, 2)
+
+    @skip("Disabled for early v3 development")
+    def test_tags(self):
+        "Test job tags"
+        self.config.set('zuul', 'layout_config',
+                        'tests/fixtures/layout-tags.yaml')
+        self.sched.reconfigure(self.config)
+
+        A = self.fake_gerrit.addFakeChange('org/project1', 'master', 'A')
+        B = self.fake_gerrit.addFakeChange('org/project2', 'master', 'B')
+        self.fake_gerrit.addEvent(A.getPatchsetCreatedEvent(1))
+        self.fake_gerrit.addEvent(B.getPatchsetCreatedEvent(1))
+        self.waitUntilSettled()
+
+        results = {'project1-merge': 'extratag merge project1',
+                   'project2-merge': 'merge'}
+
+        for build in self.history:
+            self.assertEqual(results.get(build.name, ''),
+                             build.parameters.get('BUILD_TAGS'))
+
+    @skip("Disabled for early v3 development")
+    def test_timer(self):
+        "Test that a periodic job is triggered"
+        self.launch_server.hold_jobs_in_build = True
+        self.updateConfigLayout(
+            'tests/fixtures/layout-timer.yaml')
+        self.sched.reconfigure(self.config)
+        self.registerJobs()
+
+        # The pipeline triggers every second, so we should have seen
+        # several by now.
+        time.sleep(5)
+        self.waitUntilSettled()
+
+        self.assertEqual(len(self.builds), 2)
+
+        port = self.webapp.server.socket.getsockname()[1]
+
+        req = urllib.request.Request("http://localhost:%s/status.json" % port)
+        f = urllib.request.urlopen(req)
+        data = f.read()
+
+        self.launch_server.hold_jobs_in_build = False
+        # Stop queuing timer triggered jobs so that the assertions
+        # below don't race against more jobs being queued.
+        self.updateConfigLayout(
+            'tests/fixtures/layout-no-timer.yaml')
+        self.sched.reconfigure(self.config)
+        self.registerJobs()
+        self.launch_server.release()
+        self.waitUntilSettled()
+
+        self.assertEqual(self.getJobFromHistory(
+            'project-bitrot-stable-old').result, 'SUCCESS')
+        self.assertEqual(self.getJobFromHistory(
+            'project-bitrot-stable-older').result, 'SUCCESS')
+
+        data = json.loads(data)
+        status_jobs = set()
+        for p in data['pipelines']:
+            for q in p['change_queues']:
+                for head in q['heads']:
+                    for change in head:
+                        self.assertEqual(change['id'], None)
+                        for job in change['jobs']:
+                            status_jobs.add(job['name'])
+        self.assertIn('project-bitrot-stable-old', status_jobs)
+        self.assertIn('project-bitrot-stable-older', status_jobs)
+
+    @skip("Disabled for early v3 development")
+    def test_idle(self):
+        "Test that frequent periodic jobs work"
+        self.launch_server.hold_jobs_in_build = True
+
+        for x in range(1, 3):
+            # Test that timer triggers periodic jobs even across
+            # layout config reloads.
+            # Start timer trigger
+            self.updateConfigLayout(
+                'tests/fixtures/layout-idle.yaml')
+            self.sched.reconfigure(self.config)
+            self.registerJobs()
+            self.waitUntilSettled()
+
+            # The pipeline triggers every second, so we should have seen
+            # several by now.
+            time.sleep(5)
+
+            # Stop queuing timer triggered jobs so that the assertions
+            # below don't race against more jobs being queued.
+            self.updateConfigLayout(
+                'tests/fixtures/layout-no-timer.yaml')
+            self.sched.reconfigure(self.config)
+            self.registerJobs()
+            self.waitUntilSettled()
+
+            self.assertEqual(len(self.builds), 2)
+            self.launch_server.release('.*')
+            self.waitUntilSettled()
+            self.assertEqual(len(self.builds), 0)
+            self.assertEqual(len(self.history), x * 2)
+
+    def test_check_smtp_pool(self):
+        self.updateConfigLayout('layout-smtp')
+        self.sched.reconfigure(self.config)
+
+        A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
+        self.waitUntilSettled()
+
+        self.fake_gerrit.addEvent(A.getPatchsetCreatedEvent(1))
+        self.waitUntilSettled()
+
+        self.assertEqual(len(self.smtp_messages), 2)
+
+        # A.messages only holds what FakeGerrit places in it. Thus we
+        # work on the knowledge of what the first message should be as
+        # it is only configured to go to SMTP.
+
+        self.assertEqual('zuul@example.com',
+                         self.smtp_messages[0]['from_email'])
+        self.assertEqual(['you@example.com'],
+                         self.smtp_messages[0]['to_email'])
+        self.assertEqual('Starting check jobs.',
+                         self.smtp_messages[0]['body'])
+
+        self.assertEqual('zuul_from@example.com',
+                         self.smtp_messages[1]['from_email'])
+        self.assertEqual(['alternative_me@example.com'],
+                         self.smtp_messages[1]['to_email'])
+        self.assertEqual(A.messages[0],
+                         self.smtp_messages[1]['body'])
+
+    @skip("Disabled for early v3 development")
+    def test_timer_smtp(self):
+        "Test that a periodic job is triggered"
+        self.launch_server.hold_jobs_in_build = True
+        self.updateConfigLayout(
+            'tests/fixtures/layout-timer-smtp.yaml')
+        self.sched.reconfigure(self.config)
+        self.registerJobs()
+
+        # The pipeline triggers every second, so we should have seen
+        # several by now.
+        time.sleep(5)
+        self.waitUntilSettled()
+
+        self.assertEqual(len(self.builds), 2)
+        self.launch_server.release('.*')
+        self.waitUntilSettled()
+        self.assertEqual(len(self.history), 2)
+
+        self.assertEqual(self.getJobFromHistory(
+            'project-bitrot-stable-old').result, 'SUCCESS')
+        self.assertEqual(self.getJobFromHistory(
+            'project-bitrot-stable-older').result, 'SUCCESS')
+
+        self.assertEqual(len(self.smtp_messages), 1)
+
+        # A.messages only holds what FakeGerrit places in it. Thus we
+        # work on the knowledge of what the first message should be as
+        # it is only configured to go to SMTP.
+
+        self.assertEqual('zuul_from@example.com',
+                         self.smtp_messages[0]['from_email'])
+        self.assertEqual(['alternative_me@example.com'],
+                         self.smtp_messages[0]['to_email'])
+        self.assertIn('Subject: Periodic check for org/project succeeded',
+                      self.smtp_messages[0]['headers'])
+
+        # Stop queuing timer triggered jobs and let any that may have
+        # queued through so that end of test assertions pass.
+        self.updateConfigLayout(
+            'tests/fixtures/layout-no-timer.yaml')
+        self.sched.reconfigure(self.config)
+        self.registerJobs()
+        self.waitUntilSettled()
+        self.launch_server.release('.*')
+        self.waitUntilSettled()
+
+    def test_client_enqueue_change(self):
+        "Test that the RPC client can enqueue a change"
+        A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
+        A.addApproval('code-review', 2)
+        A.addApproval('approved', 1)
+
+        client = zuul.rpcclient.RPCClient('127.0.0.1',
+                                          self.gearman_server.port)
+        r = client.enqueue(tenant='tenant-one',
+                           pipeline='gate',
+                           project='org/project',
+                           trigger='gerrit',
+                           change='1,1')
+        self.waitUntilSettled()
+        self.assertEqual(self.getJobFromHistory('project-merge').result,
+                         'SUCCESS')
+        self.assertEqual(self.getJobFromHistory('project-test1').result,
+                         'SUCCESS')
+        self.assertEqual(self.getJobFromHistory('project-test2').result,
+                         'SUCCESS')
+        self.assertEqual(A.data['status'], 'MERGED')
+        self.assertEqual(A.reported, 2)
+        self.assertEqual(r, True)
+
+    @skip("Disabled for early v3 development")
+    def test_client_enqueue_ref(self):
+        "Test that the RPC client can enqueue a ref"
+
+        client = zuul.rpcclient.RPCClient('127.0.0.1',
+                                          self.gearman_server.port)
+        r = client.enqueue_ref(
+            tenant='tenant-one',
+            pipeline='post',
+            project='org/project',
+            trigger='gerrit',
+            ref='master',
+            oldrev='90f173846e3af9154517b88543ffbd1691f31366',
+            newrev='d479a0bfcb34da57a31adb2a595c0cf687812543')
+        self.waitUntilSettled()
+        job_names = [x.name for x in self.history]
+        self.assertEqual(len(self.history), 1)
+        self.assertIn('project-post', job_names)
+        self.assertEqual(r, True)
+
+    def test_client_enqueue_negative(self):
+        "Test that the RPC client returns errors"
+        client = zuul.rpcclient.RPCClient('127.0.0.1',
+                                          self.gearman_server.port)
+        with testtools.ExpectedException(zuul.rpcclient.RPCFailure,
+                                         "Invalid tenant"):
+            r = client.enqueue(tenant='tenant-foo',
+                               pipeline='gate',
+                               project='org/project',
+                               trigger='gerrit',
+                               change='1,1')
+            client.shutdown()
+            self.assertEqual(r, False)
+
+        with testtools.ExpectedException(zuul.rpcclient.RPCFailure,
+                                         "Invalid project"):
+            r = client.enqueue(tenant='tenant-one',
+                               pipeline='gate',
+                               project='project-does-not-exist',
+                               trigger='gerrit',
+                               change='1,1')
+            client.shutdown()
+            self.assertEqual(r, False)
+
+        with testtools.ExpectedException(zuul.rpcclient.RPCFailure,
+                                         "Invalid pipeline"):
+            r = client.enqueue(tenant='tenant-one',
+                               pipeline='pipeline-does-not-exist',
+                               project='org/project',
+                               trigger='gerrit',
+                               change='1,1')
+            client.shutdown()
+            self.assertEqual(r, False)
+
+        with testtools.ExpectedException(zuul.rpcclient.RPCFailure,
+                                         "Invalid trigger"):
+            r = client.enqueue(tenant='tenant-one',
+                               pipeline='gate',
+                               project='org/project',
+                               trigger='trigger-does-not-exist',
+                               change='1,1')
+            client.shutdown()
+            self.assertEqual(r, False)
+
+        with testtools.ExpectedException(zuul.rpcclient.RPCFailure,
+                                         "Invalid change"):
+            r = client.enqueue(tenant='tenant-one',
+                               pipeline='gate',
+                               project='org/project',
+                               trigger='gerrit',
+                               change='1,1')
+            client.shutdown()
+            self.assertEqual(r, False)
+
+        self.waitUntilSettled()
+        self.assertEqual(len(self.history), 0)
+        self.assertEqual(len(self.builds), 0)
+
+    def test_client_promote(self):
+        "Test that the RPC client can promote a change"
+        self.launch_server.hold_jobs_in_build = True
+        A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
+        B = self.fake_gerrit.addFakeChange('org/project', 'master', 'B')
+        C = self.fake_gerrit.addFakeChange('org/project', 'master', 'C')
+        A.addApproval('code-review', 2)
+        B.addApproval('code-review', 2)
+        C.addApproval('code-review', 2)
+
+        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
+        self.fake_gerrit.addEvent(B.addApproval('approved', 1))
+        self.fake_gerrit.addEvent(C.addApproval('approved', 1))
+
+        self.waitUntilSettled()
+
+        tenant = self.sched.abide.tenants.get('tenant-one')
+        items = tenant.layout.pipelines['gate'].getAllItems()
+        enqueue_times = {}
+        for item in items:
+            enqueue_times[str(item.change)] = item.enqueue_time
+
+        client = zuul.rpcclient.RPCClient('127.0.0.1',
+                                          self.gearman_server.port)
+        r = client.promote(tenant='tenant-one',
+                           pipeline='gate',
+                           change_ids=['2,1', '3,1'])
+
+        # ensure that enqueue times are durable
+        items = tenant.layout.pipelines['gate'].getAllItems()
+        for item in items:
+            self.assertEqual(
+                enqueue_times[str(item.change)], item.enqueue_time)
+
+        self.waitUntilSettled()
+        self.launch_server.release('.*-merge')
+        self.waitUntilSettled()
+        self.launch_server.release('.*-merge')
+        self.waitUntilSettled()
+        self.launch_server.release('.*-merge')
+        self.waitUntilSettled()
+
+        self.assertEqual(len(self.builds), 6)
+        self.assertEqual(self.builds[0].name, 'project-test1')
+        self.assertEqual(self.builds[1].name, 'project-test2')
+        self.assertEqual(self.builds[2].name, 'project-test1')
+        self.assertEqual(self.builds[3].name, 'project-test2')
+        self.assertEqual(self.builds[4].name, 'project-test1')
+        self.assertEqual(self.builds[5].name, 'project-test2')
+
+        self.assertTrue(self.builds[0].hasChanges(B))
+        self.assertFalse(self.builds[0].hasChanges(A))
+        self.assertFalse(self.builds[0].hasChanges(C))
+
+        self.assertTrue(self.builds[2].hasChanges(B))
+        self.assertTrue(self.builds[2].hasChanges(C))
+        self.assertFalse(self.builds[2].hasChanges(A))
+
+        self.assertTrue(self.builds[4].hasChanges(B))
+        self.assertTrue(self.builds[4].hasChanges(C))
+        self.assertTrue(self.builds[4].hasChanges(A))
+
+        self.launch_server.release()
+        self.waitUntilSettled()
+
+        self.assertEqual(A.data['status'], 'MERGED')
+        self.assertEqual(A.reported, 2)
+        self.assertEqual(B.data['status'], 'MERGED')
+        self.assertEqual(B.reported, 2)
+        self.assertEqual(C.data['status'], 'MERGED')
+        self.assertEqual(C.reported, 2)
+
+        client.shutdown()
+        self.assertEqual(r, True)
+
+    def test_client_promote_dependent(self):
+        "Test that the RPC client can promote a dependent change"
+        # C (depends on B) -> B -> A ; then promote C to get:
+        # A -> C (depends on B) -> B
+        self.launch_server.hold_jobs_in_build = True
+        A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
+        B = self.fake_gerrit.addFakeChange('org/project', 'master', 'B')
+        C = self.fake_gerrit.addFakeChange('org/project', 'master', 'C')
+
+        C.setDependsOn(B, 1)
+
+        A.addApproval('code-review', 2)
+        B.addApproval('code-review', 2)
+        C.addApproval('code-review', 2)
+
+        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
+        self.fake_gerrit.addEvent(B.addApproval('approved', 1))
+        self.fake_gerrit.addEvent(C.addApproval('approved', 1))
+
+        self.waitUntilSettled()
+
+        client = zuul.rpcclient.RPCClient('127.0.0.1',
+                                          self.gearman_server.port)
+        r = client.promote(tenant='tenant-one',
+                           pipeline='gate',
+                           change_ids=['3,1'])
+
+        self.waitUntilSettled()
+        self.launch_server.release('.*-merge')
+        self.waitUntilSettled()
+        self.launch_server.release('.*-merge')
+        self.waitUntilSettled()
+        self.launch_server.release('.*-merge')
+        self.waitUntilSettled()
+
+        self.assertEqual(len(self.builds), 6)
+        self.assertEqual(self.builds[0].name, 'project-test1')
+        self.assertEqual(self.builds[1].name, 'project-test2')
+        self.assertEqual(self.builds[2].name, 'project-test1')
+        self.assertEqual(self.builds[3].name, 'project-test2')
+        self.assertEqual(self.builds[4].name, 'project-test1')
+        self.assertEqual(self.builds[5].name, 'project-test2')
+
+        self.assertTrue(self.builds[0].hasChanges(B))
+        self.assertFalse(self.builds[0].hasChanges(A))
+        self.assertFalse(self.builds[0].hasChanges(C))
+
+        self.assertTrue(self.builds[2].hasChanges(B))
+        self.assertTrue(self.builds[2].hasChanges(C))
+        self.assertFalse(self.builds[2].hasChanges(A))
+
+        self.assertTrue(self.builds[4].hasChanges(B))
+        self.assertTrue(self.builds[4].hasChanges(C))
+        self.assertTrue(self.builds[4].hasChanges(A))
+
+        self.launch_server.release()
+        self.waitUntilSettled()
+
+        self.assertEqual(A.data['status'], 'MERGED')
+        self.assertEqual(A.reported, 2)
+        self.assertEqual(B.data['status'], 'MERGED')
+        self.assertEqual(B.reported, 2)
+        self.assertEqual(C.data['status'], 'MERGED')
+        self.assertEqual(C.reported, 2)
+
+        client.shutdown()
+        self.assertEqual(r, True)
+
+    def test_client_promote_negative(self):
+        "Test that the RPC client returns errors for promotion"
+        self.launch_server.hold_jobs_in_build = True
+        A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
+        A.addApproval('code-review', 2)
+        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
+        self.waitUntilSettled()
+
+        client = zuul.rpcclient.RPCClient('127.0.0.1',
+                                          self.gearman_server.port)
+
+        with testtools.ExpectedException(zuul.rpcclient.RPCFailure):
+            r = client.promote(tenant='tenant-one',
+                               pipeline='nonexistent',
+                               change_ids=['2,1', '3,1'])
+            client.shutdown()
+            self.assertEqual(r, False)
+
+        with testtools.ExpectedException(zuul.rpcclient.RPCFailure):
+            r = client.promote(tenant='tenant-one',
+                               pipeline='gate',
+                               change_ids=['4,1'])
+            client.shutdown()
+            self.assertEqual(r, False)
+
+        self.launch_server.hold_jobs_in_build = False
+        self.launch_server.release()
+        self.waitUntilSettled()
+
+    @skip("Disabled for early v3 development")
+    def test_queue_rate_limiting(self):
+        "Test that DependentPipelines are rate limited with dep across window"
+        self.updateConfigLayout(
+            'tests/fixtures/layout-rate-limit.yaml')
+        self.sched.reconfigure(self.config)
+        self.launch_server.hold_jobs_in_build = True
+        A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
+        B = self.fake_gerrit.addFakeChange('org/project', 'master', 'B')
+        C = self.fake_gerrit.addFakeChange('org/project', 'master', 'C')
+
+        C.setDependsOn(B, 1)
+        self.launch_server.failJob('project-test1', A)
+
+        A.addApproval('code-review', 2)
+        B.addApproval('code-review', 2)
+        C.addApproval('code-review', 2)
+
+        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
+        self.fake_gerrit.addEvent(B.addApproval('approved', 1))
+        self.fake_gerrit.addEvent(C.addApproval('approved', 1))
+        self.waitUntilSettled()
+
+        # Only A and B will have their merge jobs queued because
+        # window is 2.
+        self.assertEqual(len(self.builds), 2)
+        self.assertEqual(self.builds[0].name, 'project-merge')
+        self.assertEqual(self.builds[1].name, 'project-merge')
+
+        self.launch_server.release('.*-merge')
+        self.waitUntilSettled()
+        self.launch_server.release('.*-merge')
+        self.waitUntilSettled()
+
+        # Only A and B will have their test jobs queued because
+        # window is 2.
+        self.assertEqual(len(self.builds), 4)
+        self.assertEqual(self.builds[0].name, 'project-test1')
+        self.assertEqual(self.builds[1].name, 'project-test2')
+        self.assertEqual(self.builds[2].name, 'project-test1')
+        self.assertEqual(self.builds[3].name, 'project-test2')
+
+        self.launch_server.release('project-.*')
+        self.waitUntilSettled()
+
+        queue = self.sched.layout.pipelines['gate'].queues[0]
+        # A failed so window is reduced by 1 to 1.
+        self.assertEqual(queue.window, 1)
+        self.assertEqual(queue.window_floor, 1)
+        self.assertEqual(A.data['status'], 'NEW')
+
+        # Gate is reset and only B's merge job is queued because
+        # window shrunk to 1.
+        self.assertEqual(len(self.builds), 1)
+        self.assertEqual(self.builds[0].name, 'project-merge')
+
+        self.launch_server.release('.*-merge')
+        self.waitUntilSettled()
+
+        # Only B's test jobs are queued because window is still 1.
+        self.assertEqual(len(self.builds), 2)
+        self.assertEqual(self.builds[0].name, 'project-test1')
+        self.assertEqual(self.builds[1].name, 'project-test2')
+
+        self.launch_server.release('project-.*')
+        self.waitUntilSettled()
+
+        # B was successfully merged so window is increased to 2.
+        self.assertEqual(queue.window, 2)
+        self.assertEqual(queue.window_floor, 1)
+        self.assertEqual(B.data['status'], 'MERGED')
+
+        # Only C is left and its merge job is queued.
+        self.assertEqual(len(self.builds), 1)
+        self.assertEqual(self.builds[0].name, 'project-merge')
+
+        self.launch_server.release('.*-merge')
+        self.waitUntilSettled()
+
+        # After successful merge job the test jobs for C are queued.
+        self.assertEqual(len(self.builds), 2)
+        self.assertEqual(self.builds[0].name, 'project-test1')
+        self.assertEqual(self.builds[1].name, 'project-test2')
+
+        self.launch_server.release('project-.*')
+        self.waitUntilSettled()
+
+        # C successfully merged so window is bumped to 3.
+        self.assertEqual(queue.window, 3)
+        self.assertEqual(queue.window_floor, 1)
+        self.assertEqual(C.data['status'], 'MERGED')
+
+    @skip("Disabled for early v3 development")
+    def test_queue_rate_limiting_dependent(self):
+        "Test that DependentPipelines are rate limited with dep in window"
+        self.updateConfigLayout(
+            'tests/fixtures/layout-rate-limit.yaml')
+        self.sched.reconfigure(self.config)
+        self.launch_server.hold_jobs_in_build = True
+        A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
+        B = self.fake_gerrit.addFakeChange('org/project', 'master', 'B')
+        C = self.fake_gerrit.addFakeChange('org/project', 'master', 'C')
+
+        B.setDependsOn(A, 1)
+
+        self.launch_server.failJob('project-test1', A)
+
+        A.addApproval('code-review', 2)
+        B.addApproval('code-review', 2)
+        C.addApproval('code-review', 2)
+
+        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
+        self.fake_gerrit.addEvent(B.addApproval('approved', 1))
+        self.fake_gerrit.addEvent(C.addApproval('approved', 1))
+        self.waitUntilSettled()
+
+        # Only A and B will have their merge jobs queued because
+        # window is 2.
+        self.assertEqual(len(self.builds), 2)
+        self.assertEqual(self.builds[0].name, 'project-merge')
+        self.assertEqual(self.builds[1].name, 'project-merge')
+
+        self.launch_server.release('.*-merge')
+        self.waitUntilSettled()
+        self.launch_server.release('.*-merge')
+        self.waitUntilSettled()
+
+        # Only A and B will have their test jobs queued because
+        # window is 2.
+        self.assertEqual(len(self.builds), 4)
+        self.assertEqual(self.builds[0].name, 'project-test1')
+        self.assertEqual(self.builds[1].name, 'project-test2')
+        self.assertEqual(self.builds[2].name, 'project-test1')
+        self.assertEqual(self.builds[3].name, 'project-test2')
+
+        self.launch_server.release('project-.*')
+        self.waitUntilSettled()
+
+        queue = self.sched.layout.pipelines['gate'].queues[0]
+        # A failed so window is reduced by 1 to 1.
+        self.assertEqual(queue.window, 1)
+        self.assertEqual(queue.window_floor, 1)
+        self.assertEqual(A.data['status'], 'NEW')
+        self.assertEqual(B.data['status'], 'NEW')
+
+        # Gate is reset and only C's merge job is queued because
+        # window shrunk to 1 and A and B were dequeued.
+        self.assertEqual(len(self.builds), 1)
+        self.assertEqual(self.builds[0].name, 'project-merge')
+
+        self.launch_server.release('.*-merge')
+        self.waitUntilSettled()
+
+        # Only C's test jobs are queued because window is still 1.
+        self.assertEqual(len(self.builds), 2)
+        self.assertEqual(self.builds[0].name, 'project-test1')
+        self.assertEqual(self.builds[1].name, 'project-test2')
+
+        self.launch_server.release('project-.*')
+        self.waitUntilSettled()
+
+        # C was successfully merged so window is increased to 2.
+        self.assertEqual(queue.window, 2)
+        self.assertEqual(queue.window_floor, 1)
+        self.assertEqual(C.data['status'], 'MERGED')
+
+    @skip("Disabled for early v3 development")
+    def test_worker_update_metadata(self):
+        "Test if a worker can send back metadata about itself"
+        self.launch_server.hold_jobs_in_build = True
+
+        A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
+        A.addApproval('code-review', 2)
+        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
+        self.waitUntilSettled()
+
+        self.assertEqual(len(self.launcher.builds), 1)
+
+        self.log.debug('Current builds:')
+        self.log.debug(self.launcher.builds)
+
+        start = time.time()
+        while True:
+            if time.time() - start > 10:
+                raise Exception("Timeout waiting for gearman server to report "
+                                + "back to the client")
+            build = self.launcher.builds.values()[0]
+            if build.worker.name == "My Worker":
+                break
+            else:
+                time.sleep(0)
+
+        self.log.debug(build)
+        self.assertEqual("My Worker", build.worker.name)
+        self.assertEqual("localhost", build.worker.hostname)
+        self.assertEqual(['127.0.0.1', '192.168.1.1'], build.worker.ips)
+        self.assertEqual("zuul.example.org", build.worker.fqdn)
+        self.assertEqual("FakeBuilder", build.worker.program)
+        self.assertEqual("v1.1", build.worker.version)
+        self.assertEqual({'something': 'else'}, build.worker.extra)
+
+        self.launch_server.hold_jobs_in_build = False
+        self.launch_server.release()
+        self.waitUntilSettled()
+
+    @skip("Disabled for early v3 development")
+    def test_footer_message(self):
+        "Test a pipeline's footer message is correctly added to the report."
+        self.updateConfigLayout(
+            'tests/fixtures/layout-footer-message.yaml')
+        self.sched.reconfigure(self.config)
+        self.registerJobs()
+
+        A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
+        A.addApproval('code-review', 2)
+        self.launch_server.failJob('test1', A)
+        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
+        self.waitUntilSettled()
+
+        B = self.fake_gerrit.addFakeChange('org/project', 'master', 'B')
+        B.addApproval('code-review', 2)
+        self.fake_gerrit.addEvent(B.addApproval('approved', 1))
+        self.waitUntilSettled()
+
+        self.assertEqual(2, len(self.smtp_messages))
+
+        failure_body = """\
+Build failed.  For information on how to proceed, see \
+http://wiki.example.org/Test_Failures
+
+- test1 http://logs.example.com/1/1/gate/test1/0 : FAILURE in 0s
+- test2 http://logs.example.com/1/1/gate/test2/1 : SUCCESS in 0s
+
+For CI problems and help debugging, contact ci@example.org"""
+
+        success_body = """\
+Build succeeded.
+
+- test1 http://logs.example.com/2/1/gate/test1/2 : SUCCESS in 0s
+- test2 http://logs.example.com/2/1/gate/test2/3 : SUCCESS in 0s
+
+For CI problems and help debugging, contact ci@example.org"""
+
+        self.assertEqual(failure_body, self.smtp_messages[0]['body'])
+        self.assertEqual(success_body, self.smtp_messages[1]['body'])
+
+    @skip("Disabled for early v3 development")
+    def test_merge_failure_reporters(self):
+        """Check that the config is set up correctly"""
+
+        self.updateConfigLayout(
+            'tests/fixtures/layout-merge-failure.yaml')
+        self.sched.reconfigure(self.config)
+        self.registerJobs()
+
+        self.assertEqual(
+            "Merge Failed.\n\nThis change or one of its cross-repo "
+            "dependencies was unable to be automatically merged with the "
+            "current state of its repository. Please rebase the change and "
+            "upload a new patchset.",
+            self.sched.layout.pipelines['check'].merge_failure_message)
+        self.assertEqual(
+            "The merge failed! For more information...",
+            self.sched.layout.pipelines['gate'].merge_failure_message)
+
+        self.assertEqual(
+            len(self.sched.layout.pipelines['check'].merge_failure_actions), 1)
+        self.assertEqual(
+            len(self.sched.layout.pipelines['gate'].merge_failure_actions), 2)
+
+        self.assertTrue(isinstance(
+            self.sched.layout.pipelines['check'].merge_failure_actions[0],
+            zuul.reporter.gerrit.GerritReporter))
+
+        self.assertTrue(
+            (
+                isinstance(self.sched.layout.pipelines['gate'].
+                           merge_failure_actions[0],
+                           zuul.reporter.smtp.SMTPReporter) and
+                isinstance(self.sched.layout.pipelines['gate'].
+                           merge_failure_actions[1],
+                           zuul.reporter.gerrit.GerritReporter)
+            ) or (
+                isinstance(self.sched.layout.pipelines['gate'].
+                           merge_failure_actions[0],
+                           zuul.reporter.gerrit.GerritReporter) and
+                isinstance(self.sched.layout.pipelines['gate'].
+                           merge_failure_actions[1],
+                           zuul.reporter.smtp.SMTPReporter)
+            )
+        )
+
+    @skip("Disabled for early v3 development")
+    def test_merge_failure_reports(self):
+        """Check that when a change fails to merge the correct message is sent
+        to the correct reporter"""
+        self.updateConfigLayout(
+            'tests/fixtures/layout-merge-failure.yaml')
+        self.sched.reconfigure(self.config)
+        self.registerJobs()
+
+        # Check a test failure isn't reported to SMTP
+        A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
+        A.addApproval('code-review', 2)
+        self.launch_server.failJob('project-test1', A)
+        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
+        self.waitUntilSettled()
+
+        self.assertEqual(3, len(self.history))  # 3 jobs
+        self.assertEqual(0, len(self.smtp_messages))
+
+        # Check a merge failure is reported to SMTP
+        # B should be merged, but C will conflict with B
+        B = self.fake_gerrit.addFakeChange('org/project', 'master', 'B')
+        B.addPatchset(['conflict'])
+        C = self.fake_gerrit.addFakeChange('org/project', 'master', 'C')
+        C.addPatchset(['conflict'])
+        B.addApproval('code-review', 2)
+        C.addApproval('code-review', 2)
+        self.fake_gerrit.addEvent(B.addApproval('approved', 1))
+        self.fake_gerrit.addEvent(C.addApproval('approved', 1))
+        self.waitUntilSettled()
+
+        self.assertEqual(6, len(self.history))  # A and B jobs
+        self.assertEqual(1, len(self.smtp_messages))
+        self.assertEqual('The merge failed! For more information...',
+                         self.smtp_messages[0]['body'])
+
+    @skip("Disabled for early v3 development")
+    def test_default_merge_failure_reports(self):
+        """Check that the default merge failure reports are correct."""
+
+        # A should report success, B should report merge failure.
+        A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
+        A.addPatchset(['conflict'])
+        B = self.fake_gerrit.addFakeChange('org/project', 'master', 'B')
+        B.addPatchset(['conflict'])
+        A.addApproval('code-review', 2)
+        B.addApproval('code-review', 2)
+        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
+        self.fake_gerrit.addEvent(B.addApproval('approved', 1))
+        self.waitUntilSettled()
+
+        self.assertEqual(3, len(self.history))  # A jobs
+        self.assertEqual(A.reported, 2)
+        self.assertEqual(B.reported, 2)
+        self.assertEqual(A.data['status'], 'MERGED')
+        self.assertEqual(B.data['status'], 'NEW')
+        self.assertIn('Build succeeded', A.messages[1])
+        self.assertIn('Merge Failed', B.messages[1])
+        self.assertIn('automatically merged', B.messages[1])
+        self.assertNotIn('logs.example.com', B.messages[1])
+        self.assertNotIn('SKIPPED', B.messages[1])
+
+    @skip("Disabled for early v3 development")
+    def test_swift_instructions(self):
+        "Test that the correct swift instructions are sent to the workers"
+        self.updateConfigLayout(
+            'tests/fixtures/layout-swift.yaml')
+        self.sched.reconfigure(self.config)
+        self.registerJobs()
+
+        self.launch_server.hold_jobs_in_build = True
+        A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
+
+        A.addApproval('code-review', 2)
+        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
+        self.waitUntilSettled()
+
+        self.assertEqual(
+            "https://storage.example.org/V1/AUTH_account/merge_logs/1/1/1/"
+            "gate/test-merge/",
+            self.builds[0].parameters['SWIFT_logs_URL'][:-7])
+        self.assertEqual(5,
+                         len(self.builds[0].parameters['SWIFT_logs_HMAC_BODY'].
+                             split('\n')))
+        self.assertIn('SWIFT_logs_SIGNATURE', self.builds[0].parameters)
+
+        self.assertEqual(
+            "https://storage.example.org/V1/AUTH_account/logs/1/1/1/"
+            "gate/test-test/",
+            self.builds[1].parameters['SWIFT_logs_URL'][:-7])
+        self.assertEqual(5,
+                         len(self.builds[1].parameters['SWIFT_logs_HMAC_BODY'].
+                             split('\n')))
+        self.assertIn('SWIFT_logs_SIGNATURE', self.builds[1].parameters)
+
+        self.assertEqual(
+            "https://storage.example.org/V1/AUTH_account/stash/1/1/1/"
+            "gate/test-test/",
+            self.builds[1].parameters['SWIFT_MOSTLY_URL'][:-7])
+        self.assertEqual(5,
+                         len(self.builds[1].
+                             parameters['SWIFT_MOSTLY_HMAC_BODY'].split('\n')))
+        self.assertIn('SWIFT_MOSTLY_SIGNATURE', self.builds[1].parameters)
+
+        self.launch_server.hold_jobs_in_build = False
+        self.launch_server.release()
+        self.waitUntilSettled()
+
+    def test_client_get_running_jobs(self):
+        "Test that the RPC client can get a list of running jobs"
+        self.launch_server.hold_jobs_in_build = True
+        A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
+        A.addApproval('code-review', 2)
+        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
+        self.waitUntilSettled()
+
+        client = zuul.rpcclient.RPCClient('127.0.0.1',
+                                          self.gearman_server.port)
+
+        # Wait for gearman server to send the initial workData back to zuul
+        start = time.time()
+        while True:
+            if time.time() - start > 10:
+                raise Exception("Timeout waiting for gearman server to report "
+                                + "back to the client")
+            build = self.launch_client.builds.values()[0]
+            if build.worker.name == "My Worker":
+                break
+            else:
+                time.sleep(0)
+
+        running_items = client.get_running_jobs()
+
+        self.assertEqual(1, len(running_items))
+        running_item = running_items[0]
+        self.assertEqual([], running_item['failing_reasons'])
+        self.assertEqual([], running_item['items_behind'])
+        self.assertEqual('https://hostname/1', running_item['url'])
+        self.assertEqual(None, running_item['item_ahead'])
+        self.assertEqual('org/project', running_item['project'])
+        self.assertEqual(None, running_item['remaining_time'])
+        self.assertEqual(True, running_item['active'])
+        self.assertEqual('1,1', running_item['id'])
+
+        self.assertEqual(3, len(running_item['jobs']))
+        for job in running_item['jobs']:
+            if job['name'] == 'project-merge':
+                self.assertEqual('project-merge', job['name'])
+                self.assertEqual('gate', job['pipeline'])
+                self.assertEqual(False, job['retry'])
+                self.assertEqual('https://server/job/project-merge/0/',
+                                 job['url'])
+                self.assertEqual(7, len(job['worker']))
+                self.assertEqual(False, job['canceled'])
+                self.assertEqual(True, job['voting'])
+                self.assertEqual(None, job['result'])
+                self.assertEqual('gate', job['pipeline'])
+                break
+
+        self.launch_server.hold_jobs_in_build = False
+        self.launch_server.release()
+        self.waitUntilSettled()
+
+        running_items = client.get_running_jobs()
+        self.assertEqual(0, len(running_items))
+
+    def test_nonvoting_pipeline(self):
+        "Test that a nonvoting pipeline (experimental) can still report"
+
+        A = self.fake_gerrit.addFakeChange('org/experimental-project',
+                                           'master', 'A')
+        self.fake_gerrit.addEvent(A.getPatchsetCreatedEvent(1))
+        self.waitUntilSettled()
+
+        self.assertEqual(self.getJobFromHistory('project-merge').result,
+                         'SUCCESS')
+        self.assertEqual(
+            self.getJobFromHistory('experimental-project-test').result,
+            'SUCCESS')
+        self.assertEqual(A.reported, 1)
+
+    def test_crd_gate(self):
+        "Test cross-repo dependencies"
+        A = self.fake_gerrit.addFakeChange('org/project1', 'master', 'A')
+        B = self.fake_gerrit.addFakeChange('org/project2', 'master', 'B')
+        A.addApproval('code-review', 2)
+        B.addApproval('code-review', 2)
+
+        AM2 = self.fake_gerrit.addFakeChange('org/project1', 'master', 'AM2')
+        AM1 = self.fake_gerrit.addFakeChange('org/project1', 'master', 'AM1')
+        AM2.setMerged()
+        AM1.setMerged()
+
+        BM2 = self.fake_gerrit.addFakeChange('org/project2', 'master', 'BM2')
+        BM1 = self.fake_gerrit.addFakeChange('org/project2', 'master', 'BM1')
+        BM2.setMerged()
+        BM1.setMerged()
+
+        # A -> AM1 -> AM2
+        # B -> BM1 -> BM2
+        # A Depends-On: B
+        # M2 is here to make sure it is never queried.  If it is, it
+        # means zuul is walking down the entire history of merged
+        # changes.
+
+        B.setDependsOn(BM1, 1)
+        BM1.setDependsOn(BM2, 1)
+
+        A.setDependsOn(AM1, 1)
+        AM1.setDependsOn(AM2, 1)
+
+        A.data['commitMessage'] = '%s\n\nDepends-On: %s\n' % (
+            A.subject, B.data['id'])
+
+        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
+        self.waitUntilSettled()
+
+        self.assertEqual(A.data['status'], 'NEW')
+        self.assertEqual(B.data['status'], 'NEW')
+
+        for connection in self.connections.connections.values():
+            connection.maintainCache([])
+
+        self.launch_server.hold_jobs_in_build = True
+        B.addApproval('approved', 1)
+        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
+        self.waitUntilSettled()
+
+        self.launch_server.release('.*-merge')
+        self.waitUntilSettled()
+        self.launch_server.release('.*-merge')
+        self.waitUntilSettled()
+        self.launch_server.hold_jobs_in_build = False
+        self.launch_server.release()
+        self.waitUntilSettled()
+
+        self.assertEqual(AM2.queried, 0)
+        self.assertEqual(BM2.queried, 0)
+        self.assertEqual(A.data['status'], 'MERGED')
+        self.assertEqual(B.data['status'], 'MERGED')
+        self.assertEqual(A.reported, 2)
+        self.assertEqual(B.reported, 2)
+
+        changes = self.getJobFromHistory(
+            'project-merge', 'org/project1').changes
+        self.assertEqual(changes, '2,1 1,1')
+
+    def test_crd_branch(self):
+        "Test cross-repo dependencies in multiple branches"
+
+        self.create_branch('org/project2', 'mp')
+        A = self.fake_gerrit.addFakeChange('org/project1', 'master', 'A')
+        B = self.fake_gerrit.addFakeChange('org/project2', 'master', 'B')
+        C = self.fake_gerrit.addFakeChange('org/project2', 'mp', 'C')
+        C.data['id'] = B.data['id']
+        A.addApproval('code-review', 2)
+        B.addApproval('code-review', 2)
+        C.addApproval('code-review', 2)
+
+        # A Depends-On: B+C
+        A.data['commitMessage'] = '%s\n\nDepends-On: %s\n' % (
+            A.subject, B.data['id'])
+
+        self.launch_server.hold_jobs_in_build = True
+        B.addApproval('approved', 1)
+        C.addApproval('approved', 1)
+        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
+        self.waitUntilSettled()
+
+        self.launch_server.release('.*-merge')
+        self.waitUntilSettled()
+        self.launch_server.release('.*-merge')
+        self.waitUntilSettled()
+        self.launch_server.release('.*-merge')
+        self.waitUntilSettled()
+        self.launch_server.hold_jobs_in_build = False
+        self.launch_server.release()
+        self.waitUntilSettled()
+
+        self.assertEqual(A.data['status'], 'MERGED')
+        self.assertEqual(B.data['status'], 'MERGED')
+        self.assertEqual(C.data['status'], 'MERGED')
+        self.assertEqual(A.reported, 2)
+        self.assertEqual(B.reported, 2)
+        self.assertEqual(C.reported, 2)
+
+        changes = self.getJobFromHistory(
+            'project-merge', 'org/project1').changes
+        self.assertEqual(changes, '2,1 3,1 1,1')
+
+    def test_crd_multiline(self):
+        "Test multiple depends-on lines in commit"
+        A = self.fake_gerrit.addFakeChange('org/project1', 'master', 'A')
+        B = self.fake_gerrit.addFakeChange('org/project2', 'master', 'B')
+        C = self.fake_gerrit.addFakeChange('org/project2', 'master', 'C')
+        A.addApproval('code-review', 2)
+        B.addApproval('code-review', 2)
+        C.addApproval('code-review', 2)
+
+        # A Depends-On: B+C
+        A.data['commitMessage'] = '%s\n\nDepends-On: %s\nDepends-On: %s\n' % (
+            A.subject, B.data['id'], C.data['id'])
+
+        self.launch_server.hold_jobs_in_build = True
+        B.addApproval('approved', 1)
+        C.addApproval('approved', 1)
+        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
+        self.waitUntilSettled()
+
+        self.launch_server.release('.*-merge')
+        self.waitUntilSettled()
+        self.launch_server.release('.*-merge')
+        self.waitUntilSettled()
+        self.launch_server.release('.*-merge')
+        self.waitUntilSettled()
+        self.launch_server.hold_jobs_in_build = False
+        self.launch_server.release()
+        self.waitUntilSettled()
+
+        self.assertEqual(A.data['status'], 'MERGED')
+        self.assertEqual(B.data['status'], 'MERGED')
+        self.assertEqual(C.data['status'], 'MERGED')
+        self.assertEqual(A.reported, 2)
+        self.assertEqual(B.reported, 2)
+        self.assertEqual(C.reported, 2)
+
+        changes = self.getJobFromHistory(
+            'project-merge', 'org/project1').changes
+        self.assertEqual(changes, '2,1 3,1 1,1')
+
+    def test_crd_unshared_gate(self):
+        "Test cross-repo dependencies in unshared gate queues"
+        A = self.fake_gerrit.addFakeChange('org/project1', 'master', 'A')
+        B = self.fake_gerrit.addFakeChange('org/project', 'master', 'B')
+        A.addApproval('code-review', 2)
+        B.addApproval('code-review', 2)
+
+        # A Depends-On: B
+        A.data['commitMessage'] = '%s\n\nDepends-On: %s\n' % (
+            A.subject, B.data['id'])
+
+        # A and B do not share a queue, make sure that A is unable to
+        # enqueue B (and therefore, A is unable to be enqueued).
+        B.addApproval('approved', 1)
+        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
+        self.waitUntilSettled()
+
+        self.assertEqual(A.data['status'], 'NEW')
+        self.assertEqual(B.data['status'], 'NEW')
+        self.assertEqual(A.reported, 0)
+        self.assertEqual(B.reported, 0)
+        self.assertEqual(len(self.history), 0)
+
+        # Enqueue and merge B alone.
+        self.fake_gerrit.addEvent(B.addApproval('approved', 1))
+        self.waitUntilSettled()
+
+        self.assertEqual(B.data['status'], 'MERGED')
+        self.assertEqual(B.reported, 2)
+
+        # Now that B is merged, A should be able to be enqueued and
+        # merged.
+        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
+        self.waitUntilSettled()
+
+        self.assertEqual(A.data['status'], 'MERGED')
+        self.assertEqual(A.reported, 2)
+
+    def test_crd_gate_reverse(self):
+        "Test reverse cross-repo dependencies"
+        A = self.fake_gerrit.addFakeChange('org/project1', 'master', 'A')
+        B = self.fake_gerrit.addFakeChange('org/project2', 'master', 'B')
+        A.addApproval('code-review', 2)
+        B.addApproval('code-review', 2)
+
+        # A Depends-On: B
+
+        A.data['commitMessage'] = '%s\n\nDepends-On: %s\n' % (
+            A.subject, B.data['id'])
+
+        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
+        self.waitUntilSettled()
+
+        self.assertEqual(A.data['status'], 'NEW')
+        self.assertEqual(B.data['status'], 'NEW')
+
+        self.launch_server.hold_jobs_in_build = True
+        A.addApproval('approved', 1)
+        self.fake_gerrit.addEvent(B.addApproval('approved', 1))
+        self.waitUntilSettled()
+
+        self.launch_server.release('.*-merge')
+        self.waitUntilSettled()
+        self.launch_server.release('.*-merge')
+        self.waitUntilSettled()
+        self.launch_server.hold_jobs_in_build = False
+        self.launch_server.release()
+        self.waitUntilSettled()
+
+        self.assertEqual(A.data['status'], 'MERGED')
+        self.assertEqual(B.data['status'], 'MERGED')
+        self.assertEqual(A.reported, 2)
+        self.assertEqual(B.reported, 2)
+
+        changes = self.getJobFromHistory(
+            'project-merge', 'org/project1').changes
+        self.assertEqual(changes, '2,1 1,1')
+
+    def test_crd_cycle(self):
+        "Test cross-repo dependency cycles"
+        A = self.fake_gerrit.addFakeChange('org/project1', 'master', 'A')
+        B = self.fake_gerrit.addFakeChange('org/project2', 'master', 'B')
+        A.addApproval('code-review', 2)
+        B.addApproval('code-review', 2)
+
+        # A -> B -> A (via commit-depends)
+
+        A.data['commitMessage'] = '%s\n\nDepends-On: %s\n' % (
+            A.subject, B.data['id'])
+        B.data['commitMessage'] = '%s\n\nDepends-On: %s\n' % (
+            B.subject, A.data['id'])
+
+        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
+        self.waitUntilSettled()
+
+        self.assertEqual(A.reported, 0)
+        self.assertEqual(B.reported, 0)
+        self.assertEqual(A.data['status'], 'NEW')
+        self.assertEqual(B.data['status'], 'NEW')
+
+    def test_crd_gate_unknown(self):
+        "Test unknown projects in dependent pipeline"
+        self.init_repo("org/unknown")
+        A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
+        B = self.fake_gerrit.addFakeChange('org/unknown', 'master', 'B')
+        A.addApproval('code-review', 2)
+        B.addApproval('code-review', 2)
+
+        # A Depends-On: B
+        A.data['commitMessage'] = '%s\n\nDepends-On: %s\n' % (
+            A.subject, B.data['id'])
+
+        B.addApproval('approved', 1)
+        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
+        self.waitUntilSettled()
+
+        # Unknown projects cannot share a queue with any other
+        # since they don't have common jobs with any other (they have no jobs).
+        # Changes which depend on unknown project changes
+        # should not be processed in dependent pipeline
+        self.assertEqual(A.data['status'], 'NEW')
+        self.assertEqual(B.data['status'], 'NEW')
+        self.assertEqual(A.reported, 0)
+        self.assertEqual(B.reported, 0)
+        self.assertEqual(len(self.history), 0)
+
+        # Simulate change B being gated outside this layout
+        self.fake_gerrit.addEvent(B.addApproval('approved', 1))
+        B.setMerged()
+        self.waitUntilSettled()
+        self.assertEqual(len(self.history), 0)
+
+        # Now that B is merged, A should be able to be enqueued and
+        # merged.
+        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
+        self.waitUntilSettled()
+
+        self.assertEqual(A.data['status'], 'MERGED')
+        self.assertEqual(A.reported, 2)
+        self.assertEqual(B.data['status'], 'MERGED')
+        self.assertEqual(B.reported, 0)
+
+    def test_crd_check(self):
+        "Test cross-repo dependencies in independent pipelines"
+
+        self.launch_server.hold_jobs_in_build = True
+        self.gearman_server.hold_jobs_in_queue = True
+        A = self.fake_gerrit.addFakeChange('org/project1', 'master', 'A')
+        B = self.fake_gerrit.addFakeChange('org/project2', 'master', 'B')
+
+        # A Depends-On: B
+        A.data['commitMessage'] = '%s\n\nDepends-On: %s\n' % (
+            A.subject, B.data['id'])
+
+        self.fake_gerrit.addEvent(A.getPatchsetCreatedEvent(1))
+        self.waitUntilSettled()
+
+        queue = self.gearman_server.getQueue()
+        ref = self.getParameter(queue[-1], 'ZUUL_REF')
+        self.gearman_server.hold_jobs_in_queue = False
+        self.gearman_server.release()
+        self.waitUntilSettled()
+
+        self.launch_server.release('.*-merge')
+        self.waitUntilSettled()
+
+        path = os.path.join(self.builds[0].jobdir.git_root, "org/project1")
+        repo = git.Repo(path)
+        repo_messages = [c.message.strip() for c in repo.iter_commits(ref)]
+        repo_messages.reverse()
+        correct_messages = [
+            'initial commit', 'add content from fixture', 'A-1']
+        self.assertEqual(repo_messages, correct_messages)
+
+        path = os.path.join(self.builds[0].jobdir.git_root, "org/project2")
+        repo = git.Repo(path)
+        repo_messages = [c.message.strip() for c in repo.iter_commits(ref)]
+        repo_messages.reverse()
+        correct_messages = [
+            'initial commit', 'add content from fixture', 'B-1']
+        self.assertEqual(repo_messages, correct_messages)
+
+        self.launch_server.hold_jobs_in_build = False
+        self.launch_server.release()
+        self.waitUntilSettled()
+
+        self.assertEqual(A.data['status'], 'NEW')
+        self.assertEqual(B.data['status'], 'NEW')
+        self.assertEqual(A.reported, 1)
+        self.assertEqual(B.reported, 0)
+
+        self.assertEqual(self.history[0].changes, '2,1 1,1')
+        tenant = self.sched.abide.tenants.get('tenant-one')
+        self.assertEqual(len(tenant.layout.pipelines['check'].queues), 0)
+
+    def test_crd_check_git_depends(self):
+        "Test single-repo dependencies in independent pipelines"
+        self.gearman_server.hold_jobs_in_build = True
+        A = self.fake_gerrit.addFakeChange('org/project1', 'master', 'A')
+        B = self.fake_gerrit.addFakeChange('org/project1', 'master', 'B')
+
+        # Add two git-dependent changes and make sure they both report
+        # success.
+        B.setDependsOn(A, 1)
+        self.fake_gerrit.addEvent(A.getPatchsetCreatedEvent(1))
+        self.waitUntilSettled()
+        self.fake_gerrit.addEvent(B.getPatchsetCreatedEvent(1))
+        self.waitUntilSettled()
+
+        self.orderedRelease()
+        self.gearman_server.hold_jobs_in_build = False
+        self.waitUntilSettled()
+
+        self.assertEqual(A.data['status'], 'NEW')
+        self.assertEqual(B.data['status'], 'NEW')
+        self.assertEqual(A.reported, 1)
+        self.assertEqual(B.reported, 1)
+
+        self.assertEqual(self.history[0].changes, '1,1')
+        self.assertEqual(self.history[-1].changes, '1,1 2,1')
+        tenant = self.sched.abide.tenants.get('tenant-one')
+        self.assertEqual(len(tenant.layout.pipelines['check'].queues), 0)
+
+        self.assertIn('Build succeeded', A.messages[0])
+        self.assertIn('Build succeeded', B.messages[0])
+
+    def test_crd_check_duplicate(self):
+        "Test duplicate check in independent pipelines"
+        self.launch_server.hold_jobs_in_build = True
+        A = self.fake_gerrit.addFakeChange('org/project1', 'master', 'A')
+        B = self.fake_gerrit.addFakeChange('org/project1', 'master', 'B')
+        tenant = self.sched.abide.tenants.get('tenant-one')
+        check_pipeline = tenant.layout.pipelines['check']
+
+        # Add two git-dependent changes...
+        B.setDependsOn(A, 1)
+        self.fake_gerrit.addEvent(B.getPatchsetCreatedEvent(1))
+        self.waitUntilSettled()
+        self.assertEqual(len(check_pipeline.getAllItems()), 2)
+
+        # ...make sure the live one is not duplicated...
+        self.fake_gerrit.addEvent(B.getPatchsetCreatedEvent(1))
+        self.waitUntilSettled()
+        self.assertEqual(len(check_pipeline.getAllItems()), 2)
+
+        # ...but the non-live one is able to be.
+        self.fake_gerrit.addEvent(A.getPatchsetCreatedEvent(1))
+        self.waitUntilSettled()
+        self.assertEqual(len(check_pipeline.getAllItems()), 3)
+
+        # Release jobs in order to avoid races with change A jobs
+        # finishing before change B jobs.
+        self.orderedRelease()
+        self.launch_server.hold_jobs_in_build = False
+        self.launch_server.release()
+        self.waitUntilSettled()
+
+        self.assertEqual(A.data['status'], 'NEW')
+        self.assertEqual(B.data['status'], 'NEW')
+        self.assertEqual(A.reported, 1)
+        self.assertEqual(B.reported, 1)
+
+        self.assertEqual(self.history[0].changes, '1,1 2,1')
+        self.assertEqual(self.history[1].changes, '1,1')
+        self.assertEqual(len(tenant.layout.pipelines['check'].queues), 0)
+
+        self.assertIn('Build succeeded', A.messages[0])
+        self.assertIn('Build succeeded', B.messages[0])
+
+    def _test_crd_check_reconfiguration(self, project1, project2):
+        "Test cross-repo dependencies re-enqueued in independent pipelines"
+
+        self.gearman_server.hold_jobs_in_queue = True
+        A = self.fake_gerrit.addFakeChange(project1, 'master', 'A')
+        B = self.fake_gerrit.addFakeChange(project2, 'master', 'B')
+
+        # A Depends-On: B
+        A.data['commitMessage'] = '%s\n\nDepends-On: %s\n' % (
+            A.subject, B.data['id'])
+
+        self.fake_gerrit.addEvent(A.getPatchsetCreatedEvent(1))
+        self.waitUntilSettled()
+
+        self.sched.reconfigure(self.config)
+
+        # Make sure the items still share a change queue, and the
+        # first one is not live.
+        tenant = self.sched.abide.tenants.get('tenant-one')
+        self.assertEqual(len(tenant.layout.pipelines['check'].queues), 1)
+        queue = tenant.layout.pipelines['check'].queues[0]
+        first_item = queue.queue[0]
+        for item in queue.queue:
+            self.assertEqual(item.queue, first_item.queue)
+        self.assertFalse(first_item.live)
+        self.assertTrue(queue.queue[1].live)
+
+        self.gearman_server.hold_jobs_in_queue = False
+        self.gearman_server.release()
+        self.waitUntilSettled()
+
+        self.assertEqual(A.data['status'], 'NEW')
+        self.assertEqual(B.data['status'], 'NEW')
+        self.assertEqual(A.reported, 1)
+        self.assertEqual(B.reported, 0)
+
+        self.assertEqual(self.history[0].changes, '2,1 1,1')
+        self.assertEqual(len(tenant.layout.pipelines['check'].queues), 0)
+
+    def test_crd_check_reconfiguration(self):
+        self._test_crd_check_reconfiguration('org/project1', 'org/project2')
+
+    def test_crd_undefined_project(self):
+        """Test that undefined projects in dependencies are handled for
+        independent pipelines"""
+        # It's a hack for fake gerrit,
+        # as it implies repo creation upon the creation of any change
+        self.init_repo("org/unknown")
+        self._test_crd_check_reconfiguration('org/project1', 'org/unknown')
+
+    @skip("Disabled for early v3 development")
+    def test_crd_check_ignore_dependencies(self):
+        "Test cross-repo dependencies can be ignored"
+        self.updateConfigLayout(
+            'tests/fixtures/layout-ignore-dependencies.yaml')
+        self.sched.reconfigure(self.config)
+        self.registerJobs()
+
+        self.gearman_server.hold_jobs_in_queue = True
+        A = self.fake_gerrit.addFakeChange('org/project1', 'master', 'A')
+        B = self.fake_gerrit.addFakeChange('org/project2', 'master', 'B')
+        C = self.fake_gerrit.addFakeChange('org/project2', 'master', 'C')
+
+        # A Depends-On: B
+        A.data['commitMessage'] = '%s\n\nDepends-On: %s\n' % (
+            A.subject, B.data['id'])
+        # C git-depends on B
+        C.setDependsOn(B, 1)
+        self.fake_gerrit.addEvent(A.getPatchsetCreatedEvent(1))
+        self.fake_gerrit.addEvent(B.getPatchsetCreatedEvent(1))
+        self.fake_gerrit.addEvent(C.getPatchsetCreatedEvent(1))
+        self.waitUntilSettled()
+
+        # Make sure none of the items share a change queue, and all
+        # are live.
+        check_pipeline = self.sched.layout.pipelines['check']
+        self.assertEqual(len(check_pipeline.queues), 3)
+        self.assertEqual(len(check_pipeline.getAllItems()), 3)
+        for item in check_pipeline.getAllItems():
+            self.assertTrue(item.live)
+
+        self.gearman_server.hold_jobs_in_queue = False
+        self.gearman_server.release()
+        self.waitUntilSettled()
+
+        self.assertEqual(A.data['status'], 'NEW')
+        self.assertEqual(B.data['status'], 'NEW')
+        self.assertEqual(C.data['status'], 'NEW')
+        self.assertEqual(A.reported, 1)
+        self.assertEqual(B.reported, 1)
+        self.assertEqual(C.reported, 1)
+
+        # Each job should have tested exactly one change
+        for job in self.history:
+            self.assertEqual(len(job.changes.split()), 1)
+
+    @skip("Disabled for early v3 development")
+    def test_crd_check_transitive(self):
+        "Test transitive cross-repo dependencies"
+        # Specifically, if A -> B -> C, and C gets a new patchset and
+        # A gets a new patchset, ensure the test of A,2 includes B,1
+        # and C,2 (not C,1 which would indicate stale data in the
+        # cache for B).
+        A = self.fake_gerrit.addFakeChange('org/project1', 'master', 'A')
+        B = self.fake_gerrit.addFakeChange('org/project2', 'master', 'B')
+        C = self.fake_gerrit.addFakeChange('org/project3', 'master', 'C')
+
+        # A Depends-On: B
+        A.data['commitMessage'] = '%s\n\nDepends-On: %s\n' % (
+            A.subject, B.data['id'])
+
+        # B Depends-On: C
+        B.data['commitMessage'] = '%s\n\nDepends-On: %s\n' % (
+            B.subject, C.data['id'])
+
+        self.fake_gerrit.addEvent(A.getPatchsetCreatedEvent(1))
+        self.waitUntilSettled()
+        self.assertEqual(self.history[-1].changes, '3,1 2,1 1,1')
+
+        self.fake_gerrit.addEvent(B.getPatchsetCreatedEvent(1))
+        self.waitUntilSettled()
+        self.assertEqual(self.history[-1].changes, '3,1 2,1')
+
+        self.fake_gerrit.addEvent(C.getPatchsetCreatedEvent(1))
+        self.waitUntilSettled()
+        self.assertEqual(self.history[-1].changes, '3,1')
+
+        C.addPatchset()
+        self.fake_gerrit.addEvent(C.getPatchsetCreatedEvent(2))
+        self.waitUntilSettled()
+        self.assertEqual(self.history[-1].changes, '3,2')
+
+        A.addPatchset()
+        self.fake_gerrit.addEvent(A.getPatchsetCreatedEvent(2))
+        self.waitUntilSettled()
+        self.assertEqual(self.history[-1].changes, '3,2 2,1 1,2')
+
+    def test_crd_check_unknown(self):
+        "Test unknown projects in independent pipeline"
+        self.init_repo("org/unknown")
+        A = self.fake_gerrit.addFakeChange('org/project1', 'master', 'A')
+        B = self.fake_gerrit.addFakeChange('org/unknown', 'master', 'D')
+        # A Depends-On: B
+        A.data['commitMessage'] = '%s\n\nDepends-On: %s\n' % (
+            A.subject, B.data['id'])
+
+        # Make sure zuul has seen an event on B.
+        self.fake_gerrit.addEvent(B.getPatchsetCreatedEvent(1))
+        self.fake_gerrit.addEvent(A.getPatchsetCreatedEvent(1))
+        self.waitUntilSettled()
+
+        self.assertEqual(A.data['status'], 'NEW')
+        self.assertEqual(A.reported, 1)
+        self.assertEqual(B.data['status'], 'NEW')
+        self.assertEqual(B.reported, 0)
+
+    def test_crd_cycle_join(self):
+        "Test an updated change creates a cycle"
+        A = self.fake_gerrit.addFakeChange('org/project2', 'master', 'A')
+
+        self.fake_gerrit.addEvent(A.getPatchsetCreatedEvent(1))
+        self.waitUntilSettled()
+
+        # Create B->A
+        B = self.fake_gerrit.addFakeChange('org/project1', 'master', 'B')
+        B.data['commitMessage'] = '%s\n\nDepends-On: %s\n' % (
+            B.subject, A.data['id'])
+        self.fake_gerrit.addEvent(B.getPatchsetCreatedEvent(1))
+        self.waitUntilSettled()
+
+        # Update A to add A->B (a cycle).
+        A.addPatchset()
+        A.data['commitMessage'] = '%s\n\nDepends-On: %s\n' % (
+            A.subject, B.data['id'])
+        # Normally we would submit the patchset-created event for
+        # processing here, however, we have no way of noting whether
+        # the dependency cycle detection correctly raised an
+        # exception, so instead, we reach into the source driver and
+        # call the method that would ultimately be called by the event
+        # processing.
+
+        tenant = self.sched.abide.tenants.get('tenant-one')
+        source = tenant.layout.pipelines['gate'].source
+
+        # TODO(pabelanger): As we add more source / trigger APIs we should make
+        # it easier for users to create events for testing.
+        event = zuul.model.TriggerEvent()
+        event.trigger_name = 'gerrit'
+        event.change_number = '1'
+        event.patch_number = '2'
+        with testtools.ExpectedException(
+            Exception, "Dependency cycle detected"):
+            source.getChange(event, True)
+        self.log.debug("Got expected dependency cycle exception")
+
+        # Now if we update B to remove the depends-on, everything
+        # should be okay.  B; A->B
+
+        B.addPatchset()
+        B.data['commitMessage'] = '%s\n' % (B.subject,)
+
+        source.getChange(event, True)
+        event.change_number = '2'
+        source.getChange(event, True)
+
+    def test_disable_at(self):
+        "Test a pipeline will only report to the disabled trigger when failing"
+
+        self.updateConfigLayout('layout-disabled-at')
+        self.sched.reconfigure(self.config)
+
+        tenant = self.sched.abide.tenants.get('openstack')
+        self.assertEqual(3, tenant.layout.pipelines['check'].disable_at)
+        self.assertEqual(
+            0, tenant.layout.pipelines['check']._consecutive_failures)
+        self.assertFalse(tenant.layout.pipelines['check']._disabled)
+
+        A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
+        B = self.fake_gerrit.addFakeChange('org/project', 'master', 'B')
+        C = self.fake_gerrit.addFakeChange('org/project', 'master', 'C')
+        D = self.fake_gerrit.addFakeChange('org/project', 'master', 'D')
+        E = self.fake_gerrit.addFakeChange('org/project', 'master', 'E')
+        F = self.fake_gerrit.addFakeChange('org/project', 'master', 'F')
+        G = self.fake_gerrit.addFakeChange('org/project', 'master', 'G')
+        H = self.fake_gerrit.addFakeChange('org/project', 'master', 'H')
+        I = self.fake_gerrit.addFakeChange('org/project', 'master', 'I')
+        J = self.fake_gerrit.addFakeChange('org/project', 'master', 'J')
+        K = self.fake_gerrit.addFakeChange('org/project', 'master', 'K')
+
+        self.launch_server.failJob('project-test1', A)
+        self.launch_server.failJob('project-test1', B)
+        # Let C pass, resetting the counter
+        self.launch_server.failJob('project-test1', D)
+        self.launch_server.failJob('project-test1', E)
+        self.launch_server.failJob('project-test1', F)
+        self.launch_server.failJob('project-test1', G)
+        self.launch_server.failJob('project-test1', H)
+        # I also passes but should only report to the disabled reporters
+        self.launch_server.failJob('project-test1', J)
+        self.launch_server.failJob('project-test1', K)
+
+        self.fake_gerrit.addEvent(A.getPatchsetCreatedEvent(1))
+        self.fake_gerrit.addEvent(B.getPatchsetCreatedEvent(1))
+        self.waitUntilSettled()
+
+        self.assertEqual(
+            2, tenant.layout.pipelines['check']._consecutive_failures)
+        self.assertFalse(tenant.layout.pipelines['check']._disabled)
+
+        self.fake_gerrit.addEvent(C.getPatchsetCreatedEvent(1))
+        self.waitUntilSettled()
+
+        self.assertEqual(
+            0, tenant.layout.pipelines['check']._consecutive_failures)
+        self.assertFalse(tenant.layout.pipelines['check']._disabled)
+
+        self.fake_gerrit.addEvent(D.getPatchsetCreatedEvent(1))
+        self.fake_gerrit.addEvent(E.getPatchsetCreatedEvent(1))
+        self.fake_gerrit.addEvent(F.getPatchsetCreatedEvent(1))
+        self.waitUntilSettled()
+
+        # We should be disabled now
+        self.assertEqual(
+            3, tenant.layout.pipelines['check']._consecutive_failures)
+        self.assertTrue(tenant.layout.pipelines['check']._disabled)
+
+        # We need to wait between each of these patches to make sure the
+        # smtp messages come back in an expected order
+        self.fake_gerrit.addEvent(G.getPatchsetCreatedEvent(1))
+        self.waitUntilSettled()
+        self.fake_gerrit.addEvent(H.getPatchsetCreatedEvent(1))
+        self.waitUntilSettled()
+        self.fake_gerrit.addEvent(I.getPatchsetCreatedEvent(1))
+        self.waitUntilSettled()
+
+        # The first 6 (ABCDEF) jobs should have reported back to gerrt thus
+        # leaving a message on each change
+        self.assertEqual(1, len(A.messages))
+        self.assertIn('Build failed.', A.messages[0])
+        self.assertEqual(1, len(B.messages))
+        self.assertIn('Build failed.', B.messages[0])
+        self.assertEqual(1, len(C.messages))
+        self.assertIn('Build succeeded.', C.messages[0])
+        self.assertEqual(1, len(D.messages))
+        self.assertIn('Build failed.', D.messages[0])
+        self.assertEqual(1, len(E.messages))
+        self.assertIn('Build failed.', E.messages[0])
+        self.assertEqual(1, len(F.messages))
+        self.assertIn('Build failed.', F.messages[0])
+
+        # The last 3 (GHI) would have only reported via smtp.
+        self.assertEqual(3, len(self.smtp_messages))
+        self.assertEqual(0, len(G.messages))
+        self.assertIn('Build failed.', self.smtp_messages[0]['body'])
+        self.assertIn(
+            'project-test1 https://server/job', self.smtp_messages[0]['body'])
+        self.assertEqual(0, len(H.messages))
+        self.assertIn('Build failed.', self.smtp_messages[1]['body'])
+        self.assertIn(
+            'project-test1 https://server/job', self.smtp_messages[1]['body'])
+        self.assertEqual(0, len(I.messages))
+        self.assertIn('Build succeeded.', self.smtp_messages[2]['body'])
+        self.assertIn(
+            'project-test1 https://server/job', self.smtp_messages[2]['body'])
+
+        # Now reload the configuration (simulate a HUP) to check the pipeline
+        # comes out of disabled
+        self.sched.reconfigure(self.config)
+
+        tenant = self.sched.abide.tenants.get('openstack')
+
+        self.assertEqual(3, tenant.layout.pipelines['check'].disable_at)
+        self.assertEqual(
+            0, tenant.layout.pipelines['check']._consecutive_failures)
+        self.assertFalse(tenant.layout.pipelines['check']._disabled)
+
+        self.fake_gerrit.addEvent(J.getPatchsetCreatedEvent(1))
+        self.fake_gerrit.addEvent(K.getPatchsetCreatedEvent(1))
+        self.waitUntilSettled()
+
+        self.assertEqual(
+            2, tenant.layout.pipelines['check']._consecutive_failures)
+        self.assertFalse(tenant.layout.pipelines['check']._disabled)
+
+        # J and K went back to gerrit
+        self.assertEqual(1, len(J.messages))
+        self.assertIn('Build failed.', J.messages[0])
+        self.assertEqual(1, len(K.messages))
+        self.assertIn('Build failed.', K.messages[0])
+        # No more messages reported via smtp
+        self.assertEqual(3, len(self.smtp_messages))
+
+    def test_rerun_on_abort(self):
+        "Test that if a launch server fails to run a job, it is run again"
+
+        self.launch_server.hold_jobs_in_build = True
+        A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
+        self.fake_gerrit.addEvent(A.getPatchsetCreatedEvent(1))
+        self.waitUntilSettled()
+
+        self.launch_server.release('.*-merge')
+        self.waitUntilSettled()
+
+        self.assertEqual(len(self.builds), 2)
+        self.builds[0].requeue = True
+        self.launch_server.release('.*-test*')
+        self.waitUntilSettled()
+
+        for x in range(3):
+            self.assertEqual(len(self.builds), 1,
+                             'len of builds at x=%d is wrong' % x)
+            self.builds[0].requeue = True
+            self.launch_server.release('.*-test1')
+            self.waitUntilSettled()
+
+        self.launch_server.hold_jobs_in_build = False
+        self.launch_server.release()
+        self.waitUntilSettled()
+        self.assertEqual(len(self.history), 6)
+        self.assertEqual(self.countJobResults(self.history, 'SUCCESS'), 2)
+        self.assertEqual(A.reported, 1)
+        self.assertIn('RETRY_LIMIT', A.messages[0])
+
+    def test_zookeeper_disconnect(self):
+        "Test that jobs are launched after a zookeeper disconnect"
+
+        self.fake_nodepool.paused = True
+        A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
+        A.addApproval('code-review', 2)
+        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
+        self.waitUntilSettled()
+
+        self.zk.client.stop()
+        self.zk.client.start()
+        self.fake_nodepool.paused = False
+        self.waitUntilSettled()
+
+        self.assertEqual(A.data['status'], 'MERGED')
+        self.assertEqual(A.reported, 2)
+
+    def test_nodepool_failure(self):
+        "Test that jobs are reported after a nodepool failure"
+
+        self.fake_nodepool.paused = True
+        A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
+        A.addApproval('code-review', 2)
+        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
+        self.waitUntilSettled()
+
+        req = self.fake_nodepool.getNodeRequests()[0]
+        self.fake_nodepool.addFailRequest(req)
+
+        self.fake_nodepool.paused = False
+        self.waitUntilSettled()
+
+        self.assertEqual(A.data['status'], 'NEW')
+        self.assertEqual(A.reported, 2)
+        self.assertIn('project-merge : NODE_FAILURE', A.messages[1])
+        self.assertIn('project-test1 : SKIPPED', A.messages[1])
+        self.assertIn('project-test2 : SKIPPED', A.messages[1])
+
+
+class TestDuplicatePipeline(ZuulTestCase):
+    tenant_config_file = 'config/duplicate-pipeline/main.yaml'
+
+    def test_duplicate_pipelines(self):
+        "Test that a change matching multiple pipelines works"
+
+        A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
+        self.fake_gerrit.addEvent(A.getChangeRestoredEvent())
+        self.waitUntilSettled()
+
+        self.assertHistory([
+            dict(name='project-test1', result='SUCCESS', changes='1,1',
+                 pipeline='dup1'),
+            dict(name='project-test1', result='SUCCESS', changes='1,1',
+                 pipeline='dup2'),
+        ], ordered=False)
+
+        self.assertEqual(len(A.messages), 2)
+
+        if 'dup1' in A.messages[0]:
+            self.assertIn('dup1', A.messages[0])
+            self.assertNotIn('dup2', A.messages[0])
+            self.assertIn('project-test1', A.messages[0])
+            self.assertIn('dup2', A.messages[1])
+            self.assertNotIn('dup1', A.messages[1])
+            self.assertIn('project-test1', A.messages[1])
+        else:
+            self.assertIn('dup1', A.messages[1])
+            self.assertNotIn('dup2', A.messages[1])
+            self.assertIn('project-test1', A.messages[1])
+            self.assertIn('dup2', A.messages[0])
+            self.assertNotIn('dup1', A.messages[0])
+            self.assertIn('project-test1', A.messages[0])
+
+
+class TestSchedulerOneJobProject(ZuulTestCase):
+    tenant_config_file = 'config/one-job-project/main.yaml'
+
+    def test_one_job_project(self):
+        "Test that queueing works with one job"
+        A = self.fake_gerrit.addFakeChange('org/one-job-project',
+                                           'master', 'A')
+        B = self.fake_gerrit.addFakeChange('org/one-job-project',
+                                           'master', 'B')
+        A.addApproval('code-review', 2)
+        B.addApproval('code-review', 2)
+        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
+        self.fake_gerrit.addEvent(B.addApproval('approved', 1))
+        self.waitUntilSettled()
+
+        self.assertEqual(A.data['status'], 'MERGED')
+        self.assertEqual(A.reported, 2)
+        self.assertEqual(B.data['status'], 'MERGED')
+        self.assertEqual(B.reported, 2)
+
+
+class TestSchedulerTemplatedProject(ZuulTestCase):
+    tenant_config_file = 'config/templated-project/main.yaml'
+
+    def test_job_from_templates_launched(self):
+        "Test whether a job generated via a template can be launched"
+
+        A = self.fake_gerrit.addFakeChange(
+            'org/templated-project', 'master', 'A')
+        self.fake_gerrit.addEvent(A.getPatchsetCreatedEvent(1))
+        self.waitUntilSettled()
+
+        self.assertEqual(self.getJobFromHistory('project-test1').result,
+                         'SUCCESS')
+        self.assertEqual(self.getJobFromHistory('project-test2').result,
+                         'SUCCESS')
+
+    def test_layered_templates(self):
+        "Test whether a job generated via a template can be launched"
+
+        A = self.fake_gerrit.addFakeChange(
+            'org/layered-project', 'master', 'A')
+        self.fake_gerrit.addEvent(A.getPatchsetCreatedEvent(1))
+        self.waitUntilSettled()
+
+        self.assertEqual(self.getJobFromHistory('project-test1').result,
+                         'SUCCESS')
+        self.assertEqual(self.getJobFromHistory('project-test2').result,
+                         'SUCCESS')
+        self.assertEqual(self.getJobFromHistory('layered-project-test3'
+                                                ).result, 'SUCCESS')
+        self.assertEqual(self.getJobFromHistory('layered-project-test4'
+                                                ).result, 'SUCCESS')
+        self.assertEqual(self.getJobFromHistory('layered-project-foo-test5'
+                                                ).result, 'SUCCESS')
+        self.assertEqual(self.getJobFromHistory('project-test6').result,
+                         'SUCCESS')
+
+
+class TestSchedulerSuccessURL(ZuulTestCase):
+    tenant_config_file = 'config/success-url/main.yaml'
+
+    def test_success_url(self):
+        "Ensure bad build params are ignored"
+        self.sched.reconfigure(self.config)
+        self.init_repo('org/docs')
+
+        A = self.fake_gerrit.addFakeChange('org/docs', 'master', 'A')
+        self.fake_gerrit.addEvent(A.getPatchsetCreatedEvent(1))
+        self.waitUntilSettled()
+
+        # Both builds ran: docs-draft-test + docs-draft-test2
+        self.assertEqual(len(self.history), 2)
+
+        # Grab build id
+        for build in self.history:
+            if build.name == 'docs-draft-test':
+                uuid = build.uuid[:7]
+                break
+
+        # Two msgs: 'Starting...'  + results
+        self.assertEqual(len(self.smtp_messages), 2)
+        body = self.smtp_messages[1]['body'].splitlines()
+        self.assertEqual('Build succeeded.', body[0])
+
+        self.assertIn(
+            '- docs-draft-test http://docs-draft.example.org/1/1/1/check/'
+            'docs-draft-test/%s/publish-docs/' % uuid,
+            body[2])
+
+        # NOTE: This default URL is currently hard-coded in launcher/server.py
+        self.assertIn(
+            '- docs-draft-test2 https://server/job',
+            body[3])
+
+
+class TestSchedulerMergeModes(ZuulTestCase):
+    tenant_config_file = 'config/merge-modes/main.yaml'
+
+    def _test_project_merge_mode(self, mode):
+        self.launch_server.keep_jobdir = False
+        project = 'org/project-%s' % mode
+        self.launch_server.hold_jobs_in_build = True
+        A = self.fake_gerrit.addFakeChange(project, 'master', 'A')
+        B = self.fake_gerrit.addFakeChange(project, 'master', 'B')
+        C = self.fake_gerrit.addFakeChange(project, 'master', 'C')
+        A.addApproval('code-review', 2)
+        B.addApproval('code-review', 2)
+        C.addApproval('code-review', 2)
+        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
+        self.fake_gerrit.addEvent(B.addApproval('approved', 1))
+        self.fake_gerrit.addEvent(C.addApproval('approved', 1))
+        self.waitUntilSettled()
+
+        build = self.builds[-1]
+        ref = self.getParameter(build, 'ZUUL_REF')
+
+        path = os.path.join(build.jobdir.git_root, project)
+        repo = git.Repo(path)
+        repo_messages = [c.message.strip() for c in repo.iter_commits(ref)]
+        repo_messages.reverse()
+
+        self.launch_server.hold_jobs_in_build = False
+        self.launch_server.release()
+        self.waitUntilSettled()
+
+        return repo_messages
+
+    def _test_merge(self, mode):
+        us_path = os.path.join(
+            self.upstream_root, 'org/project-%s' % mode)
+        expected_messages = [
+            'initial commit',
+            'add content from fixture',
+            # the intermediate commits order is nondeterministic
+            "Merge commit 'refs/changes/1/2/1' of %s into HEAD" % us_path,
+            "Merge commit 'refs/changes/1/3/1' of %s into HEAD" % us_path,
+        ]
+        result = self._test_project_merge_mode(mode)
+        self.assertEqual(result[:2], expected_messages[:2])
+        self.assertEqual(result[-2:], expected_messages[-2:])
+
+    def test_project_merge_mode_merge(self):
+        self._test_merge('merge')
+
+    def test_project_merge_mode_merge_resolve(self):
+        self._test_merge('merge-resolve')
+
+    def test_project_merge_mode_cherrypick(self):
+        expected_messages = [
+            'initial commit',
+            'add content from fixture',
+            'A-1',
+            'B-1',
+            'C-1']
+        result = self._test_project_merge_mode('cherry-pick')
+        self.assertEqual(result, expected_messages)
diff --git a/tests/unit/test_stack_dump.py b/tests/unit/test_stack_dump.py
new file mode 100644
index 0000000..824e04c
--- /dev/null
+++ b/tests/unit/test_stack_dump.py
@@ -0,0 +1,34 @@
+# Copyright 2013 Hewlett-Packard Development Company, L.P.
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+
+import fixtures
+import logging
+import signal
+import testtools
+
+import zuul.cmd
+
+
+class TestStackDump(testtools.TestCase):
+    def setUp(self):
+        super(TestStackDump, self).setUp()
+        self.log_fixture = self.useFixture(
+            fixtures.FakeLogger(level=logging.DEBUG))
+
+    def test_stack_dump_logs(self):
+        "Test that stack dumps end up in logs."
+
+        zuul.cmd.stack_dump_handler(signal.SIGUSR2, None)
+        self.assertIn("Thread", self.log_fixture.output)
+        self.assertIn("test_stack_dump_logs", self.log_fixture.output)
diff --git a/tests/unit/test_v3.py b/tests/unit/test_v3.py
new file mode 100644
index 0000000..8853302
--- /dev/null
+++ b/tests/unit/test_v3.py
@@ -0,0 +1,112 @@
+#!/usr/bin/env python
+
+# Copyright 2012 Hewlett-Packard Development Company, L.P.
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+
+import logging
+import textwrap
+
+from tests.base import AnsibleZuulTestCase
+
+logging.basicConfig(level=logging.DEBUG,
+                    format='%(asctime)s %(name)-32s '
+                    '%(levelname)-8s %(message)s')
+
+
+class TestMultipleTenants(AnsibleZuulTestCase):
+    # A temporary class to hold new tests while others are disabled
+
+    tenant_config_file = 'config/multi-tenant/main.yaml'
+
+    def test_multiple_tenants(self):
+        A = self.fake_gerrit.addFakeChange('org/project1', 'master', 'A')
+        A.addApproval('code-review', 2)
+        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
+        self.waitUntilSettled()
+        self.assertEqual(self.getJobFromHistory('project1-test1').result,
+                         'SUCCESS')
+        self.assertEqual(self.getJobFromHistory('python27').result,
+                         'SUCCESS')
+        self.assertEqual(A.data['status'], 'MERGED')
+        self.assertEqual(A.reported, 2,
+                         "A should report start and success")
+        self.assertIn('tenant-one-gate', A.messages[1],
+                      "A should transit tenant-one gate")
+        self.assertNotIn('tenant-two-gate', A.messages[1],
+                         "A should *not* transit tenant-two gate")
+
+        B = self.fake_gerrit.addFakeChange('org/project2', 'master', 'B')
+        B.addApproval('code-review', 2)
+        self.fake_gerrit.addEvent(B.addApproval('approved', 1))
+        self.waitUntilSettled()
+        self.assertEqual(self.getJobFromHistory('python27',
+                                                'org/project2').result,
+                         'SUCCESS')
+        self.assertEqual(self.getJobFromHistory('project2-test1').result,
+                         'SUCCESS')
+        self.assertEqual(B.data['status'], 'MERGED')
+        self.assertEqual(B.reported, 2,
+                         "B should report start and success")
+        self.assertIn('tenant-two-gate', B.messages[1],
+                      "B should transit tenant-two gate")
+        self.assertNotIn('tenant-one-gate', B.messages[1],
+                         "B should *not* transit tenant-one gate")
+
+        self.assertEqual(A.reported, 2, "Activity in tenant two should"
+                         "not affect tenant one")
+
+
+class TestInRepoConfig(AnsibleZuulTestCase):
+    # A temporary class to hold new tests while others are disabled
+
+    tenant_config_file = 'config/in-repo/main.yaml'
+
+    def test_in_repo_config(self):
+        A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
+        A.addApproval('code-review', 2)
+        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
+        self.waitUntilSettled()
+        self.assertEqual(self.getJobFromHistory('project-test1').result,
+                         'SUCCESS')
+        self.assertEqual(A.data['status'], 'MERGED')
+        self.assertEqual(A.reported, 2,
+                         "A should report start and success")
+        self.assertIn('tenant-one-gate', A.messages[1],
+                      "A should transit tenant-one gate")
+
+    def test_dynamic_config(self):
+        in_repo_conf = textwrap.dedent(
+            """
+            - job:
+                name: project-test2
+
+            - project:
+                name: org/project
+                tenant-one-gate:
+                  jobs:
+                    - project-test2
+            """)
+
+        A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A',
+                                           files={'.zuul.yaml': in_repo_conf})
+        A.addApproval('code-review', 2)
+        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
+        self.waitUntilSettled()
+        self.assertEqual(self.getJobFromHistory('project-test2').result,
+                         'SUCCESS')
+        self.assertEqual(A.data['status'], 'MERGED')
+        self.assertEqual(A.reported, 2,
+                         "A should report start and success")
+        self.assertIn('tenant-one-gate', A.messages[1],
+                      "A should transit tenant-one gate")
diff --git a/tests/unit/test_webapp.py b/tests/unit/test_webapp.py
new file mode 100644
index 0000000..2211d1b
--- /dev/null
+++ b/tests/unit/test_webapp.py
@@ -0,0 +1,87 @@
+#!/usr/bin/env python
+
+# Copyright 2014 Hewlett-Packard Development Company, L.P.
+# Copyright 2014 Rackspace Australia
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+
+import json
+
+from six.moves import urllib
+
+from tests.base import ZuulTestCase
+
+
+class TestWebapp(ZuulTestCase):
+    tenant_config_file = 'config/single-tenant/main.yaml'
+
+    def setUp(self):
+        super(TestWebapp, self).setUp()
+        self.launch_server.hold_jobs_in_build = True
+        A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
+        A.addApproval('code-review', 2)
+        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
+        B = self.fake_gerrit.addFakeChange('org/project1', 'master', 'B')
+        B.addApproval('code-review', 2)
+        self.fake_gerrit.addEvent(B.addApproval('approved', 1))
+        self.waitUntilSettled()
+        self.port = self.webapp.server.socket.getsockname()[1]
+
+    def tearDown(self):
+        self.launch_server.hold_jobs_in_build = False
+        self.launch_server.release()
+        self.waitUntilSettled()
+        super(TestWebapp, self).tearDown()
+
+    def test_webapp_status(self):
+        "Test that we can filter to only certain changes in the webapp."
+
+        req = urllib.request.Request(
+            "http://localhost:%s/tenant-one/status" % self.port)
+        f = urllib.request.urlopen(req)
+        data = json.loads(f.read())
+
+        self.assertIn('pipelines', data)
+
+    def test_webapp_status_compat(self):
+        # testing compat with status.json
+        req = urllib.request.Request(
+            "http://localhost:%s/tenant-one/status.json" % self.port)
+        f = urllib.request.urlopen(req)
+        data = json.loads(f.read())
+
+        self.assertIn('pipelines', data)
+
+    def test_webapp_bad_url(self):
+        # do we 404 correctly
+        req = urllib.request.Request(
+            "http://localhost:%s/status/foo" % self.port)
+        self.assertRaises(urllib.error.HTTPError, urllib.request.urlopen, req)
+
+    def test_webapp_find_change(self):
+        # can we filter by change id
+        req = urllib.request.Request(
+            "http://localhost:%s/tenant-one/status/change/1,1" % self.port)
+        f = urllib.request.urlopen(req)
+        data = json.loads(f.read())
+
+        self.assertEqual(1, len(data), data)
+        self.assertEqual("org/project", data[0]['project'])
+
+        req = urllib.request.Request(
+            "http://localhost:%s/tenant-one/status/change/2,1" % self.port)
+        f = urllib.request.urlopen(req)
+        data = json.loads(f.read())
+
+        self.assertEqual(1, len(data), data)
+        self.assertEqual("org/project1", data[0]['project'], data)
diff --git a/tests/unit/test_zuultrigger.py b/tests/unit/test_zuultrigger.py
new file mode 100644
index 0000000..b36e5a4
--- /dev/null
+++ b/tests/unit/test_zuultrigger.py
@@ -0,0 +1,139 @@
+#!/usr/bin/env python
+
+# Copyright 2014 Hewlett-Packard Development Company, L.P.
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+
+import logging
+
+from tests.base import ZuulTestCase
+
+logging.basicConfig(level=logging.DEBUG,
+                    format='%(asctime)s %(name)-32s '
+                    '%(levelname)-8s %(message)s')
+
+
+class TestZuulTriggerParentChangeEnqueued(ZuulTestCase):
+    tenant_config_file = 'config/zuultrigger/parent-change-enqueued/main.yaml'
+
+    def test_zuul_trigger_parent_change_enqueued(self):
+        "Test Zuul trigger event: parent-change-enqueued"
+        # This test has the following three changes:
+        # B1 -> A; B2 -> A
+        # When A is enqueued in the gate, B1 and B2 should both attempt
+        # to be enqueued in both pipelines.  B1 should end up in check
+        # and B2 in gate because of differing pipeline requirements.
+        self.launch_server.hold_jobs_in_build = True
+        A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
+        B1 = self.fake_gerrit.addFakeChange('org/project', 'master', 'B1')
+        B2 = self.fake_gerrit.addFakeChange('org/project', 'master', 'B2')
+        A.addApproval('code-review', 2)
+        B1.addApproval('code-review', 2)
+        B2.addApproval('code-review', 2)
+        A.addApproval('verified', 1)    # required by gate
+        B1.addApproval('verified', -1)  # should go to check
+        B2.addApproval('verified', 1)   # should go to gate
+        B1.addApproval('approved', 1)
+        B2.addApproval('approved', 1)
+        B1.setDependsOn(A, 1)
+        B2.setDependsOn(A, 1)
+        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
+        # Jobs are being held in build to make sure that 3,1 has time
+        # to enqueue behind 1,1 so that the test is more
+        # deterministic.
+        self.waitUntilSettled()
+        self.launch_server.hold_jobs_in_build = False
+        self.launch_server.release()
+        self.waitUntilSettled()
+
+        self.assertEqual(len(self.history), 3)
+        for job in self.history:
+            if job.changes == '1,1':
+                self.assertEqual(job.name, 'project-gate')
+            elif job.changes == '1,1 2,1':
+                self.assertEqual(job.name, 'project-check')
+            elif job.changes == '1,1 3,1':
+                self.assertEqual(job.name, 'project-gate')
+            else:
+                raise Exception("Unknown job")
+
+
+class TestZuulTriggerProjectChangeMerged(ZuulTestCase):
+
+    def setUp(self):
+        self.skip("Disabled because v3 noop job does not perform merge")
+
+    tenant_config_file = 'config/zuultrigger/project-change-merged/main.yaml'
+
+    def test_zuul_trigger_project_change_merged(self):
+        # This test has the following three changes:
+        # A, B, C;  B conflicts with A, but C does not.
+        # When A is merged, B and C should be checked for conflicts,
+        # and B should receive a -1.
+        # D and E are used to repeat the test in the second part, but
+        # are defined here to that they end up in the trigger cache.
+        A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
+        B = self.fake_gerrit.addFakeChange('org/project', 'master', 'B')
+        C = self.fake_gerrit.addFakeChange('org/project', 'master', 'C')
+        D = self.fake_gerrit.addFakeChange('org/project', 'master', 'D')
+        E = self.fake_gerrit.addFakeChange('org/project', 'master', 'E')
+        A.addPatchset({'conflict': 'foo'})
+        B.addPatchset({'conflict': 'bar'})
+        D.addPatchset({'conflict2': 'foo'})
+        E.addPatchset({'conflict2': 'bar'})
+        A.addApproval('code-review', 2)
+        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
+        self.waitUntilSettled()
+
+        self.assertEqual(len(self.history), 1)
+        self.assertEqual(self.history[0].name, 'project-gate')
+        self.assertEqual(A.reported, 2)
+        self.assertEqual(B.reported, 1)
+        self.assertEqual(C.reported, 0)
+        self.assertEqual(D.reported, 0)
+        self.assertEqual(E.reported, 0)
+        self.assertEqual(
+            B.messages[0],
+            "Merge Failed.\n\nThis change or one of its cross-repo "
+            "dependencies was unable to be automatically merged with the "
+            "current state of its repository. Please rebase the change and "
+            "upload a new patchset.")
+
+        self.assertTrue("project:org/project status:open" in
+                        self.fake_gerrit.queries)
+
+        # Reconfigure and run the test again.  This is a regression
+        # check to make sure that we don't end up with a stale trigger
+        # cache that has references to projects from the old
+        # configuration.
+        self.sched.reconfigure(self.config)
+
+        D.addApproval('code-review', 2)
+        self.fake_gerrit.addEvent(D.addApproval('approved', 1))
+        self.waitUntilSettled()
+
+        self.assertEqual(len(self.history), 2)
+        self.assertEqual(self.history[1].name, 'project-gate')
+        self.assertEqual(A.reported, 2)
+        self.assertEqual(B.reported, 1)
+        self.assertEqual(C.reported, 0)
+        self.assertEqual(D.reported, 2)
+        self.assertEqual(E.reported, 1)
+        self.assertEqual(
+            E.messages[0],
+            "Merge Failed.\n\nThis change or one of its cross-repo "
+            "dependencies was unable to be automatically merged with the "
+            "current state of its repository. Please rebase the change and "
+            "upload a new patchset.")
+        self.assertEqual(self.fake_gerrit.queries[1],
+                         "project:org/project status:open")