Merge "Allow merge failures to have unique reporters."
diff --git a/doc/source/zuul.rst b/doc/source/zuul.rst
index c711394..7274342 100644
--- a/doc/source/zuul.rst
+++ b/doc/source/zuul.rst
@@ -238,6 +238,12 @@
   reported back to Gerrit when at least one voting build fails.
   Defaults to "Build failed."
 
+**merge-failure-message**
+  An optional field that supplies the introductory text in message
+  reported back to Gerrit when a change fails to merge with the
+  current state of the repository.
+  Defaults to "Merge failed."
+
 **footer-message**
   An optional field to supply additional information after test results.
   Useful for adding information about the CI system such as debugging
@@ -413,6 +419,12 @@
   Uses the same syntax as **success**, but describes what Zuul should
   do if at least one job fails.
 
+**merge-failure**
+  Uses the same syntax as **success**, but describes what Zuul should
+  do if it is unable to merge in the patchset. If no merge-failure
+  reporters are listed then the ``failure`` reporters will be used to
+  notify of unsuccessful merges.
+
 **start**
   Uses the same syntax as **success**, but describes what Zuul should
   do when a change is added to the pipeline manager.  This can be used,
diff --git a/tests/fixtures/layout-merge-failure.yaml b/tests/fixtures/layout-merge-failure.yaml
new file mode 100644
index 0000000..72bc9c9
--- /dev/null
+++ b/tests/fixtures/layout-merge-failure.yaml
@@ -0,0 +1,56 @@
+pipelines:
+  - name: check
+    manager: IndependentPipelineManager
+    trigger:
+      gerrit:
+        - event: patchset-created
+    success:
+      gerrit:
+        verified: 1
+    failure:
+      gerrit:
+        verified: -1
+
+  - name: post
+    manager: IndependentPipelineManager
+    trigger:
+      gerrit:
+        - event: ref-updated
+          ref: ^(?!refs/).*$
+
+  - name: gate
+    manager: DependentPipelineManager
+    failure-message: Build failed.  For information on how to proceed, see http://wiki.example.org/Test_Failures
+    merge-failure-message: "The merge failed! For more information..."
+    trigger:
+      gerrit:
+        - event: comment-added
+          approval:
+            - approved: 1
+    success:
+      gerrit:
+        verified: 2
+        submit: true
+    failure:
+      gerrit:
+        verified: -2
+    merge-failure:
+      gerrit:
+        verified: -1
+      smtp:
+        to: you@example.com
+    start:
+      gerrit:
+        verified: 0
+    precedence: high
+
+projects:
+  - name: org/project
+    check:
+      - project-merge:
+        - project-test1
+        - project-test2
+    gate:
+      - project-merge:
+        - project-test1
+        - project-test2
diff --git a/tests/fixtures/layouts/bad_merge_failure.yaml b/tests/fixtures/layouts/bad_merge_failure.yaml
new file mode 100644
index 0000000..313d23b
--- /dev/null
+++ b/tests/fixtures/layouts/bad_merge_failure.yaml
@@ -0,0 +1,39 @@
+pipelines:
+  - name: check
+    manager: IndependentPipelineManager
+    trigger:
+      gerrit:
+        - event: patchset-created
+    success:
+      gerrit:
+        verified: 1
+    failure:
+      gerrit:
+        verified: -1
+    merge-failure-message:
+
+  - 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
+    merge-failure:
+    start:
+      gerrit:
+        verified: 0
+    precedence: high
+
+projects:
+  - name: org/project
+    check:
+      - project-check
diff --git a/tests/fixtures/layouts/good_merge_failure.yaml b/tests/fixtures/layouts/good_merge_failure.yaml
new file mode 100644
index 0000000..f69b764
--- /dev/null
+++ b/tests/fixtures/layouts/good_merge_failure.yaml
@@ -0,0 +1,53 @@
+pipelines:
+  - name: check
+    manager: IndependentPipelineManager
+    merge-failure-message: "Could not merge the change. Please rebase..."
+    trigger:
+      gerrit:
+        - event: patchset-created
+    success:
+      gerrit:
+        verified: 1
+    failure:
+      gerrit:
+        verified: -1
+
+  - name: post
+    manager: IndependentPipelineManager
+    trigger:
+      gerrit:
+        - event: ref-updated
+          ref: ^(?!refs/).*$
+    merge-failure:
+      gerrit:
+        verified: -1
+
+  - 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
+    merge-failure:
+      gerrit:
+        verified: -1
+      smtp:
+        to: you@example.com
+    start:
+      gerrit:
+        verified: 0
+    precedence: high
+
+projects:
+  - name: org/project
+    check:
+      - project-check
diff --git a/tests/test_scheduler.py b/tests/test_scheduler.py
index 2d8d6bb..351854d 100755
--- a/tests/test_scheduler.py
+++ b/tests/test_scheduler.py
@@ -3731,3 +3731,82 @@
 
         self.assertEqual(failure_body, self.smtp_messages[0]['body'])
         self.assertEqual(success_body, self.smtp_messages[1]['body'])
