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)
 
diff --git a/zuul/change_matcher.py b/zuul/change_matcher.py
index 1da1d2c..baea217 100644
--- a/zuul/change_matcher.py
+++ b/zuul/change_matcher.py
@@ -108,7 +108,9 @@
         yield self.commit_regex
 
     def matches(self, change):
-        if not (hasattr(change, 'files') and len(change.files) > 1):
+        if not (hasattr(change, 'files') and change.files):
+            return False
+        if len(change.files) == 1 and self.commit_regex.match(change.files[0]):
             return False
         for file_ in change.files:
             matched_file = False
diff --git a/zuul/driver/github/githubconnection.py b/zuul/driver/github/githubconnection.py
index e8162cd..0c4434f 100644
--- a/zuul/driver/github/githubconnection.py
+++ b/zuul/driver/github/githubconnection.py
@@ -288,6 +288,7 @@
             change.url = event.change_url
             change.updated_at = self._ghTimestampToDate(event.updated_at)
             change.patchset = event.patch_number
+            change.files = self.getPullFileNames(project, change.number)
             change.title = event.title
             change.source_event = event
         elif event.ref:
@@ -347,6 +348,11 @@
         # For now, just send back a True value.
         return True
 
+    def getPullFileNames(self, project, number):
+        owner, proj = project.name.split('/')
+        return [f.filename for f in
+                self.github.pull_request(owner, proj, number).files()]
+
     def getUser(self, login):
         return GithubUser(self.github, login)
 
diff --git a/zuul/driver/github/githubsource.py b/zuul/driver/github/githubsource.py
index a638122..312ee87 100644
--- a/zuul/driver/github/githubsource.py
+++ b/zuul/driver/github/githubsource.py
@@ -84,5 +84,9 @@
         """Get the git-web url for a project."""
         return self.connection.getGitwebUrl(project, sha)
 
+    def getPullFiles(self, project, number):
+        """Get filenames of the pull request"""
+        return self.connection.getPullFileNames(project, number)
+
     def _ghTimestampToDate(self, timestamp):
         return time.strptime(timestamp, '%Y-%m-%dT%H:%M:%SZ')