Merge "show reconfiguration failures"
diff --git a/doc/source/zuul.rst b/doc/source/zuul.rst
index 7c3e384..884e25f 100644
--- a/doc/source/zuul.rst
+++ b/doc/source/zuul.rst
@@ -462,13 +462,21 @@
     A deprecated alternate spelling of *comment*.  Only one of *comment* or
     *comment_filter* should be used.
 
-    *require-approval*
+    *require-any-approval*
     This may be used for any event.  It requires that a certain kind
     of approval be present for the current patchset of the change (the
     approval could be added by the event in question).  It follows the
     same syntax as the :ref:`"approval" pipeline requirement below
     <pipeline-require-approval>`.
 
+    *require-all-approvals*
+    This takes a list of approvals in the same format as
+    *require-any-approval* but requires all approvals match the rules.
+
+    **require-approval** (depreciated)
+    A deprecated alternate spelling of *require-any-approval*. This will
+    be joined with *require-any-approval* if both are present.
+
   **timer**
     This trigger will run based on a cron-style time specification.
     It will enqueue an event into its pipeline for every project
@@ -515,7 +523,7 @@
 
 .. _pipeline-require-approval:
 
-  **approval**
+  **any-approval**
   This requires that a certain kind of approval be present for the
   current patchset of the change (the approval could be added by the
   event in question).  It takes several sub-parameters, all of which
@@ -549,6 +557,24 @@
     be a single value or a list: ``verified: [1, 2]`` would match
     either a +1 or +2 vote.
 
+    You can also match negative conditions by starting with an
+    exclamation mark (!). This requires the value to be a string.
+    Example: ``verified: '![-1, -2]'``
+
+  This takes a list of approvals in the same format as above. It
+  requires that any approval on a change can meet the specified
+  criteria.
+
+  **all-approvals**
+  This takes a list of approvals in the same format as *any-approval* but
+  requires all approvals match the rules. For example, you can stop any
+  new changes from queueing when there is a negative vote by requiring
+  all approves to not have a -1.
+
+  **approval** (depreciated)
+  A deprecated alternate spelling of *any-approval*. This will be
+  joined with *any-approval* if both are present.
+
   **open**
   A boolean value (``true`` or ``false``) that indicates whether the change
   must be open or closed in order to be enqueued.
@@ -924,9 +950,10 @@
 whether a change merges cleanly::
 
   - name: ^.*-merge$
-    failure-message: This change was unable to be automatically merged
-    with the current state of the repository. Please rebase your
-    change and upload a new patchset.
+    failure-message: This change or one of its cross-repo dependencies
+    was unable to be automatically merged with the current state of
+    its repository. Please rebase the change and upload a new
+    patchset.
 
 Projects
 """"""""
diff --git a/tests/base.py b/tests/base.py
index 535bb7f..5ddb160 100755
--- a/tests/base.py
+++ b/tests/base.py
@@ -47,8 +47,9 @@
 import zuul.rpclistener
 import zuul.launcher.gearman
 import zuul.lib.swift
-import zuul.merger.server
 import zuul.merger.client
+import zuul.merger.merger
+import zuul.merger.server
 import zuul.reporter.gerrit
 import zuul.reporter.smtp
 import zuul.trigger.gerrit
@@ -145,7 +146,7 @@
                                                         self.latest_patchset),
                                      'refs/tags/init')
         repo.head.reference = ref
-        repo.head.reset(index=True, working_tree=True)
+        zuul.merger.merger.reset_repo_to_head(repo)
         repo.git.clean('-x', '-f', '-d')
 
         path = os.path.join(self.upstream_root, self.project)
@@ -167,7 +168,7 @@
 
         r = repo.index.commit(msg)
         repo.head.reference = 'master'
-        repo.head.reset(index=True, working_tree=True)
+        zuul.merger.merger.reset_repo_to_head(repo)
         repo.git.clean('-x', '-f', '-d')
         repo.heads['master'].checkout()
         return r
@@ -258,8 +259,8 @@
                  "comment": "This is a comment"}
         return event
 