+
+    def test_merge_failure_reporters(self):
+        """Check that the config is set up correctly"""
+
+        self.config.set('zuul', 'layout_config',
+                        'tests/fixtures/layout-merge-failure.yaml')
+        self.sched.reconfigure(self.config)
+        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.",
+            self.sched.layout.pipelines['check'].merge_failure_message)
+        self.assertEqual(
+            "The merge failed! For more information...",
+            self.sched.layout.pipelines['gate'].merge_failure_message)
+
+        self.assertEqual(
+            len(self.sched.layout.pipelines['check'].merge_failure_actions), 1)
+        self.assertEqual(
+            len(self.sched.layout.pipelines['gate'].merge_failure_actions), 2)
+
+        self.assertTrue(isinstance(
+            self.sched.layout.pipelines['check'].merge_failure_actions[0].
+            reporter, zuul.reporter.gerrit.Reporter))
+
+        self.assertTrue(
+            (
+                isinstance(self.sched.layout.pipelines['gate'].
+                           merge_failure_actions[0].reporter,
+                           zuul.reporter.smtp.Reporter) and
+                isinstance(self.sched.layout.pipelines['gate'].
+                           merge_failure_actions[1].reporter,
+                           zuul.reporter.gerrit.Reporter)
+            ) or (
+                isinstance(self.sched.layout.pipelines['gate'].
+                           merge_failure_actions[0].reporter,
+                           zuul.reporter.gerrit.Reporter) and
+                isinstance(self.sched.layout.pipelines['gate'].
+                           merge_failure_actions[1].reporter,
+                           zuul.reporter.smtp.Reporter)
+            )
+        )
+
+    def test_merge_failure_reports(self):
+        """Check that when a change fails to merge the correct message is sent
+        to the correct reporter"""
+        self.config.set('zuul', 'layout_config',
+                        'tests/fixtures/layout-merge-failure.yaml')
+        self.sched.reconfigure(self.config)
+        self.registerJobs()
+
+        # Check a test failure isn't reported to SMTP
+        A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
+        A.addApproval('CRVW', 2)
+        self.worker.addFailTest('project-test1', A)
+        self.fake_gerrit.addEvent(A.addApproval('APRV', 1))
+        self.waitUntilSettled()
+
+        self.assertEqual(3, len(self.history))  # 3 jobs
+        self.assertEqual(0, len(self.smtp_messages))
+
+        # Check a merge failure is reported to SMTP
+        # B should be merged, but C will conflict with B
+        B = self.fake_gerrit.addFakeChange('org/project', 'master', 'B')
+        B.addPatchset(['conflict'])
+        C = self.fake_gerrit.addFakeChange('org/project', 'master', 'C')
+        C.addPatchset(['conflict'])
+        B.addApproval('CRVW', 2)
+        C.addApproval('CRVW', 2)
+        self.fake_gerrit.addEvent(B.addApproval('APRV', 1))
+        self.fake_gerrit.addEvent(C.addApproval('APRV', 1))
+        self.waitUntilSettled()
+
+        self.assertEqual(6, len(self.history))  # A and B jobs
+        self.assertEqual(1, len(self.smtp_messages))
+        self.assertEqual('The merge failed! For more information...',
+                         self.smtp_messages[0]['body'])
diff --git a/zuul/layoutvalidator.py b/zuul/layoutvalidator.py
index de58c25..18b532d 100644
--- a/zuul/layoutvalidator.py
+++ b/zuul/layoutvalidator.py
@@ -79,11 +79,13 @@
                 'description': str,
                 'success-message': str,
                 'failure-message': str,
+                'merge-failure-message': str,
                 'footer-message': str,
                 'dequeue-on-new-patchset': bool,
                 'trigger': trigger,
                 'success': report_actions,
                 'failure': report_actions,
+                'merge-failure': report_actions,
                 'start': report_actions,
                 'window': window,
                 'window-floor': window_floor,
