Merge "Remove pipeline argument from various report fncts" into feature/zuulv3
diff --git a/doc/source/reporters.rst b/doc/source/reporters.rst
index e3ab947..dd053fa 100644
--- a/doc/source/reporters.rst
+++ b/doc/source/reporters.rst
@@ -44,6 +44,10 @@
   set as the commit status on github.
   ``status: 'success'``
 
+  **status-url**
+  String value for a link url to set in the github status. Defaults to the zuul
+  server status_url, or the empty string if that is unset.
+
   **comment**
   Boolean value (``true`` or ``false``) that determines if the reporter should
   add a comment to the pipeline status to the github pull request. Defaults
diff --git a/doc/source/zuul.rst b/doc/source/zuul.rst
index a78a0fa..a7dfb44 100644
--- a/doc/source/zuul.rst
+++ b/doc/source/zuul.rst
@@ -174,6 +174,10 @@
 
 The zuul-executor process configuration.
 
+**finger_port**
+  Port to use for finger log streamer.
+  ``finger_port=79``
+
 **git_dir**
   Directory that Zuul should clone local git repositories to.
   ``git_dir=/var/lib/zuul/git``
diff --git a/etc/zuul.conf-sample b/etc/zuul.conf-sample
index bf19895..1065cec 100644
--- a/etc/zuul.conf-sample
+++ b/etc/zuul.conf-sample
@@ -18,6 +18,9 @@
 ;git_user_name=zuul
 zuul_url=http://zuul.example.com/p
 
+[executor]
+default_username=zuul
+
 [webapp]
 listen_address=0.0.0.0
 port=8001
diff --git a/setup.cfg b/setup.cfg
index 9ee64f3..5ae0903 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -26,6 +26,7 @@
     zuul = zuul.cmd.client:main
     zuul-cloner = zuul.cmd.cloner:main
     zuul-executor = zuul.cmd.executor:main
+    zuul-bwrap = zuul.driver.bubblewrap:main
 
 [build_sphinx]
 source-dir = doc/source
diff --git a/tests/base.py b/tests/base.py
index 3082921..48716e2 100755
--- a/tests/base.py
+++ b/tests/base.py
@@ -592,6 +592,21 @@
     def getPullRequestClosedEvent(self):
         return self._getPullRequestEvent('closed')
 
+    def getPushEvent(self, old_sha, ref='refs/heads/master'):
+        name = 'push'
+        data = {
+            'ref': ref,
+            'before': old_sha,
+            'after': self.head_sha,
+            'repository': {
+                'full_name': self.project
+            },
+            'sender': {
+                'login': 'ghuser'
+            }
+        }
+        return (name, data)
+
     def addComment(self, message):
         self.comments.append(message)
         self._updateTimeStamp()
@@ -1206,6 +1221,25 @@
         self.log.debug("  OK")
         return True
 
