GitHub file matching support

Allow to configure jobs to run only when certain files are changed.

Github does not list the /COMMIT_MSG in the changed files as gerrit
does. Therefore the matcher now returns False only if the single file is
the /COMMIT_MSG one.

Change-Id: I4fa8a328f2ba430c25377e50e1eff7c45829eba6
diff --git a/tests/base.py b/tests/base.py
index c567b03..937d60f 100755
--- a/tests/base.py
+++ b/tests/base.py
@@ -547,7 +547,7 @@
 class FakeGithubPullRequest(object):
 
     def __init__(self, github, number, project, branch,
-                 subject, upstream_root, number_of_commits=1):
+                 subject, upstream_root, files=[], number_of_commits=1):
         """Creates a new PR with several commits.
         Sends an event about opened PR."""
         self.github = github
@@ -558,6 +558,7 @@
         self.subject = subject
         self.number_of_commits = 0
         self.upstream_root = upstream_root
+        self.files = []
         self.comments = []
         self.labels = []
         self.statuses = {}
@@ -566,18 +567,18 @@
         self.is_merged = False
         self.merge_message = None
         self._createPRRef()
-        self._addCommitToRepo()
+        self._addCommitToRepo(files=files)
         self._updateTimeStamp()
 
-    def addCommit(self):
+    def addCommit(self, files=[]):
         """Adds a commit on top of the actual PR head."""
-        self._addCommitToRepo()
+        self._addCommitToRepo(files=files)
         self._updateTimeStamp()
         self._clearStatuses()
 
-    def forcePush(self):
+    def forcePush(self, files=[]):
         """Clears actual commits and add a commit on top of the base."""
-        self._addCommitToRepo(reset=True)
+        self._addCommitToRepo(files=files, reset=True)
         self._updateTimeStamp()
         self._clearStatuses()
 
@@ -690,7 +691,7 @@
         GithubChangeReference.create(
             repo, self._getPRReference(), 'refs/tags/init')
 
-    def _addCommitToRepo(self, reset=False):
+    def _addCommitToRepo(self, files=[], reset=False):
         repo = self._getRepo()
         ref = repo.references[self._getPRReference()]
         if reset:
@@ -701,7 +702,12 @@
         zuul.merger.merger.reset_repo_to_head(repo)
         repo.git.clean('-x', '-f', '-d')
 
-        fn = '%s-%s' % (self.branch.replace('/', '_'), self.number)
+        if files:
+            fn = files[0]
+            self.files = files
+        else:
+            fn = '%s-%s' % (self.branch.replace('/', '_'), self.number)
+            self.files = [fn]
         msg = self.subject + '-' + str(self.number_of_commits)
         fn = os.path.join(repo.working_dir, fn)
         f = open(fn, 'w')
@@ -776,10 +782,11 @@
         self.merge_failure = False
         self.merge_not_allowed_count = 0
 
-    def openFakePullRequest(self, project, branch, subject):
+    def openFakePullRequest(self, project, branch, subject, files=[]):
         self.pr_number += 1
         pull_request = FakeGithubPullRequest(
-            self, self.pr_number, project, branch, subject, self.upstream_root)
+            self, self.pr_number, project, branch, subject, self.upstream_root,
+            files=files)
         self.pull_requests.append(pull_request)
         return pull_request
 
@@ -830,6 +837,10 @@
         }
         return data
 
+    def getPullFileNames(self, project, number):
+        pr = self.pull_requests[number - 1]
+        return pr.files
+
     def getUser(self, login):
         data = {
             'username': login,
diff --git a/tests/fixtures/layouts/files-github.yaml b/tests/fixtures/layouts/files-github.yaml
new file mode 100644
index 0000000..734b945
--- /dev/null
+++ b/tests/fixtures/layouts/files-github.yaml
@@ -0,0 +1,18 @@
+- pipeline:
+    name: check
+    manager: independent
+    trigger:
+      github:
+        - event: pull_request
+          action: opened
+
+- job:
+    name: project-test1
+    files:
+      - '.*-requires'
+
+- project:
+    name: org/project
+    check:
+      jobs:
+        - project-test1
diff --git a/tests/unit/test_change_matcher.py b/tests/unit/test_change_matcher.py
index 0585322..6b161a1 100644
--- a/tests/unit/test_change_matcher.py
+++ b/tests/unit/test_change_matcher.py
@@ -125,12 +125,18 @@
     def test_matches_returns_false_when_not_all_files_match(self):
         self._test_matches(False, files=['/COMMIT_MSG', 'docs/foo', 'foo/bar'])
 
+    def test_matches_returns_true_when_single_file_does_not_match(self):
+        self._test_matches(True, files=['docs/foo'])
+
     def test_matches_returns_false_when_commit_message_matches(self):
         self._test_matches(False, files=['/COMMIT_MSG'])
 
     def test_matches_returns_true_when_all_files_match(self):
         self._test_matches(True, files=['/COMMIT_MSG', 'docs/foo'])
 
+    def test_matches_returns_true_when_single_file_matches(self):
+        self._test_matches(True, files=['docs/foo'])
+
 
 class TestMatchAll(BaseTestMatcher):
 
diff --git a/tests/unit/test_github_driver.py b/tests/unit/test_github_driver.py
index 7267b83..605a479 100644
--- a/tests/unit/test_github_driver.py
+++ b/tests/unit/test_github_driver.py
@@ -65,6 +65,22 @@
 
         self.assertEqual(2, len(self.history))
 
+    @simple_layout('layouts/files-github.yaml', driver='github')
+    def test_pull_matched_file_event(self):
+        A = self.fake_github.openFakePullRequest(
+            'org/project', 'master', 'A',
+            files=['random.txt', 'build-requires'])
+        self.fake_github.emitEvent(A.getPullRequestOpenedEvent())
+        self.waitUntilSettled()
+        self.assertEqual(1, len(self.history))
+
+        # test_pull_unmatched_file_event
+        B = self.fake_github.openFakePullRequest('org/project', 'master', 'B',
+                                                 files=['random.txt'])
+        self.fake_github.emitEvent(B.getPullRequestOpenedEvent())
+        self.waitUntilSettled()
+        self.assertEqual(1, len(self.history))
+
     @simple_layout('layouts/basic-github.yaml', driver='github')
     def test_comment_event(self):
         A = self.fake_github.openFakePullRequest('org/project', 'master', 'A')
diff --git a/tests/unit/test_model.py b/tests/unit/test_model.py
index d8480ea..5f968b4 100644
--- a/tests/unit/test_model.py
+++ b/tests/unit/test_model.py
@@ -73,11 +73,21 @@
         change.files = ['/COMMIT_MSG', 'docs/foo']
         self.assertFalse(self.job.changeMatches(change))
 
+    def test_change_matches_returns_false_for_single_matched_skip_if(self):
+        change = model.Change('project')
+        change.files = ['docs/foo']
+        self.assertFalse(self.job.changeMatches(change))
+
     def test_change_matches_returns_true_for_unmatched_skip_if(self):
         change = model.Change('project')
         change.files = ['/COMMIT_MSG', 'foo']
         self.assertTrue(self.job.changeMatches(change))
 
+    def test_change_matches_returns_true_for_single_unmatched_skip_if(self):
+        change = model.Change('project')
+        change.files = ['foo']
+        self.assertTrue(self.job.changeMatches(change))
+
     def test_job_sets_defaults_for_boolean_attributes(self):
         self.assertIsNotNone(self.job.voting)