| /** |
| * @license |
| * 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 {DATA_SYMBOL} from './checks-result'; |
| import './binary-size-table'; |
| import {humanByteSizeDelta} from './binary-size-table'; |
| import {PluginApi} from '@gerritcodereview/typescript-api/plugin'; |
| import { |
| ChangeInfo, |
| ChangeStatus, |
| NumericChangeId, |
| PatchSetNum, |
| RevisionKind, |
| } from '@gerritcodereview/typescript-api/rest-api'; |
| import { |
| Category, |
| ChangeData, |
| CheckRun, |
| ResponseCode, |
| RunStatus, |
| } from '@gerritcodereview/typescript-api/checks'; |
| |
| export declare interface BinarySizeRow { |
| id: string; |
| builder: string; |
| binary: string; |
| |
| ciSize: number; |
| ciUrl?: string; |
| ciBudget?: number; |
| |
| trySize: number; |
| tryUrl?: string; |
| tryBudget?: number; |
| |
| budgetExceeded: boolean; |
| |
| tryCreepBudget?: number; |
| creepExceeded: boolean; |
| |
| ownerUrl: string; |
| } |
| |
| export declare interface BinarySizeInfo { |
| showBudgets: boolean; |
| showCreepBudgets: boolean; |
| rows: BinarySizeRow[]; |
| } |
| |
| export declare interface BinarySizeConfig { |
| gitHost: string; |
| gerritHost: string; |
| builders: BuilderPair[]; |
| creepExemptionLabel: string; |
| } |
| |
| export declare interface BuilderPair { |
| tryBuilder: string; |
| tryBucket: string; |
| ciBuilder: string; |
| ciBucket: string; |
| ciBuilderRepo?: string; |
| ciBuilderGitHost?: string; |
| } |
| |
| declare interface BuildbucketRequest { |
| searchBuilds: { |
| predicate: { |
| builder: string; |
| tags?: BuildbucketTag[]; |
| gerritChanges?: object[]; |
| includeExperimental: boolean; |
| }; |
| fields: string; |
| pageSize: number; |
| }; |
| } |
| |
| declare interface BuildbucketResponse { |
| error?: string; |
| searchBuilds?: { |
| builds: BuildbucketBuild[]; |
| }; |
| } |
| |
| declare interface BuildbucketTag { |
| key: string; |
| value: string; |
| } |
| |
| export declare interface BuildbucketBuilder { |
| project?: string; |
| bucket: string; |
| builder: string; |
| } |
| |
| export declare interface BuildbucketBuild { |
| id: string; |
| builder: BuildbucketBuilder; |
| status: string; |
| output: { |
| // eslint-disable-next-line @typescript-eslint/no-explicit-any |
| properties: any; |
| }; |
| tags: BuildbucketTag[]; |
| url?: string; |
| } |
| |
| declare interface CompareFunction { |
| (a: BinarySizeRow, b: BinarySizeRow): number; |
| } |
| |
| declare global { |
| interface Window { |
| // eslint-disable-next-line @typescript-eslint/no-explicit-any |
| buildbucket: any; |
| } |
| } |
| |
| export class ChecksFetcher { |
| pluginConfig: BinarySizeConfig | null = null; |
| |
| private enabledCache: Map<string, BinarySizeConfig> = new Map(); |
| |
| constructor(public plugin: PluginApi, private buildbucketHost: string) {} |
| |
| async isEnabled(project: string): Promise<boolean> { |
| if (!window.buildbucket) { |
| console.error( |
| 'The "binary-size" plugin requires the "buildbucket" plugin for ' + |
| 'searching builds. Please activate both.' |
| ); |
| return false; |
| } |
| |
| const path = |
| `/projects/${encodeURIComponent(project)}/` + |
| `${encodeURIComponent(this.plugin.getPluginName())}~config`; |
| if (!this.enabledCache.has(path)) { |
| let config = {} as BinarySizeConfig; |
| try { |
| config = await this.plugin.restApi().get(path); |
| } catch (e) { |
| console.warn(`The 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; |
| } |
| |
| /** |
| * 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) { |
| const {changeNumber, patchsetNumber, repo, changeInfo} = changeData; |
| if (!(await this.isEnabled(repo))) { |
| return {responseCode: ResponseCode.OK}; |
| } |
| |
| const patchsets = this.computeValidPatchNums( |
| changeInfo, |
| patchsetNumber as PatchSetNum |
| ); |
| const [tryBuilds, ciBuilds] = await this.tryGetNewBinarySizeBuilds( |
| changeInfo, |
| patchsets |
| ); |
| const binarySizeInfo = this.processBinarySizeInfo( |
| this.pluginConfig!.builders, |
| tryBuilds, |
| ciBuilds |
| ); |
| |
| // If there is no binary size info yet, return no runs or results. |
| const numRows = binarySizeInfo?.rows?.length || 0; |
| if (numRows === 0) { |
| return {responseCode: ResponseCode.OK}; |
| } |
| |
| // Build summary and message based off budgets and deltas. |
| // |
| // Passing example: |
| // Summary: All 23 files within budgets. 1 of 23 sizes changed. |
| // Message: Sizes changed: foo (+34 B). Expand to view more. |
| // |
| // Failing example: |
| // Summary: 2 of 23 budgets exceeded. 3 of 23 sizes changed. |
| // Message: Exceeded budgets: foo (+34 B), bar (+8.00 KiB). Expand to view more. |
| const exceededRows = []; |
| const changedRows = []; |
| for (const row of binarySizeInfo.rows) { |
| if (row.budgetExceeded || row.creepExceeded) { |
| exceededRows.push(row); |
| } |
| if (row.trySize !== row.ciSize) { |
| changedRows.push(row); |
| } |
| } |
| |
| let summary = ''; |
| let message = ''; |
| let addedBudgetMessage = false; |
| |
| const createRowsDesc = (rows: BinarySizeRow[]) => |
| rows |
| .map(r => `${r.binary} (${humanByteSizeDelta(r.ciSize, r.trySize)})`) |
| .join(', '); |
| |
| if (exceededRows.length > 0) { |
| summary += `${exceededRows.length} of ${numRows} budgets exceeded. `; |
| message += `Exceeded budgets: ${createRowsDesc(exceededRows)}. `; |
| addedBudgetMessage = true; |
| } else { |
| summary += `All ${numRows} files within budgets. `; |
| } |
| if (changedRows.length > 0) { |
| summary += `${changedRows.length} of ${numRows} sizes changed.`; |
| |
| // To prevent cluttering the row, only add if nothing was added before. |
| if (!addedBudgetMessage) { |
| message += `Sizes changed: ${createRowsDesc(changedRows)}. `; |
| } |
| } else { |
| summary += 'No sizes changed.'; |
| } |
| message += 'Expand to view more.'; |
| |
| const runId = `//${this.buildbucketHost}/binary-size-plugin`; |
| return { |
| responseCode: ResponseCode.OK, |
| runs: [ |
| { |
| change: changeNumber, |
| patchset: patchsetNumber, |
| attempt: binarySizeInfo.checkRunAttempt, |
| externalId: runId, |
| checkName: 'Binary Size', |
| checkDescription: |
| 'This run shows how your change and patchset affect binary sizes', |
| checkLink: |
| 'https://chromium.googlesource.com/' + |
| 'infra/gerrit-plugins/binary-size/+/HEAD/README.md', |
| status: RunStatus.COMPLETED, |
| results: [ |
| { |
| externalId: `${runId}/result`, |
| category: binarySizeInfo.checkResultCategory, |
| summary, |
| message, |
| }, |
| ], |
| [DATA_SYMBOL]: {binarySizeInfo}, |
| } as CheckRun, |
| ], |
| }; |
| } |
| |
| /** |
| * Returns the CheckResult category corresponding to builds. |
| */ |
| getCheckResultCategory(builds: BuildbucketBuild[], rows: BinarySizeRow[]) { |
| const underBudget = !rows.some(row => row.budgetExceeded); |
| const underCreepBudget = !rows.some(row => row.creepExceeded); |
| if (underBudget && underCreepBudget) { |
| return Category.INFO; |
| } |
| if (builds.every(build => build.status === 'SUCCESS')) { |
| return Category.WARNING; |
| } |
| return Category.ERROR; |
| } |
| |
| /** |
| * Returns the CheckRun attempt number corresponding to builds. |
| */ |
| getCheckRunAttempt(builds: BuildbucketBuild[]) { |
| const buildCount: {[key: string]: number} = {}; |
| builds.forEach(build => { |
| const name = build.builder.builder; |
| buildCount[name] = name in buildCount ? buildCount[name] + 1 : 1; |
| }); |
| return Math.max(...Object.values(buildCount)); |
| } |
| |
| /** |
| * Return builds from Buildbucket that contain information about binary size. |
| */ |
| async tryGetNewBinarySizeBuilds( |
| change: ChangeInfo, |
| patchsets: PatchSetNum[] |
| ): Promise<BuildbucketBuild[][]> { |
| // Get unique builders so we don't query for the same data more than once. |
| const [tryBuilders, ciBuilders] = this.getUniqueTryAndCiBuilders(); |
| |
| let tryBuilds = await this.getBuilds( |
| change._number, |
| tryBuilders, |
| this.gerritChanges(change, patchsets), |
| [] |
| ); |
| tryBuilds = this.selectRelevantBuilds(tryBuilds); |
| const tryBuildToBuilderPair: Map<BuildbucketBuild, BuilderPair> = new Map(); |
| tryBuilds.forEach(build => { |
| this.pluginConfig!.builders.forEach(pair => { |
| if (build.builder.builder === pair.tryBuilder) { |
| tryBuildToBuilderPair.set(build, pair); |
| } |
| }); |
| }); |
| let ciBuilds = await this.getBuilds( |
| change._number, |
| ciBuilders, |
| [], |
| this.revisionTags(tryBuildToBuilderPair, change) |
| ); |
| ciBuilds = this.selectRelevantBuilds(ciBuilds); |
| |
| return [tryBuilds, ciBuilds]; |
| } |
| |
| /** |
| * Return unique try and ci builders from plugin configuration. |
| */ |
| getUniqueTryAndCiBuilders(): BuildbucketBuilder[][] { |
| const tryBuilderMap: Map<string, BuildbucketBuilder> = new Map(); |
| const ciBuilderMap: Map<string, BuildbucketBuilder> = new Map(); |
| this.pluginConfig!.builders.forEach(pair => { |
| // Use "bucket/builder" key to deduplicate. |
| tryBuilderMap.set(`${pair.tryBucket}/${pair.tryBuilder}`, { |
| bucket: pair.tryBucket, |
| builder: pair.tryBuilder, |
| }); |
| ciBuilderMap.set(`${pair.ciBucket}/${pair.ciBuilder}`, { |
| bucket: pair.ciBucket, |
| builder: pair.ciBuilder, |
| }); |
| }); |
| return [ |
| Array.from(tryBuilderMap.values()), |
| Array.from(ciBuilderMap.values()), |
| ]; |
| } |
| |
| /** |
| * Return the Buildbucket tags corresponding to the provided patchset numbers. |
| */ |
| gerritChanges(change: ChangeInfo, validPatchNums: PatchSetNum[]) { |
| return validPatchNums.map(patchNum => { |
| return { |
| host: this.pluginConfig!.gerritHost, |
| project: change.project, |
| change: change._number, |
| patchset: patchNum, |
| }; |
| }); |
| } |
| |
| /** |
| * Return the Buildbucket tags corresponding to the base revision of each |
| * of the provided builds. |
| */ |
| revisionTags( |
| tryBuildToBuilderPair: Map<BuildbucketBuild, BuilderPair>, |
| change: ChangeInfo |
| ): BuildbucketTag[] { |
| const tags: Map<string, BuildbucketTag> = new Map(); |
| tryBuildToBuilderPair.forEach((builderPair, build) => { |
| const host = builderPair.ciBuilderGitHost || this.pluginConfig!.gitHost; |
| const project = builderPair.ciBuilderRepo || change.project; |
| const revision = build.output.properties.got_revision; |
| const value = `commit/gitiles/${host}/${project}/+/${revision}`; |
| tags.set(value, { |
| key: 'buildset', |
| value, |
| }); |
| }); |
| return Array.from(tags.values()); |
| } |
| |
| /** |
| * Get builds that match any of the builders and any of the tags. |
| */ |
| async getBuilds( |
| changeNumber: NumericChangeId, |
| builders: BuildbucketBuilder[], |
| gerritChanges: object[], |
| tags: BuildbucketTag[] |
| ): Promise<BuildbucketBuild[]> { |
| if ( |
| builders.length === 0 || |
| (tags.length === 0 && gerritChanges.length === 0) |
| ) { |
| return []; |
| } |
| const bb = new window.buildbucket.BuildbucketV2Client( |
| this.buildbucketHost, |
| changeNumber |
| ); |
| const fields = [ |
| 'builder', |
| 'id', |
| 'status', |
| 'output.properties.fields.binarySizes', |
| 'output.properties.fields.gotRevision', |
| ] |
| .map(f => `builds.*.${f}`) |
| .join(','); |
| |
| try { |
| const requests: BuildbucketRequest[] = []; |
| builders.forEach((builder: BuildbucketBuilder) => { |
| const builderID = window.buildbucket.convertLegacyBucket( |
| builder.bucket |
| ); |
| builderID.builder = builder.builder; |
| tags.forEach(tag => { |
| requests.push({ |
| searchBuilds: { |
| predicate: { |
| builder: builderID, |
| tags: [tag], |
| includeExperimental: true, |
| }, |
| fields, |
| pageSize: 500, |
| }, |
| }); |
| }); |
| gerritChanges.forEach(gerritChange => { |
| requests.push({ |
| searchBuilds: { |
| predicate: { |
| builder: builderID, |
| gerritChanges: [gerritChange], |
| includeExperimental: true, |
| }, |
| fields, |
| pageSize: 500, |
| }, |
| }); |
| }); |
| }); |
| const res = await bb.batch({requests}); |
| const builds = (res.responses || []).map( |
| (response: BuildbucketResponse) => { |
| if (Object.prototype.hasOwnProperty.call(response, 'error')) { |
| console.error( |
| 'Buildbucket response contains error', |
| response.error |
| ); |
| return []; |
| } |
| return response.searchBuilds?.builds || []; |
| } |
| ); |
| // Concatenate builds in all responses. Assume that the builds in the |
| // response for each tag entry in tags are mutually exclusive, because |
| // each tag represents one patchset of the CL. |
| return Array.prototype.concat.apply([], builds); |
| } catch (err) { |
| console.error('Buildbucket search failed', err); |
| return []; |
| } |
| } |
| |
| /** |
| * Filter out builds that don't have properties needed to display binary size |
| * information. |
| */ |
| selectRelevantBuilds(builds: BuildbucketBuild[]) { |
| return builds.filter(build => { |
| if (!build.output || !build.output.properties) { |
| return false; |
| } |
| const properties = build.output.properties; |
| |
| return properties.got_revision && properties.binary_sizes; |
| }); |
| } |
| |
| /** |
| * Collate the information about builds into a list of info objects. |
| * tryBuilds are tryjobs for the current CL. ciBuilds are builds that can be |
| * used as a baseline for the calculation of binary size difference, typically |
| * CI builds with base revisions that match tryBuilds. |
| */ |
| processBinarySizeInfo( |
| builderPairs: BuilderPair[], |
| tryBuilds: BuildbucketBuild[], |
| ciBuilds: BuildbucketBuild[] |
| ) { |
| const results: BinarySizeRow[] = []; |
| let showBudgets = false; |
| let showCreepBudgets = false; |
| |
| // For each definition of equivalent builder pairs, find builds that match |
| // the bucket/builder names, and also have a mutually matching got_revision. |
| builderPairs.forEach(pairDefinition => { |
| const selectedTryBuilds = tryBuilds.filter( |
| tryBuild => |
| window.buildbucket.identicalBucket( |
| tryBuild.builder, |
| window.buildbucket.convertLegacyBucket(pairDefinition.tryBucket) |
| ) && tryBuild.builder.builder === pairDefinition.tryBuilder |
| ); |
| |
| const selectedCiBuilds = ciBuilds.filter( |
| ciBuild => |
| window.buildbucket.identicalBucket( |
| ciBuild.builder, |
| window.buildbucket.convertLegacyBucket(pairDefinition.ciBucket) |
| ) && ciBuild.builder.builder === pairDefinition.ciBuilder |
| ); |
| |
| selectedTryBuilds.forEach(tryBuild => { |
| selectedCiBuilds.forEach(ciBuild => { |
| if ( |
| tryBuild.output.properties.got_revision !== |
| ciBuild.output.properties.got_revision |
| ) { |
| return; |
| } |
| const trySizeInfoDict = tryBuild.output.properties.binary_sizes; |
| const ciSizeInfoDict = ciBuild.output.properties.binary_sizes; |
| |
| for (const item in trySizeInfoDict) { |
| if (!Object.prototype.hasOwnProperty.call(ciSizeInfoDict, item)) { |
| continue; |
| } |
| if (trySizeInfoDict[item + '.budget']) { |
| showBudgets = true; |
| } |
| if (trySizeInfoDict[item + '.creepBudget']) { |
| showCreepBudgets = true; |
| } |
| // Ensure that it's only the items representing the actual |
| // 'binary' that get returned in the result set - marker entries |
| // such as budget & owner are included in the record as associated |
| // properties and should not create distinct records themselves. |
| if ( |
| !item.endsWith('.budget') && |
| !item.endsWith('.creepBudget') && |
| !item.endsWith('.owner') |
| ) { |
| results.push({ |
| id: tryBuild.id, |
| builder: pairDefinition.tryBuilder, |
| binary: item, |
| trySize: trySizeInfoDict[item], |
| tryUrl: tryBuild.url, |
| ciSize: ciSizeInfoDict[item], |
| ciUrl: ciBuild.url, |
| tryBudget: trySizeInfoDict[item + '.budget'], |
| budgetExceeded: |
| trySizeInfoDict[item] > trySizeInfoDict[item + '.budget'], |
| tryCreepBudget: trySizeInfoDict[item + '.creepBudget'], |
| creepExceeded: |
| trySizeInfoDict[item] - ciSizeInfoDict[item] > |
| trySizeInfoDict[item + '.creepBudget'], |
| ownerUrl: trySizeInfoDict[item + '.owner'], |
| }); |
| } |
| } |
| }); |
| }); |
| }); |
| |
| const rows = this.sortUniqueInfoRows(results); |
| const allBuilds = [...tryBuilds, ...ciBuilds]; |
| return { |
| rows, |
| showBudgets, |
| showCreepBudgets, |
| checkRunAttempt: this.getCheckRunAttempt(allBuilds), |
| checkResultCategory: this.getCheckResultCategory(allBuilds, rows), |
| }; |
| } |
| |
| /** |
| * Sort rows of binary size information by builder, and keep only the latest |
| * build for each. |
| */ |
| sortUniqueInfoRows(rows: BinarySizeRow[]): BinarySizeRow[] { |
| const compareBuilderName = (a: BinarySizeRow, b: BinarySizeRow) => |
| a.builder.localeCompare(b.builder) || a.binary.localeCompare(b.binary); |
| const compareId = (a: BinarySizeRow, b: BinarySizeRow) => |
| -window.buildbucket.compareBuildIds(a.id, b.id); |
| return this.sortUniqueItems(rows, compareBuilderName, compareId); |
| } |
| |
| /** |
| * Sort items by primaryCmp then by secondaryCmp. Remove consecutive items |
| * which are equal by primaryCmp. |
| */ |
| sortUniqueItems( |
| /* eslint-disable @typescript-eslint/no-explicit-any */ |
| items: BinarySizeRow[], |
| primaryCmp: CompareFunction, |
| secondaryCmp: CompareFunction |
| /* eslint-enable @typescript-eslint/no-explicit-any */ |
| ) { |
| const sorted = items |
| .slice() |
| .sort((a, b) => primaryCmp(a, b) || secondaryCmp(a, b)); |
| return sorted.filter( |
| (item, i) => i === 0 || primaryCmp(sorted[i - 1], item) !== 0 |
| ); |
| } |
| |
| /** |
| * 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: PatchSetNum |
| ): PatchSetNum[] { |
| const validKinds = [ |
| RevisionKind.TRIVIAL_REBASE, |
| RevisionKind.NO_CHANGE, |
| RevisionKind.NO_CODE_CHANGE, |
| ]; |
| const revisions = Object.values(change.revisions || []).sort( |
| (a, b) => |
| // Reverse sort. |
| (b._number as number) - (a._number as number) |
| ); |
| const patchNums = []; |
| 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 > patchNum) { |
| // Patches after the one we're displaying don't count. |
| continue; |
| } |
| patchNums.push(revisions[i]._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; |
| } |
| } |