| // Copyright 2021 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 { |
| Action, |
| Category, |
| ChangeData, |
| CheckResult, |
| CheckRun, |
| FetchResponse, |
| LinkIcon, |
| ResponseCode, |
| RunStatus, |
| } from '@gerritcodereview/typescript-api/checks'; |
| import { |
| ChangeInfo, |
| ChangeStatus, |
| RevisionInfo, |
| RevisionKind, |
| } from '@gerritcodereview/typescript-api/rest-api'; |
| import {DATA_SYMBOL} from './checks-result.js'; |
| import { |
| Build, |
| BuildbucketV2Client, |
| ScheduleBuildRequest, |
| SearchBuildsRequest, |
| } from './buildbucket-client.js'; |
| |
| // TODO(gavinmak): Fix linter issues. |
| |
| export declare interface ChromiumBinarySizeConfig { |
| tryBuilder: string; |
| tryBucket: string; |
| tryProject: string; |
| gerritHost: string; |
| } |
| |
| export declare interface ChromiumBinarySizeInfo { |
| loaded: boolean; |
| listings: ChromiumBinarySizeListing[]; |
| extras: ChromiumBinarySizeExtra[]; |
| } |
| |
| declare interface ChromiumBinarySizeExtra { |
| url: string; |
| text: string; |
| } |
| |
| export declare interface ChromiumBinarySizeListing { |
| name: string; |
| delta: string; |
| limit: string; |
| log_name: string; |
| allowed: boolean; |
| large_improvement: string; |
| |
| url?: string; |
| css_class?: string; |
| } |
| |
| declare interface PatchPredicate { |
| host: string; |
| change: number; |
| patchset: number; |
| } |
| |
| export class ChecksFetcher { |
| public pluginConfig: ChromiumBinarySizeConfig = { |
| tryBuilder: '', |
| tryBucket: '', |
| tryProject: '', |
| gerritHost: '', |
| }; |
| |
| private enabledCache: Map<string, ChromiumBinarySizeConfig> = new Map(); |
| |
| constructor(public plugin: PluginApi, private buildbucketHost: string) {} |
| |
| async isEnabled(project: string): Promise<boolean> { |
| const path = |
| `/projects/${encodeURIComponent(project)}/` + |
| `${encodeURIComponent(this.plugin.getPluginName())}~config`; |
| if (!this.enabledCache.has(path)) { |
| let config = {} as ChromiumBinarySizeConfig; |
| try { |
| config = await this.plugin.restApi().get(path); |
| } catch (e) { |
| console.log( |
| `The chromium-binary-size plugin is not enabled on ${project}` |
| ); |
| } |
| this.enabledCache.set(path, config); |
| } |
| this.pluginConfig = this.enabledCache.get(path)!; |
| return Object.keys(this.pluginConfig).length > 0; |
| } |
| |
| /** |
| * Compares build IDs by creation time. |
| * |
| * Build IDs are monotonically decreasing. |
| * |
| * A Buildbucket build ID is a positive int64. This is too large for |
| * JavaScript numbers; we cannot just parse them as integers, so we |
| * keep them as strings. |
| * |
| * @return Negative if `a` should go before `b`, |
| * 0 if they're equal, and positive if `a` should go after `b`. |
| */ |
| compareBuildIds(a: string, b: string): number { |
| const d = b.length - a.length; |
| if (d != 0) { |
| return d; |
| } |
| if (a > b) { |
| return -1; |
| } |
| if (a < b) { |
| return 1; |
| } |
| return 0; |
| } |
| |
| /** |
| * Returns a CheckRun and CheckResult which contain binary size information. |
| * |
| * For more information on the Checks API: |
| * https://gerrit.googlesource.com/gerrit/+/refs/heads/master/polygerrit-ui/app/api/checks.ts |
| */ |
| async fetchChecks(changeData: ChangeData): Promise<FetchResponse> { |
| const {changeNumber, patchsetNumber, repo, changeInfo} = changeData; |
| // When switching between changes on different repos, Checks can display |
| // Chromium Binary Size Checks results on repos that don't have the plugin |
| // enabled. This double-checks that a change has the plugin enabled. |
| if (!(await this.isEnabled(repo))) { |
| return {responseCode: ResponseCode.OK}; |
| } |
| |
| const patchsets = this.computeValidPatchNums(changeInfo, patchsetNumber); |
| const builds = |
| (await this.tryGetNewBinarySizeBuilds(changeInfo, patchsets)) || []; |
| builds.sort((a, b) => -this.compareBuildIds(a.id, b.id)); |
| const binarySizeInfo = this.processBinarySizeBuilds(builds) || {}; |
| const { |
| extras = [], |
| listings = [], |
| build = builds[0] || {}, |
| } = binarySizeInfo; |
| |
| const runId = `//${this.buildbucketHost}/chromium-binary-size-plugin`; |
| const resultId = `${runId}/result`; |
| const category = this.getCheckResultCategory(build, listings); |
| const results: CheckResult[] = []; |
| let delta_summary = '(unknown)'; |
| // Create a CheckResult from binarySizeInfo if possible. Otherwise, create |
| // one based on builder status if applicable. |
| if (binarySizeInfo?.loaded && (listings.length || extras.length)) { |
| const links = extras.map((e: ChromiumBinarySizeExtra) => { |
| const isApkBreakdown = e.text === 'APK Breakdown'; |
| return { |
| url: e.url, |
| tooltip: e.text, |
| primary: isApkBreakdown, |
| icon: isApkBreakdown ? LinkIcon.FILE_PRESENT : LinkIcon.EXTERNAL, |
| }; |
| }); |
| |
| // Build summary and message based off listings. |
| // |
| // Passing example: |
| // Summary: Android Binary Size changed by +10 bytes. All checks passed. |
| // Message: Expand to view more. |
| // |
| // Failing example: |
| // Summary: Android Binary Size changed by +18,954 bytes. 2 of 3 checks failed. |
| // Message: Failing checks: foo, bar. Expand to view more. |
| let summary = ''; |
| let message = ''; |
| const unallowed = []; |
| for (const l of listings) { |
| if (l.name === 'Android Binary Size') { |
| summary = `Android Binary Size changed by ${l.delta}. `; |
| delta_summary = this.getHumanReadableDelta(l.delta); |
| } |
| if (!l.allowed) { |
| unallowed.push(l.name); |
| } |
| } |
| |
| if (unallowed.length > 0) { |
| summary += `${unallowed.length} of ${listings.length} checks failed.`; |
| message += `Failing checks: ${unallowed.join(', ')}. `; |
| } else { |
| summary += 'All checks passed.'; |
| } |
| message += 'Expand to view more.'; |
| |
| results.push({ |
| externalId: resultId, |
| category, |
| summary, |
| message, |
| links, |
| [DATA_SYMBOL]: {listings}, |
| } as CheckResult); |
| } else if (['SCHEDULED', 'STARTED', 'CANCELED'].includes(build.status)) { |
| let summary = ''; |
| const builder = this.pluginConfig.tryBuilder; |
| if (build.status === 'CANCELED') { |
| summary = |
| `Run the ${builder} trybot on the latest patchset to see ` + |
| 'your binary size impact.'; |
| } else if (build.status === 'SCHEDULED') { |
| summary = `Scheduling the ${builder} tryjob.`; |
| } else if (build.status === 'STARTED') { |
| summary = `Waiting for ${builder} trybot run to complete.`; |
| } |
| delta_summary = '(pending)'; |
| |
| results.push({ |
| externalId: resultId, |
| category, |
| summary, |
| [DATA_SYMBOL]: {listings}, |
| } as CheckResult); |
| } |
| |
| if (build?.status && ['FAILED', 'INFRA_FAILED'].includes(build.status)) { |
| delta_summary = '(failed)'; |
| } |
| |
| const actions: Action[] = []; |
| if (!build?.status || build.status === 'CANCELED') { |
| actions.push({ |
| name: 'Run', |
| tooltip: |
| `Start a new ${this.pluginConfig.tryBuilder} run on the ` + |
| 'latest patchset.', |
| primary: true, |
| callback: async () => { |
| // TODO(gavinmak): Fix error handling. |
| await this.scheduleBuild( |
| changeNumber, |
| Object.keys(changeInfo.revisions || {}).length, |
| repo |
| ); |
| return {shouldReload: true}; |
| }, |
| }); |
| } |
| |
| return { |
| responseCode: ResponseCode.OK, |
| runs: [ |
| { |
| change: changeNumber, |
| patchset: patchsetNumber, |
| attempt: builds.length || undefined, |
| externalId: runId, |
| checkName: 'APK Size ' + delta_summary, |
| checkDescription: |
| 'This run shows how your change and patchset ' + |
| 'affect the normalized APK size and other checks', |
| checkLink: |
| 'https://chromium.googlesource.com/chromium/src/+/HEAD/' + |
| 'docs/speed/binary_size/android_binary_size_trybot.md', |
| status: this.getCheckRunStatus(build), |
| statusDescription: this.getCheckRunStatusDesc(build), |
| results, |
| actions, |
| } as CheckRun, |
| ], |
| }; |
| } |
| |
| /** |
| * Returns a short string with the binary size delta. |
| */ |
| getHumanReadableDelta(delta_string: string | null): string { |
| if (delta_string == null) { |
| return '(unknown)'; |
| } else { |
| // The delta gets sent to us as a string, we need to parse the number. |
| const delta = parseInt( |
| delta_string.replace('bytes', '').replace(/,/g, '') |
| ); |
| const abs_delta = Math.abs(delta); |
| const maybePlus = delta > 0 ? '+' : ''; |
| const fmtr = Intl.NumberFormat('en-US', { maximumFractionDigits: 1}); |
| if (abs_delta < 1024) { |
| return '(' + maybePlus + delta + ' B)'; |
| } else if (abs_delta < 1024 * 1024) { |
| return '(' + maybePlus + fmtr.format(delta / 1024) + ' KiB)'; |
| } else { |
| return '(' + maybePlus + fmtr.format(delta / (1024 * 1024)) + ' MiB)'; |
| } |
| } |
| } |
| |
| /** |
| * Returns the CheckRun status based on the build. |
| */ |
| getCheckRunStatus(build: Build): RunStatus { |
| switch (build?.status) { |
| case undefined: |
| return RunStatus.RUNNABLE; |
| case 'SCHEDULED': |
| // TODO(gavinmak): Replace with RunStatus.SCHEDULED once |
| // @gerritcodereview/typescript-api is updated. |
| return 'SCHEDULED' as RunStatus; |
| case 'STARTED': |
| return RunStatus.RUNNING; |
| default: |
| return RunStatus.COMPLETED; |
| } |
| } |
| |
| /** |
| * Returns the CheckRun statusDescription based on the build. |
| */ |
| getCheckRunStatusDesc(build: Build): string { |
| const builder = this.pluginConfig.tryBuilder; |
| if (!build?.status) { |
| return `Run the ${builder} trybot`; |
| } |
| if (build.status === 'SCHEDULED') { |
| return `Scheduling the ${builder} tryjob`; |
| } |
| if (build.status === 'STARTED') { |
| return `Waiting for the ${builder} trybot run to complete`; |
| } |
| return ''; |
| } |
| |
| /** |
| * Returns the CheckResult category based on the build and listings. |
| */ |
| getCheckResultCategory( |
| build: Build, |
| listings: ChromiumBinarySizeListing[] |
| ): Category { |
| if (listings?.every(l => l.allowed)) { |
| return Category.INFO; |
| } |
| if (build?.status === 'SUCCESS') { |
| return Category.WARNING; |
| } |
| return Category.ERROR; |
| } |
| |
| /** |
| * Post process binary size info json so as to help the template display |
| * code. |
| */ |
| private processBinarySizeBuilds(builds: Build[]) { |
| const build = this.selectRelevantBuild(builds); |
| if (!build) { |
| return null; |
| } |
| |
| const binarySizeInfo = { |
| build, |
| loaded: true, |
| ...(build.output as any)?.properties?.binary_size_plugin, |
| }; |
| |
| for (const listing of binarySizeInfo.listings) { |
| if (!listing['allowed']) { |
| listing.css_class = 'red'; |
| } else if (listing['large_improvement']) { |
| listing.css_class = 'green'; |
| } |
| } |
| return binarySizeInfo; |
| } |
| |
| /** |
| * Fetch builds from Buildbucket and return information about binary size, |
| * or null in case of failure. |
| */ |
| private async tryGetNewBinarySizeBuilds( |
| change: ChangeInfo, |
| patchsets: number[] |
| ): Promise<Build[] | null> { |
| try { |
| return await this.getBuilds( |
| this.tryjobPatchPredicates(change, patchsets) |
| ); |
| } catch (e) { |
| console.warn('Buildbucket search failed', e); |
| return null; |
| } |
| } |
| |
| /** |
| * Return the Buildbucket search predicates corresponding to the provided |
| * patchset numbers. |
| */ |
| tryjobPatchPredicates( |
| change: ChangeInfo, |
| validPatchNums: number[] |
| ): PatchPredicate[] { |
| return validPatchNums.map((patchNum: number) => { |
| return { |
| host: this.pluginConfig.gerritHost, |
| change: change._number, |
| patchset: patchNum, |
| }; |
| }); |
| } |
| |
| /** |
| * Get builds for the |bucket| that match any of the |patchPredictates|. |
| */ |
| async getBuilds(patchPredicates: PatchPredicate[]): Promise<Build[]> { |
| if (!patchPredicates?.length) { |
| return []; |
| } |
| |
| const bb = new BuildbucketV2Client( |
| this.buildbucketHost, |
| String(patchPredicates[0].change) |
| ); |
| const fields = [ |
| 'id', |
| 'status', |
| 'startTime', |
| 'endTime', |
| 'output.properties.fields.binarySizePlugin', |
| ] |
| .map(f => `builds.*.${f}`) |
| .join(','); |
| |
| const searchResponses = await Promise.all( |
| patchPredicates.map(patchPredicate => |
| bb.searchBuilds({ |
| predicate: { |
| gerritChanges: [patchPredicate], |
| builder: { |
| builder: this.pluginConfig.tryBuilder, |
| bucket: this.pluginConfig.tryBucket, |
| project: this.pluginConfig.tryProject, |
| }, |
| }, |
| fields, |
| } as unknown as SearchBuildsRequest) |
| ) |
| ); |
| const builds = searchResponses |
| .map(response => response.builds) |
| .filter(Boolean); // filter out undefineds |
| |
| // Concatenate builds in all responses. Assume that the builds in the |
| // response for each tag entry in tags are mutually exclusive, because |
| // each predicate represents one patchset of the CL. |
| return Array.prototype.concat.apply([], builds); |
| } |
| |
| /** |
| * Return the latest build that has finished and has the properties needed |
| * to display binary size information, or null. |
| */ |
| selectRelevantBuild(builds: Build[]) { |
| return ( |
| builds.find( |
| build => |
| build.endTime && (build.output as any)?.properties?.binary_size_plugin |
| ) || null |
| ); |
| } |
| |
| /** |
| * List numbers of patchsets (revisions) that are applicable. |
| * |
| * The reason why this is not just the current patchset number is because |
| * there may have been a succession of 'trivial' changes before the current |
| * patchset. |
| * |
| * @param change A Gerrit ChangeInfo object. |
| * @param patchNum Revision number of currently displayed patch. |
| * @return Revision numbers for the displayed builds. |
| */ |
| computeValidPatchNums(change: ChangeInfo, patchNum: number): number[] { |
| if (!change || !patchNum) { |
| return []; |
| } |
| const validKinds = [ |
| RevisionKind.TRIVIAL_REBASE, |
| RevisionKind.NO_CHANGE, |
| RevisionKind.NO_CODE_CHANGE, |
| ]; |
| const revisions: RevisionInfo[] = []; |
| for (const revision in change.revisions) { |
| if (change.revisions.hasOwnProperty(revision)) { |
| revisions.push(change.revisions[revision]); |
| } |
| } |
| revisions.sort( |
| (a, b) => |
| // Reverse sort. |
| (b._number as number) - (a._number as number) |
| ); |
| const patchNums: number[] = []; |
| for (let i = 0; i < revisions.length; i++) { |
| if (i === 0 && change.status === ChangeStatus.MERGED) { |
| // Skip past the most recent patch on submitted CLs because the last |
| // patchset is always the autogenerated one, which may or may not |
| // count as a trivial change depending on the submit strategy. |
| continue; |
| } |
| if ((revisions[i]._number as number) > patchNum) { |
| // Patches after the one we're displaying don't count. |
| continue; |
| } |
| patchNums.push(revisions[i]._number as number); |
| if (validKinds.indexOf(revisions[i].kind) === -1) { |
| // If this revision was a non-trivial change, |
| // don't consider patchsets prior to it. |
| break; |
| } |
| } |
| return patchNums; |
| } |
| |
| private async scheduleBuild( |
| change: number, |
| patchset: number, |
| project: string |
| ) { |
| try { |
| const bb = new BuildbucketV2Client(this.buildbucketHost, String(change)); |
| |
| await bb.scheduleBuild({ |
| builder: { |
| builder: this.pluginConfig.tryBuilder, |
| bucket: this.pluginConfig.tryBucket, |
| project: this.pluginConfig.tryProject, |
| }, |
| gerritChanges: [ |
| { |
| host: this.pluginConfig.gerritHost, |
| change, |
| project, |
| patchset, |
| }, |
| ], |
| } as unknown as ScheduleBuildRequest); |
| } catch (err) { |
| console.warn('Buildbucket scheduleBuild failed', err); |
| } |
| } |
| } |