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; + }); + } +}