Merge "Add multi-branch support for project-templates" into feature/zuulv3
diff --git a/tests/fixtures/config/central-jobs/git/central-jobs/README b/tests/fixtures/config/central-jobs/git/central-jobs/README
new file mode 100644
index 0000000..9daeafb
--- /dev/null
+++ b/tests/fixtures/config/central-jobs/git/central-jobs/README
@@ -0,0 +1 @@
+test
diff --git a/tests/fixtures/config/central-jobs/git/central-jobs/playbooks/central-job.yaml b/tests/fixtures/config/central-jobs/git/central-jobs/playbooks/central-job.yaml
new file mode 100644
index 0000000..f679dce
--- /dev/null
+++ b/tests/fixtures/config/central-jobs/git/central-jobs/playbooks/central-job.yaml
@@ -0,0 +1,2 @@
+- hosts: all
+  tasks: []
diff --git a/tests/fixtures/config/central-jobs/git/central-jobs/zuul.yaml b/tests/fixtures/config/central-jobs/git/central-jobs/zuul.yaml
new file mode 100644
index 0000000..2bf782e
--- /dev/null
+++ b/tests/fixtures/config/central-jobs/git/central-jobs/zuul.yaml
@@ -0,0 +1,9 @@
+- job:
+    name: central-job
+    run: playbooks/central-job.yaml
+
+- project-template:
+    name: central-jobs
+    check:
+      jobs:
+        - central-job
diff --git a/tests/fixtures/config/central-jobs/git/common-config/zuul.yaml b/tests/fixtures/config/central-jobs/git/common-config/zuul.yaml
new file mode 100644
index 0000000..c31af45
--- /dev/null
+++ b/tests/fixtures/config/central-jobs/git/common-config/zuul.yaml
@@ -0,0 +1,52 @@
+- pipeline:
+    name: check
+    manager: independent
+    trigger:
+      gerrit:
+        - event: patchset-created
+    success:
+      gerrit:
+        Verified: 1
+    failure:
+      gerrit:
+        Verified: -1
+
+- pipeline:
+    name: gate
+    manager: dependent
+    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
+
+- project:
+    name: common-config
+    check:
+      jobs: []
+    gate:
+      jobs:
+        - noop
+
+- project:
+    name: org/project
+    check:
+      jobs: []
+    gate:
+      jobs:
+        - noop
diff --git a/tests/fixtures/config/central-jobs/git/org_project/README b/tests/fixtures/config/central-jobs/git/org_project/README
new file mode 100644
index 0000000..9daeafb
--- /dev/null
+++ b/tests/fixtures/config/central-jobs/git/org_project/README
@@ -0,0 +1 @@
+test
diff --git a/tests/fixtures/config/central-jobs/main.yaml b/tests/fixtures/config/central-jobs/main.yaml
new file mode 100644
index 0000000..08f4d5d
--- /dev/null
+++ b/tests/fixtures/config/central-jobs/main.yaml
@@ -0,0 +1,9 @@
+- tenant:
+    name: tenant-one
+    source:
+      gerrit:
+        config-projects:
+          - common-config
+        untrusted-projects:
+          - central-jobs
+          - org/project
diff --git a/tests/unit/test_v3.py b/tests/unit/test_v3.py
index 80e6ccc..c04604d 100755
--- a/tests/unit/test_v3.py
+++ b/tests/unit/test_v3.py
@@ -251,6 +251,94 @@
         self.waitUntilSettled()
 
 