+    def getWorkspaceRepos(self, projects):
+        """Return workspace git repo objects for the listed projects
+
+        :arg list projects: A list of strings, each the canonical name
+                            of a project.
+
+        :returns: A dictionary of {name: repo} for every listed
+                  project.
+        :rtype: dict
+
+        """
+
+        repos = {}
+        for project in projects:
+            path = os.path.join(self.jobdir.src_root, project)
+            repo = git.Repo(path)
+            repos[project] = repo
+        return repos
+
 
 class RecordingExecutorServer(zuul.executor.server.ExecutorServer):
     """An Ansible executor to be used in tests.
@@ -1292,10 +1326,10 @@
 
 
 class RecordingAnsibleJob(zuul.executor.server.AnsibleJob):
-    def doMergeChanges(self, items, repo_state):
+    def doMergeChanges(self, merger, items, repo_state):
         # Get a merger in order to update the repos involved in this job.
         commit = super(RecordingAnsibleJob, self).doMergeChanges(
-            items, repo_state)
+            merger, items, repo_state)
         if not commit:  # merge conflict
             self.recordResult('MERGER_FAILURE')
         return commit
@@ -1391,9 +1425,9 @@
                 len(self.low_queue))
         self.log.debug("releasing queued job %s (%s)" % (regex, qlen))
         for job in self.getQueue():
-            if job.name != 'executor:execute':
+            if job.name != b'executor:execute':
                 continue
-            parameters = json.loads(job.arguments)
+            parameters = json.loads(job.arguments.decode('utf8'))
             if not regex or re.match(regex, parameters.get('job')):
                 self.log.debug("releasing queued job %s" %
                                job.unique)
@@ -1844,12 +1878,20 @@
 
         # Make per test copy of Configuration.
         self.setup_config()
+        self.private_key_file = os.path.join(self.test_root, 'test_id_rsa')
+        if not os.path.exists(self.private_key_file):
+            src_private_key_file = os.path.join(FIXTURE_DIR, 'test_id_rsa')
+            shutil.copy(src_private_key_file, self.private_key_file)
+            shutil.copy('{}.pub'.format(src_private_key_file),
+                        '{}.pub'.format(self.private_key_file))
+            os.chmod(self.private_key_file, 0o0600)
         self.config.set('zuul', 'tenant_config',
                         os.path.join(FIXTURE_DIR,
                                      self.config.get('zuul', 'tenant_config')))
         self.config.set('merger', 'git_dir', self.merger_src_root)
         self.config.set('executor', 'git_dir', self.executor_src_root)
         self.config.set('zuul', 'state_dir', self.state_root)
+        self.config.set('executor', 'private_key_file', self.private_key_file)
 
         self.statsd = FakeStatsd()
         # note, use 127.0.0.1 rather than localhost to avoid getting ipv6
@@ -2167,6 +2209,8 @@
 
     def shutdown(self):
         self.log.debug("Shutting down after tests")
+        self.executor_server.hold_jobs_in_build = False
+        self.executor_server.release()
         self.executor_client.stop()
         self.merge_client.stop()
         self.executor_server.stop()
@@ -2293,7 +2337,8 @@
             # It hasn't been reported yet.
             return False
         # Make sure that none of the worker connections are in GRAB_WAIT
-        for connection in self.executor_server.worker.active_connections:
+        worker = self.executor_server.executor_worker
+        for connection in worker.active_connections:
             if connection.state == 'GRAB_WAIT':
                 return False
         return True
@@ -2412,6 +2457,12 @@
         jobs = filter(lambda x: x.result == result, jobs)
         return len(list(jobs))
 
+    def getBuildByName(self, name):
+        for build in self.builds:
+            if build.name == name:
+                return build
+        raise Exception("Unable to find build %s" % name)
+
     def getJobFromHistory(self, name, project=None):
         for job in self.history:
             if (job.name == name and
@@ -2645,6 +2696,29 @@
                 specified_conn.server == conn.server):
                 conn.addEvent(event)
 
+    def getUpstreamRepos(self, projects):
+        """Return upstream git repo objects for the listed projects
+
+        :arg list projects: A list of strings, each the canonical name
+                            of a project.
+
+        :returns: A dictionary of {name: repo} for every listed
+                  project.
+        :rtype: dict
+
+        """
+
+        repos = {}
+        for project in projects:
+            # FIXME(jeblair): the upstream root does not yet have a
+            # hostname component; that needs to be added, and this
+            # line removed:
+            tmp_project_name = '/'.join(project.split('/')[1:])
+            path = os.path.join(self.upstream_root, tmp_project_name)
+            repo = git.Repo(path)
+            repos[project] = repo
+        return repos
+
 
 class AnsibleZuulTestCase(ZuulTestCase):
     """ZuulTestCase but with an actual ansible executor running"""
diff --git a/tests/fixtures/config/ansible/git/common-config/playbooks/hello-post.yaml b/tests/fixtures/config/ansible/git/common-config/playbooks/hello-post.yaml
new file mode 100644
index 0000000..d528be1
--- /dev/null
+++ b/tests/fixtures/config/ansible/git/common-config/playbooks/hello-post.yaml
@@ -0,0 +1,12 @@
+- hosts: all
+  tasks:
+    - name: Register hello-world.txt file.
+      stat:
+        path: "{{zuul.executor.log_root}}/hello-world.txt"
+      register: st
+
+    - name: Assert hello-world.txt file.
+      assert:
+        that:
+          - st.stat.exists
+          - st.stat.isreg
diff --git a/tests/fixtures/config/ansible/git/common-config/zuul.yaml b/tests/fixtures/config/ansible/git/common-config/zuul.yaml
index f9be158..02b87bd 100644
--- a/tests/fixtures/config/ansible/git/common-config/zuul.yaml
+++ b/tests/fixtures/config/ansible/git/common-config/zuul.yaml
@@ -72,3 +72,7 @@
     nodes:
       - name: ubuntu-xenial
         image: ubuntu-xenial
+
+- job:
+    name: hello
+    post-run: hello-post
diff --git a/tests/fixtures/config/ansible/git/org_project/.zuul.yaml b/tests/fixtures/config/ansible/git/org_project/.zuul.yaml
index a2d9c6f..ca734c5 100644
--- a/tests/fixtures/config/ansible/git/org_project/.zuul.yaml
+++ b/tests/fixtures/config/ansible/git/org_project/.zuul.yaml
@@ -2,6 +2,10 @@
     parent: python27
     name: faillocal
 
+- job:
+    parent: hello
+    name: hello-world
+
 - project:
     name: org/project
     check:
@@ -10,3 +14,4 @@
         - faillocal
         - check-vars
         - timeout
+        - hello-world
diff --git a/tests/fixtures/config/ansible/git/org_project/playbooks/hello-world.yaml b/tests/fixtures/config/ansible/git/org_project/playbooks/hello-world.yaml
new file mode 100644
index 0000000..373de02
--- /dev/null
+++ b/tests/fixtures/config/ansible/git/org_project/playbooks/hello-world.yaml
@@ -0,0 +1,5 @@
+- hosts: all
+  tasks:
+    - copy:
+        content: "hello world"
+        dest: "{{zuul.executor.log_root}}/hello-world.txt"
diff --git a/tests/fixtures/config/inventory/git/common-config/playbooks/group-inventory.yaml b/tests/fixtures/config/inventory/git/common-config/playbooks/group-inventory.yaml
new file mode 100644
index 0000000..f679dce
--- /dev/null
+++ b/tests/fixtures/config/inventory/git/common-config/playbooks/group-inventory.yaml
@@ -0,0 +1,2 @@
+- hosts: all
+  tasks: []
diff --git a/tests/fixtures/config/inventory/git/common-config/playbooks/single-inventory.yaml b/tests/fixtures/config/inventory/git/common-config/playbooks/single-inventory.yaml
new file mode 100644
index 0000000..f679dce
--- /dev/null
+++ b/tests/fixtures/config/inventory/git/common-config/playbooks/single-inventory.yaml
@@ -0,0 +1,2 @@
+- hosts: all
+  tasks: []
diff --git a/tests/fixtures/config/inventory/git/common-config/zuul.yaml b/tests/fixtures/config/inventory/git/common-config/zuul.yaml
new file mode 100644
index 0000000..184bd80
--- /dev/null
+++ b/tests/fixtures/config/inventory/git/common-config/zuul.yaml
@@ -0,0 +1,42 @@
+- pipeline:
+    name: check
+    manager: independent
+    allow-secrets: true
+    trigger:
+      gerrit:
+        - event: patchset-created
+    success:
+      gerrit:
+        verified: 1
+    failure:
+      gerrit:
+        verified: -1
+
+- nodeset:
+    name: nodeset1
+    nodes:
+      - name: controller
+        image: controller-image
+      - name: compute1
+        image: compute-image
+      - name: compute2
+        image: compute-image
+    groups:
+      - name: ceph-osd
+        nodes:
+          - controller
+      - name: ceph-monitor
+        nodes:
+          - controller
+          - compute1
+          - compute2
+
+- job:
+    name: single-inventory
+    nodes:
+      - name: ubuntu-xenial
+        image: ubuntu-xenial
+
+- job:
+    name: group-inventory
+    nodes: nodeset1
diff --git a/tests/fixtures/config/inventory/git/org_project/.zuul.yaml b/tests/fixtures/config/inventory/git/org_project/.zuul.yaml
new file mode 100644
index 0000000..26310a0
--- /dev/null
+++ b/tests/fixtures/config/inventory/git/org_project/.zuul.yaml
@@ -0,0 +1,6 @@
+- project:
+    name: org/project
+    check:
+      jobs:
+        - single-inventory
+        - group-inventory
diff --git a/tests/fixtures/config/inventory/git/org_project/README b/tests/fixtures/config/inventory/git/org_project/README
new file mode 100644
index 0000000..9daeafb
--- /dev/null
+++ b/tests/fixtures/config/inventory/git/org_project/README
@@ -0,0 +1 @@
+test
diff --git a/tests/fixtures/config/inventory/main.yaml b/tests/fixtures/config/inventory/main.yaml
new file mode 100644
index 0000000..208e274
--- /dev/null
+++ b/tests/fixtures/config/inventory/main.yaml
@@ -0,0 +1,8 @@
+- tenant:
+    name: tenant-one
+    source:
+      gerrit:
+        config-projects:
+          - common-config
+        untrusted-projects:
+          - org/project
diff --git a/tests/fixtures/config/openstack/git/project-config/zuul.yaml b/tests/fixtures/config/openstack/git/project-config/zuul.yaml
index 5d0c774..aff2046 100644
--- a/tests/fixtures/config/openstack/git/project-config/zuul.yaml
+++ b/tests/fixtures/config/openstack/git/project-config/zuul.yaml
@@ -66,7 +66,7 @@
 - job:
     name: dsvm
     parent: base
-    repos:
+    required-projects:
       - openstack/keystone
       - openstack/nova
 
diff --git a/tests/fixtures/config/push-reqs/git/common-config/playbooks/job1.yaml b/tests/fixtures/config/push-reqs/git/common-config/playbooks/job1.yaml
new file mode 100644
index 0000000..f679dce
--- /dev/null
+++ b/tests/fixtures/config/push-reqs/git/common-config/playbooks/job1.yaml
@@ -0,0 +1,2 @@
+- hosts: all
+  tasks: []
diff --git a/tests/fixtures/config/push-reqs/git/common-config/zuul.yaml b/tests/fixtures/config/push-reqs/git/common-config/zuul.yaml
new file mode 100644
index 0000000..6569966
--- /dev/null
+++ b/tests/fixtures/config/push-reqs/git/common-config/zuul.yaml
@@ -0,0 +1,119 @@
+- pipeline:
+    name: current
+    manager: independent
+    require:
+      github:
+        current-patchset: true
+      gerrit:
+        current-patchset: true
+    trigger:
+      github:
+        - event: push
+      gerrit:
+        - event: ref-updated
+
+- pipeline:
+    name: open
+    manager: independent
+    require:
+      github:
+        open: true
+      gerrit:
+        open: true
+    trigger:
+      github:
+        - event: push
+      gerrit:
+        - event: ref-updated
+
+- pipeline:
+    name: review
+    manager: independent
+    require:
+      github:
+        review:
+          - type: approval
+      gerrit:
+        approval:
+          - email: herp@derp.invalid
+    trigger:
+      github:
+        - event: push
+      gerrit:
+        - event: ref-updated
+
+- pipeline:
+    name: status
+    manager: independent
+    require:
+      github:
+        status: 'zuul:check:success'
+    trigger:
+      github:
+        - event: push
+
+- pipeline:
+    name: pushhub
+    manager: independent
+    require:
+      gerrit:
+        open: true
+    trigger:
+      github:
+        - event: push
+      gerrit:
+        - event: ref-updated
+
+- pipeline:
+    name: pushgerrit
+    manager: independent
+    require:
+      github:
+        open: true
+    trigger:
+      github:
+        - event: push
+      gerrit:
+        - event: ref-updated
+
+- job:
+    name: job1
+
+- project:
+    name: org/project1
+    current:
+      jobs:
+        - job1
+    open:
+      jobs:
+        - job1
+    review:
+      jobs:
+        - job1
+    status:
+      jobs:
+        - job1
+    pushhub:
+      jobs:
+        - job1
+    pushgerrit:
+      jobs:
+        - job1
+
+- project:
+    name: org/project2
+    current:
+      jobs:
+        - job1
+    open:
+      jobs:
+        - job1
+    review:
+      jobs:
+        - job1
+    pushhub:
+      jobs:
+        - job1
+    pushgerrit:
+      jobs:
+        - job1
diff --git a/tests/fixtures/config/push-reqs/git/org_project1/README b/tests/fixtures/config/push-reqs/git/org_project1/README
new file mode 100644
index 0000000..9daeafb
--- /dev/null
+++ b/tests/fixtures/config/push-reqs/git/org_project1/README
@@ -0,0 +1 @@
+test
diff --git a/tests/fixtures/config/push-reqs/git/org_project2/README b/tests/fixtures/config/push-reqs/git/org_project2/README
new file mode 100644
index 0000000..9daeafb
--- /dev/null
+++ b/tests/fixtures/config/push-reqs/git/org_project2/README
@@ -0,0 +1 @@
+test
diff --git a/tests/fixtures/config/push-reqs/main.yaml b/tests/fixtures/config/push-reqs/main.yaml
new file mode 100644
index 0000000..d9f1a42
--- /dev/null
+++ b/tests/fixtures/config/push-reqs/main.yaml
@@ -0,0 +1,11 @@
+- tenant:
+    name: tenant-one
+    source:
+      github:
+        config-projects:
+          - common-config
+        untrusted-projects:
+          - org/project1
+      gerrit:
+        untrusted-projects:
+          - org/project2
diff --git a/tests/fixtures/layouts/repo-checkout-four-project.yaml b/tests/fixtures/layouts/repo-checkout-four-project.yaml
new file mode 100644
index 0000000..392931a
--- /dev/null
+++ b/tests/fixtures/layouts/repo-checkout-four-project.yaml
@@ -0,0 +1,81 @@
+- pipeline:
+    name: check
+    manager: independent
+    trigger:
+      gerrit:
+        - event: patchset-created
+    success:
+      gerrit:
+        verified: 1
+    failure:
+      gerrit:
+        verified: -1
+
+- pipeline:
+    name: gate
+    manager: dependent
+    success-message: Build succeeded (gate).
+    trigger:
+      gerrit:
+        - event: comment-added
+          approval:
+            - approved: 1
+    success:
+      gerrit:
+        verified: 2
+        submit: true
+    failure:
+      gerrit:
+        verified: -2
+    start:
+      gerrit:
+        verified: 0
+    precedence: high
+
+- job:
+    name: integration
+    required-projects:
+      - org/project1
+      - org/project2
+      - org/project3
+      - org/project4
+
+- project:
+    name: org/project1
+    check:
+      jobs:
+        - integration
+    gate:
+      queue: integrated
+      jobs:
+        - integration
+
+- project:
+    name: org/project2
+    check:
+      jobs:
+        - integration
+    gate:
+      queue: integrated
+      jobs:
+        - integration
+
+- project:
+    name: org/project3
+    check:
+      jobs:
+        - integration
+    gate:
+      queue: integrated
+      jobs:
+        - integration
+
+- project:
+    name: org/project4
+    check:
+      jobs:
+        - integration
+    gate:
+      queue: integrated
+      jobs:
+        - integration
diff --git a/tests/fixtures/layouts/repo-checkout-no-timer.yaml b/tests/fixtures/layouts/repo-checkout-no-timer.yaml
new file mode 100644
index 0000000..2b65850
--- /dev/null
+++ b/tests/fixtures/layouts/repo-checkout-no-timer.yaml
@@ -0,0 +1,20 @@
+- pipeline:
+    name: periodic
+    manager: independent
+    # Trigger is required, set it to one that is a noop
+    # during tests that check the timer trigger.
+    trigger:
+      gerrit:
+        - event: ref-updated
+
+- job:
+    name: integration
+    override-branch: stable/havana
+    required-projects:
+      - org/project1
+
+- project:
+    name: org/project1
+    periodic:
+      jobs:
+        - integration
diff --git a/tests/fixtures/layouts/repo-checkout-post.yaml b/tests/fixtures/layouts/repo-checkout-post.yaml
new file mode 100644
index 0000000..9698289
--- /dev/null
+++ b/tests/fixtures/layouts/repo-checkout-post.yaml
@@ -0,0 +1,25 @@
+- pipeline:
+    name: post
+    manager: independent
+    trigger:
+      gerrit:
+        - event: ref-updated
+          ref: ^(?!refs/).*$
+
+- job:
+    name: integration
+    required-projects:
+      - org/project1
+      - org/project2
+
+- project:
+    name: org/project1
+    post:
+      jobs:
+        - integration
+
+- project:
+    name: org/project2
+    post:
+      jobs:
+        - integration
diff --git a/tests/fixtures/layouts/repo-checkout-six-project.yaml b/tests/fixtures/layouts/repo-checkout-six-project.yaml
new file mode 100644
index 0000000..93a64ea
--- /dev/null
+++ b/tests/fixtures/layouts/repo-checkout-six-project.yaml
@@ -0,0 +1,104 @@
+- pipeline:
+    name: check
+    manager: independent
+    trigger:
+      gerrit:
+        - event: patchset-created
+    success:
+      gerrit:
+        verified: 1
+    failure:
+      gerrit:
+        verified: -1
+
+- pipeline:
+    name: gate
+    manager: dependent
+    success-message: Build succeeded (gate).
+    trigger:
+      gerrit:
+        - event: comment-added
+          approval:
+            - approved: 1
+    success:
+      gerrit:
+        verified: 2
+        submit: true
+    failure:
+      gerrit:
+        verified: -2
+    start:
+      gerrit:
+        verified: 0
+    precedence: high
+
+- job:
+    name: integration
+    required-projects:
+      - org/project1
+      - org/project2
+      - org/project3
+      - name: org/project4
+        override-branch: master
+      - org/project5
+      - org/project6
+
+- project:
+    name: org/project1
+    check:
+      jobs:
+        - integration
+    gate:
+      queue: integrated
+      jobs:
+        - integration
+
+- project:
+    name: org/project2
+    check:
+      jobs:
+        - integration
+    gate:
+      queue: integrated
+      jobs:
+        - integration
+
+- project:
+    name: org/project3
+    check:
+      jobs:
+        - integration
+    gate:
+      queue: integrated
+      jobs:
+        - integration
+
+- project:
+    name: org/project4
+    check:
+      jobs:
+        - integration
+    gate:
+      queue: integrated
+      jobs:
+        - integration
+
+- project:
+    name: org/project5
+    check:
+      jobs:
+        - integration
+    gate:
+      queue: integrated
+      jobs:
+        - integration
+
+- project:
+    name: org/project6
+    check:
+      jobs:
+        - integration
+    gate:
+      queue: integrated
+      jobs:
+        - integration
diff --git a/tests/fixtures/layouts/repo-checkout-timer.yaml b/tests/fixtures/layouts/repo-checkout-timer.yaml
new file mode 100644
index 0000000..d5917d1
--- /dev/null
+++ b/tests/fixtures/layouts/repo-checkout-timer.yaml
@@ -0,0 +1,18 @@
+- pipeline:
+    name: periodic
+    manager: independent
+    trigger:
+      timer:
+        - time: '* * * * * */1'
+
+- job:
+    name: integration
+    override-branch: stable/havana
+    required-projects:
+      - org/project1
+
+- project:
+    name: org/project1
+    periodic:
+      jobs:
+        - integration
diff --git a/tests/fixtures/layouts/repo-checkout-two-project.yaml b/tests/fixtures/layouts/repo-checkout-two-project.yaml
new file mode 100644
index 0000000..239d80c
--- /dev/null
+++ b/tests/fixtures/layouts/repo-checkout-two-project.yaml
@@ -0,0 +1,59 @@
+- pipeline:
+    name: check
+    manager: independent
+    trigger:
+      gerrit:
+        - event: patchset-created
+    success:
+      gerrit:
+        verified: 1
+    failure:
+      gerrit:
+        verified: -1
+
+- pipeline:
+    name: gate
+    manager: dependent
+    success-message: Build succeeded (gate).
+    trigger:
+      gerrit:
+        - event: comment-added
+          approval:
+            - approved: 1
+    success:
+      gerrit:
+        verified: 2
+        submit: true
+    failure:
+      gerrit:
+        verified: -2
+    start:
+      gerrit:
+        verified: 0
+    precedence: high
+
+- job:
+    name: integration
+    required-projects:
+      - org/project1
+      - org/project2
+
+- project:
+    name: org/project1
+    check:
+      jobs:
+        - integration
+    gate:
+      queue: integrated
+      jobs:
+        - integration
+
+- project:
+    name: org/project2
+    check:
+      jobs:
+        - integration
+    gate:
+      queue: integrated
+      jobs:
+        - integration
diff --git a/tests/fixtures/layouts/reporting-github.yaml b/tests/fixtures/layouts/reporting-github.yaml
index c939f39..8dd35b0 100644
--- a/tests/fixtures/layouts/reporting-github.yaml
+++ b/tests/fixtures/layouts/reporting-github.yaml
@@ -29,6 +29,7 @@
       github:
         comment: false
         status: 'success'
+        status-url: http://logs.example.com/{pipeline.name}/{change.project}/{change.number}/{change.patchset}/
     failure:
       github:
         comment: false
diff --git a/tests/fixtures/layouts/unmanaged-project.yaml b/tests/fixtures/layouts/unmanaged-project.yaml
new file mode 100644
index 0000000..d72c26e
--- /dev/null
+++ b/tests/fixtures/layouts/unmanaged-project.yaml
@@ -0,0 +1,25 @@
+- pipeline:
+    name: check
+    manager: independent
+    require:
+      gerrit:
+        open: True
+        current-patchset: True
+    trigger:
+      gerrit:
+        - event: patchset-created
+    success:
+      gerrit:
+        verified: 1
+    failure:
+      gerrit:
+        verified: -1
+    start:
+      gerrit:
+        verified: 0
+
+- project:
+    name: org/project1
+    check:
+      jobs:
+        - noop
diff --git a/tests/fixtures/test_id_rsa b/tests/fixtures/test_id_rsa
new file mode 100644
index 0000000..a793bd0
--- /dev/null
+++ b/tests/fixtures/test_id_rsa
@@ -0,0 +1,15 @@
+-----BEGIN RSA PRIVATE KEY-----
+MIICWwIBAAKBgQCX10EQhi7hEMk1h7/fQaEj9H2DxWR0s3RXD5UI7j1Bn21tBUus
+Y0tPC5wXES4VfilXg+EuOKsE6z8x8txP1wd1+d6Hq3SWXnOcqxxv2ueAy6Gc31E7
+a2IVDYvqVsAOtxsWddvMGTj98/lexQBX6Bh+wmuba/43lq5UPepwvfgNOQIDAQAB
+AoGADMCHNlwOk9hVDanY82cPoXVnFSn+xc5MdwNYAOgBPQGmrwFC2bd9G6Zd9ZH7
+zNJLpo3s23Tm6ALZy9gZqJrmhWDZBOqeYtmkd0yUf5bCbUzNre8+gHJY8k9PAxVM
+dPr2bq8G4PyN3yC2euTht35KLjb7hD8WiF3exgI/d8oBvgECQQDFKuWmkLtkSkGo
+1KRbeBfRePbfzhGJ1yHRyO72Z1+hVXuRmtcjTfPhMikgx9dxWbpqr/RPgs7D7N8D
+JpFlsiR/AkEAxSX4LOwovklPzCZ8FyfHhkydNgDyBw8y2Xe1OO0LBN51batf9rcl
+rJBYFvulrD+seYNRCWBFpEi4KKZh4YESRwJAKmz+mYbPK9dmpYOMEjqXNXXH+YSH
+9ZcbKd8IvHCl/Ts9qakd3fTqI2z9uJYH39Yk7MwL0Agfob0Yh78GzlE01QJACheu
+g8Y3M76XCjFyKtFLgpGLfsc/nKLnjIB3U4m3BbHJuyqJyByKHjJpgAuz6IR99N6H
+GH7IMefTHame2yd7YwJAUIGRD+iOO0RJvtEHUbsz6IxrQdubNOvzm/78eyBTcbsa
+8996D18fJF6Q0/Gg0Cm65PNOpIthP3qxFkuuduUEUg==
+-----END RSA PRIVATE KEY-----
diff --git a/tests/fixtures/test_id_rsa.pub b/tests/fixtures/test_id_rsa.pub
new file mode 100644
index 0000000..bffc726
--- /dev/null
+++ b/tests/fixtures/test_id_rsa.pub
@@ -0,0 +1 @@
+ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQCX10EQhi7hEMk1h7/fQaEj9H2DxWR0s3RXD5UI7j1Bn21tBUusY0tPC5wXES4VfilXg+EuOKsE6z8x8txP1wd1+d6Hq3SWXnOcqxxv2ueAy6Gc31E7a2IVDYvqVsAOtxsWddvMGTj98/lexQBX6Bh+wmuba/43lq5UPepwvfgNOQ== Private Key For Zuul Tests DO NOT USE
diff --git a/tests/fixtures/zuul-github-driver.conf b/tests/fixtures/zuul-github-driver.conf
index ab34619..dfa813d 100644
--- a/tests/fixtures/zuul-github-driver.conf
+++ b/tests/fixtures/zuul-github-driver.conf
@@ -3,7 +3,7 @@
 
 [zuul]
 job_name_in_report=true
-status_url=http://zuul.example.com/status
+status_url=http://zuul.example.com/status/#{change.number},{change.patchset}
 
 [merger]
 git_dir=/tmp/zuul-test/git
diff --git a/tests/fixtures/zuul-push-reqs.conf b/tests/fixtures/zuul-push-reqs.conf
new file mode 100644
index 0000000..661ac79
--- /dev/null
+++ b/tests/fixtures/zuul-push-reqs.conf
@@ -0,0 +1,23 @@
+[gearman]
+server=127.0.0.1
+
+[zuul]
+job_name_in_report=true
+status_url=http://zuul.example.com/status
+
+[merger]
+git_dir=/tmp/zuul-test/git
+git_user_email=zuul@example.com
+git_user_name=zuul
+zuul_url=http://zuul.example.com/p
+
+[executor]
+git_dir=/tmp/zuul-test/executor-git
+
+[connection github]
+driver=github
+
+[connection gerrit]
+driver=gerrit
+server=review.example.com
+user=jenkins
diff --git a/tests/unit/test_bubblewrap.py b/tests/unit/test_bubblewrap.py
new file mode 100644
index 0000000..b274944
--- /dev/null
+++ b/tests/unit/test_bubblewrap.py
@@ -0,0 +1,54 @@
+# 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 subprocess
+import tempfile
+import testtools
+
+from zuul.driver import bubblewrap
+from zuul.executor.server import SshAgent
+
+
+class TestBubblewrap(testtools.TestCase):
+    def setUp(self):
+        super(TestBubblewrap, self).setUp()
+        self.log_fixture = self.useFixture(
+            fixtures.FakeLogger(level=logging.DEBUG))
+        self.useFixture(fixtures.NestedTempfile())
+
+    def test_bubblewrap_wraps(self):
+        bwrap = bubblewrap.BubblewrapDriver()
+        work_dir = tempfile.mkdtemp()
+        ansible_dir = tempfile.mkdtemp()
+        ssh_agent = SshAgent()
+        self.addCleanup(ssh_agent.stop)
+        ssh_agent.start()
+        po = bwrap.getPopen(work_dir=work_dir,
+                            ansible_dir=ansible_dir,
+                            ssh_auth_sock=ssh_agent.env['SSH_AUTH_SOCK'])
+        self.assertTrue(po.passwd_r > 2)
+        self.assertTrue(po.group_r > 2)
+        self.assertTrue(work_dir in po.command)
+        self.assertTrue(ansible_dir in po.command)
+        # Now run /usr/bin/id to verify passwd/group entries made it in
+        true_proc = po(['/usr/bin/id'], stdout=subprocess.PIPE,
+                       stderr=subprocess.PIPE)
+        (output, errs) = true_proc.communicate()
+        # Make sure it printed things on stdout
+        self.assertTrue(len(output.strip()))
+        # And that it did not print things on stderr
+        self.assertEqual(0, len(errs.strip()))
+        # Make sure the _r's are closed
+        self.assertIsNone(po.passwd_r)
+        self.assertIsNone(po.group_r)
diff --git a/tests/unit/test_cloner.py b/tests/unit/test_cloner.py
deleted file mode 100644
index e65904b..0000000
--- a/tests/unit/test_cloner.py
+++ /dev/null
@@ -1,752 +0,0 @@
-#!/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
-
-
-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_project=build.parameters.get('ZUUL_PROJECT', None),
-                zuul_branch=build.parameters['ZUUL_BRANCH'],
-                zuul_ref=build.parameters['ZUUL_REF'],
-                zuul_url=self.src_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)
-        # project1 is the zuul_project so the origin should be set to the
-        # zuul_url since that is the most up to date.
-        cache_repo_path = os.path.join(cache_root, 'org/project1')
-        self.assertNotEqual(
-            work['org/project1'].remotes.origin.url,
-            cache_repo_path,
-            'workspace repo origin should not be the cache'
-        )
-        zuul_url_repo_path = os.path.join(self.git_root, 'org/project1')
-        self.assertEqual(
-            work['org/project1'].remotes.origin.url,
-            zuul_url_repo_path,
-            'workspace repo origin should be the zuul url'
-        )
-
-        # project2 is not the zuul_project so the origin should be set
-        # to upstream since that is the best we can do
-        cache_repo_path = os.path.join(cache_root, 'org/project2')
-        self.assertNotEqual(
-            work['org/project2'].remotes.origin.url,
-            cache_repo_path,
-            'workspace repo origin should not be the cache'
-        )
-        upstream_repo_path = os.path.join(self.upstream_root, 'org/project2')
-        self.assertEqual(
-            work['org/project2'].remotes.origin.url,
-            upstream_repo_path,
-            'workspace repo origin should be the upstream url'
-        )
-
-        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_project=build.parameters.get('ZUUL_PROJECT', None),
-                zuul_branch=build.parameters['ZUUL_BRANCH'],
-                zuul_ref=build.parameters['ZUUL_REF'],
-                zuul_url=self.src_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_project=build.parameters.get('ZUUL_PROJECT', None),
-                zuul_branch=build.parameters['ZUUL_BRANCH'],
-                zuul_ref=build.parameters['ZUUL_REF'],
-                zuul_url=self.src_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_project=build.parameters.get('ZUUL_PROJECT', None),
-                zuul_branch=build.parameters['ZUUL_BRANCH'],
-                zuul_ref=build.parameters['ZUUL_REF'],
-                zuul_url=self.src_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_project=build.parameters.get('ZUUL_PROJECT', None),
-                zuul_branch=build.parameters['ZUUL_BRANCH'],
-                zuul_ref=build.parameters['ZUUL_REF'],
-                zuul_url=self.src_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_project=build.parameters.get('ZUUL_PROJECT', None),
-                zuul_branch=build.parameters['ZUUL_BRANCH'],
-                zuul_ref=build.parameters['ZUUL_REF'],
-                zuul_url=self.src_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_project=build.parameters.get('ZUUL_PROJECT', None),
-                zuul_branch=build.parameters.get('ZUUL_BRANCH', None),
-                zuul_ref=build.parameters.get('ZUUL_REF', None),
-                zuul_url=self.src_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_periodic_update(self):
-        # Test that the merger correctly updates its local repository
-        # before running a periodic job.
-
-        # Prime the merger with the current state
-        A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
-        self.fake_gerrit.addEvent(A.getPatchsetCreatedEvent(1))
-        self.waitUntilSettled()
-
-        # Merge a different change
-        B = self.fake_gerrit.addFakeChange('org/project', 'master', 'B')
-        B.setMerged()
-
-        # Start a periodic job
-        self.worker.hold_jobs_in_build = True
-        self.executor.negative_function_cache_ttl = 0
-        self.config.set('zuul', 'layout_config',
-                        '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.config.set('zuul', 'layout_config',
-                        '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)
-        self.assertEqual(upstream['org/project'].commit('master').hexsha,
-                         B.patchsets[0]['revision'])
-        states = [
-            {'org/project':
-                str(upstream['org/project'].commit('master')),
-             },
-            {'org/project':
-                str(upstream['org/project'].commit('master')),
-             },
-        ]
-
-        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_project=build.parameters.get('ZUUL_PROJECT', None),
-                zuul_branch=build.parameters.get('ZUUL_BRANCH', None),
-                zuul_ref=build.parameters.get('ZUUL_REF', None),
-                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_post_checkout(self):
-        self.worker.hold_jobs_in_build = True
-        project = "org/project1"
-
-        A = self.fake_gerrit.addFakeChange(project, 'master', 'A')
-        event = A.getRefUpdatedEvent()
-        A.setMerged()
-        self.fake_gerrit.addEvent(event)
-        self.waitUntilSettled()
-
-        build = self.builds[0]
-        state = {'org/project1': build.parameters['ZUUL_COMMIT']}
-
-        build.release()
-        self.waitUntilSettled()
-
-        cloner = zuul.lib.cloner.Cloner(
-            git_base_url=self.upstream_root,
-            projects=[project],
-            workspace=self.workspace_root,
-            zuul_project=build.parameters.get('ZUUL_PROJECT', None),
-            zuul_branch=build.parameters.get('ZUUL_BRANCH', None),
-            zuul_ref=build.parameters.get('ZUUL_REF', None),
-            zuul_newrev=build.parameters.get('ZUUL_NEWREV', None),
-            zuul_url=self.git_root,
-        )
-        cloner.execute()
-        work = self.getWorkspaceRepos([project])
-        self.assertEquals(state[project],
-                          str(work[project].commit('HEAD')),
-                          'Project %s commit for build %s should '
-                          'be correct' % (project, 0))
-        shutil.rmtree(self.workspace_root)
-
-    def test_post_and_master_checkout(self):
-        self.worker.hold_jobs_in_build = True
-        projects = ["org/project1", "org/project2"]
-
-        A = self.fake_gerrit.addFakeChange(projects[0], 'master', 'A')
-        event = A.getRefUpdatedEvent()
-        A.setMerged()
-        self.fake_gerrit.addEvent(event)
-        self.waitUntilSettled()
-
-        build = self.builds[0]
-        upstream = self.getUpstreamRepos(projects)
-        state = {'org/project1':
-                 build.parameters['ZUUL_COMMIT'],
-                 'org/project2':
-                 str(upstream['org/project2'].commit('master')),
-                 }
-
-        build.release()
-        self.waitUntilSettled()
-
-        cloner = zuul.lib.cloner.Cloner(
-            git_base_url=self.upstream_root,
-            projects=projects,
-            workspace=self.workspace_root,
-            zuul_project=build.parameters.get('ZUUL_PROJECT', None),
-            zuul_branch=build.parameters.get('ZUUL_BRANCH', None),
-            zuul_ref=build.parameters.get('ZUUL_REF', None),
-            zuul_newrev=build.parameters.get('ZUUL_NEWREV', None),
-            zuul_url=self.git_root,
-        )
-        cloner.execute()
-        work = self.getWorkspaceRepos(projects)
-
-        for project in projects:
-            self.assertEquals(state[project],
-                              str(work[project].commit('HEAD')),
-                              'Project %s commit for build %s should '
-                              'be correct' % (project, 0))
-        shutil.rmtree(self.workspace_root)
diff --git a/tests/unit/test_cloner_cmd.py b/tests/unit/test_cloner_cmd.py
index 2d8747f..84bd243 100644
--- a/tests/unit/test_cloner_cmd.py
+++ b/tests/unit/test_cloner_cmd.py
@@ -26,7 +26,7 @@
 
     def test_default_cache_dir_empty(self):
         self.app.parse_arguments(['base', 'repo'])
-        self.assertEqual(None, self.app.args.cache_dir)
+        self.assertIsNone(self.app.args.cache_dir)
 
     def test_default_cache_dir_environ(self):
         try:
diff --git a/tests/unit/test_executor.py b/tests/unit/test_executor.py
new file mode 100644
index 0000000..100e4ec
--- /dev/null
+++ b/tests/unit/test_executor.py
@@ -0,0 +1,349 @@
+#!/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 time
+
+from tests.base import ZuulTestCase, simple_layout
+
+
+class TestExecutorRepos(ZuulTestCase):
+    tenant_config_file = 'config/single-tenant/main.yaml'
+
+    log = logging.getLogger("zuul.test.executor")
+
+    def assertRepoState(self, repo, state, project, build, number):
+        if 'branch' in state:
+            self.assertFalse(repo.head.is_detached,
+                             'Project %s commit for build %s #%s should '
+                             'not have a detached HEAD' % (
+                                 project, build, number))
+            self.assertEquals(repo.active_branch.name,
+                              state['branch'],
+                              'Project %s commit for build %s #%s should '
+                              'be on the correct branch' % (
+                                  project, build, number))
+        if 'commit' in state:
+            self.assertEquals(state['commit'],
+                              str(repo.commit('HEAD')),
+                              'Project %s commit for build %s #%s should '
+                              'be correct' % (
+                                  project, build, number))
+        ref = repo.commit('HEAD')
+        repo_messages = set(
+            [c.message.strip() for c in repo.iter_commits(ref)])
+        if 'present' in state:
+            for change in state['present']:
+                msg = '%s-1' % change.subject
+                self.assertTrue(msg in repo_messages,
+                                'Project %s for build %s #%s should '
+                                'have change %s' % (
+                                    project, build, number, change.subject))
+        if 'absent' in state:
+            for change in state['absent']:
+                msg = '%s-1' % change.subject
+                self.assertTrue(msg not in repo_messages,
+                                'Project %s for build %s #%s should '
+                                'not have change %s' % (
+                                    project, build, number, change.subject))
+
+    @simple_layout('layouts/repo-checkout-two-project.yaml')
+    def test_one_branch(self):
+        self.executor_server.hold_jobs_in_build = True
+
+        p1 = 'review.example.com/org/project1'
+        p2 = 'review.example.com/org/project2'
+        projects = [p1, p2]
+        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)
+        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
+        self.fake_gerrit.addEvent(B.addApproval('approved', 1))
+
+        self.waitUntilSettled()
+
+        self.assertEquals(2, len(self.builds), "Two builds are running")
+
+        upstream = self.getUpstreamRepos(projects)
+        states = [
+            {p1: dict(present=[A], absent=[B], branch='master'),
+             p2: dict(commit=str(upstream[p2].commit('master')),
+                      branch='master'),
+             },
+            {p1: dict(present=[A], absent=[B], branch='master'),
+             p2: dict(present=[B], absent=[A], branch='master'),
+             },
+        ]
+
+        for number, build in enumerate(self.builds):
+            self.log.debug("Build parameters: %s", build.parameters)
+            work = build.getWorkspaceRepos(projects)
+            state = states[number]
+
+            for project in projects:
+                self.assertRepoState(work[project], state[project],
+                                     project, build, number)
+
+        self.executor_server.hold_jobs_in_build = False
+        self.executor_server.release()
+        self.waitUntilSettled()
+
+    @simple_layout('layouts/repo-checkout-four-project.yaml')
+    def test_multi_branch(self):
+        self.executor_server.hold_jobs_in_build = True
+
+        p1 = 'review.example.com/org/project1'
+        p2 = 'review.example.com/org/project2'
+        p3 = 'review.example.com/org/project3'
+        p4 = 'review.example.com/org/project4'
+        projects = [p1, p2, p3, p4]
+
+        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('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.assertEquals(3, len(self.builds), "Three builds are running")
+
+        upstream = self.getUpstreamRepos(projects)
+        states = [
+            {p1: dict(present=[A], absent=[B, C], branch='master'),
+             p2: dict(commit=str(upstream[p2].commit('master')),
+                      branch='master'),
+             p3: dict(commit=str(upstream[p3].commit('master')),
+                      branch='master'),
+             p4: dict(commit=str(upstream[p4].commit('master')),
+                      branch='master'),
+             },
+            {p1: dict(present=[A], absent=[B, C], branch='master'),
+             p2: dict(present=[B], absent=[A, C], branch='stable/havana'),
+             p3: dict(commit=str(upstream[p3].commit('master')),
+                      branch='master'),
+             p4: dict(commit=str(upstream[p4].commit('stable/havana')),
+                      branch='stable/havana'),
+             },
+            {p1: dict(present=[A], absent=[B, C], branch='master'),
+             p2: dict(commit=str(upstream[p2].commit('master')),
+                      branch='master'),
+             p3: dict(present=[C], absent=[A, B], branch='master'),
+             p4: dict(commit=str(upstream[p4].commit('master')),
+                      branch='master'),
+             },
+        ]
+
+        for number, build in enumerate(self.builds):
+            self.log.debug("Build parameters: %s", build.parameters)
+            work = build.getWorkspaceRepos(projects)
+            state = states[number]
+
+            for project in projects:
+                self.assertRepoState(work[project], state[project],
+                                     project, build, number)
+
+        self.executor_server.hold_jobs_in_build = False
+        self.executor_server.release()
+        self.waitUntilSettled()
+
+    @simple_layout('layouts/repo-checkout-six-project.yaml')
+    def test_project_override(self):
+        self.executor_server.hold_jobs_in_build = True
+
+        p1 = 'review.example.com/org/project1'
+        p2 = 'review.example.com/org/project2'
+        p3 = 'review.example.com/org/project3'
+        p4 = 'review.example.com/org/project4'
+        p5 = 'review.example.com/org/project5'
+        p6 = 'review.example.com/org/project6'
+        projects = [p1, p2, p3, p4, p5, p6]
+
+        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('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.assertEquals(4, len(self.builds), "Four builds are running")
+
+        upstream = self.getUpstreamRepos(projects)
+        states = [
+            {p1: dict(present=[A], absent=[B, C, D], branch='master'),
+             p2: dict(commit=str(upstream[p2].commit('master')),
+                      branch='master'),
+             p3: dict(commit=str(upstream[p3].commit('master')),
+                      branch='master'),
+             p4: dict(commit=str(upstream[p4].commit('master')),
+                      branch='master'),
+             p5: dict(commit=str(upstream[p5].commit('master')),
+                      branch='master'),
+             p6: dict(commit=str(upstream[p6].commit('master')),
+                      branch='master'),
+             },
+            {p1: dict(present=[A, B], absent=[C, D], branch='master'),
+             p2: dict(commit=str(upstream[p2].commit('master')),
+                      branch='master'),
+             p3: dict(commit=str(upstream[p3].commit('master')),
+                      branch='master'),
+             p4: dict(commit=str(upstream[p4].commit('master')),
+                      branch='master'),
+             p5: dict(commit=str(upstream[p5].commit('master')),
+                      branch='master'),
+             p6: dict(commit=str(upstream[p6].commit('master')),
+                      branch='master'),
+             },
+            {p1: dict(present=[A, B], absent=[C, D], branch='master'),
+             p2: dict(present=[C], absent=[A, B, D], branch='master'),
+             p3: dict(commit=str(upstream[p3].commit('master')),
+                      branch='master'),
+             p4: dict(commit=str(upstream[p4].commit('master')),
+                      branch='master'),
+             p5: dict(commit=str(upstream[p5].commit('master')),
+                      branch='master'),
+             p6: dict(commit=str(upstream[p6].commit('master')),
+                      branch='master'),
+             },
+            {p1: dict(present=[A, B], absent=[C, D], branch='master'),
+             p2: dict(present=[C], absent=[A, B, D], branch='master'),
+             p3: dict(present=[D], absent=[A, B, C],
+                      branch='stable/havana'),
+             p4: dict(commit=str(upstream[p4].commit('master')),
+                      branch='master'),
+             p5: dict(commit=str(upstream[p5].commit('master')),
+                      branch='master'),
+             p6: dict(commit=str(upstream[p6].commit('stable/havana')),
+                      branch='stable/havana'),
+             },
+        ]
+
+        for number, build in enumerate(self.builds):
+            self.log.debug("Build parameters: %s", build.parameters)
+            work = build.getWorkspaceRepos(projects)
+            state = states[number]
+
+            for project in projects:
+                self.assertRepoState(work[project], state[project],
+                                     project, build, number)
+
+        self.executor_server.hold_jobs_in_build = False
+        self.executor_server.release()
+        self.waitUntilSettled()
+
+    def test_periodic(self):
+        # This test can not use simple_layout because it must start
+        # with a configuration which does not include a
+        # timer-triggered job so that we have an opportunity to set
+        # the hold flag before the first job.
+        self.executor_server.hold_jobs_in_build = True
+        # Start timer trigger - also org/project
+        self.commitConfigUpdate('common-config',
+                                'layouts/repo-checkout-timer.yaml')
+        self.sched.reconfigure(self.config)
+
+        p1 = 'review.example.com/org/project1'
+        projects = [p1]
+        self.create_branch('org/project1', 'stable/havana')
+
+        # 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.commitConfigUpdate('common-config',
+                                'layouts/repo-checkout-no-timer.yaml')
+        self.sched.reconfigure(self.config)
+
+        self.assertEquals(1, len(self.builds), "One build is running")
+
+        upstream = self.getUpstreamRepos(projects)
+        states = [
+            {p1: dict(commit=str(upstream[p1].commit('stable/havana')),
+                      branch='stable/havana'),
+             },
+        ]
+
+        for number, build in enumerate(self.builds):
+            self.log.debug("Build parameters: %s", build.parameters)
+            work = build.getWorkspaceRepos(projects)
+            state = states[number]
+
+            for project in projects:
+                self.assertRepoState(work[project], state[project],
+                                     project, build, number)
+
+        self.executor_server.hold_jobs_in_build = False
+        self.executor_server.release()
+        self.waitUntilSettled()
+
+    @simple_layout('layouts/repo-checkout-post.yaml')
+    def test_post_and_master_checkout(self):
+        self.executor_server.hold_jobs_in_build = True
+        p1 = "review.example.com/org/project1"
+        p2 = "review.example.com/org/project2"
+        projects = [p1, p2]
+
+        A = self.fake_gerrit.addFakeChange('org/project1', 'master', 'A')
+        event = A.getRefUpdatedEvent()
+        A.setMerged()
+        self.fake_gerrit.addEvent(event)
+        self.waitUntilSettled()
+
+        upstream = self.getUpstreamRepos(projects)
+        states = [
+            {p1: dict(commit=str(upstream[p1].commit('master')),
+                      present=[A], branch='master'),
+             p2: dict(commit=str(upstream[p2].commit('master')),
+                      absent=[A], branch='master'),
+             },
+        ]
+
+        for number, build in enumerate(self.builds):
+            self.log.debug("Build parameters: %s", build.parameters)
+            work = build.getWorkspaceRepos(projects)
+            state = states[number]
+
+            for project in projects:
+                self.assertRepoState(work[project], state[project],
+                                     project, build, number)
+
+        self.executor_server.hold_jobs_in_build = False
+        self.executor_server.release()
+        self.waitUntilSettled()
diff --git a/tests/unit/test_github_driver.py b/tests/unit/test_github_driver.py
index 227d659..6cc010e 100644
--- a/tests/unit/test_github_driver.py
+++ b/tests/unit/test_github_driver.py
@@ -300,6 +300,9 @@
         self.assertEqual('tenant-one/reporting', report_status['context'])
         self.assertEqual('success', report_status['state'])
         self.assertEqual(2, len(A.comments))
+        report_url = ('http://logs.example.com/reporting/%s/%s/%s/' %
+                      (A.project, A.number, A.head_sha))
+        self.assertEqual(report_url, report_status['url'])
 
     @simple_layout('layouts/merging-github.yaml', driver='github')
     def test_report_pull_merge(self):
diff --git a/tests/unit/test_inventory.py b/tests/unit/test_inventory.py
new file mode 100644
index 0000000..2835d30
--- /dev/null
+++ b/tests/unit/test_inventory.py
@@ -0,0 +1,82 @@
+#!/usr/bin/env python
+
+# 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 os
+
+import yaml
+
+from tests.base import ZuulTestCase
+
+
+class TestInventory(ZuulTestCase):
+
+    tenant_config_file = 'config/inventory/main.yaml'
+
+    def setUp(self):
+        super(TestInventory, self).setUp()
+        self.executor_server.hold_jobs_in_build = True
+        A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
+        self.fake_gerrit.addEvent(A.getPatchsetCreatedEvent(1))
+        self.waitUntilSettled()
+
+    def _get_build_inventory(self, name):
+        build = self.getBuildByName(name)
+        inv_path = os.path.join(build.jobdir.root, 'ansible', 'inventory.yaml')
+        return yaml.safe_load(open(inv_path, 'r'))
+
+    def test_single_inventory(self):
+
+        inventory = self._get_build_inventory('single-inventory')
+
+        all_nodes = ('ubuntu-xenial',)
+        self.assertIn('all', inventory)
+        self.assertIn('hosts', inventory['all'])
+        self.assertIn('vars', inventory['all'])
+        for node_name in all_nodes:
+            self.assertIn(node_name, inventory['all']['hosts'])
+        self.assertIn('zuul', inventory['all']['vars'])
+        z_vars = inventory['all']['vars']['zuul']
+        self.assertIn('executor', z_vars)
+        self.assertIn('src_root', z_vars['executor'])
+        self.assertIn('job', z_vars)
+        self.assertEqual(z_vars['job'], 'single-inventory')
+
+        self.executor_server.release()
+        self.waitUntilSettled()
+
+    def test_group_inventory(self):
+
+        inventory = self._get_build_inventory('group-inventory')
+
+        all_nodes = ('controller', 'compute1', 'compute2')
+        self.assertIn('all', inventory)
+        self.assertIn('hosts', inventory['all'])
+        self.assertIn('vars', inventory['all'])
+        for group_name in ('ceph-osd', 'ceph-monitor'):
+            self.assertIn(group_name, inventory)
+        for node_name in all_nodes:
+            self.assertIn(node_name, inventory['all']['hosts'])
+            self.assertIn(node_name,
+                          inventory['ceph-monitor']['hosts'])
+        self.assertIn('zuul', inventory['all']['vars'])
+        z_vars = inventory['all']['vars']['zuul']
+        self.assertIn('executor', z_vars)
+        self.assertIn('src_root', z_vars['executor'])
+        self.assertIn('job', z_vars)
+        self.assertEqual(z_vars['job'], 'group-inventory')
+
+        self.executor_server.release()
+        self.waitUntilSettled()
diff --git a/tests/unit/test_model.py b/tests/unit/test_model.py
index 5f968b4..e7e53c4 100644
--- a/tests/unit/test_model.py
+++ b/tests/unit/test_model.py
@@ -106,7 +106,7 @@
         base.auth = model.AuthContext()
 
         py27 = model.Job('py27')
-        self.assertEqual(None, py27.timeout)
+        self.assertIsNone(py27.timeout)
         py27.inheritFrom(base)
         self.assertEqual(30, py27.timeout)
         self.assertEqual(['base-pre'],
@@ -115,7 +115,7 @@
                          [x.path for x in py27.run])
         self.assertEqual(['base-post'],
                          [x.path for x in py27.post_run])
-        self.assertEqual(None, py27.auth)
+        self.assertIsNone(py27.auth)
 
     def test_job_variants(self):
         # This simulates freezing a job.
@@ -433,11 +433,11 @@
             })
         layout.addJob(in_repo_job_with_inherit_false)
 
-        self.assertEqual(None, in_repo_job_without_inherit.auth)
+        self.assertIsNone(in_repo_job_without_inherit.auth)
         self.assertEqual(1, len(in_repo_job_with_inherit.auth.secrets))
         self.assertEqual(in_repo_job_with_inherit.auth.secrets[0].name,
                          'pypi-credentials')
-        self.assertEqual(None, in_repo_job_with_inherit_false.auth)
+        self.assertIsNone(in_repo_job_with_inherit_false.auth)
 
     def test_job_inheritance_job_tree(self):
         tenant = model.Tenant('tenant')
diff --git a/tests/unit/test_multi_driver.py b/tests/unit/test_multi_driver.py
index a1107de..864bd31 100644
--- a/tests/unit/test_multi_driver.py
+++ b/tests/unit/test_multi_driver.py
@@ -28,10 +28,10 @@
 
         A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
         self.fake_gerrit.addEvent(A.getPatchsetCreatedEvent(1))
+        self.waitUntilSettled()
 
         B = self.fake_github.openFakePullRequest('org/project1', 'master', 'B')
         self.fake_github.emitEvent(B.getPullRequestOpenedEvent())
-
         self.waitUntilSettled()
 
         self.assertEqual(2, len(self.builds))
diff --git a/tests/unit/test_push_reqs.py b/tests/unit/test_push_reqs.py
new file mode 100644
index 0000000..657d9b8
--- /dev/null
+++ b/tests/unit/test_push_reqs.py
@@ -0,0 +1,53 @@
+# Copyright (c) 2017 IBM Corp.
+#
+# 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 TestPushRequirements(ZuulTestCase):
+    config_file = 'zuul-push-reqs.conf'
+    tenant_config_file = 'config/push-reqs/main.yaml'
+
+    def setup_config(self):
+        super(TestPushRequirements, self).setup_config()
+
+    def test_push_requirements(self):
+        self.executor_server.hold_jobs_in_build = True
+
+        # Create a github change, add a change and emit a push event
+        A = self.fake_github.openFakePullRequest('org/project1', 'master', 'A')
+        old_sha = A.head_sha
+        self.fake_github.emitEvent(A.getPushEvent(old_sha))
+
+        self.waitUntilSettled()
+
+        # All but one pipeline should be skipped
+        self.assertEqual(1, len(self.builds))
+        self.assertEqual('pushhub', self.builds[0].pipeline)
+        self.assertEqual('org/project1', self.builds[0].project)
+
+        # Make a gerrit change, and emit a ref-updated event
+        B = self.fake_gerrit.addFakeChange('org/project2', 'master', 'B')
+        self.fake_gerrit.addEvent(B.getRefUpdatedEvent())
+
+        self.waitUntilSettled()
+
+        # All but one pipeline should be skipped, increasing builds by 1
+        self.assertEqual(2, len(self.builds))
+        self.assertEqual('pushgerrit', self.builds[1].pipeline)
+        self.assertEqual('org/project2', self.builds[1].project)
+
+        self.executor_server.hold_jobs_in_build = False
+        self.executor_server.release()
+        self.waitUntilSettled()
diff --git a/tests/unit/test_scheduler.py b/tests/unit/test_scheduler.py
index 2624944..0ac42c1 100755
--- a/tests/unit/test_scheduler.py
+++ b/tests/unit/test_scheduler.py
@@ -21,9 +21,8 @@
 import os
 import re
 import shutil
-import sys
 import time
-from unittest import (skip, skipIf)
+from unittest import skip
 
 import git
 from six.moves import urllib
@@ -64,7 +63,11 @@
         self.assertIsNone(self.getJobFromHistory('project-test2').node)
 
         # TODOv3(jeblair): we may want to report stats by tenant (also?).
-        self.assertReportedStat('gerrit.event.comment-added', value='1|c')
+        # Per-driver
+        self.assertReportedStat('zuul.event.gerrit.comment-added', value='1|c')
+        # Per-driver per-connection
+        self.assertReportedStat('zuul.event.gerrit.gerrit.comment-added',
+                                value='1|c')
         self.assertReportedStat('zuul.pipeline.gate.current_changes',
                                 value='1|g')
         self.assertReportedStat('zuul.pipeline.gate.job.project-merge.SUCCESS',
@@ -510,7 +513,6 @@
         self.assertEqual(B.reported, 2)
         self.assertEqual(C.reported, 2)
 
-    @skipIf(sys.version_info.major > 2, 'Fails on py3')
     def test_failed_change_at_head_with_queue(self):
         "Test that if a change at the head fails, queued jobs are canceled"
 
@@ -937,7 +939,6 @@
         a = source.getChange(event, refresh=True)
         self.assertTrue(source.canMerge(a, mgr.getSubmitAllowNeeds()))
 
-    @skipIf(sys.version_info.major > 2, 'Fails on py3')
     def test_project_merge_conflict(self):
         "Test that gate merge conflicts are handled properly"
 
@@ -959,7 +960,6 @@
         self.waitUntilSettled()
 
         self.assertEqual(A.reported, 1)
-        self.assertEqual(B.reported, 1)
         self.assertEqual(C.reported, 1)
 
         self.gearman_server.release('project-merge')
@@ -977,7 +977,7 @@
         self.assertEqual(B.data['status'], 'NEW')
         self.assertEqual(C.data['status'], 'MERGED')
         self.assertEqual(A.reported, 2)
-        self.assertEqual(B.reported, 2)
+        self.assertIn('Merge Failed', B.messages[-1])
         self.assertEqual(C.reported, 2)
 
         self.assertHistory([
@@ -989,7 +989,6 @@
             dict(name='project-test2', result='SUCCESS', changes='1,1 3,1'),
         ], ordered=False)
 
-    @skipIf(sys.version_info.major > 2, 'Fails on py3')
     def test_delayed_merge_conflict(self):
         "Test that delayed check merge conflicts are handled properly"
 
@@ -1186,7 +1185,8 @@
         self.assertEqual(B.data['status'], 'NEW')
         self.assertEqual(B.reported, 2)
         self.assertEqual(C.data['status'], 'NEW')
-        self.assertEqual(C.reported, 2)
+        self.assertIn('This change depends on a change that failed to merge.',
+                      C.messages[-1])
         self.assertEqual(len(self.history), 1)
 
     def test_failing_dependent_changes(self):
@@ -1931,7 +1931,6 @@
         self.assertEqual(A.reported, 2)
 
     @simple_layout('layouts/no-jobs-project.yaml')
-    @skipIf(sys.version_info.major > 2, 'Fails on py3')
     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',
@@ -2063,7 +2062,6 @@
         self.assertReportedStat('test-timing', '3|ms')
         self.assertReportedStat('test-gauge', '12|g')
 
-    @skipIf(sys.version_info.major > 2, 'Fails on py3')
     def test_stuck_job_cleanup(self):
         "Test that pending jobs are cleaned up if removed from layout"
 
@@ -2191,7 +2189,6 @@
         self.assertEqual(q1.name, 'integrated')
         self.assertEqual(q2.name, 'integrated')
 
-    @skipIf(sys.version_info.major > 2, 'Fails on py3')
     def test_queue_precedence(self):
         "Test that queue precedence works"
 
@@ -2336,7 +2333,6 @@
         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.
@@ -2352,7 +2348,7 @@
         self.assertEqual(A.data['status'], 'MERGED')
         self.assertEqual(A.reported, 2)
         self.assertEqual(B.data['status'], 'NEW')
-        self.assertEqual(B.reported, 2)
+        self.assertIn('Merge Failed', B.messages[-1])
         self.assertEqual(self.getJobFromHistory('project-merge').result,
                          'SUCCESS')
         self.assertEqual(self.getJobFromHistory('project-test1').result,
@@ -3404,6 +3400,16 @@
         self.assertFalse(self.smtp_messages[1]['body'].startswith(failure_msg))
         self.assertTrue(self.smtp_messages[1]['body'].endswith(footer_msg))
 
+    @simple_layout('layouts/unmanaged-project.yaml')
+    def test_unmanaged_project_start_message(self):
+        "Test start reporting is not done for unmanaged projects."
+        self.init_repo("org/project", tag='init')
+        A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
+        self.fake_gerrit.addEvent(A.getPatchsetCreatedEvent(1))
+        self.waitUntilSettled()
+
+        self.assertEqual(0, len(A.messages))
+
     @skip("Disabled for early v3 development")
     def test_merge_failure_reporters(self):
         """Check that the config is set up correctly"""
@@ -3543,9 +3549,9 @@
         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.assertIsNone(running_item['item_ahead'])
         self.assertEqual('org/project', running_item['project'])
-        self.assertEqual(None, running_item['remaining_time'])
+        self.assertIsNone(running_item['remaining_time'])
         self.assertEqual(True, running_item['active'])
         self.assertEqual('1,1', running_item['id'])
 
@@ -3560,7 +3566,7 @@
                 self.assertEqual(7, len(job['worker']))
                 self.assertEqual(False, job['canceled'])
                 self.assertEqual(True, job['voting'])
-                self.assertEqual(None, job['result'])
+                self.assertIsNone(job['result'])
                 self.assertEqual('gate', job['pipeline'])
                 break
 
@@ -3878,7 +3884,6 @@
         self.assertEqual(B.data['status'], 'MERGED')
         self.assertEqual(B.reported, 0)
 
-    @skipIf(sys.version_info.major > 2, 'Fails on py3')
     def test_crd_check(self):
         "Test cross-repo dependencies in independent pipelines"
 
@@ -4029,11 +4034,9 @@
         self.assertEqual(self.history[0].changes, '2,1 1,1')
         self.assertEqual(len(tenant.layout.pipelines['check'].queues), 0)
 
-    @skipIf(sys.version_info.major > 2, 'Fails on py3')
     def test_crd_check_reconfiguration(self):
         self._test_crd_check_reconfiguration('org/project1', 'org/project2')
 
-    @skipIf(sys.version_info.major > 2, 'Fails on py3')
     def test_crd_undefined_project(self):
         """Test that undefined projects in dependencies are handled for
         independent pipelines"""
@@ -4043,7 +4046,6 @@
         self._test_crd_check_reconfiguration('org/project1', 'org/unknown')
 
     @simple_layout('layouts/ignore-dependencies.yaml')
-    @skipIf(sys.version_info.major > 2, 'Fails on py3')
     def test_crd_check_ignore_dependencies(self):
         "Test cross-repo dependencies can be ignored"
 
diff --git a/tests/unit/test_ssh_agent.py b/tests/unit/test_ssh_agent.py
new file mode 100644
index 0000000..c9c1ebd
--- /dev/null
+++ b/tests/unit/test_ssh_agent.py
@@ -0,0 +1,56 @@
+# 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 subprocess
+
+from tests.base import ZuulTestCase
+from zuul.executor.server import SshAgent
+
+
+class TestSshAgent(ZuulTestCase):
+    tenant_config_file = 'config/single-tenant/main.yaml'
+
+    def test_ssh_agent(self):
+        # Need a private key to add
+        env_copy = dict(os.environ)
+        # DISPLAY and SSH_ASKPASS will cause interactive test runners to get a
+        # surprise
+        if 'DISPLAY' in env_copy:
+            del env_copy['DISPLAY']
+        if 'SSH_ASKPASS' in env_copy:
+            del env_copy['SSH_ASKPASS']
+
+        agent = SshAgent()
+        agent.start()
+        env_copy.update(agent.env)
+
+        pub_key_file = '{}.pub'.format(self.private_key_file)
+        pub_key = None
+        with open(pub_key_file) as pub_key_f:
+            pub_key = pub_key_f.read().split('== ')[0]
+
+        agent.add(self.private_key_file)
+        keys = agent.list()
+        self.assertEqual(1, len(keys))
+        self.assertEqual(keys[0].split('== ')[0], pub_key)
+        agent.remove(self.private_key_file)
+        keys = agent.list()
+        self.assertEqual([], keys)
+        agent.stop()
+        # Agent is now dead and thus this should fail
+        with open('/dev/null') as devnull:
+            self.assertRaises(subprocess.CalledProcessError,
+                              subprocess.check_call,
+                              ['ssh-add', self.private_key_file],
+                              env=env_copy,
+                              stderr=devnull)
diff --git a/tests/unit/test_v3.py b/tests/unit/test_v3.py
index 2168a7f..18a49db 100644
--- a/tests/unit/test_v3.py
+++ b/tests/unit/test_v3.py
@@ -262,9 +262,9 @@
         self.waitUntilSettled()
 
         self.assertEqual(A.data['status'], 'NEW')
-        self.assertEqual(A.reported, 2,
-                         "A should report start and failure")
-        self.assertIn('syntax error', A.messages[1],
+        self.assertEqual(A.reported, 1,
+                         "A should report failure")
+        self.assertIn('syntax error', A.messages[0],
                       "A should have a syntax error reported")
 
     def test_trusted_syntax_error(self):
@@ -283,9 +283,9 @@
         self.waitUntilSettled()
 
         self.assertEqual(A.data['status'], 'NEW')
-        self.assertEqual(A.reported, 2,
-                         "A should report start and failure")
-        self.assertIn('syntax error', A.messages[1],
+        self.assertEqual(A.reported, 1,
+                         "A should report failure")
+        self.assertIn('syntax error', A.messages[0],
                       "A should have a syntax error reported")
 
     def test_untrusted_yaml_error(self):
@@ -303,9 +303,9 @@
         self.waitUntilSettled()
 
         self.assertEqual(A.data['status'], 'NEW')
-        self.assertEqual(A.reported, 2,
-                         "A should report start and failure")
-        self.assertIn('syntax error', A.messages[1],
+        self.assertEqual(A.reported, 1,
+                         "A should report failure")
+        self.assertIn('syntax error', A.messages[0],
                       "A should have a syntax error reported")
 
     def test_untrusted_shadow_error(self):
@@ -323,9 +323,9 @@
         self.waitUntilSettled()
 
         self.assertEqual(A.data['status'], 'NEW')
-        self.assertEqual(A.reported, 2,
-                         "A should report start and failure")
-        self.assertIn('not permitted to shadow', A.messages[1],
+        self.assertEqual(A.reported, 1,
+                         "A should report failure")
+        self.assertIn('not permitted to shadow', A.messages[0],
                       "A should have a syntax error reported")
 
 
@@ -344,6 +344,8 @@
         self.assertEqual(build.result, 'FAILURE')
         build = self.getJobFromHistory('check-vars')
         self.assertEqual(build.result, 'SUCCESS')
+        build = self.getJobFromHistory('hello-world')
+        self.assertEqual(build.result, 'SUCCESS')
         build = self.getJobFromHistory('python27')
         self.assertEqual(build.result, 'SUCCESS')
         flag_path = os.path.join(self.test_root, build.uuid + '.flag')
diff --git a/zuul/ansible/action/copy.py b/zuul/ansible/action/copy.py
index bb54430..d870c24 100644
--- a/zuul/ansible/action/copy.py
+++ b/zuul/ansible/action/copy.py
@@ -25,6 +25,6 @@
         source = self._task.args.get('src', None)
         remote_src = self._task.args.get('remote_src', False)
 
-        if not remote_src and not paths._is_safe_path(source):
+        if not remote_src and source and not paths._is_safe_path(source):
             return paths._fail_dict(source)
         return super(ActionModule, self).run(tmp, task_vars)
diff --git a/zuul/cmd/executor.py b/zuul/cmd/executor.py
index ea12b0b..931639f 100755
--- a/zuul/cmd/executor.py
+++ b/zuul/cmd/executor.py
@@ -39,7 +39,7 @@
 # Similar situation with gear and statsd.
 
 
-FINGER_PORT = 79
+DEFAULT_FINGER_PORT = 79
 
 
 class Executor(zuul.cmd.ZuulApp):
@@ -86,7 +86,7 @@
 
             self.log.info("Starting log streamer")
             streamer = zuul.lib.log_streamer.LogStreamer(
-                self.user, '0.0.0.0', FINGER_PORT, self.jobroot_dir)
+                self.user, '0.0.0.0', self.finger_port, self.jobroot_dir)
 
             # Keep running until the parent dies:
             pipe_read = os.fdopen(pipe_read)
@@ -127,6 +127,11 @@
         self.setup_logging('executor', 'log_config')
         self.log = logging.getLogger("zuul.Executor")
 
+        if self.config.has_option('executor', 'finger_port'):
+            self.finger_port = int(self.config.get('executor', 'finger_port'))
+        else:
+            self.finger_port = DEFAULT_FINGER_PORT
+
         self.start_log_streamer()
         self.change_privs()
 
diff --git a/zuul/configloader.py b/zuul/configloader.py
index 7d03eef..3438815 100644
--- a/zuul/configloader.py
+++ b/zuul/configloader.py
@@ -47,6 +47,16 @@
     pass
 
 
+class NodeFromGroupNotFoundError(Exception):
+    def __init__(self, nodeset, node, group):
+        message = textwrap.dedent("""\
+        In nodeset {nodeset} the group {group} contains a
+        node named {node} which is not defined in the nodeset.""")
+        message = textwrap.fill(message.format(nodeset=nodeset,
+                                               node=node, group=group))
+        super(NodeFromGroupNotFoundError, self).__init__(message)
+
+
 class ProjectNotFoundError(Exception):
     def __init__(self, project):
         message = textwrap.dedent("""\
