blob: e3f1b1723f39d3375f83e25eb7d09d547b839812 [file] [log] [blame]
// 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();
}
}