-    def addApproval(self, category, value, username='jenkins',
-                    granted_on=None):
+    def addApproval(self, category, value, username='reviewer_john',
+                    granted_on=None, message=''):
         if not granted_on:
             granted_on = time.time()
         approval = {
@@ -277,20 +278,20 @@
                 del self.patchsets[-1]['approvals'][i]
         self.patchsets[-1]['approvals'].append(approval)
         event = {'approvals': [approval],
-                 'author': {'email': 'user@example.com',
-                            'name': 'User Name',
-                            'username': 'username'},
+                 'author': {'email': 'author@example.com',
+                            'name': 'Patchset Author',
+                            'username': 'author_phil'},
                  'change': {'branch': self.branch,
                             'id': 'Iaa69c46accf97d0598111724a38250ae76a22c87',
                             'number': str(self.number),
-                            'owner': {'email': 'user@example.com',
-                                      'name': 'User Name',
-                                      'username': 'username'},
+                            'owner': {'email': 'owner@example.com',
+                                      'name': 'Change Owner',
+                                      'username': 'owner_jane'},
                             'project': self.project,
                             'subject': self.subject,
                             'topic': 'master',
                             'url': 'https://hostname/459'},
-                 'comment': '',
+                 'comment': message,
                  'patchSet': self.patchsets[-1],
                  'type': 'comment-added'}
         self.data['submitRecords'] = self.getSubmitRecords()
@@ -380,11 +381,16 @@
 class FakeGerrit(object):
     log = logging.getLogger("zuul.test.FakeGerrit")
 
-    def __init__(self, *args, **kw):
+    def __init__(self, hostname, username, port=29418, keyfile=None,
+                 changes_dbs={}):
+        self.hostname = hostname
+        self.username = username
+        self.port = port
+        self.keyfile = keyfile
         self.event_queue = Queue.Queue()
         self.fixture_dir = os.path.join(FIXTURE_DIR, 'gerrit')
         self.change_number = 0
-        self.changes = {}
+        self.changes = changes_dbs.get(hostname, {})
         self.queries = []
 
     def addFakeChange(self, project, branch, subject, status='NEW'):
@@ -407,7 +413,27 @@
     def review(self, project, changeid, message, action):
         number, ps = changeid.split(',')
         change = self.changes[int(number)]
+
+        # Add the approval back onto the change (ie simulate what gerrit would
+        # do).
+        # Usually when zuul leaves a review it'll create a feedback loop where
+        # zuul's review enters another gerrit event (which is then picked up by
+        # zuul). However, we can't mimic this behaviour (by adding this
+        # approval event into the queue) as it stops jobs from checking what
+        # happens before this event is triggered. If a job needs to see what
+        # happens they can add their own verified event into the queue.
+        # Nevertheless, we can update change with the new review in gerrit.
+
+        for cat in ['CRVW', 'VRFY', 'APRV']:
+            if cat in action:
+                change.addApproval(cat, action[cat], username=self.username)
+
+        if 'label' in action:
+            parts = action['label'].split('=')
+            change.addApproval(parts[0], parts[2], username=self.username)
+
         change.messages.append(message)
+
         if 'submit' in action:
             change.setMerged()
         if message:
@@ -939,7 +965,19 @@
             args = [self.smtp_messages] + list(args)
             return FakeSMTP(*args, **kw)
 
-        zuul.lib.gerrit.Gerrit = FakeGerrit
+        # Set a changes database so multiple FakeGerrit's can report back to
+        # a virtual canonical database given by the configured hostname
+        self.gerrit_changes_dbs = {
+            self.config.get('gerrit', 'server'): {}
+        }
+
+        def FakeGerritFactory(*args, **kw):
+            kw['changes_dbs'] = self.gerrit_changes_dbs
+            return FakeGerrit(*args, **kw)
+
+        self.useFixture(fixtures.MonkeyPatch('zuul.lib.gerrit.Gerrit',
+                                             FakeGerritFactory))
+
         self.useFixture(fixtures.MonkeyPatch('smtplib.SMTP', FakeSMTPFactory))
 
         self.gerrit = FakeGerritTrigger(
@@ -1045,7 +1083,7 @@
         repo.create_tag('init')
 
         repo.head.reference = master
-        repo.head.reset(index=True, working_tree=True)
+        zuul.merger.merger.reset_repo_to_head(repo)
         repo.git.clean('-x', '-f', '-d')
 
         self.create_branch(project, 'mp')
@@ -1064,7 +1102,7 @@
         repo.index.commit('%s commit' % branch)
 
         repo.head.reference = repo.heads['master']
-        repo.head.reset(index=True, working_tree=True)
+        zuul.merger.merger.reset_repo_to_head(repo)
         repo.git.clean('-x', '-f', '-d')
 
     def ref_has_change(self, ref, change):
diff --git a/tests/fixtures/layout-live-reconfiguration-add-job.yaml b/tests/fixtures/layout-live-reconfiguration-add-job.yaml
new file mode 100644
index 0000000..e4aea6f
--- /dev/null
+++ b/tests/fixtures/layout-live-reconfiguration-add-job.yaml
@@ -0,0 +1,38 @@
+pipelines:
+  - name: gate
+    manager: DependentPipelineManager
+    failure-message: Build failed.  For information on how to proceed, see http://wiki.example.org/Test_Failures
+    trigger:
+      gerrit:
+        - event: comment-added
+          approval:
+            - approved: 1
+    success:
+      gerrit:
+        verified: 2
+        submit: true
+    failure:
+      gerrit:
+        verified: -2
+    start:
+      gerrit:
+        verified: 0
+    precedence: high
+
+jobs:
+  - name: ^.*-merge$
+    failure-message: Unable to merge change
+    hold-following-changes: true
+  - name: project-testfile
+    files:
+      - '.*-requires'
+
+projects:
+  - name: org/project
+    merge-mode: cherry-pick
+    gate:
+      - project-merge:
+        - project-test1
+        - project-test2
+        - project-test3
+        - project-testfile
diff --git a/tests/fixtures/layout-requirement-all.yaml b/tests/fixtures/layout-requirement-all.yaml
new file mode 100644
index 0000000..968739d
--- /dev/null
+++ b/tests/fixtures/layout-requirement-all.yaml
@@ -0,0 +1,41 @@
+pipelines:
+  - name: pipeline
+    manager: IndependentPipelineManager
+    require:
+      all-approvals:
+        - username: jenkins
+          verified: [1, 2]
+        - verified: "![-1, -2]"
+    trigger:
+      gerrit:
+        - event: comment-added
+    success:
+      gerrit:
+        verified: 1
+    failure:
+      gerrit:
+        verified: -1
+
+  - name: trigger
+    manager: IndependentPipelineManager
+    trigger:
+      gerrit:
+        - event: comment-added
+          require-all-approvals:
+            - username: jenkins
+              verified: [1, 2]
+            - verified: "![-1, -2]"
+    success:
+      gerrit:
+        verified: 1
+    failure:
+      gerrit:
+        verified: -1
+
+projects:
+  - name: org/project1
+    pipeline:
+      - project1-pipeline
+  - name: org/project2
+    trigger:
+      - project2-trigger
diff --git a/tests/fixtures/layout-requirement-any.yaml b/tests/fixtures/layout-requirement-any.yaml
new file mode 100644
index 0000000..6275d8d
--- /dev/null
+++ b/tests/fixtures/layout-requirement-any.yaml
@@ -0,0 +1,43 @@
+pipelines:
+  - name: pipeline
+    manager: IndependentPipelineManager
+    require:
+      any-approval:
+        - username: jenkins
+          verified: [1, 2]
+        - username: core-reviewer
+          code-review: "![-1, -2]"
+    trigger:
+      gerrit:
+        - event: comment-added
+    success:
+      gerrit:
+        verified: 1
+    failure:
+      gerrit:
+        verified: -1
+
+  - name: trigger
+    manager: IndependentPipelineManager
+    trigger:
+      gerrit:
+        - event: comment-added
+          require-any-approval:
+            - username: jenkins
+              verified: [1, 2]
+            - username: core-reviewer
+              code-review: "![-1, -2]"
+    success:
+      gerrit:
+        verified: 1
+    failure:
+      gerrit:
+        verified: -1
+
+projects:
+  - name: org/project1
+    pipeline:
+      - project1-pipeline
+  - name: org/project2
+    trigger:
+      - project2-trigger
diff --git a/tests/fixtures/layout-requirement-email.yaml b/tests/fixtures/layout-requirement-email.yaml
index 4bfb733..dadcd6c 100644
--- a/tests/fixtures/layout-requirement-email.yaml
+++ b/tests/fixtures/layout-requirement-email.yaml
@@ -2,7 +2,7 @@
   - name: pipeline
     manager: IndependentPipelineManager
     require:
-      approval:
+      any-approval:
         - email: jenkins@example.com
     trigger:
       gerrit:
@@ -19,7 +19,7 @@
     trigger:
       gerrit:
         - event: comment-added
-          require-approval:
+          require-any-approval:
             - email: jenkins@example.com
     success:
       gerrit:
diff --git a/tests/fixtures/layout-requirement-negative-username.yaml b/tests/fixtures/layout-requirement-negative-username.yaml
new file mode 100644
index 0000000..f542b86
--- /dev/null
+++ b/tests/fixtures/layout-requirement-negative-username.yaml
@@ -0,0 +1,37 @@
+pipelines:
+  - name: pipeline
+    manager: IndependentPipelineManager
+    require:
+      all-approvals:
+        - username: '!jenkins'
+    trigger:
+      gerrit:
+        - event: comment-added
+    success:
+      gerrit:
+        verified: 1
+    failure:
+      gerrit:
+        verified: -1
+
+  - name: trigger
+    manager: IndependentPipelineManager
+    trigger:
+      gerrit:
+        - event: comment-added
+          require-all-approvals:
+            - username: '!jenkins'
+    success:
+      gerrit:
+        verified: 1
+    failure:
+      gerrit:
+        verified: -1
+
+projects:
+  - name: org/project1
+    pipeline:
+      - project1-pipeline
+  - name: org/project2
+    trigger:
+      - project2-trigger
\ No newline at end of file
diff --git a/tests/fixtures/layout-requirement-newer-than.yaml b/tests/fixtures/layout-requirement-newer-than.yaml
index b6beb35..f723c79 100644
--- a/tests/fixtures/layout-requirement-newer-than.yaml
+++ b/tests/fixtures/layout-requirement-newer-than.yaml
@@ -2,7 +2,7 @@
   - name: pipeline
     manager: IndependentPipelineManager
     require:
-      approval:
+      any-approval:
         - username: jenkins
           newer-than: 48h
     trigger:
@@ -20,7 +20,7 @@
     trigger:
       gerrit:
         - event: comment-added
-          require-approval:
+          require-any-approval:
             - username: jenkins
               newer-than: 48h
     success:
diff --git a/tests/fixtures/layout-requirement-older-than.yaml b/tests/fixtures/layout-requirement-older-than.yaml
index 2edf9df..0e011cc 100644
--- a/tests/fixtures/layout-requirement-older-than.yaml
+++ b/tests/fixtures/layout-requirement-older-than.yaml
@@ -2,7 +2,7 @@
   - name: pipeline
     manager: IndependentPipelineManager
     require:
-      approval:
+      any-approval:
         - username: jenkins
           older-than: 48h
     trigger:
@@ -20,7 +20,7 @@
     trigger:
       gerrit:
         - event: comment-added
-          require-approval:
+          require-any-approval:
             - username: jenkins
               older-than: 48h
     success:
diff --git a/tests/fixtures/layout-requirement-username.yaml b/tests/fixtures/layout-requirement-username.yaml
index 7a549f0..8520179 100644
--- a/tests/fixtures/layout-requirement-username.yaml
+++ b/tests/fixtures/layout-requirement-username.yaml
@@ -2,7 +2,7 @@
   - name: pipeline
     manager: IndependentPipelineManager
     require:
-      approval:
+      any-approval:
         - username: jenkins
     trigger:
       gerrit:
@@ -19,7 +19,7 @@
     trigger:
       gerrit:
         - event: comment-added
-          require-approval:
+          require-any-approval:
             - username: jenkins
     success:
       gerrit:
diff --git a/tests/fixtures/layout-requirement-vote.yaml b/tests/fixtures/layout-requirement-vote.yaml
index 7ccadff..6736e98 100644
--- a/tests/fixtures/layout-requirement-vote.yaml
+++ b/tests/fixtures/layout-requirement-vote.yaml
@@ -2,7 +2,7 @@
   - name: pipeline
     manager: IndependentPipelineManager
     require:
-      approval:
+      any-approval:
         - username: jenkins
           verified: 1
     trigger:
@@ -20,7 +20,7 @@
     trigger:
       gerrit:
         - event: comment-added
-          require-approval:
+          require-any-approval:
             - username: jenkins
               verified: 1
     success:
diff --git a/tests/fixtures/layout-requirement-vote1.yaml b/tests/fixtures/layout-requirement-vote1.yaml
index 7ccadff..6736e98 100644
--- a/tests/fixtures/layout-requirement-vote1.yaml
+++ b/tests/fixtures/layout-requirement-vote1.yaml
@@ -2,7 +2,7 @@
   - name: pipeline
     manager: IndependentPipelineManager
     require:
-      approval:
+      any-approval:
         - username: jenkins
           verified: 1
     trigger:
@@ -20,7 +20,7 @@
     trigger:
       gerrit:
         - event: comment-added
-          require-approval:
+          require-any-approval:
             - username: jenkins
               verified: 1
     success:
diff --git a/tests/fixtures/layout-requirement-vote2.yaml b/tests/fixtures/layout-requirement-vote2.yaml
index 33d84d1..a6cd6a3 100644
--- a/tests/fixtures/layout-requirement-vote2.yaml
+++ b/tests/fixtures/layout-requirement-vote2.yaml
@@ -2,7 +2,7 @@
   - name: pipeline
     manager: IndependentPipelineManager
     require:
-      approval:
+      any-approval:
         - username: jenkins
           verified: [1, 2]
     trigger:
@@ -20,7 +20,7 @@
     trigger:
       gerrit:
         - event: comment-added
-          require-approval:
+          require-any-approval:
             - username: jenkins
               verified: [1, 2]
     success:
diff --git a/tests/test_requirements.py b/tests/test_requirements.py
index 120e37e..52e3973 100644
--- a/tests/test_requirements.py
+++ b/tests/test_requirements.py
@@ -52,13 +52,14 @@
         self.assertEqual(len(self.history), 0)
 
         # Add a too-old +1, should not be enqueued
-        A.addApproval('VRFY', 1, granted_on=time.time() - 72 * 60 * 60)
+        A.addApproval('VRFY', 1, username='jenkins',
+                      granted_on=time.time() - 72 * 60 * 60)
         self.fake_gerrit.addEvent(comment)
         self.waitUntilSettled()
         self.assertEqual(len(self.history), 0)
 
         # Add a recent +1
-        self.fake_gerrit.addEvent(A.addApproval('VRFY', 1))
+        self.fake_gerrit.addEvent(A.addApproval('VRFY', 1, username='jenkins'))
         self.fake_gerrit.addEvent(comment)
         self.waitUntilSettled()
         self.assertEqual(len(self.history), 1)
@@ -95,7 +96,8 @@
         self.assertEqual(len(self.history), 0)
 
         # Add an old +1 which should be enqueued
-        A.addApproval('VRFY', 1, granted_on=time.time() - 72 * 60 * 60)
+        A.addApproval('VRFY', 1, username='jenkins',
+                      granted_on=time.time() - 72 * 60 * 60)
         self.fake_gerrit.addEvent(comment)
         self.waitUntilSettled()
         self.assertEqual(len(self.history), 1)
@@ -126,7 +128,7 @@
         self.assertEqual(len(self.history), 0)
 
         # Add an approval from Jenkins
-        A.addApproval('VRFY', 1)
+        A.addApproval('VRFY', 1, username='jenkins')
         self.fake_gerrit.addEvent(comment)
         self.waitUntilSettled()
         self.assertEqual(len(self.history), 1)
@@ -157,7 +159,7 @@
         self.assertEqual(len(self.history), 0)
 
         # Add an approval from Jenkins
-        A.addApproval('VRFY', 1)
+        A.addApproval('VRFY', 1, username='jenkins')
         self.fake_gerrit.addEvent(comment)
         self.waitUntilSettled()
         self.assertEqual(len(self.history), 1)
@@ -188,13 +190,13 @@
         self.assertEqual(len(self.history), 0)
 
         # A -1 from jenkins should not cause it to be enqueued
-        A.addApproval('VRFY', -1)
+        A.addApproval('VRFY', -1, username='jenkins')
         self.fake_gerrit.addEvent(comment)
         self.waitUntilSettled()
         self.assertEqual(len(self.history), 0)
 
         # A +1 should allow it to be enqueued
-        A.addApproval('VRFY', 1)
+        A.addApproval('VRFY', 1, username='jenkins')
         self.fake_gerrit.addEvent(comment)
         self.waitUntilSettled()
         self.assertEqual(len(self.history), 1)
@@ -225,19 +227,19 @@
         self.assertEqual(len(self.history), 0)
 
         # A -1 from jenkins should not cause it to be enqueued
-        A.addApproval('VRFY', -1)
+        A.addApproval('VRFY', -1, username='jenkins')
         self.fake_gerrit.addEvent(comment)
         self.waitUntilSettled()
         self.assertEqual(len(self.history), 0)
 
         # A -2 from jenkins should not cause it to be enqueued
-        A.addApproval('VRFY', -2)
+        A.addApproval('VRFY', -2, username='jenkins')
         self.fake_gerrit.addEvent(comment)
         self.waitUntilSettled()
         self.assertEqual(len(self.history), 0)
 
-        # A +1 should allow it to be enqueued
-        A.addApproval('VRFY', 1)
+        # A +1 from jenkins should allow it to be enqueued
+        A.addApproval('VRFY', 1, username='jenkins')
         self.fake_gerrit.addEvent(comment)
         self.waitUntilSettled()
         self.assertEqual(len(self.history), 1)
@@ -251,7 +253,7 @@
         self.waitUntilSettled()
         self.assertEqual(len(self.history), 1)
 
-        B.addApproval('VRFY', 2)
+        B.addApproval('VRFY', 2, username='jenkins')
         self.fake_gerrit.addEvent(comment)
         self.waitUntilSettled()
         self.assertEqual(len(self.history), 2)
@@ -321,3 +323,131 @@
         self.fake_gerrit.addEvent(B.addApproval('CRVW', 2))
         self.waitUntilSettled()
         self.assertEqual(len(self.history), 1)
+
+    def test_pipeline_require_negative_username(self):
+        "Test negative pipeline requirement: no comment from jenkins"
+        return self._test_require_negative_username('org/project1',
+                                                    'project1-pipeline')
+
+    def test_trigger_require_negative_username(self):
+        "Test negative trigger requirement: no comment from jenkins"
+        return self._test_require_negative_username('org/project2',
+                                                    'project2-trigger')
+
+    def _test_require_negative_username(self, project, job):
+        "Test negative username's match"
+        # Should only trigger if Jenkins hasn't voted.
+        self.config.set(
+            'zuul', 'layout_config',
+            'tests/fixtures/layout-requirement-negative-username.yaml')
+        self.sched.reconfigure(self.config)
+        self.registerJobs()
+
+        # add in a change with no comments
+        A = self.fake_gerrit.addFakeChange(project, 'master', 'A')
+        self.waitUntilSettled()
+        self.assertEqual(len(self.history), 0)
+
+        # add in a comment that will trigger
+        self.fake_gerrit.addEvent(A.addApproval('CRVW', 1,
+                                                username='reviewer'))
+        self.waitUntilSettled()
+        self.assertEqual(len(self.history), 1)
+        self.assertEqual(self.history[0].name, job)
+
+        # add in a comment from jenkins user which shouldn't trigger
+        self.fake_gerrit.addEvent(A.addApproval('VRFY', 1, username='jenkins'))
+        self.waitUntilSettled()
+        self.assertEqual(len(self.history), 1)
+
+        # Check future reviews also won't trigger as a 'jenkins' user has
+        # commented previously
+        self.fake_gerrit.addEvent(A.addApproval('CRVW', 1,
+                                                username='reviewer'))
+        self.waitUntilSettled()
+        self.assertEqual(len(self.history), 1)
+
+    def test_pipeline_require_any(self):
+        "Test pipeline requirement: any requirement passes"
+        return self._test_require_any('org/project1', 'project1-pipeline')
+
+    def test_trigger_require_any(self):
+        "Test trigger requirement: any requirement passes"
+        return self._test_require_any('org/project2', 'project2-trigger')
+
+    def _test_require_any(self, project, job):
+        "Test any of the given requirements are matched"
+        self.config.set(
+            'zuul', 'layout_config',
+            'tests/fixtures/layout-requirement-any.yaml')
+        self.sched.reconfigure(self.config)
+        self.registerJobs()
+
+        A = self.fake_gerrit.addFakeChange(project, 'master', 'A')
+        # A comment event that we will keep submitting to trigger
+        comment = A.addApproval('CRVW', 1, username='nobody')
+        self.fake_gerrit.addEvent(comment)
+        self.waitUntilSettled()
+        # No approval from Jenkins so should not be enqueued
+        self.assertEqual(len(self.history), 0)
+
+        # A +1 from jenkins should allow it to be enqueued
+        A.addApproval('VRFY', 1, username='jenkins')
+        self.fake_gerrit.addEvent(comment)
+        self.waitUntilSettled()
+        self.assertEqual(len(self.history), 1)
+        self.assertEqual(self.history[0].name, job)
+
+        # A non-negative from a non-core should not queue
+        B = self.fake_gerrit.addFakeChange(project, 'master', 'B')
+        # A comment event that we will keep submitting to trigger
+        comment = B.addApproval('CRVW', 1, username='nobody')
+        self.fake_gerrit.addEvent(comment)
+        self.waitUntilSettled()
+        self.assertEqual(len(self.history), 1)
+
+        # A non-negative from a core member should queue
+        B.addApproval('CRVW', 2, username='core-reviewer')
+        self.fake_gerrit.addEvent(comment)
+        self.waitUntilSettled()
+        self.assertEqual(len(self.history), 2)
+        self.assertEqual(self.history[1].name, job)
+
+    def test_pipeline_require_all(self):
+        "Test pipeline requirement: all requirements pass"
+        return self._test_require_all('org/project1', 'project1-pipeline')
+
+    def test_trigger_require_all(self):
+        "Test trigger requirement: all requirements pass"
+        return self._test_require_all('org/project2', 'project2-trigger')
+
+    def _test_require_all(self, project, job):
+        "Test all of the given requirements are matched"
+        self.config.set(
+            'zuul', 'layout_config',
+            'tests/fixtures/layout-requirement-all.yaml')
+        self.sched.reconfigure(self.config)
+        self.registerJobs()
+
+        A = self.fake_gerrit.addFakeChange(project, 'master', 'A')
+        self.waitUntilSettled()
+        self.assertEqual(len(self.history), 0)
+
+        # A +2 from 'nobody' only satisfies the non-negative requirement,
+        # not the requirement to be from 'jenkins'
+        comment = A.addApproval('VRFY', 1, username='nobody')
+        self.fake_gerrit.addEvent(comment)
+        self.waitUntilSettled()
+        self.assertEqual(len(self.history), 0)
+
+        B = self.fake_gerrit.addFakeChange(project, 'master', 'A')
+        self.waitUntilSettled()
+        self.assertEqual(len(self.history), 0)
+
+        # A +2 from Jenkins satisfies both the user condition and the
+        # non-negative condition
+        comment = B.addApproval('VRFY', 2, username='jenkins')
+        self.fake_gerrit.addEvent(comment)
+        self.waitUntilSettled()
+        self.assertEqual(len(self.history), 1)
+        self.assertEqual(self.history[0].name, job)
diff --git a/tests/test_scheduler.py b/tests/test_scheduler.py
index 032a8f8..8347f7a 100755
--- a/tests/test_scheduler.py
+++ b/tests/test_scheduler.py
@@ -2036,6 +2036,30 @@
         self.assertEqual(self.history[0].name, 'gate-noop')
         self.assertEqual(self.history[0].result, 'SUCCESS')
 
+    def test_file_head(self):
+        # This is a regression test for an observed bug.  A change
+        # with a file named "HEAD" in the root directory of the repo
+        # was processed by a merger.  It then was unable to reset the
+        # repo because of:
+        #   GitCommandError: 'git reset --hard HEAD' returned
+        #       with exit code 128
+        #   stderr: 'fatal: ambiguous argument 'HEAD': both revision
+        #       and filename
+        #   Use '--' to separate filenames from revisions'
+
+        A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
+        A.addPatchset(['HEAD'])
+        B = self.fake_gerrit.addFakeChange('org/project', 'master', 'B')
+
+        self.fake_gerrit.addEvent(A.getPatchsetCreatedEvent(2))
+        self.waitUntilSettled()
+
+        self.fake_gerrit.addEvent(B.getPatchsetCreatedEvent(1))
+        self.waitUntilSettled()
+
+        self.assertIn('Build succeeded', A.messages[0])
+        self.assertIn('Build succeeded', B.messages[0])
+
     def test_file_jobs(self):
         "Test that file jobs run only when appropriate"
         A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
@@ -2241,6 +2265,128 @@
         self.assertEqual(A.data['status'], 'MERGED')
         self.assertEqual(A.reported, 2)
 
+    def test_live_reconfiguration_merge_conflict(self):
+        # A real-world bug: a change in a gate queue has a merge
+        # conflict and a job is added to its project while it's
+        # sitting in the queue.  The job gets added to the change and
+        # enqueued and the change gets stuck.
+        self.worker.registerFunction('build:project-test3')
+        self.worker.hold_jobs_in_build = True
+
+        # This change is fine.  It's here to stop the queue long
+        # enough for the next change to be subject to the
+        # reconfiguration, as well as to provide a conflict for the
+        # next change.  This change will succeed and merge.
+        A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
+        A.addPatchset(['conflict'])
+        A.addApproval('CRVW', 2)
+
+        # This change will be in merge conflict.  During the
+        # reconfiguration, we will add a job.  We want to make sure
+        # that doesn't cause it to get stuck.
+        B = self.fake_gerrit.addFakeChange('org/project', 'master', 'B')
+        B.addPatchset(['conflict'])
+        B.addApproval('CRVW', 2)
+
+        self.fake_gerrit.addEvent(A.addApproval('APRV', 1))
+        self.fake_gerrit.addEvent(B.addApproval('APRV', 1))
+
+        self.waitUntilSettled()
+
+        # No jobs have run yet
+        self.assertEqual(A.data['status'], 'NEW')
+        self.assertEqual(A.reported, 1)
+        self.assertEqual(B.data['status'], 'NEW')
+        self.assertEqual(B.reported, 1)
+        self.assertEqual(len(self.history), 0)
+
+        # Add the "project-test3" job.
+        self.config.set('zuul', 'layout_config',
+                        'tests/fixtures/layout-live-'
+                        'reconfiguration-add-job.yaml')
+        self.sched.reconfigure(self.config)
+        self.waitUntilSettled()
+
+        self.worker.hold_jobs_in_build = False
+        self.worker.release()
+        self.waitUntilSettled()
+
+        self.assertEqual(A.data['status'], 'MERGED')
+        self.assertEqual(A.reported, 2)
+        self.assertEqual(B.data['status'], 'NEW')
+        self.assertEqual(B.reported, 2)
+        self.assertEqual(self.getJobFromHistory('project-merge').result,
+                         'SUCCESS')
+        self.assertEqual(self.getJobFromHistory('project-test1').result,
+                         'SUCCESS')
+        self.assertEqual(self.getJobFromHistory('project-test2').result,
+                         'SUCCESS')
+        self.assertEqual(self.getJobFromHistory('project-test3').result,
+                         'SUCCESS')
+        self.assertEqual(len(self.history), 4)
+
+    def test_live_reconfiguration_failed_job(self):
+        # An extrapolation of test_live_reconfiguration_merge_conflict
+        # that tests a job added to a job tree with a failed root does
+        # not run.
+        self.worker.registerFunction('build:project-test3')
+        self.worker.hold_jobs_in_build = True
+
+        # This change is fine.  It's here to stop the queue long
+        # enough for the next change to be subject to the
+        # reconfiguration.  This change will succeed and merge.
+        A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
+        A.addPatchset(['conflict'])
+        A.addApproval('CRVW', 2)
+        self.fake_gerrit.addEvent(A.addApproval('APRV', 1))
+        self.waitUntilSettled()
+        self.worker.release('.*-merge')
+        self.waitUntilSettled()
+
+        B = self.fake_gerrit.addFakeChange('org/project', 'master', 'B')
+        self.worker.addFailTest('project-merge', B)
+        B.addApproval('CRVW', 2)
+        self.fake_gerrit.addEvent(B.addApproval('APRV', 1))
+        self.waitUntilSettled()
+
+        self.worker.release('.*-merge')
+        self.waitUntilSettled()
+
+        # Both -merge jobs have run, but no others.
+        self.assertEqual(A.data['status'], 'NEW')
+        self.assertEqual(A.reported, 1)
+        self.assertEqual(B.data['status'], 'NEW')
+        self.assertEqual(B.reported, 1)
+        self.assertEqual(self.history[0].result, 'SUCCESS')
+        self.assertEqual(self.history[0].name, 'project-merge')
+        self.assertEqual(self.history[1].result, 'FAILURE')
+        self.assertEqual(self.history[1].name, 'project-merge')
+        self.assertEqual(len(self.history), 2)
+
+        # Add the "project-test3" job.
+        self.config.set('zuul', 'layout_config',
+                        'tests/fixtures/layout-live-'
+                        'reconfiguration-add-job.yaml')
+        self.sched.reconfigure(self.config)
+        self.waitUntilSettled()
+
+        self.worker.hold_jobs_in_build = False
+        self.worker.release()
+        self.waitUntilSettled()
+
+        self.assertEqual(A.data['status'], 'MERGED')
+        self.assertEqual(A.reported, 2)
+        self.assertEqual(B.data['status'], 'NEW')
+        self.assertEqual(B.reported, 2)
+        self.assertEqual(self.history[0].result, 'SUCCESS')
+        self.assertEqual(self.history[0].name, 'project-merge')
+        self.assertEqual(self.history[1].result, 'FAILURE')
+        self.assertEqual(self.history[1].name, 'project-merge')
+        self.assertEqual(self.history[2].result, 'SUCCESS')
+        self.assertEqual(self.history[3].result, 'SUCCESS')
+        self.assertEqual(self.history[4].result, 'SUCCESS')
+        self.assertEqual(len(self.history), 5)
+
     def test_live_reconfiguration_functions(self):
         "Test live reconfiguration with a custom function"
         self.worker.registerFunction('build:node-project-test1:debian')
@@ -2968,9 +3114,10 @@
         self.registerJobs()
 
         self.assertEqual(
-            "Merge Failed.\n\nThis change was unable to be automatically "
-            "merged with the current state of the repository. Please rebase "
-            "your change and upload a new patchset.",
+            "Merge Failed.\n\nThis change or one of its cross-repo "
+            "dependencies was unable to be automatically merged with the "
+            "current state of its repository. Please rebase the change and "
+            "upload a new patchset.",
             self.sched.layout.pipelines['check'].merge_failure_message)
         self.assertEqual(
             "The merge failed! For more information...",
diff --git a/tests/test_zuultrigger.py b/tests/test_zuultrigger.py
index 2f0e4f0..0d52fc9 100644
--- a/tests/test_zuultrigger.py
+++ b/tests/test_zuultrigger.py
@@ -107,9 +107,10 @@
         self.assertEqual(E.reported, 0)
         self.assertEqual(
             B.messages[0],
-            "Merge Failed.\n\nThis change was unable to be automatically "
-            "merged with the current state of the repository. Please rebase "
-            "your change and upload a new patchset.")
+            "Merge Failed.\n\nThis change or one of its cross-repo "
+            "dependencies was unable to be automatically merged with the "
+            "current state of its repository. Please rebase the change and "
+            "upload a new patchset.")
 
         self.assertTrue("project:org/project status:open" in
                         self.fake_gerrit.queries)
@@ -133,8 +134,9 @@
         self.assertEqual(E.reported, 1)
         self.assertEqual(
             E.messages[0],
-            "Merge Failed.\n\nThis change was unable to be automatically "
-            "merged with the current state of the repository. Please rebase "
-            "your change and upload a new patchset.")
+            "Merge Failed.\n\nThis change or one of its cross-repo "
+            "dependencies was unable to be automatically merged with the "
+            "current state of its repository. Please rebase the change and "
+            "upload a new patchset.")
         self.assertEqual(self.fake_gerrit.queries[1],
                          "project:org/project status:open")
diff --git a/zuul/cmd/cloner.py b/zuul/cmd/cloner.py
index 5bbe3a0..187b6b0 100755
--- a/zuul/cmd/cloner.py
+++ b/zuul/cmd/cloner.py
@@ -88,17 +88,14 @@
             )
 
         args = parser.parse_args()
