Convert from Polymer to Lit

Change-Id: Ib9c32a740d90aaeaa0d5be4150e66834e358bf05
diff --git a/.gitignore b/.gitignore
index 0f55ddc..53acbd5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,4 @@
-
+.DS_Store
 /.classpath
 /.primary_build_tool
 /.project
diff --git a/web/.eslintrc.js b/web/.eslintrc.js
deleted file mode 100644
index cdb0d00..0000000
--- a/web/.eslintrc.js
+++ /dev/null
@@ -1,20 +0,0 @@
-/**
- * @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.
- */
-__plugindir = 'zuul-results-summary/web';
-module.exports = {
-  extends: '../../.eslintrc.js',
-};
diff --git a/web/BUILD b/web/BUILD
index 818e7a7..4ee1836 100644
--- a/web/BUILD
+++ b/web/BUILD
@@ -1,5 +1,6 @@
 load("//tools/js:eslint.bzl", "plugin_eslint")
 load("//tools/bzl:js.bzl", "gerrit_js_bundle")
+load("@npm//@bazel/typescript:index.bzl", "ts_config", "ts_project")
 
 package_group(
     name = "visibility",
@@ -8,9 +9,34 @@
 
 package(default_visibility = [":visibility"])
 
+ts_config(
+    name = "tsconfig",
+    src = "tsconfig.json",
+    deps = [
+        "//plugins:tsconfig-plugins-base.json",
+    ],
+)
+
+ts_project(
+    name = "zuul-results-summary-ts",
+    srcs = glob(
+        ["**/*.ts"],
+        exclude = ["**/*test*"],
+    ),
+    incremental = True,
+    out_dir = "_bazel_ts_out",
+    tsc = "//tools/node_tools:tsc-bin",
+    tsconfig = ":tsconfig",
+    deps = [
+        "@plugins_npm//@gerritcodereview/typescript-api",
+        "@plugins_npm//lit",
+    ],
+)
+
 gerrit_js_bundle(
     name = "zuul-results-summary",
-    entry_point = "plugin.js",
+    srcs = [":zuul-results-summary-ts"],
+    entry_point = "_bazel_ts_out/plugin.js",
 )
 
 # bazel run plugins/zuul-results-summary/web:lint_bin
diff --git a/web/eslint.config.js b/web/eslint.config.js
new file mode 100644
index 0000000..74ce3bf
--- /dev/null
+++ b/web/eslint.config.js
@@ -0,0 +1,18 @@
+/**
+ * @license
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+const {defineConfig} = require('eslint/config');
+
+// eslint-disable-next-line no-undef
+__plugindir = 'zuul-results-summary/web';
+
+const gerritEslint = require('../../eslint.config.js');
+
+module.exports = defineConfig([
+  {
+    extends: [gerritEslint],
+  },
+]);
\ No newline at end of file
diff --git a/web/plugin.js b/web/plugin.js
deleted file mode 100644
index 3a9907f..0000000
--- a/web/plugin.js
+++ /dev/null
@@ -1,411 +0,0 @@
-// Copyright (c) 2020 Red Hat
-//
-// 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.
-
-// TODO(ianw) : find some way to make this configurable
-const ZUUL_PRIORITY = [22348];
-
-/*
- * Tab contents
- */
-class ZuulSummaryStatusTab extends Polymer.Element {
-
-  /** Get properties
-   *
-   * @returns {dict} change and revision
-   */
-  static get properties() {
-    return {
-      plugin: Object,
-      _enabled: Boolean,
-      change: {
-        type: Object,
-        observer: '_processChange',
-      },
-      revision: Object,
-    };
-  }
-
-  /** Get template
-   *
-   * @returns {Polymer.html} the template
-   */
-  static get template() {
-    return Polymer.html`
-  <style>
-    table {
-      table-layout: fixed;
-      width: 100%;
-      border-collapse: collapse;
-    }
-
-    th, td {
-      text-align: left;
-      padding: 2px;
-    }
-
-    th {
-      background-color: var(--background-color-primary, #f7ffff);
-      font-weight: normal;
-      color: var(--primary-text-color, rgb(33, 33, 33));
-    }
-
-    thead tr th:first-of-type,
-    tbody tr td:first-of-type {
-     padding-left: 12px;
-    }
-
-    a:link, a:visited {
-      color: var(--link-color);
-    }
-
-    tr:nth-child(even) {
-     background-color: var(--background-color-secondary, #f2f2f2);
-    }
-
-    tr:nth-child(odd) {
-     background-color: var(--background-color-tertiary, #f7ffff);
-    }
-
-    tr:hover td {
-     background-color: var(--hover-background-color, #fffed);
-    }
-
-    .status-SUCCESS {
-      color: green;
-    }
-
-    .status-FAILURE {
-      color: red;
-    }
-
-    .status-ERROR {
-      color: red;
-    }
-
-    .status-RETRY_LIMIT {
-      color: red;
-    }
-
-    .status-SKIPPED {
-      color: #73bcf7;
-    }
-
-    .status-ABORTED {
-      color: orange;
-    }
-
-    .status-MERGER_FAILURE {
-      color: orange;
-    }
-
-    .status-NODE_FAILURE {
-      color: orange;
-    }
-
-    .status-TIMED_OUT {
-      color: orange;
-    }
-
-    .status-POST_FAILURE {
-      color: orange;
-    }
-
-    .status-CONFIG_ERROR {
-      color: orange;
-    }
-
-    .status-DISK_FULL {
-      color: orange;
-    }
-
-    .date {
-      color: var(--deemphasized-text-color);
-    }
-  </style>
-
-  <template is="dom-if" if="[[!_enabled]]">
-      Zuul integration is not enabled.
-  </template>
-  <template is="dom-repeat" items="[[__table]]">
-   <div style="padding-bottom:2px;">
-   <table>
-    <thead>
-     <tr>
-      <th>
-       <template is="dom-if" if="{{item.succeeded}}"><gr-icon icon="check" style="color:var(--success-foreground)"></gr-icon></template>
-       <template is="dom-if" if="{{!item.succeeded}}"><gr-icon icon="close" style="color:var(--error-foreground)"></gr-icon></template>
-       <b>[[item.author_name]]</b> on Patchset <b>[[item.revision]]</b> in pipeline <b>[[item.pipeline]]</b></th>
-      <th><template is="dom-if" if="{{item.rechecks}}">[[item.rechecks]] rechecks</template></th>
-      <th><span class="date"><gr-date-formatter show-date-and-time="" date-str="[[item.gr_date]]"></gr-date-formatter></span></th>
-     </tr>
-    </thead>
-    <tbody>
-     <template is="dom-repeat" items="[[item.results]]" as="job">
-      <tr>
-       <template is="dom-if" if="{{job.link}}"><td><a href="{{job.link}}">[[job.job]]</a></td></template>
-       <template is="dom-if" if="{{!job.link}}"><td><span style="color: var(--secondary-text-color)">[[job.job]]</span></td></template>
-       <template is="dom-if" if="{{job.errormsg}}"><td><span title="[[job.errormsg]]" class$="status-[[job.result]]">[[job.result]]</span></td></template>
-       <template is="dom-if" if="{{!job.errormsg}}"><td><span class$="status-[[job.result]]">[[job.result]]</span></td></template>
-       <td>[[job.time]]</td>
-      </tr>
-     </template>
-    </tbody>
-   </table>
-   </div>
-  </template>`;
-  }
-
-  /**
-   * Process the change. Retrieve project configuration, and if it's
-   * enabled for Zuul, parse the messages and render Zuul Summary tab.
-   *
-   * @param {Object} change
-   */
-  async _processChange(change) {
-    // TODO(davido): Cache results of project config request
-    this._enabled = await this._projectEnabled(change.project);
-    if (this._enabled) {
-      this._processMessages(change);
-    }
-  }
-
-  /**
-   * Returns whether the project is enabled for Zuul.
-   *
-   * @param {string} project
-   * @return {promise<boolean>} Resolves to true if the project is enabled
-   *     otherwise, false.
-   */
-  async _projectEnabled(project) {
-    const configPromise = this.plugin.restApi().get(
-        `/projects/${encodeURIComponent(project)}/` +
-        `${encodeURIComponent(this.plugin.getPluginName())}~config`);
-    try {
-      const config = await configPromise;
-      return config && config.enabled;
-    } catch (error) {
-      console.log(error);
-      return false;
-    }
-  }
-
-  /** Look for Zuul tag in message
-   *
-   * @param{ChangeMessageInfo} message
-   * @returns {bool} if this is a Zuul message or not
-   */
-  _match_message_via_tag(message) {
-    return !!(message.tag &&
-        message.tag.startsWith('autogenerated:zuul'));
-  }
-
-  /** Look for 3rd-party CI messages via regex
-   *
-   * @param{ChangeMessageInfo} message
-   * @returns {bool} if this is a Zuul-ish message or not
-   */
-  _match_message_via_regex(message) {
-    // TODO: allow this to be passed in via config
-    const authorRe = /^(?<author>.* CI|Zuul)/;
-    const author = authorRe.exec(message.author.name);
-    return !!author;
-  }
-
-  /** Extract the status and pipeline from the message
-   *
-   * @param{ChangeMessageInfo} message
-   * @returns {list} status and pipeline
-   */
-  _get_status_and_pipeline(message) {
-    // Look for the full Zuul-3ish build status message, e.g.:
-    //    Build succeeded (check pipeline).
-    const statusRe = /^Build (?<status>\w+) \((?<pipeline>[\w]+) pipeline\)\./gm;
-    let statusMatch = statusRe.exec(message.message);
-    if (!statusMatch) {
-      // Match non-pipeline CI comments, e.g.:
-      //   Build succeeded.
-      const statusRe = /^Build (?<status>\w+)\./gm;
-      statusMatch = statusRe.exec(message.message);
-    }
-    if (!statusMatch) {
-      return false; // we can't parse this
-    }
-
-    const status = statusMatch.groups.status;
-    const pipeline = statusMatch.groups.pipeline ?
-      statusMatch.groups.pipeline : 'unknown';
-    return [status, pipeline];
-  }
-
-  /** Change Modified */
-  _processMessages(change) {
-    /*
-     * change-view-tab-content gets passed ChangeInfo object [1],
-     * registered in the property "change".  We walk the list of
-     * messages with some regexps to extract into a data structure
-     * stored in __table
-     *
-     * __table is an [] of objects
-     *
-     *  author: "<string> CI"
-     *  date: Date object of date message posted, useful for
-     *    sorting, diffs, etc.
-     *  gr_date: original message timestamp sutiable to pass to
-     *    gr-date-formatter
-     *  revision: the revision the patchset was made against
-     *  rechecks: the number of times we've seen the same
-     *    ci run for the same revision
-     *  status: one of <succeeded|failed>
-     *  pipeline: string of reporting pipeline
-     *    (may be undefined for some CI)
-     *  results: [] of objects
-     *    job: job name
-     *    link: raw URL link to logs
-     *    result: one of <SUCCESS|FAILURE>
-     *    time: duration of run in human string (e.g. 2m 5s)
-     *
-     * This is then presented by the template
-     *
-     * [1] https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#change-info
-     */
-    this.__table = [];
-    change.messages.forEach(message => {
-      if (! (this._match_message_via_tag(message) ||
-                    this._match_message_via_regex(message))) {
-        return;
-      }
-
-      const date = new Date(message.date);
-      const revision = message._revision_number;
-      const sp = this._get_status_and_pipeline(message);
-      if (!sp) {
-        // This shouldn't happen as we've validated it is a Zuul message.
-        return;
-      }
-      const status = sp[0];
-      const pipeline = sp[1];
-
-      // We only want the latest entry for each CI system in
-      // each pipeline
-      const existing = this.__table.findIndex(entry =>
-        (entry.author_id === message.author._account_id) &&
-                    (entry.pipeline === pipeline));
-
-      // If this is a comment by the same CI on the same pipeline and
-      // the same revision, it's considered a "recheck" ... i.e. likely
-      // manually triggered to run again.  Take a note of this.
-      let rechecks = 0;
-      if (existing !== -1) {
-        if (this.__table[existing].revision === revision) {
-          rechecks = this.__table[existing].rechecks + 1;
-        }
-      }
-
-      // Find each result line
-      const results = [];
-      const lines = message.message.split('\n');
-      // We have to match a few different things ...
-      // A "standard" line is like
-      //   - passing-job http://... : SUCCESS in 2m 45s
-      // Skipped jobs don't have a time, e.g.
-      //   - skipped-job http://... : SKIPPED
-      // Error status has a string before the time
-      //   - error-job http://... : ERROR A freeform string in 2m 45s
-
-      const resultRe = /^- (?<job>[^ ]+) (?:(?<link>https?:\/\/[^ ]+)|[^ ]+) : ((ERROR (?<errormsg>.*?) in (?<errtime>.*))|(?<result>[^ ]+)( in (?<time>.*))?)/;
-      lines.forEach(line => {
-        const result = resultRe.exec(line);
-        if (result) {
-          if (result.groups.result === "SKIPPED") {
-            result.groups.link = null;
-          }
-          // Note you can't duplicate match group names, even if
-          // it's behind an | statement like above.  So for error
-          // matches we copy things into the right place to display.
-          if (result.groups.errormsg) {
-            result.groups.result = "ERROR";
-            result.groups.time = result.groups.errtime;
-          }
-          results.push(result.groups);
-        }
-      });
-
-      const table = {
-        author_name: message.author.name,
-        author_id: message.author._account_id,
-        revision,
-        rechecks,
-        date,
-        gr_date: message.date,
-        status,
-        succeeded: status === 'succeeded',
-        pipeline,
-        results,
-      };
-
-      if (existing === -1) {
-        this.__table.push(table);
-      } else {
-        this.__table[existing] = table;
-      }
-
-      // Sort first by listed priority, then by date
-      this.__table.sort((a, b) => {
-        // >>> 0 is just a trick to convert -1 to uint max
-        // of 2^32-1
-        const p_a = ZUUL_PRIORITY.indexOf(a.author_id) >>> 0;
-        const p_b = ZUUL_PRIORITY.indexOf(b.author_id) >>> 0;
-        const priority = p_a - p_b;
-        const date = b.date - a.date;
-        return priority || date;
-      });
-    });
-  }
-}
-
-customElements.define('zuul-summary-status-tab',
-    ZuulSummaryStatusTab);
-
-/*
- * Tab Header Element
- */
-class ZuulSummaryStatusTabHeader extends Polymer.Element {
-  /** Get template
-   *
-   * @returns {Polymer.html} the template
-   */
-  static get template() {
-    return Polymer.html`Zuul Summary`;
-  }
-}
-
-customElements.define('zuul-summary-status-tab-header',
-    ZuulSummaryStatusTabHeader);
-
-/*
- * Install plugin
- */
-Gerrit.install(plugin => {
-  'use strict';
-
-  plugin.registerDynamicCustomComponent(
-      'change-view-tab-header',
-      'zuul-summary-status-tab-header'
-  );
-
-  plugin.registerDynamicCustomComponent(
-      'change-view-tab-content',
-      'zuul-summary-status-tab'
-  );
-});
diff --git a/web/plugin.ts b/web/plugin.ts
new file mode 100644
index 0000000..d696391
--- /dev/null
+++ b/web/plugin.ts
@@ -0,0 +1,32 @@
+/**
+ * @license
+ * Copyright (C) 2020 Red Hat
+ *
+ * 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 '@gerritcodereview/typescript-api/gerrit';
+import './zuul-summary-status-tab';
+import './zuul-summary-status-tab-header';
+
+window.Gerrit?.install(plugin => {
+  plugin.registerDynamicCustomComponent(
+    'change-view-tab-header',
+    'zuul-summary-status-tab-header',
+  );
+
+  plugin.registerDynamicCustomComponent(
+    'change-view-tab-content',
+    'zuul-summary-status-tab',
+  );
+});
diff --git a/web/tsconfig.json b/web/tsconfig.json
index 19040ee..2e886b5 100644
--- a/web/tsconfig.json
+++ b/web/tsconfig.json
@@ -1,5 +1,8 @@
 {
   "extends": "../../tsconfig-plugins-base.json",
+  "compilerOptions": {
+    "outDir": "../../../.ts-out/plugins/zuul-results-summary" /* overridden by bazel */
+  },
   "include": [
     "**/*.ts"
   ]