@@ -169,8 +179,13 @@
                 vs.Required('image'): str,
                 }
 
+        group = {vs.Required('name'): str,
+                 vs.Required('nodes'): [str]
+                 }
+
         nodeset = {vs.Required('name'): str,
                    vs.Required('nodes'): [node],
+                   'groups': [group],
                    '_source_context': model.SourceContext,
                    '_start_mark': yaml.Mark,
                    }
@@ -182,9 +197,18 @@
         with configuration_exceptions('nodeset', conf):
             NodeSetParser.getSchema()(conf)
         ns = model.NodeSet(conf['name'])
+        node_names = []
         for conf_node in as_list(conf['nodes']):
             node = model.Node(conf_node['name'], conf_node['image'])
             ns.addNode(node)
+            node_names.append(conf_node['name'])
+        for conf_group in as_list(conf.get('groups', [])):
+            for node_name in conf_group['nodes']:
+                if node_name not in node_names:
+                    raise NodeFromGroupNotFoundError(conf['name'], node_name,
+                                                     conf_group['name'])
+            group = model.Group(conf_group['name'], conf_group['nodes'])
+            ns.addGroup(group)
         return ns
 
 
@@ -229,6 +253,9 @@
 
         role = vs.Any(zuul_role, galaxy_role)
 