+        # Validate ZUUL_* arguments. If ref is provided then URL is required.
+        zuul_args = [zuul_opt for zuul_opt, val in vars(args).items()
+                     if zuul_opt.startswith('zuul') and val is not None]
+        if 'zuul_ref' in zuul_args and 'zuul_url' not in zuul_args:
+            parser.error("Specifying a Zuul ref requires a Zuul url. "
+                         "Define Zuul arguments either via environment "
+                         "variables or using options above.")
 
-        # Validate ZUUL_* arguments. If any ZUUL_* argument is set they
-        # must all be set, otherwise fallback to defaults.
-        zuul_missing = [zuul_opt for zuul_opt, val in vars(args).items()
-                        if zuul_opt.startswith('zuul') and val is None]
-        if (len(zuul_missing) > 0 and
-            len(zuul_missing) < len(ZUUL_ENV_SUFFIXES)):
-            parser.error(("Some Zuul parameters are not set:\n\t%s\n"
-                          "Define them either via environment variables or "
-                          "using options above." %
-                          "\n\t".join(sorted(zuul_missing))))
         self.args = args
 
     def setup_logging(self, color=False, verbose=False):
diff --git a/zuul/cmd/server.py b/zuul/cmd/server.py
index 1e635aa..8887d36 100755
--- a/zuul/cmd/server.py
+++ b/zuul/cmd/server.py
@@ -122,6 +122,7 @@
             statsd_host = os.environ.get('STATSD_HOST')
             statsd_port = int(os.environ.get('STATSD_PORT', 8125))
             gear.Server(4730,
+                        host=self.config.get('gearman', 'server'),
                         statsd_host=statsd_host,
                         statsd_port=statsd_port,
                         statsd_prefix='zuul.geard')
