Merge "Only strip trailing whitespace from console logs" into feature/zuulv3
diff --git a/tests/base.py b/tests/base.py
index 4214809..45d724d 100755
--- a/tests/base.py
+++ b/tests/base.py
@@ -2145,6 +2145,7 @@
         def getGithubConnection(driver, name, config):
             con = FakeGithubConnection(driver, name, config,
                                        upstream_root=self.upstream_root)
+            self.event_queues.append(con.event_queue)
             setattr(self, 'fake_' + name, con)
             return con
 
diff --git a/tests/fixtures/config/in-repo-join/git/common-config/playbooks/common-config-test.yaml b/tests/fixtures/config/in-repo-join/git/common-config/playbooks/common-config-test.yaml
new file mode 100644
index 0000000..f679dce
--- /dev/null
+++ b/tests/fixtures/config/in-repo-join/git/common-config/playbooks/common-config-test.yaml
@@ -0,0 +1,2 @@
+- hosts: all
+  tasks: []
diff --git a/tests/fixtures/config/in-repo-join/git/common-config/zuul.yaml b/tests/fixtures/config/in-repo-join/git/common-config/zuul.yaml
new file mode 100644
index 0000000..561fc39
--- /dev/null
+++ b/tests/fixtures/config/in-repo-join/git/common-config/zuul.yaml
@@ -0,0 +1,46 @@
+- 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 (tenant-one-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: base
+    parent: null
+
+- job:
+    name: common-config-test
+
+- project:
+    name: org/project
+    check:
+      jobs:
+        - common-config-test
diff --git a/tests/fixtures/config/in-repo-join/git/org_project/.zuul.yaml b/tests/fixtures/config/in-repo-join/git/org_project/.zuul.yaml
new file mode 100644
index 0000000..280342c
--- /dev/null
+++ b/tests/fixtures/config/in-repo-join/git/org_project/.zuul.yaml
@@ -0,0 +1,2 @@
+- job:
+    name: project-test1
diff --git a/tests/fixtures/config/in-repo-join/git/org_project/README b/tests/fixtures/config/in-repo-join/git/org_project/README
new file mode 100644
index 0000000..9daeafb
--- /dev/null
+++ b/tests/fixtures/config/in-repo-join/git/org_project/README
@@ -0,0 +1 @@
+test
diff --git a/tests/fixtures/config/in-repo-join/git/org_project/playbooks/project-test1.yaml b/tests/fixtures/config/in-repo-join/git/org_project/playbooks/project-test1.yaml
new file mode 100644
index 0000000..f679dce
--- /dev/null
+++ b/tests/fixtures/config/in-repo-join/git/org_project/playbooks/project-test1.yaml
@@ -0,0 +1,2 @@
+- hosts: all
+  tasks: []
diff --git a/tests/fixtures/config/in-repo-join/main.yaml b/tests/fixtures/config/in-repo-join/main.yaml
new file mode 100644
index 0000000..208e274
--- /dev/null
+++ b/tests/fixtures/config/in-repo-join/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/in-repo/git/common-config/zuul.yaml b/tests/fixtures/config/in-repo/git/common-config/zuul.yaml
index ff4268b..5623467 100644
--- a/tests/fixtures/config/in-repo/git/common-config/zuul.yaml
+++ b/tests/fixtures/config/in-repo/git/common-config/zuul.yaml
@@ -78,6 +78,8 @@
 
 - project:
     name: common-config
+    check:
+      jobs: []
     tenant-one-gate:
       jobs:
         - common-config-test
diff --git a/tests/fixtures/config/in-repo/git/org_project/.zuul.yaml b/tests/fixtures/config/in-repo/git/org_project/.zuul.yaml
index 60cd434..e1c27bb 100644
--- a/tests/fixtures/config/in-repo/git/org_project/.zuul.yaml
+++ b/tests/fixtures/config/in-repo/git/org_project/.zuul.yaml
@@ -3,6 +3,8 @@
 
 - project:
     name: org/project
+    check:
+      jobs: []
     tenant-one-gate:
       jobs:
         - project-test1
diff --git a/tests/unit/test_github_driver.py b/tests/unit/test_github_driver.py
index a088236..ebb5e1c 100644
--- a/tests/unit/test_github_driver.py
+++ b/tests/unit/test_github_driver.py
@@ -17,6 +17,7 @@
 from testtools.matchers import MatchesRegex, StartsWith
 import urllib
 import time
+from unittest import skip
 
 import git
 
@@ -685,6 +686,8 @@
         # New timestamp should be greater than the old timestamp
         self.assertLess(old, new)
 
+    # TODO(jlk): Make this a more generic test for unknown project
+    @skip("Skipped for rewrite of webhook handler")
     @simple_layout('layouts/basic-github.yaml', driver='github')
     def test_ping_event(self):
         # Test valid ping
diff --git a/tests/unit/test_v3.py b/tests/unit/test_v3.py
index c681305..8eba623 100755
--- a/tests/unit/test_v3.py
+++ b/tests/unit/test_v3.py
@@ -371,55 +371,6 @@
             dict(name='project-test1', result='SUCCESS', changes='1,2'),
             dict(name='project-test2', result='SUCCESS', changes='1,2')])
 
