Migrate to polymer 3

Change-Id: I6cd88920bfa5d7d7f3d6a6cb9d291b3a805965ff
diff --git a/.gitignore b/.gitignore
index 499784b..6ad0d3e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,14 @@
 /.classpath
+/.primary_build_tool
 /.project
-/.settings/org.maven.ide.eclipse.prefs
-/.settings/org.eclipse.m2e.core.prefs
+/.settings
+/bazel-*
+/eclipse-out
+node_modules
+bower_components
+npm-debug.log
+dist
+fonts
+.tmp
+.vscode
+.DS_Store
\ No newline at end of file
diff --git a/BUILD b/BUILD
index 782e3c0..0a532a8 100644
--- a/BUILD
+++ b/BUILD
@@ -1,4 +1,6 @@
+load("@npm//@bazel/rollup:index.bzl", "rollup_bundle")
 load("//tools/bzl:plugin.bzl", "gerrit_plugin")
+load("//tools/bzl:genrule2.bzl", "genrule2")
 load("//tools/bzl:js.bzl", "polygerrit_plugin")
 
 gerrit_plugin(
@@ -10,14 +12,36 @@
         "Implementation-Title: Zuul status plugin",
         "Implementation-Vendor: Wikimedia Foundation",
     ],
+    resource_jars = [":zuul-status-static"],
     resources = glob(["src/main/**/*"]),
 )
 