diff --git a/zuul/launcher/gearman.py b/zuul/launcher/gearman.py
index 57ac5ca..828e2a9 100644
--- a/zuul/launcher/gearman.py
+++ b/zuul/launcher/gearman.py
@@ -342,8 +342,7 @@
         build.parameters = params
 
         if job.name == 'noop':
-            build.result = 'SUCCESS'
-            self.sched.onBuildCompleted(build)
+            self.sched.onBuildCompleted(build, 'SUCCESS')
             return build
 
         gearman_job = gear.Job(name, json.dumps(params),
@@ -431,8 +430,7 @@
                     build.retry = True
                 self.log.info("Build %s complete, result %s" %
                               (job, result))
-                build.result = result
-                self.sched.onBuildCompleted(build)
+                self.sched.onBuildCompleted(build, result)
             # The test suite expects the build to be removed from the
             # internal dict after it's added to the report queue.
             del self.builds[job.unique]
diff --git a/zuul/layoutvalidator.py b/zuul/layoutvalidator.py
index 88d10e2..1569fa9 100644
--- a/zuul/layoutvalidator.py
+++ b/zuul/layoutvalidator.py
@@ -62,6 +62,8 @@
                       'branch': toList(str),
                       'ref': toList(str),
                       'approval': toList(variable_dict),
+                      'require-all-approvals': toList(require_approval),
+                      'require-any-approval': toList(require_approval),
                       'require-approval': toList(require_approval),
                       }
 
