| // 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]; |
| } |
| } |