Merge "Provide error message on malformed job list" into feature/zuulv3
diff --git a/tests/fixtures/layouts/delayed-repo-init.yaml b/tests/fixtures/layouts/delayed-repo-init.yaml
index e97d37a..c89e2fa 100644
--- a/tests/fixtures/layouts/delayed-repo-init.yaml
+++ b/tests/fixtures/layouts/delayed-repo-init.yaml
@@ -67,7 +67,7 @@
             dependencies: project-merge
     gate:
       jobs:
-        - project-merge:
+        - project-merge
         - project-test1:
             dependencies: project-merge
         - project-test2:
diff --git a/tests/unit/test_v3.py b/tests/unit/test_v3.py
index 78524f2..4b0bfc5 100755
--- a/tests/unit/test_v3.py
+++ b/tests/unit/test_v3.py
@@ -851,6 +851,60 @@
                       A.messages[0],
                       "A should have a syntax error reported")
 
+    def test_job_list_in_project_template_not_dict_error(self):
+        in_repo_conf = textwrap.dedent(
+            """
+            - job:
+                name: project-test1
+            - project-template:
+                name: some-jobs
+                check:
+                  jobs:
+                    - project-test1:
+                        - required-projects:
+                            org/project2
+            """)
+
+        file_dict = {'.zuul.yaml': in_repo_conf}
+        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()
+
+        self.assertEqual(A.data['status'], 'NEW')
+        self.assertEqual(A.reported, 1,
+                         "A should report failure")
+        self.assertIn('expected str for dictionary value',
+                      A.messages[0], "A should have a syntax error reported")
+
+    def test_job_list_in_project_not_dict_error(self):
+        in_repo_conf = textwrap.dedent(
+            """
+            - job:
+                name: project-test1
+            - project:
+                name: org/project1
+                check:
+                  jobs:
+                    - project-test1:
+                        - required-projects:
+                            org/project2
+            """)
+
+        file_dict = {'.zuul.yaml': in_repo_conf}
+        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()
+
+        self.assertEqual(A.data['status'], 'NEW')
+        self.assertEqual(A.reported, 1,
+                         "A should report failure")
+        self.assertIn('expected str for dictionary value',
+                      A.messages[0], "A should have a syntax error reported")
+
     def test_multi_repo(self):
         downstream_repo_conf = textwrap.dedent(
             """
diff --git a/zuul/configloader.py b/zuul/configloader.py
index 6a9ba01..2b91966 100644
--- a/zuul/configloader.py
+++ b/zuul/configloader.py
@@ -11,6 +11,7 @@
 # under the License.
 
 import base64
+import collections
 from contextlib import contextmanager
 import copy
 import os
@@ -397,39 +398,42 @@
     secret = {vs.Required('name'): str,
               vs.Required('secret'): str}
 
-    job = {vs.Required('name'): str,
-           'parent': vs.Any(str, None),
-           'final': bool,
-           'failure-message': str,
-           'success-message': str,
-           'failure-url': str,
-           'success-url': str,
-           'hold-following-changes': bool,
-           'voting': bool,
-           'semaphore': str,
-           'tags': to_list(str),
-           'branches': to_list(str),
-           'files': to_list(str),
-           'secrets': to_list(vs.Any(secret, str)),
-           'irrelevant-files': to_list(str),
-           # validation happens in NodeSetParser
-           'nodeset': vs.Any(dict, str),
-           'timeout': int,
-           'attempts': int,
-           'pre-run': to_list(str),
-           'post-run': to_list(str),
-           'run': str,
-           '_source_context': model.SourceContext,
-           '_start_mark': ZuulMark,
-           'roles': to_list(role),
-           'required-projects': to_list(vs.Any(job_project, str)),
-           'vars': dict,
-           'dependencies': to_list(str),
-           'allowed-projects': to_list(str),
-           'override-branch': str,
-           'description': str,
-           'post-review': bool
-           }
+    # Attributes of a job that can also be used in Project and ProjectTemplate
+    job_attributes = {'parent': vs.Any(str, None),
+                      'final': bool,
+                      'failure-message': str,
+                      'success-message': str,
+                      'failure-url': str,
+                      'success-url': str,
+                      'hold-following-changes': bool,
+                      'voting': bool,
+                      'semaphore': str,
+                      'tags': to_list(str),
+                      'branches': to_list(str),
+                      'files': to_list(str),
+                      'secrets': to_list(vs.Any(secret, str)),
+                      'irrelevant-files': to_list(str),
+                      # validation happens in NodeSetParser
+                      'nodeset': vs.Any(dict, str),
+                      'timeout': int,
+                      'attempts': int,
+                      'pre-run': to_list(str),
+                      'post-run': to_list(str),
+                      'run': str,
+                      '_source_context': model.SourceContext,
+                      '_start_mark': ZuulMark,
+                      'roles': to_list(role),
+                      'required-projects': to_list(vs.Any(job_project, str)),
+                      'vars': dict,
+                      'dependencies': to_list(str),
+                      'allowed-projects': to_list(str),
+                      'override-branch': str,
+                      'description': str,
+                      'post-review': bool}
+
+    job_name = {vs.Required('name'): str}
+
+    job = dict(collections.ChainMap(job_name, job_attributes))
 
     schema = vs.Schema(job)
 
@@ -725,9 +729,12 @@
             '_start_mark': ZuulMark,
         }
 
+        job = {str: vs.Any(str, JobParser.job_attributes)}
+        job_list = [vs.Any(str, job)]
+        pipeline_contents = {'queue': str, 'jobs': job_list}
+
         for p in self.layout.pipelines.values():
-            project_template[p.name] = {'queue': str,
-                                        'jobs': [vs.Any(str, dict)]}
+            project_template[p.name] = pipeline_contents
         return vs.Schema(project_template)
 
     def fromYaml(self, conf, validate=True):
@@ -796,9 +803,12 @@
             '_start_mark': ZuulMark,
         }
 
+        job = {str: vs.Any(str, JobParser.job_attributes)}
+        job_list = [vs.Any(str, job)]
+        pipeline_contents = {'queue': str, 'jobs': job_list}
+
         for p in self.layout.pipelines.values():
-            project[p.name] = {'queue': str,
-                               'jobs': [vs.Any(str, dict)]}
+            project[p.name] = pipeline_contents
         return vs.Schema(project)
 
     def fromYaml(self, conf_list):