+class TestCentralJobs(ZuulTestCase):
+    tenant_config_file = 'config/central-jobs/main.yaml'
+
+    def setUp(self):
+        super(TestCentralJobs, self).setUp()
+        self.create_branch('org/project', 'stable')
+        self.fake_gerrit.addEvent(
+            self.fake_gerrit.getFakeBranchCreatedEvent(
+                'org/project', 'stable'))
+        self.waitUntilSettled()
+
+    def _updateConfig(self, config, branch):
+        file_dict = {'.zuul.yaml': config}
+        C = self.fake_gerrit.addFakeChange('org/project', branch, 'C',
+                                           files=file_dict)
+        C.addApproval('Code-Review', 2)
+        self.fake_gerrit.addEvent(C.addApproval('Approved', 1))
+        self.waitUntilSettled()
+        self.fake_gerrit.addEvent(C.getChangeMergedEvent())
+        self.waitUntilSettled()
+
+    def _test_central_job_on_branch(self, branch, other_branch):
+        # Test that a job defined on a branchless repo only runs on
+        # the branch applied
+        config = textwrap.dedent(
+            """
+            - project:
+                name: org/project
+                check:
+                  jobs:
+                    - central-job
+            """)
+        self._updateConfig(config, branch)
+
+        A = self.fake_gerrit.addFakeChange('org/project', branch, 'A')
+        self.fake_gerrit.addEvent(A.getPatchsetCreatedEvent(1))
+        self.waitUntilSettled()
+
+        self.assertHistory([
+            dict(name='central-job', result='SUCCESS', changes='2,1')])
+
+        # No jobs should run for this change.
+        B = self.fake_gerrit.addFakeChange('org/project', other_branch, 'B')
+        self.fake_gerrit.addEvent(B.getPatchsetCreatedEvent(1))
+        self.waitUntilSettled()
+
+        self.assertHistory([
+            dict(name='central-job', result='SUCCESS', changes='2,1')])
+
+    def test_central_job_on_stable(self):
+        self._test_central_job_on_branch('master', 'stable')
+
+    def test_central_job_on_master(self):
+        self._test_central_job_on_branch('stable', 'master')
+
+    def _test_central_template_on_branch(self, branch, other_branch):
+        # Test that a project-template defined on a branchless repo
+        # only runs on the branch applied
+        config = textwrap.dedent(
+            """
+            - project:
+                name: org/project
+                templates: ['central-jobs']
+            """)
+        self._updateConfig(config, branch)
+
+        A = self.fake_gerrit.addFakeChange('org/project', branch, 'A')
+        self.fake_gerrit.addEvent(A.getPatchsetCreatedEvent(1))
+        self.waitUntilSettled()
+
+        self.assertHistory([
+            dict(name='central-job', result='SUCCESS', changes='2,1')])
+
+        # No jobs should run for this change.
+        B = self.fake_gerrit.addFakeChange('org/project', other_branch, 'B')
+        self.fake_gerrit.addEvent(B.getPatchsetCreatedEvent(1))
+        self.waitUntilSettled()
+
+        self.assertHistory([
+            dict(name='central-job', result='SUCCESS', changes='2,1')])
+
+    def test_central_template_on_stable(self):
+        self._test_central_template_on_branch('master', 'stable')
+
+    def test_central_template_on_master(self):
+        self._test_central_template_on_branch('stable', 'master')
+
+
 class TestInRepoConfig(ZuulTestCase):
     # A temporary class to hold new tests while others are disabled
 
@@ -357,6 +445,8 @@
             dict(name='project-test2', result='SUCCESS', changes='2,1')])
 
     def test_dynamic_template(self):