@@ -85,7 +87,9 @@
                                },
                       }
 
-    require = {'approval': toList(require_approval),
+    require = {'all-approvals': toList(require_approval),
+               'any-approval': toList(require_approval),
+               'approval': toList(require_approval),
                'open': bool,
                'current-patchset': bool,
                'status': toList(str)}
diff --git a/zuul/lib/cloner.py b/zuul/lib/cloner.py
index d697648..0ac7f0f 100644
--- a/zuul/lib/cloner.py
+++ b/zuul/lib/cloner.py
@@ -138,8 +138,11 @@
         if project in self.project_branches:
             indicated_branch = self.project_branches[project]
 
-        override_zuul_ref = re.sub(self.zuul_branch, indicated_branch,
-                                   self.zuul_ref)
+        if indicated_branch:
+            override_zuul_ref = re.sub(self.zuul_branch, indicated_branch,
+                                       self.zuul_ref)
+        else:
+            override_zuul_ref = None
 
         if indicated_branch and repo.hasBranch(indicated_branch):
             self.log.info("upstream repo has branch %s", indicated_branch)
@@ -150,14 +153,18 @@
             # FIXME should be origin HEAD branch which might not be 'master'
             fallback_branch = 'master'
 
-        fallback_zuul_ref = re.sub(self.zuul_branch, fallback_branch,
-                                   self.zuul_ref)
+        if self.zuul_branch:
+            fallback_zuul_ref = re.sub(self.zuul_branch, fallback_branch,
+                                       self.zuul_ref)
+        else:
+            fallback_zuul_ref = None
 
         # If we have a non empty zuul_ref to use, use it. Otherwise we fall
         # back to checking out the branch.
         if ((override_zuul_ref and
             self.fetchFromZuul(repo, project, override_zuul_ref)) or
-            (fallback_zuul_ref != override_zuul_ref and
+            (fallback_zuul_ref and
+             fallback_zuul_ref != override_zuul_ref and
             self.fetchFromZuul(repo, project, fallback_zuul_ref))):
             # Work around a bug in GitPython which can not parse FETCH_HEAD
             gitcmd = git.Git(dest)
@@ -169,9 +176,9 @@
             # Checkout branch
             self.log.info("Falling back to branch %s", fallback_branch)
             try:
-                repo.checkout('remotes/origin/%s' % fallback_branch)
+                commit = repo.checkout('remotes/origin/%s' % fallback_branch)
             except (ValueError, GitCommandError):
                 self.log.exception("Fallback branch not found: %s",
                                    fallback_branch)
-            self.log.info("Prepared %s repo with branch %s",
-                          project, fallback_branch)
+            self.log.info("Prepared %s repo with branch %s at commit %s",
+                          project, fallback_branch, commit)
diff --git a/zuul/merger/merger.py b/zuul/merger/merger.py
index f36b974..c84d042 100644
--- a/zuul/merger/merger.py
+++ b/zuul/merger/merger.py
@@ -20,6 +20,21 @@
 import zuul.model
 
 
+def reset_repo_to_head(repo):
+    # This lets us reset the repo even if there is a file in the root
+    # directory named 'HEAD'.  Currently, GitPython does not allow us
+    # to instruct it to always include the '--' to disambiguate.  This
+    # should no longer be necessary if this PR merges:
+    #   https://github.com/gitpython-developers/GitPython/pull/319
+    try:
+        repo.git.reset('--hard', 'HEAD', '--')
+    except git.GitCommandError as e:
+        # git nowadays may use 1 as status to indicate there are still unstaged
+        # modifications after the reset
+        if e.status != 1:
+            raise
+
+
 class ZuulReference(git.Reference):
     _common_path_default = "refs/zuul"
     _points_to_commits_only = True
@@ -71,9 +86,9 @@
         return repo
 
     def reset(self):
-        repo = self.createRepoObject()
         self.log.debug("Resetting repository %s" % self.local_path)
         self.update()
+        repo = self.createRepoObject()
         origin = repo.remotes.origin
         for ref in origin.refs:
             if ref.remote_head == 'HEAD':
@@ -82,7 +97,7 @@
 
         # Reset to remote HEAD (usually origin/master)
         repo.head.reference = origin.refs['HEAD']
-        repo.head.reset(index=True, working_tree=True)
+        reset_repo_to_head(repo)
         repo.git.clean('-x', '-f', '-d')
 
     def prune(self):
@@ -114,7 +129,8 @@
         repo = self.createRepoObject()
         self.log.debug("Checking out %s" % ref)
         repo.head.reference = ref
-        repo.head.reset(index=True, working_tree=True)
+        reset_repo_to_head(repo)
+        return repo.head.commit
 
     def cherryPick(self, ref):
         repo = self.createRepoObject()
diff --git a/zuul/model.py b/zuul/model.py
index 4b907c3..6648774 100644
--- a/zuul/model.py
+++ b/zuul/model.py
@@ -12,8 +12,10 @@
 # License for the specific language governing permissions and limitations
 # under the License.
 
+import ast
 import copy
 import re
+import six
 import time
 from uuid import uuid4
 import extras
@@ -1025,68 +1027,127 @@
 
 
 class BaseFilter(object):
-    def __init__(self, required_approvals=[]):
-        self._required_approvals = copy.deepcopy(required_approvals)
-        self.required_approvals = required_approvals
+    def __init__(self, required_any_approval=[], required_all_approvals=[]):
+        self._required_any_approval = copy.deepcopy(required_any_approval)
+        self.required_any_approval = self._tidy_approvals(
+            required_any_approval)
+        self._required_all_approvals = copy.deepcopy(required_all_approvals)
+        self.required_all_approvals = self._tidy_approvals(
+            required_all_approvals)
 
-        for a in self.required_approvals:
+    def _tidy_approvals(self, approvals):
+        for a in approvals:
             for k, v in a.items():
                 if k == 'username':
                     pass
                 elif k in ['email', 'email-filter']:
-                    a['email'] = re.compile(v)
+                    a['email'] = v
                 elif k == 'newer-than':
-                    a[k] = time_to_seconds(v)
+                    a[k] = v
                 elif k == 'older-than':
-                    a[k] = time_to_seconds(v)
-                else:
-                    if not isinstance(v, list):
-                        a[k] = [v]
+                    a[k] = v
             if 'email-filter' in a:
                 del a['email-filter']
+        return approvals
+
+    def _match_approval_required_approval(self, rapproval, approval):
+        # Check if the required approval and approval match
+        if 'description' not in approval:
+            return False
+        now = time.time()
+        found_approval = True
+        by = approval.get('by', {})
+        for k, v in rapproval.items():
+            negative_match = False
+            item_match = True
+            if isinstance(v, six.string_types) and v[0] == '!':
+                v = v[1:].strip()
+                item_match = False
+                negative_match = True
+
+            if k == 'username':
+                if (by.get('username', '') != v):
+                        item_match = negative_match
+            elif k == 'email':
+                v = re.compile(v)
+                if (not v.search(by.get('email', ''))):
+                        item_match = negative_match
+            elif k == 'newer-than':
+                t = now - time_to_seconds(v)
+                if (approval['grantedOn'] < t):
+                        item_match = negative_match
+            elif k == 'older-than':
+                t = now - time_to_seconds(v)
+                if (approval['grantedOn'] >= t):
+                    item_match = negative_match
+            else:
+                if isinstance(v, six.string_types):
+                    v = ast.literal_eval(v)
+                if not isinstance(v, list):
+                    v = [v]
+                if (normalizeCategory(approval['description']) != k or
+                        int(approval['value']) not in v):
+                    item_match = negative_match
+            if not item_match:
+                found_approval = False
+        return found_approval
 
     def matchesRequiredApprovals(self, change):
-        now = time.time()
-        for rapproval in self.required_approvals:
+        if (self.required_any_approval and not change.approvals
+                or self.required_all_approvals and not change.approvals):
+            # A change with no approvals can not match
+            return False
+
+        # TODO(jhesketh): If we wanted to optimise this slightly we could
+        # analyse both the ANY and ALL filters by looping over the approvals
+        # on the change and keeping track of what we have checked rather than
+        # needing to loop on the change approvals twice
+        return (self.matchesRequiredAnyApproval(change) and
+                self.matchesRequiredAllApprovals(change))
+
+    def matchesRequiredAnyApproval(self, change):
+        # Check if any approvals match the any requirements
+        if not self.required_any_approval:
+            # No approval required, so we must match
+            return True
+
+        for rapproval in self.required_any_approval:
             matches_approval = False
             for approval in change.approvals:
-                if 'description' not in approval:
-                    continue
-                found_approval = True
-                by = approval.get('by', {})
-                for k, v in rapproval.items():
-                    if k == 'username':
-                        if (by.get('username', '') != v):
-                            found_approval = False
-                    elif k == 'email':
-                        if (not v.search(by.get('email', ''))):
-                            found_approval = False
-                    elif k == 'newer-than':
-                        t = now - v
-                        if (approval['grantedOn'] < t):
-                            found_approval = False
-                    elif k == 'older-than':
-                        t = now - v
-                        if (approval['grantedOn'] >= t):
-                            found_approval = False
-                    else:
-                        if (normalizeCategory(approval['description']) != k or
-                            int(approval['value']) not in v):
-                            found_approval = False
-                if found_approval:
-                    matches_approval = True
-                    break
-            if not matches_approval:
-                return False
+                matches_approval = self._match_approval_required_approval(
+                    rapproval, approval)
+                if matches_approval:
+                    # We have a matching approval so this requirement is
+                    # fulfilled
+                    return True
+        return False
+
+    def matchesRequiredAllApprovals(self, change):
+        # Check that /all/ of the approvals match the requirements
+        if not self.required_all_approvals:
+            # No approvals required, so we must match
+            return True
+
+        for rapproval in self.required_all_approvals:
+            for approval in change.approvals:
+                matches_approval = self._match_approval_required_approval(
+                    rapproval, approval)
+                if not matches_approval:
+                    # We have an approval that doesn't match so this
+                    # requirement can't be fulfilled
+                    return False
+        # We must have matched everything
         return True
 
 
 class EventFilter(BaseFilter):
     def __init__(self, trigger, types=[], branches=[], refs=[],
                  event_approvals={}, comments=[], emails=[], usernames=[],
-                 timespecs=[], required_approvals=[], pipelines=[]):
+                 timespecs=[], required_any_approval=[],
+                 required_all_approvals=[], pipelines=[]):
         super(EventFilter, self).__init__(
-            required_approvals=required_approvals)
+            required_any_approval=required_any_approval,
+            required_all_approvals=required_all_approvals)
         self.trigger = trigger
         self._types = types
         self._branches = branches
