Add pragma directive

This allows the user to override the implied branch matcher behavior.

Change-Id: I3ef43fd868988666cb01e8a6bb28552cc42151b4
diff --git a/doc/source/user/config.rst b/doc/source/user/config.rst
index 65bffcf..80c9136 100644
--- a/doc/source/user/config.rst
+++ b/doc/source/user/config.rst
@@ -668,6 +668,9 @@
       affect that branch, and likewise, changes to the master branch
       only affect it.
 
+      See :attr:`pragma.implied-branch-matchers` for how to override
+      this behavior on a per-file basis.
+
    .. attr:: files
 
       This attribute indicates that the job should only run on changes
@@ -1275,3 +1278,41 @@
       :default: 1
 
       The maximum number of running jobs which can use this semaphore.
+
+.. _pragma:
+
+Pragma
+~~~~~~
+
+The `pragma` item does not behave like the others.  It can not be
+included or excluded from configuration loading by the administrator,
+and does not form part of the final configuration itself.  It is used
+to alter how the configuration is processed while loading.
+
+A pragma item only affects the current file.  The same file in another
+branch of the same project will not be affected, nor any other files
+or any other projects.  The effect is global within that file --
+pragma directives may not be set and then unset within the same file.
+
+.. code-block:: yaml
+
+   - pragma:
+       implied-branch-matchers: False
+
+.. attr:: pragma
+
+   The pragma item currently only supports one attribute:
+
+   .. attr:: implied-branch-matchers
+
+      This is a boolean, which, if set, may be used to enable
+      (``True``) or disable (``False``) the addition of implied branch
+      matchers to job definitions.  Normally Zuul decides whether to
+      add these based on heuristics described in :attr:`job.branches`.
+      This attribute overrides that behavior.
+
+      This can be useful if a project has multiple branches, yet the
+      jobs defined in the master branch should apply to all branches.
+
+      Note that if a job contains an explicit branch matcher, it will
+      be used regardless of the value supplied here.
diff --git a/tests/fixtures/config/pragma/git/common-config/zuul.yaml b/tests/fixtures/config/pragma/git/common-config/zuul.yaml
new file mode 100644
index 0000000..7a8b45e
--- /dev/null
+++ b/tests/fixtures/config/pragma/git/common-config/zuul.yaml
@@ -0,0 +1,53 @@
+- pipeline:
+    name: check
+    manager: independent
+    trigger:
+      gerrit:
+        - event: patchset-created
+    success:
+      gerrit:
+        Verified: 1
+    failure:
+      gerrit:
+        Verified: -1
+
+- pipeline:
+    name: gate
+    manager: dependent
+    post-review: True
+    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/pragma/git/org_project/README b/tests/fixtures/config/pragma/git/org_project/README
new file mode 100644
index 0000000..9daeafb
--- /dev/null
+++ b/tests/fixtures/config/pragma/git/org_project/README
@@ -0,0 +1 @@
+test
diff --git a/tests/fixtures/config/pragma/git/org_project/nopragma.yaml b/tests/fixtures/config/pragma/git/org_project/nopragma.yaml
new file mode 100644
index 0000000..95a306b
--- /dev/null
+++ b/tests/fixtures/config/pragma/git/org_project/nopragma.yaml
@@ -0,0 +1,2 @@
+- job:
+    name: test-job
diff --git a/tests/fixtures/config/pragma/git/org_project/playbooks/test-job.yaml b/tests/fixtures/config/pragma/git/org_project/playbooks/test-job.yaml
new file mode 100644
index 0000000..f679dce
--- /dev/null
+++ b/tests/fixtures/config/pragma/git/org_project/playbooks/test-job.yaml
@@ -0,0 +1,2 @@
+- hosts: all
+  tasks: []
diff --git a/tests/fixtures/config/pragma/git/org_project/pragma.yaml b/tests/fixtures/config/pragma/git/org_project/pragma.yaml
new file mode 100644
index 0000000..89852b0
--- /dev/null
+++ b/tests/fixtures/config/pragma/git/org_project/pragma.yaml
@@ -0,0 +1,5 @@
+- pragma:
+    implied-branch-matchers: False
+
+- job:
+    name: test-job
diff --git a/tests/fixtures/config/pragma/main.yaml b/tests/fixtures/config/pragma/main.yaml
new file mode 100644
index 0000000..208e274
--- /dev/null
+++ b/tests/fixtures/config/pragma/main.yaml
@@ -0,0 +1,8 @@
+- tenant:
+    name: tenant-one
+    source:
+      gerrit:
+        config-projects:
+          - common-config
+        untrusted-projects:
+          - org/project
diff --git a/tests/unit/test_v3.py b/tests/unit/test_v3.py
index 70b898e..b9ae04b 100755
--- a/tests/unit/test_v3.py
+++ b/tests/unit/test_v3.py
@@ -1907,6 +1907,57 @@
                          "B should not fail because of timeout limit")
 
 