+        job_project = {vs.Required('name'): str,
+                       'override-branch': str}
+
         job = {vs.Required('name'): str,
                'parent': str,
                'failure-message': str,
@@ -252,10 +279,11 @@
                '_source_context': model.SourceContext,
                '_start_mark': yaml.Mark,
                'roles': to_list(role),
-               'repos': to_list(str),
+               'required-projects': to_list(vs.Any(job_project, str)),
                'vars': dict,
                'dependencies': to_list(str),
                'allowed-projects': to_list(str),
+               'override-branch': str,
                }
 
         return vs.Schema(job)
@@ -271,6 +299,7 @@
         'success-message',
         'failure-url',
         'success-url',
+        'override-branch',
     ]
 
     @staticmethod
@@ -364,10 +393,23 @@
                     ns.addNode(node)
             job.nodeset = ns
 
-        if 'repos' in conf:
-            # Accumulate repos in a set so that job inheritance
-            # is additive.
-            job.repos = job.repos.union(set(conf.get('repos', [])))
+        if 'required-projects' in conf:
+            new_projects = {}
+            projects = as_list(conf.get('required-projects', []))
+            for project in projects:
+                if isinstance(project, dict):
+                    project_name = project['name']
+                    project_override_branch = project.get('override-branch')
+                else:
+                    project_name = project
+                    project_override_branch = None
+                (trusted, project) = tenant.getProject(project_name)
+                if project is None:
+                    raise Exception("Unknown project %s" % (project_name,))
+                job_project = model.JobProject(project_name,
+                                               project_override_branch)
+                new_projects[project_name] = job_project
+            job.updateProjects(new_projects)
 
         tags = conf.get('tags')
         if tags:
