blob: ec9dac89c398da48e1666b1af987f0b130d55a8b [file]
// 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 {AuthorizationHeader, getAuthorizationHeader} from './auth';
import {
BuildbucketV2Client,
makeBuildRequests,
retry,
BuildStatus,
DEFAULT_UPDATE_INTERVAL_MS,
MAX_UPDATE_INTERVAL_MS,
RETRY_FAILED_TAG,
Builder,
BuildRequests,
SearchBuildsRequest,
SearchBuildsResponse,
} from './buildbucket-client';
import {
createBuilderLink,
createBuildUrl,
createBuildResourceName,
compareBuildIds,
getDateFromTimestamp,
getNewOperationId,
hasHideInGerritTag,
hasHideTestResultsInGerritTag,
isExperimental,
getFailedBuilders,
splitPatchsetGroups,
shouldSkipRetry,
showChooseTryjobs,
showRetryFailed,
} from './buildbucket-utils';
import {
Artifact,
ResultDbV1Client,
TestStatus,
TestVariant,
TestVariantStatus,
} from './resultdb-client';
import {
createLayoutResultLink,
createTestTaskLink,
createVariantName,
createVariantLink,
} from './resultdb-utils';
import {openTryjobPicker} from './cr-tryjob-picker';
import {PluginApi} from '@gerritcodereview/typescript-api/plugin';
import {
Action,
Category,
ChangeData,
CheckResult,
Link,
LinkIcon,
ResponseCode,
Tag,
TagColor
} from '@gerritcodereview/typescript-api/checks';
import {ChangeInfo} from '@gerritcodereview/typescript-api/rest-api';
// TODO(aravindvasudev): Update to @gerritcodereview/typescript-api/checks implementation once Gerrit updates their API.
export enum RunStatus {
RUNNABLE = 'RUNNABLE',
RUNNING = 'RUNNING',
SCHEDULED = 'SCHEDULED',
COMPLETED = 'COMPLETED',
}
// Categories sorted by decreasing severity.
const CATEGORY_ORDER = [
Category.ERROR,
Category.WARNING,
Category.INFO,
Category.SUCCESS,
];
// The field is used to store arbitrary data in a CheckResult.
export const DATA_SYMBOL = Symbol('chromeData');
// An mapping of Buildbucket build statuses to adjectives. Used to create
// CheckResult summaries.
const BUILD_STATUS_TO_ADJECTIVE = {
[BuildStatus.SCHEDULED]: 'scheduled',
[BuildStatus.STARTED]: 'started',
[BuildStatus.SUCCESS]: 'succeeded',
[BuildStatus.FAILURE]: 'failed',
[BuildStatus.INFRA_FAILURE]: 'infra failed',
[BuildStatus.CANCELED]: 'was canceled',
};
const INFRA_FAILURE_ICON = '🟪';
const QUICK_RUN_ICON = '🚀';
// The space to use between the icons above and regular text. This is equivalent
// to two four-per-em spaces and works well between Chrome on Linux and Mac.
const ICON_SPACE = '\u2005\u2005';
export declare interface Build {
id: string;
builder: Builder;
number?: number;
tags?: {
key: string;
value: string;
}[];
status: BuildStatus;
critical?: string;
createTime: string;
startTime: string;
endTime: string;
summaryMarkdown?: string;
infra?: {
resultdb?: {
invocation: string;
hostname: string;
}
};
input?: {experiments: string[]};
output?: {
properties?: {
rts_was_used: boolean;
}
};
}
export declare interface Config {
buckets?: {
name: string;
builders?: string[];
}[];
hideRetryButton?: boolean;
gerritHost?: string;
gitHost?: string;
}
export declare interface Attempt {
id: string;
status: BuildStatus;
isQuickRun: boolean;
build: Build;
}
// TODO(aravindvasudev): Update to @gerritcodereview/typescript-api/checks implementation once Gerrit updates their API.
export declare interface CheckRun {
change?: number;
patchset?: number;
attempt?: number;
externalId?: string;
checkName: string;
checkDescription?: string;
checkLink?: string;
status: RunStatus;
statusDescription?: string;
statusLink?: string;
labelName?: string;
actions?: Action[];
scheduledTimestamp?: Date;
startedTimestamp?: Date;
finishedTimestamp?: Date;
results?: CheckResult[];
}
export class ChecksFetcher {
plugin: PluginApi;
config: Config | null;
buildbucketHost: string;
maxVariantsPerBuild: number;
includeAdditionalResults: boolean;
showPreviousAttempts: boolean;
retryEnabled: boolean;
controllers: Map<string, AbortController>;
constructor(plugin: PluginApi, buildbucketHost: string, maxVariantsPerBuild: number) {
this.plugin = plugin;
this.config = null;
this.buildbucketHost = buildbucketHost;
this.maxVariantsPerBuild = maxVariantsPerBuild;
this.includeAdditionalResults = false;
this.showPreviousAttempts = false;
this.retryEnabled = false;
// Maps a change and patchset to an AbortController for aborting pending
// requests.
this.controllers = new Map();
}
async fetch(changeData: ChangeData) {
// Abort any unfinished fetch requests for this changeData. If this is the
// first fetch, don't abort. Otherwise, abort using old controller.
const controllerKey = this.getChangeDataId(changeData);
if (this.controllers.has(controllerKey)) {
this.controllers.get(controllerKey)?.abort();
}
const controller = new AbortController();
this.controllers.set(controllerKey, controller);
const {changeNumber, patchsetNumber, repo, changeInfo} = changeData;
if (!this.config) {
const pluginName = encodeURIComponent(this.plugin.getPluginName());
const config = await this.plugin.restApi()
.get(`/projects/${encodeURIComponent(repo)}/${pluginName}~config`) as Config;
if (!config) {
console.info('Buildbucket plugin not configured for this project.');
return {responseCode: ResponseCode.OK};
}
this.config = config;
}
const accessToken = await this.getAuthorizationHeader(changeNumber);
const loggedIn = Object.keys(accessToken).length !== 0;
this.retryEnabled = showRetryFailed(this.config, loggedIn, changeInfo);
// Query BuildBucket for build info.
const builds = await this.fetchDisplayedBuilds(
changeNumber, patchsetNumber, repo, changeInfo, controller);
builds.sort((a, b) => -compareBuildIds(a.id, b.id));
// Query ResultDB for TestVariants.
const rdbResults = await Promise.allSettled(builds.map(build =>
this.fetchTestVariants(
build, this.includeAdditionalResults, changeNumber)));
let complete = true;
const runs = [];
const allAttempts = this.createAttemptList(builds);
const latestPatchset = Object.keys(changeInfo.revisions as {}).length;
for (const [i, build] of builds.entries()) {
const run = this.convertBuildToRun(
changeNumber,
build,
patchsetNumber,
latestPatchset,
allAttempts[build.builder.builder!],
repo);
if (!run) {
continue;
}
const result = rdbResults[i];
if (result.status === 'rejected') {
console.warn(
`Failed to query ResultDB for build ${build.id}: ${result.reason}`);
complete = false;
}
const checkResults = await this.convertVariantsToCheckResults(
build, (result as PromiseFulfilledResult<TestVariant[]>).value, changeNumber);
if (!run.results) {
run.results = [];
}
run.results.push(...checkResults);
runs.push(run);
};
// Create a fake run notifying the user that ResultDB results are missing.
if (!complete) {
runs.push({
change: changeNumber,
patchset: patchsetNumber,
checkName: 'Notice',
status: RunStatus.COMPLETED,
results: [{
category: Category.WARNING,
summary: 'Test results may be missing.',
message: 'View console logs for more information.',
}],
});
}
runs.sort((a, b) => {
const aName = this.sortName(a.checkName);
const bName = this.sortName(b.checkName);
if (aName < bName) {
return -1;
}
if (aName > bName) {
return 1;
}
return 0;
});
const actions = [];
if (showChooseTryjobs(this.config, loggedIn, changeInfo)) {
actions.push({
name: 'Choose Tryjobs',
primary: true,
summary: true,
tooltip: 'Select specific builders to run on the latest patchset',
callback: (change: number) =>
this.chooseTryjobsCallback(change, latestPatchset, repo),
});
}
if (this.retryEnabled) {
// We delay checking failed build length since checks 'retry failed'
// queries all builds from all patchsets and could significantly impact
// loading time.
actions.push({
name: 'Retry Failed Builds',
primary: true,
summary: false,
tooltip: 'Retry any failed builds from all patchsets',
callback: (change: number) =>
this.retryFailedBuildsCallback(
change, latestPatchset, repo, controller),
});
}
// Set primary for 'Additional Results' and 'Give Feedback' if none of
// 'Choose Tryjobs' or 'Retry Failed' are added as buttons.
const primary = actions.length === 0;
actions.push(
{
name: `${this.includeAdditionalResults ? 'Hide' : 'Show'} ` +
'Additional Results',
primary,
summary: false,
tooltip: 'Display experimental, flaky, etc. results',
callback: () => this.filterAddlResultsCallback(),
},
{
name: `${this.showPreviousAttempts ? 'Hide' : 'Show'} ` +
'Previous Attempts',
primary: false,
summary: false,
tooltip: 'Display previous builder attempts on this patchset as ' +
'individual results',
callback: () => this.showPreviousAttemptsCallback(),
},
{
name: 'Give Feedback',
primary,
summary: false,
tooltip: 'Tell us what you think about Checks!',
// WARNING: window.open is safe only if the arguments are hard-coded
// and unchanged.
callback: () => window.open('https://forms.gle/1wP6uGSdyKiiWHY38'),
},
);
return {
responseCode: ResponseCode.OK,
actions,
runs,
links: [{
url: 'https://bugs.chromium.org/p/chromium/issues/entry' +
'?components=Infra%3ELUCI%3EBuildService%3EPreSubmit%3EGerrit',
tooltip: 'File a bug',
primary: true,
icon: LinkIcon.REPORT_BUG,
}],
};
}
/**
* Returns Buildbucket builds to be displayed in the UI.
*
* Compared to fetchBuilds, this method handles fetching equivalent patchsets.
*/
async fetchDisplayedBuilds(change: number, patchset: number, project: string, changeInfo: ChangeInfo, controller: AbortController): Promise<Build[]> {
let patchsets: number[] = [];
const groups = splitPatchsetGroups(changeInfo);
for (const group of groups) {
if (!group.has(patchset)) {
continue;
}
// Builds for the patchsets greater than patchNum are not included,
// even if they're in the same equivalent group. Why? This matches
// the historical behavior of the buildbucket plugin.
patchsets = Array.from(group).filter(n => (n <= patchset)).sort();
break;
}
if (patchsets.length === 0) {
return [];
}
// Get builds for the equivalent patchsets.
const builds = await this.fetchBuilds(
change, patchsets, project, this.includeAdditionalResults, controller);
if (builds.length > 0 || changeInfo.status !== 'MERGED') {
return builds;
}
// If the submit strategy is "Rebase Always" (such as for Chromium), then
// the last patchset of the merged CL is autogenerated, and it may be
// labeled as "REWORK" even though it's actually a trivial rebase; in
// this case we try again with the previous group of patchsets.
if (groups.length >= 2 && groups[0].size === 1 &&
patchsets.length === 1 && groups[0].has(patchsets[0])) {
const previousPatchsets = Array.from(groups[1]);
return await this.fetchBuilds(
change, previousPatchsets, project, this.includeAdditionalResults, controller);
}
// If we reach this point, we're in a merged change with no builds to
// display, but the last patchset doesn't appear to be a special
// autogenerated patchset.
return [];
}
/**
* Returns Buildbucket builds for the given change and patchsets.
*/
async fetchBuilds(change: number, patchsets: number[], project: string, inclExp: boolean, controller: AbortController): Promise<Build[]> {
const builds: Build[] = [];
const fields = [
'id',
'builder',
'number',
'tags',
'status',
'critical',
'createTime',
'startTime',
'endTime',
'summaryMarkdown',
'infra.resultdb',
].join(',');
let requests: SearchBuildsRequest[] = patchsets.map(patchset => ({
searchBuilds: {
pageSize: 1000,
predicate: {
includeExperimental: inclExp,
gerritChanges: [{
host: this.config?.gerritHost,
project,
change,
patchset,
}],
},
mask: {
fields,
outputProperties: [{path: ['rts_was_used']}],
},
},
}));
const bbClient = new BuildbucketV2Client(this.buildbucketHost, String(change));
while (requests.length > 0 && !controller?.signal.aborted) {
const batch = async () => await bbClient.batch(
{requests}, controller?.signal);
const {responses} = await retry(
batch, DEFAULT_UPDATE_INTERVAL_MS, MAX_UPDATE_INTERVAL_MS, 3, 2);
const newRequests: SearchBuildsRequest[] = [];
(responses || []).forEach((response: SearchBuildsResponse, i: number) => {
if (response.error) {
console.error('Buildbucket request failed with error code ' +
`${response.error.code}: ${response.error.message}`);
return;
}
builds.push(...(response.searchBuilds?.builds || []));
// If there are more builds to fetch, add the request to be re-fetched.
if (response.searchBuilds?.nextPageToken) {
requests[i].searchBuilds.pageToken = response.searchBuilds.nextPageToken;
newRequests.push(requests[i]);
}
});
requests = newRequests;
}
const visibleBuilds = builds.filter(build => !hasHideInGerritTag(build));
if (inclExp) {
return visibleBuilds;
}
return visibleBuilds.filter(build => !isExperimental(build));
}
/**
* Returns TestVariants for the given build. If inclAdditional is true, also
* fetch results for SUCCESSful builds and exonerated, flaky, or skipped
* results.
*
* If a build is tagged with 'hide-test-results-in-gerrit', ResultDB will not
* be queried.
*/
async fetchTestVariants(build: Build, inclAdditional: boolean, changeId: number): Promise<TestVariant[]> {
// Don't query ResultDB for successful builds.
if (build.status === BuildStatus.SUCCESS && !inclAdditional ||
!build?.infra?.resultdb?.invocation ||
hasHideTestResultsInGerritTag(build)) {
return [];
}
const client = new ResultDbV1Client(
build.infra.resultdb.hostname, String(changeId));
return await client.fetchTestVariants(
build, this.maxVariantsPerBuild, inclAdditional);
}
/**
* Converts a Buildbucket `Build` object to a Reboot Checks API `Run` object.
*
* TODO(gavinmak): Once attempts contains only builds, remove build arg and
* rely only on attempts.
*/
convertBuildToRun(change: number, build: Build, currPatchset: number, latestPatchset: number, attempts: Attempt[], project: string): CheckRun {
const buildResourceName =
createBuildResourceName(this.buildbucketHost, build.id);
const currAttemptNum = attempts.map(a => a.id).indexOf(build.id) + 1;
const run: CheckRun = {
patchset: currPatchset,
attempt: currAttemptNum,
externalId: buildResourceName,
checkName: this.getRunNameFromBuild(build, attempts),
checkLink: createBuilderLink(build),
status: this.getRunStatusFromBuild(build),
statusDescription: this.getRunStatusDescFromBuild(build),
statusLink: createBuildUrl(this.buildbucketHost, build.id),
actions: this.createRunActions(change, build, project, latestPatchset, attempts),
scheduledTimestamp: getDateFromTimestamp(build.createTime),
startedTimestamp: getDateFromTimestamp(build.startTime),
finishedTimestamp: getDateFromTimestamp(build.endTime),
results: [],
};
// Find the most severe category for this set of attempts.
let maxCategory = this.getResultCategoryFromStatus(build.status, build);
if (this.showPreviousAttempts) {
let maxCategoryIdx = CATEGORY_ORDER.indexOf(maxCategory);
for (const attempt of attempts) {
const category =
this.getResultCategoryFromStatus(attempt.status, attempt.build);
const idx = CATEGORY_ORDER.indexOf(category);
// Lower index means higher severity.
if (idx < maxCategoryIdx) {
maxCategory = category;
maxCategoryIdx = idx;
}
}
}
// Successful results are noisy.
if (maxCategory === Category.SUCCESS) {
return run;
}
if (this.showPreviousAttempts) {
// Display most recent attempts first.
for (let attemptNum = currAttemptNum; attemptNum > 0; attemptNum--) {
const currBuild = attempts[attemptNum - 1].build;
// Using the same result category for each previous attempt result means
// attempts are grouped together.
run.results?.push({
externalId: createBuildResourceName(this.buildbucketHost, currBuild.id),
category: maxCategory,
summary: this.getResultSummaryFromBuildAttempt(currBuild, attemptNum),
message: currBuild.summaryMarkdown || '',
tags: this.createResultTags(currBuild),
links: this.createBuildResultLinks(currBuild),
});
}
} else if (![BuildStatus.SCHEDULED, BuildStatus.STARTED]
.includes(build.status)) {
// To minimize clutter for the user, SUCCESSful and STARTED builds should
// not contain the Buildbucket CheckResult. Relevant information should be
// put in the CheckRun object.
run.results?.push({
externalId: buildResourceName,
category: maxCategory,
summary: this.getResultSummaryFromBuild(build),
message: build.summaryMarkdown || '',
tags: this.createResultTags(build),
links: this.createBuildResultLinks(build),
});
}
return run;
}
/**
* Converts ResultDB test variants into CheckResults. For more information
* about TestVariants, see:
* https://source.chromium.org/chromium/infra/infra/+/main:go/src/go.chromium.org/luci/resultdb/internal/proto/ui/ui.proto
*/
async convertVariantsToCheckResults(build: Build, testVariants: TestVariant[], changeId: number): Promise<CheckResult[]> {
if (!testVariants?.length) return [];
const commonTags = this.createResultTags(build);
if (!build.endTime) {
commonTags.push({
name: 'Preliminary',
color: TagColor.YELLOW,
tooltip: 'This result may not match the final result.'
});
}
const checkResults: CheckResult[] = [];
for (const variant of testVariants) {
const variantTags = [...commonTags];
// Tag test results based on variant definition.
const def = variant.variant?.def;
const suite = def?.test_suite || '';
if (suite) {
variantTags.push({
name: `suite: ${suite}`,
color: TagColor.GRAY,
tooltip: `This test belongs to the ${suite} test suite.`,
});
}
const gpu = def?.gpu || '';
if (gpu) {
variantTags.push({
name: `gpu: ${gpu}`,
color: TagColor.GRAY,
tooltip: `This test was run with ${gpu} GPU.`,
});
}
const buildTarget = def?.build_target || '';
if (buildTarget) {
variantTags.push({
name: `build_target: ${buildTarget}`,
color: TagColor.GRAY,
tooltip: `This test was run on ${buildTarget} build target.`,
});
}
// Create a data object to store information used when rendering a
// CheckResult row.
const numTestResults =
this.includeAdditionalResults ? variant.results.length : 1;
const variantName = createVariantName(variant);
// TODO(gavinmak): Simplify data.
const rdbHost = build.infra?.resultdb?.hostname || '';
const data = {
plugin: this.plugin,
variant: {variant: variant.variant},
variantName,
variantTestSuite: suite,
testResults: variant.results.slice(0, numTestResults),
testExonerations: variant.exonerations || [],
rdbHost,
addedArtifactLinks: false,
changeId,
};
const messages: string[] = [];
const allInvArtifacts: Artifact[] = [];
const resArtifactLinks: Link[] = [];
const variantLinks = this.createTestResultLinks(variant, build);
const client = new ResultDbV1Client(rdbHost, String(changeId));
for (let i = 0; i < numTestResults; i++) {
const {result: testResult} = variant.results[i];
// Link to artifacts if previously fetched and available in cache.
const {fromCache, resArtifacts, invArtifacts} =
await client.fetchArtifacts(testResult.name, false);
data.addedArtifactLinks = fromCache;
const runNumber = numTestResults > 1 ? i + 1 : null;
resArtifactLinks.push(
...resArtifacts.map(a => createArtifactLink(a, false, runNumber)));
allInvArtifacts.push(...invArtifacts);
// TODO(gavinmak): Once multiple results are processed, create a better
// summary.
messages.push(`Run #${i + 1}: ${createTestResultSummary(testResult)}.`);
}
// Result artifacts are named 'Run #${runNumber}: ${tooltip}'.
// @ts-ignore
resArtifactLinks.sort((a, b) => a.tooltip.localeCompare(b.tooltip));
// Dedupe invocation-level artifacts.
const invArtifactLinks = [
...new Map(allInvArtifacts.map(a => [a.name, a])).values(),
].map(a => createArtifactLink(a, true, null));
// Gerrit doesn't define any 'data' field in the CheckResult object.
// To store the data we need in checks-result.js, we use DATA_SYMBOL.
// Unlike with a string attribute, values stored with symbols are
// guaranteed not to be overwritten by Gerrit along the way to
// checks-result.js.
checkResults.push({
externalId: variantName,
category: this.getResultCategoryFromVariant(variant, build),
summary: this.getResultSummaryFromVariant(variant, build),
tags: variantTags,
links: [
...variantLinks,
...resArtifactLinks,
...invArtifactLinks,
],
message: messages.join(' '),
[DATA_SYMBOL]: data,
} as any);
}
// Sort and group tests by test suite.
function sortResults(element: string) {
checkResults.sort((a: any, b: any) => {
const aName = a[DATA_SYMBOL][element]?.toUpperCase();
const bName = b[DATA_SYMBOL][element]?.toUpperCase();
if (aName < bName) {
return -1;
}
if (aName > bName) {
return 1;
}
return 0;
});
}
sortResults('variantName');
sortResults('variantTestSuite');
// Create a CheckResult to direct to additional failures.
if (testVariants.length >= this.maxVariantsPerBuild) {
checkResults.push({
externalId:
createBuildResourceName(this.buildbucketHost, build.id) +
'/extra-failures',
category: this.getResultCategoryFromStatus(build.status, build),
summary: this.getResultSummaryFromBuild(build),
message: 'More tests may have failed. View all results on the ' +
'build page.',
tags: commonTags,
links: this.createBuildResultLinks(build),
});
}
return checkResults;
}
/**
* Returns a CheckResult name from a Buildbucket build and a list of runs.
*/
getRunNameFromBuild(build: Build, attempts: Attempt[]) {
const icons = []
// Use the latest attempt's status to create the name, otherwise Checks will
// not collate all builds into one run.
const {status, isQuickRun} = attempts[attempts.length - 1];
if (status === BuildStatus.INFRA_FAILURE) {
icons.push(INFRA_FAILURE_ICON);
}
if (isQuickRun) {
icons.push(QUICK_RUN_ICON);
}
return icons.map(i => i + ICON_SPACE).join('') + build.builder.builder;
}
/**
* Returns a CheckRun status from a Buildbucket build.
*/
getRunStatusFromBuild(build: Build): RunStatus {
switch (build.status) {
case BuildStatus.SCHEDULED:
return RunStatus.SCHEDULED;
case BuildStatus.STARTED:
return RunStatus.RUNNING;
case BuildStatus.SUCCESS:
case BuildStatus.FAILURE:
case BuildStatus.CANCELED:
case BuildStatus.INFRA_FAILURE:
return RunStatus.COMPLETED;
default:
return RunStatus.RUNNABLE;
}
}
/**
* Returns a CheckRun statusDescription from a Buildbucket build.
*/
getRunStatusDescFromBuild(build: Build): string {
let desc = '';
if (build.status === BuildStatus.INFRA_FAILURE) {
desc = ` The ${INFRA_FAILURE_ICON}${ICON_SPACE}icon is used for builds ` +
'that may have ended unsuccessfully from a failure independent of ' +
'this change and patchset.';
}
if (build.output?.properties?.rts_was_used) {
desc += ` The ${QUICK_RUN_ICON}${ICON_SPACE}icon indicates that this ` +
'build used Quick Run. Unlike dry runs, these cannot be reused ' +
'when submitting.';
}
return this.getResultSummaryFromBuild(build) + desc;
}
/**
* Returns a CheckResult summary from a Buildbucket build.
*/
getResultSummaryFromBuild(build: Build): string {
const summary = build?.critical === 'NO' ? 'Non-critical build': 'Build';
return `${summary} ${build.builder.builder} ` +
`${BUILD_STATUS_TO_ADJECTIVE[build.status]}.`;
}
/**
* Returns a CheckResult summary from a Buildbucket build and attempt number.
*/
getResultSummaryFromBuildAttempt(build: Build, attemptNum: number): string {
return `Attempt ${attemptNum} ${BUILD_STATUS_TO_ADJECTIVE[build.status]}.`;
}
/**
* Returns a CheckResult category from a Buildbucket status.
*/
getResultCategoryFromStatus(status: BuildStatus, build: Build): Category {
if (status === BuildStatus.SUCCESS) {
return Category.SUCCESS;
} else if ([BuildStatus.SCHEDULED, BuildStatus.CANCELED,
BuildStatus.STARTED].includes(status)) {
return Category.INFO;
} else if (build?.critical === 'NO') {
return Category.WARNING;
} else {
return Category.ERROR;
}
}
/**
* Returns a CheckResult summary from a TestVariant.
*/
getResultSummaryFromVariant(variant: TestVariant, build: Build): string {
const summary = `Test ${createVariantName(variant)} `;
// Build has terminated.
if (variant.status === TestVariantStatus.FLAKY) {
return summary + 'flaked.';
}
if (variant.status === TestVariantStatus.EXONERATED) {
return summary + 'was exonerated.';
}
if (variant.status === TestVariantStatus.UNEXPECTEDLY_SKIPPED) {
return summary + 'was unexpectedly skipped.';
}
// Variant status is UNEXPECTED here.
if (build.status === BuildStatus.SUCCESS) {
return summary + 'failed but the build succeeded.';
}
return summary + 'failed.';
}
/**
* Returns a CheckResult category for a TestVariant CheckResult.
*/
getResultCategoryFromVariant(variant: TestVariant, build: Build): Category {
const warningStatuses = [
TestVariantStatus.UNEXPECTEDLY_SKIPPED,
TestVariantStatus.FLAKY,
TestVariantStatus.EXONERATED,
];
if (build.status === BuildStatus.SUCCESS || !build.endTime ||
warningStatuses.includes(variant.status)) {
return Category.WARNING;
} else {
return Category.ERROR;
}
}
/**
* Returns an array of common Checks tags for CheckResults.
*/
createResultTags(build: Build): Tag[] {
const tags: Tag[] = [];
if (build.output?.properties?.rts_was_used) {
tags.push({
name: `${QUICK_RUN_ICON}${ICON_SPACE}Quick Run`,
color: TagColor.PINK,
tooltip: 'This result was produced by a build using the Quick Run CQ ' +
'mode.',
});
}
if (isExperimental(build)) {
tags.push({name: 'Experimental', color: TagColor.CYAN});
}
if (build.status === BuildStatus.INFRA_FAILURE) {
tags.push({name: 'Infra Failure', color: TagColor.PURPLE});
}
return tags;
}
/**
* Returns an array of Checks links for test variant CheckResults.
*/
createTestResultLinks(variant: TestVariant, build: Build): Link[] {
const links = [{
url: createVariantLink(variant, build),
tooltip: 'Test Results',
primary: true,
icon: LinkIcon.EXTERNAL,
}];
// Link to Web Test Results if available.
const layoutLink = createLayoutResultLink(variant, build);
if (layoutLink) {
links.push({
url: layoutLink,
tooltip: 'Web Test Results',
primary: false,
icon: LinkIcon.EXTERNAL,
});
}
// Link to the swarming tasks.
const taskLinks = [...new Set(
variant.results.map(r => createTestTaskLink(r.result)))
].filter(l => l !== '');
for (const [i, link] of taskLinks.entries()) {
links.push({
url: link,
tooltip: 'Swarming Task' +
(taskLinks.length > 1 ? ` (${i+1} of ${taskLinks.length})` : ''),
primary: false,
icon: LinkIcon.EXTERNAL,
});
}
return links;
}
/**
* Returns an array of Checks links for Buildbucket CheckResults.
*/
createBuildResultLinks(build: Build): Link[] {
return [{
url: createBuildUrl(this.buildbucketHost, build.id),
tooltip: 'Build',
primary: true,
icon: LinkIcon.EXTERNAL,
}];
}
/**
* Returns a dictionary mapping builders to an ordered list of build ids,
* build statuses, and quick run statuses.
*
* TODO(gavinmak): Refactor to only include build and isQuickRun.
*/
createAttemptList(builds: Build[]): {[key: string]: Attempt[]} {
const dct: {[key: string]: Attempt[]} = {};
builds.forEach(build => {
const {id, status} = build;
const isQuickRun = !!build.output?.properties?.rts_was_used;
const info: Attempt = {id, status, isQuickRun, build};
const name = build.builder.builder!;
if (!(name in dct)) {
dct[name] = [info];
} else {
dct[name].push(info);
}
});
Object.values(dct).forEach(
arr => {(arr as Attempt[]).sort((a, b) => compareBuildIds(a.id, b.id))});
return dct;
}
/**
* Returns a list of actions used in a Reboot Checks API `Run` object.
*/
createRunActions(change: number, build: Build, project: string, patchset: number, attempts: Attempt[]): Action[] {
const actions: Action[] = [];
if (!this.retryEnabled) {
return actions;
}
const cancellableBuilds = attempts.filter(
a => [BuildStatus.SCHEDULED, BuildStatus.STARTED].includes(a.status));
// Successful or unfinished builds should not have a run action.
if (build.status !== BuildStatus.SUCCESS && build.endTime &&
!shouldSkipRetry(build)) {
actions.push({
name: 'Run',
tooltip: 'Start a new builder run on the latest patchset. This ' +
'cancels any existing runs if they exist.',
primary: false,
summary: false,
callback: async (change) => {
const cancelRes = await this.cancelRunsCallback(change, cancellableBuilds);
if (cancelRes.message) {
return cancelRes;
}
return await this.startRunCallback(change, patchset, project, build);
},
});
}
if (cancellableBuilds.length) {
actions.push({
name: 'Cancel',
tooltip: 'Cancel any existing runs.',
primary: false,
summary: false,
callback: () => this.cancelRunsCallback(change, cancellableBuilds),
});
}
return actions;
}
/**
* ActionCallback that starts a new builder run via Buildbucket.
*/
async startRunCallback(change: number, patchset: number, project: string, build: Build): Promise<{message: string}> {
const tags = [];
if ([BuildStatus.FAILURE, BuildStatus.INFRA_FAILURE]
.includes(build.status)) {
tags.push(RETRY_FAILED_TAG);
}
return await this.scheduleBuildsCallback(
change, patchset, project, [build.builder], tags);
}
/**
* A helper callback that uses Buildbucket's batch operation.
*/
async batchCallback(change: number, requests: BuildRequests, operation: string): Promise<{message: string}> {
const bbClient = new BuildbucketV2Client(this.buildbucketHost, String(change));
const batch = async () => await bbClient.batch(requests);
const {responses} = await retry(
batch, DEFAULT_UPDATE_INTERVAL_MS, MAX_UPDATE_INTERVAL_MS, 3, 2);
let numError = 0;
(responses || []).forEach((r: {error: any}) => {
if (r.error) {
numError++;
console.warn('Batch request failed:', r.error);
}
});
this.plugin.checks().announceUpdate();
return {
message: numError
? `${numError} of ${responses.length} ${operation} requests ` +
'failed. See console logs.'
: '',
};
}
/**
* ActionCallback that cancels any existing builds. If a build is in an end
* state, this is a no-op.
*/
async cancelRunsCallback(change: number, builds: Attempt[]): Promise<{message: string}> {
const requests = builds.map(build => ({
cancelBuild: {
id: build.id,
summaryMarkdown: 'Cancel build',
},
}));
return await this.batchCallback(change, {requests}, 'cancel');
}
/**
* Callback that schedules all builders for the given change and patchset.
*/
async scheduleBuildsCallback(change: number, patchset: number, project: string, builders: Builder[], tags: any[]): Promise<{message: string}> {
const requests = makeBuildRequests(
builders,
[{
host: this.config?.gerritHost,
project,
change,
patchset,
}],
getNewOperationId(),
tags || [],
);
return await this.batchCallback(change, requests, 'schedule');
}
/**
* Top-Level ActionCallback that opens the tryjob picker popup.
*/
async chooseTryjobsCallback(change: number, patchset: number, project: string): Promise<{message: string}> {
try {
await openTryjobPicker(this.plugin, this.config, this.buildbucketHost,
project, change, patchset);
} catch (e) {
return {message: `Failed to open tryjobs panel: ${e}`};
}
this.plugin.checks().announceUpdate();
return {message: ''};
}
/**
* Top-Level ActionCallback that retries any builders whose latest build on
* any patchset has failed.
*/
async retryFailedBuildsCallback(change: number, patchset: number, project: string, controller: AbortController): Promise<{message: string}> {
const allPs = Array.from(Array(patchset), (_, x) => x + 1);
const allBuilds = await this.fetchBuilds(
change, allPs, project, false, controller);
const failedBuilders = getFailedBuilders(allBuilds);
if (!failedBuilders?.length) {
return {message: 'No failed builds to retry'};
}
return await this.scheduleBuildsCallback(
change, patchset, project, failedBuilders, [RETRY_FAILED_TAG])
}
/**
* Top-Level ActionCallback that toggles the display and fetching of
* experimental, flaky, etc. builds, CheckRuns, and CheckResults.
*/
async filterAddlResultsCallback(): Promise<{message: string}> {
this.includeAdditionalResults = !this.includeAdditionalResults;
this.plugin.checks().announceUpdate();
return {message: ''};
}
/**
* Top-Level ActionCallback that toggles the display of previous attempts of
* builders as individual results.
*/
async showPreviousAttemptsCallback(): Promise<{message: string}> {
this.showPreviousAttempts = !this.showPreviousAttempts;
this.plugin.checks().announceUpdate();
return {message: ''};
}
/**
* Cleans up a string to be used for sorting results.
*/
sortName(s: string) {
return (s || '').replace(/[^\x00-\x7F]+/gm, '').trim().toUpperCase();
}
/**
* Creates a unique identifier for a given changeObject. Corresponds to a
* single repo, change, and patchset.
*/
getChangeDataId(changeObject: ChangeData): string {
const {repo, changeNumber, patchsetNumber} = changeObject;
return `${repo}:${changeNumber}:${patchsetNumber}`;
}
/**
* Mockable equivalent of window.buildbucket.getAuthorizationHeader.
*
* @returns authorization header to use in requests.
*/
getAuthorizationHeader(changeId: number): Promise<AuthorizationHeader> {
return getAuthorizationHeader(String(changeId));
}
}
/**
* Returns a Checks link object for a given artifact and parent.
*
* @param artifact A ResultDB artifact.
* @param isInvLevel Whether artifact is invocation-level.
* @param number The numbered run this belongs to. If this is null or
* undefined, the link is not labeled with a run number.
*/
export function createArtifactLink(artifact: any, isInvLevel: boolean, number: number | null): Link {
let tooltip = number != null ? `Run ${number}: ` : '';
tooltip += artifact.artifactId + (isInvLevel ? ' of Parent Invocation' : '');
return {
url: artifact.fetchUrl,
tooltip,
primary: false,
icon: LinkIcon.FILE_PRESENT,
};
}
export function createTestResultSummary(result: any): string | null {
const {status, expected} = result;
const prefix = expected ? 'expectedly ' : 'unexpectedly ';
if (status === TestStatus.PASS) {
return prefix + 'passed';
}
if (status === TestStatus.FAIL) {
return prefix + 'failed';
}
if (status === TestStatus.CRASH) {
return prefix + 'crashed';
}
if (status === TestStatus.ABORT) {
return prefix + 'aborted';
}
if (status === TestStatus.SKIP) {
return prefix + 'skipped';
}
return null;
}