diff options
282 files changed, 2655 insertions, 1374 deletions
diff --git a/.zuul.yaml b/.zuul.yaml index ff5f491c1..9b4ebd9aa 100644 --- a/.zuul.yaml +++ b/.zuul.yaml @@ -232,11 +232,11 @@ - tox-linters: vars: tox_install_bindep: false - - tox-py35: + - tox-py36: irrelevant-files: - zuul/cmd/migrate.py - playbooks/zuul-migrate/.* - nodeset: ubuntu-xenial + nodeset: ubuntu-bionic timeout: 4800 # 80 minutes vars: &zuul_tox_vars test_setup_environment: @@ -264,7 +264,6 @@ success-url: 'npm/reports/bundle.html' files: - web/.* - - zuul-stream-functional-2.7 - zuul-stream-functional-2.8 - zuul-stream-functional-2.9 - zuul-tox-remote: @@ -282,11 +281,11 @@ - tox-linters: vars: tox_install_bindep: false - - tox-py35: + - tox-py36: irrelevant-files: - zuul/cmd/migrate.py - playbooks/zuul-migrate/.* - nodeset: ubuntu-xenial + nodeset: ubuntu-bionic timeout: 4800 # 80 minutes vars: *zuul_tox_vars - tox-py38: @@ -306,7 +305,6 @@ success-url: 'npm/reports/bundle.html' files: - web/.* - - zuul-stream-functional-2.7 - zuul-stream-functional-2.8 - zuul-stream-functional-2.9 - zuul-tox-remote: diff --git a/doc/source/reference/config.rst b/doc/source/reference/config.rst index e91001f6f..036980ca8 100644 --- a/doc/source/reference/config.rst +++ b/doc/source/reference/config.rst @@ -77,6 +77,10 @@ example using number prefixes in file's names:: * zuul.d/01_jobs.yaml * zuul.d/02_jobs-variants.yaml +Note subdirectories are traversed. Any subdirectories with a +``.zuul.ignore`` file will be pruned and ignored (this is facilitates +keeping playbooks or roles in the config directory, if required). + Below are references to the different configuration items you may use within the YAML files: diff --git a/doc/source/reference/drivers/mqtt.rst b/doc/source/reference/drivers/mqtt.rst index 8aa0911dc..421c7112c 100644 --- a/doc/source/reference/drivers/mqtt.rst +++ b/doc/source/reference/drivers/mqtt.rst @@ -50,6 +50,14 @@ An MQTT report uses this schema: The patchset number. + .. attr:: commit_id + + The commit id number. + + .. attr:: owner + + The owner username of the change. + .. attr:: ref The change reference. @@ -129,10 +137,12 @@ Here is an example of a start message: 'enqueue_time': '1524801093.5689457', 'change': '3', 'patchset': '1', + 'commit_id': '2db20c7fb26adf9ac9936a9e750ced9b4854a964', + 'owner': 'username', 'ref': 'refs/changes/03/3/1', 'zuul_ref': 'Zf8b3d7cd34f54cb396b488226589db8f', 'buildset': { - 'uuid': 'f8b3d7cd34f54cb396b488226589db8f' + 'uuid': 'f8b3d7cd34f54cb396b488226589db8f', 'builds': [{ 'job_name': 'linters', 'voting': True @@ -157,10 +167,12 @@ Here is an example of a success message: 'enqueue_time': '1524801093.5689457', 'change': '3', 'patchset': '1', + 'commit_id': '2db20c7fb26adf9ac9936a9e750ced9b4854a964', + 'owner': 'username', 'ref': 'refs/changes/03/3/1', 'zuul_ref': 'Zf8b3d7cd34f54cb396b488226589db8f', 'buildset': { - 'uuid': 'f8b3d7cd34f54cb396b488226589db8f' + 'uuid': 'f8b3d7cd34f54cb396b488226589db8f', 'builds': [{ 'job_name': 'linters', 'voting': True diff --git a/doc/source/reference/job_def.rst b/doc/source/reference/job_def.rst index db0dfd94d..ba8c72e94 100644 --- a/doc/source/reference/job_def.rst +++ b/doc/source/reference/job_def.rst @@ -153,6 +153,21 @@ Here is an example of two job definitions: limitation does not apply to jobs in a :term:`config-project`. + .. attr:: intermediate + :default: false + + An intermediate job must be inherited by an abstract job; it can + not be inherited by a final job. All ``intermediate`` jobs + *must* also be ``abstract``; a configuration error will be + raised if not. + + For example, you may define a base abstract job `foo` and create + two abstract jobs that inherit from `foo` called + `foo-production` and `foo-development`. If it would be an error + to accidentally inherit from the base job `foo` instead of + choosing one of the two variants, `foo` could be marked as + ``intermediate``. + .. attr:: success-message :default: SUCCESS diff --git a/doc/source/reference/monitoring.rst b/doc/source/reference/monitoring.rst index ab59f5338..8f9c5299d 100644 --- a/doc/source/reference/monitoring.rst +++ b/doc/source/reference/monitoring.rst @@ -361,6 +361,30 @@ These metrics are emitted by the Zuul :ref:`scheduler`: executor to run on. This should ideally be at zero; persistent higher values indicate more executor resources would be useful. + +.. stat:: zuul.scheduler + + Holds metrics related to the Zuul scheduler. + + .. stat:: eventqueues + + Holds metrics about the event queue lengths in the Zuul scheduler. + + .. stat:: trigger + :type: gauge + + The size of the current trigger event queue. + + .. stat:: result + :type: gauge + + The size of the current result event queue. + + .. stat:: management + :type: gauge + + The size of the current management event queue. + .. stat:: zuul.geard Gearman job distribution statistics. Gearman jobs encompass the diff --git a/doc/source/reference/vulnerabilities.rst b/doc/source/reference/vulnerabilities.rst index d41929655..09e719fea 100644 --- a/doc/source/reference/vulnerabilities.rst +++ b/doc/source/reference/vulnerabilities.rst @@ -62,7 +62,7 @@ fingerprints: `key 0xfb2ee15b2f0f12662b68ed9603750dec158e5fa2`_ (details__) .. _`key 0x97ae496fc02dec9fc353b2e748f9961143495829`: ../_static/0x97ae496fc02dec9fc353b2e748f9961143495829.txt -.. __: https://sks-keyservers.net/pks/lookup?op=vindex&search=0x97ae496fc02dec9fc353b2e748f9961143495829&fingerprint=on +.. __: http://pool.sks-keyservers.net:11371/pks/lookup?op=vindex&search=0x97ae496fc02dec9fc353b2e748f9961143495829&fingerprint=on .. _`key 0xfb2ee15b2f0f12662b68ed9603750dec158e5fa2`: ../_static/0xfb2ee15b2f0f12662b68ed9603750dec158e5fa2.txt -.. __: https://sks-keyservers.net/pks/lookup?op=vindex&search=0xfb2ee15b2f0f12662b68ed9603750dec158e5fa2&fingerprint=on +.. __: http://pool.sks-keyservers.net:11371/pks/lookup?op=vindex&search=0xfb2ee15b2f0f12662b68ed9603750dec158e5fa2&fingerprint=on diff --git a/doc/source/tutorials/user.rst b/doc/source/tutorials/user.rst index 1f65bad43..d58bc9f5d 100644 --- a/doc/source/tutorials/user.rst +++ b/doc/source/tutorials/user.rst @@ -1,5 +1,11 @@ User Tutorials ============== +The User tutorials are currently a work in progress. Currently, only admin +related tutorials are available. + .. toctree:: :maxdepth: 1 + + admin + diff --git a/releasenotes/notes/ansible-2.7-4b6504e46c18cc57.yaml b/releasenotes/notes/ansible-2.7-4b6504e46c18cc57.yaml new file mode 100644 index 000000000..5ac517a0e --- /dev/null +++ b/releasenotes/notes/ansible-2.7-4b6504e46c18cc57.yaml @@ -0,0 +1,4 @@ +--- +upgrade: + - | + Support for Ansible 2.7 has been removed. diff --git a/releasenotes/notes/change-missing-build-url-6df030ae3d8793ae.yaml b/releasenotes/notes/change-missing-build-url-6df030ae3d8793ae.yaml new file mode 100644 index 000000000..6a6f0baaa --- /dev/null +++ b/releasenotes/notes/change-missing-build-url-6df030ae3d8793ae.yaml @@ -0,0 +1,7 @@ +--- +upgrade: + - | + Previously the SqlReporter would record the job name in the database in + place of the url if the url was empty. This has now been updated to store + a null in the database for that case. Zuul will automatically run a + database migration to correct old values. diff --git a/releasenotes/notes/config-zuul-ignore-695aafd639d874e0.yaml b/releasenotes/notes/config-zuul-ignore-695aafd639d874e0.yaml new file mode 100644 index 000000000..84436734f --- /dev/null +++ b/releasenotes/notes/config-zuul-ignore-695aafd639d874e0.yaml @@ -0,0 +1,6 @@ +--- +features: + - | + Subdirectories of ``zuul.d`` or ``.zuul.d`` can be ignored when + reading configuration by placing a ``.zuul.ignore`` file inside + them. diff --git a/releasenotes/notes/intermediate-jobs-101e04e7e1497af9.yaml b/releasenotes/notes/intermediate-jobs-101e04e7e1497af9.yaml new file mode 100644 index 000000000..3a1b4c6f6 --- /dev/null +++ b/releasenotes/notes/intermediate-jobs-101e04e7e1497af9.yaml @@ -0,0 +1,5 @@ +--- +features: + - Jobs may specify the new ``intermediate`` flag to note they may only + be inherited by abstract jobs. This can be useful if building a job + hierarchy where wish to limit where a base job is instantiated. diff --git a/releasenotes/notes/python35-fd8e7f7f577b55e5.yaml b/releasenotes/notes/python35-fd8e7f7f577b55e5.yaml new file mode 100644 index 000000000..617532a9f --- /dev/null +++ b/releasenotes/notes/python35-fd8e7f7f577b55e5.yaml @@ -0,0 +1,4 @@ +--- +upgrade: + - | + Support for running Zuul under Python 3.5 has been dropped. diff --git a/requirements.txt b/requirements.txt index 20778a1fd..313790c18 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,7 +6,7 @@ virtualenv!=20.0.0,!=20.0.1,>20 python-dateutil github3.py>=1.1.0 -PyYAML>=3.1.0 +PyYAML>=5.1.0 paramiko>=2.0.1 GitPython>=2.1.8 python-daemon>=2.0.4 @@ -6,7 +6,7 @@ description-file = author = Zuul Team author-email = zuul-discuss@lists.zuul-ci.org home-page = https://zuul-ci.org/ -python-requires = >=3.5 +python-requires = >=3.6 classifier = Intended Audience :: Information Technology Intended Audience :: System Administrators @@ -14,7 +14,6 @@ classifier = Operating System :: POSIX :: Linux Programming Language :: Python Programming Language :: Python :: 3 - Programming Language :: Python :: 3.5 Programming Language :: Python :: 3.6 Programming Language :: Python :: 3.7 Programming Language :: Python :: 3.8 diff --git a/tests/base.py b/tests/base.py index 2811db7ce..94146ebff 100644 --- a/tests/base.py +++ b/tests/base.py @@ -1628,6 +1628,12 @@ class FakeGitlabConnection(gitlabconnection.GitlabConnection): } return (name, data) + @contextmanager + def enable_community_edition(self): + self.gl_client.community_edition = True + yield + self.gl_client.community_edition = False + class FakeGitlabAPIClient(gitlabconnection.GitlabAPIClient): log = logging.getLogger("zuul.test.FakeGitlabAPIClient") @@ -1635,6 +1641,7 @@ class FakeGitlabAPIClient(gitlabconnection.GitlabAPIClient): def __init__(self, baseurl, api_token, merge_requests_db={}): super(FakeGitlabAPIClient, self).__init__(baseurl, api_token) self.merge_requests = merge_requests_db + self.community_edition = False def gen_error(self, verb): return { @@ -1661,10 +1668,19 @@ class FakeGitlabAPIClient(gitlabconnection.GitlabAPIClient): 'title': mr.subject, 'state': mr.state, 'description': mr.description, + 'author': { + 'name': 'Administrator', + 'username': 'admin' + }, 'updated_at': mr.updated_at.strftime('%Y-%m-%dT%H:%M:%S.%fZ'), 'sha': mr.sha, 'labels': mr.labels, 'merged_at': mr.merged_at, + 'diff_refs': { + 'base_sha': 'c380d3acebd181f13629a25d2e2acca46ffe1e00', + 'head_sha': '2be7ddb704c7b6b83732fdd5b9f09d5a397b5f8f', + 'start_sha': 'c380d3acebd181f13629a25d2e2acca46ffe1e00' + }, 'merge_status': mr.merge_status, }, 200, "", "GET" @@ -1676,9 +1692,14 @@ class FakeGitlabAPIClient(gitlabconnection.GitlabAPIClient): r'.+/projects/(.+)/merge_requests/(\d+)/approvals$', url) if match: mr = self._get_mr(match) - return { - 'approvals_left': 0 if mr.approved else 1, - }, 200, "", "GET" + if not self.community_edition: + return { + 'approvals_left': 0 if mr.approved else 1, + }, 200, "", "GET" + else: + return { + 'approved': mr.approved, + }, 200, "", "GET" def post(self, url, params=None, zuul_event_id=None): @@ -1693,7 +1714,11 @@ class FakeGitlabAPIClient(gitlabconnection.GitlabAPIClient): match = re.match( r'.+/projects/(.+)/merge_requests/(\d+)/approve$', url) if match: + assert 'sha' in params mr = self._get_mr(match) + if params['sha'] != mr.sha: + return {'message': 'SHA does not match HEAD of source ' + 'branch: <new_sha>'}, 409, "", "POST" mr.approved = True match = re.match( @@ -1909,7 +1934,8 @@ class FakeGithubPullRequest(object): def __init__(self, github, number, project, branch, subject, upstream_root, files=[], number_of_commits=1, - writers=[], body=None, body_text=None, draft=False): + writers=[], body=None, body_text=None, draft=False, + base_sha=None): """Creates a new PR with several commits. Sends an event about opened PR.""" self.github = github @@ -1936,7 +1962,7 @@ class FakeGithubPullRequest(object): self.merge_message = None self.state = 'open' self.url = 'https://%s/%s/pull/%s' % (github.server, project, number) - self._createPRRef() + self._createPRRef(base_sha=base_sha) self._addCommitToRepo(files=files) self._updateTimeStamp() @@ -2103,10 +2129,11 @@ class FakeGithubPullRequest(object): repo_path = os.path.join(self.upstream_root, self.project) return git.Repo(repo_path) - def _createPRRef(self): + def _createPRRef(self, base_sha=None): + base_sha = base_sha or 'refs/tags/init' repo = self._getRepo() GithubChangeReference.create( - repo, self.getPRReference(), 'refs/tags/init') + repo, self.getPRReference(), base_sha) def _addCommitToRepo(self, files=[], reset=False): repo = self._getRepo() @@ -2359,11 +2386,13 @@ class FakeGithubConnection(githubconnection.GithubConnection): self.zuul_web_port = port def openFakePullRequest(self, project, branch, subject, files=[], - body=None, body_text=None, draft=False): + body=None, body_text=None, draft=False, + base_sha=None): self.pr_number += 1 pull_request = FakeGithubPullRequest( self, self.pr_number, project, branch, subject, self.upstream_root, - files=files, body=body, body_text=body_text, draft=draft) + files=files, body=body, body_text=body_text, draft=draft, + base_sha=base_sha) self.pull_requests[self.pr_number] = pull_request return pull_request diff --git a/tests/fake_graphql.py b/tests/fake_graphql.py index 9e86f81ad..6c7567910 100644 --- a/tests/fake_graphql.py +++ b/tests/fake_graphql.py @@ -72,9 +72,14 @@ class FakeBranchProtectionRules(ObjectType): return parent.values() +class FakeActor(ObjectType): + login = String() + + class FakeStatusContext(ObjectType): state = String() context = String() + creator = Field(FakeActor) def resolve_state(parent, info): state = parent.state.upper() @@ -83,6 +88,9 @@ class FakeStatusContext(ObjectType): def resolve_context(parent, info): return parent.context + def resolve_creator(parent, info): + return parent.creator + class FakeStatus(ObjectType): contexts = List(FakeStatusContext) @@ -99,7 +107,9 @@ class FakeCheckRun(ObjectType): return parent.name def resolve_conclusion(parent, info): - return parent.conclusion.upper() + if parent.conclusion: + return parent.conclusion.upper() + return None class FakeCheckRuns(ObjectType): @@ -109,11 +119,28 @@ class FakeCheckRuns(ObjectType): return parent +class FakeApp(ObjectType): + slug = String() + name = String() + + class FakeCheckSuite(ObjectType): + app = Field(FakeApp) checkRuns = Field(FakeCheckRuns, first=Int()) + def resolve_app(parent, info): + if not parent: + return None + return parent[0].app + def resolve_checkRuns(parent, info, first=None): - return parent + # We only want to return the latest result for a check run per app. + # Since the check runs are ordered from latest to oldest result we + # need to traverse the list in reverse order. + check_runs_by_name = { + "{}:{}".format(cr.app, cr.name): cr for cr in reversed(parent) + } + return check_runs_by_name.values() class FakeCheckSuites(ObjectType): diff --git a/tests/fakegithub.py b/tests/fakegithub.py index 89b3ec73a..8e2f275eb 100644 --- a/tests/fakegithub.py +++ b/tests/fakegithub.py @@ -65,13 +65,18 @@ class FakeBranch(object): } +class FakeCreator: + def __init__(self, login): + self.login = login + + class FakeStatus(object): def __init__(self, state, url, description, context, user): self.state = state self.context = context + self.creator = FakeCreator(user) self._url = url self._description = description - self._user = user def as_dict(self): return { @@ -80,11 +85,17 @@ class FakeStatus(object): 'description': self._description, 'context': self.context, 'creator': { - 'login': self._user + 'login': self.creator.login } } +class FakeApp: + def __init__(self, name, slug): + self.name = name + self.slug = slug + + class FakeCheckRun(object): def __init__(self, name, details_url, output, status, conclusion, completed_at, external_id, actions, app): @@ -98,7 +109,7 @@ class FakeCheckRun(object): self.completed_at = completed_at self.external_id = external_id self.actions = actions - self.app = app + self.app = FakeApp(name=app, slug=app) # Github automatically sets the status to "completed" if a conclusion # is provided. @@ -118,7 +129,8 @@ class FakeCheckRun(object): "external_id": self.external_id, "actions": self.actions, "app": { - "slug": self.app, + "slug": self.app.slug, + "name": self.app.name, }, } @@ -543,6 +555,9 @@ class FakePull(object): }, 'ref': pr.branch, }, + 'user': { + 'login': 'octocat' + }, 'draft': pr.draft, 'mergeable': True, 'state': pr.state, @@ -719,6 +734,13 @@ class FakeGithubClient(object): def pull_request(self, owner, project, number): fake_pr = self._data.pull_requests[int(number)] + repo = self.repository(owner, project) + # Ensure a commit for the head_sha exists so this can be resolved in + # graphql queries. + repo._commits.setdefault( + fake_pr.head_sha, + FakeCommit(fake_pr.head_sha) + ) return FakePull(fake_pr) def search_issues(self, query): @@ -735,9 +757,24 @@ class FakeGithubClient(object): return re.match(r'[a-z0-9]{40}', s) if query_is_sha(query): - return (FakeIssueSearchResult(FakeIssue(pr)) - for pr in self._data.pull_requests.values() - if pr.head_sha == query) + # Github returns all PR's that contain the sha in their history + result = [] + for pr in self._data.pull_requests.values(): + # Quick check if head sha matches + if pr.head_sha == query: + result.append(FakeIssueSearchResult(FakeIssue(pr))) + continue + + # If head sha doesn't match it still could be in the pr history + repo = pr._getRepo() + commits = repo.iter_commits( + '%s...%s' % (pr.branch, pr.head_sha)) + for commit in commits: + if commit.hexsha == query: + result.append(FakeIssueSearchResult(FakeIssue(pr))) + continue + + return result # Non-SHA queries are of the form: # diff --git a/tests/fixtures/config/ansible-versions/git/common-config/zuul.yaml b/tests/fixtures/config/ansible-versions/git/common-config/zuul.yaml index 488e41a5c..8cef613a4 100644 --- a/tests/fixtures/config/ansible-versions/git/common-config/zuul.yaml +++ b/tests/fixtures/config/ansible-versions/git/common-config/zuul.yaml @@ -34,15 +34,7 @@ parent: ansible-version vars: test_ansible_version_major: 2 - test_ansible_version_minor: 7 - -- job: - name: ansible-27 - parent: ansible-version - ansible-version: 2.7 - vars: - test_ansible_version_major: 2 - test_ansible_version_minor: 7 + test_ansible_version_minor: 8 - job: name: ansible-28 @@ -66,7 +58,6 @@ check: jobs: - ansible-default - - ansible-27 - ansible-28 - ansible-29 @@ -75,6 +66,5 @@ check: jobs: - ansible-default-zuul-conf - - ansible-27 - ansible-28 - ansible-29 diff --git a/tests/fixtures/config/intermediate/git/common-config/playbooks/base.yaml b/tests/fixtures/config/intermediate/git/common-config/playbooks/base.yaml new file mode 100644 index 000000000..f679dceae --- /dev/null +++ b/tests/fixtures/config/intermediate/git/common-config/playbooks/base.yaml @@ -0,0 +1,2 @@ +- hosts: all + tasks: [] diff --git a/tests/fixtures/config/intermediate/git/common-config/zuul.yaml b/tests/fixtures/config/intermediate/git/common-config/zuul.yaml new file mode 100644 index 000000000..6c787c91d --- /dev/null +++ b/tests/fixtures/config/intermediate/git/common-config/zuul.yaml @@ -0,0 +1,50 @@ +- pipeline: + name: check + manager: independent + trigger: + gerrit: + - event: patchset-created + success: + gerrit: + Verified: 1 + failure: + gerrit: + Verified: -1 + +- job: + name: base + parent: null + run: playbooks/base.yaml + +- job: + name: job-abstract-intermediate + abstract: true + intermediate: true + +- job: + name: job-abstract + abstract: true + parent: job-abstract-intermediate + +# an intermediate, with an intermediate parent also +- job: + name: job-another-intermediate + parent: job-abstract-intermediate + abstract: true + intermediate: true + +- job: + name: job-another-abstract + parent: job-another-intermediate + abstract: true + +- job: + name: job-actual + parent: job-another-abstract + run: playbooks/base.yaml + +- project: + name: org/project + check: + jobs: [] + diff --git a/tests/fixtures/config/intermediate/git/org_project/README b/tests/fixtures/config/intermediate/git/org_project/README new file mode 100644 index 000000000..9daeafb98 --- /dev/null +++ b/tests/fixtures/config/intermediate/git/org_project/README @@ -0,0 +1 @@ +test diff --git a/tests/fixtures/config/intermediate/main.yaml b/tests/fixtures/config/intermediate/main.yaml new file mode 100644 index 000000000..208e274b1 --- /dev/null +++ b/tests/fixtures/config/intermediate/main.yaml @@ -0,0 +1,8 @@ +- tenant: + name: tenant-one + source: + gerrit: + config-projects: + - common-config + untrusted-projects: + - org/project diff --git a/tests/fixtures/config/inventory/git/common-config/zuul.yaml b/tests/fixtures/config/inventory/git/common-config/zuul.yaml index 42c933201..32f64c592 100644 --- a/tests/fixtures/config/inventory/git/common-config/zuul.yaml +++ b/tests/fixtures/config/inventory/git/common-config/zuul.yaml @@ -100,15 +100,6 @@ run: playbooks/jinja2-message.yaml - job: - name: ansible-version27-inventory - nodeset: - nodes: - - name: ubuntu-xenial - label: ubuntu-xenial - ansible-version: '2.7' - run: playbooks/ansible-version.yaml - -- job: name: ansible-version28-inventory nodeset: nodes: diff --git a/tests/fixtures/config/inventory/git/org_project/.zuul.yaml b/tests/fixtures/config/inventory/git/org_project/.zuul.yaml index 83961d75a..fd615518d 100644 --- a/tests/fixtures/config/inventory/git/org_project/.zuul.yaml +++ b/tests/fixtures/config/inventory/git/org_project/.zuul.yaml @@ -7,5 +7,4 @@ - executor-only-inventory - group-inventory - hostvars-inventory - - ansible-version27-inventory - ansible-version28-inventory diff --git a/tests/fixtures/config/inventory/git/org_project3/.zuul.yaml b/tests/fixtures/config/inventory/git/org_project3/.zuul.yaml index ac09fd329..6cf9faf88 100644 --- a/tests/fixtures/config/inventory/git/org_project3/.zuul.yaml +++ b/tests/fixtures/config/inventory/git/org_project3/.zuul.yaml @@ -6,5 +6,4 @@ - executor-only-inventory - group-inventory - hostvars-inventory - - ansible-version27-inventory - ansible-version28-inventory diff --git a/tests/fixtures/config/remote-action-modules/git/org_project/playbooks/uri-bad-dest.yaml b/tests/fixtures/config/remote-action-modules/git/org_project/playbooks/uri-bad-dest.yaml new file mode 100644 index 000000000..6c151842a --- /dev/null +++ b/tests/fixtures/config/remote-action-modules/git/org_project/playbooks/uri-bad-dest.yaml @@ -0,0 +1,6 @@ +- hosts: localhost + tasks: + - name: Request with bad src + uri: + url: https://zuul.opendev.org + dest: /etc/zuul-uri-output-testing diff --git a/tests/fixtures/config/remote-action-modules/git/org_project/playbooks/uri-bad-src.yaml b/tests/fixtures/config/remote-action-modules/git/org_project/playbooks/uri-bad-src.yaml new file mode 100644 index 000000000..9a9460c9b --- /dev/null +++ b/tests/fixtures/config/remote-action-modules/git/org_project/playbooks/uri-bad-src.yaml @@ -0,0 +1,7 @@ +- hosts: localhost + tasks: + - name: Request with bad src + uri: + url: https://zuul.opendev.org + method: POST + src: /etc/resolv.conf diff --git a/tests/fixtures/config/remote-action-modules/git/org_project/playbooks/uri-bad-url.yaml b/tests/fixtures/config/remote-action-modules/git/org_project/playbooks/uri-bad-url.yaml new file mode 100644 index 000000000..222f1925a --- /dev/null +++ b/tests/fixtures/config/remote-action-modules/git/org_project/playbooks/uri-bad-url.yaml @@ -0,0 +1,14 @@ +- hosts: localhost + tasks: + - name: Request with bad url scheme + uri: + url: "file:///etc/resolv.conf" + dest: "{{ zuul.executor.log_root }}/resolv.conf" + - name: stat file that shouldnt exist + stat: + path: "{{ zuul.executor.log_root }}/resolv.conf" + register: test_stat + - name: Debug the stat + debug: + msg: "resolv.conf exists when it shouldn't" + when: test_stat.stat.exists diff --git a/tests/fixtures/config/remote-action-modules/git/org_project/playbooks/uri-good.yaml b/tests/fixtures/config/remote-action-modules/git/org_project/playbooks/uri-good.yaml new file mode 100644 index 000000000..0416cef4e --- /dev/null +++ b/tests/fixtures/config/remote-action-modules/git/org_project/playbooks/uri-good.yaml @@ -0,0 +1,16 @@ +- hosts: localhost + tasks: + - name: Safe uri request from localhost + uri: + # We don't seem to have working ssl cert chains in + # the test bwrap context. Use http to workaround that + # and don't follow the redirect to https. + url: http://zuul.opendev.org + follow_redirects: none + return_content: yes + status_code: + - 301 + - 302 + - 303 + - 307 + - 308 diff --git a/tests/fixtures/config/split-config/git/org_project1/.zuul.d/playbooks/.zuul.ignore b/tests/fixtures/config/split-config/git/org_project1/.zuul.d/playbooks/.zuul.ignore new file mode 100644 index 000000000..e69de29bb --- /dev/null +++ b/tests/fixtures/config/split-config/git/org_project1/.zuul.d/playbooks/.zuul.ignore diff --git a/tests/fixtures/config/split-config/git/org_project1/.zuul.d/playbooks/main.yaml b/tests/fixtures/config/split-config/git/org_project1/.zuul.d/playbooks/main.yaml new file mode 100644 index 000000000..b136e8444 --- /dev/null +++ b/tests/fixtures/config/split-config/git/org_project1/.zuul.d/playbooks/main.yaml @@ -0,0 +1,8 @@ +# This is a playbook in the configuration directory which should be +# ignored because the directory has a .zuul.ignore +- hosts: all + tasks: + + - name: foo + debug: + msg: blah diff --git a/tests/fixtures/zuul-default-ansible-version.conf b/tests/fixtures/zuul-default-ansible-version.conf index caecb38ef..ff15a0fed 100644 --- a/tests/fixtures/zuul-default-ansible-version.conf +++ b/tests/fixtures/zuul-default-ansible-version.conf @@ -9,7 +9,7 @@ server=127.0.0.1 [scheduler] tenant_config=main.yaml relative_priority=true -default_ansible_version=2.7 +default_ansible_version=2.8 [merger] git_dir=/tmp/zuul-test/merger-git diff --git a/tests/remote/test_remote_action_modules.py b/tests/remote/test_remote_action_modules.py index 23833f174..a147a9e5d 100644 --- a/tests/remote/test_remote_action_modules.py +++ b/tests/remote/test_remote_action_modules.py @@ -22,6 +22,7 @@ ERROR_LOCAL_CODE = "Executing local code is prohibited" ERROR_SYNC_TO_OUTSIDE = "Syncing files to outside the working dir" ERROR_SYNC_FROM_OUTSIDE = "Syncing files from outside the working dir" ERROR_SYNC_RSH = "Using custom synchronize rsh is prohibited" +ERROR_SCHEME_INVALID = "file urls are not allowed from localhost." class FunctionalActionModulesMixIn: @@ -221,13 +222,11 @@ class FunctionalActionModulesMixIn: self._run_job('known-hosts-bad', 'FAILURE', ERROR_ACCESS_OUTSIDE) - -class TestActionModules27(AnsibleZuulTestCase, FunctionalActionModulesMixIn): - ansible_version = '2.7' - - def setUp(self): - super().setUp() - self._setUp() + def test_uri_module(self): + self._run_job('uri-good', 'SUCCESS') + self._run_job('uri-bad-src', 'FAILURE', ERROR_ACCESS_OUTSIDE) + self._run_job('uri-bad-dest', 'FAILURE', ERROR_ACCESS_OUTSIDE) + self._run_job('uri-bad-url', 'FAILURE', ERROR_SCHEME_INVALID) class TestActionModules28(AnsibleZuulTestCase, FunctionalActionModulesMixIn): diff --git a/tests/remote/test_remote_zuul_json.py b/tests/remote/test_remote_zuul_json.py index e93f3f119..688894190 100644 --- a/tests/remote/test_remote_zuul_json.py +++ b/tests/remote/test_remote_zuul_json.py @@ -91,8 +91,11 @@ class FunctionalZuulJSONMixIn: text = self._get_json_as_text(build) json_result = json.loads(text) tasks = json_result[0]['plays'][0]['tasks'] + # NOTE(pabelanger): In 2.8 gather_facts are now logged as an + # expected action. expected_actions = [ - 'debug', 'debug', 'debug', 'copy', 'find', 'stat', 'debug' + 'gather_facts', 'debug', 'debug', 'debug', 'copy', 'find', + 'stat', 'debug' ] for i, expected in enumerate(expected_actions): host_result = tasks[i]['hosts']['controller'] @@ -108,10 +111,12 @@ class FunctionalZuulJSONMixIn: self.assertIn('json-role', text) json_result = json.loads(text) - role_name = json_result[0]['plays'][0]['tasks'][0]['role']['name'] + # NOTE(pabelanger): In 2.8 gather_facts are now logged as the + # first task. + role_name = json_result[0]['plays'][0]['tasks'][1]['role']['name'] self.assertEqual('json-role', role_name) - role_path = json_result[0]['plays'][0]['tasks'][0]['role']['path'] + role_path = json_result[0]['plays'][0]['tasks'][1]['role']['path'] self.assertEqual('json-role', os.path.basename(role_path)) def test_json_time_log(self): @@ -143,58 +148,17 @@ class FunctionalZuulJSONMixIn: dateutil.parser.parse(play_end_time) -class TestZuulJSON27(AnsibleZuulTestCase, FunctionalZuulJSONMixIn): - ansible_version = '2.7' +class TestZuulJSON28(AnsibleZuulTestCase, FunctionalZuulJSONMixIn): + ansible_version = '2.8' def setUp(self): super().setUp() self._setUp() -class TestZuulJSON28(AnsibleZuulTestCase, FunctionalZuulJSONMixIn): - ansible_version = '2.8' +class TestZuulJSON29(AnsibleZuulTestCase, FunctionalZuulJSONMixIn): + ansible_version = '2.9' def setUp(self): super().setUp() self._setUp() - - def test_json_task_action(self): - job = self._run_job('no-log') - with self.jobLog(job): - build = self.history[-1] - self.assertEqual(build.result, 'SUCCESS') - - text = self._get_json_as_text(build) - json_result = json.loads(text) - tasks = json_result[0]['plays'][0]['tasks'] - # NOTE(pabelanger): In 2.8 gather_facts are now logged as an - # expected action. - expected_actions = [ - 'gather_facts', 'debug', 'debug', 'debug', 'copy', 'find', - 'stat', 'debug' - ] - for i, expected in enumerate(expected_actions): - host_result = tasks[i]['hosts']['controller'] - self.assertEquals(expected, host_result['action']) - - def test_json_role_log(self): - job = self._run_job('json-role') - with self.jobLog(job): - build = self.history[-1] - self.assertEqual(build.result, 'SUCCESS') - - text = self._get_json_as_text(build) - self.assertIn('json-role', text) - - json_result = json.loads(text) - # NOTE(pabelanger): In 2.8 gather_facts are now logged as the - # first task. - role_name = json_result[0]['plays'][0]['tasks'][1]['role']['name'] - self.assertEqual('json-role', role_name) - - role_path = json_result[0]['plays'][0]['tasks'][1]['role']['path'] - self.assertEqual('json-role', os.path.basename(role_path)) - - -class TestZuulJSON29(TestZuulJSON28): - ansible_version = '2.9' diff --git a/tests/remote/test_remote_zuul_stream.py b/tests/remote/test_remote_zuul_stream.py index 9fef472b5..aed500769 100644 --- a/tests/remote/test_remote_zuul_stream.py +++ b/tests/remote/test_remote_zuul_stream.py @@ -158,9 +158,11 @@ class FunctionalZuulStreamMixIn: self.assertLogLine( r'controller \| ok: Runtime: \d:\d\d:\d\d\.\d\d\d\d\d\d', text) self.assertLogLine('PLAY RECAP', text) + # NOTE(pabelanger): Ansible 2.8 added new stats + # skipped, rescued, ignored. self.assertLogLine( - r'controller \| ok: \d+ changed: \d+ unreachable: 0 failed: 1', - text) + r'controller \| ok: \d+ changed: \d+ unreachable: 0 failed: 0 ' + 'skipped: 0 rescued: 1 ignored: 0', text) self.assertLogLine( r'RUN END RESULT_NORMAL: \[untrusted : review.example.com/' r'org/project/playbooks/command.yaml@master]', text) @@ -210,103 +212,17 @@ class FunctionalZuulStreamMixIn: self.assertLogLine(regex, text) -class TestZuulStream27(AnsibleZuulTestCase, FunctionalZuulStreamMixIn): - ansible_version = '2.7' +class TestZuulStream28(AnsibleZuulTestCase, FunctionalZuulStreamMixIn): + ansible_version = '2.8' def setUp(self): super().setUp() self._setUp() -class TestZuulStream28(AnsibleZuulTestCase, FunctionalZuulStreamMixIn): - ansible_version = '2.8' +class TestZuulStream29(AnsibleZuulTestCase, FunctionalZuulStreamMixIn): + ansible_version = '2.9' def setUp(self): super().setUp() self._setUp() - - def test_command(self): - job = self._run_job('command') - with self.jobLog(job): - build = self.history[-1] - self.assertEqual(build.result, 'SUCCESS') - - text = self._get_job_output(build) - self.assertLogLine( - r'RUN START: \[untrusted : review.example.com/org/project/' - r'playbooks/command.yaml@master\]', text) - self.assertLogLine(r'PLAY \[all\]', text) - self.assertLogLine( - r'Ansible version={}'.format(self.ansible_version), text) - self.assertLogLine(r'TASK \[Show contents of first file\]', text) - self.assertLogLine(r'controller \| command test one', text) - self.assertLogLine( - r'controller \| ok: Runtime: \d:\d\d:\d\d\.\d\d\d\d\d\d', text) - self.assertLogLine(r'TASK \[Show contents of second file\]', text) - self.assertLogLine(r'compute1 \| command test two', text) - self.assertLogLine(r'controller \| command test two', text) - self.assertLogLine(r'compute1 \| This is a rescue task', text) - self.assertLogLine(r'controller \| This is a rescue task', text) - self.assertLogLine(r'compute1 \| This is an always task', text) - self.assertLogLine(r'controller \| This is an always task', text) - self.assertLogLine(r'compute1 \| This is a handler', text) - self.assertLogLine(r'controller \| This is a handler', text) - self.assertLogLine(r'controller \| First free task', text) - self.assertLogLine(r'controller \| Second free task', text) - self.assertLogLine(r'controller \| This is a shell task after an ' - 'included role', text) - self.assertLogLine(r'compute1 \| This is a shell task after an ' - 'included role', text) - self.assertLogLine(r'controller \| This is a command task after ' - 'an included role', text) - self.assertLogLine(r'compute1 \| This is a command task after an ' - 'included role', text) - self.assertLogLine(r'controller \| This is a shell task with ' - 'delegate compute1', text) - self.assertLogLine(r'controller \| This is a shell task with ' - 'delegate controller', text) - self.assertLogLine(r'compute1 \| item_in_loop1', text) - self.assertLogLine(r'compute1 \| ok: Item: item_in_loop1 ' - r'Runtime: \d:\d\d:\d\d\.\d\d\d\d\d\d', text) - self.assertLogLine(r'compute1 \| item_in_loop2', text) - self.assertLogLine(r'compute1 \| ok: Item: item_in_loop2 ' - r'Runtime: \d:\d\d:\d\d\.\d\d\d\d\d\d', text) - self.assertLogLine(r'compute1 \| failed_in_loop1', text) - self.assertLogLine(r'compute1 \| ok: Item: failed_in_loop1 ' - r'Result: 1', text) - self.assertLogLine(r'compute1 \| failed_in_loop2', text) - self.assertLogLine(r'compute1 \| ok: Item: failed_in_loop2 ' - r'Result: 1', text) - self.assertLogLine(r'compute1 \| .*No such file or directory: .*' - r'\'/remote-shelltask/somewhere/' - r'that/does/not/exist\'', text) - self.assertLogLine(r'controller \| .*No such file or directory: .*' - r'\'/remote-shelltask/somewhere/' - r'that/does/not/exist\'', text) - self.assertLogLine( - r'controller \| ok: Runtime: \d:\d\d:\d\d\.\d\d\d\d\d\d', text) - self.assertLogLine('PLAY RECAP', text) - # NOTE(pabelanger): Ansible 2.8 added new stats - # skipped, rescued, ignored. - self.assertLogLine( - r'controller \| ok: \d+ changed: \d+ unreachable: 0 failed: 0 ' - 'skipped: 0 rescued: 1 ignored: 0', text) - self.assertLogLine( - r'RUN END RESULT_NORMAL: \[untrusted : review.example.com/' - r'org/project/playbooks/command.yaml@master]', text) - - # Run a pre-defined job that is defined in a trusted repo to test - # localhost tasks. - job = self._run_job('command-localhost', create=False) - with self.jobLog(job): - build = self.history[-1] - self.assertEqual(build.result, 'SUCCESS') - - text = self._get_job_output(build) - self.assertLogLine(r'localhost \| .*No such file or directory: .*' - r'\'/local-shelltask/somewhere/' - r'that/does/not/exist\'', text) - - -class TestZuulStream29(TestZuulStream28): - ansible_version = '2.9' diff --git a/tests/unit/test_configloader.py b/tests/unit/test_configloader.py index cf156e156..9dbba9e60 100644 --- a/tests/unit/test_configloader.py +++ b/tests/unit/test_configloader.py @@ -397,6 +397,11 @@ class TestSplitConfig(ZuulTestCase): self.assertIn('project1-project2-integration', project1_config[0].pipelines['check'].job_list.jobs) + # This check ensures the .zuul.ignore flag file is working in + # the config directory. + self.assertEquals( + len(tenant.layout.loading_errors), 0) + def test_dynamic_split_config(self): in_repo_conf = textwrap.dedent( """ diff --git a/tests/unit/test_connection.py b/tests/unit/test_connection.py index f64de8c7c..ebabb6a0b 100644 --- a/tests/unit/test_connection.py +++ b/tests/unit/test_connection.py @@ -555,6 +555,8 @@ class TestMQTTConnection(ZuulTestCase): 'tenant-one/zuul_start/check/org/project/master') mqtt_payload = start_event['msg'] self.assertEquals(mqtt_payload['project'], 'org/project') + self.assertEqual(len(mqtt_payload['commit_id']), 40) + self.assertEquals(mqtt_payload['owner'], 'username') self.assertEquals(mqtt_payload['branch'], 'master') self.assertEquals(mqtt_payload['buildset']['result'], None) self.assertEquals(mqtt_payload['buildset']['builds'][0]['job_name'], diff --git a/tests/unit/test_github_requirements.py b/tests/unit/test_github_requirements.py index e542f80f9..bde2c5ca8 100644 --- a/tests/unit/test_github_requirements.py +++ b/tests/unit/test_github_requirements.py @@ -92,6 +92,11 @@ class TestGithubRequirements(ZuulTestCase): project = 'org/project2' A = self.fake_github.openFakePullRequest(project, 'master', 'A') + # Create second PR which contains the head of A in its history. Zuul + # should not get disturbed by the existence of this one. + self.fake_github.openFakePullRequest( + project, 'master', 'A', base_sha=A.head_sha) + # An error status should not cause it to be enqueued self.fake_github.setCommitStatus(project, A.head_sha, 'error', context='tenant-one/check') diff --git a/tests/unit/test_gitlab_driver.py b/tests/unit/test_gitlab_driver.py index 11edaf0c4..c9a08c56b 100644 --- a/tests/unit/test_gitlab_driver.py +++ b/tests/unit/test_gitlab_driver.py @@ -162,6 +162,27 @@ class TestGitlabDriver(ZuulTestCase): self.assertEqual('check-approval', zuulvars['pipeline']) @simple_layout('layouts/basic-gitlab.yaml', driver='gitlab') + def test_merge_request_updated_during_build(self): + + A = self.fake_gitlab.openFakeMergeRequest('org/project', 'master', 'A') + self.fake_gitlab.emitEvent(A.getMergeRequestOpenedEvent()) + old = A.sha + A.addCommit() + new = A.sha + self.assertNotEqual(old, new) + self.waitUntilSettled() + + self.assertEqual(2, len(self.history)) + # MR must not be approved: tested commit isn't current commit + self.assertFalse(A.approved) + + self.fake_gitlab.emitEvent(A.getMergeRequestUpdatedEvent()) + self.waitUntilSettled() + + self.assertEqual(4, len(self.history)) + self.assertTrue(A.approved) + + @simple_layout('layouts/basic-gitlab.yaml', driver='gitlab') def test_merge_request_labeled(self): A = self.fake_gitlab.openFakeMergeRequest('org/project', 'master', 'A') @@ -560,6 +581,29 @@ class TestGitlabDriver(ZuulTestCase): self.assertEqual(1, len(self.history)) @simple_layout('layouts/requirements-gitlab.yaml', driver='gitlab') + def test_approval_require_community_edition(self): + + with self.fake_gitlab.enable_community_edition(): + A = self.fake_gitlab.openFakeMergeRequest( + 'org/project2', 'master', 'A') + + self.fake_gitlab.emitEvent(A.getMergeRequestOpenedEvent()) + self.waitUntilSettled() + self.assertEqual(0, len(self.history)) + + A.approved = True + + self.fake_gitlab.emitEvent(A.getMergeRequestUpdatedEvent()) + self.waitUntilSettled() + self.assertEqual(1, len(self.history)) + + A.approved = False + + self.fake_gitlab.emitEvent(A.getMergeRequestUpdatedEvent()) + self.waitUntilSettled() + self.assertEqual(1, len(self.history)) + + @simple_layout('layouts/requirements-gitlab.yaml', driver='gitlab') def test_label_require(self): A = self.fake_gitlab.openFakeMergeRequest( diff --git a/tests/unit/test_inventory.py b/tests/unit/test_inventory.py index af45aebb7..024e438a4 100644 --- a/tests/unit/test_inventory.py +++ b/tests/unit/test_inventory.py @@ -167,30 +167,6 @@ class TestInventoryAutoPython(TestInventoryBase): self.executor_server.release() self.waitUntilSettled() - def test_auto_python_ansible27_inventory(self): - inventory = self._get_build_inventory('ansible-version27-inventory') - - all_nodes = ('ubuntu-xenial',) - self.assertIn('all', inventory) - self.assertIn('hosts', inventory['all']) - self.assertIn('vars', inventory['all']) - for node_name in all_nodes: - self.assertIn(node_name, inventory['all']['hosts']) - node_vars = inventory['all']['hosts'][node_name] - self.assertEqual( - '/usr/bin/python2', node_vars['ansible_python_interpreter']) - - self.assertIn('zuul', inventory['all']['vars']) - z_vars = inventory['all']['vars']['zuul'] - self.assertIn('executor', z_vars) - self.assertIn('src_root', z_vars['executor']) - self.assertIn('job', z_vars) - self.assertEqual(z_vars['job'], 'ansible-version27-inventory') - self.assertEqual(z_vars['message'], 'QQ==') - - self.executor_server.release() - self.waitUntilSettled() - class TestInventory(TestInventoryBase): diff --git a/tests/unit/test_merger_repo.py b/tests/unit/test_merger_repo.py index 28e4b4456..9a626ea56 100644 --- a/tests/unit/test_merger_repo.py +++ b/tests/unit/test_merger_repo.py @@ -731,3 +731,40 @@ class TestMerger(ZuulTestCase): ref_map[foo_zuul_ref].commit.hexsha, merge_state[("gerrit", "org/project", "foo")] ) + + def test_stale_index_lock_cleanup(self): + # Stop the running executor's merger. We needed it running to merge + # things during test boostrapping but now it is just in the way. + self.executor_server.merger_gearworker.stop() + self.executor_server.merger_gearworker.join() + # Start the merger and do a merge to populate the repo on disk + self._startMerger() + + A = self.fake_gerrit.addFakeChange('org/project1', 'master', 'A') + A.addApproval('Code-Review', 2) + self.fake_gerrit.addEvent(A.addApproval('Approved', 1)) + self.waitUntilSettled() + self.assertEqual(A.data['status'], 'MERGED') + + # Stop the merger so we can modify the git repo + self.merge_server.stop() + self.merge_server.join() + + # Add an index.lock file + fpath = os.path.join(self.merger_src_root, 'review.example.com', + 'org', 'project1', '.git', 'index.lock') + with open(fpath, 'w'): + pass + self.assertTrue(os.path.exists(fpath)) + + # Start a new merger and check that we can still merge things + self._startMerger() + + # This will fail if git can't modify the repo due to a stale lock file. + B = self.fake_gerrit.addFakeChange('org/project1', 'master', 'B') + B.addApproval('Code-Review', 2) + self.fake_gerrit.addEvent(B.addApproval('Approved', 1)) + self.waitUntilSettled() + self.assertEqual(B.data['status'], 'MERGED') + + self.assertFalse(os.path.exists(fpath)) diff --git a/tests/unit/test_scheduler.py b/tests/unit/test_scheduler.py index f92206d55..3c9abbe56 100644 --- a/tests/unit/test_scheduler.py +++ b/tests/unit/test_scheduler.py @@ -8365,3 +8365,71 @@ class TestSchedulerSmartReconfiguration(ZuulTestCase): def test_smart_reconfiguration_command_socket(self): "Test that live reconfiguration works using command socket" self._test_smart_reconfiguration(command_socket=True) + + +class TestReconfigureBranch(ZuulTestCase): + + def _setupTenantReconfigureTime(self): + self.old = self.scheds.first.sched.tenant_last_reconfigured\ + .get('tenant-one', 0) + + def _createBranch(self): + self.create_branch('org/project1', 'stable') + self.fake_gerrit.addEvent( + self.fake_gerrit.getFakeBranchCreatedEvent( + 'org/project1', 'stable')) + self.waitUntilSettled() + + def _deleteBranch(self): + self.delete_branch('org/project1', 'stable') + self.fake_gerrit.addEvent( + self.fake_gerrit.getFakeBranchDeletedEvent( + 'org/project1', 'stable')) + self.waitUntilSettled() + + def _expectReconfigure(self, doReconfigure): + new = self.scheds.first.sched.tenant_last_reconfigured\ + .get('tenant-one', 0) + if doReconfigure: + self.assertLess(self.old, new) + else: + self.assertEqual(self.old, new) + self.old = new + + +class TestReconfigureBranchCreateDeleteSshHttp(TestReconfigureBranch): + tenant_config_file = 'config/single-tenant/main.yaml' + config_file = 'zuul-gerrit-web.conf' + + def test_reconfigure_cache_branch_create_delete(self): + "Test that cache is updated clear on branch creation/deletion" + self._setupTenantReconfigureTime() + self._createBranch() + self._expectReconfigure(True) + self._deleteBranch() + self._expectReconfigure(True) + + +class TestReconfigureBranchCreateDeleteSsh(TestReconfigureBranch): + tenant_config_file = 'config/single-tenant/main.yaml' + + def test_reconfigure_cache_branch_create_delete(self): + "Test that cache is updated clear on branch creation/deletion" + self._setupTenantReconfigureTime() + self._createBranch() + self._expectReconfigure(True) + self._deleteBranch() + self._expectReconfigure(True) + + +class TestReconfigureBranchCreateDeleteHttp(TestReconfigureBranch): + tenant_config_file = 'config/single-tenant/main.yaml' + config_file = 'zuul-gerrit-no-stream.conf' + + def test_reconfigure_cache_branch_create_delete(self): + "Test that cache is updated clear on branch creation/deletion" + self._setupTenantReconfigureTime() + self._createBranch() + self._expectReconfigure(True) + self._deleteBranch() + self._expectReconfigure(True) diff --git a/tests/unit/test_v3.py b/tests/unit/test_v3.py index dc8e8f6a1..3f8315e40 100644 --- a/tests/unit/test_v3.py +++ b/tests/unit/test_v3.py @@ -16,9 +16,10 @@ import io import json import logging import os +import sys import textwrap import gc -from unittest import skip +from unittest import skip, skipIf import paramiko @@ -221,6 +222,81 @@ class TestAbstract(ZuulTestCase): self.assertEqual(A.patchsets[-1]['approvals'][0]['value'], '1') +class TestIntermediate(ZuulTestCase): + tenant_config_file = 'config/intermediate/main.yaml' + + def test_intermediate_fail(self): + # you can not instantiate from an intermediate job + in_repo_conf = textwrap.dedent( + """ + - job: + name: job-instantiate-intermediate + parent: job-abstract-intermediate + + - project: + check: + jobs: + - job-instantiate-intermediate + """) + + file_dict = {'zuul.yaml': in_repo_conf} + A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A', + files=file_dict) + self.fake_gerrit.addEvent(A.getPatchsetCreatedEvent(1)) + self.waitUntilSettled() + + self.assertEqual(A.reported, 1) + self.assertEqual(A.patchsets[-1]['approvals'][0]['value'], '-1') + self.assertIn('may only inherit to another abstract job', + A.messages[0]) + + def test_intermediate_config_fail(self): + # an intermediate job must also be abstract + in_repo_conf = textwrap.dedent( + """ + - job: + name: job-intermediate-but-not-abstract + intermediate: true + abstract: false + + - project: + check: + jobs: + - job-intermediate-but-not-abstract + """) + + file_dict = {'zuul.yaml': in_repo_conf} + A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A', + files=file_dict) + self.fake_gerrit.addEvent(A.getPatchsetCreatedEvent(1)) + self.waitUntilSettled() + + self.assertEqual(A.reported, 1) + self.assertEqual(A.patchsets[-1]['approvals'][0]['value'], '-1') + self.assertIn('An intermediate job must also be abstract', + A.messages[0]) + + def test_intermediate_several(self): + # test passing through several intermediate jobs + in_repo_conf = textwrap.dedent( + """ + - project: + name: org/project + check: + jobs: + - job-actual + """) + + file_dict = {'.zuul.yaml': in_repo_conf} + A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A', + files=file_dict) + self.fake_gerrit.addEvent(A.getPatchsetCreatedEvent(1)) + self.waitUntilSettled() + + self.assertEqual(A.reported, 1) + self.assertEqual(A.patchsets[-1]['approvals'][0]['value'], '1') + + class TestFinal(ZuulTestCase): tenant_config_file = 'config/final/main.yaml' @@ -1457,11 +1533,13 @@ class TestInRepoConfig(ZuulTestCase): "A should report failure") self.assertIn('not a list', A.messages[0], "A should have a syntax error reported") + self.assertIn('job: foo', A.messages[0], + "A should display the failing list") def test_yaml_dict_error(self): in_repo_conf = textwrap.dedent( """ - - job + - job_not_a_dict """) file_dict = {'.zuul.yaml': in_repo_conf} @@ -1476,6 +1554,8 @@ class TestInRepoConfig(ZuulTestCase): "A should report failure") self.assertIn('not a dictionary', A.messages[0], "A should have a syntax error reported") + self.assertIn('job_not_a_dict', A.messages[0], + "A should list the bad key") def test_yaml_duplicate_key_error(self): in_repo_conf = textwrap.dedent( @@ -1517,6 +1597,41 @@ class TestInRepoConfig(ZuulTestCase): "A should report failure") self.assertIn('has more than one key', A.messages[0], "A should have a syntax error reported") + self.assertIn("job: null\n name: project-test2", A.messages[0], + "A should have the failing section displayed") + + # This is non-deterministic without default dict ordering, which + # happended with python 3.7. + @skipIf(sys.version_info < (3, 7), "non-deterministic on < 3.7") + def test_yaml_error_truncation_message(self): + in_repo_conf = textwrap.dedent( + """ + - job: + name: project-test2 + this: is + a: long + set: of + keys: that + should: be + truncated: ok + """) + + file_dict = {'.zuul.yaml': in_repo_conf} + 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.assertEqual(A.data['status'], 'NEW') + self.assertEqual(A.reported, 1, + "A should report failure") + self.assertIn('has more than one key', A.messages[0], + "A should have a syntax error reported") + self.assertIn("job: null\n name: project-test2", A.messages[0], + "A should have the failing section displayed") + self.assertIn("...", A.messages[0], + "A should have the failing section truncated") def test_yaml_unknown_error(self): in_repo_conf = textwrap.dedent( @@ -1537,6 +1652,8 @@ class TestInRepoConfig(ZuulTestCase): "A should report failure") self.assertIn('not recognized', A.messages[0], "A should have a syntax error reported") + self.assertIn('foobar:\n foo: bar', A.messages[0], + "A should report the bad keys") def test_invalid_job_secret_var_name(self): in_repo_conf = textwrap.dedent( @@ -2849,10 +2966,6 @@ class FunctionalAnsibleMixIn(object): self._test_plugins(plugin_tests) -class TestAnsible27(AnsibleZuulTestCase, FunctionalAnsibleMixIn): - ansible_version = '2.7' - - class TestAnsible28(AnsibleZuulTestCase, FunctionalAnsibleMixIn): ansible_version = '2.8' @@ -6597,7 +6710,6 @@ class TestAnsibleVersion(AnsibleZuulTestCase): self.assertHistory([ dict(name='ansible-default', result='SUCCESS', changes='1,1'), - dict(name='ansible-27', result='SUCCESS', changes='1,1'), dict(name='ansible-28', result='SUCCESS', changes='1,1'), dict(name='ansible-29', result='SUCCESS', changes='1,1'), ], ordered=False) @@ -6618,7 +6730,6 @@ class TestDefaultAnsibleVersion(AnsibleZuulTestCase): self.assertHistory([ dict(name='ansible-default-zuul-conf', result='SUCCESS', changes='1,1'), - dict(name='ansible-27', result='SUCCESS', changes='1,1'), dict(name='ansible-28', result='SUCCESS', changes='1,1'), dict(name='ansible-29', result='SUCCESS', changes='1,1'), ], ordered=False) diff --git a/tests/unit/test_web.py b/tests/unit/test_web.py index 6a74a8a96..784d4ba3a 100644 --- a/tests/unit/test_web.py +++ b/tests/unit/test_web.py @@ -341,6 +341,7 @@ class TestWeb(BaseTestWeb): 'dependencies': [], 'description': None, 'files': [], + 'intermediate': False, 'irrelevant_files': [], 'match_on_config_updates': True, 'final': False, @@ -385,6 +386,7 @@ class TestWeb(BaseTestWeb): 'dependencies': [], 'description': None, 'files': [], + 'intermediate': False, 'irrelevant_files': [], 'match_on_config_updates': True, 'final': False, @@ -434,6 +436,7 @@ class TestWeb(BaseTestWeb): 'description': None, 'files': [], 'final': False, + 'intermediate': False, 'irrelevant_files': [], 'match_on_config_updates': True, 'name': 'test-job', @@ -555,6 +558,7 @@ class TestWeb(BaseTestWeb): 'description': None, 'files': [], 'final': False, + 'intermediate': False, 'irrelevant_files': [], 'match_on_config_updates': True, 'name': 'project-merge', @@ -592,6 +596,7 @@ class TestWeb(BaseTestWeb): 'description': None, 'files': [], 'final': False, + 'intermediate': False, 'irrelevant_files': [], 'match_on_config_updates': True, 'name': 'project-test1', @@ -629,6 +634,7 @@ class TestWeb(BaseTestWeb): 'description': None, 'files': [], 'final': False, + 'intermediate': False, 'irrelevant_files': [], 'match_on_config_updates': True, 'name': 'project-test2', @@ -666,6 +672,7 @@ class TestWeb(BaseTestWeb): 'description': None, 'files': [], 'final': False, + 'intermediate': False, 'irrelevant_files': [], 'match_on_config_updates': True, 'name': 'project1-project2-integration', @@ -723,6 +730,7 @@ class TestWeb(BaseTestWeb): 'description': None, 'files': [], 'final': False, + 'intermediate': False, 'irrelevant_files': [], 'match_on_config_updates': True, 'name': 'project-post', diff --git a/web/package.json b/web/package.json index f1cf7f2dc..693f892f6 100644 --- a/web/package.json +++ b/web/package.json @@ -7,7 +7,8 @@ "license": "Apache-2.0", "private": true, "dependencies": { - "@patternfly/react-core": "^4.18.5", + "@patternfly/react-core": "^4.40.4", + "@patternfly/react-table": "^4.15.5", "axios": "^0.19.0", "immutability-helper": "^2.8.1", "js-yaml": "^3.13.0", diff --git a/web/src/App.jsx b/web/src/App.jsx index 6bfc755f4..ab54441a3 100644 --- a/web/src/App.jsx +++ b/web/src/App.jsx @@ -29,22 +29,16 @@ import { Brand, Button, ButtonVariant, - Drawer, - DrawerActions, - DrawerCloseButton, - DrawerContent, - DrawerContentBody, - DrawerPanelContent, Dropdown, DropdownItem, KebabToggle, + Modal, Nav, NavItem, NavList, NotificationBadge, NotificationDrawer, NotificationDrawerBody, - NotificationDrawerHeader, NotificationDrawerList, NotificationDrawerListItem, NotificationDrawerListItemBody, @@ -218,7 +212,7 @@ class App extends React.Component { history.push(tenant.defaultRoute) } - handleDrawerClose = () => { + handleModalClose = () => { this.setState({ showErrors: false }) @@ -245,6 +239,7 @@ class App extends React.Component { renderConfigErrors = (configErrors) => { const { history } = this.props + const { showErrors } = this.state const errors = [] configErrors.forEach((item, idx) => { let error = item.error @@ -265,38 +260,47 @@ class App extends React.Component { this.setState({showErrors: false}) }} > - <NotificationDrawerListItemHeader title={error} variant="danger" /> + <NotificationDrawerListItemHeader + title={item.source_context.project + ' | ' + ctxPath} + variant="danger" /> <NotificationDrawerListItemBody> - {item.source_context.project} | {ctxPath} + <pre style={{whiteSpace: 'pre-wrap'}}> + {error} + </pre> </NotificationDrawerListItemBody> </NotificationDrawerListItem> ) }) return ( - <DrawerPanelContent> + <Modal + isOpen={showErrors} + onClose={this.handleModalClose} + aria-label="Config Errors" + header={ + <> + <span className="zuul-config-errors-title"> + Config Errors + </span> + <span className="zuul-config-errors-count"> + {errors.length} error(s) + </span> + </> + } + > <NotificationDrawer> - <NotificationDrawerHeader - count={errors.length} - title="Config Errors" - unreadText="error(s)" - > - <DrawerActions> - <DrawerCloseButton onClick={this.handleDrawerClose} /> - </DrawerActions> - </NotificationDrawerHeader> <NotificationDrawerBody> <NotificationDrawerList> {errors.map(item => (item))} </NotificationDrawerList> </NotificationDrawerBody> </NotificationDrawer> - </DrawerPanelContent> + </Modal> ) } render() { - const { isKebabDropdownOpen, showErrors } = this.state + const { isKebabDropdownOpen } = this.state const { errors, configErrors, tenant } = this.props const nav = this.renderMenu() @@ -402,21 +406,14 @@ class App extends React.Component { /> ) - const drawerPanelContent = this.renderConfigErrors(configErrors) - return ( <React.Fragment> {errors.length > 0 && this.renderErrors(errors)} + {this.renderConfigErrors(configErrors)} <Page header={pageHeader}> - <Drawer isExpanded={showErrors}> - <DrawerContent panelContent={drawerPanelContent}> - <DrawerContentBody> - <ErrorBoundary> - {this.renderContent()} - </ErrorBoundary> - </DrawerContentBody> - </DrawerContent> - </Drawer> + <ErrorBoundary> + {this.renderContent()} + </ErrorBoundary> </Page> </React.Fragment> ) diff --git a/web/src/Misc.jsx b/web/src/Misc.jsx index a2e9eb0c0..1633b897b 100644 --- a/web/src/Misc.jsx +++ b/web/src/Misc.jsx @@ -43,4 +43,48 @@ ExternalLink.propTypes = { children: PropTypes.node, } -export { ExternalLink } +function buildExternalLink(buildish) { + /* TODO (felix): What should we show for periodic builds + here? They don't provide a change, but the ref_url is + also not usable */ + if (buildish.ref_url && buildish.change) { + return ( + <ExternalLink target={buildish.ref_url}> + <strong>Change </strong> + {buildish.change},{buildish.patchset} + </ExternalLink> + ) + } else if (buildish.ref_url && buildish.newrev) { + return ( + <ExternalLink target={buildish.ref_url}> + <strong>Revision </strong> + {buildish.newrev.slice(0,7)} + </ExternalLink> + ) + } + + return null +} + +function buildExternalTableLink(buildish) { + /* TODO (felix): What should we show for periodic builds + here? They don't provide a change, but the ref_url is + also not usable */ + if (buildish.ref_url && buildish.change) { + return ( + <ExternalLink target={buildish.ref_url}> + {buildish.change},{buildish.patchset} + </ExternalLink> + ) + } else if (buildish.ref_url && buildish.newrev) { + return ( + <ExternalLink target={buildish.ref_url}> + {buildish.newrev.slice(0,7)} + </ExternalLink> + ) + } + + return null +} + +export { ExternalLink, buildExternalLink, buildExternalTableLink } diff --git a/web/src/containers/FilterToolbar.jsx b/web/src/containers/FilterToolbar.jsx new file mode 100644 index 000000000..f1277a441 --- /dev/null +++ b/web/src/containers/FilterToolbar.jsx @@ -0,0 +1,248 @@ +// Copyright 2020 BMW Group +// +// 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. + +import React, { useState } from 'react' +import PropTypes from 'prop-types' +import { + Button, + ButtonVariant, + Dropdown, + DropdownItem, + DropdownPosition, + DropdownToggle, + InputGroup, + TextInput, + Toolbar, + ToolbarContent, + ToolbarFilter, + ToolbarGroup, + ToolbarItem, + ToolbarToggleGroup, +} from '@patternfly/react-core' +import { FilterIcon, SearchIcon } from '@patternfly/react-icons' + +function FilterToolbar(props) { + const [isCategoryDropdownOpen, setIsCategoryDropdownOpen] = useState(false) + const [currentCategory, setCurrentCategory] = useState( + props.filterCategories[0].title + ) + const [inputValue, setInputValue] = useState('') + + function handleCategoryToggle(isOpen) { + setIsCategoryDropdownOpen(isOpen) + } + + function handleCategorySelect(event) { + setCurrentCategory(event.target.innerText) + setIsCategoryDropdownOpen(!isCategoryDropdownOpen) + } + + function handleInputChange(newValue) { + setInputValue(newValue) + } + + function handleInputSend(event, category) { + const { onFilterChange, filters } = props + + // In case the event comes from a key press, only accept "Enter" + if (event.key && event.key !== 'Enter') { + return + } + + // Ignore empty values + if (!inputValue) { + return + } + + const prevFilters = filters[category.key] + const newFilters = { + ...filters, + [category.key]: prevFilters.includes(inputValue) + ? prevFilters + : [...prevFilters, inputValue], + } + + // Clear the input field + setInputValue('') + // Notify the parent component about the filter change + onFilterChange(newFilters) + } + + function handleDelete(type = '', id = '', category) { + const { filterCategories, filters, onFilterChange } = props + + // Usually the type contains the category for which a chip should be deleted + // If the type is set, we got a delete() call for a single chip. The type + // reflects the name of the Chip group which does not necessarily go in hand + // with our category keys. Thus, we use the category to identify the correct + // filter to be updated/removed. + let newFilters = {} + if (type) { + newFilters = { + ...filters, + [category.key]: filters[category.key].filter((s) => s !== id), + } + } else { + // Delete the values for each filter category + newFilters = filterCategories.reduce((filterDict, category) => { + filterDict[category.key] = [] + return filterDict + }, {}) + } + + // Notify the parent component about the filter change + onFilterChange(newFilters) + } + + function renderCategoryDropdown() { + const { filterCategories } = props + + return ( + <ToolbarItem> + <Dropdown + onSelect={handleCategorySelect} + position={DropdownPosition.left} + toggle={ + <DropdownToggle + onToggle={handleCategoryToggle} + style={{ width: '100%' }} + > + <FilterIcon /> {currentCategory} + </DropdownToggle> + } + isOpen={isCategoryDropdownOpen} + dropdownItems={filterCategories.map((category) => ( + <DropdownItem key={category.key}>{category.title}</DropdownItem> + ))} + style={{ width: '100%' }} + /> + </ToolbarItem> + ) + } + + function renderFilterInput(category) { + if (category.type === 'search') { + return ( + <InputGroup> + <TextInput + name={`${category.key}-input`} + id={`${category.key}-input`} + type="search" + aria-label={`${category.key} filter`} + onChange={handleInputChange} + value={inputValue} + placeholder={category.placeholder} + onKeyDown={(event) => handleInputSend(event, category)} + /> + <Button + variant={ButtonVariant.control} + aria-label="search button for search input" + onClick={(event) => handleInputSend(event, category)} + > + <SearchIcon /> + </Button> + </InputGroup> + ) + } + } + + function renderFilterDropdown() { + const { filterCategories, filters } = props + + return ( + <> + {filterCategories.map((category) => ( + <ToolbarFilter + key={category.key} + chips={filters[category.key]} + deleteChip={(type, id) => handleDelete(type, id, category)} + categoryName={category.title} + showToolbarItem={currentCategory === category.title} + > + {renderFilterInput(category)} + </ToolbarFilter> + ))} + </> + ) + } + + return ( + <> + <Toolbar + id="toolbar-with-chip-groups" + clearAllFilters={handleDelete} + collapseListedFiltersBreakpoint="md" + > + <ToolbarContent> + <ToolbarToggleGroup toggleIcon={<FilterIcon />} breakpoint="md"> + <ToolbarGroup variant="filter-group"> + {renderCategoryDropdown()} + {renderFilterDropdown()} + </ToolbarGroup> + </ToolbarToggleGroup> + </ToolbarContent> + </Toolbar> + </> + ) +} + +FilterToolbar.propTypes = { + onFilterChange: PropTypes.func.isRequired, + filters: PropTypes.object.isRequired, + filterCategories: PropTypes.array.isRequired, +} + +function getFiltersFromUrl(location, filterCategories) { + const urlParams = new URLSearchParams(location.search) + const filters = filterCategories.reduce((filterDict, item) => { + // Initialize each filter category with an empty list + filterDict[item.key] = [] + // And update the list with each matching element from the URL query + urlParams.getAll(item.key).forEach((param) => { + filterDict[item.key].push(param) + }) + return filterDict + }, {}) + return filters +} + +function writeFiltersToUrl(filters, location, history) { + // Build new URL parameters from the filters in state + const searchParams = new URLSearchParams('') + Object.keys(filters).map((key) => { + filters[key].forEach((value) => { + searchParams.append(key, value) + }) + return searchParams + }) + history.push({ + pathname: location.pathname, + search: searchParams.toString(), + }) +} + +function buildQueryString(filters) { + let queryString = '' + if (filters) { + Object.keys(filters).map((key) => { + filters[key].forEach((value) => { + queryString += '&' + key + '=' + value + }) + return queryString + }) + } + return queryString +} + +export { buildQueryString, FilterToolbar, getFiltersFromUrl, writeFiltersToUrl } diff --git a/web/src/containers/TableFilters.jsx b/web/src/containers/TableFilters.jsx deleted file mode 100644 index cf97a561c..000000000 --- a/web/src/containers/TableFilters.jsx +++ /dev/null @@ -1,236 +0,0 @@ -// Copyright 2018 Red Hat, Inc -// -// 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. - -// Boiler plate code to manage table filtering - -import * as React from 'react' -import PropTypes from 'prop-types' -import { Button, Filter, FormControl, Toolbar } from 'patternfly-react' - - -class TableFilters extends React.Component { - static propTypes = { - location: PropTypes.object - } - - getFilterFromUrl = () => { - const urlParams = new URLSearchParams(this.props.location.search) - let activeFilters = [] - this.filterTypes.forEach(item => { - urlParams.getAll(item.id).forEach(param => { - activeFilters.push({ - label: item.title + ': ' + param, - key: item.id, - value: param}) - }) - }) - this.setState({activeFilters: activeFilters}) - return activeFilters - } - - updateUrl (activeFilters) { - let path = window.location.pathname - if (activeFilters.length > 0) { - path += '?' - activeFilters.forEach((item, idx) => { - if (idx > 0) { - path += '&' - } - path += ( - encodeURIComponent(item.key) - + '=' + - encodeURIComponent(item.value) - ) - }) - } - window.history.pushState({path: path}, '', path) - } - - filterAdded = (field, value) => { - let filterText = '' - if (field.title) { - filterText = field.title - } else { - filterText = field - } - filterText += ': ' - - if (value.filterCategory) { - filterText += - (value.filterCategory.title || value.filterCategory) + - '-' + - (value.filterValue.title || value.filterValue) - } else if (value.title) { - filterText += value.title - } else { - filterText += value - } - - let activeFilters = [...this.state.activeFilters, { - label: filterText, - key: field.id, - value: value - }] - this.setState({ activeFilters: activeFilters }) - this.updateData(activeFilters) - this.updateUrl(activeFilters) - } - - selectFilterType = filterType => { - const { currentFilterType } = this.state - if (currentFilterType !== filterType) { - this.setState(prevState => { - return { - currentValue: '', - currentFilterType: filterType, - filterCategory: - filterType.filterType === 'complex-select' - ? undefined - : prevState.filterCategory, - categoryValue: - filterType.filterType === 'complex-select' - ? '' - : prevState.categoryValue - } - }) - } - } - - filterValueSelected = filterValue => { - const { currentFilterType, currentValue } = this.state - - if (filterValue !== currentValue) { - this.setState({ currentValue: filterValue }) - if (filterValue) { - this.filterAdded(currentFilterType, filterValue) - } - } - } - - filterCategorySelected = category => { - const { filterCategory } = this.state - if (filterCategory !== category) { - this.setState({ filterCategory: category, currentValue: '' }) - } - } - - categoryValueSelected = value => { - const { currentValue, currentFilterType, filterCategory } = this.state - - if (filterCategory && currentValue !== value) { - this.setState({ currentValue: value }) - if (value) { - let filterValue = { - filterCategory: filterCategory, - filterValue: value - } - this.filterAdded(currentFilterType, filterValue) - } - } - } - - updateCurrentValue = event => { - this.setState({ currentValue: event.target.value }) - } - - onValueKeyPress = keyEvent => { - const { currentValue, currentFilterType } = this.state - - if (keyEvent.key === 'Enter' && currentValue && currentValue.length > 0) { - this.setState({ currentValue: '' }) - this.filterAdded(currentFilterType, currentValue) - keyEvent.stopPropagation() - keyEvent.preventDefault() - } - } - - removeFilter = filter => { - const { activeFilters } = this.state - - let index = activeFilters.indexOf(filter) - if (index > -1) { - let updated = [ - ...activeFilters.slice(0, index), - ...activeFilters.slice(index + 1) - ] - this.setState({ activeFilters: updated }) - this.updateData(updated) - this.updateUrl(updated) - } - } - - clearFilters = () => { - this.setState({ activeFilters: [] }) - this.updateData() - this.updateUrl([]) - } - - renderFilterInput() { - const { currentFilterType, currentValue } = this.state - if (!currentFilterType) { - return null - } - return ( - <FormControl - type={currentFilterType.filterType} - value={currentValue} - placeholder={currentFilterType.placeholder} - onChange={e => this.updateCurrentValue(e)} - onKeyPress={e => this.onValueKeyPress(e)} - /> - ) - } - - renderFilter = () => { - const { currentFilterType, activeFilters } = this.state - return ( - <React.Fragment> - <div style={{ width: 300 }}> - <Filter> - <Filter.TypeSelector - filterTypes={this.filterTypes} - currentFilterType={currentFilterType} - onFilterTypeSelected={this.selectFilterType} - /> - {this.renderFilterInput()} - </Filter> - </div> - {activeFilters && activeFilters.length > 0 && ( - <Toolbar.Results> - <Filter.ActiveLabel>{'Active Filters:'}</Filter.ActiveLabel> - <Filter.List> - {activeFilters.map((item, index) => { - return ( - <Filter.Item - key={index} - onRemove={this.removeFilter} - filterData={item} - > - {item.label} - </Filter.Item> - ) - })} - </Filter.List> - <Button onClick={e => { - e.preventDefault() - this.clearFilters() - }}>Clear All Filters</Button> - </Toolbar.Results> - )} - </React.Fragment> - ) - } -} - -export default TableFilters diff --git a/web/src/containers/build/Artifact.jsx b/web/src/containers/build/Artifact.jsx index 23d4c032a..e9d6243a4 100644 --- a/web/src/containers/build/Artifact.jsx +++ b/web/src/containers/build/Artifact.jsx @@ -43,13 +43,13 @@ class Artifact extends React.Component { class ArtifactList extends React.Component { static propTypes = { - build: PropTypes.object.isRequired + artifacts: PropTypes.array.isRequired } render() { - const { build } = this.props + const { artifacts } = this.props - const nodes = build.artifacts.map((artifact, index) => { + const nodes = artifacts.map((artifact, index) => { const node = {text: <a href={artifact.url}>{artifact.name}</a>, icon: null} if (artifact.metadata) { @@ -60,11 +60,14 @@ class ArtifactList extends React.Component { }) return ( - <div className="tree-view-container"> - <TreeView - nodes={nodes} - /> - </div> + <> + <br/> + <div className="tree-view-container"> + <TreeView + nodes={nodes} + /> + </div> + </> ) } } diff --git a/web/src/containers/build/Build.jsx b/web/src/containers/build/Build.jsx index 32829f3de..43d9b27ec 100644 --- a/web/src/containers/build/Build.jsx +++ b/web/src/containers/build/Build.jsx @@ -16,68 +16,238 @@ import * as React from 'react' import PropTypes from 'prop-types' import { connect } from 'react-redux' import { Link } from 'react-router-dom' +import { + Flex, + FlexItem, + List, + ListItem, + Title, +} from '@patternfly/react-core' +import { + BookIcon, + BuildIcon, + CodeBranchIcon, + CodeIcon, + CubeIcon, + FileCodeIcon, + FingerprintIcon, + HistoryIcon, + OutlinedCalendarAltIcon, + OutlinedClockIcon, + StreamIcon, +} from '@patternfly/react-icons' +import * as moment from 'moment' +import 'moment-duration-format' -import Summary from './Summary' -import Manifest from './Manifest' -import Console from './Console' +import { BuildResultBadge, BuildResultWithIcon, IconProperty } from './Misc' +import { buildExternalLink, ExternalLink } from '../../Misc' -class Build extends React.Component { - static propTypes = { - build: PropTypes.object, - tenant: PropTypes.object, - active: PropTypes.string, - hash: PropTypes.array, - } +function Build(props) { + const { build, tenant, timezone, fetchable } = props + const build_link = buildExternalLink(build) - render () { - const { build, active, hash } = this.props - - return ( - <div> - <h2>Build result {build.uuid}</h2> - <div> - <ul className="nav nav-tabs nav-tabs-pf"> - <li className={active==='summary'?'active':undefined}> - <Link to={this.props.tenant.linkPrefix + '/build/' + build.uuid}> - Summary + return ( + <> + <Title + headingLevel="h2" + style={{ + color: build.voting + ? 'inherit' + : 'var(--pf-global--disabled-color--100)', + }} + > + <BuildResultWithIcon + result={build.result} + colored={build.voting} + size="md" + > + {build.job_name} {!build.voting && ' (non-voting)'} + </BuildResultWithIcon> + <BuildResultBadge result={build.result} /> + {fetchable} + </Title> + {/* We handle the spacing for the body and the flex items by ourselves + so they go hand in hand. By default, the flex items' spacing only + affects left/right margin, but not top or bottom (which looks + awkward when the items are stacked at certain breakpoints) */} + <Flex className="zuul-build-attributes"> + <Flex flex={{ lg: 'flex_1' }}> + <FlexItem> + <List style={{ listStyle: 'none' }}> + {build_link && ( + <IconProperty + WrapElement={ListItem} + icon={<CodeIcon />} + value={build_link} + /> + )} + {/* TODO (felix): Link to project page in Zuul */} + <IconProperty + WrapElement={ListItem} + icon={<CubeIcon />} + value={ + <> + <strong>Project </strong> {build.project} + </> + } + /> + <IconProperty + WrapElement={ListItem} + icon={<CodeBranchIcon />} + value={ + build.branch ? ( + <> + <strong>Branch </strong> {build.branch} + </> + ) : ( + <> + <strong>Ref </strong> {build.ref} + </> + ) + } + /> + <IconProperty + WrapElement={ListItem} + icon={<StreamIcon />} + value={ + <> + <strong>Pipeline </strong> {build.pipeline} + </> + } + /> + <IconProperty + WrapElement={ListItem} + icon={<FingerprintIcon />} + value={ + <span> + <strong>UUID </strong> {build.uuid} <br /> + <strong>Event ID </strong> {build.event_id} <br /> + </span> + } + /> + </List> + </FlexItem> + </Flex> + <Flex flex={{ lg: 'flex_1' }}> + <FlexItem> + <List style={{ listStyle: 'none' }}> + <IconProperty + WrapElement={ListItem} + icon={<OutlinedCalendarAltIcon />} + value={ + <span> + <strong>Started at </strong> + {moment + .utc(build.start_time) + .tz(timezone) + .format('YYYY-MM-DD HH:mm:ss')} + <br /> + <strong>Completed at </strong> + {moment + .utc(build.end_time) + .tz(timezone) + .format('YYYY-MM-DD HH:mm:ss')} + </span> + } + /> + <IconProperty + WrapElement={ListItem} + icon={<OutlinedClockIcon />} + value={ + <> + <strong>Took </strong> + {moment + .duration(build.duration, 'seconds') + .format('h [hr] m [min] s [sec]')} + </> + } + /> + </List> + </FlexItem> + </Flex> + <Flex flex={{ lg: 'flex_1' }}> + <FlexItem> + <List style={{ listStyle: 'none' }}> + <IconProperty + WrapElement={ListItem} + icon={<BookIcon />} + value={ + <Link to={tenant.linkPrefix + '/job/' + build.job_name}> + View job documentation + </Link> + } + /> + <IconProperty + WrapElement={ListItem} + icon={<HistoryIcon />} + value={ + <Link + to={ + tenant.linkPrefix + + '/builds?job_name=' + + build.job_name + + '&project=' + + build.project + } + title="See previous runs of this job inside current project." + > + View build history </Link> - </li> - {build.manifest && - <li className={active==='logs'?'active':undefined}> - <Link to={this.props.tenant.linkPrefix + '/build/' + build.uuid + '/logs'}> - Logs - </Link> - </li>} - {build.output && - <li className={active==='console'?'active':undefined}> - <Link - to={this.props.tenant.linkPrefix + '/build/' + build.uuid + '/console'}> - Console - </Link> - </li>} - </ul> - <div> - {/* NOTE (felix): Since I'm already working on a PF4 change for - this file, I don't want to change too much here for now and - just make it compatible to the improved routing solution. - */} - {active === 'summary' && <Summary build={build} />} - {active === 'logs' && build && build.manifest && ( - <Manifest tenant={this.props.tenant} build={build}/> - )} - {active === 'console' && build && build.output && ( - <Console - output={build.output} - errorIds={build.errorIds} - displayPath={hash.length>0?hash:undefined} - /> - )} - </div> - </div> - </div> - ) - } + } + /> + {/* In some cases not all build data is available on initial + page load (e.g. when we come from another page like the + buildset result page). Thus, we have to check for the + buildset here. */} + {build.buildset && ( + <IconProperty + WrapElement={ListItem} + icon={<BuildIcon />} + value={ + <Link + to={ + tenant.linkPrefix + '/buildset/' + build.buildset.uuid + } + > + View buildset result + </Link> + } + /> + )} + <IconProperty + WrapElement={ListItem} + icon={<FileCodeIcon />} + value={ + build.log_url ? ( + <ExternalLink target={build.log_url}>View log</ExternalLink> + ) : ( + <span + style={{ + color: 'var(--pf-global--disabled-color--100)', + }} + > + No log available + </span> + ) + } + /> + </List> + </FlexItem> + </Flex> + </Flex> + </> + ) } +Build.propTypes = { + build: PropTypes.object, + tenant: PropTypes.object, + hash: PropTypes.array, + timezone: PropTypes.string, + fetchable: PropTypes.node, +} -export default connect(state => ({tenant: state.tenant}))(Build) +export default connect((state) => ({ + tenant: state.tenant, + timezone: state.timezone, +}))(Build) diff --git a/web/src/containers/build/BuildOutput.jsx b/web/src/containers/build/BuildOutput.jsx index 709cdc6df..ae9d08af8 100644 --- a/web/src/containers/build/BuildOutput.jsx +++ b/web/src/containers/build/BuildOutput.jsx @@ -108,6 +108,7 @@ class BuildOutput extends React.Component { const { output } = this.props return ( <React.Fragment> + <br /> <div key="tasks"> {Object.entries(output) .filter(([, values]) => values.failed.length > 0) diff --git a/web/src/containers/build/BuildTable.jsx b/web/src/containers/build/BuildTable.jsx new file mode 100644 index 000000000..88e4c18f5 --- /dev/null +++ b/web/src/containers/build/BuildTable.jsx @@ -0,0 +1,268 @@ +// Copyright 2020 BMW Group +// +// 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. + +import React from 'react' +import PropTypes from 'prop-types' +import { connect } from 'react-redux' +import { Link } from 'react-router-dom' +import { + Button, + EmptyState, + EmptyStateBody, + EmptyStateIcon, + EmptyStateSecondaryActions, + Spinner, + Title, +} from '@patternfly/react-core' +import { + BuildIcon, + CodeBranchIcon, + CodeIcon, + CubeIcon, + OutlinedCalendarAltIcon, + OutlinedClockIcon, + PollIcon, + StreamIcon, +} from '@patternfly/react-icons' +import { + Table, + TableHeader, + TableBody, + TableVariant, +} from '@patternfly/react-table' +import 'moment-duration-format' +import * as moment from 'moment' + +import { BuildResult, BuildResultWithIcon, IconProperty } from './Misc' +import { buildExternalTableLink } from '../../Misc' + +function BuildTable(props) { + const { builds, fetching, onClearFilters, tenant, timezone } = props + const columns = [ + { + title: <IconProperty icon={<BuildIcon />} value="Job" />, + dataLabel: 'Job', + }, + { + title: <IconProperty icon={<CubeIcon />} value="Project" />, + dataLabel: 'Project', + }, + { + title: <IconProperty icon={<CodeBranchIcon />} value="Branch" />, + dataLabel: 'Branch', + }, + { + title: <IconProperty icon={<StreamIcon />} value="Pipeline" />, + dataLabel: 'Pipeline', + }, + { + title: <IconProperty icon={<CodeIcon />} value="Change" />, + dataLabel: 'Change', + }, + { + title: <IconProperty icon={<OutlinedClockIcon />} value="Duration" />, + dataLabel: 'Duration', + }, + { + title: ( + <IconProperty icon={<OutlinedCalendarAltIcon />} value="Start time" /> + ), + dataLabel: 'Start time', + }, + { + title: <IconProperty icon={<PollIcon />} value="Result" />, + dataLabel: 'Result', + }, + ] + + function createBuildRow(build) { + // This link will be defined on each cell of the current row as this is the + // only way to define a valid HTML link on a table row. Although we could + // simply define an onClick handler on the whole row and programatically + // switch to the buildresult page, this wouldn't provide the same + // look-and-feel as a plain HTML link. + const buildResultLink = ( + <Link + to={`${tenant.linkPrefix}/build/${build.uuid}`} + className="zuul-stretched-link" + /> + ) + const build_link = buildExternalTableLink(build) + + return { + cells: [ + { + // To allow passing anything else than simple string values to a table + // cell, we must use the title attribute. + title: ( + <> + {buildResultLink} + <BuildResultWithIcon result={build.result} colored={build.voting}> + {build.job_name} + {!build.voting && ' (non-voting)'} + </BuildResultWithIcon> + </> + ), + }, + { + title: ( + <> + {buildResultLink} + <span>{build.project}</span> + </> + ), + }, + { + title: ( + <> + {buildResultLink} + <span>{build.branch ? build.branch : build.ref}</span> + </> + ), + }, + { + title: ( + <> + {buildResultLink} + <span>{build.pipeline}</span> + </> + ), + }, + { + title: ( + <> + {buildResultLink} + {build_link && ( + <span style={{ zIndex: 1, position: 'relative' }}> + {build_link} + </span> + )} + </> + ), + }, + { + title: ( + <> + {buildResultLink} + <span> + {moment + .duration(build.duration, 'seconds') + .format('h [hr] m [min] s [sec]')} + </span> + </> + ), + }, + { + title: ( + <> + {buildResultLink} + <span> + {moment + .utc(build.start_time) + .tz(timezone) + .format('YYYY-MM-DD HH:mm:ss')} + </span> + </> + ), + }, + { + title: ( + <> + {buildResultLink} + <BuildResult result={build.result} colored={build.voting} /> + </> + ), + }, + ], + } + } + + function createFetchingRow() { + const rows = [ + { + heightAuto: true, + cells: [ + { + props: { colSpan: 8 }, + title: ( + <center> + <Spinner size="xl" /> + </center> + ), + }, + ], + }, + ] + return rows + } + + let rows = [] + if (fetching) { + rows = createFetchingRow() + // The dataLabel property is used to show the column header in a list-like + // format for smaller viewports. When we are fetching, we don't want the + // fetching row to be prepended by a "Job" column header. The other column + // headers are not relevant here since we only have a single cell in the + // fetcihng row. + columns[0].dataLabel = '' + } else { + rows = builds.map((build) => createBuildRow(build)) + } + + return ( + <> + <Table + aria-label="Builds Table" + variant={TableVariant.compact} + cells={columns} + rows={rows} + className="zuul-build-table" + > + <TableHeader /> + <TableBody /> + </Table> + + {/* Show an empty state in case we don't have any builds but are also not + fetching */} + {!fetching && builds.length === 0 && ( + <EmptyState> + <EmptyStateIcon icon={BuildIcon} /> + <Title headingLevel="h1">No builds found</Title> + <EmptyStateBody> + No builds match this filter criteria. Remove some filters or clear + all to show results. + </EmptyStateBody> + <EmptyStateSecondaryActions> + <Button variant="link" onClick={onClearFilters}> + Clear all filters + </Button> + </EmptyStateSecondaryActions> + </EmptyState> + )} + </> + ) +} + +BuildTable.propTypes = { + builds: PropTypes.array.isRequired, + fetching: PropTypes.bool.isRequired, + onClearFilters: PropTypes.func.isRequired, + tenant: PropTypes.object.isRequired, + timezone: PropTypes.string.isRequired, +} + +export default connect((state) => ({ + tenant: state.tenant, + timezone: state.timezone, +}))(BuildTable) diff --git a/web/src/containers/build/Buildset.jsx b/web/src/containers/build/Buildset.jsx index f6eb0d587..2f275f270 100644 --- a/web/src/containers/build/Buildset.jsx +++ b/web/src/containers/build/Buildset.jsx @@ -25,11 +25,12 @@ import { StreamIcon, } from '@patternfly/react-icons' -import { ExternalLink } from '../../Misc' +import { buildExternalLink } from '../../Misc' import { BuildResultBadge, BuildResultWithIcon, IconProperty } from './Misc' function Buildset(props) { const { buildset, fetchable } = props + const buildset_link = buildExternalLink(buildset) return ( <> @@ -53,16 +54,11 @@ function Buildset(props) { show the respective icon here (GithubIcon, GitlabIcon, GitIcon - AFAIK the Gerrit icon is not very popular among icon frameworks like fontawesome */} - {buildset.change && ( + {buildset_link && ( <IconProperty WrapElement={ListItem} icon={<CodeIcon />} - value={ - <ExternalLink target={buildset.ref_url}> - <strong>Change </strong> - {buildset.change},{buildset.patchset} - </ExternalLink> - } + value={buildset_link} /> )} {/* TODO (felix): Link to project page in Zuul */} @@ -79,9 +75,15 @@ function Buildset(props) { WrapElement={ListItem} icon={<CodeBranchIcon />} value={ - <> - <strong>Branch </strong> {buildset.branch} - </> + buildset.branch ? ( + <> + <strong>Branch </strong> {buildset.branch} + </> + ) : ( + <> + <strong>Ref </strong> {buildset.ref} + </> + ) } /> <IconProperty diff --git a/web/src/containers/build/BuildsetTable.jsx b/web/src/containers/build/BuildsetTable.jsx new file mode 100644 index 000000000..dcd7efb0c --- /dev/null +++ b/web/src/containers/build/BuildsetTable.jsx @@ -0,0 +1,212 @@ +// Copyright 2020 BMW Group +// +// 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. + +import React from 'react' +import PropTypes from 'prop-types' +import { connect } from 'react-redux' +import { Link } from 'react-router-dom' +import { + Button, + EmptyState, + EmptyStateBody, + EmptyStateIcon, + EmptyStateSecondaryActions, + Spinner, + Title, +} from '@patternfly/react-core' +import { + BuildIcon, + CodeBranchIcon, + CodeIcon, + CubeIcon, + PollIcon, + StreamIcon, +} from '@patternfly/react-icons' +import { + Table, + TableHeader, + TableBody, + TableVariant, +} from '@patternfly/react-table' + +import { BuildResult, BuildResultWithIcon, IconProperty } from './Misc' +import { buildExternalTableLink } from '../../Misc' + +function BuildsetTable(props) { + const { buildsets, fetching, onClearFilters, tenant } = props + const columns = [ + { + title: <IconProperty icon={<CubeIcon />} value="Project" />, + dataLabel: 'Project', + }, + { + title: <IconProperty icon={<CodeBranchIcon />} value="Branch" />, + dataLabel: 'Branch', + }, + { + title: <IconProperty icon={<StreamIcon />} value="Pipeline" />, + dataLabel: 'Pipeline', + }, + { + title: <IconProperty icon={<CodeIcon />} value="Change" />, + dataLabel: 'Change', + }, + { + title: <IconProperty icon={<PollIcon />} value="Result" />, + dataLabel: 'Result', + }, + ] + + function createBuildsetRow(buildset) { + // This link will be defined on each cell of the current row as this is the + // only way to define a valid HTML link on a table row. Although we could + // simply define an onClick handler on the whole row and programatically + // switch to the buildresult page, this wouldn't provide the same + // look-and-feel as a plain HTML link. + const buildsetResultLink = ( + <Link + to={`${tenant.linkPrefix}/buildset/${buildset.uuid}`} + className="zuul-stretched-link" + /> + ) + const buildset_link = buildExternalTableLink(buildset) + + return { + cells: [ + { + // To allow passing anything else than simple string values to a table + // cell, we must use the title attribute. + title: ( + <> + {buildsetResultLink} + <BuildResultWithIcon result={buildset.result}> + {buildset.project} + </BuildResultWithIcon> + </> + ), + }, + { + title: ( + <> + {buildsetResultLink} + <span>{buildset.branch ? buildset.branch : buildset.ref}</span> + </> + ), + }, + { + title: ( + <> + {buildsetResultLink} + <span>{buildset.pipeline}</span> + </> + ), + }, + { + title: ( + <> + {buildsetResultLink} + {buildset_link && ( + <span style={{ zIndex: 1, position: 'relative' }}> + {buildset_link} + </span> + )} + </> + ), + }, + { + title: ( + <> + {buildsetResultLink} + <BuildResult result={buildset.result} /> + </> + ), + }, + ], + } + } + + function createFetchingRow() { + const rows = [ + { + heightAuto: true, + cells: [ + { + props: { colSpan: 8 }, + title: ( + <center> + <Spinner size="xl" /> + </center> + ), + }, + ], + }, + ] + return rows + } + + let rows = [] + if (fetching) { + rows = createFetchingRow() + // The dataLabel property is used to show the column header in a list-like + // format for smaller viewports. When we are fetching, we don't want the + // fetching row to be prepended by a "Project" column header. The other + // column headers are not relevant here since we only have a single cell in + // the fetching row. + columns[0].dataLabel = '' + } else { + rows = buildsets.map((buildset) => createBuildsetRow(buildset)) + } + + return ( + <> + <Table + aria-label="Builds Table" + variant={TableVariant.compact} + cells={columns} + rows={rows} + className="zuul-build-table" + > + <TableHeader /> + <TableBody /> + </Table> + + {/* Show an empty state in case we don't have any buildsets but are also + not fetching */} + {!fetching && buildsets.length === 0 && ( + <EmptyState> + <EmptyStateIcon icon={BuildIcon} /> + <Title headingLevel="h1">No buildsets found</Title> + <EmptyStateBody> + No buildsets match this filter criteria. Remove some filters or + clear all to show results. + </EmptyStateBody> + <EmptyStateSecondaryActions> + <Button variant="link" onClick={onClearFilters}> + Clear all filters + </Button> + </EmptyStateSecondaryActions> + </EmptyState> + )} + </> + ) +} + +BuildsetTable.propTypes = { + buildsets: PropTypes.array.isRequired, + fetching: PropTypes.bool.isRequired, + onClearFilters: PropTypes.func.isRequired, + tenant: PropTypes.object.isRequired, +} + +export default connect((state) => ({ tenant: state.tenant }))(BuildsetTable) diff --git a/web/src/containers/build/Summary.jsx b/web/src/containers/build/Summary.jsx deleted file mode 100644 index dbccdbc59..000000000 --- a/web/src/containers/build/Summary.jsx +++ /dev/null @@ -1,131 +0,0 @@ -// Copyright 2018 Red Hat, Inc -// -// 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. - -import * as React from 'react' -import PropTypes from 'prop-types' -import { connect } from 'react-redux' -import { Link } from 'react-router-dom' - -import ArtifactList from './Artifact' -import BuildOutput from './BuildOutput' - -import * as moment from 'moment' -import 'moment-duration-format' - - -class Summary extends React.Component { - static propTypes = { - build: PropTypes.object, - tenant: PropTypes.object, - timezone: PropTypes.string, - } - - render () { - const { build } = this.props - const rows = [] - const myColumns = [ - 'job_name', 'result', 'buildset', 'voting', - 'pipeline', 'start_time', 'end_time', 'duration', - 'project', 'branch', 'change', 'patchset', 'oldrev', 'newrev', - 'ref', 'new_rev', 'ref_url', 'log_url', 'event_id'] - - if (!build.buildset) { - // Safely handle missing buildset information - myColumns.splice(myColumns.indexOf('buildset'), 1) - } - - myColumns.forEach(column => { - let label = column - let value = build[column] - if (column === 'job_name') { - label = 'job' - value = ( - <React.Fragment> - <Link to={this.props.tenant.linkPrefix + '/job/' + value}> - {value} - </Link> - <span> — </span> - <Link to={this.props.tenant.linkPrefix + '/builds?job_name=' + value + '&project=' + build.project} title="See previous runs of this job inside current project."> - build history - </Link> - </React.Fragment> - ) - } - if (column === 'buildset') { - value = ( - <Link to={this.props.tenant.linkPrefix + '/buildset/' + value.uuid}> - {value.uuid} - </Link> - ) - } - if (column === 'voting') { - if (value) { - value = 'true' - } else { - value = 'false' - } - } - if (column === 'start_time' || column === 'end_time') { - value = moment.utc(value).tz(this.props.timezone).format('YYYY-MM-DD HH:mm:ss') - } - if (column === 'duration') { - value = moment.duration(value, 'seconds') - .format('h [hr] m [min] s [sec]') - } - if (value && (column === 'log_url' || column === 'ref_url')) { - value = <a href={value}>{value}</a> - } - if (column === 'log_url') { - label = 'log url' - if (build.manifest && build.manifest.index_links) { - value = <a href={value + 'index.html'}>{value}</a> - } else { - value = <a href={value}>{value}</a> - } - } - if (column === 'ref_url') { - label = 'ref url' - value = <a href={value}>{value}</a> - } - if (column === 'event_id') { - label = 'event id' - } - if (value) { - rows.push({key: label, value: value}) - } - }) - return ( - <React.Fragment> - <br/> - <table className="table table-striped table-bordered"> - <tbody> - {rows.map(item => ( - <tr key={item.key}> - <td>{item.key}</td> - <td>{item.value}</td> - </tr> - ))} - </tbody> - </table> - <h3>Artifacts</h3> - <ArtifactList build={build}/> - <h3>Results</h3> - {build.hosts && <BuildOutput output={build.hosts}/>} - </React.Fragment> - ) - } -} - - -export default connect(state => ({tenant: state.tenant, timezone: state.timezone}))(Summary) diff --git a/web/src/index.css b/web/src/index.css index 91601993a..8f2ba8a5e 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -1,8 +1,3 @@ -#root { - /* This is needed to make the navigation bar fixed on top */ - height: 100%; -} - body { margin: 0; padding: 0; @@ -34,16 +29,91 @@ a.refresh { color: var(--pf-global--Color--dark-100); } -/* Build Lists */ +/* Config error modal */ +.zuul-config-errors-title { + font-size: var(--pf-global--FontSize--xl); +} + +.zuul-config-errors-count { + margin-left: var(--pf-global--spacer--md); +} + +/* + * Build Lists and Tables + */ + +/* Improve the hover effect of selected lines in the selectable data list*/ .pf-c-data-list__item.pf-m-selectable:hover:not(.pf-m-selected), .pf-c-data-list__item.pf-m-selectable:focus:not(.pf-m-selected) { - /* Improve the hover/focus effect of selected lines */ --pf-c-data-list__item--before--BackgroundColor: var( --pf-c-data-list__item--m-selected--before--BackgroundColor ); font-weight: bold; } +.zuul-build-list:hover a { + text-decoration: none; +} + +/* Keep the normal font-size for compact tables */ +.zuul-build-table td { + font-size: var(--pf-global--FontSize--md); +} + +/* Use the same hover effect on table rows like for the selectable data list */ +.zuul-build-table tbody tr:hover { + box-shadow: var(--pf-global--BoxShadow--sm-top), + var(--pf-global--BoxShadow--sm-bottom); +} + +@media screen and (max-width: 768px) { + /* For the small-screen table layout the before element is already used to + show the column names. Thus, we fall back to the border to show the hover + effect. The drawback with that is, that we can't show a nice transition. + */ + .zuul-build-table tbody tr:hover { + border-left-color: var(--pf-global--active-color--100); + border-left-width: var(--pf-global--BorderWidth--lg); + border-left-style: solid; + /* Compensate the border width with a negative margin */ + margin-left: -3px; + } +} + +@media screen and (min-width: 769px) { + /* For the larger screens (normal table layout) we can use the before + element on the first table cell to show the same hover effect like for + the data list */ + .zuul-build-table tbody tr td:first-child::before { + position: absolute; + top: 0; + bottom: 0; + left: 0; + width: var(--pf-global--BorderWidth--lg); + content: ""; + background-color: transparent; + transition: var(--pf-global--Transition); + } + + .zuul-build-table tbody tr:hover td:first-child::before { + background-color: var(--pf-global--active-color--100); + } +} + +/* Make a link stretch the whole parent element (in this case the whole table + cell) */ +.zuul-stretched-link::after { + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + z-index: 0; + pointer-events: auto; + content: ""; + background-color: rgba(0,0,0,0); +} + /* * Build/Buildset result page */ @@ -302,7 +372,7 @@ pre.version { } pre.zuul-log-output { - overflow: inherit; + overflow-x: auto; background-color: inherit; border: none; } @@ -378,9 +448,3 @@ details.foldable[open] summary::before { ); } } - -/* This restores correct scroll bar and scrolling behaviour */ -.pf-c-page__main { - overflow-x: visible; - overflow-y: visible; -} diff --git a/web/src/pages/Build.jsx b/web/src/pages/Build.jsx index d4975d972..6384eeb2d 100644 --- a/web/src/pages/Build.jsx +++ b/web/src/pages/Build.jsx @@ -14,13 +14,36 @@ import * as React from 'react' import { connect } from 'react-redux' +import { withRouter } from 'react-router-dom' import PropTypes from 'prop-types' -import { PageSection, PageSectionVariants } from '@patternfly/react-core' +import { + EmptyState, + EmptyStateVariant, + EmptyStateIcon, + PageSection, + PageSectionVariants, + Tab, + Tabs, + TabTitleIcon, + TabTitleText, + Title, +} from '@patternfly/react-core' +import { + BuildIcon, + FileArchiveIcon, + FileCodeIcon, + TerminalIcon, + PollIcon, +} from '@patternfly/react-icons' import { fetchBuildIfNeeded } from '../actions/build' -import { Fetchable } from '../containers/Fetching' +import { EmptyPage } from '../containers/Errors' +import { Fetchable, Fetching } from '../containers/Fetching' +import ArtifactList from '../containers/build/Artifact' import Build from '../containers/build/Build' - +import BuildOutput from '../containers/build/BuildOutput' +import Console from '../containers/build/Console' +import Manifest from '../containers/build/Manifest' class BuildPage extends React.Component { static propTypes = { @@ -30,45 +53,219 @@ class BuildPage extends React.Component { dispatch: PropTypes.func, activeTab: PropTypes.string.isRequired, location: PropTypes.object, + history: PropTypes.object, } updateData = (force) => { - this.props.dispatch(fetchBuildIfNeeded( - this.props.tenant, this.props.match.params.buildId, null, force)) + this.props.dispatch( + fetchBuildIfNeeded( + this.props.tenant, + this.props.match.params.buildId, + force + ) + ) } - componentDidMount () { + componentDidMount() { document.title = 'Zuul Build' if (this.props.tenant.name) { this.updateData() } } - componentDidUpdate (prevProps) { + componentDidUpdate(prevProps) { if (this.props.tenant.name !== prevProps.tenant.name) { this.updateData() } } - render () { - const { remoteData, activeTab, location } = this.props + handleTabClick = (tabIndex, build) => { + // Usually tabs should only be used to display content in-page and not link + // to other pages: + // "Tabs are used to present a set on tabs for organizing content on a + // .page. It must always be used together with a tab content component." + // https://www.patternfly.org/v4/documentation/react/components/tabs + // But as want to be able to reach every tab's content via a dedicated URL + // while having the look and feel of tabs, we could hijack this onClick + // handler to do the link/routing stuff. + const { history, tenant } = this.props + + switch (tabIndex) { + case 'artifacts': + history.push(`${tenant.linkPrefix}/build/${build.uuid}/artifacts`) + break + case 'logs': + history.push(`${tenant.linkPrefix}/build/${build.uuid}/logs`) + break + case 'console': + history.push(`${tenant.linkPrefix}/build/${build.uuid}/console`) + break + default: + // results + history.push(`${tenant.linkPrefix}/build/${build.uuid}`) + } + } + + render() { + const { remoteData, activeTab, location, tenant } = this.props const build = remoteData.builds[this.props.match.params.buildId] const hash = location.hash.substring(1).split('/') + + if (!build && remoteData.isFetching) { + return <Fetching /> + } + + if (!build) { + return ( + <EmptyPage + title="This build does not exist" + icon={BuildIcon} + linkTarget={`${tenant.linkPrefix}/builds`} + linkText="Show all builds" + /> + ) + } + + const fetchable = ( + <Fetchable + isFetching={remoteData.isFetching} + fetchCallback={this.updateData} + /> + ) + + const resultsTabContent = + !build.hosts && remoteData.isFetchingOutput ? ( + <Fetching /> + ) : build.hosts ? ( + <BuildOutput output={build.hosts} /> + ) : ( + <EmptyState variant={EmptyStateVariant.small}> + <EmptyStateIcon icon={PollIcon} /> + <Title headingLevel="h4" size="lg"> + This build does not provide any results + </Title> + </EmptyState> + ) + + const artifactsTabContent = build.artifacts.length ? ( + <ArtifactList artifacts={build.artifacts} /> + ) : ( + <EmptyState variant={EmptyStateVariant.small}> + <EmptyStateIcon icon={FileArchiveIcon} /> + <Title headingLevel="h4" size="lg"> + This build does not provide any artifacts + </Title> + </EmptyState> + ) + + const logsTabContent = + !build.manifest && remoteData.isFetchingManifest ? ( + <Fetching /> + ) : build.manifest ? ( + <Manifest tenant={this.props.tenant} build={build} /> + ) : ( + <EmptyState variant={EmptyStateVariant.small}> + <EmptyStateIcon icon={FileCodeIcon} /> + <Title headingLevel="h4" size="lg"> + This build does not provide any logs + </Title> + </EmptyState> + ) + + const consoleTabContent = + !build.output && remoteData.isFetchingOutput ? ( + <Fetching /> + ) : build.output ? ( + <Console + output={build.output} + errorIds={build.errorIds} + displayPath={hash.length > 0 ? hash : undefined} + /> + ) : ( + <EmptyState variant={EmptyStateVariant.small}> + <EmptyStateIcon icon={TerminalIcon} /> + <Title headingLevel="h4" size="lg"> + This build does not provide any console information + </Title> + </EmptyState> + ) + return ( - <PageSection variant={PageSectionVariants.light}> - <PageSection style={{paddingRight: '5px'}}> - <Fetchable - isFetching={remoteData.isFetching} - fetchCallback={this.updateData} + <> + <PageSection variant={PageSectionVariants.light}> + <Build + build={build} + active={activeTab} + hash={hash} + fetchable={fetchable} /> </PageSection> - {build && <Build build={build} active={activeTab} hash={hash}/>} - </PageSection> + <PageSection variant={PageSectionVariants.light}> + <Tabs + isFilled + activeKey={activeTab} + onSelect={(event, tabIndex) => this.handleTabClick(tabIndex, build)} + > + <Tab + eventKey="results" + title={ + <> + <TabTitleIcon> + <PollIcon /> + </TabTitleIcon> + <TabTitleText>Results</TabTitleText> + </> + } + > + {resultsTabContent} + </Tab> + <Tab + eventKey="artifacts" + title={ + <> + <TabTitleIcon> + <FileArchiveIcon /> + </TabTitleIcon> + <TabTitleText>Artifacts</TabTitleText> + </> + } + > + {artifactsTabContent} + </Tab> + <Tab + eventKey="logs" + title={ + <> + <TabTitleIcon> + <FileCodeIcon /> + </TabTitleIcon> + <TabTitleText>Logs</TabTitleText> + </> + } + > + {logsTabContent} + </Tab> + <Tab + eventKey="console" + title={ + <> + <TabTitleIcon> + <TerminalIcon /> + </TabTitleIcon> + <TabTitleText>Console</TabTitleText> + </> + } + > + {consoleTabContent} + </Tab> + </Tabs> + </PageSection> + </> ) } } -export default connect(state => ({ +export default connect((state) => ({ tenant: state.tenant, remoteData: state.build, -}))(BuildPage) +}))(withRouter(BuildPage)) diff --git a/web/src/pages/Builds.jsx b/web/src/pages/Builds.jsx index 6777c3b76..144f9ab85 100644 --- a/web/src/pages/Builds.jsx +++ b/web/src/pages/Builds.jsx @@ -15,166 +15,161 @@ import * as React from 'react' import PropTypes from 'prop-types' import { connect } from 'react-redux' -import { Link } from 'react-router-dom' -import { Table } from 'patternfly-react' -import * as moment from 'moment-timezone' import 'moment-duration-format' import { PageSection, PageSectionVariants } from '@patternfly/react-core' import { fetchBuilds } from '../api' -import TableFilters from '../containers/TableFilters' +import { + buildQueryString, + FilterToolbar, + getFiltersFromUrl, + writeFiltersToUrl, +} from '../containers/FilterToolbar' +import BuildTable from '../containers/build/BuildTable' - -class BuildsPage extends TableFilters { +class BuildsPage extends React.Component { static propTypes = { tenant: PropTypes.object, - timezone: PropTypes.string + timezone: PropTypes.string, + location: PropTypes.object, + history: PropTypes.object, } - constructor () { + constructor(props) { super() - this.prepareTableHeaders() + this.filterCategories = [ + { + key: 'job_name', + title: 'Job', + placeholder: 'Filter by Job...', + type: 'search', + }, + { + key: 'project', + title: 'Project', + placeholder: 'Filter by Project...', + type: 'search', + }, + { + key: 'branch', + title: 'Branch', + placeholder: 'Filter by Branch...', + type: 'search', + }, + { + key: 'pipeline', + title: 'Pipeline', + placeholder: 'Filter by Pipeline...', + type: 'search', + }, + { + key: 'change', + title: 'Change', + placeholder: 'Filter by Change...', + type: 'search', + }, + // TODO (felix): We could change the result filter to a dropdown later on + { + key: 'result', + title: 'Result', + placeholder: 'Filter by Result...', + type: 'search', + }, + { + key: 'uuid', + title: 'Build', + placeholder: 'Filter by Build UUID...', + type: 'search', + }, + ] + this.state = { - builds: null, - currentFilterType: this.filterTypes[0], - activeFilters: [], - currentValue: '' + builds: [], + fetching: false, + filters: getFiltersFromUrl(props.location, this.filterCategories), } } updateData = (filters) => { - let queryString = '' - if (filters) { - filters.forEach(item => queryString += '&' + item.key + '=' + item.value) - } - this.setState({builds: null}) - fetchBuilds(this.props.tenant.apiPrefix, queryString).then(response => { - this.setState({builds: response.data}) + // When building the filter query for the API we can't rely on the location + // search parameters. Although, we've updated them in theu URL directly + // they always have the same value in here (the values when the page was + // first loaded). Most probably that's the case because the location is + // passed as prop and doesn't change since the page itself wasn't + // re-rendered. + const queryString = buildQueryString(filters) + this.setState({ fetching: true }) + // TODO (felix): What happens in case of a broken network connection? Is the + // fetching shows infinitely or can we catch this and show an erro state in + // the table instead? + fetchBuilds(this.props.tenant.apiPrefix, queryString).then((response) => { + this.setState({ + builds: response.data, + fetching: false, + }) }) } - componentDidMount () { + componentDidMount() { document.title = 'Zuul Builds' if (this.props.tenant.name) { - this.updateData(this.getFilterFromUrl()) + this.updateData(this.state.filters) } } - componentDidUpdate (prevProps) { - if (this.props.tenant.name !== prevProps.tenant.name || - this.props.timezone !== prevProps.timezone) { - this.updateData(this.getFilterFromUrl()) + componentDidUpdate(prevProps) { + const { filters } = this.state + if ( + this.props.tenant.name !== prevProps.tenant.name || + this.props.timezone !== prevProps.timezone + ) { + this.updateData(filters) } } - prepareTableHeaders() { - const headerFormat = value => <Table.Heading>{value}</Table.Heading> - const cellFormat = (value) => ( - <Table.Cell>{value}</Table.Cell>) - const linkBuildFormat = (value, rowdata) => ( - <Table.Cell> - <Link to={this.props.tenant.linkPrefix + '/build/' + rowdata.rowData.uuid}>{value}</Link> - </Table.Cell> - ) - const linkChangeFormat = (value, rowdata) => ( - <Table.Cell> - <a href={rowdata.rowData.ref_url}>{value ? rowdata.rowData.change+','+rowdata.rowData.patchset : rowdata.rowData.newrev ? rowdata.rowData.newrev.substr(0, 7) : rowdata.rowData.branch}</a> - </Table.Cell> - ) - const durationFormat = (value) => ( - <Table.Cell> - {moment.duration(value, 'seconds').format('h [hr] m [min] s [sec]')} - </Table.Cell> - ) - const timeFormat = (value) => ( - <Table.Cell> - {moment.utc(value).tz(this.props.timezone).format('YYYY-MM-DD HH:mm:ss')} - </Table.Cell> - ) - this.columns = [] - this.filterTypes = [] - const myColumns = [ - 'job', - 'project', - 'branch', - 'pipeline', - 'change', - 'duration', - 'start time', - 'result'] - myColumns.forEach(column => { - let prop = column - let formatter = cellFormat - // Adapt column name and property name - if (column === 'job') { - prop = 'job_name' - } else if (column === 'start time') { - prop = 'start_time' - formatter = timeFormat - } else if (column === 'change') { - prop = 'change' - formatter = linkChangeFormat - } else if (column === 'result') { - formatter = linkBuildFormat - } else if (column === 'duration') { - formatter = durationFormat - } - const label = column.charAt(0).toUpperCase() + column.slice(1) - this.columns.push({ - header: {label: label, formatters: [headerFormat]}, - property: prop, - cell: {formatters: [formatter]} - }) - if (prop !== 'start_time' && prop !== 'ref_url' && prop !== 'duration' - && prop !== 'log_url' && prop !== 'uuid') { - this.filterTypes.push({ - id: prop, - title: label, - placeholder: 'Filter by ' + label, - filterType: 'text', - }) + handleFilterChange = (filters) => { + const { location, history } = this.props + // We must update the URL parameters before the state. Otherwise, the URL + // will always be one filter selection behind the state. But as the URL + // reflects our state this should be ok. + writeFiltersToUrl(filters, location, history) + this.updateData(filters) + this.setState(() => { + return { + filters: filters, } }) - // Add build filter at the end - this.filterTypes.push({ - id: 'uuid', - title: 'Build', - placeholder: 'Filter by Build UUID', - filterType: 'text', - }) } - renderTable (builds) { - return ( - <Table.PfProvider - striped - bordered - columns={this.columns} - > - <Table.Header/> - <Table.Body - rows={builds} - rowKey='uuid' - onRow={(row) => { - switch (row.result) { - case 'SUCCESS': - return { className: 'success' } - default: - return { className: 'warning' } - } - }} /> - </Table.PfProvider>) + handleClearFilters = () => { + // Delete the values for each filter category + const filters = this.filterCategories.reduce((filterDict, category) => { + filterDict[category.key] = [] + return filterDict + }, {}) + this.handleFilterChange(filters) } render() { - const { builds } = this.state + const { builds, fetching, filters } = this.state return ( <PageSection variant={PageSectionVariants.light}> - {this.renderFilter()} - {builds ? this.renderTable(builds) : <p>Loading...</p>} + <FilterToolbar + filterCategories={this.filterCategories} + onFilterChange={this.handleFilterChange} + filters={filters} + /> + <BuildTable + builds={builds} + fetching={fetching} + onClearFilters={this.handleClearFilters} + /> </PageSection> ) } } -export default connect(state => ({tenant: state.tenant, timezone: state.timezone}))(BuildsPage) +export default connect((state) => ({ + tenant: state.tenant, + timezone: state.timezone, +}))(BuildsPage) diff --git a/web/src/pages/Buildsets.jsx b/web/src/pages/Buildsets.jsx index e88f01ae1..602f4d0d8 100644 --- a/web/src/pages/Buildsets.jsx +++ b/web/src/pages/Buildsets.jsx @@ -15,149 +15,146 @@ import * as React from 'react' import PropTypes from 'prop-types' import { connect } from 'react-redux' -import { Link } from 'react-router-dom' -import { Table } from 'patternfly-react' import { PageSection, PageSectionVariants } from '@patternfly/react-core' import { fetchBuildsets } from '../api' -import TableFilters from '../containers/TableFilters' +import { + buildQueryString, + FilterToolbar, + getFiltersFromUrl, + writeFiltersToUrl, +} from '../containers/FilterToolbar' +import BuildsetTable from '../containers/build/BuildsetTable' - -class BuildsetsPage extends TableFilters { +class BuildsetsPage extends React.Component { static propTypes = { - tenant: PropTypes.object + tenant: PropTypes.object, + location: PropTypes.object, + history: PropTypes.object, } - constructor () { + constructor(props) { super() + this.filterCategories = [ + { + key: 'project', + title: 'Project', + placeholder: 'Filter by Project...', + type: 'search', + }, + { + key: 'branch', + title: 'Branch', + placeholder: 'Filter by Branch...', + type: 'search', + }, + { + key: 'pipeline', + title: 'Pipeline', + placeholder: 'Filter by Pipeline...', + type: 'search', + }, + { + key: 'change', + title: 'Change', + placeholder: 'Filter by Change...', + type: 'search', + }, + // TODO (felix): We could change the result filter to a dropdown later on + { + key: 'result', + title: 'Result', + placeholder: 'Filter by Result...', + type: 'search', + }, + { + key: 'uuid', + title: 'Buildset', + placeholder: 'Filter by Buildset UUID...', + type: 'search', + }, + ] - this.prepareTableHeaders() this.state = { - buildsets: null, - currentFilterType: this.filterTypes[0], - activeFilters: [], - currentValue: '' + buildsets: [], + fetching: false, + filters: getFiltersFromUrl(props.location, this.filterCategories), } } updateData = (filters) => { - let queryString = '' - if (filters) { - filters.forEach(item => queryString += '&' + item.key + '=' + item.value) - } - this.setState({buildsets: null}) - fetchBuildsets(this.props.tenant.apiPrefix, queryString).then(response => { - this.setState({buildsets: response.data}) - }) + // When building the filter query for the API we can't rely on the location + // search parameters. Although, we've updated them in the updateUrl() method + // they always have the same value in here (the values when the page was + // first loaded). Most probably that's the case because the location is + // passed as prop and doesn't change since the page itself wasn't + // re-rendered. + const queryString = buildQueryString(filters) + this.setState({ fetching: true }) + fetchBuildsets(this.props.tenant.apiPrefix, queryString).then( + (response) => { + this.setState({ + buildsets: response.data, + fetching: false, + }) + } + ) } - componentDidMount () { + componentDidMount() { document.title = 'Zuul Buildsets' if (this.props.tenant.name) { - this.updateData(this.getFilterFromUrl()) + this.updateData(this.state.filters) } } - componentDidUpdate (prevProps) { + componentDidUpdate(prevProps) { + const { filters } = this.state if (this.props.tenant.name !== prevProps.tenant.name) { - this.updateData(this.getFilterFromUrl()) + this.updateData(filters) } } - prepareTableHeaders() { - const headerFormat = value => <Table.Heading>{value}</Table.Heading> - const cellFormat = (value) => <Table.Cell>{value}</Table.Cell> - const linkChangeFormat = (value, rowdata) => ( - <Table.Cell> - <a href={rowdata.rowData.ref_url}> - {value ? - rowdata.rowData.change + ',' + rowdata.rowData.patchset : - rowdata.rowData.newrev ? - rowdata.rowData.newrev.substr(0, 7) : - rowdata.rowData.branch} - </a> - </Table.Cell> - ) - const linkBuildsetFormat = (value, rowdata) => ( - <Table.Cell> - <Link - to={this.props.tenant.linkPrefix + - '/buildset/' + rowdata.rowData.uuid}> - {value} - </Link> - </Table.Cell> - ) - this.columns = [] - this.filterTypes = [] - const myColumns = [ - 'project', - 'branch', - 'pipeline', - 'change', - 'result'] - myColumns.forEach(column => { - let prop = column - let formatter = cellFormat - if (column === 'change') { - formatter = linkChangeFormat - } else if (column === 'result') { - formatter = linkBuildsetFormat - } - const label = column.charAt(0).toUpperCase() + column.slice(1) - this.columns.push({ - header: {label: label, formatters: [headerFormat]}, - property: prop, - cell: {formatters: [formatter]} - }) - if (column !== 'builds') { - this.filterTypes.push({ - id: prop, - title: label, - placeholder: 'Filter by ' + label, - filterType: 'text', - }) + handleFilterChange = (filters) => { + const { location, history } = this.props + // We must update the URL parameters before the state. Otherwise, the URL + // will always be one filter selection behind the state. But as the URL + // reflects our state this should be ok. + writeFiltersToUrl(filters, location, history) + this.updateData(filters) + this.setState(() => { + return { + filters: filters, } }) - // Add buildset filter at the end - this.filterTypes.push({ - id: 'uuid', - title: 'Buildset', - placeholder: 'Filter by Buildset UUID', - filterType: 'text', - }) } - renderTable (buildsets) { - return ( - <Table.PfProvider - striped - bordered - columns={this.columns} - > - <Table.Header/> - <Table.Body - rows={buildsets} - rowKey='uuid' - onRow={(row) => { - switch (row.result) { - case 'SUCCESS': - return { className: 'success' } - default: - return { className: 'warning' } - } - }} /> - </Table.PfProvider>) + handleClearFilters = () => { + // Delete the values for each filter category + const filters = this.filterCategories.reduce((filterDict, category) => { + filterDict[category.key] = [] + return filterDict + }, {}) + this.handleFilterChange(filters) } render() { - const { buildsets } = this.state + const { buildsets, fetching, filters } = this.state return ( <PageSection variant={PageSectionVariants.light}> - {this.renderFilter()} - {buildsets ? this.renderTable(buildsets) : <p>Loading...</p>} + <FilterToolbar + filterCategories={this.filterCategories} + onFilterChange={this.handleFilterChange} + filters={filters} + /> + <BuildsetTable + buildsets={buildsets} + fetching={fetching} + onClearFilters={this.handleClearFilters} + /> </PageSection> ) } } -export default connect(state => ({tenant: state.tenant}))(BuildsetsPage) +export default connect((state) => ({ tenant: state.tenant }))(BuildsetsPage) diff --git a/web/src/routes.js b/web/src/routes.js index 1a0f2aaf5..07569dc22 100644 --- a/web/src/routes.js +++ b/web/src/routes.js @@ -89,7 +89,12 @@ const routes = () => [ { to: '/build/:buildId', component: BuildPage, - props: {'activeTab': 'summary'}, + props: {'activeTab': 'results'}, + }, + { + to: '/build/:buildId/artifacts', + component: BuildPage, + props: {'activeTab': 'artifacts'}, }, { to: '/build/:buildId/logs', diff --git a/web/yarn.lock b/web/yarn.lock index f74fb7589..9d70c3f83 100644 --- a/web/yarn.lock +++ b/web/yarn.lock @@ -1510,33 +1510,51 @@ dependencies: "@types/node" ">= 8" -"@patternfly/react-core@^4.18.5": - version "4.18.5" - resolved "https://registry.yarnpkg.com/@patternfly/react-core/-/react-core-4.18.5.tgz#465ee3be0e58f7fdead9644ed2667f18eff0d684" - integrity sha512-wUHLXPOklcAVA9nCnmUvGwdfTZnypxNUnA0l+eEiq1QWhQoSRdI7S/HNOelYhpRjMMwPwy3yMsJUjHsXdqv2FQ== - dependencies: - "@patternfly/react-icons" "^4.3.5" - "@patternfly/react-styles" "^4.3.4" - "@patternfly/react-tokens" "^4.4.4" +"@patternfly/patternfly@4.31.6": + version "4.31.6" + resolved "https://registry.yarnpkg.com/@patternfly/patternfly/-/patternfly-4.31.6.tgz#ef9919df610171760cd19920a904ca9b09a74593" + integrity sha512-gp8tpbE4Z6C1PIQwNiWMjO5XSr/UGjXs4InL/zmxgZbToyizUxsudwJyCObtdvDNoN57ZJp0gYWYy0tIuwEyMA== + +"@patternfly/react-core@^4.40.4": + version "4.40.4" + resolved "https://registry.yarnpkg.com/@patternfly/react-core/-/react-core-4.40.4.tgz#e4409f89327e2fcdcd07a08833c0149e6f2f6966" + integrity sha512-NQuUgIVEty7BBNJMJAVRXejOGRGpRQwgQ8Rw/J/JlgkhtOrCSFX5cEbpAXMXLYWkJrz0++XfRK/FQMoQbvS2hQ== + dependencies: + "@patternfly/react-icons" "^4.7.2" + "@patternfly/react-styles" "^4.7.2" + "@patternfly/react-tokens" "^4.9.4" focus-trap "4.0.2" react-dropzone "9.0.0" tippy.js "5.1.2" tslib "^1.11.1" -"@patternfly/react-icons@^4.3.5": - version "4.3.5" - resolved "https://registry.yarnpkg.com/@patternfly/react-icons/-/react-icons-4.3.5.tgz#b98c5af80f4729e6203c8e799ace2f57308b3b9a" - integrity sha512-+GublxpFXR+y/5zygf9q00/LvIvso8jr0mxZGhVxsKmi2dUu7xAvN+T+5vjS9fiMbXf7WXsSPXST/UTiBIVTdQ== - -"@patternfly/react-styles@^4.3.4": - version "4.3.4" - resolved "https://registry.yarnpkg.com/@patternfly/react-styles/-/react-styles-4.3.4.tgz#ba62f983f1bd2586d2878d8a38912442ebca85e8" - integrity sha512-d5W5G9g7sr7DthGPFiF6Oa33w8JFJ+ocLZDogyZcS1Oq0BJJX8j+hZNXZfhXxmHoXufxQL6RJ4dOyoa2zEZUvA== +"@patternfly/react-icons@^4.7.2": + version "4.7.2" + resolved "https://registry.yarnpkg.com/@patternfly/react-icons/-/react-icons-4.7.2.tgz#f4ad252cb5682bd95da474ce9ce6ddf7fb3a1ac1" + integrity sha512-r1yCVHxUtRSblo8VwfaUM0d49z4eToZXAI0VzOnfKPRgSmGZrn6l8soQgDDtyQsSDr534Qvm55y/qLrlR9JCnw== + +"@patternfly/react-styles@^4.7.2": + version "4.7.2" + resolved "https://registry.yarnpkg.com/@patternfly/react-styles/-/react-styles-4.7.2.tgz#6671a243401ef55adddcb0e0922f5f5f4eea840e" + integrity sha512-r3zyrt1mXcqdXaEq+otl1cGsN0Ou1k8uIJSY+4EGe2A5jLGbX3vBTwUrpPKLB6tUdNL+mZriFf+3oKhWbVZDkw== + +"@patternfly/react-table@^4.15.5": + version "4.15.5" + resolved "https://registry.yarnpkg.com/@patternfly/react-table/-/react-table-4.15.5.tgz#7fc3fcd37a6fd4dca00cc32d24c76199ee41a7f1" + integrity sha512-GlyKrEDMY+yLvczj5rWpNKcUp90Ib7alKV9JK8rVLOpTsukQ0QplXxYFsnIrombcaw2V54XVdflZGjsB0GoHEw== + dependencies: + "@patternfly/patternfly" "4.31.6" + "@patternfly/react-core" "^4.40.4" + "@patternfly/react-icons" "^4.7.2" + "@patternfly/react-styles" "^4.7.2" + "@patternfly/react-tokens" "^4.9.4" + lodash "^4.17.19" + tslib "^1.11.1" -"@patternfly/react-tokens@^4.4.4": - version "4.4.4" - resolved "https://registry.yarnpkg.com/@patternfly/react-tokens/-/react-tokens-4.4.4.tgz#4a6fc7234908343087ccbc8e1ae53336d7fc7311" - integrity sha512-vhDBtwkp1PTAqXDjAsUPRf/ewBh2Asong8MPr9ZGeXAeOULW8creW7GJx+JZX9eaEJMA3ESMeZ6wZ5j/yyWMGQ== +"@patternfly/react-tokens@^4.9.4": + version "4.9.4" + resolved "https://registry.yarnpkg.com/@patternfly/react-tokens/-/react-tokens-4.9.4.tgz#71ea3c33045fb29bcc8d98f2c0f07bfcdc89a12c" + integrity sha512-AJpcAvzWXvfThT2mx24rV7OJSHvZnIsOP1bVrXiubpFAJhi/Suq+LGe/lTPUnuSXaflwyDBRZDXWWmJb4yaWqg== "@semantic-release/commit-analyzer@^6.1.0": version "6.3.3" @@ -8896,6 +8914,11 @@ lodash@4.17.15, "lodash@>=3.5 <5", lodash@^4.17.10, lodash@^4.17.11, lodash@^4.1 resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.15.tgz#b447f6670a0455bbfeedd11392eff330ea097548" integrity sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A== +lodash@^4.17.19: + version "4.17.20" + resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.20.tgz#b44a9b6297bcb698f1c51a3545a2b3b368d59c52" + integrity sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA== + loglevel@^1.6.6: version "1.6.8" resolved "https://registry.yarnpkg.com/loglevel/-/loglevel-1.6.8.tgz#8a25fb75d092230ecd4457270d80b54e28011171" diff --git a/zuul/ansible/2.7/action/__init__.py b/zuul/ansible/2.7/action/__init__.py deleted file mode 120000 index 4048e7ac1..000000000 --- a/zuul/ansible/2.7/action/__init__.py +++ /dev/null @@ -1 +0,0 @@ -../../base/action/__init__.py
\ No newline at end of file diff --git a/zuul/ansible/2.7/action/add_host.py b/zuul/ansible/2.7/action/add_host.py deleted file mode 120000 index fdb7f466b..000000000 --- a/zuul/ansible/2.7/action/add_host.py +++ /dev/null @@ -1 +0,0 @@ -../../base/action/add_host.py
\ No newline at end of file diff --git a/zuul/ansible/2.7/action/add_host.pyi b/zuul/ansible/2.7/action/add_host.pyi deleted file mode 120000 index 9257939ce..000000000 --- a/zuul/ansible/2.7/action/add_host.pyi +++ /dev/null @@ -1 +0,0 @@ -../../base/action/add_host.pyi
\ No newline at end of file diff --git a/zuul/ansible/2.7/action/aireos.py b/zuul/ansible/2.7/action/aireos.py deleted file mode 120000 index f2459ab86..000000000 --- a/zuul/ansible/2.7/action/aireos.py +++ /dev/null @@ -1 +0,0 @@ -../../base/action/aireos.py
\ No newline at end of file diff --git a/zuul/ansible/2.7/action/aireos.pyi b/zuul/ansible/2.7/action/aireos.pyi deleted file mode 120000 index f58722a86..000000000 --- a/zuul/ansible/2.7/action/aireos.pyi +++ /dev/null @@ -1 +0,0 @@ -../../base/action/aireos.pyi
\ No newline at end of file diff --git a/zuul/ansible/2.7/action/aireos_config.py b/zuul/ansible/2.7/action/aireos_config.py deleted file mode 120000 index acc0dd85e..000000000 --- a/zuul/ansible/2.7/action/aireos_config.py +++ /dev/null @@ -1 +0,0 @@ -../../base/action/aireos_config.py
\ No newline at end of file diff --git a/zuul/ansible/2.7/action/aireos_config.pyi b/zuul/ansible/2.7/action/aireos_config.pyi deleted file mode 120000 index fc7b16cf6..000000000 --- a/zuul/ansible/2.7/action/aireos_config.pyi +++ /dev/null @@ -1 +0,0 @@ -../../base/action/aireos_config.pyi
\ No newline at end of file diff --git a/zuul/ansible/2.7/action/aruba.py b/zuul/ansible/2.7/action/aruba.py deleted file mode 120000 index 8784b92ed..000000000 --- a/zuul/ansible/2.7/action/aruba.py +++ /dev/null @@ -1 +0,0 @@ -../../base/action/aruba.py
\ No newline at end of file diff --git a/zuul/ansible/2.7/action/aruba.pyi b/zuul/ansible/2.7/action/aruba.pyi deleted file mode 120000 index 228b3ecfe..000000000 --- a/zuul/ansible/2.7/action/aruba.pyi +++ /dev/null @@ -1 +0,0 @@ -../../base/action/aruba.pyi
\ No newline at end of file diff --git a/zuul/ansible/2.7/action/aruba_config.py b/zuul/ansible/2.7/action/aruba_config.py deleted file mode 120000 index f50d9082b..000000000 --- a/zuul/ansible/2.7/action/aruba_config.py +++ /dev/null @@ -1 +0,0 @@ -../../base/action/aruba_config.py
\ No newline at end of file diff --git a/zuul/ansible/2.7/action/aruba_config.pyi b/zuul/ansible/2.7/action/aruba_config.pyi deleted file mode 120000 index bab9d28fd..000000000 --- a/zuul/ansible/2.7/action/aruba_config.pyi +++ /dev/null @@ -1 +0,0 @@ -../../base/action/aruba_config.pyi
\ No newline at end of file diff --git a/zuul/ansible/2.7/action/asa.py b/zuul/ansible/2.7/action/asa.py deleted file mode 120000 index 3e7e93dc0..000000000 --- a/zuul/ansible/2.7/action/asa.py +++ /dev/null @@ -1 +0,0 @@ -../../base/action/asa.py
\ No newline at end of file diff --git a/zuul/ansible/2.7/action/asa.pyi b/zuul/ansible/2.7/action/asa.pyi deleted file mode 120000 index e0cdc24b5..000000000 --- a/zuul/ansible/2.7/action/asa.pyi +++ /dev/null @@ -1 +0,0 @@ -../../base/action/asa.pyi
\ No newline at end of file diff --git a/zuul/ansible/2.7/action/asa_config.py b/zuul/ansible/2.7/action/asa_config.py deleted file mode 120000 index 598f400d5..000000000 --- a/zuul/ansible/2.7/action/asa_config.py +++ /dev/null @@ -1 +0,0 @@ -../../base/action/asa_config.py
\ No newline at end of file diff --git a/zuul/ansible/2.7/action/asa_config.pyi b/zuul/ansible/2.7/action/asa_config.pyi deleted file mode 120000 index 86fe03484..000000000 --- a/zuul/ansible/2.7/action/asa_config.pyi +++ /dev/null @@ -1 +0,0 @@ -../../base/action/asa_config.pyi
\ No newline at end of file diff --git a/zuul/ansible/2.7/action/asa_template.py b/zuul/ansible/2.7/action/asa_template.py deleted file mode 120000 index e1e8eecbe..000000000 --- a/zuul/ansible/2.7/action/asa_template.py +++ /dev/null @@ -1 +0,0 @@ -../../base/action/asa_template.py
\ No newline at end of file diff --git a/zuul/ansible/2.7/action/asa_template.pyi b/zuul/ansible/2.7/action/asa_template.pyi deleted file mode 120000 index 0753101f1..000000000 --- a/zuul/ansible/2.7/action/asa_template.pyi +++ /dev/null @@ -1 +0,0 @@ -../../base/action/asa_template.pyi
\ No newline at end of file diff --git a/zuul/ansible/2.7/action/assemble.py b/zuul/ansible/2.7/action/assemble.py deleted file mode 120000 index 22273b123..000000000 --- a/zuul/ansible/2.7/action/assemble.py +++ /dev/null @@ -1 +0,0 @@ -../../base/action/assemble.py
\ No newline at end of file diff --git a/zuul/ansible/2.7/action/assemble.pyi b/zuul/ansible/2.7/action/assemble.pyi deleted file mode 120000 index 3a7074ec7..000000000 --- a/zuul/ansible/2.7/action/assemble.pyi +++ /dev/null @@ -1 +0,0 @@ -../../base/action/assemble.pyi
\ No newline at end of file diff --git a/zuul/ansible/2.7/action/aws_s3.py b/zuul/ansible/2.7/action/aws_s3.py deleted file mode 120000 index a2dabf9f8..000000000 --- a/zuul/ansible/2.7/action/aws_s3.py +++ /dev/null @@ -1 +0,0 @@ -../../base/action/aws_s3.py
\ No newline at end of file diff --git a/zuul/ansible/2.7/action/aws_s3.pyi b/zuul/ansible/2.7/action/aws_s3.pyi deleted file mode 120000 index 5ac40a7db..000000000 --- a/zuul/ansible/2.7/action/aws_s3.pyi +++ /dev/null @@ -1 +0,0 @@ -../../base/action/aws_s3.pyi
\ No newline at end of file diff --git a/zuul/ansible/2.7/action/ce.py b/zuul/ansible/2.7/action/ce.py deleted file mode 120000 index db95d9121..000000000 --- a/zuul/ansible/2.7/action/ce.py +++ /dev/null @@ -1 +0,0 @@ -../../base/action/ce.py
\ No newline at end of file diff --git a/zuul/ansible/2.7/action/ce.pyi b/zuul/ansible/2.7/action/ce.pyi deleted file mode 120000 index ba2365f2b..000000000 --- a/zuul/ansible/2.7/action/ce.pyi +++ /dev/null @@ -1 +0,0 @@ -../../base/action/ce.pyi
\ No newline at end of file diff --git a/zuul/ansible/2.7/action/ce_config.py b/zuul/ansible/2.7/action/ce_config.py deleted file mode 120000 index ec0319843..000000000 --- a/zuul/ansible/2.7/action/ce_config.py +++ /dev/null @@ -1 +0,0 @@ -../../base/action/ce_config.py
\ No newline at end of file diff --git a/zuul/ansible/2.7/action/ce_config.pyi b/zuul/ansible/2.7/action/ce_config.pyi deleted file mode 120000 index b762785d1..000000000 --- a/zuul/ansible/2.7/action/ce_config.pyi +++ /dev/null @@ -1 +0,0 @@ -../../base/action/ce_config.pyi
\ No newline at end of file diff --git a/zuul/ansible/2.7/action/ce_template.py b/zuul/ansible/2.7/action/ce_template.py deleted file mode 120000 index aa59e80a0..000000000 --- a/zuul/ansible/2.7/action/ce_template.py +++ /dev/null @@ -1 +0,0 @@ -../../base/action/ce_template.py
\ No newline at end of file diff --git a/zuul/ansible/2.7/action/ce_template.pyi b/zuul/ansible/2.7/action/ce_template.pyi deleted file mode 120000 index 75ba2250c..000000000 --- a/zuul/ansible/2.7/action/ce_template.pyi +++ /dev/null @@ -1 +0,0 @@ -../../base/action/ce_template.pyi
\ No newline at end of file diff --git a/zuul/ansible/2.7/action/copy.py b/zuul/ansible/2.7/action/copy.py deleted file mode 120000 index 1c0c2291f..000000000 --- a/zuul/ansible/2.7/action/copy.py +++ /dev/null @@ -1 +0,0 @@ -../../base/action/copy.py
\ No newline at end of file diff --git a/zuul/ansible/2.7/action/copy.pyi b/zuul/ansible/2.7/action/copy.pyi deleted file mode 120000 index 189722f2a..000000000 --- a/zuul/ansible/2.7/action/copy.pyi +++ /dev/null @@ -1 +0,0 @@ -../../base/action/copy.pyi
\ No newline at end of file diff --git a/zuul/ansible/2.7/action/dellos10_config.py b/zuul/ansible/2.7/action/dellos10_config.py deleted file mode 120000 index caf6ce6c3..000000000 --- a/zuul/ansible/2.7/action/dellos10_config.py +++ /dev/null @@ -1 +0,0 @@ -../../base/action/dellos10_config.py
\ No newline at end of file diff --git a/zuul/ansible/2.7/action/dellos10_config.pyi b/zuul/ansible/2.7/action/dellos10_config.pyi deleted file mode 120000 index ede9a927e..000000000 --- a/zuul/ansible/2.7/action/dellos10_config.pyi +++ /dev/null @@ -1 +0,0 @@ -../../base/action/dellos10_config.pyi
\ No newline at end of file diff --git a/zuul/ansible/2.7/action/dellos6_config.py b/zuul/ansible/2.7/action/dellos6_config.py deleted file mode 120000 index 080fcce4c..000000000 --- a/zuul/ansible/2.7/action/dellos6_config.py +++ /dev/null @@ -1 +0,0 @@ -../../base/action/dellos6_config.py
\ No newline at end of file diff --git a/zuul/ansible/2.7/action/dellos6_config.pyi b/zuul/ansible/2.7/action/dellos6_config.pyi deleted file mode 120000 index d5cf3ce15..000000000 --- a/zuul/ansible/2.7/action/dellos6_config.pyi +++ /dev/null @@ -1 +0,0 @@ -../../base/action/dellos6_config.pyi
\ No newline at end of file diff --git a/zuul/ansible/2.7/action/dellos9_config.py b/zuul/ansible/2.7/action/dellos9_config.py deleted file mode 120000 index c2e79870c..000000000 --- a/zuul/ansible/2.7/action/dellos9_config.py +++ /dev/null @@ -1 +0,0 @@ -../../base/action/dellos9_config.py
\ No newline at end of file diff --git a/zuul/ansible/2.7/action/dellos9_config.pyi b/zuul/ansible/2.7/action/dellos9_config.pyi deleted file mode 120000 index b50389ab4..000000000 --- a/zuul/ansible/2.7/action/dellos9_config.pyi +++ /dev/null @@ -1 +0,0 @@ -../../base/action/dellos9_config.pyi
\ No newline at end of file diff --git a/zuul/ansible/2.7/action/eos_config.py b/zuul/ansible/2.7/action/eos_config.py deleted file mode 120000 index 4d1ab53bb..000000000 --- a/zuul/ansible/2.7/action/eos_config.py +++ /dev/null @@ -1 +0,0 @@ -../../base/action/eos_config.py
\ No newline at end of file diff --git a/zuul/ansible/2.7/action/eos_config.pyi b/zuul/ansible/2.7/action/eos_config.pyi deleted file mode 120000 index f6d058ba1..000000000 --- a/zuul/ansible/2.7/action/eos_config.pyi +++ /dev/null @@ -1 +0,0 @@ -../../base/action/eos_config.pyi
\ No newline at end of file diff --git a/zuul/ansible/2.7/action/eos_template.py b/zuul/ansible/2.7/action/eos_template.py deleted file mode 120000 index a108bf66f..000000000 --- a/zuul/ansible/2.7/action/eos_template.py +++ /dev/null @@ -1 +0,0 @@ -../../base/action/eos_template.py
\ No newline at end of file diff --git a/zuul/ansible/2.7/action/eos_template.pyi b/zuul/ansible/2.7/action/eos_template.pyi deleted file mode 120000 index b9cac42e8..000000000 --- a/zuul/ansible/2.7/action/eos_template.pyi +++ /dev/null @@ -1 +0,0 @@ -../../base/action/eos_template.pyi
\ No newline at end of file diff --git a/zuul/ansible/2.7/action/fetch.py b/zuul/ansible/2.7/action/fetch.py deleted file mode 120000 index d53e02548..000000000 --- a/zuul/ansible/2.7/action/fetch.py +++ /dev/null @@ -1 +0,0 @@ -../../base/action/fetch.py
\ No newline at end of file diff --git a/zuul/ansible/2.7/action/fetch.pyi b/zuul/ansible/2.7/action/fetch.pyi deleted file mode 120000 index b3ae31523..000000000 --- a/zuul/ansible/2.7/action/fetch.pyi +++ /dev/null @@ -1 +0,0 @@ -../../base/action/fetch.pyi
\ No newline at end of file diff --git a/zuul/ansible/2.7/action/fortios_config.py b/zuul/ansible/2.7/action/fortios_config.py deleted file mode 120000 index ef38743bc..000000000 --- a/zuul/ansible/2.7/action/fortios_config.py +++ /dev/null @@ -1 +0,0 @@ -../../base/action/fortios_config.py
\ No newline at end of file diff --git a/zuul/ansible/2.7/action/fortios_config.pyi b/zuul/ansible/2.7/action/fortios_config.pyi deleted file mode 120000 index fa334a059..000000000 --- a/zuul/ansible/2.7/action/fortios_config.pyi +++ /dev/null @@ -1 +0,0 @@ -../../base/action/fortios_config.pyi
\ No newline at end of file diff --git a/zuul/ansible/2.7/action/include_vars.py b/zuul/ansible/2.7/action/include_vars.py deleted file mode 120000 index 832d5a27a..000000000 --- a/zuul/ansible/2.7/action/include_vars.py +++ /dev/null @@ -1 +0,0 @@ -../../base/action/include_vars.py
\ No newline at end of file diff --git a/zuul/ansible/2.7/action/include_vars.pyi b/zuul/ansible/2.7/action/include_vars.pyi deleted file mode 120000 index 4099c1ae6..000000000 --- a/zuul/ansible/2.7/action/include_vars.pyi +++ /dev/null @@ -1 +0,0 @@ -../../base/action/include_vars.pyi
\ No newline at end of file diff --git a/zuul/ansible/2.7/action/ios_config.py b/zuul/ansible/2.7/action/ios_config.py deleted file mode 120000 index f1495d47e..000000000 --- a/zuul/ansible/2.7/action/ios_config.py +++ /dev/null @@ -1 +0,0 @@ -../../base/action/ios_config.py
\ No newline at end of file diff --git a/zuul/ansible/2.7/action/ios_config.pyi b/zuul/ansible/2.7/action/ios_config.pyi deleted file mode 120000 index e6af5e34c..000000000 --- a/zuul/ansible/2.7/action/ios_config.pyi +++ /dev/null @@ -1 +0,0 @@ -../../base/action/ios_config.pyi
\ No newline at end of file diff --git a/zuul/ansible/2.7/action/ios_template.py b/zuul/ansible/2.7/action/ios_template.py deleted file mode 120000 index ef863cedf..000000000 --- a/zuul/ansible/2.7/action/ios_template.py +++ /dev/null @@ -1 +0,0 @@ -../../base/action/ios_template.py
\ No newline at end of file diff --git a/zuul/ansible/2.7/action/ios_template.pyi b/zuul/ansible/2.7/action/ios_template.pyi deleted file mode 120000 index c37247812..000000000 --- a/zuul/ansible/2.7/action/ios_template.pyi +++ /dev/null @@ -1 +0,0 @@ -../../base/action/ios_template.pyi
\ No newline at end of file diff --git a/zuul/ansible/2.7/action/iosxr_config.py b/zuul/ansible/2.7/action/iosxr_config.py deleted file mode 120000 index 4eb293626..000000000 --- a/zuul/ansible/2.7/action/iosxr_config.py +++ /dev/null @@ -1 +0,0 @@ -../../base/action/iosxr_config.py
\ No newline at end of file diff --git a/zuul/ansible/2.7/action/iosxr_config.pyi b/zuul/ansible/2.7/action/iosxr_config.pyi deleted file mode 120000 index 70a2d67a3..000000000 --- a/zuul/ansible/2.7/action/iosxr_config.pyi +++ /dev/null @@ -1 +0,0 @@ -../../base/action/iosxr_config.pyi
\ No newline at end of file diff --git a/zuul/ansible/2.7/action/iosxr_template.py b/zuul/ansible/2.7/action/iosxr_template.py deleted file mode 120000 index 6a60fa49e..000000000 --- a/zuul/ansible/2.7/action/iosxr_template.py +++ /dev/null @@ -1 +0,0 @@ -../../base/action/iosxr_template.py
\ No newline at end of file diff --git a/zuul/ansible/2.7/action/iosxr_template.pyi b/zuul/ansible/2.7/action/iosxr_template.pyi deleted file mode 120000 index 918f5a113..000000000 --- a/zuul/ansible/2.7/action/iosxr_template.pyi +++ /dev/null @@ -1 +0,0 @@ -../../base/action/iosxr_template.pyi
\ No newline at end of file diff --git a/zuul/ansible/2.7/action/junos_config.py b/zuul/ansible/2.7/action/junos_config.py deleted file mode 120000 index c50f3bc67..000000000 --- a/zuul/ansible/2.7/action/junos_config.py +++ /dev/null @@ -1 +0,0 @@ -../../base/action/junos_config.py
\ No newline at end of file diff --git a/zuul/ansible/2.7/action/junos_config.pyi b/zuul/ansible/2.7/action/junos_config.pyi deleted file mode 120000 index 31cbba451..000000000 --- a/zuul/ansible/2.7/action/junos_config.pyi +++ /dev/null @@ -1 +0,0 @@ -../../base/action/junos_config.pyi
\ No newline at end of file diff --git a/zuul/ansible/2.7/action/junos_template.py b/zuul/ansible/2.7/action/junos_template.py deleted file mode 120000 index a2119b7cd..000000000 --- a/zuul/ansible/2.7/action/junos_template.py +++ /dev/null @@ -1 +0,0 @@ -../../base/action/junos_template.py
\ No newline at end of file diff --git a/zuul/ansible/2.7/action/junos_template.pyi b/zuul/ansible/2.7/action/junos_template.pyi deleted file mode 120000 index e6ef22ba9..000000000 --- a/zuul/ansible/2.7/action/junos_template.pyi +++ /dev/null @@ -1 +0,0 @@ -../../base/action/junos_template.pyi
\ No newline at end of file diff --git a/zuul/ansible/2.7/action/net_banner.py b/zuul/ansible/2.7/action/net_banner.py deleted file mode 120000 index 69cc6d5a4..000000000 --- a/zuul/ansible/2.7/action/net_banner.py +++ /dev/null @@ -1 +0,0 @@ -../../base/action/net_banner.py
\ No newline at end of file diff --git a/zuul/ansible/2.7/action/net_banner.pyi b/zuul/ansible/2.7/action/net_banner.pyi deleted file mode 120000 index f926dbf70..000000000 --- a/zuul/ansible/2.7/action/net_banner.pyi +++ /dev/null @@ -1 +0,0 @@ -../../base/action/net_banner.pyi
\ No newline at end of file diff --git a/zuul/ansible/2.7/action/net_base.py b/zuul/ansible/2.7/action/net_base.py deleted file mode 120000 index f36b6ecc7..000000000 --- a/zuul/ansible/2.7/action/net_base.py +++ /dev/null @@ -1 +0,0 @@ -../../base/action/net_base.py
\ No newline at end of file diff --git a/zuul/ansible/2.7/action/net_base.pyi b/zuul/ansible/2.7/action/net_base.pyi deleted file mode 120000 index 9f6ce1b7d..000000000 --- a/zuul/ansible/2.7/action/net_base.pyi +++ /dev/null @@ -1 +0,0 @@ -../../base/action/net_base.pyi
\ No newline at end of file diff --git a/zuul/ansible/2.7/action/net_config.py b/zuul/ansible/2.7/action/net_config.py deleted file mode 120000 index a817ca518..000000000 --- a/zuul/ansible/2.7/action/net_config.py +++ /dev/null @@ -1 +0,0 @@ -../../base/action/net_config.py
\ No newline at end of file diff --git a/zuul/ansible/2.7/action/net_config.pyi b/zuul/ansible/2.7/action/net_config.pyi deleted file mode 120000 index 412b8110e..000000000 --- a/zuul/ansible/2.7/action/net_config.pyi +++ /dev/null @@ -1 +0,0 @@ -../../base/action/net_config.pyi
\ No newline at end of file diff --git a/zuul/ansible/2.7/action/net_get.py b/zuul/ansible/2.7/action/net_get.py deleted file mode 120000 index da3d7be0e..000000000 --- a/zuul/ansible/2.7/action/net_get.py +++ /dev/null @@ -1 +0,0 @@ -../../base/action/net_get.py
\ No newline at end of file diff --git a/zuul/ansible/2.7/action/net_get.pyi b/zuul/ansible/2.7/action/net_get.pyi deleted file mode 120000 index 015c4271b..000000000 --- a/zuul/ansible/2.7/action/net_get.pyi +++ /dev/null @@ -1 +0,0 @@ -../../base/action/net_get.pyi
\ No newline at end of file diff --git a/zuul/ansible/2.7/action/net_interface.py b/zuul/ansible/2.7/action/net_interface.py deleted file mode 120000 index 1d24f7821..000000000 --- a/zuul/ansible/2.7/action/net_interface.py +++ /dev/null @@ -1 +0,0 @@ -../../base/action/net_interface.py
\ No newline at end of file diff --git a/zuul/ansible/2.7/action/net_interface.pyi b/zuul/ansible/2.7/action/net_interface.pyi deleted file mode 120000 index 57bb45316..000000000 --- a/zuul/ansible/2.7/action/net_interface.pyi +++ /dev/null @@ -1 +0,0 @@ -../../base/action/net_interface.pyi
\ No newline at end of file diff --git a/zuul/ansible/2.7/action/net_l2_interface.py b/zuul/ansible/2.7/action/net_l2_interface.py deleted file mode 120000 index 1f44192c8..000000000 --- a/zuul/ansible/2.7/action/net_l2_interface.py +++ /dev/null @@ -1 +0,0 @@ -../../base/action/net_l2_interface.py
\ No newline at end of file diff --git a/zuul/ansible/2.7/action/net_l2_interface.pyi b/zuul/ansible/2.7/action/net_l2_interface.pyi deleted file mode 120000 index 2b780821d..000000000 --- a/zuul/ansible/2.7/action/net_l2_interface.pyi +++ /dev/null @@ -1 +0,0 @@ -../../base/action/net_l2_interface.pyi
\ No newline at end of file diff --git a/zuul/ansible/2.7/action/net_l3_interface.py b/zuul/ansible/2.7/action/net_l3_interface.py deleted file mode 120000 index 97017f4e3..000000000 --- a/zuul/ansible/2.7/action/net_l3_interface.py +++ /dev/null @@ -1 +0,0 @@ -../../base/action/net_l3_interface.py
\ No newline at end of file diff --git a/zuul/ansible/2.7/action/net_l3_interface.pyi b/zuul/ansible/2.7/action/net_l3_interface.pyi deleted file mode 120000 index 9317599a2..000000000 --- a/zuul/ansible/2.7/action/net_l3_interface.pyi +++ /dev/null @@ -1 +0,0 @@ -../../base/action/net_l3_interface.pyi
\ No newline at end of file diff --git a/zuul/ansible/2.7/action/net_linkagg.py b/zuul/ansible/2.7/action/net_linkagg.py deleted file mode 120000 index d3fc53dd1..000000000 --- a/zuul/ansible/2.7/action/net_linkagg.py +++ /dev/null @@ -1 +0,0 @@ -../../base/action/net_linkagg.py
\ No newline at end of file diff --git a/zuul/ansible/2.7/action/net_linkagg.pyi b/zuul/ansible/2.7/action/net_linkagg.pyi deleted file mode 120000 index b5fac56b6..000000000 --- a/zuul/ansible/2.7/action/net_linkagg.pyi +++ /dev/null @@ -1 +0,0 @@ -../../base/action/net_linkagg.pyi
\ No newline at end of file diff --git a/zuul/ansible/2.7/action/net_lldp.py b/zuul/ansible/2.7/action/net_lldp.py deleted file mode 120000 index 15fc3a859..000000000 --- a/zuul/ansible/2.7/action/net_lldp.py +++ /dev/null @@ -1 +0,0 @@ -../../base/action/net_lldp.py
\ No newline at end of file diff --git a/zuul/ansible/2.7/action/net_lldp.pyi b/zuul/ansible/2.7/action/net_lldp.pyi deleted file mode 120000 index a627fdec7..000000000 --- a/zuul/ansible/2.7/action/net_lldp.pyi +++ /dev/null @@ -1 +0,0 @@ -../../base/action/net_lldp.pyi
\ No newline at end of file diff --git a/zuul/ansible/2.7/action/net_lldp_interface.py b/zuul/ansible/2.7/action/net_lldp_interface.py deleted file mode 120000 index f57ec137f..000000000 --- a/zuul/ansible/2.7/action/net_lldp_interface.py +++ /dev/null @@ -1 +0,0 @@ -../../base/action/net_lldp_interface.py
\ No newline at end of file diff --git a/zuul/ansible/2.7/action/net_lldp_interface.pyi b/zuul/ansible/2.7/action/net_lldp_interface.pyi deleted file mode 120000 index fdd97b078..000000000 --- a/zuul/ansible/2.7/action/net_lldp_interface.pyi +++ /dev/null @@ -1 +0,0 @@ -../../base/action/net_lldp_interface.pyi
\ No newline at end of file diff --git a/zuul/ansible/2.7/action/net_logging.py b/zuul/ansible/2.7/action/net_logging.py deleted file mode 120000 index 57d9766aa..000000000 --- a/zuul/ansible/2.7/action/net_logging.py +++ /dev/null @@ -1 +0,0 @@ -../../base/action/net_logging.py
\ No newline at end of file diff --git a/zuul/ansible/2.7/action/net_logging.pyi b/zuul/ansible/2.7/action/net_logging.pyi deleted file mode 120000 index 1d6f1b029..000000000 --- a/zuul/ansible/2.7/action/net_logging.pyi +++ /dev/null @@ -1 +0,0 @@ -../../base/action/net_logging.pyi
\ No newline at end of file diff --git a/zuul/ansible/2.7/action/net_ping.py b/zuul/ansible/2.7/action/net_ping.py deleted file mode 120000 index 696d9f526..000000000 --- a/zuul/ansible/2.7/action/net_ping.py +++ /dev/null @@ -1 +0,0 @@ -../../base/action/net_ping.py
\ No newline at end of file diff --git a/zuul/ansible/2.7/action/net_ping.pyi b/zuul/ansible/2.7/action/net_ping.pyi deleted file mode 120000 index 03f84e975..000000000 --- a/zuul/ansible/2.7/action/net_ping.pyi +++ /dev/null @@ -1 +0,0 @@ -../../base/action/net_ping.pyi
\ No newline at end of file diff --git a/zuul/ansible/2.7/action/net_static_route.py b/zuul/ansible/2.7/action/net_static_route.py deleted file mode 120000 index c22b7f0e0..000000000 --- a/zuul/ansible/2.7/action/net_static_route.py +++ /dev/null @@ -1 +0,0 @@ -../../base/action/net_static_route.py
\ No newline at end of file diff --git a/zuul/ansible/2.7/action/net_static_route.pyi b/zuul/ansible/2.7/action/net_static_route.pyi deleted file mode 120000 index 859c20fec..000000000 --- a/zuul/ansible/2.7/action/net_static_route.pyi +++ /dev/null @@ -1 +0,0 @@ -../../base/action/net_static_route.pyi
\ No newline at end of file diff --git a/zuul/ansible/2.7/action/net_system.py b/zuul/ansible/2.7/action/net_system.py deleted file mode 120000 index a03dd93c9..000000000 --- a/zuul/ansible/2.7/action/net_system.py +++ /dev/null @@ -1 +0,0 @@ -../../base/action/net_system.py
\ No newline at end of file diff --git a/zuul/ansible/2.7/action/net_system.pyi b/zuul/ansible/2.7/action/net_system.pyi deleted file mode 120000 index cc159053b..000000000 --- a/zuul/ansible/2.7/action/net_system.pyi +++ /dev/null @@ -1 +0,0 @@ -../../base/action/net_system.pyi
\ No newline at end of file diff --git a/zuul/ansible/2.7/action/net_template.py b/zuul/ansible/2.7/action/net_template.py deleted file mode 120000 index 18ba965e4..000000000 --- a/zuul/ansible/2.7/action/net_template.py +++ /dev/null @@ -1 +0,0 @@ -../../base/action/net_template.py
\ No newline at end of file diff --git a/zuul/ansible/2.7/action/net_template.pyi b/zuul/ansible/2.7/action/net_template.pyi deleted file mode 120000 index 3bfbb6f0f..000000000 --- a/zuul/ansible/2.7/action/net_template.pyi +++ /dev/null @@ -1 +0,0 @@ -../../base/action/net_template.pyi
\ No newline at end of file diff --git a/zuul/ansible/2.7/action/net_user.py b/zuul/ansible/2.7/action/net_user.py deleted file mode 120000 index fcfb3f55a..000000000 --- a/zuul/ansible/2.7/action/net_user.py +++ /dev/null @@ -1 +0,0 @@ -../../base/action/net_user.py
\ No newline at end of file diff --git a/zuul/ansible/2.7/action/net_user.pyi b/zuul/ansible/2.7/action/net_user.pyi deleted file mode 120000 index 267f5da34..000000000 --- a/zuul/ansible/2.7/action/net_user.pyi +++ /dev/null @@ -1 +0,0 @@ -../../base/action/net_user.pyi
\ No newline at end of file diff --git a/zuul/ansible/2.7/action/net_vlan.py b/zuul/ansible/2.7/action/net_vlan.py deleted file mode 120000 index df275656f..000000000 --- a/zuul/ansible/2.7/action/net_vlan.py +++ /dev/null @@ -1 +0,0 @@ -../../base/action/net_vlan.py
\ No newline at end of file diff --git a/zuul/ansible/2.7/action/net_vlan.pyi b/zuul/ansible/2.7/action/net_vlan.pyi deleted file mode 120000 index 718074899..000000000 --- a/zuul/ansible/2.7/action/net_vlan.pyi +++ /dev/null @@ -1 +0,0 @@ -../../base/action/net_vlan.pyi
\ No newline at end of file diff --git a/zuul/ansible/2.7/action/net_vrf.py b/zuul/ansible/2.7/action/net_vrf.py deleted file mode 120000 index c757d2100..000000000 --- a/zuul/ansible/2.7/action/net_vrf.py +++ /dev/null @@ -1 +0,0 @@ -../../base/action/net_vrf.py
\ No newline at end of file diff --git a/zuul/ansible/2.7/action/net_vrf.pyi b/zuul/ansible/2.7/action/net_vrf.pyi deleted file mode 120000 index df043a0cd..000000000 --- a/zuul/ansible/2.7/action/net_vrf.pyi +++ /dev/null @@ -1 +0,0 @@ -../../base/action/net_vrf.pyi
\ No newline at end of file diff --git a/zuul/ansible/2.7/action/netconf_config.py b/zuul/ansible/2.7/action/netconf_config.py deleted file mode 120000 index 4d13bb82c..000000000 --- a/zuul/ansible/2.7/action/netconf_config.py +++ /dev/null @@ -1 +0,0 @@ -../../base/action/netconf_config.py
\ No newline at end of file diff --git a/zuul/ansible/2.7/action/netconf_config.pyi b/zuul/ansible/2.7/action/netconf_config.pyi deleted file mode 120000 index c75a746b4..000000000 --- a/zuul/ansible/2.7/action/netconf_config.pyi +++ /dev/null @@ -1 +0,0 @@ -../../base/action/netconf_config.pyi
\ No newline at end of file diff --git a/zuul/ansible/2.7/action/network.py b/zuul/ansible/2.7/action/network.py deleted file mode 120000 index 9e2d1baa4..000000000 --- a/zuul/ansible/2.7/action/network.py +++ /dev/null @@ -1 +0,0 @@ -../../base/action/network.py
\ No newline at end of file diff --git a/zuul/ansible/2.7/action/network.pyi b/zuul/ansible/2.7/action/network.pyi deleted file mode 120000 index e1842d197..000000000 --- a/zuul/ansible/2.7/action/network.pyi +++ /dev/null @@ -1 +0,0 @@ -../../base/action/network.pyi
\ No newline at end of file diff --git a/zuul/ansible/2.7/action/normal.py b/zuul/ansible/2.7/action/normal.py deleted file mode 120000 index 4e131d98d..000000000 --- a/zuul/ansible/2.7/action/normal.py +++ /dev/null @@ -1 +0,0 @@ -../../base/action/normal.py
\ No newline at end of file diff --git a/zuul/ansible/2.7/action/normal.pyi b/zuul/ansible/2.7/action/normal.pyi deleted file mode 120000 index bbbe0704c..000000000 --- a/zuul/ansible/2.7/action/normal.pyi +++ /dev/null @@ -1 +0,0 @@ -../../base/action/normal.pyi
\ No newline at end of file diff --git a/zuul/ansible/2.7/action/nxos_config.py b/zuul/ansible/2.7/action/nxos_config.py deleted file mode 120000 index 0cc233c34..000000000 --- a/zuul/ansible/2.7/action/nxos_config.py +++ /dev/null @@ -1 +0,0 @@ -../../base/action/nxos_config.py
\ No newline at end of file diff --git a/zuul/ansible/2.7/action/nxos_config.pyi b/zuul/ansible/2.7/action/nxos_config.pyi deleted file mode 120000 index b2e63b6e3..000000000 --- a/zuul/ansible/2.7/action/nxos_config.pyi +++ /dev/null @@ -1 +0,0 @@ -../../base/action/nxos_config.pyi
\ No newline at end of file diff --git a/zuul/ansible/2.7/action/nxos_template.py b/zuul/ansible/2.7/action/nxos_template.py deleted file mode 120000 index 3d599062a..000000000 --- a/zuul/ansible/2.7/action/nxos_template.py +++ /dev/null @@ -1 +0,0 @@ -../../base/action/nxos_template.py
\ No newline at end of file diff --git a/zuul/ansible/2.7/action/nxos_template.pyi b/zuul/ansible/2.7/action/nxos_template.pyi deleted file mode 120000 index e36fa4e90..000000000 --- a/zuul/ansible/2.7/action/nxos_template.pyi +++ /dev/null @@ -1 +0,0 @@ -../../base/action/nxos_template.pyi
\ No newline at end of file diff --git a/zuul/ansible/2.7/action/ops_config.py b/zuul/ansible/2.7/action/ops_config.py deleted file mode 120000 index 2abe0669d..000000000 --- a/zuul/ansible/2.7/action/ops_config.py +++ /dev/null @@ -1 +0,0 @@ -../../base/action/ops_config.py
\ No newline at end of file diff --git a/zuul/ansible/2.7/action/ops_config.pyi b/zuul/ansible/2.7/action/ops_config.pyi deleted file mode 120000 index 7517f9c7b..000000000 --- a/zuul/ansible/2.7/action/ops_config.pyi +++ /dev/null @@ -1 +0,0 @@ -../../base/action/ops_config.pyi
\ No newline at end of file diff --git a/zuul/ansible/2.7/action/ops_template.py b/zuul/ansible/2.7/action/ops_template.py deleted file mode 120000 index 5c841ca57..000000000 --- a/zuul/ansible/2.7/action/ops_template.py +++ /dev/null @@ -1 +0,0 @@ -../../base/action/ops_template.py
\ No newline at end of file diff --git a/zuul/ansible/2.7/action/ops_template.pyi b/zuul/ansible/2.7/action/ops_template.pyi deleted file mode 120000 index 4baa28a3f..000000000 --- a/zuul/ansible/2.7/action/ops_template.pyi +++ /dev/null @@ -1 +0,0 @@ -../../base/action/ops_template.pyi
\ No newline at end of file diff --git a/zuul/ansible/2.7/action/patch.py b/zuul/ansible/2.7/action/patch.py deleted file mode 120000 index 8996710ba..000000000 --- a/zuul/ansible/2.7/action/patch.py +++ /dev/null @@ -1 +0,0 @@ -../../base/action/patch.py
\ No newline at end of file diff --git a/zuul/ansible/2.7/action/patch.pyi b/zuul/ansible/2.7/action/patch.pyi deleted file mode 120000 index 577e43600..000000000 --- a/zuul/ansible/2.7/action/patch.pyi +++ /dev/null @@ -1 +0,0 @@ -../../base/action/patch.pyi
\ No newline at end of file diff --git a/zuul/ansible/2.7/action/raw.py b/zuul/ansible/2.7/action/raw.py deleted file mode 120000 index e4962dd8a..000000000 --- a/zuul/ansible/2.7/action/raw.py +++ /dev/null @@ -1 +0,0 @@ -../../base/action/raw.py
\ No newline at end of file diff --git a/zuul/ansible/2.7/action/raw.pyi b/zuul/ansible/2.7/action/raw.pyi deleted file mode 120000 index 741945ef2..000000000 --- a/zuul/ansible/2.7/action/raw.pyi +++ /dev/null @@ -1 +0,0 @@ -../../base/action/raw.pyi
\ No newline at end of file diff --git a/zuul/ansible/2.7/action/script.py b/zuul/ansible/2.7/action/script.py deleted file mode 120000 index 1cccf5805..000000000 --- a/zuul/ansible/2.7/action/script.py +++ /dev/null @@ -1 +0,0 @@ -../../base/action/script.py
\ No newline at end of file diff --git a/zuul/ansible/2.7/action/script.pyi b/zuul/ansible/2.7/action/script.pyi deleted file mode 120000 index aff69744b..000000000 --- a/zuul/ansible/2.7/action/script.pyi +++ /dev/null @@ -1 +0,0 @@ -../../base/action/script.pyi
\ No newline at end of file diff --git a/zuul/ansible/2.7/action/sros_config.py b/zuul/ansible/2.7/action/sros_config.py deleted file mode 120000 index 1762874e4..000000000 --- a/zuul/ansible/2.7/action/sros_config.py +++ /dev/null @@ -1 +0,0 @@ -../../base/action/sros_config.py
\ No newline at end of file diff --git a/zuul/ansible/2.7/action/sros_config.pyi b/zuul/ansible/2.7/action/sros_config.pyi deleted file mode 120000 index 453e4b8e1..000000000 --- a/zuul/ansible/2.7/action/sros_config.pyi +++ /dev/null @@ -1 +0,0 @@ -../../base/action/sros_config.pyi
\ No newline at end of file diff --git a/zuul/ansible/2.7/action/synchronize.py b/zuul/ansible/2.7/action/synchronize.py deleted file mode 120000 index f2126166c..000000000 --- a/zuul/ansible/2.7/action/synchronize.py +++ /dev/null @@ -1 +0,0 @@ -../../base/action/synchronize.py
\ No newline at end of file diff --git a/zuul/ansible/2.7/action/synchronize.pyi b/zuul/ansible/2.7/action/synchronize.pyi deleted file mode 120000 index 4ff1a2ee9..000000000 --- a/zuul/ansible/2.7/action/synchronize.pyi +++ /dev/null @@ -1 +0,0 @@ -../../base/action/synchronize.pyi
\ No newline at end of file diff --git a/zuul/ansible/2.7/action/template.py b/zuul/ansible/2.7/action/template.py deleted file mode 120000 index 720717d83..000000000 --- a/zuul/ansible/2.7/action/template.py +++ /dev/null @@ -1 +0,0 @@ -../../base/action/template.py
\ No newline at end of file diff --git a/zuul/ansible/2.7/action/template.pyi b/zuul/ansible/2.7/action/template.pyi deleted file mode 120000 index aed7fb07a..000000000 --- a/zuul/ansible/2.7/action/template.pyi +++ /dev/null @@ -1 +0,0 @@ -../../base/action/template.pyi
\ No newline at end of file diff --git a/zuul/ansible/2.7/action/unarchive.py b/zuul/ansible/2.7/action/unarchive.py deleted file mode 120000 index 2d7ce5aed..000000000 --- a/zuul/ansible/2.7/action/unarchive.py +++ /dev/null @@ -1 +0,0 @@ -../../base/action/unarchive.py
\ No newline at end of file diff --git a/zuul/ansible/2.7/action/unarchive.pyi b/zuul/ansible/2.7/action/unarchive.pyi deleted file mode 120000 index fead9ca55..000000000 --- a/zuul/ansible/2.7/action/unarchive.pyi +++ /dev/null @@ -1 +0,0 @@ -../../base/action/unarchive.pyi
\ No newline at end of file diff --git a/zuul/ansible/2.7/action/uri.py b/zuul/ansible/2.7/action/uri.py deleted file mode 120000 index 7c4b15f45..000000000 --- a/zuul/ansible/2.7/action/uri.py +++ /dev/null @@ -1 +0,0 @@ -../../base/action/uri.py
\ No newline at end of file diff --git a/zuul/ansible/2.7/action/uri.pyi b/zuul/ansible/2.7/action/uri.pyi deleted file mode 120000 index 015bb9fe3..000000000 --- a/zuul/ansible/2.7/action/uri.pyi +++ /dev/null @@ -1 +0,0 @@ -../../base/action/uri.pyi
\ No newline at end of file diff --git a/zuul/ansible/2.7/action/vyos_config.py b/zuul/ansible/2.7/action/vyos_config.py deleted file mode 120000 index 2732fd7a1..000000000 --- a/zuul/ansible/2.7/action/vyos_config.py +++ /dev/null @@ -1 +0,0 @@ -../../base/action/vyos_config.py
\ No newline at end of file diff --git a/zuul/ansible/2.7/action/vyos_config.pyi b/zuul/ansible/2.7/action/vyos_config.pyi deleted file mode 120000 index ac5d76632..000000000 --- a/zuul/ansible/2.7/action/vyos_config.pyi +++ /dev/null @@ -1 +0,0 @@ -../../base/action/vyos_config.pyi
\ No newline at end of file diff --git a/zuul/ansible/2.7/action/win_copy.py b/zuul/ansible/2.7/action/win_copy.py deleted file mode 120000 index 80c3f3846..000000000 --- a/zuul/ansible/2.7/action/win_copy.py +++ /dev/null @@ -1 +0,0 @@ -../../base/action/win_copy.py
\ No newline at end of file diff --git a/zuul/ansible/2.7/action/win_copy.pyi b/zuul/ansible/2.7/action/win_copy.pyi deleted file mode 120000 index 017c792ff..000000000 --- a/zuul/ansible/2.7/action/win_copy.pyi +++ /dev/null @@ -1 +0,0 @@ -../../base/action/win_copy.pyi
\ No newline at end of file diff --git a/zuul/ansible/2.7/action/win_template.py b/zuul/ansible/2.7/action/win_template.py deleted file mode 120000 index f2a8b2d63..000000000 --- a/zuul/ansible/2.7/action/win_template.py +++ /dev/null @@ -1 +0,0 @@ -../../base/action/win_template.py
\ No newline at end of file diff --git a/zuul/ansible/2.7/action/win_template.pyi b/zuul/ansible/2.7/action/win_template.pyi deleted file mode 120000 index 40825895b..000000000 --- a/zuul/ansible/2.7/action/win_template.pyi +++ /dev/null @@ -1 +0,0 @@ -../../base/action/win_template.pyi
\ No newline at end of file diff --git a/zuul/ansible/2.7/actiongeneral/__init__.py b/zuul/ansible/2.7/actiongeneral/__init__.py deleted file mode 120000 index 38d887448..000000000 --- a/zuul/ansible/2.7/actiongeneral/__init__.py +++ /dev/null @@ -1 +0,0 @@ -../../base/actiongeneral/__init__.py
\ No newline at end of file diff --git a/zuul/ansible/2.7/actiongeneral/zuul_return.py b/zuul/ansible/2.7/actiongeneral/zuul_return.py deleted file mode 120000 index 2f5b2559e..000000000 --- a/zuul/ansible/2.7/actiongeneral/zuul_return.py +++ /dev/null @@ -1 +0,0 @@ -../../base/actiongeneral/zuul_return.py
\ No newline at end of file diff --git a/zuul/ansible/2.7/callback/__init__.py b/zuul/ansible/2.7/callback/__init__.py deleted file mode 120000 index 00b974388..000000000 --- a/zuul/ansible/2.7/callback/__init__.py +++ /dev/null @@ -1 +0,0 @@ -../../base/callback/__init__.py
\ No newline at end of file diff --git a/zuul/ansible/2.7/callback/zuul_json.py b/zuul/ansible/2.7/callback/zuul_json.py deleted file mode 120000 index b0a07779b..000000000 --- a/zuul/ansible/2.7/callback/zuul_json.py +++ /dev/null @@ -1 +0,0 @@ -../../base/callback/zuul_json.py
\ No newline at end of file diff --git a/zuul/ansible/2.7/callback/zuul_stream.py b/zuul/ansible/2.7/callback/zuul_stream.py deleted file mode 120000 index f75561bf4..000000000 --- a/zuul/ansible/2.7/callback/zuul_stream.py +++ /dev/null @@ -1 +0,0 @@ -../../base/callback/zuul_stream.py
\ No newline at end of file diff --git a/zuul/ansible/2.7/callback/zuul_unreachable.py b/zuul/ansible/2.7/callback/zuul_unreachable.py deleted file mode 120000 index 205baca6f..000000000 --- a/zuul/ansible/2.7/callback/zuul_unreachable.py +++ /dev/null @@ -1 +0,0 @@ -../../base/callback/zuul_unreachable.py
\ No newline at end of file diff --git a/zuul/ansible/2.7/filter/__init__.py b/zuul/ansible/2.7/filter/__init__.py deleted file mode 120000 index f80a4da61..000000000 --- a/zuul/ansible/2.7/filter/__init__.py +++ /dev/null @@ -1 +0,0 @@ -../../base/filter/__init__.py
\ No newline at end of file diff --git a/zuul/ansible/2.7/filter/zuul_filters.py b/zuul/ansible/2.7/filter/zuul_filters.py deleted file mode 120000 index d406e5fe6..000000000 --- a/zuul/ansible/2.7/filter/zuul_filters.py +++ /dev/null @@ -1 +0,0 @@ -../../base/filter/zuul_filters.py
\ No newline at end of file diff --git a/zuul/ansible/2.7/library/__init__.py b/zuul/ansible/2.7/library/__init__.py deleted file mode 120000 index 0b68ce0f4..000000000 --- a/zuul/ansible/2.7/library/__init__.py +++ /dev/null @@ -1 +0,0 @@ -../../base/library/__init__.py
\ No newline at end of file diff --git a/zuul/ansible/2.7/library/command.py b/zuul/ansible/2.7/library/command.py deleted file mode 120000 index 9c7633169..000000000 --- a/zuul/ansible/2.7/library/command.py +++ /dev/null @@ -1 +0,0 @@ -../../base/library/command.py
\ No newline at end of file diff --git a/zuul/ansible/2.7/library/zuul_console.py b/zuul/ansible/2.7/library/zuul_console.py deleted file mode 120000 index 7c905e0f9..000000000 --- a/zuul/ansible/2.7/library/zuul_console.py +++ /dev/null @@ -1 +0,0 @@ -../../base/library/zuul_console.py
\ No newline at end of file diff --git a/zuul/ansible/2.7/logconfig.py b/zuul/ansible/2.7/logconfig.py deleted file mode 120000 index 767cb2e81..000000000 --- a/zuul/ansible/2.7/logconfig.py +++ /dev/null @@ -1 +0,0 @@ -../logconfig.py
\ No newline at end of file diff --git a/zuul/ansible/2.7/lookup/__init__.py b/zuul/ansible/2.7/lookup/__init__.py deleted file mode 120000 index b008ecb3b..000000000 --- a/zuul/ansible/2.7/lookup/__init__.py +++ /dev/null @@ -1 +0,0 @@ -../../base/lookup/__init__.py
\ No newline at end of file diff --git a/zuul/ansible/2.7/lookup/_banned.py b/zuul/ansible/2.7/lookup/_banned.py deleted file mode 120000 index 2e127f4e7..000000000 --- a/zuul/ansible/2.7/lookup/_banned.py +++ /dev/null @@ -1 +0,0 @@ -../../base/lookup/_banned.py
\ No newline at end of file diff --git a/zuul/ansible/2.7/lookup/_banned.pyi b/zuul/ansible/2.7/lookup/_banned.pyi deleted file mode 120000 index d7909135d..000000000 --- a/zuul/ansible/2.7/lookup/_banned.pyi +++ /dev/null @@ -1 +0,0 @@ -../../base/lookup/_banned.pyi
\ No newline at end of file diff --git a/zuul/ansible/2.7/lookup/chef_databag.py b/zuul/ansible/2.7/lookup/chef_databag.py deleted file mode 120000 index d4efe7e85..000000000 --- a/zuul/ansible/2.7/lookup/chef_databag.py +++ /dev/null @@ -1 +0,0 @@ -../../base/lookup/chef_databag.py
\ No newline at end of file diff --git a/zuul/ansible/2.7/lookup/chef_databag.pyi b/zuul/ansible/2.7/lookup/chef_databag.pyi deleted file mode 120000 index c06fc3001..000000000 --- a/zuul/ansible/2.7/lookup/chef_databag.pyi +++ /dev/null @@ -1 +0,0 @@ -../../base/lookup/chef_databag.pyi
\ No newline at end of file diff --git a/zuul/ansible/2.7/lookup/consul_kv.py b/zuul/ansible/2.7/lookup/consul_kv.py deleted file mode 120000 index 66b6f7617..000000000 --- a/zuul/ansible/2.7/lookup/consul_kv.py +++ /dev/null @@ -1 +0,0 @@ -../../base/lookup/consul_kv.py
\ No newline at end of file diff --git a/zuul/ansible/2.7/lookup/consul_kv.pyi b/zuul/ansible/2.7/lookup/consul_kv.pyi deleted file mode 120000 index 33d848347..000000000 --- a/zuul/ansible/2.7/lookup/consul_kv.pyi +++ /dev/null @@ -1 +0,0 @@ -../../base/lookup/consul_kv.pyi
\ No newline at end of file diff --git a/zuul/ansible/2.7/lookup/credstash.py b/zuul/ansible/2.7/lookup/credstash.py deleted file mode 120000 index 2c343a27e..000000000 --- a/zuul/ansible/2.7/lookup/credstash.py +++ /dev/null @@ -1 +0,0 @@ -../../base/lookup/credstash.py
\ No newline at end of file diff --git a/zuul/ansible/2.7/lookup/credstash.pyi b/zuul/ansible/2.7/lookup/credstash.pyi deleted file mode 120000 index 071af9d1a..000000000 --- a/zuul/ansible/2.7/lookup/credstash.pyi +++ /dev/null @@ -1 +0,0 @@ -../../base/lookup/credstash.pyi
\ No newline at end of file diff --git a/zuul/ansible/2.7/lookup/csvfile.py b/zuul/ansible/2.7/lookup/csvfile.py deleted file mode 120000 index 981754791..000000000 --- a/zuul/ansible/2.7/lookup/csvfile.py +++ /dev/null @@ -1 +0,0 @@ -../../base/lookup/csvfile.py
\ No newline at end of file diff --git a/zuul/ansible/2.7/lookup/csvfile.pyi b/zuul/ansible/2.7/lookup/csvfile.pyi deleted file mode 120000 index 18ded3684..000000000 --- a/zuul/ansible/2.7/lookup/csvfile.pyi +++ /dev/null @@ -1 +0,0 @@ -../../base/lookup/csvfile.pyi
\ No newline at end of file diff --git a/zuul/ansible/2.7/lookup/cyberarkpassword.py b/zuul/ansible/2.7/lookup/cyberarkpassword.py deleted file mode 120000 index a573c0cf3..000000000 --- a/zuul/ansible/2.7/lookup/cyberarkpassword.py +++ /dev/null @@ -1 +0,0 @@ -../../base/lookup/cyberarkpassword.py
\ No newline at end of file diff --git a/zuul/ansible/2.7/lookup/cyberarkpassword.pyi b/zuul/ansible/2.7/lookup/cyberarkpassword.pyi deleted file mode 120000 index 01543847e..000000000 --- a/zuul/ansible/2.7/lookup/cyberarkpassword.pyi +++ /dev/null @@ -1 +0,0 @@ -../../base/lookup/cyberarkpassword.pyi
\ No newline at end of file diff --git a/zuul/ansible/2.7/lookup/dig.py b/zuul/ansible/2.7/lookup/dig.py deleted file mode 120000 index 92a628c4a..000000000 --- a/zuul/ansible/2.7/lookup/dig.py +++ /dev/null @@ -1 +0,0 @@ -../../base/lookup/dig.py
\ No newline at end of file diff --git a/zuul/ansible/2.7/lookup/dig.pyi b/zuul/ansible/2.7/lookup/dig.pyi deleted file mode 120000 index 7268b42c3..000000000 --- a/zuul/ansible/2.7/lookup/dig.pyi +++ /dev/null @@ -1 +0,0 @@ -../../base/lookup/dig.pyi
\ No newline at end of file diff --git a/zuul/ansible/2.7/lookup/dnstxt.py b/zuul/ansible/2.7/lookup/dnstxt.py deleted file mode 120000 index aa3e0f2d9..000000000 --- a/zuul/ansible/2.7/lookup/dnstxt.py +++ /dev/null @@ -1 +0,0 @@ -../../base/lookup/dnstxt.py
\ No newline at end of file diff --git a/zuul/ansible/2.7/lookup/dnstxt.pyi b/zuul/ansible/2.7/lookup/dnstxt.pyi deleted file mode 120000 index 1c221784c..000000000 --- a/zuul/ansible/2.7/lookup/dnstxt.pyi +++ /dev/null @@ -1 +0,0 @@ -../../base/lookup/dnstxt.pyi
\ No newline at end of file diff --git a/zuul/ansible/2.7/lookup/env.py b/zuul/ansible/2.7/lookup/env.py deleted file mode 120000 index 124393630..000000000 --- a/zuul/ansible/2.7/lookup/env.py +++ /dev/null @@ -1 +0,0 @@ -../../base/lookup/env.py
\ No newline at end of file diff --git a/zuul/ansible/2.7/lookup/env.pyi b/zuul/ansible/2.7/lookup/env.pyi deleted file mode 120000 index e0ecae07a..000000000 --- a/zuul/ansible/2.7/lookup/env.pyi +++ /dev/null @@ -1 +0,0 @@ -../../base/lookup/env.pyi
\ No newline at end of file diff --git a/zuul/ansible/2.7/lookup/etcd.py b/zuul/ansible/2.7/lookup/etcd.py deleted file mode 120000 index c42007aa6..000000000 --- a/zuul/ansible/2.7/lookup/etcd.py +++ /dev/null @@ -1 +0,0 @@ -../../base/lookup/etcd.py
\ No newline at end of file diff --git a/zuul/ansible/2.7/lookup/etcd.pyi b/zuul/ansible/2.7/lookup/etcd.pyi deleted file mode 120000 index a1d4e0458..000000000 --- a/zuul/ansible/2.7/lookup/etcd.pyi +++ /dev/null @@ -1 +0,0 @@ -../../base/lookup/etcd.pyi
\ No newline at end of file diff --git a/zuul/ansible/2.7/lookup/file.py b/zuul/ansible/2.7/lookup/file.py deleted file mode 120000 index d1c0692d7..000000000 --- a/zuul/ansible/2.7/lookup/file.py +++ /dev/null @@ -1 +0,0 @@ -../../base/lookup/file.py
\ No newline at end of file diff --git a/zuul/ansible/2.7/lookup/file.pyi b/zuul/ansible/2.7/lookup/file.pyi deleted file mode 120000 index 588dc50c6..000000000 --- a/zuul/ansible/2.7/lookup/file.pyi +++ /dev/null @@ -1 +0,0 @@ -../../base/lookup/file.pyi
\ No newline at end of file diff --git a/zuul/ansible/2.7/lookup/fileglob.py b/zuul/ansible/2.7/lookup/fileglob.py deleted file mode 120000 index 0249540ac..000000000 --- a/zuul/ansible/2.7/lookup/fileglob.py +++ /dev/null @@ -1 +0,0 @@ -../../base/lookup/fileglob.py
\ No newline at end of file diff --git a/zuul/ansible/2.7/lookup/fileglob.pyi b/zuul/ansible/2.7/lookup/fileglob.pyi deleted file mode 120000 index ffce6462b..000000000 --- a/zuul/ansible/2.7/lookup/fileglob.pyi +++ /dev/null @@ -1 +0,0 @@ -../../base/lookup/fileglob.pyi
\ No newline at end of file diff --git a/zuul/ansible/2.7/lookup/filetree.py b/zuul/ansible/2.7/lookup/filetree.py deleted file mode 120000 index 96cc789ac..000000000 --- a/zuul/ansible/2.7/lookup/filetree.py +++ /dev/null @@ -1 +0,0 @@ -../../base/lookup/filetree.py
\ No newline at end of file diff --git a/zuul/ansible/2.7/lookup/filetree.pyi b/zuul/ansible/2.7/lookup/filetree.pyi deleted file mode 120000 index ca38966f5..000000000 --- a/zuul/ansible/2.7/lookup/filetree.pyi +++ /dev/null @@ -1 +0,0 @@ -../../base/lookup/filetree.pyi
\ No newline at end of file diff --git a/zuul/ansible/2.7/lookup/first_found.py b/zuul/ansible/2.7/lookup/first_found.py deleted file mode 120000 index 0a01ced20..000000000 --- a/zuul/ansible/2.7/lookup/first_found.py +++ /dev/null @@ -1 +0,0 @@ -../../base/lookup/first_found.py
\ No newline at end of file diff --git a/zuul/ansible/2.7/lookup/first_found.pyi b/zuul/ansible/2.7/lookup/first_found.pyi deleted file mode 120000 index a3a92f250..000000000 --- a/zuul/ansible/2.7/lookup/first_found.pyi +++ /dev/null @@ -1 +0,0 @@ -../../base/lookup/first_found.pyi
\ No newline at end of file diff --git a/zuul/ansible/2.7/lookup/hashi_vault.py b/zuul/ansible/2.7/lookup/hashi_vault.py deleted file mode 120000 index 6fd3dda3e..000000000 --- a/zuul/ansible/2.7/lookup/hashi_vault.py +++ /dev/null @@ -1 +0,0 @@ -../../base/lookup/hashi_vault.py
\ No newline at end of file diff --git a/zuul/ansible/2.7/lookup/hashi_vault.pyi b/zuul/ansible/2.7/lookup/hashi_vault.pyi deleted file mode 120000 index f0b0ceb19..000000000 --- a/zuul/ansible/2.7/lookup/hashi_vault.pyi +++ /dev/null @@ -1 +0,0 @@ -../../base/lookup/hashi_vault.pyi
\ No newline at end of file diff --git a/zuul/ansible/2.7/lookup/hiera.py b/zuul/ansible/2.7/lookup/hiera.py deleted file mode 120000 index 01e1883e3..000000000 --- a/zuul/ansible/2.7/lookup/hiera.py +++ /dev/null @@ -1 +0,0 @@ -../../base/lookup/hiera.py
\ No newline at end of file diff --git a/zuul/ansible/2.7/lookup/hiera.pyi b/zuul/ansible/2.7/lookup/hiera.pyi deleted file mode 120000 index b7756855a..000000000 --- a/zuul/ansible/2.7/lookup/hiera.pyi +++ /dev/null @@ -1 +0,0 @@ -../../base/lookup/hiera.pyi
\ No newline at end of file diff --git a/zuul/ansible/2.7/lookup/ini.py b/zuul/ansible/2.7/lookup/ini.py deleted file mode 120000 index 6453943b9..000000000 --- a/zuul/ansible/2.7/lookup/ini.py +++ /dev/null @@ -1 +0,0 @@ -../../base/lookup/ini.py
\ No newline at end of file diff --git a/zuul/ansible/2.7/lookup/ini.pyi b/zuul/ansible/2.7/lookup/ini.pyi deleted file mode 120000 index b66fe999a..000000000 --- a/zuul/ansible/2.7/lookup/ini.pyi +++ /dev/null @@ -1 +0,0 @@ -../../base/lookup/ini.pyi
\ No newline at end of file diff --git a/zuul/ansible/2.7/lookup/keyring.py b/zuul/ansible/2.7/lookup/keyring.py deleted file mode 120000 index 864d62d6f..000000000 --- a/zuul/ansible/2.7/lookup/keyring.py +++ /dev/null @@ -1 +0,0 @@ -../../base/lookup/keyring.py
\ No newline at end of file diff --git a/zuul/ansible/2.7/lookup/keyring.pyi b/zuul/ansible/2.7/lookup/keyring.pyi deleted file mode 120000 index 9a0c24b55..000000000 --- a/zuul/ansible/2.7/lookup/keyring.pyi +++ /dev/null @@ -1 +0,0 @@ -../../base/lookup/keyring.pyi
\ No newline at end of file diff --git a/zuul/ansible/2.7/lookup/lastpass.py b/zuul/ansible/2.7/lookup/lastpass.py deleted file mode 120000 index a28abff20..000000000 --- a/zuul/ansible/2.7/lookup/lastpass.py +++ /dev/null @@ -1 +0,0 @@ -../../base/lookup/lastpass.py
\ No newline at end of file diff --git a/zuul/ansible/2.7/lookup/lastpass.pyi b/zuul/ansible/2.7/lookup/lastpass.pyi deleted file mode 120000 index dfd6e6b31..000000000 --- a/zuul/ansible/2.7/lookup/lastpass.pyi +++ /dev/null @@ -1 +0,0 @@ -../../base/lookup/lastpass.pyi
\ No newline at end of file diff --git a/zuul/ansible/2.7/lookup/lines.py b/zuul/ansible/2.7/lookup/lines.py deleted file mode 120000 index c2cae632b..000000000 --- a/zuul/ansible/2.7/lookup/lines.py +++ /dev/null @@ -1 +0,0 @@ -../../base/lookup/lines.py
\ No newline at end of file diff --git a/zuul/ansible/2.7/lookup/lines.pyi b/zuul/ansible/2.7/lookup/lines.pyi deleted file mode 120000 index 3ddc687f9..000000000 --- a/zuul/ansible/2.7/lookup/lines.pyi +++ /dev/null @@ -1 +0,0 @@ -../../base/lookup/lines.pyi
\ No newline at end of file diff --git a/zuul/ansible/2.7/lookup/mongodb.py b/zuul/ansible/2.7/lookup/mongodb.py deleted file mode 120000 index 737037fa1..000000000 --- a/zuul/ansible/2.7/lookup/mongodb.py +++ /dev/null @@ -1 +0,0 @@ -../../base/lookup/mongodb.py
\ No newline at end of file diff --git a/zuul/ansible/2.7/lookup/mongodb.pyi b/zuul/ansible/2.7/lookup/mongodb.pyi deleted file mode 120000 index 4c8f754ef..000000000 --- a/zuul/ansible/2.7/lookup/mongodb.pyi +++ /dev/null @@ -1 +0,0 @@ -../../base/lookup/mongodb.pyi
\ No newline at end of file diff --git a/zuul/ansible/2.7/lookup/password.py b/zuul/ansible/2.7/lookup/password.py deleted file mode 120000 index 478e6bee6..000000000 --- a/zuul/ansible/2.7/lookup/password.py +++ /dev/null @@ -1 +0,0 @@ -../../base/lookup/password.py
\ No newline at end of file diff --git a/zuul/ansible/2.7/lookup/password.pyi b/zuul/ansible/2.7/lookup/password.pyi deleted file mode 120000 index 370986cbc..000000000 --- a/zuul/ansible/2.7/lookup/password.pyi +++ /dev/null @@ -1 +0,0 @@ -../../base/lookup/password.pyi
\ No newline at end of file diff --git a/zuul/ansible/2.7/lookup/passwordstore.py b/zuul/ansible/2.7/lookup/passwordstore.py deleted file mode 120000 index af4c4aa92..000000000 --- a/zuul/ansible/2.7/lookup/passwordstore.py +++ /dev/null @@ -1 +0,0 @@ -../../base/lookup/passwordstore.py
\ No newline at end of file diff --git a/zuul/ansible/2.7/lookup/passwordstore.pyi b/zuul/ansible/2.7/lookup/passwordstore.pyi deleted file mode 120000 index 9ba9b718a..000000000 --- a/zuul/ansible/2.7/lookup/passwordstore.pyi +++ /dev/null @@ -1 +0,0 @@ -../../base/lookup/passwordstore.pyi
\ No newline at end of file diff --git a/zuul/ansible/2.7/lookup/pipe.py b/zuul/ansible/2.7/lookup/pipe.py deleted file mode 120000 index ea688c3a3..000000000 --- a/zuul/ansible/2.7/lookup/pipe.py +++ /dev/null @@ -1 +0,0 @@ -../../base/lookup/pipe.py
\ No newline at end of file diff --git a/zuul/ansible/2.7/lookup/pipe.pyi b/zuul/ansible/2.7/lookup/pipe.pyi deleted file mode 120000 index 01d6b935d..000000000 --- a/zuul/ansible/2.7/lookup/pipe.pyi +++ /dev/null @@ -1 +0,0 @@ -../../base/lookup/pipe.pyi
\ No newline at end of file diff --git a/zuul/ansible/2.7/lookup/redis_kv.py b/zuul/ansible/2.7/lookup/redis_kv.py deleted file mode 120000 index a711e2651..000000000 --- a/zuul/ansible/2.7/lookup/redis_kv.py +++ /dev/null @@ -1 +0,0 @@ -../../base/lookup/redis_kv.py
\ No newline at end of file diff --git a/zuul/ansible/2.7/lookup/redis_kv.pyi b/zuul/ansible/2.7/lookup/redis_kv.pyi deleted file mode 120000 index da9acca45..000000000 --- a/zuul/ansible/2.7/lookup/redis_kv.pyi +++ /dev/null @@ -1 +0,0 @@ -../../base/lookup/redis_kv.pyi
\ No newline at end of file diff --git a/zuul/ansible/2.7/lookup/shelvefile.py b/zuul/ansible/2.7/lookup/shelvefile.py deleted file mode 120000 index 2c76ec937..000000000 --- a/zuul/ansible/2.7/lookup/shelvefile.py +++ /dev/null @@ -1 +0,0 @@ -../../base/lookup/shelvefile.py
\ No newline at end of file diff --git a/zuul/ansible/2.7/lookup/shelvefile.pyi b/zuul/ansible/2.7/lookup/shelvefile.pyi deleted file mode 120000 index 827f4b076..000000000 --- a/zuul/ansible/2.7/lookup/shelvefile.pyi +++ /dev/null @@ -1 +0,0 @@ -../../base/lookup/shelvefile.pyi
\ No newline at end of file diff --git a/zuul/ansible/2.7/lookup/template.py b/zuul/ansible/2.7/lookup/template.py deleted file mode 120000 index ef6565ceb..000000000 --- a/zuul/ansible/2.7/lookup/template.py +++ /dev/null @@ -1 +0,0 @@ -../../base/lookup/template.py
\ No newline at end of file diff --git a/zuul/ansible/2.7/lookup/template.pyi b/zuul/ansible/2.7/lookup/template.pyi deleted file mode 120000 index f03685d5c..000000000 --- a/zuul/ansible/2.7/lookup/template.pyi +++ /dev/null @@ -1 +0,0 @@ -../../base/lookup/template.pyi
\ No newline at end of file diff --git a/zuul/ansible/2.7/lookup/url.py b/zuul/ansible/2.7/lookup/url.py deleted file mode 120000 index f936b748e..000000000 --- a/zuul/ansible/2.7/lookup/url.py +++ /dev/null @@ -1 +0,0 @@ -../../base/lookup/url.py
\ No newline at end of file diff --git a/zuul/ansible/2.7/lookup/url.pyi b/zuul/ansible/2.7/lookup/url.pyi deleted file mode 120000 index fc13f47c5..000000000 --- a/zuul/ansible/2.7/lookup/url.pyi +++ /dev/null @@ -1 +0,0 @@ -../../base/lookup/url.pyi
\ No newline at end of file diff --git a/zuul/ansible/2.7/paths.py b/zuul/ansible/2.7/paths.py deleted file mode 120000 index dbdb1858e..000000000 --- a/zuul/ansible/2.7/paths.py +++ /dev/null @@ -1 +0,0 @@ -../paths.py
\ No newline at end of file diff --git a/zuul/ansible/base/action/uri.py b/zuul/ansible/base/action/uri.py index bc64657ed..44f60882c 100644 --- a/zuul/ansible/base/action/uri.py +++ b/zuul/ansible/base/action/uri.py @@ -13,10 +13,14 @@ # You should have received a copy of the GNU General Public License # along with this software. If not, see <http://www.gnu.org/licenses/>. +from ansible.errors import AnsibleError +from ansible.module_utils.six.moves.urllib.parse import urlparse from zuul.ansible import paths uri = paths._import_ansible_action_plugin("uri") +ALLOWED_URL_SCHEMES = ('https', 'http', 'ftp') + class ActionModule(uri.ActionModule): @@ -34,5 +38,12 @@ class ActionModule(uri.ActionModule): dest = self._task.args.get(arg) if dest: paths._fail_if_unsafe(dest) + scheme = urlparse(self._task.args['url']).scheme + if scheme not in ALLOWED_URL_SCHEMES: + raise AnsibleError( + "{scheme} urls are not allowed from localhost." + " Only {allowed_schemes} are allowed".format( + scheme=scheme, + allowed_schemes=ALLOWED_URL_SCHEMES)) return super(ActionModule, self).run(tmp, task_vars) diff --git a/zuul/configloader.py b/zuul/configloader.py index 10a33a1f0..22ad85144 100644 --- a/zuul/configloader.py +++ b/zuul/configloader.py @@ -603,6 +603,7 @@ class JobParser(object): 'final': bool, 'abstract': bool, 'protected': bool, + 'intermediate': bool, 'requires': to_list(str), 'provides': to_list(str), 'failure-message': str, @@ -655,6 +656,7 @@ class JobParser(object): 'final', 'abstract', 'protected', + 'intermediate', 'timeout', 'post-timeout', 'workspace', @@ -810,6 +812,9 @@ class JobParser(object): job.roles, secrets) job.run = job.run + (run,) + if conf.get('intermediate', False) and not conf.get('abstract', False): + raise Exception("An intermediate job must also be abstract") + for k in self.simple_attributes: a = k.replace('-', '_') if k in conf: diff --git a/zuul/driver/gerrit/gerritconnection.py b/zuul/driver/gerrit/gerritconnection.py index 2abdf74eb..9fd9e422d 100644 --- a/zuul/driver/gerrit/gerritconnection.py +++ b/zuul/driver/gerrit/gerritconnection.py @@ -230,17 +230,22 @@ class GerritEventConnector(threading.Thread): # This checks whether the event created or deleted a branch so # that Zuul may know to perform a reconfiguration on the # project. + branch_refs = 'refs/heads/' if (event.type == 'ref-updated' and ((not event.ref.startswith('refs/')) or - event.ref.startswith('refs/heads'))): + event.ref.startswith(branch_refs))): + + if event.ref.startswith(branch_refs): + event.branch = event.ref[len(branch_refs):] + else: + event.branch = event.ref + if event.oldrev == '0' * 40: event.branch_created = True - event.branch = event.ref project = self.connection.source.getProject(event.project_name) self.connection._clearBranchCache(project) if event.newrev == '0' * 40: event.branch_deleted = True - event.branch = event.ref project = self.connection.source.getProject(event.project_name) self.connection._clearBranchCache(project) diff --git a/zuul/driver/gerrit/gerritmodel.py b/zuul/driver/gerrit/gerritmodel.py index e9043119f..b1ad85898 100644 --- a/zuul/driver/gerrit/gerritmodel.py +++ b/zuul/driver/gerrit/gerritmodel.py @@ -31,6 +31,7 @@ EMPTY_GIT_REF = '0' * 40 # git sha of all zeros, used during creates/deletes class GerritChange(Change): def __init__(self, project): super(GerritChange, self).__init__(project) + self.status = None self.approvals = [] def update(self, data, connection): @@ -45,7 +46,7 @@ class GerritChange(Change): if 'project' not in data: raise exceptions.ChangeNotFound(self.number, self.patchset) self.project = connection.source.getProject(data['project']) - self.id = data['id'] + self.commit_id = str(data['currentPatchSet']['revision']) self.branch = data['branch'] self.url = data['url'] urlparse = urllib.parse.urlparse(connection.baseurl) @@ -73,12 +74,12 @@ class GerritChange(Change): else: self.is_current_patchset = False self.files = files - + self.id = data['id'] self.is_merged = data.get('status', '') == 'MERGED' self.approvals = data['currentPatchSet'].get('approvals', []) self.open = data['open'] self.status = data['status'] - self.owner = data['owner'] + self.owner = data['owner'].get('username') self.message = data['commitMessage'] self.missing_labels = set() @@ -99,6 +100,7 @@ class GerritChange(Change): if self.patchset is None: self.patchset = str(current_revision['_number']) self.project = connection.source.getProject(data['project']) + self.commit_id = str(data['current_revision']) self.id = data['change_id'] self.branch = data['branch'] self.url = '%s/%s' % (baseurl, self.number) @@ -147,7 +149,7 @@ class GerritChange(Change): self.missing_labels.add(label_name) self.open = data['status'] == 'NEW' self.status = data['status'] - self.owner = data['owner'] + self.owner = data['owner'].get('username') self.message = current_revision['commit']['message'] diff --git a/zuul/driver/github/githubconnection.py b/zuul/driver/github/githubconnection.py index f1ce8e986..754768232 100644 --- a/zuul/driver/github/githubconnection.py +++ b/zuul/driver/github/githubconnection.py @@ -23,6 +23,7 @@ import threading import time import json from collections import OrderedDict +from json.decoder import JSONDecodeError import cherrypy import cachecontrol @@ -228,6 +229,23 @@ class GithubRetryHandler: if not 500 <= response.status_code < 600: return + try: + data = response.json() + errors = data.get('errors', []) + for error in errors: + resource = error.get('resource') + field = error.get('field') + code = error.get('code') + if (resource == 'PullRequest' and + field == 'diff' and + code == 'not_available'): + # Github responds with 500 if the diff is too large so we + # need to ignore it because retries won't help. + return + except JSONDecodeError: + # If there is no json just continue with retry handling. + pass + if hasattr(response.request, 'zuul_retry_count'): retry_count = response.request.zuul_retry_count retry_delay = min(response.request.zuul_retry_delay * 2, @@ -429,7 +447,8 @@ class GithubEventProcessor(object): # unprotected branches, we might need to check whether the # branch is now protected. if hasattr(event, "branch") and event.branch: - b = self.connection.getBranch(project.name, event.branch) + b = self.connection.getBranch( + project.name, event.branch, zuul_event_id=event) if b is not None: branch_protected = b.get('protected') self.connection.checkBranchCache( @@ -1410,7 +1429,8 @@ class GithubConnection(BaseConnection): change.project.name, change.number, event=event) change.ref = "refs/pull/%s/head" % change.number change.branch = change.pr.get('base').get('ref') - + change.commit_id = change.pr.get('head').get('sha') + change.owner = change.pr.get('user').get('login') # Don't overwrite the files list. The change object is bound to a # specific revision and thus the changed files won't change. This is # important if we got the files later because of the 300 files limit. @@ -1419,7 +1439,9 @@ class GithubConnection(BaseConnection): # Github's pull requests files API only returns at max # the first 300 changed files of a PR in alphabetical order. # https://developer.github.com/v3/pulls/#list-pull-requests-files - if len(change.files) < change.pr.get('changed_files', 0): + if change.files is None: + log.warning("Got no files of PR.") + elif len(change.files) < change.pr.get('changed_files', 0): log.warning("Got only %s files but PR has %s files.", len(change.files), change.pr.get('changed_files', 0)) @@ -1437,8 +1459,6 @@ class GithubConnection(BaseConnection): if not change.is_merged: change.is_merged = change.pr.get('merged') - change.status = self._get_statuses( - change.project, change.patchset, event) change.reviews = self.getPullReviews( pr_obj, change.project, change.number, event) change.labels = change.pr.get('labels') @@ -1471,11 +1491,38 @@ class GithubConnection(BaseConnection): self.server, change.project.name, change.number), ] + self._updateCanMergeInfo(change, event) + if self.sched: self.sched.onChangeUpdated(change, event) return change + def _updateCanMergeInfo(self, change, event): + # NOTE: The 'mergeable' field may get a false (null) while GitHub is + # calculating if it can merge. The Github API will just return + # that as false. This could lead to false negatives. So don't get this + # field here and only evaluate branch protection settings. Any merge + # conflicts which would block merging finally will be detected by + # the zuul-mergers anyway. + github = self.getGithubClient(change.project.name, zuul_event_id=event) + + # Append accept headers so we get the draft status and checks api + self._append_accept_header(github, PREVIEW_DRAFT_ACCEPT) + self._append_accept_header(github, PREVIEW_CHECKS_ACCEPT) + + # For performance reasons fetch all needed data upfront using a + # single graphql call. + canmerge_data = self.graphql_client.fetch_canmerge( + github, change, zuul_event_id=event) + + change.contexts = self._get_contexts(canmerge_data) + change.draft = canmerge_data.get('isDraft', False) + change.review_decision = canmerge_data['reviewDecision'] + change.required_contexts = set( + canmerge_data['requiredStatusCheckContexts'] + ) + def getGitUrl(self, project: Project): if self.git_ssh_key: return 'ssh://git@%s/%s.git' % (self.server, project.name) @@ -1561,8 +1608,9 @@ class GithubConnection(BaseConnection): cache[project.name] = branches return branches - def getBranch(self, project_name, branch): - github = self.getGithubClient(project_name) + def getBranch(self, project_name, branch, zuul_event_id=None): + github = self.getGithubClient( + project_name, zuul_event_id=zuul_event_id) # Note that we directly use a web request here because if we use the # github3.py api directly we need a repository object which needs @@ -1608,7 +1656,7 @@ class GithubConnection(BaseConnection): self.log.warning("Failed to get list of files from Github. " "Using empty file list to trigger update " "via the merger: %s", exc) - pr['files'] = [] + pr['files'] = None labels = [l['name'] for l in pr['labels']] pr['labels'] = labels @@ -1616,36 +1664,21 @@ class GithubConnection(BaseConnection): return (pr, probj) def canMerge(self, change, allow_needs, event=None): - # NOTE: The mergeable call may get a false (null) while GitHub is - # calculating if it can merge. The github3.py library will just return - # that as false. This could lead to false negatives. So don't do this - # call here and only evaluate branch protection settings. Any merge - # conflicts which would block merging finally will be detected by - # the zuul-mergers anyway. - log = get_annotated_logger(self.log, event) - github = self.getGithubClient(change.project.name, zuul_event_id=event) - - # Append accept headers so we get the draft status and checks api - self._append_accept_header(github, PREVIEW_DRAFT_ACCEPT) - self._append_accept_header(github, PREVIEW_CHECKS_ACCEPT) - - # For performance reasons fetch all needed data for canMerge upfront - # using a single graphql call. - canmerge_data = self.graphql_client.fetch_canmerge( - github, change, zuul_event_id=event) - # If the PR is a draft it cannot be merged. - if canmerge_data.get('isDraft', False): + if change.draft: log.debug('Change %s can not merge because it is a draft', change) return False - if not self._hasRequiredStatusChecks(allow_needs, canmerge_data): + missing_status_checks = self._getMissingStatusChecks( + change, allow_needs) + if missing_status_checks: + log.debug('Change %s can not merge because required status checks ' + 'are missing: %s', change, missing_status_checks) return False - review_decision = canmerge_data['reviewDecision'] - if review_decision and review_decision != 'APPROVED': + if change.review_decision and change.review_decision != 'APPROVED': # If we got a review decision it must be approved log.debug('Change %s can not merge because it is not approved', change) @@ -1670,16 +1703,25 @@ class GithubConnection(BaseConnection): issues = list(github.search_issues(sha)) log.debug('Got PR on project %s for sha %s', project_name, sha) - if len(issues) > 1: - raise Exception('Multiple pulls found with head sha %s' % sha) - if len(issues) == 0: return None - pr_body = self._getChange( - project, issues.pop().issue.number, sha, event=event).pr - self._sha_pr_cache.update(project_name, pr_body) - return pr_body + # Github returns all issues that contain the sha, not only the ones + # with that sha as head_sha so we need to get and update all those + # changes and then filter for the head sha before we can error out + # with multiple pulls found. + found_pr_body = None + for item in issues: + pr_body = self._getChange( + project, item.issue.number, sha, event=event).pr + self._sha_pr_cache.update(project_name, pr_body) + if pr_body['head']['sha'] == sha: + if found_pr_body: + raise Exception( + 'Multiple pulls found with head sha %s' % sha) + found_pr_body = pr_body + + return found_pr_body def getPullReviews(self, pr_obj, project, number, event): log = get_annotated_logger(self.log, event) @@ -1762,23 +1804,19 @@ class GithubConnection(BaseConnection): return resp.json() @staticmethod - def _hasRequiredStatusChecks(allow_needs, canmerge_data): - required_contexts = canmerge_data['requiredStatusCheckContexts'] - if not required_contexts: + def _getMissingStatusChecks(change, allow_needs): + if not change.required_contexts: # There are no required contexts -> ok by definition - return True + return set() # Strip allow_needs as we will set this in the gate ourselves required_contexts = set( - [x for x in required_contexts if x not in allow_needs]) - - # Get successful statuses - successful = set([s[0] for s in canmerge_data['status'].items() - if s[1] == 'SUCCESS']) + x for x in change.required_contexts if x not in allow_needs + ) - # Required contexts must be a subset of the successful contexts as - # we allow additional successful status contexts we don't care about. - return required_contexts.issubset(successful) + # Remove successful checks from the required contexts to get the + # remaining missing required status. + return required_contexts.difference(change.successful_contexts) @cachetools.cached(cache=cachetools.TTLCache(maxsize=2048, ttl=3600), key=lambda self, login, project: @@ -2175,34 +2213,14 @@ class GithubConnection(BaseConnection): def _ghTimestampToDate(self, timestamp): return time.strptime(timestamp, '%Y-%m-%dT%H:%M:%SZ') - def _get_statuses(self, project, sha, event): - # A ref can have more than one status from each context, - # however the API returns them in order, newest first. - # So we can keep track of which contexts we've already seen - # and throw out the rest. Our unique key is based on - # the user and the context, since context is free form and anybody - # can put whatever they want there. We want to ensure we track it - # by user, so that we can require/trigger by user too. - seen = [] - statuses = [] - for status in self.getCommitStatuses(project.name, sha, event): - stuple = _status_as_tuple(status) - if "%s:%s" % (stuple[0], stuple[1]) not in seen: - statuses.append("%s:%s:%s" % stuple) - seen.append("%s:%s" % (stuple[0], stuple[1])) - - # Although Github differentiates commit statuses and commit checks via - # their respective APIs, the branch protection the status section - # (below the comments of a PR) do not differentiate between both. Thus, - # to mimic this behaviour also in Zuul, a required_status in the - # pipeline config could map to either a status or a check. - for check in self.getCommitChecks(project.name, sha, event): - ctuple = _check_as_tuple(check) - if "{}:{}".format(ctuple[0], ctuple[1]) not in seen: - statuses.append("{}:{}:{}".format(*ctuple)) - seen.append("{}:{}".format(ctuple[0], ctuple[1])) - - return statuses + def _get_contexts(self, canmerge_data): + contexts = set( + _status_as_tuple(s) for s in canmerge_data["status"].values() + ) + contexts.update(set( + _check_as_tuple(c) for c in canmerge_data["checks"].values() + )) + return contexts def getWebController(self, zuul_web): return GithubWebController(zuul_web, self) @@ -2324,6 +2342,9 @@ def _status_as_tuple(status): user = creator.get('login') context = status.get('context') state = status.get('state') + # Normalize state to lowercase as the Graphql and REST API are not + # consistent in this regard. + state = state.lower() if state else state return (user, context, state) @@ -2339,4 +2360,7 @@ def _check_as_tuple(check): slug = "Unknown" name = check.get("name") conclusion = check.get("conclusion") + # Normalize conclusion to lowercase as the Graphql and REST API are not + # consistent in this regard. + conclusion = conclusion.lower() if conclusion else conclusion return (slug, name, conclusion) diff --git a/zuul/driver/github/githubmodel.py b/zuul/driver/github/githubmodel.py index f5945a6c6..da06da46f 100644 --- a/zuul/driver/github/githubmodel.py +++ b/zuul/driver/github/githubmodel.py @@ -38,6 +38,22 @@ class PullRequest(Change): self.reviews = [] self.files = [] self.labels = [] + self.draft = None + self.review_decision = None + self.required_contexts = set() + self.contexts = set() + + @property + def status(self): + return ["{}:{}:{}".format(*c) for c in self.contexts] + + @property + def successful_contexts(self) -> set: + if not self.contexts: + return set() + return set( + s[1] for s in self.contexts if s[2] == 'success' + ) def isUpdateOf(self, other): if (self.project == other.project and diff --git a/zuul/driver/github/graphql/__init__.py b/zuul/driver/github/graphql/__init__.py index 1704d7efc..d671e9e90 100644 --- a/zuul/driver/github/graphql/__init__.py +++ b/zuul/driver/github/graphql/__init__.py @@ -126,11 +126,15 @@ class GraphQLClient: # afterwards status = commit.get('status') or {} for context in status.get('contexts', []): - result['status'][context['context']] = context['state'] + result['status'][context['context']] = context # Add check runs + result['checks'] = {} for suite in nested_get(commit, 'checkSuites', 'nodes', default=[]): for run in nested_get(suite, 'checkRuns', 'nodes', default=[]): - result['status'][run['name']] = run['conclusion'] + result['checks'][run['name']] = { + **run, + "app": suite.get("app") + } return result diff --git a/zuul/driver/github/graphql/canmerge-legacy.graphql b/zuul/driver/github/graphql/canmerge-legacy.graphql index edc1b0fcd..e86b3c7de 100644 --- a/zuul/driver/github/graphql/canmerge-legacy.graphql +++ b/zuul/driver/github/graphql/canmerge-legacy.graphql @@ -25,6 +25,10 @@ query canMergeData( ... on Commit { checkSuites(first: 100) { nodes { + app { + name + slug + } checkRuns(first: 100) { nodes { name @@ -35,6 +39,9 @@ query canMergeData( } status { contexts { + creator { + login + } state context } diff --git a/zuul/driver/github/graphql/canmerge.graphql b/zuul/driver/github/graphql/canmerge.graphql index baa4a9f81..7b7e862fd 100644 --- a/zuul/driver/github/graphql/canmerge.graphql +++ b/zuul/driver/github/graphql/canmerge.graphql @@ -26,6 +26,10 @@ query canMergeData( ... on Commit { checkSuites(first: 100) { nodes { + app { + name + slug + } checkRuns(first: 100) { nodes { name @@ -36,6 +40,9 @@ query canMergeData( } status { contexts { + creator { + login + } state context } diff --git a/zuul/driver/gitlab/gitlabconnection.py b/zuul/driver/gitlab/gitlabconnection.py index 63bc84504..a35aec114 100644 --- a/zuul/driver/gitlab/gitlabconnection.py +++ b/zuul/driver/gitlab/gitlabconnection.py @@ -321,12 +321,12 @@ class GitlabAPIClient(): return resp[0] # https://docs.gitlab.com/ee/api/merge_request_approvals.html#approve-merge-request - def approve_mr(self, project_name, number, approve=True, + def approve_mr(self, project_name, number, patchset, approve=True, zuul_event_id=None): approve = 'approve' if approve else 'unapprove' path = "/projects/%s/merge_requests/%s/%s" % ( quote_plus(project_name), number, approve) - params = {} + params = {'sha': patchset} if approve else {} resp = self.post( self.baseurl + path, params=params, zuul_event_id=zuul_event_id) @@ -336,8 +336,14 @@ class GitlabAPIClient(): # approve and unapprove endpoint could return code 401 whether the # actual state of the Merge Request approval. Two call on approve # endpoint the second call return 401. - if resp[1] != 401: + # 409 is returned when current HEAD of the merge request doesn't + # match the 'sha' parameter. + if resp[1] not in (401, 409): raise + elif approve == 'approve' and resp[1] == 409: + log = get_annotated_logger(self.log, zuul_event_id) + log.error('Fail to approve the merge request: %s' % resp[0]) + return return resp[0] # https://docs.gitlab.com/ee/api/merge_request_approvals.html#get-configuration-1 @@ -519,6 +525,8 @@ class GitlabConnection(BaseConnection): change.ref = "refs/merge-requests/%s/head" % change.number change.branch = change.mr['target_branch'] change.patchset = change.mr['sha'] + change.commit_id = change.mr['diff_refs'].get('head_sha') + change.owner = change.mr['author'].get('username') # Files changes are not part of the Merge Request data # See api/merge_requests.html#get-single-mr-changes # this endpoint includes file changes information @@ -554,7 +562,12 @@ class GitlabConnection(BaseConnection): mr_approval_status = self.gl_client.get_mr_approvals_status( project_name, number, zuul_event_id=event) log.info('Got MR approval status %s#%s', project_name, number) - mr['approved'] = mr_approval_status['approvals_left'] == 0 + if 'approvals_left' in mr_approval_status: + # 'approvals_left' is not present when 'Required Merge Request + # Approvals' feature isn't available + mr['approved'] = mr_approval_status['approvals_left'] == 0 + else: + mr['approved'] = mr_approval_status['approved'] return mr def commentMR(self, project_name, number, message, event=None): @@ -563,12 +576,14 @@ class GitlabConnection(BaseConnection): project_name, number, message, zuul_event_id=event) log.info("Commented on MR %s#%s", project_name, number) - def approveMR(self, project_name, number, approve, event=None): + def approveMR(self, project_name, number, patchset, approve, event=None): log = get_annotated_logger(self.log, event) - self.gl_client.approve_mr( - project_name, number, approve, zuul_event_id=event) - log.info( - "Set approval: %s on MR %s#%s", approve, project_name, number) + result = self.gl_client.approve_mr( + project_name, number, patchset, approve, zuul_event_id=event) + if result: + log.info( + "Set approval: %s on MR %s#%s (%s)", approve, + project_name, number, patchset) def getChangesDependingOn(self, change, projects, tenant): """ Reverse lookup of MR depending on this one diff --git a/zuul/driver/gitlab/gitlabreporter.py b/zuul/driver/gitlab/gitlabreporter.py index 6d3461011..d9a5afe6d 100644 --- a/zuul/driver/gitlab/gitlabreporter.py +++ b/zuul/driver/gitlab/gitlabreporter.py @@ -68,10 +68,11 @@ class GitlabReporter(BaseReporter): log = get_annotated_logger(self.log, item.event) project = item.change.project.name mr_number = item.change.number + patchset = item.change.patchset log.debug('Reporting change %s, params %s, approval: %s', item.change, self.config, self._approval) - self.connection.approveMR(project, mr_number, self._approval, - event=item.event) + self.connection.approveMR(project, mr_number, patchset, + self._approval, event=item.event) def mergeMR(self, item): project = item.change.project.name diff --git a/zuul/driver/mqtt/mqttreporter.py b/zuul/driver/mqtt/mqttreporter.py index 88dd70738..e489f5908 100644 --- a/zuul/driver/mqtt/mqttreporter.py +++ b/zuul/driver/mqtt/mqttreporter.py @@ -40,6 +40,8 @@ class MQTTReporter(BaseReporter): 'change_url': item.change.url, 'change': getattr(item.change, 'number', ''), 'patchset': getattr(item.change, 'patchset', ''), + 'commit_id': getattr(item.change, 'commit_id', ''), + 'owner': getattr(item.change, 'owner', ''), 'ref': getattr(item.change, 'ref', ''), 'message': self._formatItemReport( item, with_jobs=False), diff --git a/zuul/driver/pagure/paguremodel.py b/zuul/driver/pagure/paguremodel.py index f62e7f787..ebcbb11b7 100644 --- a/zuul/driver/pagure/paguremodel.py +++ b/zuul/driver/pagure/paguremodel.py @@ -28,6 +28,7 @@ class PullRequest(Change): self.score = 0 self.files = [] self.tags = [] + self.status = None def __repr__(self): r = ['<Change 0x%x' % id(self)] diff --git a/zuul/driver/sql/alembic/versions/52d49e1bfe22_change_missing_build_url_to_null.py b/zuul/driver/sql/alembic/versions/52d49e1bfe22_change_missing_build_url_to_null.py new file mode 100644 index 000000000..9457e995b --- /dev/null +++ b/zuul/driver/sql/alembic/versions/52d49e1bfe22_change_missing_build_url_to_null.py @@ -0,0 +1,41 @@ +# Copyright 2018 Red Hat, Inc. +# +# 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. + +"""Change missing build url to null + +Revision ID: 52d49e1bfe22 +Revises: e0eda5d09eae +Create Date: 2018-03-18 09:30:23.343650 + +""" + +# revision identifiers, used by Alembic. +revision = '52d49e1bfe22' +down_revision = '32e28a297c3e' +branch_labels = None +depends_on = None + +BUILD_TABLE = 'zuul_build' + +from alembic import op + + +def upgrade(table_prefix=''): + op.execute( + 'UPDATE {table_name} SET log_url=NULL WHERE log_url=job_name'.format( + table_name=table_prefix + BUILD_TABLE)) + + +def downgrade(table_prefix=''): + raise Exception("Downgrades not supported") diff --git a/zuul/executor/server.py b/zuul/executor/server.py index 337660a72..95f079227 100644 --- a/zuul/executor/server.py +++ b/zuul/executor/server.py @@ -1546,9 +1546,7 @@ class AnsibleJob(object): # which python to use on the remote host in an inbuilt # table and essentially "does the right thing" # (i.e. chooses python3 on 3-only hosts like later - # Fedoras). For "auto" with prior versions, fall back - # to the old default of /usr/bin/python2 for backwards - # compatability. + # Fedoras). # If ansible_python_interpreter is set either as a group # var or all-var, then don't do anything here; let the # user control. @@ -1556,13 +1554,6 @@ class AnsibleJob(object): if (api not in args['vars'] and not is_group_var_set(api, name, args)): python = node.get('python_path', 'auto') - compat = self.arguments.get('ansible_version') in \ - ('2.5', '2.6', '2.7') - if python == "auto" and compat: - self.log.debug( - "ansible_version set to auto but " - "overriding to python2 for Ansible <2.8") - python = '/usr/bin/python2' host_vars.setdefault(api, python) username = node.get('username') diff --git a/zuul/lib/ansible-config.conf b/zuul/lib/ansible-config.conf index 1fb75eb5b..ca58d0939 100644 --- a/zuul/lib/ansible-config.conf +++ b/zuul/lib/ansible-config.conf @@ -3,10 +3,6 @@ default_version = 2.9 requirements = ara>=0.16.5,<1.0.0 openstacksdk openshift jmespath google-cloud-storage -[2.7] -deprecated = true -requirements = ansible>=2.7,<2.8 - [2.8] requirements = ansible>=2.8,<2.9 diff --git a/zuul/merger/merger.py b/zuul/merger/merger.py index b72eab538..72e2a2d77 100644 --- a/zuul/merger/merger.py +++ b/zuul/merger/merger.py @@ -632,8 +632,24 @@ class Repo(object): for dn in dirs: if dn not in tree: continue + + # Some people like to keep playbooks, etc. grouped + # under their zuul config dirs; record the leading + # directories of any .zuul.ignore files and prune them + # from the config read. + to_ignore = [] + for blob in tree[dn].traverse(): + if blob.path.endswith(".zuul.ignore"): + to_ignore.append(os.path.split(blob.path)[0]) + + def _ignored(blob): + for prefix in to_ignore: + if blob.path.startswith(prefix): + return True + return False + for blob in tree[dn].traverse(): - if blob.path.endswith(".yaml"): + if not _ignored(blob) and blob.path.endswith(".yaml"): ret[blob.path] = blob.data_stream.read().decode( 'utf-8') return ret diff --git a/zuul/merger/server.py b/zuul/merger/server.py index 08951f75c..7b2090c5e 100644 --- a/zuul/merger/server.py +++ b/zuul/merger/server.py @@ -14,6 +14,7 @@ import json import logging +import os import threading from abc import ABCMeta @@ -103,6 +104,24 @@ class BaseMergeServer(metaclass=ABCMeta): def start(self): self.log.debug('Starting merger worker') + self.log.debug('Cleaning any stale git index.lock files') + for (dirpath, dirnames, filenames) in os.walk(self.merge_root): + if '.git' in dirnames: + # Only recurse into .git dirs + dirnames.clear() + dirnames.append('.git') + elif dirpath.endswith('/.git'): + # Recurse no further + dirnames.clear() + if 'index.lock' in filenames: + fp = os.path.join(dirpath, 'index.lock') + try: + os.unlink(fp) + self.log.debug('Removed stale git lock: %s' % fp) + except Exception: + self.log.exception( + 'Unable to remove stale git lock: ' + '%s this may result in failed merges' % fp) self.merger_gearworker.start() def stop(self): diff --git a/zuul/model.py b/zuul/model.py index ecf5c62fa..723f618f3 100644 --- a/zuul/model.py +++ b/zuul/model.py @@ -26,6 +26,7 @@ import urllib.parse import textwrap import types import itertools +import yaml import jsonpath_rw @@ -1205,6 +1206,7 @@ class Job(ConfigObject): attempts=3, final=False, abstract=False, + intermediate=False, protected=None, roles=(), required_projects={}, @@ -1268,6 +1270,7 @@ class Job(ConfigObject): d['group_variables'] = self.group_variables d['final'] = self.final d['abstract'] = self.abstract + d['intermediate'] = self.intermediate d['protected'] = self.protected d['voting'] = self.voting d['timeout'] = self.timeout @@ -1559,7 +1562,8 @@ class Job(ConfigObject): for k in self.execution_attributes: if (other._get(k) is not None and - k not in set(['final', 'abstract', 'protected'])): + k not in set(['final', 'abstract', 'protected', + 'intermediate'])): if self.final: raise Exception("Unable to modify final job %s attribute " "%s=%s with variant %s" % ( @@ -1592,6 +1596,19 @@ class Job(ConfigObject): elif other.abstract: self.abstract = True + # An intermediate job may only be inherited by an abstract + # job. Note intermediate jobs must be also be abstract, that + # has been enforced during config reading. Similar to + # abstract, it is cleared by inheriting. + if self.intermediate and not other.abstract: + raise Exception("Intermediate job %s may only inherit " + "to another abstract job" % + (repr(self))) + if other.name != self.name: + self.intermediate = other.intermediate + elif other.intermediate: + self.intermediate = True + # Protected may only be set to true if other.protected is not None: # don't allow to reset protected flag @@ -2838,7 +2855,7 @@ class QueueItem(object): self.log.exception("Error while parsing url for job %s:" % (job,)) if not url: - url = default_url or build.url or job.name + url = default_url or build.url or None return (result, url) def formatJSON(self, websocket_url=None): @@ -3204,13 +3221,15 @@ class Change(Branch): self.is_merged = False self.failed_to_merge = False self.open = None - self.status = None self.owner = None # This may be the commit message, or it may be a cover message # in the case of a PR. Either way, it's the place where we # look for depends-on headers. self.message = None + # This can be the commit id of the patchset enqueued or + # in the case of a PR the id of HEAD of the branch. + self.commit_id = None def _id(self): return '%s,%s' % (self.number, self.patchset) @@ -3507,81 +3526,103 @@ class ProjectMetadata(object): self.default_branch = None -class ConfigItemNotListError(Exception): - def __init__(self): - message = textwrap.dedent("""\ - Configuration file is not a list. Each zuul.yaml configuration - file must be a list of items, for example: +# TODO(ianw) : this would clearly be better if it recorded the +# original file and made line-relative comments, however the contexts +# the subclasses are raised in don't have that info currently, so this +# is a best-effort to show you something that clues you into the +# error. +class ConfigItemErrorException(Exception): + def __init__(self, conf): + super(ConfigItemErrorException, self).__init__( + self.message + self._generate_extract(conf)) - - job: - name: foo + def _generate_extract(self, conf): + context = textwrap.dedent("""\ - - project: - name: bar + The incorrect values are around: - Ensure that every item starts with "- " so that it is parsed as a - YAML list. """) - super(ConfigItemNotListError, self).__init__(message) + # Not sorting the keys makes it look closer to what is in the + # file and works best with >= Python 3.7 where dicts are + # ordered by default. If this is a foreign config file or + # something the dump might be really long; hence the + # truncation. + extract = yaml.dump(conf, sort_keys=False) + lines = extract.split('\n') + if len(lines) > 5: + lines = lines[0:4] + lines.append('...') + return context + '\n'.join(lines) -class ConfigItemNotDictError(Exception): - def __init__(self): - message = textwrap.dedent("""\ - Configuration item is not a dictionary. Each zuul.yaml - configuration file must be a list of dictionaries, for - example: - - job: - name: foo +class ConfigItemNotListError(ConfigItemErrorException): + message = textwrap.dedent("""\ + Configuration file is not a list. Each zuul.yaml configuration + file must be a list of items, for example: - - project: - name: bar + - job: + name: foo - Ensure that every item in the list is a dictionary with one - key (in this example, 'job' and 'project'). - """) - super(ConfigItemNotDictError, self).__init__(message) + - project: + name: bar + Ensure that every item starts with "- " so that it is parsed as a + YAML list. + """) -class ConfigItemMultipleKeysError(Exception): - def __init__(self): - message = textwrap.dedent("""\ - Configuration item has more than one key. Each zuul.yaml - configuration file must be a list of dictionaries with a - single key, for example: - - job: - name: foo +class ConfigItemNotDictError(ConfigItemErrorException): + message = textwrap.dedent("""\ + Configuration item is not a dictionary. Each zuul.yaml + configuration file must be a list of dictionaries, for + example: - - project: - name: bar + - job: + name: foo - Ensure that every item in the list is a dictionary with only - one key (in this example, 'job' and 'project'). This error - may be caused by insufficient indentation of the keys under - the configuration item ('name' in this example). - """) - super(ConfigItemMultipleKeysError, self).__init__(message) + - project: + name: bar + Ensure that every item in the list is a dictionary with one + key (in this example, 'job' and 'project'). + """) -class ConfigItemUnknownError(Exception): - def __init__(self): - message = textwrap.dedent("""\ - Configuration item not recognized. Each zuul.yaml - configuration file must be a list of dictionaries, for - example: - - job: - name: foo +class ConfigItemMultipleKeysError(ConfigItemErrorException): + message = textwrap.dedent("""\ + Configuration item has more than one key. Each zuul.yaml + configuration file must be a list of dictionaries with a + single key, for example: - - project: - name: bar + - job: + name: foo - The dictionary keys must match one of the configuration item - types recognized by zuul (for example, 'job' or 'project'). - """) - super(ConfigItemUnknownError, self).__init__(message) + - project: + name: bar + + Ensure that every item in the list is a dictionary with only + one key (in this example, 'job' and 'project'). This error + may be caused by insufficient indentation of the keys under + the configuration item ('name' in this example). + """) + + +class ConfigItemUnknownError(ConfigItemErrorException): + message = textwrap.dedent("""\ + Configuration item not recognized. Each zuul.yaml + configuration file must be a list of dictionaries, for + example: + + - job: + name: foo + + - project: + name: bar + + The dictionary keys must match one of the configuration item + types recognized by zuul (for example, 'job' or 'project'). + """) class UnparsedAbideConfig(object): @@ -3603,13 +3644,13 @@ class UnparsedAbideConfig(object): return if not isinstance(conf, list): - raise ConfigItemNotListError() + raise ConfigItemNotListError(conf) for item in conf: if not isinstance(item, dict): - raise ConfigItemNotDictError() + raise ConfigItemNotDictError(item) if len(item.keys()) > 1: - raise ConfigItemMultipleKeysError() + raise ConfigItemMultipleKeysError(item) key, value = list(item.items())[0] if key == 'tenant': self.tenants.append(value) @@ -3618,7 +3659,7 @@ class UnparsedAbideConfig(object): elif key == 'admin-rule': self.admin_rules.append(value) else: - raise ConfigItemUnknownError() + raise ConfigItemUnknownError(item) class UnparsedConfig(object): @@ -3679,13 +3720,13 @@ class UnparsedConfig(object): return if not isinstance(conf, list): - raise ConfigItemNotListError() + raise ConfigItemNotListError(conf) for item in conf: if not isinstance(item, dict): - raise ConfigItemNotDictError() + raise ConfigItemNotDictError(item) if len(item.keys()) > 1: - raise ConfigItemMultipleKeysError() + raise ConfigItemMultipleKeysError(item) key, value = list(item.items())[0] if key == 'project': self.projects.append(value) @@ -3704,7 +3745,7 @@ class UnparsedConfig(object): elif key == 'pragma': self.pragmas.append(value) else: - raise ConfigItemUnknownError() + raise ConfigItemUnknownError(item) class ParsedConfig(object): diff --git a/zuul/reporter/__init__.py b/zuul/reporter/__init__.py index da3c606b4..d7dc67bea 100644 --- a/zuul/reporter/__init__.py +++ b/zuul/reporter/__init__.py @@ -251,6 +251,12 @@ class BaseReporter(object, metaclass=abc.ABCMeta): else: error = '' name = job.name + ' ' + # TODO(mordred) The gerrit consumption interface depends on + # something existing in the url field and don't have a great + # behavior defined for url being none/missing. Put name into + # the url field to match old behavior until we can deal with + # the gerrit-side piece as well + url = url or job.name jobs_fields.append((name, url, result, error, elapsed, voting)) return jobs_fields diff --git a/zuul/scheduler.py b/zuul/scheduler.py index 1c1f2e397..96be67f6b 100644 --- a/zuul/scheduler.py +++ b/zuul/scheduler.py @@ -461,6 +461,13 @@ class Scheduler(threading.Thread): self.statsd.gauge('zuul.executors.jobs_running', execute_running) self.statsd.gauge('zuul.executors.jobs_queued', execute_queue) + self.statsd.gauge('zuul.scheduler.eventqueues.trigger', + self.trigger_event_queue.qsize()) + self.statsd.gauge('zuul.scheduler.eventqueues.result', + self.result_event_queue.qsize()) + self.statsd.gauge('zuul.scheduler.eventqueues.management', + self.management_event_queue.qsize()) + def addEvent(self, event): # Check the event type and put it in the corresponding queue if isinstance(event, TriggerEvent): |