diff --git a/web/zuul-summary-status-tab-header.ts b/web/zuul-summary-status-tab-header.ts
new file mode 100644
index 0000000..b60b1c7
--- /dev/null
+++ b/web/zuul-summary-status-tab-header.ts
@@ -0,0 +1,32 @@
+/**
+ * @license
+ * Copyright (C) 2020 Red Hat
+ *
+ * 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 {LitElement, html} from 'lit';
+import {customElement} from 'lit/decorators.js';
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'zuul-summary-status-tab-header': ZuulSummaryStatusTabHeader;
+  }
+}
+
+@customElement('zuul-summary-status-tab-header')
+export class ZuulSummaryStatusTabHeader extends LitElement {
+  override render() {
+    return html`Zuul Summary`;
+  }
+}
diff --git a/web/zuul-summary-status-tab.ts b/web/zuul-summary-status-tab.ts
new file mode 100644
index 0000000..93c091e
--- /dev/null
+++ b/web/zuul-summary-status-tab.ts
@@ -0,0 +1,310 @@
+/**
+ * @license
+ * Copyright (C) 2020 Red Hat
+ *
+ * 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 {PluginApi} from '@gerritcodereview/typescript-api/plugin';
+import {
+  ChangeInfo,
+  RevisionInfo,
+} from '@gerritcodereview/typescript-api/rest-api';
+import {LitElement, html, css, nothing} from 'lit';
+import {customElement, property, state} from 'lit/decorators.js';
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'zuul-summary-status-tab': ZuulSummaryStatusTab;
+  }
+}
+
+interface ZuulConfig {
+  enabled?: boolean;
+}
+
+interface ZuulJobResult {
+  job: string;
+  link?: string;
+  result: string;
+  errormsg?: string;
+  time?: string;
+}
+
+interface ZuulTableItem {
+  succeeded: boolean;
+  author_name: string;
+  revision: string | number;
+  pipeline: string;
+  rechecks?: number;
+  gr_date: string;
+  results: ZuulJobResult[];
+}
+
+const ZUUL_PRIORITY = [22348];
+
+@customElement('zuul-summary-status-tab')
+export class ZuulSummaryStatusTab extends LitElement {
+  @property({type: Object})
+  plugin!: PluginApi;
+
+  @property({type: Object}) change?: ChangeInfo;
+
+  @property({type: Object}) revision?: RevisionInfo;
+
+  @state() private _enabled? = false;
+
+  @state() private __table: ZuulTableItem[] = [];
+
+  static override get styles() {
+    return [
+      css`
+        table {
+          table-layout: fixed;
+          width: 100%;
+          border-collapse: collapse;
+        }
+        th,
+        td {
+          text-align: left;
+          padding: 2px;
+        }
+        th {
+          background-color: var(--background-color-primary, #f7ffff);
+          font-weight: normal;
+          color: var(--primary-text-color, rgb(33, 33, 33));
+        }
+        thead tr th:first-of-type,
+        tbody tr td:first-of-type {
+          padding-left: 12px;
+        }
+        a:link,
+        a:visited {
+          color: var(--link-color);
+        }
+        tr:nth-child(even) {
+          background-color: var(--background-color-secondary, #f2f2f2);
+        }
+        tr:nth-child(odd) {
+          background-color: var(--background-color-tertiary, #f7ffff);
+        }
+        tr:hover td {
+          background-color: var(--hover-background-color, #fffed);
+        }
+        .status-SUCCESS {
+          color: green;
+        }
+        .status-FAILURE,
+        .status-ERROR,
+        .status-RETRY_LIMIT {
+          color: red;
+        }
+        .status-SKIPPED {
+          color: #73bcf7;
+        }
+        .status-ABORTED,
+        .status-MERGER_FAILURE,
+        .status-NODE_FAILURE,
+        .status-TIMED_OUT,
+        .status-POST_FAILURE,
+        .status-CONFIG_ERROR,
+        .status-DISK_FULL {
+          color: orange;
+        }
+        .date {
+          color: var(--deemphasized-text-color);
+        }
+      `,
+    ];
+  }
+
+  override updated(changed: Map<string, unknown>) {
+    if (changed.has('change')) this._processChange(this.change);
+  }
+
+  override render() {
+    if (!this._enabled) {
+      return html`Zuul integration is not enabled.`;
+    }
+
+    return html`
+      ${this.__table.map(
+        item => html`
+          <div style="padding-bottom:2px;">
+            <table>
+              <thead>
+                <tr>
+                  <th>
+                    ${item.succeeded
+                      ? html`<gr-icon
+                          icon="check"
+                          style="color:var(--success-foreground)"
+                        ></gr-icon>`
+                      : html`<gr-icon
+                          icon="close"
+                          style="color:var(--error-foreground)"
+                        ></gr-icon>`}
+                    <b>${item.author_name}</b> on Patchset
+                    <b>${item.revision}</b> in pipeline <b>${item.pipeline}</b>
+                  </th>
+                  <th>
+                    ${item.rechecks ? html`${item.rechecks} rechecks` : nothing}
+                  </th>
+                  <th class="date">
+                    <gr-date-formatter
+                      show-date-and-time
+                      date-str="${item.gr_date}"
+                    ></gr-date-formatter>
+                  </th>
+                </tr>
+              </thead>
+              <tbody>
+                ${item.results.map(
+                  job => html`
+                    <tr>
+                      <td>
+                        ${job.link
+                          ? html`<a href="${job.link}">${job.job}</a>`
+                          : html`<span
+                              style="color: var(--secondary-text-color)"
+                              >${job.job}</span
+                            >`}
+                      </td>
+                      <td>
+                        <span
+                          class=${`status-${job.result}`}
+                          title=${job.errormsg ?? ''}
+                          >${job.result}</span
+                        >
+                      </td>
+                      <td>${job.time}</td>
+                    </tr>
+                  `,
+                )}
+              </tbody>
+            </table>
+          </div>
+        `,
+      )}
+    `;
+  }
+
+  private async _processChange(change: any) {
+    this._enabled = await this._projectEnabled(change.project);
+    if (this._enabled) this._processMessages(change);
+  }
+
+  private async _projectEnabled(project: string): Promise<boolean | undefined> {
+    try {
+      const config = (await this.plugin
+        .restApi()
+        .get(
+          `/projects/${encodeURIComponent(project)}/${encodeURIComponent(
+            this.plugin.getPluginName(),
+          )}~config`,
+        )) as ZuulConfig;
+      return config?.enabled;
+    } catch (error) {
+      console.warn(error);
+      return false;
+    }
+  }
+
+  private _match_message_via_tag(msg: any) {
+    return !!msg.tag?.startsWith('autogenerated:zuul');
+  }
+
+  private _match_message_via_regex(msg: any) {
+    return /^(.* CI|Zuul)/.test(msg.author.name);
+  }
+
+  private _get_status_and_pipeline(msg: any): [string, string] | false {
+    const fullMatch = /^Build (\w+) \(([\w]+) pipeline\)\./gm.exec(msg.message);
+    if (fullMatch) return [fullMatch[1], fullMatch[2]];
+    const simpleMatch = /^Build (\w+)\./gm.exec(msg.message);
+    if (simpleMatch) return [simpleMatch[1], 'unknown'];
+    return false;
+  }
+
+  private _processMessages(change: any) {
+    const table: any[] = [];
+
+    change.messages.forEach((message: any) => {
+      if (
+        !(
+          this._match_message_via_tag(message) ||
+          this._match_message_via_regex(message)
+        )
+      )
+        return;
+
+      const date = new Date(message.date);
+      const revision = message._revision_number;
+      const [status, pipeline] = this._get_status_and_pipeline(message) || [];
+
+      if (!status) return;
+
+      const existingIdx = table.findIndex(
+        entry =>
+          entry.author_id === message.author._account_id &&
+          entry.pipeline === pipeline,
+      );
+
+      let rechecks = 0;
+      if (existingIdx !== -1 && table[existingIdx].revision === revision) {
+        rechecks = table[existingIdx].rechecks + 1;
+      }
+
+      const results: any[] = [];
+      const resultRe =
+        /^- (?<job>[^ ]+) (?:(?<link>https?:\/\/[^ ]+)|[^ ]+) : ((ERROR (?<errormsg>.*?) in (?<errtime>.*))|(?<result>[^ ]+)( in (?<time>.*))?)/;
+
+      message.message.split('\n').forEach((line: string) => {
+        const match = resultRe.exec(line);
+        if (match?.groups) {
+          if (match.groups.result === 'SKIPPED') match.groups.link = '';
+          if (match.groups.errormsg) {
+            match.groups.result = 'ERROR';
+            match.groups.time = match.groups.errtime;
+          }
+          results.push(match.groups);
+        }
+      });
+
+      const row = {
+        author_name: message.author.name,
+        author_id: message.author._account_id,
+        revision,
+        rechecks,
+        date,
+        gr_date: message.date,
+        status,
+        succeeded: status === 'succeeded',
+        pipeline,
+        results,
+      };
+
+      if (existingIdx === -1) {
+        table.push(row);
+      } else {
+        table[existingIdx] = row;
+      }
+    });
+
+    this.__table = table.sort((a, b) => {
+      const pa = ZUUL_PRIORITY.indexOf(a.author_id) >>> 0;
+      const pb = ZUUL_PRIORITY.indexOf(b.author_id) >>> 0;
+      return pa - pb || b.date - a.date;
+    });
+  }
+}