@@ -1119,9 +1180,12 @@
         if self.event_approvals:
             ret += ' event_approvals: %s' % ', '.join(
                 ['%s:%s' % a for a in self.event_approvals.items()])
-        if self.required_approvals:
-            ret += ' required_approvals: %s' % ', '.join(
-                ['%s' % a for a in self._required_approvals])
+        if self.required_any_approval:
+            ret += ' required_any_approval: %s' % ', '.join(
+                ['%s' % a for a in self._required_any_approval])
+        if self.required_all_approvals:
+            ret += ' required_all_approvals: %s' % ', '.join(
+                ['%s' % a for a in self._required_all_approvals])
         if self._comments:
             ret += ' comments: %s' % ', '.join(self._comments)
         if self._emails:
@@ -1210,10 +1274,6 @@
             if not matches_approval:
                 return False
 
-        if self.required_approvals and not change.approvals:
-            # A change with no approvals can not match
-            return False
-
         # required approvals are ANDed
         if not self.matchesRequiredApprovals(change):
             return False
@@ -1231,9 +1291,11 @@
 
 class ChangeishFilter(BaseFilter):
     def __init__(self, open=None, current_patchset=None,
-                 statuses=[], required_approvals=[]):
+                 statuses=[], required_any_approval=[],
+                 required_all_approvals=[]):
         super(ChangeishFilter, self).__init__(
-            required_approvals=required_approvals)
+            required_any_approval=required_any_approval,
+            required_all_approvals=required_all_approvals)
         self.open = open
         self.current_patchset = current_patchset
         self.statuses = statuses