-    def test_dynamic_dependent_pipeline(self):
-        # Test dynamically adding a project to a
-        # dependent pipeline for the first time
-        self.executor_server.hold_jobs_in_build = True
-
-        tenant = self.sched.abide.tenants.get('tenant-one')
-        gate_pipeline = tenant.layout.pipelines['gate']
-
-        in_repo_conf = textwrap.dedent(
-            """
-            - job:
-                name: project-test1
-
-            - job:
-                name: project-test2
-
-            - project:
-                name: org/project
-                gate:
-                  jobs:
-                    - project-test2
-            """)
-
-        in_repo_playbook = textwrap.dedent(
-            """
-            - hosts: all
-              tasks: []
-            """)
-
-        file_dict = {'.zuul.yaml': in_repo_conf,
-                     'playbooks/project-test2.yaml': in_repo_playbook}
-        A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A',
-                                           files=file_dict)
-        A.addApproval('Approved', 1)
-        self.fake_gerrit.addEvent(A.addApproval('Code-Review', 2))
-        self.waitUntilSettled()
-
-        items = gate_pipeline.getAllItems()
-        self.assertEqual(items[0].change.number, '1')
-        self.assertEqual(items[0].change.patchset, '1')
-        self.assertTrue(items[0].live)
-
-        self.executor_server.hold_jobs_in_build = False
-        self.executor_server.release()
-        self.waitUntilSettled()
-
-        # Make sure the dynamic queue got cleaned up
-        self.assertEqual(gate_pipeline.queues, [])
-
     def test_in_repo_branch(self):
         in_repo_conf = textwrap.dedent(
             """
@@ -846,6 +797,125 @@
                       "C should have an error reported")
 
 
+class TestInRepoJoin(ZuulTestCase):
+    # In this config, org/project is not a member of any pipelines, so
+    # that we may test the changes that cause it to join them.
+
+    tenant_config_file = 'config/in-repo-join/main.yaml'
+
+    def test_dynamic_dependent_pipeline(self):
+        # Test dynamically adding a project to a
+        # dependent pipeline for the first time
+        self.executor_server.hold_jobs_in_build = True
+
+        tenant = self.sched.abide.tenants.get('tenant-one')
+        gate_pipeline = tenant.layout.pipelines['gate']
+
+        in_repo_conf = textwrap.dedent(
+            """
+            - job:
+                name: project-test1
+
+            - job:
+                name: project-test2
+
+            - project:
+                name: org/project
+                gate:
+                  jobs:
+                    - project-test2
+            """)
+
+        in_repo_playbook = textwrap.dedent(
+            """
+            - hosts: all
+              tasks: []
+            """)
+
+        file_dict = {'.zuul.yaml': in_repo_conf,
+                     'playbooks/project-test2.yaml': in_repo_playbook}
+        A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A',
+                                           files=file_dict)
+        A.addApproval('Code-Review', 2)
+        self.fake_gerrit.addEvent(A.addApproval('Approved', 1))
+        self.waitUntilSettled()
+
+        items = gate_pipeline.getAllItems()
+        self.assertEqual(items[0].change.number, '1')
+        self.assertEqual(items[0].change.patchset, '1')
+        self.assertTrue(items[0].live)
+
+        self.executor_server.hold_jobs_in_build = False
+        self.executor_server.release()
+        self.waitUntilSettled()
+
+        # Make sure the dynamic queue got cleaned up
+        self.assertEqual(gate_pipeline.queues, [])
+
+    def test_dynamic_dependent_pipeline_failure(self):
+        # Test that a change behind a failing change adding a project
+        # to a dependent pipeline is dequeued.
+        self.executor_server.hold_jobs_in_build = True
+
+        in_repo_conf = textwrap.dedent(
+            """
+            - job:
+                name: project-test1
+
+            - project:
+                name: org/project
+                gate:
+                  jobs:
+                    - project-test1
+            """)
+
+        file_dict = {'.zuul.yaml': in_repo_conf}
+        A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A',
+                                           files=file_dict)
+        self.executor_server.failJob('project-test1', A)
+        A.addApproval('Code-Review', 2)
+        self.fake_gerrit.addEvent(A.addApproval('Approved', 1))
+        self.waitUntilSettled()
+
+        B = self.fake_gerrit.addFakeChange('org/project', 'master', 'B')
+        B.addApproval('Code-Review', 2)
+        self.fake_gerrit.addEvent(B.addApproval('Approved', 1))
+        self.waitUntilSettled()
+
+        self.executor_server.hold_jobs_in_build = False
+        self.executor_server.release()
+        self.waitUntilSettled()
+        self.assertEqual(A.reported, 2,
+                         "A should report start and failure")
+        self.assertEqual(A.data['status'], 'NEW')
+        self.assertEqual(B.reported, 1,
+                         "B should report start")
+        self.assertHistory([
+            dict(name='project-test1', result='FAILURE', changes='1,1'),
+            dict(name='project-test1', result='FAILURE', changes='1,1 2,1'),
+        ], ordered=False)
+
+    def test_dynamic_dependent_pipeline_absent(self):
+        # Test that a series of dependent changes don't report merge
+        # failures to a pipeline they aren't in.
+        A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
+        B = self.fake_gerrit.addFakeChange('org/project', 'master', 'B')
+        B.setDependsOn(A, 1)
+
+        A.addApproval('Code-Review', 2)
+        A.addApproval('Approved', 1)
+        B.addApproval('Code-Review', 2)
+        self.fake_gerrit.addEvent(B.addApproval('Approved', 1))
+        self.waitUntilSettled()
+        self.assertEqual(A.reported, 0,
+                         "A should not report")
+        self.assertEqual(A.data['status'], 'NEW')
+        self.assertEqual(B.reported, 0,
+                         "B should not report")
+        self.assertEqual(B.data['status'], 'NEW')
+        self.assertHistory([])
+
+
 class TestAnsible(AnsibleZuulTestCase):
     # A temporary class to hold new tests while others are disabled
 
diff --git a/tools/zuul-cloner-shim.py b/tools/zuul-cloner-shim.py
new file mode 100755
index 0000000..3d1b2ae
--- /dev/null
+++ b/tools/zuul-cloner-shim.py
@@ -0,0 +1,157 @@
+#!/usr/bin/env python
+# Copyright 2017 Red Hat
+#
+# 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 os
+import re
+import sys
+import yaml
+
+from collections import defaultdict
+from collections import OrderedDict
+
+REPO_SRC_DIR = "~zuul/src/git.openstack.org/"
+
+
+# Class copied from zuul/lib/conemapper.py with minor logging changes
+class CloneMapper(object):
+
+    def __init__(self, clonemap, projects):
+        self.clonemap = clonemap
+        self.projects = projects
+
+    def expand(self, workspace):
+        print("Workspace path set to: %s" % workspace)
+
+        is_valid = True
+        ret = OrderedDict()
+        errors = []
+        for project in self.projects:
+            dests = []
+            for mapping in self.clonemap:
+                if re.match(r'^%s$' % mapping['name'], project):
+                    # Might be matched more than one time
+                    dests.append(
+                        re.sub(mapping['name'], mapping['dest'], project))
+
+            if len(dests) > 1:
+                errors.append(
+                    "Duplicate destinations for %s: %s." % (project, dests))
+                is_valid = False
+            elif len(dests) == 0:
+                print("Using %s as destination (unmatched)" % project)
+                ret[project] = [project]
+            else:
+                ret[project] = dests
+
+        if not is_valid:
+            raise Exception("Expansion errors: %s" % errors)
+
+        print("Mapping projects to workspace...")
+        for project, dest in ret.items():
+            dest = os.path.normpath(os.path.join(workspace, dest[0]))
+            ret[project] = dest
+            print("  %s -> %s" % (project, dest))
+
+        print("Checking overlap in destination directories...")
+        check = defaultdict(list)
+        for project, dest in ret.items():
+            check[dest].append(project)
+
+        dupes = dict((d, p) for (d, p) in check.items() if len(p) > 1)
+        if dupes:
+            raise Exception("Some projects share the same destination: %s",
+                            dupes)
+
+        print("Expansion completed.")
+        return ret
+
+
+def parseArgs():
+    ZUUL_ENV_SUFFIXES = ('branch', 'ref', 'url', 'project', 'newrev')
+
+    parser = argparse.ArgumentParser()
+
+    # Ignored arguments
+    parser.add_argument('-v', '--verbose', dest='verbose',
+                        action='store_true', help='IGNORED')
+    parser.add_argument('--color', dest='color', action='store_true',
+                        help='IGNORED')
+    parser.add_argument('--cache-dir', dest='cache_dir', help='IGNORED')
+    parser.add_argument('git_base_url', help='IGNORED')
+    parser.add_argument('--branch', help='IGNORED')
+    parser.add_argument('--project-branch', nargs=1, action='append',
+                        metavar='PROJECT=BRANCH', help='IGNORED')
+    for zuul_suffix in ZUUL_ENV_SUFFIXES:
+        env_name = 'ZUUL_%s' % zuul_suffix.upper()
+        parser.add_argument(
+            '--zuul-%s' % zuul_suffix, metavar='$' + env_name,
+            help='IGNORED'
+        )
+
+    # Active arguments
+    parser.add_argument('-m', '--map', dest='clone_map_file',
+                        help='specify clone map file')
+    parser.add_argument('--workspace', dest='workspace',
+                        default=os.getcwd(),
+                        help='where to clone repositories too')
+    parser.add_argument('projects', nargs='+',
+                        help='list of Gerrit projects to clone')
+
+    return parser.parse_args()
+
+
+def readCloneMap(clone_map):
+    clone_map_file = os.path.expanduser(clone_map)
+    if not os.path.exists(clone_map_file):
+        raise Exception("Unable to read clone map file at %s." %
+                        clone_map_file)
+    clone_map_file = open(clone_map_file)
+    clone_map = yaml.safe_load(clone_map_file).get('clonemap')
+    return clone_map
+
+
+def main():
+    args = parseArgs()
+
+    clone_map = []
+    if args.clone_map_file:
+        clone_map = readCloneMap(args.clone_map_file)
+
+    mapper = CloneMapper(clone_map, args.projects)
+    dests = mapper.expand(workspace=args.workspace)
+
+    for project in args.projects:
+        src = os.path.join(os.path.expanduser(REPO_SRC_DIR), project)
+        dst = dests[project]
+
+        # Remove the tail end of the path (since the copy operation will
+        # automatically create that)
+        d = dst.rstrip('/')
+        d, base = os.path.split(d)
+        if not os.path.exists(d):
+            print("Creating %s" % d)
+            os.makedirs(d)
+
+        # Create hard link copy of the source directory
+        cmd = "cp -al %s %s" % (src, dst)
+        print("%s" % cmd)
+        if os.system(cmd):
+            print("Error executing: %s" % cmd)
+            sys.exit(1)
+
+
+if __name__ == "__main__":
+    main()
diff --git a/zuul/driver/github/githubconnection.py b/zuul/driver/github/githubconnection.py
index 0ce6ef5..fca36c8 100644
--- a/zuul/driver/github/githubconnection.py
+++ b/zuul/driver/github/githubconnection.py
@@ -17,6 +17,8 @@
 import logging
 import hmac
 import hashlib
+import queue
+import threading
 import time
 import re
 
@@ -80,11 +82,10 @@
             delivery=delivery))
 
         self._validate_signature(request)
+        # TODO(jlk): Validate project in the request is a project we know
 
         try:
             self.__dispatch_event(request)
-        except webob.exc.HTTPNotFound:
-            raise
         except:
             self.log.exception("Exception handling Github event:")
 
@@ -98,20 +99,58 @@
                                            'header.')
 
         try:
-            method = getattr(self, '_event_' + event)
-        except AttributeError:
-            message = "Unhandled X-Github-Event: {0}".format(event)
-            self.log.debug(message)
-            # Returns empty 200 on unhandled events
-            raise webob.exc.HTTPOk()
-
-        try:
             json_body = request.json_body
+            self.connection.addEvent(json_body, event)
         except:
             message = 'Exception deserializing JSON body'
             self.log.exception(message)
             raise webob.exc.HTTPBadRequest(message)
 
+    def _validate_signature(self, request):
+        secret = self.connection.connection_config.get('webhook_token', None)
+        if secret is None:
+            raise RuntimeError("webhook_token is required")
+
+        body = request.body
+        try:
+            request_signature = request.headers['X-Hub-Signature']
+        except KeyError:
+            raise webob.exc.HTTPUnauthorized(
+                'Please specify a X-Hub-Signature header with secret.')
+
+        payload_signature = _sign_request(body, secret)
+
+        self.log.debug("Payload Signature: {0}".format(str(payload_signature)))
+        self.log.debug("Request Signature: {0}".format(str(request_signature)))
+        if not hmac.compare_digest(
+            str(payload_signature), str(request_signature)):
+            raise webob.exc.HTTPUnauthorized(
+                'Request signature does not match calculated payload '
+                'signature. Check that secret is correct.')
+
+        return True
+
+
+class GithubEventConnector(threading.Thread):
+    """Move events from GitHub into the scheduler"""
+
+    log = logging.getLogger("zuul.GithubEventConnector")
+
+    def __init__(self, connection):
+        super(GithubEventConnector, self).__init__()
+        self.daemon = True
+        self.connection = connection
+        self._stopped = False
+
+    def stop(self):
+        self._stopped = True
+        self.connection.addEvent(None)
+
+    def _handleEvent(self):
+        json_body, event_type = self.connection.getEvent()
+        if self._stopped:
+            return
+
         # If there's any installation mapping information in the body then
         # update the project mapping before any requests are made.
         installation_id = json_body.get('installation', {}).get('id')
@@ -127,9 +166,17 @@
             self.connection.installation_map[project_name] = installation_id
 
         try:
+            method = getattr(self, '_event_' + event_type)
+        except AttributeError:
+            # TODO(jlk): Gracefully handle event types we don't care about
+            # instead of logging an exception.
+            message = "Unhandled X-Github-Event: {0}".format(event_type)
+            self.log.debug(message)
+            # Returns empty on unhandled events
+            return
+
+        try:
             event = method(json_body)
-        except webob.exc.HTTPNotFound:
-            raise
         except:
             self.log.exception('Exception when handling event:')
             event = None
@@ -240,14 +287,6 @@
         event.action = body.get('action')
         return event
 
-    def _event_ping(self, body):
-        project_name = body['repository']['full_name']
-        if not self.connection.getProject(project_name):
-            self.log.warning("Ping received for unknown project %s" %
-                             project_name)
-            raise webob.exc.HTTPNotFound("Sorry, this project is not "
-                                         "registered")
-
     def _event_status(self, body):
         action = body.get('action')
         if action == 'pending':
@@ -277,30 +316,6 @@
                            (number, project_name))
         return pr_body
 
-    def _validate_signature(self, request):
-        secret = self.connection.connection_config.get('webhook_token', None)
-        if secret is None:
-            raise RuntimeError("webhook_token is required")
-
-        body = request.body
-        try:
-            request_signature = request.headers['X-Hub-Signature']
-        except KeyError:
-            raise webob.exc.HTTPUnauthorized(
-                'Please specify a X-Hub-Signature header with secret.')
-
-        payload_signature = _sign_request(body, secret)
-
-        self.log.debug("Payload Signature: {0}".format(str(payload_signature)))
-        self.log.debug("Request Signature: {0}".format(str(request_signature)))
-        if not hmac.compare_digest(
-            str(payload_signature), str(request_signature)):
-            raise webob.exc.HTTPUnauthorized(
-                'Request signature does not match calculated payload '
-                'signature. Check that secret is correct.')
-
-        return True
-
     def _pull_request_to_event(self, pr_body):
         event = GithubTriggerEvent()
         event.trigger_name = 'github'
@@ -327,6 +342,17 @@
         if login:
             return self.connection.getUser(login)
 
+    def run(self):
+        while True:
+            if self._stopped:
+                return
+            try:
+                self._handleEvent()
+            except:
+                self.log.exception("Exception moving GitHub event:")
+            finally:
+                self.connection.eventDone()
+
 
 class GithubUser(collections.Mapping):
     log = logging.getLogger('zuul.GithubUser')
@@ -376,6 +402,7 @@
         self.canonical_hostname = self.connection_config.get(
             'canonical_hostname', self.server)
         self.source = driver.getSource(self)
+        self.event_queue = queue.Queue()
 
         # ssl verification must default to true
         verify_ssl = self.connection_config.get('verify_ssl', 'true')
@@ -408,9 +435,20 @@
         self.registerHttpHandler(self.payload_path,
                                  webhook_listener.handle_request)
         self._authenticateGithubAPI()
+        self._start_event_connector()
 
     def onStop(self):
         self.unregisterHttpHandler(self.payload_path)
+        self._stop_event_connector()
+
+    def _start_event_connector(self):
+        self.github_event_connector = GithubEventConnector(self)
+        self.github_event_connector.start()
+
+    def _stop_event_connector(self):
+        if self.github_event_connector:
+            self.github_event_connector.stop()
+            self.github_event_connector.join()
 
     def _createGithubClient(self):
         if self.server != 'github.com':
@@ -504,6 +542,15 @@
 
         return token
 
+    def addEvent(self, data, event=None):
+        return self.event_queue.put((data, event))
+
+    def getEvent(self):
+        return self.event_queue.get()
+
+    def eventDone(self):
+        self.event_queue.task_done()
+
     def getGithubClient(self,
                         project=None,
                         user_id=None,
diff --git a/zuul/manager/__init__.py b/zuul/manager/__init__.py
index efd86eb..98c7350 100644
--- a/zuul/manager/__init__.py
+++ b/zuul/manager/__init__.py
@@ -529,11 +529,12 @@
 
         if not item.job_graph:
             try:
+                self.log.debug("Freezing job graph for %s" % (item,))
                 item.freezeJobGraph()
             except Exception as e:
                 # TODOv3(jeblair): nicify this exception as it will be reported
                 self.log.exception("Error freezing job graph for %s" %
-                                   item)
+                                   (item,))
                 item.setConfigError("Unable to freeze job graph: %s" %
                                     (str(e)))
                 return False
@@ -752,9 +753,12 @@
         layout = (item.current_build_set.layout or
                   self.pipeline.layout)
 
-        if not layout.hasProject(item.change.project):
+        project_in_pipeline = True
+        if not layout.getProjectPipelineConfig(item.change.project,
+                                               self.pipeline):
             self.log.debug("Project %s not in pipeline %s for change %s" % (
                 item.change.project, self.pipeline, item.change))
+            project_in_pipeline = False
             actions = []
         elif item.getConfigError():
             self.log.debug("Invalid config for change %s" % item.change)
@@ -780,7 +784,7 @@
             actions = self.pipeline.failure_actions
             item.setReportedResult('FAILURE')
             self.pipeline._consecutive_failures += 1
-        if layout.hasProject(item.change.project) and self.pipeline._disabled:
+        if project_in_pipeline 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/model.py b/zuul/model.py
index 850bbe2..d23d56f 100644
--- a/zuul/model.py
+++ b/zuul/model.py
@@ -1374,6 +1374,7 @@
         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
+        # TODO(jeblair): move job_graph to buildset
         self.job_graph = None
 
     def __repr__(self):
@@ -1391,6 +1392,7 @@
         old.next_build_set = self.current_build_set
         self.current_build_set.previous_build_set = old
         self.build_sets.append(self.current_build_set)
+        self.job_graph = None
 
     def addBuild(self, build):
         self.current_build_set.addBuild(build)
@@ -2331,19 +2333,21 @@
             job_graph.addJob(frozen_job)
 
     def createJobGraph(self, item):
-        project_config = self.project_configs.get(
-            item.change.project.canonical_name, None)
-        ret = JobGraph()
         # NOTE(pabelanger): It is possible for a foreign project not to have a
         # configured pipeline, if so return an empty JobGraph.
-        if project_config and item.pipeline.name in project_config.pipelines:
-            project_job_list = \
-                project_config.pipelines[item.pipeline.name].job_list
-            self._createJobGraph(item, project_job_list, ret)
+        ret = JobGraph()
+        ppc = self.getProjectPipelineConfig(item.change.project,
+                                            item.pipeline)
+        if ppc:
+            self._createJobGraph(item, ppc.job_list, ret)
         return ret
 
-    def hasProject(self, project):
-        return project.canonical_name in self.project_configs
+    def getProjectPipelineConfig(self, project, pipeline):
+        project_config = self.project_configs.get(
+            project.canonical_name, None)
+        if not project_config:
+            return None
+        return project_config.pipelines.get(pipeline.name, None)
 
 
 class Semaphore(object):