summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.zuul.yaml10
-rw-r--r--doc/source/reference/config.rst4
-rw-r--r--doc/source/reference/drivers/mqtt.rst16
-rw-r--r--doc/source/reference/job_def.rst15
-rw-r--r--doc/source/reference/monitoring.rst24
-rw-r--r--doc/source/reference/vulnerabilities.rst4
-rw-r--r--doc/source/tutorials/user.rst6
-rw-r--r--releasenotes/notes/ansible-2.7-4b6504e46c18cc57.yaml4
-rw-r--r--releasenotes/notes/change-missing-build-url-6df030ae3d8793ae.yaml7
-rw-r--r--releasenotes/notes/config-zuul-ignore-695aafd639d874e0.yaml6
-rw-r--r--releasenotes/notes/intermediate-jobs-101e04e7e1497af9.yaml5
-rw-r--r--releasenotes/notes/python35-fd8e7f7f577b55e5.yaml4
-rw-r--r--requirements.txt2
-rw-r--r--setup.cfg3
-rw-r--r--tests/base.py47
-rw-r--r--tests/fake_graphql.py31
-rw-r--r--tests/fakegithub.py51
-rw-r--r--tests/fixtures/config/ansible-versions/git/common-config/zuul.yaml12
-rw-r--r--tests/fixtures/config/intermediate/git/common-config/playbooks/base.yaml2
-rw-r--r--tests/fixtures/config/intermediate/git/common-config/zuul.yaml50
-rw-r--r--tests/fixtures/config/intermediate/git/org_project/README1
-rw-r--r--tests/fixtures/config/intermediate/main.yaml8
-rw-r--r--tests/fixtures/config/inventory/git/common-config/zuul.yaml9
-rw-r--r--tests/fixtures/config/inventory/git/org_project/.zuul.yaml1
-rw-r--r--tests/fixtures/config/inventory/git/org_project3/.zuul.yaml1
-rw-r--r--tests/fixtures/config/remote-action-modules/git/org_project/playbooks/uri-bad-dest.yaml6
-rw-r--r--tests/fixtures/config/remote-action-modules/git/org_project/playbooks/uri-bad-src.yaml7
-rw-r--r--tests/fixtures/config/remote-action-modules/git/org_project/playbooks/uri-bad-url.yaml14
-rw-r--r--tests/fixtures/config/remote-action-modules/git/org_project/playbooks/uri-good.yaml16
-rw-r--r--tests/fixtures/config/split-config/git/org_project1/.zuul.d/playbooks/.zuul.ignore0
-rw-r--r--tests/fixtures/config/split-config/git/org_project1/.zuul.d/playbooks/main.yaml8
-rw-r--r--tests/fixtures/zuul-default-ansible-version.conf2
-rw-r--r--tests/remote/test_remote_action_modules.py13
-rw-r--r--tests/remote/test_remote_zuul_json.py60
-rw-r--r--tests/remote/test_remote_zuul_stream.py100
-rw-r--r--tests/unit/test_configloader.py5
-rw-r--r--tests/unit/test_connection.py2
-rw-r--r--tests/unit/test_github_requirements.py5
-rw-r--r--tests/unit/test_gitlab_driver.py44
-rw-r--r--tests/unit/test_inventory.py24
-rw-r--r--tests/unit/test_merger_repo.py37
-rw-r--r--tests/unit/test_scheduler.py68
-rw-r--r--tests/unit/test_v3.py127
-rw-r--r--tests/unit/test_web.py8
-rw-r--r--web/package.json3
-rw-r--r--web/src/App.jsx63
-rw-r--r--web/src/Misc.jsx46
-rw-r--r--web/src/containers/FilterToolbar.jsx248
-rw-r--r--web/src/containers/TableFilters.jsx236
-rw-r--r--web/src/containers/build/Artifact.jsx19
-rw-r--r--web/src/containers/build/Build.jsx286
-rw-r--r--web/src/containers/build/BuildOutput.jsx1
-rw-r--r--web/src/containers/build/BuildTable.jsx268
-rw-r--r--web/src/containers/build/Buildset.jsx24
-rw-r--r--web/src/containers/build/BuildsetTable.jsx212
-rw-r--r--web/src/containers/build/Summary.jsx131
-rw-r--r--web/src/index.css92
-rw-r--r--web/src/pages/Build.jsx233
-rw-r--r--web/src/pages/Builds.jsx241
-rw-r--r--web/src/pages/Buildsets.jsx211
-rw-r--r--web/src/routes.js7
-rw-r--r--web/yarn.lock65
l---------zuul/ansible/2.7/action/__init__.py1
l---------zuul/ansible/2.7/action/add_host.py1
l---------zuul/ansible/2.7/action/add_host.pyi1
l---------zuul/ansible/2.7/action/aireos.py1
l---------zuul/ansible/2.7/action/aireos.pyi1
l---------zuul/ansible/2.7/action/aireos_config.py1
l---------zuul/ansible/2.7/action/aireos_config.pyi1
l---------zuul/ansible/2.7/action/aruba.py1
l---------zuul/ansible/2.7/action/aruba.pyi1
l---------zuul/ansible/2.7/action/aruba_config.py1
l---------zuul/ansible/2.7/action/aruba_config.pyi1
l---------zuul/ansible/2.7/action/asa.py1
l---------zuul/ansible/2.7/action/asa.pyi1
l---------zuul/ansible/2.7/action/asa_config.py1
l---------zuul/ansible/2.7/action/asa_config.pyi1
l---------zuul/ansible/2.7/action/asa_template.py1
l---------zuul/ansible/2.7/action/asa_template.pyi1
l---------zuul/ansible/2.7/action/assemble.py1
l---------zuul/ansible/2.7/action/assemble.pyi1
l---------zuul/ansible/2.7/action/aws_s3.py1
l---------zuul/ansible/2.7/action/aws_s3.pyi1
l---------zuul/ansible/2.7/action/ce.py1
l---------zuul/ansible/2.7/action/ce.pyi1
l---------zuul/ansible/2.7/action/ce_config.py1
l---------zuul/ansible/2.7/action/ce_config.pyi1
l---------zuul/ansible/2.7/action/ce_template.py1
l---------zuul/ansible/2.7/action/ce_template.pyi1
l---------zuul/ansible/2.7/action/copy.py1
l---------zuul/ansible/2.7/action/copy.pyi1
l---------zuul/ansible/2.7/action/dellos10_config.py1
l---------zuul/ansible/2.7/action/dellos10_config.pyi1
l---------zuul/ansible/2.7/action/dellos6_config.py1
l---------zuul/ansible/2.7/action/dellos6_config.pyi1
l---------zuul/ansible/2.7/action/dellos9_config.py1
l---------zuul/ansible/2.7/action/dellos9_config.pyi1
l---------zuul/ansible/2.7/action/eos_config.py1
l---------zuul/ansible/2.7/action/eos_config.pyi1
l---------zuul/ansible/2.7/action/eos_template.py1
l---------zuul/ansible/2.7/action/eos_template.pyi1
l---------zuul/ansible/2.7/action/fetch.py1
l---------zuul/ansible/2.7/action/fetch.pyi1
l---------zuul/ansible/2.7/action/fortios_config.py1
l---------zuul/ansible/2.7/action/fortios_config.pyi1
l---------zuul/ansible/2.7/action/include_vars.py1
l---------zuul/ansible/2.7/action/include_vars.pyi1
l---------zuul/ansible/2.7/action/ios_config.py1
l---------zuul/ansible/2.7/action/ios_config.pyi1
l---------zuul/ansible/2.7/action/ios_template.py1
l---------zuul/ansible/2.7/action/ios_template.pyi1
l---------zuul/ansible/2.7/action/iosxr_config.py1
l---------zuul/ansible/2.7/action/iosxr_config.pyi1
l---------zuul/ansible/2.7/action/iosxr_template.py1
l---------zuul/ansible/2.7/action/iosxr_template.pyi1
l---------zuul/ansible/2.7/action/junos_config.py1
l---------zuul/ansible/2.7/action/junos_config.pyi1
l---------zuul/ansible/2.7/action/junos_template.py1
l---------zuul/ansible/2.7/action/junos_template.pyi1
l---------zuul/ansible/2.7/action/net_banner.py1
l---------zuul/ansible/2.7/action/net_banner.pyi1
l---------zuul/ansible/2.7/action/net_base.py1
l---------zuul/ansible/2.7/action/net_base.pyi1
l---------zuul/ansible/2.7/action/net_config.py1
l---------zuul/ansible/2.7/action/net_config.pyi1
l---------zuul/ansible/2.7/action/net_get.py1
l---------zuul/ansible/2.7/action/net_get.pyi1
l---------zuul/ansible/2.7/action/net_interface.py1
l---------zuul/ansible/2.7/action/net_interface.pyi1
l---------zuul/ansible/2.7/action/net_l2_interface.py1
l---------zuul/ansible/2.7/action/net_l2_interface.pyi1
l---------zuul/ansible/2.7/action/net_l3_interface.py1
l---------zuul/ansible/2.7/action/net_l3_interface.pyi1
l---------zuul/ansible/2.7/action/net_linkagg.py1
l---------zuul/ansible/2.7/action/net_linkagg.pyi1
l---------zuul/ansible/2.7/action/net_lldp.py1
l---------zuul/ansible/2.7/action/net_lldp.pyi1
l---------zuul/ansible/2.7/action/net_lldp_interface.py1
l---------zuul/ansible/2.7/action/net_lldp_interface.pyi1
l---------zuul/ansible/2.7/action/net_logging.py1
l---------zuul/ansible/2.7/action/net_logging.pyi1
l---------zuul/ansible/2.7/action/net_ping.py1
l---------zuul/ansible/2.7/action/net_ping.pyi1
l---------zuul/ansible/2.7/action/net_static_route.py1
l---------zuul/ansible/2.7/action/net_static_route.pyi1
l---------zuul/ansible/2.7/action/net_system.py1
l---------zuul/ansible/2.7/action/net_system.pyi1
l---------zuul/ansible/2.7/action/net_template.py1
l---------zuul/ansible/2.7/action/net_template.pyi1
l---------zuul/ansible/2.7/action/net_user.py1
l---------zuul/ansible/2.7/action/net_user.pyi1
l---------zuul/ansible/2.7/action/net_vlan.py1
l---------zuul/ansible/2.7/action/net_vlan.pyi1
l---------zuul/ansible/2.7/action/net_vrf.py1
l---------zuul/ansible/2.7/action/net_vrf.pyi1
l---------zuul/ansible/2.7/action/netconf_config.py1
l---------zuul/ansible/2.7/action/netconf_config.pyi1
l---------zuul/ansible/2.7/action/network.py1
l---------zuul/ansible/2.7/action/network.pyi1
l---------zuul/ansible/2.7/action/normal.py1
l---------zuul/ansible/2.7/action/normal.pyi1
l---------zuul/ansible/2.7/action/nxos_config.py1
l---------zuul/ansible/2.7/action/nxos_config.pyi1
l---------zuul/ansible/2.7/action/nxos_template.py1
l---------zuul/ansible/2.7/action/nxos_template.pyi1
l---------zuul/ansible/2.7/action/ops_config.py1
l---------zuul/ansible/2.7/action/ops_config.pyi1
l---------zuul/ansible/2.7/action/ops_template.py1
l---------zuul/ansible/2.7/action/ops_template.pyi1
l---------zuul/ansible/2.7/action/patch.py1
l---------zuul/ansible/2.7/action/patch.pyi1
l---------zuul/ansible/2.7/action/raw.py1
l---------zuul/ansible/2.7/action/raw.pyi1
l---------zuul/ansible/2.7/action/script.py1
l---------zuul/ansible/2.7/action/script.pyi1
l---------zuul/ansible/2.7/action/sros_config.py1
l---------zuul/ansible/2.7/action/sros_config.pyi1
l---------zuul/ansible/2.7/action/synchronize.py1
l---------zuul/ansible/2.7/action/synchronize.pyi1
l---------zuul/ansible/2.7/action/template.py1
l---------zuul/ansible/2.7/action/template.pyi1
l---------zuul/ansible/2.7/action/unarchive.py1
l---------zuul/ansible/2.7/action/unarchive.pyi1
l---------zuul/ansible/2.7/action/uri.py1
l---------zuul/ansible/2.7/action/uri.pyi1
l---------zuul/ansible/2.7/action/vyos_config.py1
l---------zuul/ansible/2.7/action/vyos_config.pyi1
l---------zuul/ansible/2.7/action/win_copy.py1
l---------zuul/ansible/2.7/action/win_copy.pyi1
l---------zuul/ansible/2.7/action/win_template.py1
l---------zuul/ansible/2.7/action/win_template.pyi1
l---------zuul/ansible/2.7/actiongeneral/__init__.py1
l---------zuul/ansible/2.7/actiongeneral/zuul_return.py1
l---------zuul/ansible/2.7/callback/__init__.py1
l---------zuul/ansible/2.7/callback/zuul_json.py1
l---------zuul/ansible/2.7/callback/zuul_stream.py1
l---------zuul/ansible/2.7/callback/zuul_unreachable.py1
l---------zuul/ansible/2.7/filter/__init__.py1
l---------zuul/ansible/2.7/filter/zuul_filters.py1
l---------zuul/ansible/2.7/library/__init__.py1
l---------zuul/ansible/2.7/library/command.py1
l---------zuul/ansible/2.7/library/zuul_console.py1
l---------zuul/ansible/2.7/logconfig.py1
l---------zuul/ansible/2.7/lookup/__init__.py1
l---------zuul/ansible/2.7/lookup/_banned.py1
l---------zuul/ansible/2.7/lookup/_banned.pyi1
l---------zuul/ansible/2.7/lookup/chef_databag.py1
l---------zuul/ansible/2.7/lookup/chef_databag.pyi1
l---------zuul/ansible/2.7/lookup/consul_kv.py1
l---------zuul/ansible/2.7/lookup/consul_kv.pyi1
l---------zuul/ansible/2.7/lookup/credstash.py1
l---------zuul/ansible/2.7/lookup/credstash.pyi1
l---------zuul/ansible/2.7/lookup/csvfile.py1
l---------zuul/ansible/2.7/lookup/csvfile.pyi1
l---------zuul/ansible/2.7/lookup/cyberarkpassword.py1
l---------zuul/ansible/2.7/lookup/cyberarkpassword.pyi1
l---------zuul/ansible/2.7/lookup/dig.py1
l---------zuul/ansible/2.7/lookup/dig.pyi1
l---------zuul/ansible/2.7/lookup/dnstxt.py1
l---------zuul/ansible/2.7/lookup/dnstxt.pyi1
l---------zuul/ansible/2.7/lookup/env.py1
l---------zuul/ansible/2.7/lookup/env.pyi1
l---------zuul/ansible/2.7/lookup/etcd.py1
l---------zuul/ansible/2.7/lookup/etcd.pyi1
l---------zuul/ansible/2.7/lookup/file.py1
l---------zuul/ansible/2.7/lookup/file.pyi1
l---------zuul/ansible/2.7/lookup/fileglob.py1
l---------zuul/ansible/2.7/lookup/fileglob.pyi1
l---------zuul/ansible/2.7/lookup/filetree.py1
l---------zuul/ansible/2.7/lookup/filetree.pyi1
l---------zuul/ansible/2.7/lookup/first_found.py1
l---------zuul/ansible/2.7/lookup/first_found.pyi1
l---------zuul/ansible/2.7/lookup/hashi_vault.py1
l---------zuul/ansible/2.7/lookup/hashi_vault.pyi1
l---------zuul/ansible/2.7/lookup/hiera.py1
l---------zuul/ansible/2.7/lookup/hiera.pyi1
l---------zuul/ansible/2.7/lookup/ini.py1
l---------zuul/ansible/2.7/lookup/ini.pyi1
l---------zuul/ansible/2.7/lookup/keyring.py1
l---------zuul/ansible/2.7/lookup/keyring.pyi1
l---------zuul/ansible/2.7/lookup/lastpass.py1
l---------zuul/ansible/2.7/lookup/lastpass.pyi1
l---------zuul/ansible/2.7/lookup/lines.py1
l---------zuul/ansible/2.7/lookup/lines.pyi1
l---------zuul/ansible/2.7/lookup/mongodb.py1
l---------zuul/ansible/2.7/lookup/mongodb.pyi1
l---------zuul/ansible/2.7/lookup/password.py1
l---------zuul/ansible/2.7/lookup/password.pyi1
l---------zuul/ansible/2.7/lookup/passwordstore.py1
l---------zuul/ansible/2.7/lookup/passwordstore.pyi1
l---------zuul/ansible/2.7/lookup/pipe.py1
l---------zuul/ansible/2.7/lookup/pipe.pyi1
l---------zuul/ansible/2.7/lookup/redis_kv.py1
l---------zuul/ansible/2.7/lookup/redis_kv.pyi1
l---------zuul/ansible/2.7/lookup/shelvefile.py1
l---------zuul/ansible/2.7/lookup/shelvefile.pyi1
l---------zuul/ansible/2.7/lookup/template.py1
l---------zuul/ansible/2.7/lookup/template.pyi1
l---------zuul/ansible/2.7/lookup/url.py1
l---------zuul/ansible/2.7/lookup/url.pyi1
l---------zuul/ansible/2.7/paths.py1
-rw-r--r--zuul/ansible/base/action/uri.py11
-rw-r--r--zuul/configloader.py5
-rw-r--r--zuul/driver/gerrit/gerritconnection.py11
-rw-r--r--zuul/driver/gerrit/gerritmodel.py10
-rw-r--r--zuul/driver/github/githubconnection.py178
-rw-r--r--zuul/driver/github/githubmodel.py16
-rw-r--r--zuul/driver/github/graphql/__init__.py8
-rw-r--r--zuul/driver/github/graphql/canmerge-legacy.graphql7
-rw-r--r--zuul/driver/github/graphql/canmerge.graphql7
-rw-r--r--zuul/driver/gitlab/gitlabconnection.py33
-rw-r--r--zuul/driver/gitlab/gitlabreporter.py5
-rw-r--r--zuul/driver/mqtt/mqttreporter.py2
-rw-r--r--zuul/driver/pagure/paguremodel.py1
-rw-r--r--zuul/driver/sql/alembic/versions/52d49e1bfe22_change_missing_build_url_to_null.py41
-rw-r--r--zuul/executor/server.py11
-rw-r--r--zuul/lib/ansible-config.conf4
-rw-r--r--zuul/merger/merger.py18
-rw-r--r--zuul/merger/server.py19
-rw-r--r--zuul/model.py175
-rw-r--r--zuul/reporter/__init__.py6
-rw-r--r--zuul/scheduler.py7
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
diff --git a/setup.cfg b/setup.cfg
index 3c3dbdd63..5ba97651a 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -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> &mdash; </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):