blob: 8650a708ac3f09f4ea486ac224bf4f508adcba7d [file] [log] [blame]
// Copyright 2020 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 {BuildbucketV2Client} from './buildbucket-client';
import {
compareBuildIds,
isCQExperimental,
} from './buildbucket-utils';
import {ResultDbV1Client} from './resultdb-client';
import {openTryjobPicker} from './cr-tryjob-picker.js';
/**
* Heads up! Everything in this file is still in flux. The new reboot checks API
* is still in development. So everything in this file can change. And it is
* expected that the amount of comments and tests is limited for the time being.
*/
export const CQ_EXPERIMENTAL_TAG = 'CQ_EXPERIMENTAL';
// An ordered list of TestResult statuses from most to least important. Used to
// sort TestResults.
const RESULT_STATUSES = [
'FAIL',
'CRASH',
'ABORT',
'SKIP',
'PASS',
];
export class ChecksFetcher {
constructor(plugin, buildbucketHost, maxVariantsPerBuild) {
this.plugin = plugin;
this.project = this.extractProjectFromUrl();
this.gerritHost = this.extractHostFromUrl();
this.buildbucketHost = buildbucketHost;
this.maxVariantsPerBuild = maxVariantsPerBuild;
this.includeExperiments = false;
}
async fetch(changeNumber, patchsetNumber) {
// Query BuildBucket for build info.
const gerritChange =
{
host: this.gerritHost,
// TODO(gavinmak): Change Checks API to provide the change object and use
// change.project instead.
project: this.project,
change: changeNumber,
patchset: patchsetNumber,
};
const buildFields = [
'id',
'builder',
'tags',
'status',
'critical',
'createTime',
'startTime',
'endTime',
'summaryMarkdown',
'infra.resultdb',
'infra.swarming',
];
const bbClient = new BuildbucketV2Client(this.buildbucketHost);
const result = await bbClient.batch({
requests: [{
searchBuilds: {
pageSize: 500,
predicate: {gerritChanges: [gerritChange]},
fields: buildFields.map(f => `builds.*.${f}`).join(','),
},
}],
});
let allBuilds = result.responses.map(r => r.searchBuilds.builds || []).flat();
if (!this.includeExperiments) {
allBuilds = allBuilds.filter(build => !isCQExperimental(build));
}
allBuilds.sort((a, b) => -compareBuildIds(a.id, b.id));
// Query ResultDB for unexpected TestVariants.
const rdbResults = await Promise.allSettled(
allBuilds.map(build => this.fetchUnexpectedTestVariants(build)));
const runs = [];
const attempts = this.createAttemptList(allBuilds);
allBuilds.forEach((build, i) => {
const result = rdbResults[i];
if (result.status === 'rejected') {
console.error(result.reason);
}
const run = this.convertBuildToRun(
build,
patchsetNumber,
attempts,
result.value || [],
);
if (run) {
runs.push(run);
}
});
return {
responseCode: 'OK',
actions: [
{
name: 'Choose Tryjobs',
callback: this.chooseTryjobsCallback.bind(this),
},
{
name: `${this.includeExperiments ? 'Hide' : 'Show'} Experimental Results`,
callback: this.filterExperimentsCallback.bind(this),
},
],
runs,
};
}
/**
* Returns all TestVariants for the given build whose status is not EXPECTED.
*/
async fetchUnexpectedTestVariants(build) {
const rdbInfo = build.infra.resultdb;
const rdbClient = new ResultDbV1Client(rdbInfo.hostname);
const variants = [];
const pageSize = 1000;
let pageToken = '';
// queryTestVariants returns all UNEXPECTED, FLAKY, and EXONERATED
// TestVariants before returning EXPECTED TestVariants.
while (true) {
const response = await rdbClient.queryTestVariants({
invocations: [rdbInfo.invocation],
pageSize: pageSize,
pageToken: pageToken,
});
// Exit if there are no unexpected TestVariants.
if (!response.testVariants || response.testVariants.length === 0) {
break;
}
if (response.testVariants[0].status === 'EXPECTED') {
break;
}
variants.push(...response.testVariants);
// Exit if there are no more unexpected TestVariants to fetch.
if (response.testVariants.length < pageSize || !response.nextPageToken) {
break;
}
pageToken = response.nextPageToken;
}
return variants;
}
/**
* Converts a Buildbucket `Build` object into a Reboot Checks API `Run` object.
*/
convertBuildToRun(build, patchset, attempts, unexpectedTestVariants) {
if (!build || !build.builder || !build.builder.builder) return undefined;
const buildLink = this.createBuildLink(this.buildbucketHost, build.id);
const buildResourceName = this.buildbucketResourceName(this.buildbucketHost, build.id);
const actions = this.createActions(build);
const run = {
patchset: patchset,
attempt: attempts[build.builder.builder].indexOf(build.id) + 1,
externalId: buildResourceName,
checkName: build.builder.builder,
checkLink: this.createBuilderLink(build),
status: this.getRunStatusFromBuild(build),
statusDescription: build.status,
statusLink: buildLink,
actions: actions,
scheduledTimestamp: build.createTime,
startedTimestamp: build.startTime,
finishedTimestamp: build.endTime,
results: [],
};
// There are no CheckResults for SCHEDULED builds.
if (build.status === 'SCHEDULED') {
return run;
}
// Create a CheckResult for the Buildbucket build.
const buildCategory = this.getResultCategoryFromBuild(build);
const buildIsExperimental = isCQExperimental(build);
const commonTags = [];
buildIsExperimental && commonTags.push(CQ_EXPERIMENTAL_TAG);
// To minimize clutter for the user, SUCCESSful builds should not contain the
// Buildbucket CheckResult. Relevant information should be put in the CheckRun object.
if (build.status !== 'SUCCESS') {
run.results.push({
externalId: buildResourceName,
category: buildCategory,
summary: `Build ${build.status}`,
message: build.summaryMarkdown || '',
tags: commonTags,
links: [
{
url: buildLink,
primary: true,
},
{
url: this.createBuildTaskLink(build) || '',
primary: true,
},
],
actions: actions,
});
}
if (unexpectedTestVariants) {
// Create a CheckResult for each TestVariant. Limit the number created to
// this.maxVariantsPerBuild.
const numDisplayed = Math.min(unexpectedTestVariants.length, this.maxVariantsPerBuild);
unexpectedTestVariants.slice(0, numDisplayed).forEach((variant) => {
const result = {
category: this.getResultCategoryFromVariant(variant, build),
summary: variant.testId, // TODO(gavinmak): Replace with testMetadata.
tags: commonTags,
// TODO(crbug.com/1163705): Add links to the specific test.
links: [
{
url: buildLink,
primary: false,
},
],
actions: actions,
};
if (variant.status === 'FLAKY') {
// Remove duplicate results with identical summaryHtmls.
variant.results = [
...new Map(variant.results.map(r => [r.result.summaryHtml, r])).values(),
];
// Sort results by status from most to least important.
variant.results.sort((a, b) => {
return RESULT_STATUSES.indexOf(a.result.status) - RESULT_STATUSES.indexOf(b.result.status);
});
// TODO(gavinmak): Wrap summaryHtml with <p> when Gerrit supports HTML messages.
result.message = variant.results.map(r => r.result.summaryHtml).join('\n');
} else {
result.message = variant.results[0].result.summaryHtml;
}
result.links.push({
url: this.createTestTaskLink(variant.results[0].result) || '',
primary: false,
});
run.results.push(result);
});
// Create a CheckResult to direct to additional failures.
if (unexpectedTestVariants.length > this.maxVariantsPerBuild) {
const numExtraResults = unexpectedTestVariants.length - this.maxVariantsPerBuild;
run.results.push({
category: buildIsExperimental ? 'INFO' : 'ERROR',
summary: build.builder.builder,
message: `${numExtraResults} more test${numExtraResults > 1 ? 's' : ''} failed`,
tags: commonTags,
links: [
{
url: buildLink,
primary: false,
},
],
actions: actions,
});
}
}
return run;
}
/**
* Returns a CheckRun status from a Buildbucket build.
*/
getRunStatusFromBuild(build) {
switch (build.status) {
case 'SCHEDULED':
case 'STARTED':
return 'RUNNING';
case 'SUCCESS':
case 'FAILURE':
case 'CANCELED':
case 'INFRA_FAILURE':
return 'COMPLETED';
default:
return 'RUNNABLE';
}
}
/**
* Returns a CheckResult category from a Buildbucket build.
*/
getResultCategoryFromBuild(build) {
if (['SCHEDULED', 'CANCELED', 'STARTED', 'SUCCESS'].includes(build.status) ||
isCQExperimental(build)) {
return 'INFO';
} else if (build.critical === 'NO') {
return 'WARNING';
} else {
return 'ERROR';
}
}
/**
* Returns a CheckResult category from a TestVariant and Buildbucket build.
*/
getResultCategoryFromVariant(variant, build) {
const status = variant.status;
if (status === 'EXPECTED' || isCQExperimental(build)) {
return 'INFO';
} else if (['EXONERATED', 'FLAKY'].includes(status)) {
return 'WARNING';
} else {
return 'ERROR';
}
}
/**
* Returns a link to the Swarming build task.
*/
createBuildTaskLink(build) {
const swarming = build.infra.swarming;
return swarming && `https://${swarming.hostname}/task?id=${swarming.taskId}`;
}
/**
* Returns a link to the Swarming test task.
*/
createTestTaskLink(testResult) {
const match = /^invocations\/task-([a-zA-Z\-\.]+\.com)-(\w+)/.exec(testResult.name);
return match && `https://${match[1]}/task?id=${match[2]}`;
}
/**
* Returns a link to the builder page for the given build.
*/
createBuilderLink(build) {
const builder = build.builder;
return `https://ci.chromium.org/p/${builder.project}/builders/` +
`${builder.bucket}/${builder.builder}`;
}
/**
* Returns a link to the build page for a given Buildbucket host and buildId.
*/
createBuildLink(host, buildId) {
return 'https:' + this.buildbucketResourceName(host, buildId);
}
/**
* Returns the full resource name for a given Buildbucket host and buildId.
*/
buildbucketResourceName(host, buildId) {
return `//${host}/build/${buildId}`;
}
/**
* Returns true if the given object is empty.
*/
isEmpty(obj) {
return !obj || Object.keys(obj).length === 0;
}
/**
* Returns a dictionary mapping builders to an ordered list of build ids.
*/
createAttemptList(builds) {
const dct = {};
builds.forEach(build => {
const name = build.builder.builder;
if (!(name in dct)) {
dct[name] = [build.id];
} else {
dct[name].push(build.id);
}
});
Object.values(dct).forEach((arr) => arr.sort(compareBuildIds));
return dct;
}
/**
* Returns a list of actions used in a Reboot Checks API `Run` object.
*/
createActions(build) {
const actions = [
{
name: 'Run',
tooltip: 'Start a new builder run and cancel an existing run if it exists',
primary: false,
callback: this.startRunCallback.bind(this),
},
];
if (['SCHEDULED', 'STARTED'].includes(build.status)) {
actions.push({
name: 'Cancel',
tooltip: 'Cancel existing runs',
primary: false,
callback: this.cancelRunCallback.bind(this),
});
}
return actions;
}
/**
* ActionCallback that cancels an existing run and starts a new builder run via Buildbucket.
*/
async startRunCallback(change, patchset, attempt, externalId, checkName, actionName) {
await this.cancelRunCallback(change, patchset, attempt, externalId, checkName, actionName);
const gerritChange =
{
host: this.gerritHost,
// TODO(gavinmak): Change Checks API to provide the change object and use
// change.project instead.
project: this.project,
change: change,
patchset: patchset,
};
const client = new BuildbucketV2Client(this.buildbucketHost);
await client.scheduleBuild({
// TODO(gavinmak): Add request ID.
templateBuildId: this.extractBuildbucketId(externalId),
gerritChanges: [gerritChange],
});
return {};
}
/**
* ActionCallback that cancels an existing build. If the build is in an end state, this is a
* no-op.
*/
async cancelRunCallback(change, patchset, attempt, externalId, checkName, actionName) {
const client = new BuildbucketV2Client(this.buildbucketHost);
await client.cancelBuild({
id: this.extractBuildbucketId(externalId),
summaryMarkdown: 'Cancel build',
});
return {};
}
/**
* Top-Level ActionCallback that opens the tryjob picker popup.
*/
async chooseTryjobsCallback(change, patchset, attempt, externalId, checkName, actionName) {
// TODO(gavinmak): When Checks API uses the change object, use change.project instead.
const project = encodeURIComponent(this.project);
const pluginName = encodeURIComponent(this.plugin.getPluginName());
const pluginConfig = await this.plugin.restApi().get(
`/projects/${project}/${pluginName}~config`);
await openTryjobPicker(
this.plugin, pluginConfig, this.buildbucketHost, project, change, patchset);
this.plugin.checks().announceUpdate();
return {};
}
/**
* Top-Level ActionCallback that toggles the display and fetching of experimental builds,
* CheckRuns, and CheckResults.
*/
async filterExperimentsCallback(change, patchset, attempt, externalId, checkName, actionName) {
this.includeExperiments = !this.includeExperiments;
this.plugin.checks().announceUpdate();
return {};
}
/**
* Returns the ID to use for buildbucket RPCs. Assumes externalId is a full resource name
* which takes the format "//${host}/build/${buildId}"
*/
extractBuildbucketId(externalId) {
const match = /build\/(\d+)$/.exec(externalId);
return match && match[1];
}
/**
* Returns the host from the current URL's path.
*/
extractHostFromUrl() {
return window.location.host;
}
/**
* Returns the project from the current URL's path.
*
* TODO(gavinmak): Remove this function when Gerrit checks uses a change object.
*/
extractProjectFromUrl() {
const match = /^\/c\/([^\+]+)\/\+/.exec(window.location.pathname);
return match && match[1];
}
}