diff --git a/zuul/connection/__init__.py b/zuul/connection/__init__.py
index 49624d7..90ab39c 100644
--- a/zuul/connection/__init__.py
+++ b/zuul/connection/__init__.py
@@ -14,6 +14,7 @@
 
 import abc
 
+import extras
 import six
 
 
@@ -43,6 +44,26 @@
         self.driver = driver
         self.connection_name = connection_name
         self.connection_config = connection_config
+        self.statsd = extras.try_import('statsd.statsd')
+
+    def logEvent(self, event):
+        self.log.debug(
+            'Scheduling {driver} event from {connection}: {event}'.format(
+                driver=self.driver.name,
+                connection=self.connection_name,
+                event=event.type))
+        try:
+            if self.statsd:
+                self.statsd.incr(
+                    'zuul.event.{driver}.{event}'.format(
+                        driver=self.driver.name, event=event.type))
+                self.statsd.incr(
+                    'zuul.event.{driver}.{connection}.{event}'.format(
+                        driver=self.driver.name,
+                        connection=self.connection_name,
+                        event=event.type))
+        except:
+            self.log.exception("Exception reporting event stats")
 
     def onLoad(self):
         pass
diff --git a/zuul/driver/__init__.py b/zuul/driver/__init__.py
index 671996a..0c3105d 100644
--- a/zuul/driver/__init__.py
+++ b/zuul/driver/__init__.py
@@ -254,3 +254,27 @@
 
         """
         pass
+
+
+@six.add_metaclass(abc.ABCMeta)
+class WrapperInterface(object):
+    """The wrapper interface to be implmeneted by a driver.
+
+    A driver which wraps execution of commands executed by Zuul should
+    implement this interface.
+
+    """
+
+    @abc.abstractmethod
+    def getPopen(self, **kwargs):
+        """Create and return a subprocess.Popen factory wrapped however the
+        driver sees fit.
+
+        This method is required by the interface
+
+        :arg dict kwargs: key/values for use by driver as needed
+
+        :returns: a callable that takes the same args as subprocess.Popen
+        :rtype: Callable
+        """
+        pass
diff --git a/zuul/driver/bubblewrap/__init__.py b/zuul/driver/bubblewrap/__init__.py
new file mode 100644
index 0000000..c93e912
--- /dev/null
+++ b/zuul/driver/bubblewrap/__init__.py
@@ -0,0 +1,173 @@
+# Copyright 2012 Hewlett-Packard Development Company, L.P.
+# Copyright 2013 OpenStack Foundation
+# Copyright 2016 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 argparse
+import grp
+import logging
+import os
+import pwd
+import subprocess
+import sys
+
+from six.moves import shlex_quote
+
+from zuul.driver import (Driver, WrapperInterface)
+
+
+class WrappedPopen(object):
+    def __init__(self, command, passwd_r, group_r):
+        self.command = command
+        self.passwd_r = passwd_r
+        self.group_r = group_r
+
+    def __call__(self, args, *sub_args, **kwargs):
+        try:
+            args = self.command + args
+            if kwargs.get('close_fds') or sys.version_info.major >= 3:
+                # The default in py3 is close_fds=True, so we need to pass
+                # our open fds in. However, this can only work right in
+                # py3.2 or later due to the lack of 'pass_fds' in prior
+                # versions. So until we are py3 only we can only bwrap
+                # things that are close_fds=False
+                pass_fds = list(kwargs.get('pass_fds', []))
+                for fd in (self.passwd_r, self.group_r):
+                    if fd not in pass_fds:
+                        pass_fds.append(fd)
+                kwargs['pass_fds'] = pass_fds
+            proc = subprocess.Popen(args, *sub_args, **kwargs)
+        finally:
+            self.__del__()
+        return proc
+
+    def __del__(self):
+        if self.passwd_r:
+            try:
+                os.close(self.passwd_r)
+            except OSError:
+                pass
+            self.passwd_r = None
+        if self.group_r:
+            try:
+                os.close(self.group_r)
+            except OSError:
+                pass
+            self.group_r = None
+
+
+class BubblewrapDriver(Driver, WrapperInterface):
+    name = 'bubblewrap'
+    log = logging.getLogger("zuul.BubblewrapDriver")
+
+    bwrap_command = [
+        'bwrap',
+        '--dir', '/tmp',
+        '--tmpfs', '/tmp',
+        '--dir', '/var',
+        '--dir', '/var/tmp',
+        '--dir', '/run/user/{uid}',
+        '--ro-bind', '/usr', '/usr',
+        '--ro-bind', '/lib', '/lib',
+        '--ro-bind', '/lib64', '/lib64',
+        '--ro-bind', '/bin', '/bin',
+        '--ro-bind', '/sbin', '/sbin',
+        '--ro-bind', '/etc/resolv.conf', '/etc/resolv.conf',
+        '--ro-bind', '{ansible_dir}', '{ansible_dir}',
+        '--ro-bind', '{ssh_auth_sock}', '{ssh_auth_sock}',
+        '--dir', '{work_dir}',
+        '--bind', '{work_dir}', '{work_dir}',
+        '--dev', '/dev',
+        '--dir', '{user_home}',
+        '--chdir', '/',
+        '--unshare-all',
+        '--share-net',
+        '--uid', '{uid}',
+        '--gid', '{gid}',
+        '--file', '{uid_fd}', '/etc/passwd',
+        '--file', '{gid_fd}', '/etc/group',
+    ]
+
+    def reconfigure(self, tenant):
+        pass
+
+    def stop(self):
+        pass
+
+    def getPopen(self, **kwargs):
+        # Set zuul_dir if it was not passed in
+        if 'zuul_dir' in kwargs:
+            zuul_dir = kwargs['zuul_dir']
+        else:
+            zuul_python_dir = os.path.dirname(sys.executable)
+            # We want the dir directly above bin to get the whole venv
+            zuul_dir = os.path.normpath(os.path.join(zuul_python_dir, '..'))
+
+        bwrap_command = list(self.bwrap_command)
+        if not zuul_dir.startswith('/usr'):
+            bwrap_command.extend(['--ro-bind', zuul_dir, zuul_dir])
+
+        # Need users and groups
+        uid = os.getuid()
+        passwd = pwd.getpwuid(uid)
+        passwd_bytes = b':'.join(
+            ['{}'.format(x).encode('utf8') for x in passwd])
+        (passwd_r, passwd_w) = os.pipe()
+        os.write(passwd_w, passwd_bytes)
+        os.close(passwd_w)
+
+        gid = os.getgid()
+        group = grp.getgrgid(gid)
+        group_bytes = b':'.join(
+            ['{}'.format(x).encode('utf8') for x in group])
+        group_r, group_w = os.pipe()
+        os.write(group_w, group_bytes)
+        os.close(group_w)
+
+        kwargs = dict(kwargs)  # Don't update passed in dict
+        kwargs['uid'] = uid
+        kwargs['gid'] = gid
+        kwargs['uid_fd'] = passwd_r
+        kwargs['gid_fd'] = group_r
+        kwargs['user_home'] = passwd.pw_dir
+        command = [x.format(**kwargs) for x in bwrap_command]
+
+        self.log.debug("Bubblewrap command: %s",
+                       " ".join(shlex_quote(c) for c in command))
+
+        wrapped_popen = WrappedPopen(command, passwd_r, group_r)
+
+        return wrapped_popen
+
+
+def main(args=None):
+    driver = BubblewrapDriver()
+
+    parser = argparse.ArgumentParser()
+    parser.add_argument('work_dir')
+    parser.add_argument('ansible_dir')
+    parser.add_argument('run_args', nargs='+')
+    cli_args = parser.parse_args()
+
+    ssh_auth_sock = os.environ.get('SSH_AUTH_SOCK')
+
+    popen = driver.getPopen(work_dir=cli_args.work_dir,
+                            ansible_dir=cli_args.ansible_dir,
+                            ssh_auth_sock=ssh_auth_sock)
+    x = popen(cli_args.run_args)
+    x.wait()
+
+
+if __name__ == '__main__':
+    main()
diff --git a/zuul/driver/gerrit/gerritconnection.py b/zuul/driver/gerrit/gerritconnection.py
index 06962e5..a1d97e7 100644
--- a/zuul/driver/gerrit/gerritconnection.py
+++ b/zuul/driver/gerrit/gerritconnection.py
@@ -143,6 +143,7 @@
             self.connection._getChange(event.change_number,
                                        event.patch_number,
                                        refresh=True)
+        self.connection.logEvent(event)
         self.connection.sched.addEvent(event)
 
     def run(self):
@@ -625,7 +626,7 @@
             if val is True:
                 cmd += ' --%s' % key
             else:
-                cmd += ' --%s %s' % (key, val)
+                cmd += ' --label %s=%s' % (key, val)
         cmd += ' %s' % change
         out, err = self._ssh(cmd)
         return err
diff --git a/zuul/driver/gerrit/gerritmodel.py b/zuul/driver/gerrit/gerritmodel.py
index 009a723..818d260 100644
--- a/zuul/driver/gerrit/gerritmodel.py
+++ b/zuul/driver/gerrit/gerritmodel.py
@@ -115,6 +115,10 @@
         return True
 
     def matchesApprovals(self, change):
+        if self.required_approvals or self.reject_approvals:
+            if not hasattr(change, 'number'):
+                # Not a change, no reviews
+                return False
         if (self.required_approvals and not change.approvals
                 or self.reject_approvals and not change.approvals):
             # A change with no approvals can not match
@@ -291,10 +295,10 @@
 
 
 class GerritRefFilter(RefFilter, GerritApprovalFilter):
-    def __init__(self, open=None, current_patchset=None,
+    def __init__(self, connection_name, open=None, current_patchset=None,
                  statuses=[], required_approvals=[],
                  reject_approvals=[]):
-        RefFilter.__init__(self)
+        RefFilter.__init__(self, connection_name)
 
         GerritApprovalFilter.__init__(self,
                                       required_approvals=required_approvals,
@@ -307,6 +311,7 @@
     def __repr__(self):
         ret = '<GerritRefFilter'
 
+        ret += ' connection_name: %s' % self.connection_name
         if self.open is not None:
             ret += ' open: %s' % self.open
         if self.current_patchset is not None:
@@ -325,11 +330,21 @@
 
     def matches(self, change):
         if self.open is not None:
-            if self.open != change.open:
+            # if a "change" has no number, it's not a change, but a push
+            # and cannot possibly pass this test.
+            if hasattr(change, 'number'):
+                if self.open != change.open:
+                    return False
+            else:
                 return False
 
         if self.current_patchset is not None:
-            if self.current_patchset != change.is_current_patchset:
+            # if a "change" has no number, it's not a change, but a push
+            # and cannot possibly pass this test.
+            if hasattr(change, 'number'):
+                if self.current_patchset != change.is_current_patchset:
+                    return False
+            else:
                 return False
 
         if self.statuses:
diff --git a/zuul/driver/gerrit/gerritsource.py b/zuul/driver/gerrit/gerritsource.py
index 6cb0c39..4571cc1 100644
--- a/zuul/driver/gerrit/gerritsource.py
+++ b/zuul/driver/gerrit/gerritsource.py
@@ -65,6 +65,7 @@
 
     def getRequireFilters(self, config):
         f = GerritRefFilter(
+            connection_name=self.connection.connection_name,
             open=config.get('open'),
             current_patchset=config.get('current-patchset'),
             statuses=to_list(config.get('status')),
@@ -74,6 +75,7 @@
 
     def getRejectFilters(self, config):
         f = GerritRefFilter(
+            connection_name=self.connection.connection_name,
             reject_approvals=to_list(config.get('approval')),
         )
         return [f]
diff --git a/zuul/driver/github/githubconnection.py b/zuul/driver/github/githubconnection.py
index 27ece54..6a3c09e 100644
--- a/zuul/driver/github/githubconnection.py
+++ b/zuul/driver/github/githubconnection.py
@@ -119,7 +119,7 @@
 
         if event:
             event.project_hostname = self.connection.canonical_hostname
-            self.log.debug('Scheduling github event: {0}'.format(event.type))
+            self.connection.logEvent(event)
             self.connection.sched.addEvent(event)
 
     def _event_push(self, body):
@@ -479,8 +479,8 @@
             change.status = self._get_statuses(project, event.patch_number)
             change.reviews = self.getPullReviews(project, change.number)
             change.source_event = event
-            change.open = self.getPullOpen(project, change.number)
-            change.is_current_patchset = self.getIsCurrent(project,
+            change.open = self.getPullOpen(event.project_name, change.number)
+            change.is_current_patchset = self.getIsCurrent(event.project_name,
                                                            change.number,
                                                            event.patch_number)
         elif event.ref:
diff --git a/zuul/driver/github/githubmodel.py b/zuul/driver/github/githubmodel.py
index 3e25115..9516097 100644
--- a/zuul/driver/github/githubmodel.py
+++ b/zuul/driver/github/githubmodel.py
@@ -109,9 +109,13 @@
         return True
 
     def matchesReviews(self, change):
-        if self.required_reviews and not change.reviews:
-            # No reviews means no matching
-            return False
+        if self.required_reviews:
+            if not hasattr(change, 'number'):
+                # not a PR, no reviews
+                return False
+            if not change.reviews:
+                # No reviews means no matching
+                return False
 
         return self.matchesRequiredReviews(change)
 
@@ -133,6 +137,9 @@
         # statuses and the filter statuses are a null intersection, there
         # are no matches and we return false
         if self.required_statuses:
+            if not hasattr(change, 'number'):
+                # not a PR, no status
+                return False
             if set(change.status).isdisjoint(set(self.required_statuses)):
                 return False
         return True
@@ -263,9 +270,9 @@
 
 
 class GithubRefFilter(RefFilter, GithubCommonFilter):
-    def __init__(self, statuses=[], required_reviews=[], open=None,
-                 current_patchset=None):
-        RefFilter.__init__(self)
+    def __init__(self, connection_name, statuses=[], required_reviews=[],
+                 open=None, current_patchset=None):
+        RefFilter.__init__(self, connection_name)
 
         GithubCommonFilter.__init__(self, required_reviews=required_reviews,
                                     required_statuses=statuses)
@@ -276,6 +283,7 @@
     def __repr__(self):
         ret = '<GithubRefFilter'
 
+        ret += ' connection_name: %s' % self.connection_name
         if self.statuses:
             ret += ' statuses: %s' % ', '.join(self.statuses)
         if self.required_reviews:
@@ -295,11 +303,21 @@
             return False
 
         if self.open is not None:
-            if self.open != change.open:
+            # if a "change" has no number, it's not a change, but a push
+            # and cannot possibly pass this test.
+            if hasattr(change, 'number'):
+                if self.open != change.open:
+                    return False
+            else:
                 return False
 
         if self.current_patchset is not None:
-            if self.current_patchset != change.is_current_patchset:
+            # if a "change" has no number, it's not a change, but a push
+            # and cannot possibly pass this test.
+            if hasattr(change, 'number'):
+                if self.current_patchset != change.is_current_patchset:
+                    return False
+            else:
                 return False
 
         # required reviews are ANDed
diff --git a/zuul/driver/github/githubreporter.py b/zuul/driver/github/githubreporter.py
index 45b7d4b..29edb8a 100644
--- a/zuul/driver/github/githubreporter.py
+++ b/zuul/driver/github/githubreporter.py
@@ -71,12 +71,14 @@
         context = '%s/%s' % (item.pipeline.layout.tenant.name,
                              item.pipeline.name)
         state = self._commit_status
-        url = ''
-        if self.connection.sched.config.has_option('zuul', 'status_url'):
-            base = self.connection.sched.config.get('zuul', 'status_url')
-            url = '%s/#%s,%s' % (base,
-                                 item.change.number,
-                                 item.change.patchset)
+
+        url_pattern = self.config.get('status-url')
+        if not url_pattern:
+            sched_config = self.connection.sched.config
+            if sched_config.has_option('zuul', 'status_url'):
+                url_pattern = sched_config.get('zuul', 'status_url')
+        url = item.formatUrlPattern(url_pattern) if url_pattern else ''
+
         description = ''
         if item.pipeline.description:
             description = item.pipeline.description
@@ -158,6 +160,7 @@
 def getSchema():
     github_reporter = v.Schema({
         'status': v.Any('pending', 'success', 'failure'),
+        'status-url': str,
         'comment': bool,
         'merge': bool,
         'label': scalar_or_list(str),
diff --git a/zuul/driver/github/githubsource.py b/zuul/driver/github/githubsource.py
index 58ca2b9..1350b10 100644
--- a/zuul/driver/github/githubsource.py
+++ b/zuul/driver/github/githubsource.py
@@ -96,6 +96,7 @@
 
     def getRequireFilters(self, config):
         f = GithubRefFilter(
+            connection_name=self.connection.connection_name,
             statuses=to_list(config.get('status')),
             required_reviews=to_list(config.get('review')),
             open=config.get('open'),
diff --git a/zuul/driver/nullwrap/__init__.py b/zuul/driver/nullwrap/__init__.py
new file mode 100644
index 0000000..ebcd1da
--- /dev/null
+++ b/zuul/driver/nullwrap/__init__.py
@@ -0,0 +1,28 @@
+# Copyright 2012 Hewlett-Packard Development Company, L.P.
+# Copyright 2013 OpenStack Foundation
+# Copyright 2016 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 logging
+import subprocess
+
+from zuul.driver import (Driver, WrapperInterface)
+
+
+class NullwrapDriver(Driver, WrapperInterface):
+    name = 'nullwrap'
+    log = logging.getLogger("zuul.NullwrapDriver")
+
+    def getPopen(self, **kwargs):
+        return subprocess.Popen
diff --git a/zuul/driver/sql/sqlconnection.py b/zuul/driver/sql/sqlconnection.py
index 4b1b1a2..e478d33 100644
--- a/zuul/driver/sql/sqlconnection.py
+++ b/zuul/driver/sql/sqlconnection.py
@@ -43,6 +43,8 @@
             self.engine = sa.create_engine(self.dburi)
             self._migrate()
             self._setup_tables()
+            self.zuul_buildset_table, self.zuul_build_table \
+                = self._setup_tables()
             self.tables_established = True
         except sa.exc.NoSuchModuleError:
             self.log.exception(
@@ -68,10 +70,11 @@
 
             alembic.command.upgrade(config, 'head')
 
-    def _setup_tables(self):
+    @staticmethod
+    def _setup_tables():
         metadata = sa.MetaData()
 
-        self.zuul_buildset_table = sa.Table(
+        zuul_buildset_table = sa.Table(
             BUILDSET_TABLE, metadata,
             sa.Column('id', sa.Integer, primary_key=True),
             sa.Column('zuul_ref', sa.String(255)),
@@ -84,7 +87,7 @@
             sa.Column('message', sa.TEXT()),
         )
 
-        self.zuul_build_table = sa.Table(
+        zuul_build_table = sa.Table(
             BUILD_TABLE, metadata,
             sa.Column('id', sa.Integer, primary_key=True),
             sa.Column('buildset_id', sa.Integer,
@@ -99,6 +102,8 @@
             sa.Column('node_name', sa.String(255)),
         )
 
+        return zuul_buildset_table, zuul_build_table
+
 
 def getSchema():
     sql_connection = v.Any(str, v.Schema(dict))
diff --git a/zuul/driver/sql/sqlreporter.py b/zuul/driver/sql/sqlreporter.py
index 13f62f4..5f93ce8 100644
--- a/zuul/driver/sql/sqlreporter.py
+++ b/zuul/driver/sql/sqlreporter.py
@@ -39,13 +39,16 @@
             return
 
         with self.connection.engine.begin() as conn:
+            change = getattr(item.change, 'number', '')
+            patchset = getattr(item.change, 'patchset', '')
+            refspec = getattr(item.change, 'refspec', item.change.newrev)
             buildset_ins = self.connection.zuul_buildset_table.insert().values(
                 zuul_ref=item.current_build_set.ref,
                 pipeline=item.pipeline.name,
                 project=item.change.project.name,
-                change=item.change.number,
-                patchset=item.change.patchset,
-                ref=item.change.refspec,
+                change=change,
+                patchset=patchset,
+                ref=refspec,
                 score=self.result_score,
                 message=self._formatItemReport(
                     item, with_jobs=False),
diff --git a/zuul/executor/client.py b/zuul/executor/client.py
index 0d40716..cf8d973 100644
--- a/zuul/executor/client.py
+++ b/zuul/executor/client.py
@@ -261,6 +261,11 @@
         params['timeout'] = job.timeout
         params['items'] = merger_items
         params['projects'] = []
+        if hasattr(item.change, 'branch'):
+            params['branch'] = item.change.branch
+        else:
+            params['branch'] = None
+        params['override_branch'] = job.override_branch
         params['repo_state'] = item.current_build_set.repo_state
 
         if job.name != 'noop':
@@ -269,8 +274,9 @@
             params['post_playbooks'] = [x.toDict() for x in job.post_run]
             params['roles'] = [x.toDict() for x in job.roles]
 
+        nodeset = item.current_build_set.getJobNodeSet(job.name)
         nodes = []
-        for node in item.current_build_set.getJobNodeSet(job.name).getNodes():
+        for node in nodeset.getNodes():
             nodes.append(dict(name=node.name, image=node.image,
                               az=node.az,
                               host_keys=node.host_keys,
@@ -280,6 +286,7 @@
                               public_ipv6=node.public_ipv6,
                               public_ipv4=node.public_ipv4))
         params['nodes'] = nodes
+        params['groups'] = [group.toDict() for group in nodeset.getGroups()]
         params['vars'] = copy.deepcopy(job.variables)
         if job.auth:
             for secret in job.auth.secrets:
@@ -287,7 +294,7 @@
         params['vars']['zuul'] = zuul_params
         projects = set()
 
-        def make_project_dict(project):
+        def make_project_dict(project, override_branch=None):
             project_config = item.current_build_set.layout.project_configs.get(
                 project.canonical_name, None)
             if project_config:
@@ -297,12 +304,20 @@
             connection = project.source.connection
             return dict(connection=connection.connection_name,
                         name=project.name,
+                        canonical_name=project.canonical_name,
+                        override_branch=override_branch,
                         default_branch=project_default_branch)
 
-        if job.repos:
-            for repo in job.repos:
-                (trusted, project) = tenant.getProject(repo)
-                params['projects'].append(make_project_dict(project))
+        if job.required_projects:
+            for job_project in job.required_projects.values():
+                (trusted, project) = tenant.getProject(
+                    job_project.project_name)
+                if project is None:
+                    raise Exception("Unknown project %s" %
+                                    (job_project.project_name,))
+                params['projects'].append(
+                    make_project_dict(project,
+                                      job_project.override_branch))
                 projects.add(project)
         for item in all_items:
             if item.change.project not in projects:
diff --git a/zuul/executor/server.py b/zuul/executor/server.py
index bb3ea9e..8d2d577 100644
--- a/zuul/executor/server.py
+++ b/zuul/executor/server.py
@@ -27,14 +27,10 @@
 from zuul.lib.yamlutil import yaml
 
 import gear
-import git
 from six.moves import shlex_quote
 
 import zuul.merger.merger
-import zuul.ansible.action
-import zuul.ansible.callback
-import zuul.ansible.library
-import zuul.ansible.lookup
+import zuul.ansible
 from zuul.lib import commandsocket
 
 COMMANDS = ['stop', 'pause', 'unpause', 'graceful', 'verbose',
@@ -79,6 +75,78 @@
         self.path = None
 
 
+class SshAgent(object):
+    log = logging.getLogger("zuul.ExecutorServer")
+
+    def __init__(self):
+        self.env = {}
+        self.ssh_agent = None
+
+    def start(self):
+        if self.ssh_agent:
+            return
+        with open('/dev/null', 'r+') as devnull:
+            ssh_agent = subprocess.Popen(['ssh-agent'], close_fds=True,
+                                         stdout=subprocess.PIPE,
+                                         stderr=devnull,
+                                         stdin=devnull)
+        (output, _) = ssh_agent.communicate()
+        output = output.decode('utf8')
+        for line in output.split("\n"):
+            if '=' in line:
+                line = line.split(";", 1)[0]
+                (key, value) = line.split('=')
+                self.env[key] = value
+        self.log.info('Started SSH Agent, {}'.format(self.env))
+
+    def stop(self):
+        if 'SSH_AGENT_PID' in self.env:
+            try:
+                os.kill(int(self.env['SSH_AGENT_PID']), signal.SIGTERM)
+            except OSError:
+                self.log.exception(
+                    'Problem sending SIGTERM to agent {}'.format(self.env))
+            self.log.info('Sent SIGTERM to SSH Agent, {}'.format(self.env))
+            self.env = {}
+
+    def add(self, key_path):
+        env = os.environ.copy()
+        env.update(self.env)
+        key_path = os.path.expanduser(key_path)
+        self.log.debug('Adding SSH Key {}'.format(key_path))
+        output = ''
+        try:
+            output = subprocess.check_output(['ssh-add', key_path], env=env,
+                                             stderr=subprocess.PIPE)
+        except subprocess.CalledProcessError:
+            self.log.error('ssh-add failed: {}'.format(output))
+            raise
+        self.log.info('Added SSH Key {}'.format(key_path))
+
+    def remove(self, key_path):
+        env = os.environ.copy()
+        env.update(self.env)
+        key_path = os.path.expanduser(key_path)
+        self.log.debug('Removing SSH Key {}'.format(key_path))
+        subprocess.check_output(['ssh-add', '-d', key_path], env=env,
+                                stderr=subprocess.PIPE)
+        self.log.info('Removed SSH Key {}'.format(key_path))
+
+    def list(self):
+        if 'SSH_AUTH_SOCK' not in self.env:
+            return None
+        env = os.environ.copy()
+        env.update(self.env)
+        result = []
+        for line in subprocess.Popen(['ssh-add', '-L'], env=env,
+                                     stdout=subprocess.PIPE).stdout:
+            line = line.decode('utf8')
+            if line.strip() == 'The agent has no identities.':
+                break
+            result.append(line.strip())
+        return result
+
+
 class JobDir(object):
     def __init__(self, root, keep, build_uuid):
         '''
@@ -114,8 +182,7 @@
         self.ansible_root = os.path.join(self.root, 'ansible')
         os.makedirs(self.ansible_root)
         self.known_hosts = os.path.join(self.ansible_root, 'known_hosts')
-        self.inventory = os.path.join(self.ansible_root, 'inventory')
-        self.vars = os.path.join(self.ansible_root, 'vars.yaml')
+        self.inventory = os.path.join(self.ansible_root, 'inventory.yaml')
         self.playbooks = []  # The list of candidate playbooks
         self.playbook = None  # A pointer to the candidate we have chosen
         self.pre_playbooks = []
@@ -181,7 +248,7 @@
         self.event = threading.Event()
 
     def __eq__(self, other):
-        if (other.connection_name == self.connection_name and
+        if (other and other.connection_name == self.connection_name and
             other.project_name == self.project_name):
             return True
         return False
@@ -235,6 +302,8 @@
 def _copy_ansible_files(python_module, target_dir):
         library_path = os.path.dirname(os.path.abspath(python_module.__file__))
         for fn in os.listdir(library_path):
+            if fn == "__pycache__":
+                continue
             full_path = os.path.join(library_path, fn)
             if os.path.isdir(full_path):
                 shutil.copytree(full_path, os.path.join(target_dir, fn))
@@ -242,6 +311,45 @@
                 shutil.copy(os.path.join(library_path, fn), target_dir)
 
 
+def make_inventory_dict(nodes, groups, all_vars):
+
+    hosts = {}
+    for node in nodes:
+        hosts[node['name']] = node['host_vars']
+
+    inventory = {
+        'all': {
+            'hosts': hosts,
+            'vars': all_vars,
+        }
+    }
+
+    for group in groups:
+        group_hosts = {}
+        for node_name in group['nodes']:
+            # children is a dict with None as values because we don't have
+            # and per-group variables. If we did, None would be a dict
+            # with the per-group variables
+            group_hosts[node_name] = None
+        inventory[group['name']] = {'hosts': group_hosts}
+
+    return inventory
+
+
+class ExecutorMergeWorker(gear.TextWorker):
+    def __init__(self, executor_server, *args, **kw):
+        self.zuul_executor_server = executor_server
+        super(ExecutorMergeWorker, self).__init__(*args, **kw)
+
+    def handleNoop(self, packet):
+        # Wait until the update queue is empty before responding
+        while self.zuul_executor_server.update_queue.qsize():
+            time.sleep(1)
+
+        with self.zuul_executor_server.merger_lock:
+            super(ExecutorMergeWorker, self).handleNoop(packet)
+
+
 class ExecutorServer(object):
     log = logging.getLogger("zuul.ExecutorServer")
 
@@ -254,6 +362,7 @@
         # perhaps hostname+pid.
         self.hostname = socket.gethostname()
         self.zuul_url = config.get('merger', 'zuul_url')
+        self.merger_lock = threading.Lock()
         self.command_map = dict(
             stop=self.stop,
             pause=self.pause,
@@ -268,6 +377,12 @@
         else:
             self.merge_root = '/var/lib/zuul/executor-git'
 
+        if self.config.has_option('executor', 'default_username'):
+            self.default_username = self.config.get('executor',
+                                                    'default_username')
+        else:
+            self.default_username = 'zuul'
+
         if self.config.has_option('merger', 'git_user_email'):
             self.merge_email = self.config.get('merger', 'git_user_email')
         else:
@@ -278,6 +393,13 @@
         else:
             self.merge_name = None
 
+        if self.config.has_option('executor', 'untrusted_wrapper'):
+            untrusted_wrapper_name = self.config.get(
+                'executor', 'untrusted_wrapper').split()
+        else:
+            untrusted_wrapper_name = 'bubblewrap'
+        self.untrusted_wrapper = connections.drivers[untrusted_wrapper_name]
+
         self.connections = connections
         # This merger and its git repos are used to maintain
         # up-to-date copies of all the repos that are used by jobs, as
@@ -294,31 +416,38 @@
         path = os.path.join(state_dir, 'executor.socket')
         self.command_socket = commandsocket.CommandSocket(path)
         ansible_dir = os.path.join(state_dir, 'ansible')
-        self.library_dir = os.path.join(ansible_dir, 'library')
-        if not os.path.exists(self.library_dir):
-            os.makedirs(self.library_dir)
-        self.action_dir = os.path.join(ansible_dir, 'action')
-        if not os.path.exists(self.action_dir):
-            os.makedirs(self.action_dir)
+        self.ansible_dir = ansible_dir
 
-        self.callback_dir = os.path.join(ansible_dir, 'callback')
-        if not os.path.exists(self.callback_dir):
-            os.makedirs(self.callback_dir)
+        zuul_dir = os.path.join(ansible_dir, 'zuul')
+        plugin_dir = os.path.join(zuul_dir, 'ansible')
 
-        self.lookup_dir = os.path.join(ansible_dir, 'lookup')
-        if not os.path.exists(self.lookup_dir):
-            os.makedirs(self.lookup_dir)
+        if not os.path.exists(plugin_dir):
+            os.makedirs(plugin_dir)
 
-        _copy_ansible_files(zuul.ansible.library, self.library_dir)
-        _copy_ansible_files(zuul.ansible.action, self.action_dir)
-        _copy_ansible_files(zuul.ansible.callback, self.callback_dir)
-        _copy_ansible_files(zuul.ansible.lookup, self.lookup_dir)
+        self.library_dir = os.path.join(plugin_dir, 'library')
+        self.action_dir = os.path.join(plugin_dir, 'action')
+        self.callback_dir = os.path.join(plugin_dir, 'callback')
+        self.lookup_dir = os.path.join(plugin_dir, 'lookup')
+
+        _copy_ansible_files(zuul.ansible, plugin_dir)
+
+        # We're copying zuul.ansible.* into a directory we are going
+        # to add to pythonpath, so our plugins can "import
+        # zuul.ansible".  But we're not installing all of zuul, so
+        # create a __init__.py file for the stub "zuul" module.
+        with open(os.path.join(zuul_dir, '__init__.py'), 'w'):
+            pass
 
         self.job_workers = {}
 
-    def _getMerger(self, root):
+    def _getMerger(self, root, logger=None):
+        if root != self.merge_root:
+            cache_root = self.merge_root
+        else:
+            cache_root = None
         return zuul.merger.merger.Merger(root, self.connections,
-                                         self.merge_email, self.merge_name)
+                                         self.merge_email, self.merge_name,
+                                         cache_root, logger)
 
     def start(self):
         self._running = True
@@ -328,10 +457,13 @@
             port = self.config.get('gearman', 'port')
         else:
             port = 4730
-        self.worker = gear.TextWorker('Zuul Executor Server')
-        self.worker.addServer(server, port)
+        self.merger_worker = ExecutorMergeWorker(self, 'Zuul Executor Merger')
+        self.merger_worker.addServer(server, port)
+        self.executor_worker = gear.TextWorker('Zuul Executor Server')
+        self.executor_worker.addServer(server, port)
         self.log.debug("Waiting for server")
-        self.worker.waitForServer()
+        self.merger_worker.waitForServer()
+        self.executor_worker.waitForServer()
         self.log.debug("Registering")
         self.register()
 
@@ -345,15 +477,19 @@
         self.update_thread = threading.Thread(target=self._updateLoop)
         self.update_thread.daemon = True
         self.update_thread.start()
-        self.thread = threading.Thread(target=self.run)
-        self.thread.daemon = True
-        self.thread.start()
+        self.merger_thread = threading.Thread(target=self.run_merger)
+        self.merger_thread.daemon = True
+        self.merger_thread.start()
+        self.executor_thread = threading.Thread(target=self.run_executor)
+        self.executor_thread.daemon = True
+        self.executor_thread.start()
 
     def register(self):
-        self.worker.registerFunction("executor:execute")
-        self.worker.registerFunction("executor:stop:%s" % self.hostname)
-        self.worker.registerFunction("merger:merge")
-        self.worker.registerFunction("merger:cat")
+        self.executor_worker.registerFunction("executor:execute")
+        self.executor_worker.registerFunction("executor:stop:%s" %
+                                              self.hostname)
+        self.merger_worker.registerFunction("merger:merge")
+        self.merger_worker.registerFunction("merger:cat")
 
     def stop(self):
         self.log.debug("Stopping")
@@ -368,7 +504,8 @@
             except Exception:
                 self.log.exception("Exception sending stop command "
                                    "to worker:")
-        self.worker.shutdown()
+        self.merger_worker.shutdown()
+        self.executor_worker.shutdown()
         self.log.debug("Stopped")
 
     def pause(self):
@@ -393,7 +530,8 @@
 
     def join(self):
         self.update_thread.join()
-        self.thread.join()
+        self.merger_thread.join()
+        self.executor_thread.join()
 
     def runCommand(self):
         while self._command_running:
@@ -417,11 +555,12 @@
         if task is None:
             # We are asked to stop
             return
-        self.log.info("Updating repo %s/%s" % (
-            task.connection_name, task.project_name))
-        self.merger.updateRepo(task.connection_name, task.project_name)
-        self.log.debug("Finished updating repo %s/%s" %
-                       (task.connection_name, task.project_name))
+        with self.merger_lock:
+            self.log.info("Updating repo %s/%s" % (
+                task.connection_name, task.project_name))
+            self.merger.updateRepo(task.connection_name, task.project_name)
+            self.log.debug("Finished updating repo %s/%s" %
+                           (task.connection_name, task.project_name))
         task.setComplete()
 
     def update(self, connection_name, project_name):
@@ -430,11 +569,35 @@
         task = self.update_queue.put(task)
         return task
 
-    def run(self):
+    def run_merger(self):
+        self.log.debug("Starting merger listener")
+        while self._running:
+            try:
+                job = self.merger_worker.getJob()
+                try:
+                    if job.name == 'merger:cat':
+                        self.log.debug("Got cat job: %s" % job.unique)
+                        self.cat(job)
+                    elif job.name == 'merger:merge':
+                        self.log.debug("Got merge job: %s" % job.unique)
+                        self.merge(job)
+                    else:
+                        self.log.error("Unable to handle job %s" % job.name)
+                        job.sendWorkFail()
+                except Exception:
+                    self.log.exception("Exception while running job")
+                    job.sendWorkException(
+                        traceback.format_exc().encode('utf8'))
+            except gear.InterruptedError:
+                pass
+            except Exception:
+                self.log.exception("Exception while getting job")
+
+    def run_executor(self):
         self.log.debug("Starting executor listener")
         while self._running:
             try:
-                job = self.worker.getJob()
+                job = self.executor_worker.getJob()
                 try:
                     if job.name == 'executor:execute':
                         self.log.debug("Got execute job: %s" % job.unique)
@@ -442,12 +605,6 @@
                     elif job.name.startswith('executor:stop'):
                         self.log.debug("Got stop job: %s" % job.unique)
                         self.stopJob(job)
-                    elif job.name == 'merger:cat':
-                        self.log.debug("Got cat job: %s" % job.unique)
-                        self.cat(job)
-                    elif job.name == 'merger:merge':
-                        self.log.debug("Got merge job: %s" % job.unique)
-                        self.merge(job)
                     else:
                         self.log.error("Unable to handle job %s" % job.name)
                         job.sendWorkFail()
@@ -488,8 +645,9 @@
         args = json.loads(job.arguments)
         task = self.update(args['connection'], args['project'])
         task.wait()
-        files = self.merger.getFiles(args['connection'], args['project'],
-                                     args['branch'], args['files'])
+        with self.merger_lock:
+            files = self.merger.getFiles(args['connection'], args['project'],
+                                         args['branch'], args['files'])
         result = dict(updated=True,
                       files=files,
                       zuul_url=self.zuul_url)
@@ -497,26 +655,35 @@
 
     def merge(self, job):
         args = json.loads(job.arguments)
-        ret = self.merger.mergeChanges(args['items'], args.get('files'),
-                                       args.get('repo_state'))
+        with self.merger_lock:
+            ret = self.merger.mergeChanges(args['items'], args.get('files'),
+                                           args.get('repo_state'))
         result = dict(merged=(ret is not None),
                       zuul_url=self.zuul_url)
         if ret is None:
             result['commit'] = result['files'] = result['repo_state'] = None
         else:
-            result['commit'], result['files'], result['repo_state'] = ret
+            (result['commit'], result['files'], result['repo_state'],
+             recent) = ret
         job.sendWorkComplete(json.dumps(result))
 
 
-class AnsibleJob(object):
-    log = logging.getLogger("zuul.AnsibleJob")
+class AnsibleJobLogAdapter(logging.LoggerAdapter):
+    def process(self, msg, kwargs):
+        msg, kwargs = super(AnsibleJobLogAdapter, self).process(msg, kwargs)
+        msg = '[build: %s] %s' % (kwargs['extra']['job'], msg)
+        return msg, kwargs
 
+
+class AnsibleJob(object):
     RESULT_NORMAL = 1
     RESULT_TIMED_OUT = 2
     RESULT_UNREACHABLE = 3
     RESULT_ABORTED = 4
 
     def __init__(self, executor_server, job):
+        logger = logging.getLogger("zuul.AnsibleJob")
+        self.log = AnsibleJobLogAdapter(logger, {'job': job.unique})
         self.executor_server = executor_server
         self.job = job
         self.jobdir = None
@@ -524,6 +691,8 @@
         self.proc_lock = threading.Lock()
         self.running = False
         self.aborted = False
+        self.thread = None
+        self.ssh_agent = None
 
         if self.executor_server.config.has_option(
             'executor', 'private_key_file'):
@@ -531,8 +700,11 @@
                 'executor', 'private_key_file')
         else:
             self.private_key_file = '~/.ssh/id_rsa'
+        self.ssh_agent = SshAgent()
 
     def run(self):
+        self.ssh_agent.start()
+        self.ssh_agent.add(self.private_key_file)
         self.running = True
         self.thread = threading.Thread(target=self.execute)
         self.thread.start()
@@ -540,7 +712,8 @@
     def stop(self):
         self.aborted = True
         self.abortRunningProc()
-        self.thread.join()
+        if self.thread:
+            self.thread.join()
 
     def execute(self):
         try:
@@ -561,6 +734,11 @@
                 self.executor_server.finishJob(self.job.unique)
             except Exception:
                 self.log.exception("Error finalizing job thread:")
+            if self.ssh_agent:
+                try:
+                    self.ssh_agent.stop()
+                except Exception:
+                    self.log.exception("Error stopping SSH agent:")
 
     def _execute(self):
         self.log.debug("Job %s: beginning" % (self.job.unique,))
@@ -579,36 +757,37 @@
             task.wait()
 
         self.log.debug("Job %s: git updates complete" % (self.job.unique,))
-        repos = []
+        merger = self.executor_server._getMerger(self.jobdir.src_root,
+                                                 self.log)
+        repos = {}
         for project in args['projects']:
             self.log.debug("Cloning %s/%s" % (project['connection'],
                                               project['name'],))
-            source = self.executor_server.connections.getSource(
-                project['connection'])
-            project_object = source.getProject(project['name'])
-            url = source.getGitUrl(project_object)
-            repo = git.Repo.clone_from(
-                os.path.join(self.executor_server.merge_root,
-                             source.canonical_hostname,
-                             project['name']),
-                os.path.join(self.jobdir.src_root,
-                             source.canonical_hostname,
-                             project['name']))
-
-            repo.remotes.origin.config_writer.set('url', url)
-            repos.append(repo)
+            repo = merger.getRepo(project['connection'],
+                                  project['name'])
+            repos[project['canonical_name']] = repo
 
         merge_items = [i for i in args['items'] if i.get('refspec')]
         if merge_items:
-            if not self.doMergeChanges(merge_items, args['repo_state']):
+            if not self.doMergeChanges(merger, merge_items,
+                                       args['repo_state']):
                 # There was a merge conflict and we have already sent
                 # a work complete result, don't run any jobs
                 return
 
+        for project in args['projects']:
+            repo = repos[project['canonical_name']]
+            self.checkoutBranch(repo,
+                                project['name'],
+                                args['branch'],
+                                args['override_branch'],
+                                project['override_branch'],
+                                project['default_branch'])
+
         # Delete the origin remote from each repo we set up since
         # it will not be valid within the jobs.
-        for repo in repos:
-            repo.delete_remote(repo.remotes.origin)
+        for repo in repos.values():
+            repo.deleteRemote('origin')
 
         # is the playbook in a repo that we have already prepared?
         trusted, untrusted = self.preparePlaybookRepos(args)
@@ -646,16 +825,43 @@
         result = dict(result=result)
         self.job.sendWorkComplete(json.dumps(result))
 
-    def doMergeChanges(self, items, repo_state):
-        # Get a merger in order to update the repos involved in this job.
-        merger = self.executor_server._getMerger(self.jobdir.src_root)
+    def doMergeChanges(self, merger, items, repo_state):
         ret = merger.mergeChanges(items, repo_state=repo_state)
         if not ret:  # merge conflict
             result = dict(result='MERGER_FAILURE')
             self.job.sendWorkComplete(json.dumps(result))
             return False
+        recent = ret[3]
+        for key, commit in recent.items():
+            (connection, project, branch) = key
+            repo = merger.getRepo(connection, project)
+            repo.setRef('refs/heads/' + branch, commit)
         return True
 
+    def checkoutBranch(self, repo, project_name, zuul_branch,
+                       job_branch, project_override_branch,
+                       project_default_branch):
+        branches = repo.getBranches()
+        if project_override_branch in branches:
+            self.log.info("Checking out %s project override branch %s",
+                          project_name, project_override_branch)
+            repo.checkoutLocalBranch(project_override_branch)
+        elif job_branch in branches:
+            self.log.info("Checking out %s job branch %s",
+                          project_name, job_branch)
+            repo.checkoutLocalBranch(job_branch)
+        elif zuul_branch and zuul_branch in branches:
+            self.log.info("Checking out %s zuul branch %s",
+                          project_name, zuul_branch)
+            repo.checkoutLocalBranch(zuul_branch)
+        elif project_default_branch in branches:
+            self.log.info("Checking out %s project default branch %s",
+                          project_name, project_default_branch)
+            repo.checkoutLocalBranch(project_default_branch)
+        else:
+            raise Exception("Project %s does not have the default branch %s" %
+                            (project_name, project_default_branch))
+
     def runPlaybooks(self, args):
         result = None
 
@@ -707,6 +913,7 @@
             ip = node.get('interface_ip')
             host_vars = dict(
                 ansible_host=ip,
+                ansible_user=self.executor_server.default_username,
                 nodepool_az=node.get('az'),
                 nodepool_provider=node.get('provider'),
                 nodepool_region=node.get('region'))
@@ -811,7 +1018,8 @@
         # the stack of changes we are testing, so check out the branch
         # tip into a dedicated space.
 
-        merger = self.executor_server._getMerger(jobdir_playbook.root)
+        merger = self.executor_server._getMerger(jobdir_playbook.root,
+                                                 self.log)
         merger.checkoutBranch(playbook['connection'], project.name,
                               playbook['branch'])
 
@@ -895,7 +1103,8 @@
             # in the dependency chain for the change (in which case,
             # there is no existing untrusted checkout of it).  Check
             # out the branch tip into a dedicated space.
-            merger = self.executor_server._getMerger(trusted_root)
+            merger = self.executor_server._getMerger(trusted_root,
+                                                     self.log)
             merger.checkoutBranch(role['connection'], project.name,
                                   'master')
             orig_repo_path = os.path.join(trusted_root,
@@ -943,28 +1152,24 @@
             self.jobdir.trusted_roles_path.append(trusted_role_path)
 
     def prepareAnsibleFiles(self, args):
-        keys = []
-        with open(self.jobdir.inventory, 'w') as inventory:
-            for item in self.getHostList(args):
-                inventory.write(item['name'])
-                for k, v in item['host_vars'].items():
-                    inventory.write(' %s="%s"' % (k, v))
-                inventory.write('\n')
-                for key in item['host_keys']:
-                    keys.append(key)
+        all_vars = dict(args['vars'])
+        all_vars['zuul']['executor'] = dict(
+            hostname=self.executor_server.hostname,
+            src_root=self.jobdir.src_root,
+            log_root=self.jobdir.log_root)
+
+        nodes = self.getHostList(args)
+        inventory = make_inventory_dict(nodes, args['groups'], all_vars)
+
+        with open(self.jobdir.inventory, 'w') as inventory_yaml:
+            inventory_yaml.write(
+                yaml.safe_dump(inventory, default_flow_style=False))
 
         with open(self.jobdir.known_hosts, 'w') as known_hosts:
-            for key in keys:
-                known_hosts.write('%s\n' % key)
+            for node in nodes:
+                for key in node['host_keys']:
+                    known_hosts.write('%s\n' % key)
 
-        with open(self.jobdir.vars, 'w') as vars_yaml:
-            zuul_vars = dict(args['vars'])
-            zuul_vars['zuul']['executor'] = dict(
-                hostname=self.executor_server.hostname,
-                src_root=self.jobdir.src_root,
-                log_root=self.jobdir.log_root)
-            vars_yaml.write(
-                yaml.safe_dump(zuul_vars, default_flow_style=False))
         self.writeAnsibleConfig(self.jobdir.untrusted_config)
         self.writeAnsibleConfig(self.jobdir.trusted_config, trusted=True)
 
@@ -1042,12 +1247,26 @@
 
     def runAnsible(self, cmd, timeout, trusted=False):
         env_copy = os.environ.copy()
+        env_copy.update(self.ssh_agent.env)
         env_copy['LOGNAME'] = 'zuul'
+        pythonpath = env_copy.get('PYTHONPATH')
+        if pythonpath:
+            pythonpath = [pythonpath]
+        else:
+            pythonpath = []
+        pythonpath = [self.executor_server.ansible_dir] + pythonpath
+        env_copy['PYTHONPATH'] = os.path.pathsep.join(pythonpath)
 
         if trusted:
             config_file = self.jobdir.trusted_config
+            popen = subprocess.Popen
         else:
             config_file = self.jobdir.untrusted_config
+            driver = self.executor_server.untrusted_wrapper
+            popen = driver.getPopen(
+                work_dir=self.jobdir.root,
+                ansible_dir=self.executor_server.ansible_dir,
+                ssh_auth_sock=env_copy.get('SSH_AUTH_SOCK'))
 
         env_copy['ANSIBLE_CONFIG'] = config_file
 
@@ -1056,7 +1275,7 @@
                 return (self.RESULT_ABORTED, None)
             self.log.debug("Ansible command: ANSIBLE_CONFIG=%s %s",
                            config_file, " ".join(shlex_quote(c) for c in cmd))
-            self.proc = subprocess.Popen(
+            self.proc = popen(
                 cmd,
                 cwd=self.jobdir.work_root,
                 stdout=subprocess.PIPE,
@@ -1104,12 +1323,10 @@
         else:
             verbose = '-v'
 
-        cmd = ['ansible-playbook', playbook.path]
+        cmd = ['ansible-playbook', verbose, playbook.path]
 
         if success is not None:
             cmd.extend(['-e', 'success=%s' % str(bool(success))])
 
-        cmd.extend(['-e@%s' % self.jobdir.vars, verbose])
-
         return self.runAnsible(
             cmd=cmd, timeout=timeout, trusted=playbook.trusted)
diff --git a/zuul/lib/connections.py b/zuul/lib/connections.py
index 9908fff..79d78f4 100644
--- a/zuul/lib/connections.py
+++ b/zuul/lib/connections.py
@@ -22,6 +22,8 @@
 import zuul.driver.smtp
 import zuul.driver.timer
 import zuul.driver.sql
+import zuul.driver.bubblewrap
+import zuul.driver.nullwrap
 from zuul.connection import BaseConnection
 from zuul.driver import SourceInterface
 
@@ -46,6 +48,8 @@
         self.registerDriver(zuul.driver.smtp.SMTPDriver())
         self.registerDriver(zuul.driver.timer.TimerDriver())
         self.registerDriver(zuul.driver.sql.SQLDriver())
+        self.registerDriver(zuul.driver.bubblewrap.BubblewrapDriver())
+        self.registerDriver(zuul.driver.nullwrap.NullwrapDriver())
 
     def registerDriver(self, driver):
         if driver.name in self.drivers:
diff --git a/zuul/lib/log_streamer.py b/zuul/lib/log_streamer.py
index 8bb586f..de072b6 100644
--- a/zuul/lib/log_streamer.py
+++ b/zuul/lib/log_streamer.py
@@ -47,8 +47,39 @@
     the (class/method/attribute) names were changed to protect the innocent.
     '''
 
+    MAX_REQUEST_LEN = 1024
+    REQUEST_TIMEOUT = 10
+
+    def get_command(self):
+        poll = select.poll()
+        bitmask = (select.POLLIN | select.POLLERR |
+                   select.POLLHUP | select.POLLNVAL)
+        poll.register(self.request, bitmask)
+        buffer = b''
+        ret = None
+        start = time.time()
+        while True:
+            elapsed = time.time() - start
+            timeout = max(self.REQUEST_TIMEOUT - elapsed, 0)
+            if not timeout:
+                raise Exception("Timeout while waiting for input")
+            for fd, event in poll.poll(timeout):
+                if event & select.POLLIN:
+                    buffer += self.request.recv(self.MAX_REQUEST_LEN)
+                else:
+                    raise Exception("Received error event")
+            if len(buffer) >= self.MAX_REQUEST_LEN:
+                raise Exception("Request too long")
+            try:
+                ret = buffer.decode('utf-8')
+                x = ret.find('\n')
+                if x > 0:
+                    return ret[:x]
+            except UnicodeDecodeError:
+                pass
+
     def handle(self):
-        build_uuid = self.request.recv(1024).decode("utf-8")
+        build_uuid = self.get_command()
         build_uuid = build_uuid.rstrip()
 
         # validate build ID
@@ -172,8 +203,14 @@
         '''
         Overridden from base class to shutdown the socket immediately.
         '''
-        self.socket.shutdown(socket.SHUT_RD)
-        self.socket.close()
+        try:
+            self.socket.shutdown(socket.SHUT_RD)
+            self.socket.close()
+        except socket.error as e:
+            # If it's already closed, don't error.
+            if e.errno == socket.EBADF:
+                return
+            raise
 
 
 class LogStreamer(object):
diff --git a/zuul/manager/__init__.py b/zuul/manager/__init__.py
index 6ea182b..3728c73 100644
--- a/zuul/manager/__init__.py
+++ b/zuul/manager/__init__.py
@@ -282,6 +282,10 @@
 
         if not ignore_requirements:
             for f in self.changeish_filters:
+                if f.connection_name != change.project.connection_name:
+                    self.log.debug("Filter %s skipped for change %s due "
+                                   "to mismatched connections" % (f, change))
+                    continue
                 if not f.matches(change):
                     self.log.debug("Change %s does not match pipeline "
                                    "requirement %s" % (change, f))
@@ -312,9 +316,7 @@
                 item.enqueue_time = enqueue_time
             item.live = live
             self.reportStats(item)
-            if not quiet:
-                if len(self.pipeline.start_actions) > 0:
-                    self.reportStart(item)
+            item.quiet = quiet
             self.enqueueChangesBehind(change, quiet, ignore_requirements,
                                       change_queue)
             zuul_driver = self.sched.connections.drivers['zuul']
@@ -580,6 +582,14 @@
                 self.cancelJobs(item)
             if actionable:
                 ready = self.prepareItem(item) and self.prepareJobs(item)
+                # Starting jobs reporting should only be done once if there are
+                # jobs to run for this item.
+                if ready and len(self.pipeline.start_actions) > 0 \
+                        and len(item.job_graph.jobs) > 0 \
+                        and not item.reported_start \
+                        and not item.quiet:
+                    self.reportStart(item)
+                    item.reported_start = True
                 if item.current_build_set.unable_to_merge:
                     failing_reasons.append("it has a merge conflict")
                 if item.current_build_set.config_error:
@@ -721,7 +731,20 @@
     def _reportItem(self, item):
         self.log.debug("Reporting change %s" % item.change)
         ret = True  # Means error as returned by trigger.report
-        if item.getConfigError():
+
+        # In the case of failure, we may not hove completed an initial
+        # merge which would get the layout for this item, so in order
+        # to determine whether this item's project is in this
+        # pipeline, use the dynamic layout if available, otherwise,
+        # fall back to the current static layout as a best
+        # approximation.
+        layout = item.layout or self.pipeline.layout
+
+        if not layout.hasProject(item.change.project):
+            self.log.debug("Project %s not in pipeline %s for change %s" % (
+                item.change.project, self.pipeline, item.change))
+            actions = []
+        elif item.getConfigError():
             self.log.debug("Invalid config for change %s" % item.change)
             # TODOv3(jeblair): consider a new reporter action for this
             actions = self.pipeline.merge_failure_actions
@@ -729,9 +752,12 @@
         elif item.didMergerFail():
             actions = self.pipeline.merge_failure_actions
             item.setReportedResult('MERGER_FAILURE')
+        elif item.wasDequeuedNeedingChange():
+            actions = self.pipeline.failure_actions
+            item.setReportedResult('FAILURE')
         elif not item.getJobs():
             # We don't send empty reports with +1
-            self.log.debug("No jobs for change %s" % item.change)
+            self.log.debug("No jobs for change %s" % (item.change,))
             actions = []
         elif item.didAllJobsSucceed():
             self.log.debug("success %s" % (self.pipeline.success_actions))
@@ -742,7 +768,7 @@
             actions = self.pipeline.failure_actions
             item.setReportedResult('FAILURE')
             self.pipeline._consecutive_failures += 1
-        if self.pipeline._disabled:
+        if layout.hasProject(item.change.project) and self.pipeline._disabled:
             actions = self.pipeline.disabled_actions
         # Check here if we should disable so that we only use the disabled
         # reporters /after/ the last disable_at failure is still reported as
diff --git a/zuul/merger/merger.py b/zuul/merger/merger.py
index ee83fa0..6cfd904 100644
--- a/zuul/merger/merger.py
+++ b/zuul/merger/merger.py
@@ -42,13 +42,17 @@
 
 
 class Repo(object):
-    log = logging.getLogger("zuul.Repo")
-
-    def __init__(self, remote, local, email, username):
+    def __init__(self, remote, local, email, username,
+                 cache_path=None, logger=None):
+        if logger is None:
+            self.log = logging.getLogger("zuul.Repo")
+        else:
+            self.log = logger
         self.remote_url = remote
         self.local_path = local
         self.email = email
         self.username = username
+        self.cache_path = cache_path
         self._initialized = False
         try:
             self._ensure_cloned()
@@ -60,17 +64,32 @@
         if self._initialized and repo_is_cloned:
             return
         # If the repo does not exist, clone the repo.
+        rewrite_url = False
         if not repo_is_cloned:
             self.log.debug("Cloning from %s to %s" % (self.remote_url,
                                                       self.local_path))
-            git.Repo.clone_from(self.remote_url, self.local_path)
+            if self.cache_path:
+                git.Repo.clone_from(self.cache_path, self.local_path)
+                rewrite_url = True
+            else:
+                git.Repo.clone_from(self.remote_url, self.local_path)
         repo = git.Repo(self.local_path)
+        # Create local branches corresponding to all the remote branches
+        if not repo_is_cloned:
+            origin = repo.remotes.origin
+            for ref in origin.refs:
+                if ref.remote_head == 'HEAD':
+                    continue
+                repo.create_head(ref.remote_head, ref, force=True)
         with repo.config_writer() as config_writer:
             if self.email:
                 config_writer.set_value('user', 'email', self.email)
             if self.username:
                 config_writer.set_value('user', 'name', self.username)
             config_writer.write()
+        if rewrite_url:
+            with repo.remotes.origin.config_writer as config_writer:
+                config_writer.set('url', self.remote_url)
         self._initialized = True
 
     def isInitialized(self):
@@ -118,6 +137,10 @@
         origin = repo.remotes.origin
         return branch in origin.refs
 
+    def getBranches(self):
+        repo = self.createRepoObject()
+        return [x.name for x in repo.heads]
+
     def getCommitFromRef(self, refname):
         repo = self.createRepoObject()
         if refname not in repo.refs:
@@ -129,6 +152,14 @@
         repo = self.createRepoObject()
         return repo.refs
 
+    def setRef(self, path, hexsha, repo=None):
+        if repo is None:
+            repo = self.createRepoObject()
+        binsha = gitdb.util.to_bin_sha(hexsha)
+        obj = git.objects.Object.new_from_sha(repo, binsha)
+        self.log.debug("Create reference %s", path)
+        git.refs.Reference.create(repo, path, obj, force=True)
+
     def setRefs(self, refs):
         repo = self.createRepoObject()
         current_refs = {}
@@ -136,10 +167,7 @@
             current_refs[ref.path] = ref
         unseen = set(current_refs.keys())
         for path, hexsha in refs.items():
-            binsha = gitdb.util.to_bin_sha(hexsha)
-            obj = git.objects.Object.new_from_sha(repo, binsha)
-            self.log.debug("Create reference %s", path)
-            git.refs.Reference.create(repo, path, obj, force=True)
+            self.setRef(path, hexsha, repo)
             unseen.discard(path)
         for path in unseen:
             self.log.debug("Delete reference %s", path)
@@ -152,6 +180,13 @@
         reset_repo_to_head(repo)
         return repo.head.commit
 
+    def checkoutLocalBranch(self, branch):
+        repo = self.createRepoObject()
+        # Perform a hard reset before checking out so that we clean up
+        # anything that might be left over from a merge.
+        reset_repo_to_head(repo)
+        repo.heads[branch].checkout()
+
     def cherryPick(self, ref):
         repo = self.createRepoObject()
         self.log.debug("Cherry-picking %s" % ref)
@@ -225,11 +260,19 @@
                 ret[fn] = None
         return ret
 
+    def deleteRemote(self, remote):
+        repo = self.createRepoObject()
+        repo.delete_remote(repo.remotes[remote])
+
 
 class Merger(object):
-    log = logging.getLogger("zuul.Merger")
-
-    def __init__(self, working_root, connections, email, username):
+    def __init__(self, working_root, connections, email, username,
+                 cache_root=None, logger=None):
+        self.logger = logger
+        if logger is None:
+            self.log = logging.getLogger("zuul.Merger")
+        else:
+            self.log = logger
         self.repos = {}
         self.working_root = working_root
         if not os.path.exists(working_root):
@@ -237,6 +280,7 @@
         self.connections = connections
         self.email = email
         self.username = username
+        self.cache_root = cache_root
 
     def _get_ssh_cmd(self, connection_name):
         sshkey = self.connections.connections.get(connection_name).\
@@ -259,7 +303,13 @@
         key = '/'.join([hostname, project_name])
         try:
             path = os.path.join(self.working_root, hostname, project_name)
-            repo = Repo(url, path, self.email, self.username)
+            if self.cache_root:
+                cache_path = os.path.join(self.cache_root, hostname,
+                                          project_name)
+            else:
+                cache_path = None
+            repo = Repo(url, path, self.email, self.username, cache_path,
+                        self.logger)
 
             self.repos[key] = repo
         except Exception:
@@ -296,28 +346,25 @@
                                connection_name, project_name)
 
     def checkoutBranch(self, connection_name, project_name, branch):
+        self.log.info("Checking out %s/%s branch %s",
+                      connection_name, project_name, branch)
         repo = self.getRepo(connection_name, project_name)
-        if repo.hasBranch(branch):
-            self.log.info("Checking out branch %s of %s/%s" %
-                          (branch, connection_name, project_name))
-            head = repo.getBranchHead(branch)
-            repo.checkout(head)
-        else:
-            raise Exception("Project %s/%s does not have branch %s" %
-                            (connection_name, project_name, branch))
+        repo.checkoutLocalBranch(branch)
 
     def _saveRepoState(self, connection_name, project_name, repo,
-                       repo_state):
+                       repo_state, recent):
         projects = repo_state.setdefault(connection_name, {})
         project = projects.setdefault(project_name, {})
-        if project:
-            # We already have a state for this project.
-            return
         for ref in repo.getRefs():
-            if ref.path.startswith('refs/zuul'):
+            if ref.path.startswith('refs/zuul/'):
                 continue
-            if ref.path.startswith('refs/remotes'):
+            if ref.path.startswith('refs/remotes/'):
                 continue
+            if ref.path.startswith('refs/heads/'):
+                branch = ref.path[len('refs/heads/'):]
+                key = (connection_name, project_name, branch)
+                if key not in recent:
+                    recent[key] = ref.object
             project[ref.path] = ref.object.hexsha
 
     def _restoreRepoState(self, connection_name, project_name, repo,
@@ -386,7 +433,7 @@
             # Save the repo state so that later mergers can repeat
             # this process.
             self._saveRepoState(item['connection'], item['project'], repo,
-                                repo_state)
+                                repo_state, recent)
         else:
             self.log.debug("Found base commit %s for %s" % (base, key,))
         # Merge the change
@@ -439,7 +486,10 @@
                     project=item['project'],
                     branch=item['branch'],
                     files=repo_files))
-        return commit.hexsha, read_files, repo_state
+        ret_recent = {}
+        for k, v in recent.items():
+            ret_recent[k] = v.hexsha
+        return commit.hexsha, read_files, repo_state, ret_recent
 
     def getFiles(self, connection_name, project_name, branch, files):
         repo = self.getRepo(connection_name, project_name)
diff --git a/zuul/merger/server.py b/zuul/merger/server.py
index 15f1a41..1a32f96 100644
--- a/zuul/merger/server.py
+++ b/zuul/merger/server.py
@@ -110,7 +110,8 @@
         if ret is None:
             result['commit'] = result['files'] = result['repo_state'] = None
         else:
-            result['commit'], result['files'], result['repo_state'] = ret
+            (result['commit'], result['files'], result['repo_state'],
+             recent) = ret
         job.sendWorkComplete(json.dumps(result))
 
     def cat(self, job):
diff --git a/zuul/model.py b/zuul/model.py
index bfd4d76..6ad34ff 100644
--- a/zuul/model.py
+++ b/zuul/model.py
@@ -410,6 +410,37 @@
         self._keys = keys
 
 
+class Group(object):
+    """A logical group of nodes for use by a job.
+
+    A Group is a named set of node names that will be provided to
+    jobs in the inventory to describe logical units where some subset of tasks
+    run.
+    """
+
+    def __init__(self, name, nodes):
+        self.name = name
+        self.nodes = nodes
+
+    def __repr__(self):
+        return '<Group %s %s>' % (self.name, str(self.nodes))
+
+    def __ne__(self, other):
+        return not self.__eq__(other)
+
+    def __eq__(self, other):
+        if not isinstance(other, Group):
+            return False
+        return (self.name == other.name and
+                self.nodes == other.nodes)
+
+    def toDict(self):
+        return {
+            'name': self.name,
+            'nodes': self.nodes
+        }
+
+
 class NodeSet(object):
     """A set of nodes.
 
