Merge "Move tenant_config option to scheduler section" into feature/zuulv3
diff --git a/tests/base.py b/tests/base.py
index dbeb108..e605c87 100755
--- a/tests/base.py
+++ b/tests/base.py
@@ -554,7 +554,7 @@
def __init__(self, github, number, project, branch,
subject, upstream_root, files=[], number_of_commits=1,
- writers=[]):
+ writers=[], body=''):
"""Creates a new PR with several commits.
Sends an event about opened PR."""
self.github = github
@@ -563,6 +563,7 @@
self.project = project
self.branch = branch
self.subject = subject
+ self.body = body
self.number_of_commits = 0
self.upstream_root = upstream_root
self.files = []
@@ -602,6 +603,9 @@
def getPullRequestClosedEvent(self):
return self._getPullRequestEvent('closed')
+ def getPullRequestEditedEvent(self):
+ return self._getPullRequestEvent('edited')
+
def addComment(self, message):
self.comments.append(message)
self._updateTimeStamp()
@@ -723,6 +727,10 @@
}
return (name, data)
+ def editBody(self, body):
+ self.body = body
+ self._updateTimeStamp()
+
def _getRepo(self):
repo_path = os.path.join(self.upstream_root, self.project)
return git.Repo(repo_path)
@@ -830,7 +838,8 @@
'repo': {
'full_name': self.project
}
- }
+ },
+ 'body': self.body
},
'sender': {
'login': 'ghuser'
@@ -868,12 +877,14 @@
self.upstream_root = upstream_root
self.merge_failure = False
self.merge_not_allowed_count = 0
+ self.reports = []
- def openFakePullRequest(self, project, branch, subject, files=[]):
+ def openFakePullRequest(self, project, branch, subject, files=[],
+ body=''):
self.pr_number += 1
pull_request = FakeGithubPullRequest(
self, self.pr_number, project, branch, subject, self.upstream_root,
- files=files)
+ files=files, body=body)
self.pull_requests.append(pull_request)
return pull_request
@@ -934,7 +945,9 @@
}
},
'files': pr.files,
- 'labels': pr.labels
+ 'labels': pr.labels,
+ 'merged': pr.is_merged,
+ 'body': pr.body
}
return data
@@ -980,10 +993,14 @@
return ['master']
def commentPull(self, project, pr_number, message):
+ # record that this got reported
+ self.reports.append((project, pr_number, 'comment'))
pull_request = self.pull_requests[pr_number - 1]
pull_request.addComment(message)
def mergePull(self, project, pr_number, commit_message='', sha=None):
+ # record that this got reported
+ self.reports.append((project, pr_number, 'merge'))
pull_request = self.pull_requests[pr_number - 1]
if self.merge_failure:
raise Exception('Pull request was not merged')
@@ -999,6 +1016,8 @@
def setCommitStatus(self, project, sha, state, url='', description='',
context='default', user='zuul'):
+ # record that this got reported
+ self.reports.append((project, sha, 'status', (user, context, state)))
# always insert a status to the front of the list, to represent
# the last status provided for a commit.
# Since we're bypassing github API, which would require a user, we
@@ -1015,13 +1034,30 @@
})
def labelPull(self, project, pr_number, label):
+ # record that this got reported
+ self.reports.append((project, pr_number, 'label', label))
pull_request = self.pull_requests[pr_number - 1]
pull_request.addLabel(label)
def unlabelPull(self, project, pr_number, label):
+ # record that this got reported
+ self.reports.append((project, pr_number, 'unlabel', label))
pull_request = self.pull_requests[pr_number - 1]
pull_request.removeLabel(label)
+ def _getNeededByFromPR(self, change):
+ prs = []
+ pattern = re.compile(r"Depends-On.*https://%s/%s/pull/%s" %
+ (self.git_host, change.project.name,
+ change.number))
+ for pr in self.pull_requests:
+ if pattern.search(pr.body):
+ # Get our version of a pull so that it's a dict
+ pull = self.getPull(pr.project, pr.number)
+ prs.append(pull)
+
+ return prs
+
class BuildHistory(object):
def __init__(self, **kw):
diff --git a/tests/fixtures/layouts/crd-github.yaml b/tests/fixtures/layouts/crd-github.yaml
new file mode 100644
index 0000000..11bdf76
--- /dev/null
+++ b/tests/fixtures/layouts/crd-github.yaml
@@ -0,0 +1,79 @@
+- pipeline:
+ name: check
+ manager: independent
+ trigger:
+ github:
+ - event: pull_request
+ action: edited
+ start:
+ github: {}
+ success:
+ github: {}
+ failure:
+ github: {}
+
+- pipeline:
+ name: gate
+ manager: dependent
+ trigger:
+ github:
+ - event: pull_request
+ action: edited
+ start:
+ github: {}
+ success:
+ github:
+ merge: true
+ failure:
+ github: {}
+
+- job:
+ name: project1-test
+- job:
+ name: project2-test
+- job:
+ name: project3-test
+- job:
+ name: project4-test
+- job:
+ name: project5-test
+- job:
+ name: project6-test
+
+- project:
+ name: org/project1
+ check:
+ jobs:
+ - project1-test
+
+- project:
+ name: org/project2
+ check:
+ jobs:
+ - project2-test
+
+- project:
+ name: org/project3
+ gate:
+ queue: cogated
+ jobs:
+ - project3-test
+
+- project:
+ name: org/project4
+ gate:
+ queue: cogated
+ jobs:
+ - project4-test
+
+- project:
+ name: org/project5
+ gate:
+ jobs:
+ - project5-test
+
+- project:
+ name: org/project6
+ gate:
+ jobs:
+ - project6-test
diff --git a/tests/fixtures/layouts/reporting-github.yaml b/tests/fixtures/layouts/reporting-github.yaml
index d054df7..0fdec85 100644
--- a/tests/fixtures/layouts/reporting-github.yaml
+++ b/tests/fixtures/layouts/reporting-github.yaml
@@ -34,6 +34,29 @@
github:
comment: false
+- pipeline:
+ name: push-reporting
+ description: Uncommon reporting
+ manager: independent
+ trigger:
+ github:
+ - event: push
+ - event: pull_request
+ action: opened
+ start:
+ github:
+ comment: true
+ status: 'pending'
+ success:
+ github:
+ comment: true
+ status: 'success'
+ merge: true
+ failure:
+ github:
+ comment: true
+ status: 'failure'
+
- job:
name: project-test1
@@ -45,3 +68,9 @@
reporting:
jobs:
- project-test1
+
+- project:
+ name: org/project2
+ push-reporting:
+ jobs:
+ - project-test1
diff --git a/tests/unit/test_github_crd.py b/tests/unit/test_github_crd.py
new file mode 100644
index 0000000..bc7d499
--- /dev/null
+++ b/tests/unit/test_github_crd.py
@@ -0,0 +1,172 @@
+#!/usr/bin/env python
+# Copyright (c) 2017 IBM Corp.
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+
+from tests.base import ZuulTestCase, simple_layout
+
+
+class TestGithubCrossRepoDeps(ZuulTestCase):
+ """Test Github cross-repo dependencies"""
+ config_file = 'zuul-github-driver.conf'
+
+ @simple_layout('layouts/crd-github.yaml', driver='github')
+ def test_crd_independent(self):
+ "Test cross-repo dependences on an independent pipeline"
+
+ # Create a change in project1 that a project2 change will depend on
+ A = self.fake_github.openFakePullRequest('org/project1', 'master', 'A')
+
+ # Create a commit in B that sets the dependency on A
+ msg = "Depends-On: https://github.com/org/project1/pull/%s" % A.number
+ B = self.fake_github.openFakePullRequest('org/project2', 'master', 'B',
+ body=msg)
+
+ # Make an event to re-use
+ event = B.getPullRequestEditedEvent()
+
+ self.fake_github.emitEvent(event)
+ self.waitUntilSettled()
+
+ # The changes for the job from project2 should include the project1
+ # PR contet
+ changes = self.getJobFromHistory(
+ 'project2-test', 'org/project2').changes
+
+ self.assertEqual(changes, "%s,%s %s,%s" % (A.number,
+ A.head_sha,
+ B.number,
+ B.head_sha))
+
+ # There should be no more changes in the queue
+ tenant = self.sched.abide.tenants.get('tenant-one')
+ self.assertEqual(len(tenant.layout.pipelines['check'].queues), 0)
+
+ @simple_layout('layouts/crd-github.yaml', driver='github')
+ def test_crd_dependent(self):
+ "Test cross-repo dependences on a dependent pipeline"
+
+ # Create a change in project3 that a project4 change will depend on
+ A = self.fake_github.openFakePullRequest('org/project3', 'master', 'A')
+
+ # Create a commit in B that sets the dependency on A
+ msg = "Depends-On: https://github.com/org/project3/pull/%s" % A.number
+ B = self.fake_github.openFakePullRequest('org/project4', 'master', 'B',
+ body=msg)
+
+ # Make an event to re-use
+ event = B.getPullRequestEditedEvent()
+
+ self.fake_github.emitEvent(event)
+ self.waitUntilSettled()
+
+ # The changes for the job from project4 should include the project3
+ # PR contet
+ changes = self.getJobFromHistory(
+ 'project4-test', 'org/project4').changes
+
+ self.assertEqual(changes, "%s,%s %s,%s" % (A.number,
+ A.head_sha,
+ B.number,
+ B.head_sha))
+
+ self.assertTrue(A.is_merged)
+ self.assertTrue(B.is_merged)
+
+ @simple_layout('layouts/crd-github.yaml', driver='github')
+ def test_crd_unshared_dependent(self):
+ "Test cross-repo dependences on unshared dependent pipeline"
+
+ # Create a change in project1 that a project2 change will depend on
+ A = self.fake_github.openFakePullRequest('org/project5', 'master', 'A')
+
+ # Create a commit in B that sets the dependency on A
+ msg = "Depends-On: https://github.com/org/project5/pull/%s" % A.number
+ B = self.fake_github.openFakePullRequest('org/project6', 'master', 'B',
+ body=msg)
+
+ # Make an event for B
+ event = B.getPullRequestEditedEvent()
+
+ # Emit for B, which should not enqueue A because they do not share
+ # A queue. Since B depends on A, and A isn't enqueue, B will not run
+ self.fake_github.emitEvent(event)
+ self.waitUntilSettled()
+
+ self.assertEqual(0, len(self.history))
+
+ # Enqueue A alone, let it finish
+ self.fake_github.emitEvent(A.getPullRequestEditedEvent())
+ self.waitUntilSettled()
+
+ self.assertTrue(A.is_merged)
+ self.assertFalse(B.is_merged)
+ self.assertEqual(1, len(self.history))
+
+ # With A merged, B should go through
+ self.fake_github.emitEvent(event)
+ self.waitUntilSettled()
+
+ self.assertTrue(B.is_merged)
+ self.assertEqual(2, len(self.history))
+
+ @simple_layout('layouts/crd-github.yaml', driver='github')
+ def test_crd_cycle(self):
+ "Test cross-repo dependency cycles"
+
+ # A -> B -> A
+ msg = "Depends-On: https://github.com/org/project6/pull/2"
+ A = self.fake_github.openFakePullRequest('org/project5', 'master', 'A',
+ body=msg)
+ msg = "Depends-On: https://github.com/org/project5/pull/1"
+ B = self.fake_github.openFakePullRequest('org/project6', 'master', 'B',
+ body=msg)
+
+ self.fake_github.emitEvent(A.getPullRequestEditedEvent())
+ self.waitUntilSettled()
+
+ self.assertFalse(A.is_merged)
+ self.assertFalse(B.is_merged)
+ self.assertEqual(0, len(self.history))
+
+ @simple_layout('layouts/crd-github.yaml', driver='github')
+ def test_crd_needed_changes(self):
+ "Test cross-repo needed changes discovery"
+
+ # Given change A and B, where B depends on A, when A
+ # completes B should be enqueued (using a shared queue)
+
+ # Create a change in project3 that a project4 change will depend on
+ A = self.fake_github.openFakePullRequest('org/project3', 'master', 'A')
+
+ # Set B to depend on A
+ msg = "Depends-On: https://github.com/org/project3/pull/%s" % A.number
+ B = self.fake_github.openFakePullRequest('org/project4', 'master', 'B',
+ body=msg)
+
+ # Enqueue A, which when finished should enqueue B
+ self.fake_github.emitEvent(A.getPullRequestEditedEvent())
+ self.waitUntilSettled()
+
+ # The changes for the job from project4 should include the project3
+ # PR contet
+ changes = self.getJobFromHistory(
+ 'project4-test', 'org/project4').changes
+
+ self.assertEqual(changes, "%s,%s %s,%s" % (A.number,
+ A.head_sha,
+ B.number,
+ B.head_sha))
+
+ self.assertTrue(A.is_merged)
+ self.assertTrue(B.is_merged)
diff --git a/tests/unit/test_github_driver.py b/tests/unit/test_github_driver.py
index ba8e497..a19073c 100644
--- a/tests/unit/test_github_driver.py
+++ b/tests/unit/test_github_driver.py
@@ -320,6 +320,46 @@
self.assertThat(report_status['url'][len(base):],
MatchesRegex('^[a-fA-F0-9]{32}\/$'))
+ @simple_layout('layouts/reporting-github.yaml', driver='github')
+ def test_push_reporting(self):
+ project = 'org/project2'
+ # pipeline reports pull status both on start and success
+ self.executor_server.hold_jobs_in_build = True
+ pevent = self.fake_github.getPushEvent(project=project,
+ ref='refs/heads/master')
+
+ self.fake_github.emitEvent(pevent)
+ self.waitUntilSettled()
+
+ # there should only be one report, a status
+ self.assertEqual(1, len(self.fake_github.reports))
+ # Verify the user/context/state of the status
+ status = ('zuul', 'tenant-one/push-reporting', 'pending')
+ self.assertEqual(status, self.fake_github.reports[0][-1])
+
+ # free the executor, allow the build to finish
+ self.executor_server.hold_jobs_in_build = False
+ self.executor_server.release()
+ self.waitUntilSettled()
+
+ # Now there should be a second report, the success of the build
+ self.assertEqual(2, len(self.fake_github.reports))
+ # Verify the user/context/state of the status
+ status = ('zuul', 'tenant-one/push-reporting', 'success')
+ self.assertEqual(status, self.fake_github.reports[-1][-1])
+
+ # now make a PR which should also comment
+ self.executor_server.hold_jobs_in_build = True
+ A = self.fake_github.openFakePullRequest(project, 'master', 'A')
+ self.fake_github.emitEvent(A.getPullRequestOpenedEvent())
+ self.waitUntilSettled()
+
+ # Now there should be a four reports, a new comment
+ # and status
+ self.assertEqual(4, len(self.fake_github.reports))
+ self.executor_server.release()
+ self.waitUntilSettled()
+
@simple_layout('layouts/merging-github.yaml', driver='github')
def test_report_pull_merge(self):
# pipeline merges the pull request on success
diff --git a/zuul/driver/github/githubconnection.py b/zuul/driver/github/githubconnection.py
index 2e5d49a..838cba5 100644
--- a/zuul/driver/github/githubconnection.py
+++ b/zuul/driver/github/githubconnection.py
@@ -18,6 +18,7 @@
import hmac
import hashlib
import time
+import re
import cachecontrol
from cachecontrol.cache import DictCache
@@ -72,7 +73,10 @@
self._validate_signature(request)
- self.__dispatch_event(request)
+ try:
+ self.__dispatch_event(request)
+ except:
+ self.log.exception("Exception handling Github event:")
def __dispatch_event(self, request):
try:
@@ -172,6 +176,8 @@
elif action == 'unlabeled':
event.action = 'unlabeled'
event.label = body['label']['name']
+ elif action == 'edited':
+ event.action = 'edited'
else:
return None
@@ -353,6 +359,12 @@
DictCache(),
cache_etags=True)
+ # The regex is based on the connection host. We do not yet support
+ # cross-connection dependency gathering
+ self.depends_on_re = re.compile(
+ r"^Depends-On: https://%s/.+/.+/pull/[0-9]+$" % self.git_host,
+ re.MULTILINE | re.IGNORECASE)
+
def onLoad(self):
webhook_listener = GithubWebhookListener(self)
self.registerHttpHandler(self.payload_path,
@@ -476,8 +488,6 @@
if event.change_number:
change = self._getChange(project, event.change_number,
event.patch_number, refresh=refresh)
- change.refspec = event.refspec
- change.branch = event.branch
change.url = event.change_url
change.updated_at = self._ghTimestampToDate(event.updated_at)
change.source_event = event
@@ -495,8 +505,9 @@
change = Ref(project)
return change
- def _getChange(self, project, number, patchset, refresh=False):
- key = '%s/%s/%s' % (project.name, number, patchset)
+ def _getChange(self, project, number, patchset=None, refresh=False,
+ history=None):
+ key = (project.name, number, patchset)
change = self._change_cache.get(key)
if change and not refresh:
return change
@@ -507,24 +518,122 @@
change.patchset = patchset
self._change_cache[key] = change
try:
- self._updateChange(change)
+ self._updateChange(change, history)
except Exception:
if key in self._change_cache:
del self._change_cache[key]
raise
return change
- def _updateChange(self, change):
+ def _getDependsOnFromPR(self, body):
+ prs = []
+ seen = set()
+
+ for match in self.depends_on_re.findall(body):
+ if match in seen:
+ self.log.debug("Ignoring duplicate Depends-On: %s" % (match,))
+ continue
+ seen.add(match)
+ # Get the github url
+ url = match.rsplit()[-1]
+ # break it into the parts we need
+ _, org, proj, _, num = url.rsplit('/', 4)
+ # Get a pull object so we can get the head sha
+ pull = self.getPull('%s/%s' % (org, proj), int(num))
+ prs.append(pull)
+
+ return prs
+
+ def _getNeededByFromPR(self, change):
+ prs = []
+ seen = set()
+ # This shouldn't return duplicate issues, but code as if it could
+
+ # This leaves off the protocol, but looks for the specific GitHub
+ # hostname, the org/project, and the pull request number.
+ pattern = 'Depends-On %s/%s/pull/%s' % (self.git_host,
+ change.project.name,
+ change.number)
+ query = '%s type:pr is:open in:body' % pattern
+ github = self.getGithubClient()
+ for issue in github.search_issues(query=query):
+ pr = issue.issue.pull_request().as_dict()
+ if not pr.get('url'):
+ continue
+ if issue in seen:
+ continue
+ # the issue provides no good description of the project :\
+ org, proj, _, num = pr.get('url').split('/')[-4:]
+ self.log.debug("Found PR %s/%s/%s needs %s/%s" %
+ (org, proj, num, change.project.name,
+ change.number))
+ prs.append(pr)
+ seen.add(issue)
+
+ log_rate_limit(self.log, github)
+ return prs
+
+ def _updateChange(self, change, history=None):
+
+ # If this change is already in the history, we have a cyclic
+ # dependency loop and we do not need to update again, since it
+ # was done in a previous frame.
+ if history and (change.project.name, change.number) in history:
+ return change
+
self.log.info("Updating %s" % (change,))
change.pr = self.getPull(change.project.name, change.number)
+ change.refspec = "refs/pull/%s/head" % change.number
+ change.branch = change.pr.get('base').get('ref')
change.files = change.pr.get('files')
change.title = change.pr.get('title')
change.open = change.pr.get('state') == 'open'
+ change.is_merged = change.pr.get('merged')
change.status = self._get_statuses(change.project,
change.patchset)
change.reviews = self.getPullReviews(change.project,
change.number)
change.labels = change.pr.get('labels')
+ change.body = change.pr.get('body')
+
+ if history is None:
+ history = []
+ else:
+ history = history[:]
+ history.append((change.project.name, change.number))
+
+ needs_changes = []
+
+ # Get all the PRs this may depend on
+ for pr in self._getDependsOnFromPR(change.body):
+ proj = pr.get('base').get('repo').get('full_name')
+ pull = pr.get('number')
+ self.log.debug("Updating %s: Getting dependent "
+ "pull request %s/%s" %
+ (change, proj, pull))
+ project = self.source.getProject(proj)
+ dep = self._getChange(project, pull,
+ patchset=pr.get('head').get('sha'),
+ history=history)
+ if (not dep.is_merged) and dep not in needs_changes:
+ needs_changes.append(dep)
+
+ change.needs_changes = needs_changes
+
+ needed_by_changes = []
+ for pr in self._getNeededByFromPR(change):
+ proj = pr.get('base').get('repo').get('full_name')
+ pull = pr.get('number')
+ self.log.debug("Updating %s: Getting needed "
+ "pull request %s/%s" %
+ (change, proj, pull))
+ project = self.source.getProject(proj)
+ dep = self._getChange(project, pull,
+ patchset=pr.get('head').get('sha'),
+ history=history)
+ if not dep.is_merged:
+ needed_by_changes.append(dep)
+ change.needed_by_changes = needed_by_changes
return change
diff --git a/zuul/driver/github/githubreporter.py b/zuul/driver/github/githubreporter.py
index 37cbe61..72087bf 100644
--- a/zuul/driver/github/githubreporter.py
+++ b/zuul/driver/github/githubreporter.py
@@ -43,10 +43,13 @@
"""Report on an event."""
# order is important for github branch protection.
# A status should be set before a merge attempt
- if (self._commit_status is not None and
- hasattr(item.change, 'patchset') and
- item.change.patchset is not None):
- self.setCommitStatus(item)
+ if self._commit_status is not None:
+ if (hasattr(item.change, 'patchset') and
+ item.change.patchset is not None):
+ self.setCommitStatus(item)
+ elif (hasattr(item.change, 'newrev') and
+ item.change.newrev is not None):
+ self.setCommitStatus(item)
# Comments, labels, and merges can only be performed on pull requests.
# If the change is not a pull request (e.g. a push) skip them.
if hasattr(item.change, 'number'):
@@ -71,7 +74,10 @@
def setCommitStatus(self, item):
project = item.change.project.name
- sha = item.change.patchset
+ if hasattr(item.change, 'patchset'):
+ sha = item.change.patchset
+ elif hasattr(item.change, 'newrev'):
+ sha = item.change.newrev
context = '%s/%s' % (item.pipeline.layout.tenant.name,
item.pipeline.name)
state = self._commit_status
@@ -139,7 +145,7 @@
if change.title:
message += change.title
- account = change.source_event.account
+ account = getattr(change.source_event, 'account', None)
if not account:
return message