+class TestPragma(ZuulTestCase):
+    tenant_config_file = 'config/pragma/main.yaml'
+
+    def test_no_pragma(self):
+        self.create_branch('org/project', 'stable')
+        with open(os.path.join(FIXTURE_DIR,
+                               'config/pragma/git/',
+                               'org_project/nopragma.yaml')) as f:
+            config = f.read()
+        file_dict = {'.zuul.yaml': config}
+        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.fake_gerrit.addEvent(A.getChangeMergedEvent())
+        self.waitUntilSettled()
+
+        # This is an untrusted repo with 2 branches, so it should have
+        # an implied branch matcher for the job.
+        tenant = self.sched.abide.tenants.get('tenant-one')
+        jobs = tenant.layout.getJobs('test-job')
+        self.assertEqual(len(jobs), 1)
+        for job in tenant.layout.getJobs('test-job'):
+            self.assertIsNotNone(job.branch_matcher)
+
+    def test_pragma(self):
+        self.create_branch('org/project', 'stable')
+        with open(os.path.join(FIXTURE_DIR,
+                               'config/pragma/git/',
+                               'org_project/pragma.yaml')) as f:
+            config = f.read()
+        file_dict = {'.zuul.yaml': config}
+        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.fake_gerrit.addEvent(A.getChangeMergedEvent())
+        self.waitUntilSettled()
+
+        # This is an untrusted repo with 2 branches, so it would
+        # normally have an implied branch matcher, but our pragma
+        # overrides it.
+        tenant = self.sched.abide.tenants.get('tenant-one')
+        jobs = tenant.layout.getJobs('test-job')
+        self.assertEqual(len(jobs), 1)
+        for job in tenant.layout.getJobs('test-job'):
+            self.assertIsNone(job.branch_matcher)
+
+
 class TestBaseJobs(ZuulTestCase):
     tenant_config_file = 'config/base-jobs/main.yaml'
 
diff --git a/zuul/configloader.py b/zuul/configloader.py
index 6ff7dad..2cb23d9 100644
--- a/zuul/configloader.py
+++ b/zuul/configloader.py
@@ -238,7 +238,7 @@
 class ZuulSafeLoader(yaml.SafeLoader):
     zuul_node_types = frozenset(('job', 'nodeset', 'secret', 'pipeline',
                                  'project', 'project-template',
-                                 'semaphore'))
+                                 'semaphore', 'pragma'))
 
     def __init__(self, stream, context):
         wrapped_stream = io.StringIO(stream)
@@ -313,6 +313,30 @@
                                                  private_key).decode('utf8')
 
 
+class PragmaParser(object):
+    pragma = {
+        'implied-branch-matchers': bool,
+        '_source_context': model.SourceContext,
+        '_start_mark': ZuulMark,
+    }
+
+    schema = vs.Schema(pragma)
+
+    def __init__(self):
+        self.log = logging.getLogger("zuul.PragmaParser")
+
+    def fromYaml(self, conf):
+        with configuration_exceptions('project-template', conf):
+            self.schema(conf)
+
+        bm = conf.get('implied-branch-matchers')
+        if bm is None:
+            return
+
+        source_context = conf['_source_context']
+        source_context.implied_branch_matchers = bm
+
+
 class NodeSetParser(object):
     @staticmethod
     def getSchema(anonymous=False):
@@ -459,6 +483,13 @@
         if project_pipeline:
             return None
 
+        # 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:
+            return [job.source_context.branch]
+        elif job.source_context.implied_branch_matchers is False:
+            return None
+
         # If this is a trusted project, don't create implied branch
         # matchers.
         if job.source_context.trusted:
@@ -1462,6 +1493,12 @@
     @staticmethod
     def _parseLayoutItems(layout, tenant, data, scheduler, connections,
                           skip_pipelines=False, skip_semaphores=False):
+        # Handle pragma items first since they modify the source context
+        # used by other classes.
+        pragma_parser = PragmaParser()
+        for config_pragma in data.pragmas:
+            pragma_parser.fromYaml(config_pragma)
+
         if not skip_pipelines:
             for config_pipeline in data.pipelines:
                 classes = TenantParser._getLoadClasses(
diff --git a/zuul/model.py b/zuul/model.py
index ee2ea26..d2ecef4 100644
--- a/zuul/model.py
+++ b/zuul/model.py
@@ -635,6 +635,7 @@
         self.branch = branch
         self.path = path
         self.trusted = trusted
+        self.implied_branch_matchers = None
 
     def __str__(self):
         return '%s/%s@%s' % (self.project, self.path, self.branch)
@@ -2338,6 +2339,7 @@
     """A collection of yaml lists that has not yet been parsed into objects."""
 
     def __init__(self):
+        self.pragmas = []
         self.pipelines = []
         self.jobs = []
         self.project_templates = []
@@ -2348,6 +2350,7 @@
 
     def copy(self):
         r = UnparsedTenantConfig()
+        r.pragmas = copy.deepcopy(self.pragmas)
         r.pipelines = copy.deepcopy(self.pipelines)
         r.jobs = copy.deepcopy(self.jobs)
         r.project_templates = copy.deepcopy(self.project_templates)
@@ -2359,6 +2362,7 @@
 
     def extend(self, conf):
         if isinstance(conf, UnparsedTenantConfig):
+            self.pragmas.extend(conf.pragmas)
             self.pipelines.extend(conf.pipelines)
             self.jobs.extend(conf.jobs)
             self.project_templates.extend(conf.project_templates)
@@ -2393,6 +2397,8 @@
                 self.secrets.append(value)
             elif key == 'semaphore':
                 self.semaphores.append(value)
+            elif key == 'pragma':
+                self.pragmas.append(value)
             else:
                 raise ConfigItemUnknownError()