diff --git a/zuul/model.py b/zuul/model.py
index b20c08e..1a7c421 100644
--- a/zuul/model.py
+++ b/zuul/model.py
@@ -63,6 +63,7 @@
         self.name = name
         self.description = None
         self.failure_message = None
+        self.merge_failure_message = None
         self.success_message = None
         self.footer_message = None
         self.dequeue_on_new_patchset = True
@@ -171,6 +172,11 @@
                 return False
         return True
 
+    def didMergerSucceed(self, item):
+        if item.current_build_set.unable_to_merge:
+            return False
+        return True
+
     def didAnyJobFail(self, item):
         for job in self.getJobs(item.change):
             if not job.voting:
@@ -206,9 +212,8 @@
                 fakebuild.result = 'SKIPPED'
                 item.addBuild(fakebuild)
 
-    def setUnableToMerge(self, item, msg):
+    def setUnableToMerge(self, item):
         item.current_build_set.unable_to_merge = True
-        item.current_build_set.unable_to_merge_message = msg
         root = self.getJobTree(item.change.project)
         for job in root.getJobs():
             fakebuild = Build(job, None)
@@ -677,7 +682,6 @@
         self.commit = None
         self.zuul_url = None
         self.unable_to_merge = False
-        self.unable_to_merge_message = None
         self.failing_reasons = []
         self.merge_state = self.NEW
 
diff --git a/zuul/scheduler.py b/zuul/scheduler.py
index c941a98..1a4474c 100644
--- a/zuul/scheduler.py
+++ b/zuul/scheduler.py
@@ -226,6 +226,11 @@
             pipeline.precedence = precedence
             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 "
+                "patchset.")
             pipeline.success_message = conf_pipeline.get('success-message',
                                                          "Build succeeded.")
             pipeline.footer_message = conf_pipeline.get('footer-message', "")
@@ -233,7 +238,7 @@
                 'dequeue-on-new-patchset', True)
 
             action_reporters = {}
-            for action in ['start', 'success', 'failure']:
+            for action in ['start', 'success', 'failure', 'merge-failure']:
                 action_reporters[action] = []
                 if conf_pipeline.get(action):
                     for reporter_name, params \
@@ -247,6 +252,11 @@
             pipeline.start_actions = action_reporters['start']
             pipeline.success_actions = action_reporters['success']
             pipeline.failure_actions = action_reporters['failure']
+            if len(action_reporters['merge-failure']) > 0:
+                pipeline.merge_failure_actions = \
+                    action_reporters['merge-failure']
+            else:
+                pipeline.merge_failure_actions = action_reporters['failure']
 
             pipeline.window = conf_pipeline.get('window', 20)
             pipeline.window_floor = conf_pipeline.get('window-floor', 3)
@@ -936,6 +946,8 @@
         self.log.info("    %s" % self.pipeline.success_actions)
         self.log.info("  On failure:")
         self.log.info("    %s" % self.pipeline.failure_actions)
+        self.log.info("  On merge-failure:")
+        self.log.info("    %s" % self.pipeline.merge_failure_actions)
 
     def getSubmitAllowNeeds(self):
         # Get a list of code review labels that are allowed to be
@@ -1334,10 +1346,7 @@
             build_set.commit = item.change.newrev
         if not build_set.commit:
             self.log.info("Unable to merge change %s" % item.change)
-            msg = ("This change was unable to be automatically merged "
-                   "with the current state of the repository. Please "
-                   "rebase your change and upload a new patchset.")
-            self.pipeline.setUnableToMerge(item, msg)
+            self.pipeline.setUnableToMerge(item)
 
     def reportItem(self, item):
         if item.reported:
@@ -1370,10 +1379,12 @@
         self.log.debug("Reporting change %s" % item.change)
         ret = True  # Means error as returned by trigger.report
         if self.pipeline.didAllJobsSucceed(item):
-            self.log.debug("success %s %s" % (self.pipeline.success_actions,
-                                              self.pipeline.failure_actions))
+            self.log.debug("success %s" % (self.pipeline.success_actions))
             actions = self.pipeline.success_actions
             item.setReportedResult('SUCCESS')
+        elif not self.pipeline.didMergerSucceed(item):
+            actions = self.pipeline.merge_failure_actions
+            item.setReportedResult('MERGER_FAILURE')
         else:
             actions = self.pipeline.failure_actions
             item.setReportedResult('FAILURE')
@@ -1395,66 +1406,72 @@
 
     def formatReport(self, item):
         ret = ''