@@ -423,6 +454,7 @@
     def __init__(self, name=None):
         self.name = name or ''
         self.nodes = OrderedDict()
+        self.groups = OrderedDict()
 
     def __ne__(self, other):
         return not self.__eq__(other)
@@ -437,6 +469,8 @@
         n = NodeSet(self.name)
         for name, node in self.nodes.items():
             n.addNode(Node(node.name, node.image))
+        for name, group in self.groups.items():
+            n.addGroup(Group(group.name, group.nodes[:]))
         return n
 
     def addNode(self, node):
@@ -447,12 +481,20 @@
     def getNodes(self):
         return list(self.nodes.values())
 
+    def addGroup(self, group):
+        if group.name in self.groups:
+            raise Exception("Duplicate group in %s" % (self,))
+        self.groups[group.name] = group
+
+    def getGroups(self):
+        return list(self.groups.values())
+
     def __repr__(self):
         if self.name:
             name = self.name + ' '
         else:
             name = ''
-        return '<NodeSet %s%s>' % (name, self.nodes)
+        return '<NodeSet %s%s%s>' % (name, self.nodes, self.groups)
 
 
 class NodeRequest(object):
@@ -752,8 +794,9 @@
             attempts=3,
             final=False,
             roles=frozenset(),
-            repos=frozenset(),
+            required_projects={},
             allowed_projects=None,