+        # Tests that a project can't update a template in another
+        # project.
         in_repo_conf = textwrap.dedent(
             """
             - job:
@@ -378,8 +468,12 @@
                                            files=file_dict)
         self.fake_gerrit.addEvent(A.getPatchsetCreatedEvent(1))
         self.waitUntilSettled()
-        self.assertHistory([
-            dict(name='template-job', result='SUCCESS', changes='1,1')])
+
+        self.assertEqual(A.patchsets[0]['approvals'][0]['value'], "-1")
+        self.assertIn('Project template common-config-template '
+                      'is already defined',
+                      A.messages[0],
+                      "A should have failed the check pipeline")
 
     def test_dynamic_config_non_existing_job(self):
         """Test that requesting a non existent job fails"""
diff --git a/zuul/configloader.py b/zuul/configloader.py
index ec2c1e0..01a87fd 100644
--- a/zuul/configloader.py
+++ b/zuul/configloader.py
@@ -477,12 +477,7 @@
     ]
 
     @staticmethod
-    def _getImpliedBranches(tenant, job, project_pipeline):
-        # If this is a project pipeline, don't create implied branch
-        # matchers -- that's handled in ProjectParser.
-        if project_pipeline:
-            return None
-
+    def _getImpliedBranches(tenant, job):
         # If the user has set a pragma directive for this, use the
         # value (if unset, the value is None).
         if job.source_context.implied_branch_matchers is True:
@@ -673,8 +668,7 @@
 
         branches = None
         if ('branches' not in conf):
-            branches = JobParser._getImpliedBranches(
-                tenant, job, project_pipeline)
+            branches = JobParser._getImpliedBranches(tenant, job)
         if (not branches) and ('branches' in conf):
             branches = as_list(conf['branches'])
         if branches:
@@ -745,8 +739,8 @@
         if validate:
             with configuration_exceptions('project-template', conf):
                 self.schema(conf)
-        project_template = model.ProjectConfig(conf['name'])
         source_context = conf['_source_context']
+        project_template = model.ProjectConfig(conf['name'], source_context)
         start_mark = conf['_start_mark']
         for pipeline in self.layout.pipelines.values():
             conf_pipeline = conf.get(pipeline.name)
@@ -1562,8 +1556,9 @@
             classes = TenantParser._getLoadClasses(tenant, config_template)
             if 'project-template' not in classes:
                 continue
-            layout.addProjectTemplate(project_template_parser.fromYaml(
-                config_template))
+            with configuration_exceptions('project-template', config_template):
+                layout.addProjectTemplate(project_template_parser.fromYaml(
+                    config_template))
 
         project_parser = ProjectParser(tenant, layout, project_template_parser)
         for config_projects in data.projects.values():
diff --git a/zuul/model.py b/zuul/model.py
index 61c0c8a..cf63f64 100644
--- a/zuul/model.py
+++ b/zuul/model.py
@@ -1097,7 +1097,8 @@
                 if not job.branch_matcher and implied_branch:
                     job = job.copy()
                     job.setBranchMatcher([implied_branch])
-                joblist.append(job)
+                if job not in joblist:
+                    joblist.append(job)
 
 
 class JobGraph(object):
@@ -2212,8 +2213,11 @@
 
 class ProjectConfig(object):
     # Represents a project cofiguration
-    def __init__(self, name):
+    def __init__(self, name, source_context=None):
         self.name = name
+        # If this is a template, it will have a source_context, but
+        # not if it is a project definition.
+        self.source_context = source_context
         self.merge_mode = None
         # The default branch for the project (usually master).
         self.default_branch = None
@@ -2482,12 +2486,18 @@
         self.pipelines[pipeline.name] = pipeline
 
     def addProjectTemplate(self, project_template):
-        if project_template.name in self.project_templates:
-            # TODO(jeblair): issue a warning to the logs on loading
-            # the config, and an error when this hits in a proposed
-            # change.
-            return
-        self.project_templates[project_template.name] = project_template
+        template = self.project_templates.get(project_template.name)
+        if template:
+            if (project_template.source_context.project !=
+                template.source_context.project):
+                raise Exception("Project template %s is already defined" %
+                                (project_template.name,))
+            for pipeline in project_template.pipelines:
+                template.pipelines[pipeline].job_list.\
+                    inheritFrom(project_template.pipelines[pipeline].job_list,
+                                None)
+        else:
+            self.project_templates[project_template.name] = project_template
 
     def addProjectConfig(self, project_config):
         self.project_configs[project_config.name] = project_config