blob: f1b7217ea63be8476f12c18cbbc197df70b8acf8 [file] [log] [blame]
// Copyright 2016 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.
(function() {
'use strict';
const DEFAULT_UPDATE_INTERVAL_MS = 1000 * 15;
const MAX_UPDATE_INTERVAL_MS = 1000 * 60 * 5;
const BUILDBUCKET_HOST = 'cr-buildbucket.appspot.com';
Polymer({
is: 'binary-size-view',
properties: {
plugin: Object,
projectName: String,
change: Object,
revision: Object,
_validPatchNums: {
type: Array,
computed: '_computeValidPatchNums(change, revision._number)',
observer: '_refresh',
},
_pluginConfig: Object,
_binarySizeInfo: {
type: Array,
value() { return []; },
},
_statusString: String,
_needsUpdate: {
type: Boolean,
value: false,
},
_updateIntervalMs: {
type: Number,
value: DEFAULT_UPDATE_INTERVAL_MS,
},
_updateTimeoutID: Number,
},
attached() {
this.listen(document, 'visibilitychange', '_handleVisibilityChange');
if (!this.change) {
console.warn('element attached without change property set.');
return;
}
this.reload();
},
detached() {
this._clearUpdateTimeout();
},
_clearUpdateTimeout() {
if (this._updateTimeoutID) {
window.clearTimeout(this._updateTimeoutID);
this._updateTimeoutID = null;
}
},
/**
* Reset the state of the element and fetch and display builds.
*/
async reload() {
const pluginName = this.plugin.getPluginName();
const project = this.change.project;
this._pluginConfig = await this.plugin.restApi().get(
`/projects/${encodeURIComponent(project)}/${pluginName}~config`);
if (!this._pluginConfig) {
console.log('binary-size plugin not configured for this project.');
return;
}
await this._refresh();
},
_handleVisibilityChange(e) {
if (!document.hidden && this._needsUpdate) {
this._refresh();
this._needsUpdate = false;
}
},
_updateTimerFired() {
if (document.hidden) {
this._needsUpdate = true;
return;
}
this._refresh();
},
/**
* Fetch builds from Buildbucket, update the view with information about
* binary size, and update the timeout depending on success/failure.
*/
async _refresh() {
if (!this._pluginConfig) {
return;
}
if (!window.buildbucket) {
console.error('The "binary-size" plugin requires the "buildbucket"' +
' plugin for searching builds. Please activate both.');
}
const newBinarySizeInfo = await this._tryGetNewBinarySizeInfo();
if (newBinarySizeInfo != null) {
this._binarySizeInfo = this._sortUniqueInfoRows(newBinarySizeInfo);
this._statusString = this._getStatusString(this._validPatchNums);
this._updateIntervalMs = DEFAULT_UPDATE_INTERVAL_MS;
} else {
this._updateIntervalMs = Math.min(
MAX_UPDATE_INTERVAL_MS,
(1 + Math.random()) * this._updateIntervalMs * 2);
}
this._clearUpdateTimeout();
this._updateTimeoutID = window.setTimeout(
this._updateTimerFired.bind(this), this._updateIntervalMs);
},
/**
* Fetch builds from Buildbucket and return information about binary size,
* or null in case of failure.
*/
async _tryGetNewBinarySizeInfo() {
const tryBuckets = new Set();
const ciBuckets = new Set();
this._pluginConfig.builders.forEach((pair) => {
tryBuckets.add(pair.tryBucket);
ciBuckets.add(pair.ciBucket);
});
let tryBuilds = await this._getBuilds(
tryBuckets, this._tryjobTags(this._validPatchNums));
if (tryBuilds == null) {
return null;
}
tryBuilds = this._selectRelevantBuilds(tryBuilds);
let ciBuilds = await this._getBuilds(
ciBuckets, this._revisionTags(tryBuilds));
if (ciBuilds == null) {
return null;
}
ciBuilds = this._selectRelevantBuilds(ciBuilds);
return this._collateBinarySizeInfoFromBuildPairs(
this._pluginConfig.builders, tryBuilds, ciBuilds);
},
/**
* Return the Buildbucket tags corresponding to the provided patchset
* numbers.
*/
_tryjobTags(validPatchNums) {
return validPatchNums.map((patchNum) => {
const host = this._pluginConfig.gerritHost;
const changeNum = this.change._number;
return `buildset:patch/gerrit/${host}/${changeNum}/${patchNum}`;
});
},
/**
* Return the Buildbucket tags corresponding to the base revision of each
* of the provided builds.
*/
_revisionTags(builds) {
const tags = builds.map((build) => {
const host = this._pluginConfig.gitHost;
const project = this.change.project;
const revision = build.properties.got_revision;
return `buildset:commit/gitiles/${host}/${project}/+/${revision}`;
});
return Array.from(new Set(tags));
},
/**
* Get builds that match any of the buckets and any of the tags.
*/
async _getBuilds(buckets, tags) {
if (buckets.length == 0 || tags.length == 0) {
return [];
}
try {
const bb = new window.buildbucket.BuildbucketV1Client(BUILDBUCKET_HOST);
// Send a request for each tag.
const buildArrays = await Promise.all(
tags.map((tag) => {
const params = new URLSearchParams({
max_builds: 500,
include_experimental: true,
fields: 'builds(bucket,id,result_details_json,tags,url)',
tag: tag,
});
buckets.forEach((bucket) => {
params.append('bucket', bucket);
});
return bb.searchBuilds(params);
})
);
// 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([], buildArrays);
} catch (err) {
console.warn('Buildbucket search failed', err);
return null;
}
},
/**
* Filter out builds that don't have properties needed to display binary
* size information. This also mutates the builds to populate properties
* based on result_details_json.
*/
_selectRelevantBuilds(builds) {
return builds.filter((build) => {
try {
build.properties = JSON.parse(build.result_details_json).properties;
} catch (e) {
return false;
}
return (build.properties && build.properties.got_revision &&
build.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.
*/
_collateBinarySizeInfoFromBuildPairs(builderPairs, tryBuilds, ciBuilds) {
const results = [];
// 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) => (
tryBuild.bucket === pairDefinition.tryBucket &&
this._getBuilder(tryBuild) === pairDefinition.tryBuilder));
const selectedCiBuilds = ciBuilds.filter((ciBuild) => (
ciBuild.bucket === pairDefinition.ciBucket &&
this._getBuilder(ciBuild) === pairDefinition.ciBuilder));
selectedTryBuilds.forEach((tryBuild) => {
selectedCiBuilds.forEach((ciBuild) => {
if (tryBuild.properties.got_revision !==
ciBuild.properties.got_revision) {
return;
}
const trySizes = tryBuild.properties.binary_sizes;
const ciSizes = ciBuild.properties.binary_sizes;
for (const binary in trySizes) {
if (!ciSizes.hasOwnProperty(binary)) {
continue;
}
results.push({
id: tryBuild.id,
builder: pairDefinition.tryBuilder,
binary: binary,
trySize: trySizes[binary],
tryUrl: tryBuild.url,
ciSize: ciSizes[binary],
ciUrl: ciBuild.url,
});
}
});
});
});
return results;
},
/**
* Extract the builder name from a build.
*/
_getBuilder(build) {
const TAG_PREFIX = 'builder:';
for (let i = 0; i < build.tags.length; i++) {
const tag = build.tags[i];
if (tag.startsWith(TAG_PREFIX)) {
return tag.slice(TAG_PREFIX.length);
}
}
return build.properties.buildername;
},
/**
* Sort rows of binary size information by builder, and keep only the latest
* build for each.
*/
_sortUniqueInfoRows(rows) {
const compareBuilderName = (a, b) => {
return (a.builder.localeCompare(b.builder) ||
a.binary.localeCompare(b.binary));
};
const compareId = (a, b) => {
return -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(items, primaryCmp, secondaryCmp) {
const sorted = items.slice().sort((a, b) => {
return primaryCmp(a, b) || secondaryCmp(a, b);
});
return sorted.filter((item, i) => {
return i == 0 || primaryCmp(sorted[i-1], item) != 0;
});
},
/**
* Construct a description string for the given patchset numbers.
*/
_getStatusString(validPatchNums) {
let patchStatus = '';
if (validPatchNums) {
if (validPatchNums.length === 1) {
patchStatus = `Showing results from patch set ${validPatchNums[0]}. `;
} else {
const begin = Math.min(...validPatchNums);
const end = Math.max(...validPatchNums);
patchStatus = `Showing results from patch sets ${begin}-${end}. `;
}
}
return patchStatus + 'Last updated at ' + new Date().toLocaleTimeString();
},
/**
* 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 {Object} change A Gerrit ChangeInfo object.
* @param {number} patchNum Revision number of currently displayed patch.
* @return {number[]} Revision numbers for the displayed builds.
*/
_computeValidPatchNums(change, patchNum) {
const validKinds = ['TRIVIAL_REBASE', 'NO_CHANGE', 'NO_CODE_CHANGE'];
const revisions = [];
for (const revision in change.revisions) {
if (!change.revisions.hasOwnProperty(revision)) { continue; }
revisions.push(change.revisions[revision]);
}
revisions.sort((a, b) => {
// Reverse sort.
return b._number - a._number;
});
const patchNums = [];
for (let i = 0; i < revisions.length; i++) {
if (i == 0 && change.status === '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;
},
/**
* Convert a positive number of bytes to a human-readable string.
*/
_humanByteSize(bytes) {
const units = ['B', 'KiB', 'MiB', 'GiB'];
const decPoints = [0, 2, 3, 4];
let unitIndex = 0;
while (Math.abs(bytes) >= 2048 && unitIndex < units.length - 1) {
bytes /= 1024;
unitIndex += 1;
}
return `${bytes.toFixed(decPoints[unitIndex])} ${units[unitIndex]}`;
},
/**
* Return a plus for positive numbers, a minus for negative numbers.
*/
_signFor(number) {
if (number > 0) {
return '+';
} else if (number < 0) {
return '\u2212'; // MINUS SIGN
} else {
return '';
}
},
/**
* Convert a difference between two numbers of bytes to a human-readable
* string, with plus or minus in front.
*/
_humanByteSizeDelta(before, after) {
const delta = after - before;
if (delta == 0) {
return '\u2014'; // EM DASH
}
return this._signFor(delta) + this._humanByteSize(Math.abs(delta));
},
/**
* Convert a difference between two numbers to a percentage with 4 decimal
* places, with plus or minus in front.
*/
_percentSizeDelta(before, after) {
const delta = ((after - before) / before) * 100;
if (delta == 0) {
return '\u2014'; // EM DASH
}
return this._signFor(delta) + Math.abs(delta).toFixed(4) + '%';
},
_subtract(a, b) {
return a - b;
},
});
})();