+            override_branch=None,
         )
 
         # These are generally internal attributes which are not
@@ -820,6 +863,11 @@
         Job._deepUpdate(v, other_vars)
         self.variables = v
 
+    def updateProjects(self, other_projects):
+        required_projects = self.required_projects
+        Job._deepUpdate(required_projects, other_projects)
+        self.required_projects = required_projects
+
     @staticmethod
     def _deepUpdate(a, b):
         # Merge nested dictionaries if possible, otherwise, overwrite
@@ -871,7 +919,8 @@
                                     "%s=%s with variant %s" % (
                                         repr(self), k, other._get(k),
                                         repr(other)))
-                if k not in set(['pre_run', 'post_run', 'roles', 'variables']):
+                if k not in set(['pre_run', 'post_run', 'roles', 'variables',
+                                 'required_projects']):
                     setattr(self, k, copy.deepcopy(other._get(k)))
 
         # Don't set final above so that we don't trip an error halfway
@@ -887,6 +936,8 @@
             self.roles = self.roles.union(other.roles)
         if other._get('variables') is not None:
             self.updateVariables(other.variables)
+        if other._get('required_projects') is not None:
+            self.updateProjects(other.required_projects)
 
         for k in self.context_attributes:
             if (other._get(k) is not None and
@@ -914,6 +965,14 @@
         return True
 
 
+class JobProject(object):
+    """ A reference to a project from a job. """
+
+    def __init__(self, project_name, override_branch=None):
+        self.project_name = project_name
+        self.override_branch = override_branch
+
+
 class JobList(object):
     """ A list of jobs in a project's pipeline. """
 
@@ -929,7 +988,7 @@
     def inheritFrom(self, other):
         for jobname, jobs in other.jobs.items():
             if jobname in self.jobs:
-                self.jobs[jobname].append(jobs)
+                self.jobs[jobname].extend(jobs)
             else:
                 self.jobs[jobname] = jobs
 
@@ -1273,6 +1332,8 @@
         self.enqueue_time = None
         self.dequeue_time = None
         self.reported = False
+        self.reported_start = False
+        self.quiet = False
         self.active = False  # Whether an item is within an active window
         self.live = True  # Whether an item is intended to be processed at all
         self.layout = None  # This item's shadow layout
@@ -1380,6 +1441,9 @@
     def getConfigError(self):
         return self.current_build_set.config_error
 
+    def wasDequeuedNeedingChange(self):
+        return self.dequeued_needing_change
+
     def isHoldingFollowingChanges(self):
         if not self.live:
             return False
@@ -1524,8 +1588,8 @@
         # secrets, etc.
         safe_change = self.change.getSafeAttributes()
         safe_pipeline = self.pipeline.getSafeAttributes()
-        safe_job = job.getSafeAttributes()
-        safe_build = build.getSafeAttributes()
+        safe_job = job.getSafeAttributes() if job else {}
+        safe_build = build.getSafeAttributes() if build else {}
         try:
             url = url_pattern.format(change=safe_change,
                                      pipeline=safe_pipeline,
@@ -1924,8 +1988,9 @@
 
 class RefFilter(BaseFilter):
     """Allows a Manager to only enqueue Changes that meet certain criteria."""
-    def __init__(self):
+    def __init__(self, connection_name):
         super(RefFilter, self).__init__()
+        self.connection_name = connection_name
 
     def matches(self, change):
         return True
@@ -2186,6 +2251,9 @@
             self._createJobGraph(item, project_job_list, ret)
         return ret
 
+    def hasProject(self, project):
+        return project.canonical_name in self.project_configs
+
 
 class Semaphore(object):
     def __init__(self, name, max=1):
diff --git a/zuul/scheduler.py b/zuul/scheduler.py
index 40d5eb7..61f1e5f 100644
--- a/zuul/scheduler.py
+++ b/zuul/scheduler.py
@@ -258,11 +258,6 @@
 
     def addEvent(self, event):
         self.log.debug("Adding trigger event: %s" % event)
-        try:
-            if self.statsd:
-                self.statsd.incr('gerrit.event.%s' % event.type)
-        except:
-            self.log.exception("Exception reporting event stats")
         self.trigger_event_queue.put(event)
         self.wake_event.set()
         self.log.debug("Done adding trigger event: %s" % event)