@@ -1247,8 +1309,12 @@
             ret += ' current-patchset: %s' % self.current_patchset
         if self.statuses:
             ret += ' statuses: %s' % ', '.join(self.statuses)
-        if self.required_approvals:
-            ret += ' required_approvals: %s' % str(self.required_approvals)
+        if self.required_any_approval:
+            ret += (' required_any_approval: %s' %
+                    str(self.required_any_approval))
+        if self.required_all_approvals:
+            ret += (' required_all_approvals: %s' %
+                    str(self.required_all_approvals))
         ret += '>'
 
         return ret
@@ -1266,10 +1332,6 @@
             if change.status not in self.statuses:
                 return False
 
-        if self.required_approvals and not change.approvals:
-            # A change with no approvals can not match
-            return False
-
         # required approvals are ANDed
         if not self.matchesRequiredApprovals(change):
             return False
diff --git a/zuul/scheduler.py b/zuul/scheduler.py
index 9620036..7ea4467 100644
--- a/zuul/scheduler.py
+++ b/zuul/scheduler.py
@@ -272,9 +272,10 @@
             pipeline.failure_message = conf_pipeline.get('failure-message',
                                                          "Build failed.")
             pipeline.merge_failure_message = conf_pipeline.get(
-                'merge-failure-message', "Merge Failed.\n\nThis change was "
-                "unable to be automatically merged with the current state of "
-                "the repository. Please rebase your change and upload a new "
+                'merge-failure-message', "Merge Failed.\n\nThis change or one "
+                "of its cross-repo dependencies was unable to be "
+                "automatically merged with the current state of its "
+                "repository. Please rebase the change and upload a new "
                 "patchset.")
             pipeline.success_message = conf_pipeline.get('success-message',
                                                          "Build succeeded.")