-polygerrit_plugin(
-    name = "zuul-status-ui",
-    srcs = glob([
-        "zuul-status/*.html",
-        "zuul-status/*.js",
+genrule2(
+    name = "zuul-status-static",
+    srcs = [":zuul_status"],
+    outs = ["zuul-status-static.jar"],
+    cmd = " && ".join([
+        "mkdir $$TMP/static",
+        "cp -r $(locations :zuul_status) $$TMP/static",
+        "cd $$TMP",
+        "zip -Drq $$ROOT/$@ -g .",
     ]),
-    app = "zuul-status/zuul-status.html",
+)
+
+polygerrit_plugin(
+    name = "zuul_status",
+    app = "zuul-status-bundle.js",
+    plugin_name = "zuul-status",
+)
+
+rollup_bundle(
+    name = "zuul-status-bundle",
+    srcs = glob(["zuul-status/*.js"]),
+    entry_point = "zuul-status/plugin.js",
+    format = "iife",
+    rollup_bin = "//tools/node_tools:rollup-bin",
+    sourcemap = "hidden",
+    deps = [
+        "@tools_npm//rollup-plugin-node-resolve",
+    ],
 )
diff --git a/src/main/java/com/googlesource/gerrit/plugins/zuulstatus/Module.java b/src/main/java/com/googlesource/gerrit/plugins/zuulstatus/Module.java
index 59dcff6..d80f679 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/zuulstatus/Module.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/zuulstatus/Module.java
@@ -27,7 +27,7 @@
   @Override
   protected void configure() {
     DynamicSet.bind(binder(), WebUiPlugin.class)
-        .toInstance(new JavaScriptPlugin("zuul-status.html"));
+        .toInstance(new JavaScriptPlugin("zuul-status.js"));
 
     get(PROJECT_KIND, "config").to(GetConfig.class);
 
diff --git a/src/main/resources/static/zuul-status-view.html b/src/main/resources/static/zuul-status-view.html
deleted file mode 100644
index e50beab..0000000
--- a/src/main/resources/static/zuul-status-view.html
+++ /dev/null
@@ -1,234 +0,0 @@
-<!--
-@license
-Copyright (C) 2019 The Android Open Source Project
-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.
--->
-
-<dom-module id="zuul-status-view">
-  <template>
-    <style include="shared-styles">
-      #view-container {
-        display: block;
-      }
-      .container {
-        align-items: center;
-        display: flex;
-        flex-wrap: wrap;
-      }
-      .header {
-        background-color: var(--table-header-background-color);
-        justify-content: space-between;
-        min-height: 3.2em;
-        padding: .5em var(--default-horizontal-margin, 1rem);
-        border-top: 1px solid var(--border-color);
-        border-bottom: 1px solid var(--border-color);
-      }
-      .header .label {
-        font: inherit;
-        font-family: var(--font-family-bold);
-        font-size: 1.17em;
-        margin-right: 1em;
-      }
-      .progress {
-        overflow: hidden;
-        height: 20px;
-        margin-bottom: 20px;
-        background-color: #ededed;
-        border-radius: 1px;
-        -webkit-box-shadow: inset 0 1px 2px rgba(0,0,0,.1);
-        box-shadow: inset 0 1px 2px rgba(0,0,0,.1);
-      }
-      .progress-bar {
-        float: left;
-        width: 0;
-        height: 100%;
-        font-size: 11px;
-        line-height: 20px;
-        color: #fff;
-        text-align: center;
-        background-color: #39a5dc;
-        -webkit-box-shadow: inset 0 -1px 0 rgba(0,0,0,.15);
-        box-shadow: inset 0 -1px 0 rgba(0,0,0,.15);
-        -webkit-transition: width .6s ease;
-        -o-transition: width .6s ease;
-        transition: width .6s ease;
-      }
-      #progressBar {
-        width: var(--progress-bar-width, 0%);
-      }
-      #list {
-        padding-bottom: 1em;
-      }
-      .labels {
-        display: inline;
-        padding: .2em .6em .3em;
-        font-size: 100%;
-        font-weight: 600;
-        line-height: 1;
-        color: #fff;
-        text-align: center;
-        white-space: nowrap;
-        vertical-align: baseline;
-        border-radius: 0;
-      }
-      .label-danger {
-        background-color: #c00;
-      }
-      .label-default {
-        background-color: #9c9c9c;
-      }
-      .label-info {
-        background-color: #00659c;
-      }
-      .label-success {
-        background-color: #3f9c35;
-      }
-      .label-warning {
-        background-color: #ec7a08;
-      }
-      .zuul-job-result {
-        float: right;
-        width: 90px;
-        height: 20px;
-        margin: 2px 5px 0;
-        padding: 4px;
-      }
-      a {
-        color: var(--primary-text-color);
-        cursor: pointer;
-        display: inline-block;
-        text-decoration: none;
-      }
-      a:hover {
-        text-decoration: underline;
-      }
-      .time, {
-        align-items: center;
-        color: #757575;
-        display: flex;
-      }
-      #icon {
-        margin-right: 0.5em;
-      }
-    </style>
-    <style include="gr-change-list-styles">
-      :host {
-        font-size: var(--font-size-normal);
-      }
-      .padding {
-        width: var(--default-horizontal-margin);
-        padding: 0.5em;
-      }
-      #changeList {
-        border-collapse: collapse;
-        width: 100%;
-      }
-    </style>
-    <template is="dom-if" if="[[zuulUrl]]">
-      <div id="view-container">
-        <div class="header container">
-          <div class="container">
-            <h3 class="labelName">[[_computeTitle(_response)]]</h3>
-          </div>
-          <div class="container">
-            <template is="dom-if" if="[[!zuulDisable]]" restamp="true">
-              <gr-button link on-tap="_handleDisableZuulStatus">Disable Zuul Status</gr-button>
-            </template>
-            <template is="dom-if" if="[[zuulDisable]]" restamp="true">
-              <gr-button link on-tap="_handleEnableZuulStatus">Enable Zuul Status</gr-button>
-            </template>
-          </div>
-        </div>
-        <template is="dom-if" if="[[!zuulDisable]]" restamp="true">
-          <div class="bottom container">
-            <table id="changeList">
-              <template is="dom-repeat" items=[[_response]] as="response">
-                <tr class="groupHeader">
-                  <td class="padding">
-                    <template is="dom-if" if="[[!_isFailing(response)]]" restamp="true">
-                      <span class="fullStatus success">
-                        <iron-icon id="icon" icon="gr-icons:check" style="color: #388E3C;"></iron-icon>
-                      </span>
-                    </template>
-                    <template is="dom-if" if="[[_isFailing(response)]]" restamp="true">
-                      <span class="fullStatus failed">
-                        <iron-icon id="icon" icon="gr-icons:close" style="color: #D32F2F;"></iron-icon>
-                      </span>
-                    </template>
-                  </td>
-                  <td class="cell" colspan="15">[[response.name]]</td>
-                </tr>
-                <template is="dom-repeat" items=[[response.results.jobs]] as="jobs">
-                  <tr class="table">
-                    <td class="padding"></td>
-                    <td class="cell jobName">
-                      <iron-icon id="icon" icon="zuul-icons:code"></iron-icon>
-                      <a href="[[_computeReportURL(jobs)]]" target="_blank"
-                         hidden$="[[!jobs.name]]">
-                        [[jobs.name]]
-                      </a>
-                    </td>
-                    <td class="cell">
-                      <div class="time">
-                        <iron-icon id="icon" icon="zuul-icons:query-builder"></iron-icon>
-                        <div class="remainingTime">
-                          [[_getRemainingTime(response.results.remaining_time)]]
-                        </div>
-                      </div>
-                    </td>
-                    <td class="cell">
-                      <div class="time">
-                        <iron-icon id="icon" icon="zuul-icons:today"></iron-icon>
-                        <div class="enqueueTime">
-                          [[_getEnqueueTime(response.results.enqueue_time)]]
-                        </div>
-                      </div>
-                    </td>
-                    <td class="cell progressName">
-                      <template is="dom-if" if="[[_getResults(jobs, 'true', 'in progress')]]" restamp="true">
-                        <div class="progress zuul-job-result">
-                          <div class="progress-bar"
-                            id="progressBar"
-                            role="progressbar"
-                            aria-valuenow$="[[_progressPercent(jobs)]]"
-                            aria-valuemin="0"
-                            aria-valuemax="100"></div>
-                        </div>
-                      </template>
-                      <template is="dom-if" if="[[!_getResults(jobs, 'true', 'in progress')]]" restamp="true">
-                        <span class$="zuul-job-result labels [[_renderJobStatusLabel(jobs)]]">
-                          [[_getResults(jobs)]]
-                        </span>
-                      </template>
-                    </td>
-                  </tr>
-                </template>
-              </template>
-            </table>
-          </div>
-        </template>
-      </div>
-    </template>
-    <iron-iconset-svg name="zuul-icons" size="24">
-      <svg>
-        <defs>
-          <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
-          <g id="code"><path d="M9.4 16.6L4.8 12l4.6-4.6L8 6l-6 6 6 6 1.4-1.4zm5.2 0l4.6-4.6-4.6-4.6L16 6l6 6-6 6-1.4-1.4z"></path></g>
-          <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
-          <g id="query-builder"><path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm.5-13H11v6l5.25 3.15.75-1.23-4.5-2.67z"></path></g>
-          <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
-          <g id="today"><path d="M19 3h-1V1h-2v2H8V1H6v2H5c-1.11 0-1.99.9-1.99 2L3 19c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H5V8h14v11zM7 10h5v5H7z"></path></g>
-        </defs>
-      </svg>
-    </iron-iconset-svg>
-  </template>
-  <script src="zuul-status-view.js"></script>
-</dom-module>
diff --git a/src/main/resources/static/zuul-status-view.js b/src/main/resources/static/zuul-status-view.js
deleted file mode 100644
index 2073f78..0000000
--- a/src/main/resources/static/zuul-status-view.js
+++ /dev/null
@@ -1,411 +0,0 @@
-/**
- * @license
- * Copyright (C) 2019 The Android Open Source Project
- *
- * 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.
- */
- (function() {
-  'use strict';
-
-  const DEFAULT_UPDATE_INTERVAL_MS = 1000 * 2;
-  const MAX_UPDATE_INTERVAL_MS = 1000 * 30 * 2;
-
-  /**
-   * Wrapper around localStorage to prevent using it if you have it disabled.
-   *
-   * Temporary until https://gerrit-review.googlesource.com/c/gerrit/+/211472 is merged.
-   *
-   */
-  class ZuulSiteBasedStorage {
-    // Returns the local storage.
-    _storage() {
-      try {
-        return window.localStorage;
-      } catch (err) {
-        console.error('localStorage is disabled with this error ' + err);
-        return null;
-      }
-    }
-
-    getItem(key) {
-      if (this._storage() === null) { return null; }
-      return this._storage().getItem(key);
-    }
-
-    setItem(key, value) {
-      if (this._storage() === null) { return null; }
-      return this._storage().setItem(key, value);
-    }
-
-    removeItem(key) {
-      if (this._storage() === null) { return null; }
-      return this._storage().removeItem(key);
-    }
-
-    clear() {
-      if (this._storage() === null) { return null; }
-      return this._storage().clear();
-    }
-  }
-
-  Polymer({
-    is: 'zuul-status-view',
-
-    properties: {
-      zuulUrl: String,
-      zuulTenant: {
-        type: String,
-        value: null,
-      },
-      plugin: Object,
-      change: Object,
-      revision: {
-        type: Object,
-        observer: 'reload',
-      },
-      _needsUpdate: {
-        type: Boolean,
-        value: false,
-      },
-      _response: Array,
-      _updateIntervalMs: {
-        type: Number,
-        value: DEFAULT_UPDATE_INTERVAL_MS,
-      },
-      // Start time is the time that this element was loaded,
-      // used to determine how long we've been trying to update.
-      _startTime: Date,
-      _updateTimeoutID: Number,
-      _storage: {
-        type: Object,
-        value: new ZuulSiteBasedStorage(),
-      },
-      zuulDisable: {
-        type: Boolean,
-        value: false,
-      }
-    },
-
-    attached() {
-      this.listen(document, 'visibilitychange', '_handleVisibilityChange');
-      if (!this.change || !this.revision) {
-        console.warn('element attached without change and revision set.');
-        return;
-      }
-    },
-
-    detached() {
-      this._clearUpdateTimeout();
-    },
-
-    /**
-     * Reset the state of the element, then fetch and display progress.
-     *
-     * @return {Promise} Resolves upon completion.
-     */
-    async reload() {
-      this._response = null;
-      this._startTime = new Date();
-
-      if (this._storage.getItem('disable_zuul_status')) {
-        this.set('zuulDisable', true);
-      } else {
-        this.set('zuulDisable', false);
-      }
-
-      const project = this.change.project;
-      const plugin = this.plugin.getPluginName();
-      const config = await this.getConfig(project, plugin);
-      if (config && config.zuul_url) {
-        this.zuulUrl = config.zuul_url;
-        if (config.zuul_tenant) {
-          this.zuulTenant = config.zuul_tenant;
-          console.info(`zuul-status: Zuul v3 at ${this.zuulUrl}, tenant ${this.zuulTenant}`);
-        } else {
-          console.info(`zuul-status: Zuul v2 at ${this.zuulUrl}`);
-        }
-      } else {
-        console.info("No config found for plugin zuul-status");
-      }
-      if (this.zuulUrl) {
-        await this._update();
-      }
-    },
-
-    /**
-     * Fetch the config for this plugin
-     *
-     * @return {Promise} Resolves to the fetched config object,
-     *     or rejects if the response is non-OK.
-     */
-    async getConfig(project, plugin) {
-      return await this.plugin.restApi().get(
-              `/projects/${encodeURIComponent(project)}` +
-              `/${encodeURIComponent(plugin)}~config`);
-    },
-
-    /**
-     * Fetch current progress state and update properties.
-     *
-     * @return {Promise} Resolves upon completion.
-     */
-    async _update() {
-      try {
-        const response = await this.getZuulStatus(this.change, this.revision);
-        this._response = response.map((results, i) => ({
-          name: results.jobs[i].pipeline,
-          results,
-        }));
-        this._updateIntervalMs = DEFAULT_UPDATE_INTERVAL_MS;
-      } catch (err) {
-        this._updateIntervalMs = Math.min(
-            MAX_UPDATE_INTERVAL_MS,
-            (1 + Math.random()) * this._updateIntervalMs * 2);
-        console.warn(err);
-      }
-      this._resetTimeout();
-    },
-
-    /**
-     * Makes a request to the zuul server to get the status on the change.
-     *
-     * @param {ChangeInfo} change The current CL.
-     * @param {RevisionInfo} revision The current patchset.
-     * @return {Promise} Resolves to a fetch Response object.
-     */
-    async getZuulStatus(change, revision) {
-      const response = await this._getReponse(change, revision);
-
-      if (response && response.status && response.status === 200) {
-        const text = await response.text();
-        return await JSON.parse(text);
-      }
-
-      return [];
-    },
-
-    /**
-     * Makes a GET request to the zuul server.
-     *
-     * @param {ChangeInfo} change change The current CL.
-     * @param {RevisionInfo} revision The current patchset.
-     * @return {Promise} Resolves to a fetch Response object.
-     */
-    async _getReponse(change, revision) {
-      if (!change || !revision) return false;
-
-      const url = this.zuulTenant === null ?
-        `${this.zuulUrl}${change._number},${revision._number}` :
-        `${this.zuulUrl}/api/tenant/${this.zuulTenant}/status/change/${change._number},${revision._number}`;
-      const options = {method: 'GET'};
-
-      return await fetch(url, options);
-    },
-
-    /**
-     * Set a timeout to update again if applicable.
-     */
-    _resetTimeout() {
-      this._clearUpdateTimeout();
-
-      if (this._response === []) {
-        return;
-      }
-
-      this._updateTimeoutID = window.setTimeout(
-          this._updateTimeoutFired.bind(this),
-          this._updateIntervalMs);
-    },
-
-    _clearUpdateTimeout() {
-      if (this._updateTimeoutID) {
-        window.clearTimeout(this._updateTimeoutID);
-        this._updateTimeoutID = null;
-      }
-    },
-
-    _handleVisibilityChange(e) {
-      if (!document.hidden && this._needsUpdate) {
-        this._update();
-        this._needsUpdate = false;
-      }
-    },
-
-    _updateTimeoutFired() {
-      if (document.hidden) {
-        this._needsUpdate = true;
-        return;
-      }
-
-      this._update();
-    },
-
-    /**
-     * Return a string to show as the bold title in the UI.
-     */
-    _computeTitle(response) {
-      if (!response) {
-        return 'No Zuul Status Results';
-      }
-
-      return 'Zuul Status';
-    },
-
-    /**
-     * Check whether the response contains report_url.
-     *
-     * @return {String} True when we are done requesting results.
-     */
-    _computeReportURL(response) {
-
-      if (this.zuulTenant) {
-        // Zuul v3 live streaming URL has to be checked early because `report_url` always contains at least a placeholder
-        if (response && response.result == null && response.url && response.url.startsWith('stream/')) {
-          return `${this.zuulUrl}/t/${this.zuulTenant}/${response.url}`;
-        }
-      }
-
-      if (response && response.report_url) {
-        return response.report_url;
-      }
-
-      return '';
-    },
-
-    _progressPercent(jobs) {
-      if (!jobs || !jobs.elapsed_time && !jobs.remaining_time) {
-          return '';
-      }
-
-      let progressPercent = 100 * (jobs.elapsed_time / (jobs.elapsed_time +
-          jobs.remaining_time));
-
-      this.customStyle['--progress-bar-width'] = `${progressPercent}%;`;
-      this.updateStyles();
-
-      return progressPercent;
-    },
-
-    _getResults(jobs, equals, name) {
-      let result = jobs.result ? jobs.result.toLowerCase() : null;
-      if (result === null) {
-        if (jobs.url === null) {
-          result = 'queued'
-        } else if (jobs.paused !== null && jobs.paused) {
-          result = 'paused'
-        } else {
-          result = 'in progress'
-        }
-      }
-
-      if (equals) {
-        return result === name;
-      } else {
-        return result;
-      }
-    },
-
-    _renderJobStatusLabel (jobs) {
-      let result = jobs.result ? jobs.result.toLowerCase() : null;
-
-      let className;
-
-      switch (result) {
-        case 'success':
-          className = 'label-success'
-          break;
-        case 'failure':
-          className = 'label-danger'
-          break;
-        case 'unstable':
-          className = 'label-warning'
-          break;
-        case 'skipped':
-          className = 'label-info'
-          break;
-        // 'in progress' 'queued' 'lost' 'aborted' ...
-        default:
-          className = 'label-default'
-      }
-
-      return className;
-    },
-
-    _handleDisableZuulStatus(e) {
-      this._storage.setItem('disable_zuul_status', 'yes');
-
-      this.reload();
-    },
-
-    _handleEnableZuulStatus(e) {
-      this._storage.removeItem('disable_zuul_status');
-
-      this.reload();
-    },
-
-    _isFailing(results) {
-      if (results && results.results &&
-          results.results.failing_reasons.length !== 0) {
-        return true;
-      }
-
-      return false;
-    },
-
-    _getTime(ms, words) {
-      if (typeof (words) === 'undefined') {
-        words = false;
-      }
-      let seconds = (+ms) / 1000;
-      let minutes = Math.floor(seconds / 60);
-      let hours = Math.floor(minutes / 60);
-      seconds = Math.floor(seconds % 60);
-      minutes = Math.floor(minutes % 60);
-      let r = '';
-      if (words) {
-        if (hours) {
-          r += hours + ' hr ';
-        }
-        r += minutes + ' min';
-      } else {
-        if (hours < 10) {
-          r += '0';
-        }
-        r += hours + ':';
-        if (minutes < 10) {
-          r += '0';
-        }
-        r += minutes + ':';
-        if (seconds < 10) {
-          r += '0';
-        }
-        r += seconds;
-      }
-
-      return r;
-    },
-
-    _getRemainingTime(time) {
-      return time === null ?
-          'unknown' : this._getTime(time, true);
-    },
-
-    _getEnqueueTime(ms) {
-      // Special format case for enqueue time to add style
-      let now = Date.now();
-      let delta = now - ms;
-
-      return this._getTime(delta, true);
-    },
-  });
-})();
diff --git a/src/main/resources/static/zuul-status.html b/src/main/resources/static/zuul-status.html
deleted file mode 100644
index e72548e..0000000
--- a/src/main/resources/static/zuul-status.html
+++ /dev/null
@@ -1,30 +0,0 @@
-<!--
-@license
-Copyright (C) 2019 The Android Open Source Project
-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.
--->
-
-<link rel="import" href="zuul-status-view.html">
-
-<script>
-  (function() {
-    'use strict';
-
-    // This plugin is only supported in PolyGerrit from gerrit 2.16+
-    if (window.Polymer) {
-      Gerrit.install((plugin) => {
-
-        plugin.registerCustomComponent(
-            'change-view-integration', 'zuul-status-view');
-      });
-    }
-  })();
-</script>
diff --git a/zuul-status/plugin.js b/zuul-status/plugin.js
new file mode 100644
index 0000000..3dba001
--- /dev/null
+++ b/zuul-status/plugin.js
@@ -0,0 +1,23 @@
+/**
+ * @license
+ * Copyright (C) 2021 The Android Open Source Project
+ *
+ * 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 './zuul-status.js';
+
+Gerrit.install(plugin => {
+  plugin.registerCustomComponent(
+    'change-view-integration', 'zuul-status');
+});
diff --git a/zuul-status/zuul-status.js b/zuul-status/zuul-status.js
new file mode 100644
index 0000000..57d5647
--- /dev/null
+++ b/zuul-status/zuul-status.js
@@ -0,0 +1,402 @@
+/**
+ * @license
+ * Copyright (C) 2021 The Android Open Source Project
+ *
+ * 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 {htmlTemplate} from './zuul-status_html.js';
+
+const DEFAULT_UPDATE_INTERVAL_MS = 1000 * 2;
+const MAX_UPDATE_INTERVAL_MS = 1000 * 30 * 2;
+
+class ZuulStatus extends Polymer.Element {
+  static get is() {
+    return 'zuul-status';
+  }
+
+  static get template() {
+    return htmlTemplate;
+  }
+
+  static get properties() {
+    return {
+      zuulUrl: String,
+      zuulTenant: {
+        type: String,
+        value: null,
+      },
+      plugin: Object,
+      change: Object,
+      revision: {
+        type: Object,
+        observer: 'reload',
+      },
+      _needsUpdate: {
+        type: Boolean,
+        value: false,
+      },
+      _response: Object,
+      _updateIntervalMs: {
+        type: Number,
+        value: DEFAULT_UPDATE_INTERVAL_MS,
+      },
+      // Start time is the time that this element was loaded,
+      // used to determine how long we've been trying to update.
+      _startTime: Date,
+      _updateTimeoutID: Number,
+      zuulDisable: {
+        type: Boolean,
+        value: false,
+      }
+    };
+  }
+
+  connectedCallback() {
+    super.connectedCallback();
+
+    document.addEventListener('visibilitychange', this._handleVisibilityChange);
+
+    if (!this.change || !this.revision) {
+      console.warn('element attached without change and revision set.');
+      return;
+    }
+  }
+
+  disconnectedCallback() {
+    super.disconnectedCallback();
+
+    this._clearUpdateTimeout();
+  }
+
+  /**
+   * Reset the state of the element, then fetch and display progress.
+   */
+  async reload() {
+    this._response = null;
+    this._startTime = new Date();
+
+    if (window.localStorage.getItem('disable_zuul_status')) {
+      this.set('zuulDisable', true);
+    } else {
+      this.set('zuulDisable', false);
+    }
+
+    const project = this.change.project;
+    const plugin = this.plugin.getPluginName();
+    const config = await this.getConfig(project, plugin);
+    if (config && config.zuul_url) {
+      this.zuulUrl = config.zuul_url;
+      if (config.zuul_tenant) {
+        this.zuulTenant = config.zuul_tenant;
+        console.info(`zuul-status: Zuul v3 at ${this.zuulUrl}, tenant ${this.zuulTenant}`);
+      } else {
+        console.info(`zuul-status: Zuul v2 at ${this.zuulUrl}`);
+      }
+    } else {
+      console.info("No config found for plugin zuul-status");
+    }
+    if (this.zuulUrl) {
+      await this._update();
+    }
+  }
+
+  /**
+   * Fetch the config for this plugin
+   *
+   * @return {Promise} Resolves to the fetched config object,
+   *     or rejects if the response is non-OK.
+   */
+  async getConfig(project, plugin) {
+    return await this.plugin.restApi().get(
+            `/projects/${encodeURIComponent(project)}` +
+            `/${encodeURIComponent(plugin)}~config`);
+  }
+
+  /**
+   * Fetch current progress state and update properties.
+   *
+   * @return {Promise} Resolves upon completion.
+   */
+  async _update() {
+    try {
+      let response = await this.getZuulStatus(this.change, this.revision);
+      response = response.map((results, i) => {
+        if (!results || typeof results.jobs[i] === 'undefined')
+          return;
+
+        return {
+          name: results.jobs[i].pipeline,
+          results,
+        };
+      });
+
+      // We remove undefined keys from the object
+      Object.keys(response).forEach(key => {
+       if (response && typeof response[key] === 'undefined')
+          delete response[key];
+      });
+
+      this._response = response;
+
+      this._updateIntervalMs = DEFAULT_UPDATE_INTERVAL_MS;
+    } catch (err) {
+      this._updateIntervalMs = Math.min(
+          MAX_UPDATE_INTERVAL_MS,
+          (1 + Math.random()) * this._updateIntervalMs * 2);
+      console.warn(err);
+    }
+    this._resetTimeout();
+  }
+
+  /**
+   * Makes a request to the zuul server to get the status on the change.
+   *
+   * @param {ChangeInfo} change The current CL.
+   * @param {RevisionInfo} revision The current patchset.
+   * @return {Promise} Resolves to a fetch Response object.
+   */
+  async getZuulStatus(change, revision) {
+    const response = await this._getReponse(change, revision);
+
+    if (response && response.status && response.status === 200) {
+      const text = await response.text();
+      return await JSON.parse(text);
+    }
+
+    return [];
+  }
+
+  /**
+   * Makes a GET request to the zuul server.
+   *
+   * @param {ChangeInfo} change change The current CL.
+   * @param {RevisionInfo} revision The current patchset.
+   * @return {Promise} Resolves to a fetch Response object.
+   */
+  async _getReponse(change, revision) {
+    if (!change || !revision) return false;
+
+    const url = this.zuulTenant === null ?
+      `${this.zuulUrl}${change._number},${revision._number}` :
+      `${this.zuulUrl}/api/tenant/${this.zuulTenant}/status/change/${change._number},${revision._number}`;
+    const options = {method: 'GET'};
+
+    return await fetch(url, options);
+  }
+
+  /**
+   * Set a timeout to update again if applicable.
+   */
+  _resetTimeout() {
+    this._clearUpdateTimeout();
+
+    if (this._response === []) {
+      return;
+    }
+
+    this._updateTimeoutID = window.setTimeout(
+        this._updateTimeoutFired.bind(this),
+        this._updateIntervalMs);
+  }
+
+  _clearUpdateTimeout() {
+    if (this._updateTimeoutID) {
+      window.clearTimeout(this._updateTimeoutID);
+      this._updateTimeoutID = null;
+    }
+  }
+
+  _handleVisibilityChange(e) {
+    if (!document.hidden && this._needsUpdate) {
+      this._update();
+      this._needsUpdate = false;
+    }
+  }
+
+  _updateTimeoutFired() {
+    if (document.hidden) {
+      this._needsUpdate = true;
+      return;
+    }
+
+    this._update();
+  }
+
+  /**
+   * Return a string to show as the bold title in the UI.
+   */
+  _computeTitle(response) {
+    if (!response) {
+      return 'No Zuul Status Results';
+    }
+
+    return 'Zuul Status';
+  }
+
+  /**
+   * Check whether the response contains report_url.
+   *
+   * @return {String} True when we are done requesting results.
+   */
+  _computeReportURL(response) {
+    if (!response) {
+      return '';
+    }
+
+    if (this.zuulTenant) {
+      // Zuul v3 live streaming URL has to be checked early
+      // because `report_url` always contains at least a placeholder
+      if (response.result == null &&
+          response.url && response.url.startsWith('stream/')) {
+        return `${this.zuulUrl}/t/${this.zuulTenant}/${response.url}`;
+      }
+    }
+
+    if (response.report_url) {
+      return response.report_url;
+    }
+
+    return '';
+  }
+
+  _progressPercent(jobs) {
+    if (!jobs || !jobs.elapsed_time && !jobs.remaining_time) {
+        return '';
+    }
+
+    let progressPercent = 100 * (jobs.elapsed_time / (jobs.elapsed_time +
+        jobs.remaining_time));
+
+    this.updateStyles({'--progress-bar-width': `${progressPercent}%`});
+
+    return progressPercent;
+  }
+
+  _getResults(jobs, equals, name) {
+    if (!jobs) return '';
+
+    let result = jobs.result ? jobs.result.toLowerCase() : null;
+    if (result === null) {
+      if (jobs.url === null) {
+        result = 'queued'
+      } else if (jobs.paused !== null && jobs.paused) {
+        result = 'paused'
+      } else {
+        result = 'in progress'
+      }
+    }
+
+    if (equals) {
+      return result === name;
+    } else {
+      return result;
+    }
+  }
+
+  _renderJobStatusLabel (jobs) {
+    if (!jobs) return '';
+
+    let result = jobs.result ? jobs.result.toLowerCase() : null;
+
+    let className;
+
+    switch (result) {
+      case 'success':
+        className = 'label-success'
+        break;
+      case 'failure':
+        className = 'label-danger'
+        break;
+      case 'unstable':
+        className = 'label-warning'
+        break;
+      case 'skipped':
+        className = 'label-info'
+        break;
+      // 'in progress' 'queued' 'lost' 'aborted' ...
+      default:
+        className = 'label-default'
+    }
+
+    return className;
+  }
+
+  _handleDisableZuulStatus(e) {
+    window.localStorage.setItem('disable_zuul_status', 'yes');
+
+    this.reload();
+  }
+
+  _handleEnableZuulStatus(e) {
+    window.localStorage.removeItem('disable_zuul_status');
+
+    this.reload();
+  }
+
+  _isFailing(results) {
+    if (results && results.results &&
+        results.results.failing_reasons.length !== 0) {
+      return true;
+    }
+
+    return false;
+  }
+
+  _getTime(ms, words) {
+    if (typeof (words) === 'undefined') {
+      words = false;
+    }
+    let seconds = (+ms) / 1000;
+    let minutes = Math.floor(seconds / 60);
+    let hours = Math.floor(minutes / 60);
+    seconds = Math.floor(seconds % 60);
+    minutes = Math.floor(minutes % 60);
+    let r = '';
+    if (words) {
+      if (hours) {
+        r += hours + ' hr ';
+      }
+      r += minutes + ' min';
+    } else {
+      if (hours < 10) {
+        r += '0';
+      }
+      r += hours + ':';
+      if (minutes < 10) {
+        r += '0';
+      }
+      r += minutes + ':';
+      if (seconds < 10) {
+        r += '0';
+      }
+      r += seconds;
+    }
+
+    return r;
+  }
+
+  _getRemainingTime(time) {
+    return time === null ?
+        'unknown' : this._getTime(time, true);
+  }
+
+  _getEnqueueTime(ms) {
+    // Special format case for enqueue time to add style
+    let now = Date.now();
+    let delta = now - ms;
+
+    return this._getTime(delta, true);
+  }
+}
+
+customElements.define(ZuulStatus.is, ZuulStatus);
diff --git a/zuul-status/zuul-status_html.js b/zuul-status/zuul-status_html.js
new file mode 100644
index 0000000..be07481
--- /dev/null
+++ b/zuul-status/zuul-status_html.js
@@ -0,0 +1,237 @@
+/**
+ * @license
+ * Copyright (C) 2021 The Android Open Source Project
+ *
+ * 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.
+ */
+
+export const htmlTemplate = Polymer.html`
+<style include="shared-styles">
+  #view-container {
+    display: block;
+  }
+  .container {
+    align-items: center;
+    display: flex;
+    flex-wrap: wrap;
+  }
+  .header {
+    background-color: var(--table-header-background-color);
+    justify-content: space-between;
+    min-height: 3.2em;
+    padding: .5em var(--default-horizontal-margin, 1rem);
+    border-top: 1px solid var(--border-color);
+    border-bottom: 1px solid var(--border-color);
+  }
+  .header .label {
+    font: inherit;
+    font-family: var(--font-family-bold);
+    font-size: 1.17em;
+    margin-right: 1em;
+  }
+  .progress {
+    overflow: hidden;
+    height: 20px;
+    margin-bottom: 20px;
+    background-color: #ededed;
+    border-radius: 1px;
+    -webkit-box-shadow: inset 0 1px 2px rgba(0,0,0,.1);
+    box-shadow: inset 0 1px 2px rgba(0,0,0,.1);
+  }
+  .progress-bar {
+    float: left;
+    width: 0;
+    height: 100%;
+    font-size: 11px;
+    line-height: 20px;
+    color: #fff;
+    text-align: center;
+    background-color: #39a5dc;
+    -webkit-box-shadow: inset 0 -1px 0 rgba(0,0,0,.15);
+    box-shadow: inset 0 -1px 0 rgba(0,0,0,.15);
+    -webkit-transition: width .6s ease;
+    -o-transition: width .6s ease;
+    transition: width .6s ease;
+  }
+  #progressBar {
+    width: var(--progress-bar-width, 0%);
+  }
+  #list {
+    padding-bottom: 1em;
+  }
+  .labels {
+    display: inline;
+    padding: .2em .6em .3em;
+    font-size: 100%;
+    font-weight: 600;
+    line-height: 1;
+    color: #fff;
+    text-align: center;
+    white-space: nowrap;
+    vertical-align: baseline;
+    border-radius: 0;
+  }
+  .label-danger {
+    background-color: #c00;
+  }
+  .label-default {
+    background-color: #9c9c9c;
+  }
+  .label-info {
+    background-color: #00659c;
+  }
+  .label-success {
+    background-color: #3f9c35;
+  }
+  .label-warning {
+    background-color: #ec7a08;
+  }
+  .zuul-job-result {
+    float: right;
+    width: 90px;
+    height: 20px;
+    margin: 2px 5px 0;
+    padding: 4px;
+  }
+  a {
+    color: var(--primary-text-color);
+    cursor: pointer;
+    display: inline-block;
+    text-decoration: none;
+  }
+  a:hover {
+    text-decoration: underline;
+  }
+  .time, {
+    align-items: center;
+    color: #757575;
+    display: flex;
+  }
+  #icon {
+    margin-right: 0.5em;
+  }
+</style>
+<style include="gr-change-list-styles">
+  :host {
+    font-size: var(--font-size-normal);
+  }
+  .padding {
+    width: var(--default-horizontal-margin);
+    padding: 0.5em;
+  }
+  #changeList {
+    border-collapse: collapse;
+    width: 100%;
+  }
+</style>
+<template is="dom-if" if="[[zuulUrl]]">
+  <div id="view-container">
+    <div class="header container">
+      <div class="container">
+        <h3 class="labelName">[[_computeTitle(_response)]]</h3>
+      </div>
+      <div class="container">
+        <template is="dom-if" if="[[!zuulDisable]]" restamp="true">
+          <gr-button link on-tap="_handleDisableZuulStatus">Disable Zuul Status</gr-button>
+        </template>
+        <template is="dom-if" if="[[zuulDisable]]" restamp="true">
+          <gr-button link on-tap="_handleEnableZuulStatus">Enable Zuul Status</gr-button>
+        </template>
+      </div>
+    </div>
+    <template is="dom-if" if="[[!zuulDisable]]" restamp="true">
+      <div class="bottom container">
+        <table id="changeList">
+          <template is="dom-repeat" items=[[_response]] as="response">
+            <tbody>
+              <tr class="groupHeader">
+                <td class="padding">
+                  <template is="dom-if" if="[[!_isFailing(response)]]" restamp="true">
+                    <span class="fullStatus success">
+                      <iron-icon id="icon" icon="gr-icons:check" style="color: #388E3C;"></iron-icon>
+                    </span>
+                  </template>
+                  <template is="dom-if" if="[[_isFailing(response)]]" restamp="true">
+                    <span class="fullStatus failed">
+                      <iron-icon id="icon" icon="gr-icons:close" style="color: #D32F2F;"></iron-icon>
+                    </span>
+                  </template>
+                </td>
+                <td class="cell" colspan="15">[[response.name]]</td>
+              </tr>
+            </tbody>
+            <template is="dom-repeat" items=[[response.results.jobs]] as="jobs">
+              <tbody class="groupContent">
+                <tr class="groupTitle">
+                  <td class="padding"></td>
+                  <td class="cell jobName">
+                    <iron-icon id="icon" icon="zuul-icons:code"></iron-icon>
+                    <a href="[[_computeReportURL(jobs)]]" target="_blank"
+                       hidden$="[[!jobs.name]]">
+                      [[jobs.name]]
+                    </a>
+                  </td>
+                  <td class="cell">
+                    <div class="time">
+                      <iron-icon id="icon" icon="zuul-icons:query-builder"></iron-icon>
+                      <div class="remainingTime">
+                        [[_getRemainingTime(response.results.remaining_time)]]
+                      </div>
+                    </div>
+                  </td>
+                  <td class="cell">
+                    <div class="time">
+                      <iron-icon id="icon" icon="zuul-icons:today"></iron-icon>
+                      <div class="enqueueTime">
+                        [[_getEnqueueTime(response.results.enqueue_time)]]
+                      </div>
+                    </div>
+                  </td>
+                  <td class="cell progressName">
+                    <template is="dom-if" if="[[_getResults(jobs, 'true', 'in progress')]]" restamp="true">
+                      <div class="progress zuul-job-result">
+                        <div class="progress-bar"
+                          id="progressBar"
+                          role="progressbar"
+                          aria-valuenow$="[[_progressPercent(jobs)]]"
+                          aria-valuemin="0"
+                          aria-valuemax="100"></div>
+                      </div>
+                    </template>
+                    <template is="dom-if" if="[[!_getResults(jobs, 'true', 'in progress')]]" restamp="true">
+                      <span class$="zuul-job-result labels [[_renderJobStatusLabel(jobs)]]">
+                        [[_getResults(jobs)]]
+                      </span>
+                    </template>
+                  </td>
+                </tr>
+              </tbody>
+            </template>
+          </template>
+        </table>
+      </div>
+    </template>
+  </div>
+</template>
+<iron-iconset-svg name="zuul-icons" size="24">
+  <svg>
+    <defs>
+      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
+      <g id="code"><path d="M9.4 16.6L4.8 12l4.6-4.6L8 6l-6 6 6 6 1.4-1.4zm5.2 0l4.6-4.6-4.6-4.6L16 6l6 6-6 6-1.4-1.4z"></path></g>
+      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
+      <g id="query-builder"><path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm.5-13H11v6l5.25 3.15.75-1.23-4.5-2.67z"></path></g>
+      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
+      <g id="today"><path d="M19 3h-1V1h-2v2H8V1H6v2H5c-1.11 0-1.99.9-1.99 2L3 19c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H5V8h14v11zM7 10h5v5H7z"></path></g>
+    </defs>
+  </svg>
+</iron-iconset-svg>`;