+
+        if not self.pipeline.didMergerSucceed(item):
+            ret += self.pipeline.merge_failure_message
+            if item.dequeued_needing_change:
+                ret += ('\n\nThis change depends on a change that failed to '
+                        'merge.')
+            if self.pipeline.footer_message:
+                ret += '\n\n' + self.pipeline.footer_message
+            return ret
+
         if self.pipeline.didAllJobsSucceed(item):
             ret += self.pipeline.success_message + '\n\n'
         else:
             ret += self.pipeline.failure_message + '\n\n'
 
-        if item.dequeued_needing_change:
-            ret += "This change depends on a change that failed to merge."
-        elif item.current_build_set.unable_to_merge_message:
-            ret += item.current_build_set.unable_to_merge_message
+        if self.sched.config.has_option('zuul', 'url_pattern'):
+            url_pattern = self.sched.config.get('zuul', 'url_pattern')
         else:
-            if self.sched.config.has_option('zuul', 'url_pattern'):
-                url_pattern = self.sched.config.get('zuul', 'url_pattern')
+            url_pattern = None
+
+        for job in self.pipeline.getJobs(item.change):
+            build = item.current_build_set.getBuild(job.name)
+            result = build.result
+            pattern = url_pattern
+            if result == 'SUCCESS':
+                if job.success_message:
+                    result = job.success_message
+                if job.success_pattern:
+                    pattern = job.success_pattern
+            elif result == 'FAILURE':
+                if job.failure_message:
+                    result = job.failure_message
+                if job.failure_pattern:
+                    pattern = job.failure_pattern
+            if pattern:
+                url = pattern.format(change=item.change,
+                                     pipeline=self.pipeline,
+                                     job=job,
+                                     build=build)
             else:
-                url_pattern = None
-            for job in self.pipeline.getJobs(item.change):
-                build = item.current_build_set.getBuild(job.name)
-                result = build.result
-                pattern = url_pattern
-                if result == 'SUCCESS':
-                    if job.success_message:
-                        result = job.success_message
-                    if job.success_pattern:
-                        pattern = job.success_pattern
-                elif result == 'FAILURE':
-                    if job.failure_message:
-                        result = job.failure_message
-                    if job.failure_pattern:
-                        pattern = job.failure_pattern
-                if pattern:
-                    url = pattern.format(change=item.change,
-                                         pipeline=self.pipeline,
-                                         job=job,
-                                         build=build)
+                url = build.url or job.name
+            if not job.voting:
+                voting = ' (non-voting)'
+            else:
+                voting = ''
+            if self.report_times and build.end_time and build.start_time:
+                dt = int(build.end_time - build.start_time)
+                m, s = divmod(dt, 60)
+                h, m = divmod(m, 60)
+                if h:
+                    elapsed = ' in %dh %02dm %02ds' % (h, m, s)
+                elif m:
+                    elapsed = ' in %dm %02ds' % (m, s)
                 else:
-                    url = build.url or job.name
-                if not job.voting:
-                    voting = ' (non-voting)'
-                else:
-                    voting = ''
-                if self.report_times and build.end_time and build.start_time:
-                    dt = int(build.end_time - build.start_time)
-                    m, s = divmod(dt, 60)
-                    h, m = divmod(m, 60)
-                    if h:
-                        elapsed = ' in %dh %02dm %02ds' % (h, m, s)
-                    elif m:
-                        elapsed = ' in %dm %02ds' % (m, s)
-                    else:
-                        elapsed = ' in %ds' % (s)
-                else:
-                    elapsed = ''
-                name = ''
-                if self.sched.config.has_option('zuul', 'job_name_in_report'):
-                    if self.sched.config.getboolean('zuul',
-                                                    'job_name_in_report'):
-                        name = job.name + ' '
-                ret += '- %s%s : %s%s%s\n' % (name, url, result, elapsed,
-                                              voting)
-            ret += '\n'
-        ret += self.pipeline.footer_message
+                    elapsed = ' in %ds' % (s)
+            else:
+                elapsed = ''
+            name = ''
+            if self.sched.config.has_option('zuul', 'job_name_in_report'):
+                if self.sched.config.getboolean('zuul',
+                                                'job_name_in_report'):
+                    name = job.name + ' '
+            ret += '- %s%s : %s%s%s\n' % (name, url, result, elapsed,
+                                          voting)
+        if self.pipeline.footer_message:
+            ret += '\n' + self.pipeline.footer_message
         return ret
 
     def formatDescription(self, build):