| // Copyright 2017 The Chromium Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| import {PluginApi} from '@gerritcodereview/typescript-api/plugin'; |
| import {css, html, LitElement} from 'lit'; |
| import {customElement, property, state} from 'lit/decorators'; |
| import { |
| BuildbucketV2Client, |
| Builder, |
| GerritChange, |
| makeBuildRequests, |
| } from './buildbucket-client'; |
| import {getNewOperationId} from './buildbucket-utils'; |
| import {Config} from './checks-fetcher'; |
| |
| let _TRYJOB_PICKER: any = null; |
| |
| declare interface Bucket { |
| project: string; |
| bucket: string; |
| builders: string[]; |
| } |
| |
| /** |
| * Open a CrTryjobPicker popup with the given configuration and change info. |
| * |
| * @param plugin: the plugin object |
| * @param pluginConfig: the plugin configurtion object |
| * @param buildbucketHost: the Buildbucket host |
| * @param project: the change project |
| * @param change: the change number |
| * @param patchset: the patchset number |
| */ |
| export async function openTryjobPicker( |
| plugin: PluginApi, |
| pluginConfig: Config | null, |
| buildbucketHost: string, |
| project: string, |
| change: number, |
| patchset: number |
| ) { |
| if (_TRYJOB_PICKER) { |
| _TRYJOB_PICKER.close(); |
| } |
| _TRYJOB_PICKER = await plugin.popup('cr-tryjob-picker'); |
| const el = _TRYJOB_PICKER._getElement().querySelector('cr-tryjob-picker'); |
| Object.assign(el, { |
| buildbucketHost, |
| change: {_number: change, project}, |
| revision: {_number: patchset}, |
| close: () => _TRYJOB_PICKER.close(), |
| }); |
| |
| // pluginConfig triggers observer function and hence the other properties |
| // needs to be set first. |
| Object.assign(el, {pluginConfig}); |
| |
| // The autofocus attribute of the filter input does not take effect when |
| // initialized here. Manually focus on the input. |
| el.shadowRoot.querySelector('input.filter').focus(); |
| } |
| |
| @customElement('cr-tryjob-picker') |
| export class CrTryjobPicker extends LitElement { |
| @property() |
| buildbucketHost: string | undefined; |
| |
| @property() |
| change: {_number?: number; project?: string} = {}; |
| |
| @property() |
| revision: {_number?: number} | undefined; |
| |
| @property() |
| plugin: PluginApi | undefined; |
| |
| @property() |
| disabled = false; |
| |
| @state() |
| bucketListMessage = ''; |
| |
| @state() |
| buckets: Bucket[] = []; |
| |
| @state() |
| clientOperationId: string | undefined; |
| |
| @state() |
| filterClass: string | undefined; |
| |
| @state() |
| matchesFilter: (arg0: string) => boolean = _ => true; |
| |
| @state() |
| selectedBuilders: {[key: string]: Builder} = {}; |
| |
| @state() |
| includeTrybots = ''; |
| |
| @state() |
| close = () => this.dispatchEvent(new CustomEvent('close')); |
| |
| @state() |
| currentPluginConfigChangedOp: any; |
| |
| @state() |
| bucketsUpdating: any; |
| |
| private filterString = ''; |
| |
| private pluginConfiguration: Config = {}; |
| |
| set filter(val: string) { |
| this.filterString = val; |
| this.filterChanged(val); |
| } |
| |
| get filter(): string { |
| return this.filterString; |
| } |
| |
| set pluginConfig(val: Config) { |
| this.pluginConfiguration = val; |
| this.pluginConfigChanged(val); |
| } |
| |
| get pluginConfig(): Config { |
| return this.pluginConfiguration; |
| } |
| |
| static override styles = css` |
| :host { |
| display: block; |
| min-width: 35em; |
| } |
| header, |
| main, |
| footer { |
| padding: 1em; |
| } |
| a { |
| color: var(--link-color); |
| } |
| header { |
| align-items: center; |
| border-bottom: 1px solid #ddd; |
| display: flex; |
| justify-content: space-between; |
| } |
| main { |
| padding-bottom: 0; |
| } |
| .filter { |
| display: block; |
| font: inherit; |
| margin-bottom: 0.5em; |
| width: 100%; |
| } |
| .filter.error { |
| color: red; |
| } |
| .list { |
| max-height: 50vh; |
| overflow-y: scroll; |
| } |
| .bucket { |
| margin-bottom: 0.5em; |
| } |
| main h3 { |
| color: #666; |
| } |
| main label { |
| cursor: pointer; |
| display: block; |
| } |
| .helpText { |
| border-top: 1px solid #ddd; |
| color: #666; |
| padding: 1em; |
| } |
| .includeTrybots { |
| width: 100%; |
| background-color: var(--shell-command-background-color); |
| padding: var(--spacing-m) var(--spacing-xs); |
| } |
| footer { |
| border-top: 1px solid #ddd; |
| display: flex; |
| justify-content: space-between; |
| } |
| `; |
| |
| override render() { |
| const bucketsHTML = []; |
| for (const bucket of this.buckets.filter( |
| this.computeBucketFilterFn(this.matchesFilter) |
| )) { |
| const builders = bucket.builders.filter(this.matchesFilter).map( |
| builder => html` |
| <label> |
| <input |
| type="checkbox" |
| value="${builder}" |
| data-project="${bucket.project}" |
| data-bucket="${bucket.bucket}" |
| ?checked=${this.computeChecked( |
| bucket.project, |
| bucket.bucket, |
| builder |
| )} |
| ?disabled=${this.disabled} |
| @change=${this.handleCheckboxChange} |
| /> |
| ${builder} |
| </label> |
| ` |
| ); |
| |
| bucketsHTML.push(html` |
| <div class="bucket"> |
| <h3>luci.${bucket.project}.${bucket.bucket}</h3> |
| ${builders} |
| </div> |
| `); |
| } |
| |
| return html` |
| <header> |
| <h3>Choose tryjobs</h3> |
| </header> |
| <main> |
| <input |
| autofocus |
| class="filter ${this.filterClass}" |
| placeholder="Regex filter" |
| @input=${(e: Event) => |
| (this.filter = (e.target as HTMLInputElement)?.value)} |
| /> |
| <div class="list"> |
| <div>${this.bucketListMessage}</div> |
| ${bucketsHTML} |
| </div> |
| </main> |
| <div class="helpText"> |
| Don't see the bots you want? Edit this repo's |
| <a |
| href="https://${this.pluginConfig.gitHost}/${this.change |
| .project}/+/refs/meta/config/buildbucket.config" |
| >buildbucket.config</a |
| > |
| to add them. |
| </div> |
| <div class="helpText"> |
| <div class="includeTrybots"> |
| <gr-copy-clipboard |
| text="Cq-Include-Trybots: ${this.includeTrybots}" |
| ></gr-copy-clipboard> |
| </div> |
| </div> |
| <footer> |
| <gr-button |
| primary |
| ?disabled=${this.computeAddButtonDisabled( |
| this.selectedBuilders, |
| this.disabled |
| )} |
| @click=${this.handleAddTap} |
| >Add</gr-button |
| > |
| <gr-button ?disabled=${this.disabled} @click=${this.handleCancelTap} |
| >Cancel</gr-button |
| > |
| </footer> |
| `; |
| } |
| |
| /** |
| * Fetch all builders in a given project/bucket using a BBv2 client. |
| * |
| * @param client BuildbucketV2Client used to perform listBuilders |
| * requests. |
| * @param project LUCI project to query for builders. |
| * @param bucket Buildbucket bucket to query for builders. Note that |
| * this is a short bucket name (with no dots). |
| * @return Resolves to {project, bucket, builders} where project and |
| * bucket are the same values passed to fetchBuilders, and builders is the |
| * list of builders for the project/bucket. |
| */ |
| private async fetchBuilders( |
| client: BuildbucketV2Client, |
| project: string, |
| bucket: string |
| ): Promise<{project: string; bucket: string; builders: string[]}> { |
| const builders = []; |
| let pageToken = ''; |
| while (true) { |
| const response = await client.listBuilders({ |
| project, |
| bucket, |
| pageToken, |
| pageSize: 1000, |
| }); |
| if (!response.builders || response.builders.length === 0) { |
| break; |
| } |
| builders.push(...response.builders.map(b => b.id?.builder ?? '')); |
| pageToken = response.nextPageToken; |
| if (!pageToken) { |
| break; |
| } |
| } |
| return {project, bucket, builders}; |
| } |
| |
| /** |
| * Computes and returns value of this.buckets given pluginConfig.buckets. |
| * |
| * Loads the list of builders from the buildbucket server for the |
| * configured buckets, deduplicates and sorts builders in each bucket. |
| * |
| * @param cfgBuckets Buckets from the pluginConfig, in the same |
| * format. |
| * @return Resolves to the computed list of builders of type |
| * Array<{project, bucket, builders}>. |
| */ |
| async computeBuckets( |
| cfgBuckets: {name: string; builders?: string[]}[] |
| ): Promise<{project: string; bucket: string; builders: string[]}[]> { |
| // Properties must be set before computing buckets. |
| if (!this.buildbucketHost || !this.change) { |
| return []; |
| } |
| |
| const client = new BuildbucketV2Client( |
| this.buildbucketHost, |
| String(this.change._number) |
| ); |
| |
| const buckets: Map< |
| string, |
| {project: string; bucket: string; builders: string[]} |
| > = new Map(); |
| const promises: Promise<any>[] = []; |
| |
| cfgBuckets.forEach(b => { |
| const luciPrefixLength = 'luci.'.length; |
| const sepIndex = b.name.indexOf('.', luciPrefixLength); |
| const project = b.name.slice(luciPrefixLength, sepIndex); |
| const bucket = b.name.slice(sepIndex + 1); |
| const builders = b.builders || []; |
| buckets.set(`${project}/${bucket}`, {project, bucket, builders}); |
| promises.push(this.fetchBuilders(client, project, bucket)); |
| }); |
| |
| const responses = await Promise.allSettled(promises); |
| responses.forEach(result => { |
| if (result.status === 'rejected') { |
| console.error(result.reason); |
| return; |
| } |
| const {project, bucket, builders} = result.value; |
| const b = buckets.get(`${project}/${bucket}`); |
| if (b) { |
| b.builders = Array.from(new Set(b.builders.concat(builders))).sort(); |
| } |
| }); |
| return Array.from(buckets.values()); |
| } |
| |
| override connectedCallback() { |
| super.connectedCallback(); |
| this.pluginConfigChanged(this.pluginConfig); |
| } |
| |
| /** |
| * Observer for pluginConfig, updates state when config changes. |
| * |
| * @param newValue Newly loaded config value. |
| */ |
| pluginConfigChanged(newValue: Config): void { |
| const op = {}; |
| this.currentPluginConfigChangedOp = op; |
| this.bucketListMessage = 'Loading...'; |
| |
| const cfgBuckets = (newValue || {}).buckets || []; |
| this.bucketsUpdating = this.computeBuckets(cfgBuckets) |
| .then(buckets => { |
| if (this.currentPluginConfigChangedOp === op) { |
| this.bucketListMessage = ''; |
| this.buckets = buckets; |
| } |
| // Force a redraw of the _TRYJOB_PICKER element. |
| if (_TRYJOB_PICKER) { |
| window.dispatchEvent(new Event('resize')); |
| } |
| }) |
| .catch(reason => { |
| if (this.currentPluginConfigChangedOp === op) { |
| console.error('Failed to load the list of builders:', reason); |
| this.bucketListMessage = 'Failed to load the list of builders'; |
| this.buckets = []; |
| } |
| }); |
| } |
| |
| /** |
| * Observer for filter, updates state when user-input filter changes. |
| * |
| * @param newValue Updated filter value. |
| */ |
| filterChanged(newValue: string): void { |
| try { |
| const exp = new RegExp(newValue, 'i'); |
| this.matchesFilter = s => exp.test(s); |
| this.filterClass = ''; |
| } catch (e) { |
| this.matchesFilter = () => false; |
| this.filterClass = 'error'; |
| console.warn('invalid regexp +"' + newValue + '":', e); |
| } |
| } |
| |
| computeBucketFilterFn(builderPredicate: (arg0: string) => boolean) { |
| return (bucket: Bucket) => bucket.builders.some(builderPredicate); |
| } |
| |
| private computeAddButtonDisabled( |
| selectedBuilders: {[key: string]: Builder}, |
| disabled: boolean |
| ) { |
| return disabled || Object.keys(selectedBuilders).length === 0; |
| } |
| |
| /** |
| * Returns a key for an entry in the selectedBuilders object. Dots are |
| * replaced with hashtags as Polymer doesn't handle well properties with dots: |
| * Bug: https://github.com/Polymer/polymer/issues/3127 |
| * |
| * @param project Project name. |
| * @param bucket Bucket name. |
| * @param builder Builder name. |
| * @return A key that doesn't contain any dots. |
| */ |
| private builderKey(project: string, bucket: string, builder: string): string { |
| return `${project}/${bucket}/${builder}`.replace(/\./g, '##'); |
| } |
| |
| computeChecked(project: string, bucket: string, builder: string): boolean { |
| const builderKey = this.builderKey(project, bucket, builder); |
| return !!Object.keys(this.selectedBuilders).includes(builderKey); |
| } |
| |
| private computeIncludeTrybots(): void { |
| const bucketToBuilders: {[key: string]: string[]} = {}; |
| Object.values(this.selectedBuilders).forEach((b: Builder) => { |
| const bucket = `luci.${b.project}.${b.bucket}`; |
| if (!bucketToBuilders[bucket]) { |
| bucketToBuilders[bucket] = []; |
| } |
| bucketToBuilders[bucket].push(b.builder!); |
| }); |
| const includeTrybotStrings = []; |
| for (const [bucket, builders] of Object.entries(bucketToBuilders)) { |
| includeTrybotStrings.push(`${bucket}:${builders.join(',')}`); |
| } |
| this.includeTrybots = includeTrybotStrings.join(';'); |
| } |
| |
| /** |
| * Updates the state to select or deselect a builder. |
| * |
| * @param e A checkbox change event. |
| */ |
| handleCheckboxChange(e: Event): void { |
| const el = e.target as HTMLInputElement; |
| const project = el.getAttribute('data-project'); |
| const bucket = el.getAttribute('data-bucket'); |
| const builder = el.value; |
| const builderKey = this.builderKey(project!, bucket!, builder); |
| if (el.checked) { |
| this.selectedBuilders = Object.assign(this.selectedBuilders, { |
| [builderKey]: {project, bucket, builder}, |
| }); |
| } else { |
| delete this.selectedBuilders[builderKey]; |
| } |
| this.computeIncludeTrybots(); |
| } |
| |
| /** |
| * Schedules builds on the currently selected builders. |
| * |
| * Regenerates client operation ID if needed. |
| */ |
| private async scheduleSelectedBuilders(): Promise<void> { |
| this.clientOperationId = this.clientOperationId || getNewOperationId(); |
| const builders = Object.values(this.selectedBuilders); |
| const gerritChanges: GerritChange[] = [ |
| { |
| host: this.pluginConfig.gerritHost, |
| change: this.change._number!, |
| project: this.change.project!, |
| patchset: this.revision!._number!, |
| }, |
| ]; |
| const requests = makeBuildRequests( |
| builders, |
| gerritChanges, |
| this.clientOperationId |
| ); |
| |
| const client = new BuildbucketV2Client( |
| this.buildbucketHost!, |
| String(this.change._number) |
| ); |
| const {responses} = await client.batch(requests); |
| (responses || []).forEach((r: any) => { |
| if (r.error) { |
| throw new Error(r.error.message); |
| } |
| }); |
| } |
| |
| /** |
| * Handles the tap event which schedules builds. |
| * |
| * @param e A user tap event. |
| */ |
| private handleAddTap(e: Event): void { |
| e.preventDefault(); |
| this.onAddTap().catch(ex => { |
| // If at least one build failed, show the error to the user and suggest |
| // that the user retry. |
| // |
| // The client operation ID is kept to prevent successfully-triggered |
| // builds from being scheduled more than once. However, the client |
| // operation ID is retained for only 1 minute on the server. |
| // TODO(nodir): Replace this; instead deselect successfully-triggered |
| // builders. |
| // |
| // In PolyGerrit, alerts are shown by firing a "show-alert" event, |
| // handled by gr-error-manager, but this isn't part of the public API |
| // and isn't guaranteed to be backward-compatible; window.alert |
| // may not be pretty, but it's guaranteed to be shown. |
| alert( |
| 'Failed to create builds. Please try again. ' + 'See console logs, too.' |
| ); |
| console.error('Failed to schedule builds:', ex); |
| }); |
| } |
| |
| async onAddTap(): Promise<void> { |
| this.disabled = true; |
| try { |
| // Schedule selected builds. |
| await this.scheduleSelectedBuilders(); |
| // All builds have been successfully scheduled. |
| this.reset(); |
| this.close(); |
| } finally { |
| // In any case, re-enable the element. |
| this.disabled = false; |
| } |
| } |
| |
| /** Resets the state of the picker element. */ |
| private reset(): void { |
| this.clientOperationId = undefined; |
| this.filter = ''; |
| this.selectedBuilders = {}; |
| this.uncheckAllBoxes(); |
| this.includeTrybots = ''; |
| } |
| |
| /** Unchecks all builder checkboxes. */ |
| private uncheckAllBoxes(): void { |
| // Programmatically changing the checked attribute does not trigger a |
| // change event, which means that binding the value to selectedBuilders |
| // won't actually cause the checkbox to be unchecked. This must be done |
| // manually. |
| const checkboxes = this.shadowRoot?.querySelectorAll( |
| 'input[type="checkbox"]' |
| ) as unknown as Element[]; |
| for (const c of checkboxes) { |
| (c as HTMLInputElement).checked = false; |
| } |
| } |
| |
| private handleCancelTap(e: Event): void { |
| e.preventDefault(); |
| this.close(); |
| this.reset(); |
| } |
| } |