@@ -326,7 +327,10 @@
                     open=require.get('open'),
                     current_patchset=require.get('current-patchset'),
                     statuses=toList(require.get('status')),
-                    required_approvals=toList(require.get('approval')))
+                    required_any_approval=(toList(require.get('any-approval'))
+                                           + toList(require.get('approval'))),
+                    required_all_approvals=toList(require.get('all-approvals'))
+                )
                 manager.changeish_filters.append(f)
 
             # TODO: move this into triggers (may require pluggable
@@ -356,9 +360,13 @@
                         comments=comments,
                         emails=emails,
                         usernames=usernames,
-                        required_approvals=toList(
-                            trigger.get('require-approval')
-                        )
+                        required_any_approval=(
+                            toList(trigger.get('require-any-approval'))
+                            + toList(trigger.get('require-approval'))
+                        ),
+                        required_all_approvals=toList(
+                            trigger.get('require-all-approvals')
+                        ),
                     )
                     manager.event_filters.append(f)
             if 'timer' in conf_pipeline['trigger']:
@@ -373,9 +381,13 @@
                         trigger=self.triggers['zuul'],
                         types=toList(trigger['event']),
                         pipelines=toList(trigger.get('pipeline')),
-                        required_approvals=toList(
-                            trigger.get('require-approval')
-                        )
+                        required_any_approval=(
+                            toList(trigger.get('require-any-approval'))
+                            + toList(trigger.get('require-approval'))
+                        ),
+                        required_all_approvals=toList(
+                            trigger.get('require-all-approvals')
+                        ),
                     )
                     manager.event_filters.append(f)
 
@@ -538,9 +550,15 @@
         self.wake_event.set()
         self.log.debug("Done adding start event for build: %s" % build)
 
-    def onBuildCompleted(self, build):
-        self.log.debug("Adding complete event for build: %s" % build)
+    def onBuildCompleted(self, build, result):
+        self.log.debug("Adding complete event for build: %s result: %s" % (
+            build, result))
         build.end_time = time.time()
+        # Note, as soon as the result is set, other threads may act
+        # upon this, even though the event hasn't been fully
+        # processed.  Ensure that any other data from the event (eg,
+        # timing) is recorded before setting the result.
+        build.result = result
         try:
             if statsd and build.pipeline:
                 jobname = build.job.name.replace('.', '_')
@@ -1176,6 +1194,18 @@
                 self.log.debug("Re-enqueing change %s in queue %s" %
                                (item.change, change_queue))
                 change_queue.enqueueItem(item)
+
+                # Re-set build results in case any new jobs have been
+                # added to the tree.
+                for build in item.current_build_set.getBuilds():
+                    if build.result:
+                        self.pipeline.setResult(item, build)
+                # Similarly, reset the item state.
+                if item.current_build_set.unable_to_merge:
+                    self.pipeline.setUnableToMerge(item)
+                if item.dequeued_needing_change:
+                    self.pipeline.setDequeuedNeedingChange(item)
+
                 self.reportStats(item)
                 return True
             else: