blob: 498ec10a9f3542938e0510be5fb9ca46bf0fb4ab [file] [log] [blame]
/**
* @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;
}
}