blob: 6e9a7289ca39f8b56b2e74bece17339039ae8041 [file] [log] [blame]
// 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 {
ChangeInfo,
RevisionInfo,
} from '@gerritcodereview/typescript-api/rest-api';
import {Builder, BuildStatus} from './buildbucket-client';
import {Build, Config} from './checks-fetcher';
/**
* 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`.
*/
export function 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;
}
/**
* Constructs a string used to uniquely identify a BB operation.
*
* @return A client operation ID string.
*/
export function getNewOperationId(): string {
return `opid:${Date.now()}:${Math.random().toString(36)}`;
}
/*
* Converts string representation of legacy bucket definition (i.e.
* luci.project.bucket) to object with project and bucket properties.
*/
export function convertLegacyBucket(bucket: string): Builder {
const luciPrefixLength = 'luci.'.length;
const sepIndex = bucket.indexOf('.', luciPrefixLength);
return {
project: bucket.slice(luciPrefixLength, sepIndex),
bucket: bucket.slice(sepIndex + 1),
};
}
/*
* A helper function for checking build tags.
*/
function checkTag(build: Build, key: string, check: (arg0: string) => boolean) {
return (build.tags || []).some(t => t.key === key && check(t.value));
}
/*
* Returns true if a build is either CQ experimental or LUCI experimental.
*/
export function isExperimental(build: Build): boolean {
return (
(build.input?.experiments || []).includes('luci.non_production') ||
checkTag(build, 'cq_experimental', v => v === 'true')
);
}
/*
* Returns true if builders have the same project and bucket.
*/
export function identicalBucket(builder1: Builder, builder2: Builder): boolean {
return (
builder1.project === builder2.project && builder1.bucket === builder2.bucket
);
}
/**
* Returns whether the 'Choose Tryjobs' button should appear.
*
* @param config Buildbucket plugin config.
* @param loggedIn Whether the user is logged in.
* @param change Change object.
*/
export function showChooseTryjobs(
config: Config | null,
loggedIn: boolean,
change: ChangeInfo
): boolean {
return !!(config?.buckets?.length && loggedIn && change.status === 'NEW');
}
/**
* Returns whether the 'Retry Failed', 'Run', or 'Cancel' build buttons should
* show.
*
* @param config Buildbucket plugin config.
* @param loggedIn Whether the user is logged in.
* @param change Change object.
*/
export function showRetryFailed(
config: Config | null,
loggedIn: boolean,
change: ChangeInfo
): boolean {
return !!(
config &&
!config.hideRetryButton &&
loggedIn &&
change.status === 'NEW'
);
}
/**
* Returns whether the given build should skip retry.
*
* @param build Build object.
* @return boolean whether the given build should be skipped when the
* "retry failed" button is used.
*/
export function shouldSkipRetry(build: Build): boolean {
return checkTag(build, 'skip-retry-in-gerrit', v => v !== 'false');
}
/**
* Returns whether the given build has a tag for hiding in gerrit.
*
* @param build Build object.
* @return boolean whether the given build has a tag for hiding in gerrit.
*/
export function hasHideInGerritTag(build: Build): boolean {
return checkTag(build, 'hide-in-gerrit', v => v !== 'false');
}
/**
* Returns whether the given build has a tag for hiding ResultDB test results in
* gerrit.
*
* @param build Build object.
* @return boolean whether the given build has a tag for hiding ResultDB test
* results in gerrit.
*/
export function hasHideTestResultsInGerritTag(build: Build): boolean {
return checkTag(build, 'hide-test-results-in-gerrit', v => v !== 'false');
}
/**
* Divides the given builds into regular and experimental groups.
*
* @param allBuilds All builds in a flat Array.
* @return Two Arrays; the first with regular builds
* and the second with experimental builds.
*/
export function splitExperimentalBuilds(allBuilds: Build[]): Build[][] {
const builds: Build[] = [];
const experimentalBuilds: Build[] = [];
allBuilds.forEach(b => {
if (isExperimental(b)) {
experimentalBuilds.push(b);
} else {
builds.push(b);
}
});
return [builds, experimentalBuilds];
}
/**
* Returns the buildIds for failed builds that should be retried.
*
* We want to retry builders when:
* - There are no running builds.
* - The latest completed build is failed (FAILURE or INFRA_FAILURE).
* - The builder is non-experimental.
*
* @param allBuilds Build objects from Buildbucket. These
* have a builder property which itself is an object with project,
* bucket, and builder string properties (where bucket is short name,
* e.g. try).
* @return An array of buildIds of failed builds where a retry has not
* already started.
*/
export function getRetryTemplateBuildIds(allBuilds: Build[]): string[] {
// Only consider non-experimental builds.
const [builds] = splitExperimentalBuilds(allBuilds);
const buildIds: Map<string, string> = new Map();
const seen = new Set();
// Sort builds from latest to oldest.
builds.sort((a, b) => -compareBuildIds(a.id, b.id));
builds.forEach(b => {
const builder = b.builder;
const key = `${builder.project}/${builder.bucket}/${builder.builder}`;
// We only care if the latest build for a builder has failed.
if (
!seen.has(key) &&
!shouldSkipRetry(b) &&
[BuildStatus.FAILURE, BuildStatus.INFRA_FAILURE].includes(b.status)
) {
buildIds.set(key, b.id);
}
// If a build for this builder has already started, we don't need to
// retry again.
if (buildIds.has(key) && b.status == BuildStatus.STARTED) {
buildIds.delete(key);
}
seen.add(key);
});
return Array.from(buildIds.values());
}
/**
* Returns the revisions in a change from latest to earliest.
*
* @param change A Gerrit ChangeInfo object.
* @return Gerrit RevisionInfo objects, which all have
* a _number property; ordered by _number from highest to lowest.
*/
export function reversedRevisions(change: ChangeInfo): RevisionInfo[] {
const revisions: RevisionInfo[] = [];
for (const revision in change.revisions) {
const rev = change.revisions[revision];
if (rev && typeof rev._number === 'number') {
revisions.push(rev);
}
}
revisions.sort(
(a, b) =>
// Reverse sort.
(b._number as number) - (a._number as number)
);
return revisions;
}
/**
* Checks whether the currently-displayed patchset is the latest patchset.
*
* @param change The Gerrit change.
* @param patchNum The current patchset number.
* @returns true if the current patchset is the latest.
*/
export function isLatestPatchset(
change: ChangeInfo,
patchNum: number
): boolean {
const revs = reversedRevisions(change);
return revs.length > 0 && revs[0]._number === patchNum;
}
/**
* Groups the patchsets in the change into sets of equivalent patchsets.
*
* @param change A Gerrit ChangeInfo object.
* @return Groups of equivalent patchsets, from latest to
* earliest.
*/
export function splitPatchsetGroups(change: ChangeInfo): Set<number>[] {
const trivialKinds = new Set([
'TRIVIAL_REBASE',
'NO_CHANGE',
'NO_CODE_CHANGE',
]);
const revisions = reversedRevisions(change);
const equivalentSets: Set<number>[] = [];
let currentSet: Set<number> = new Set();
for (let i = 0; i < revisions.length; i++) {
currentSet.add(revisions[i]._number as number);
if (!trivialKinds.has(revisions[i].kind)) {
// If this revision was a non-trivial change, it is not part
// of the same equivalent patchset set as those after it.
equivalentSets.push(currentSet);
currentSet = new Set();
}
}
console.assert(
currentSet.size === 0,
'the first patchset is expected to be non-trivial'
);
return equivalentSets;
}
/**
* Returns a Date object from a Timestamp string defined in:
* https://github.com/protocolbuffers/protobuf/blob/master/src/google/protobuf/timestamp.proto
*
* @param timestamp A timestamp string with the above format.
*/
export function getDateFromTimestamp(
timestamp: string | undefined
): Date | undefined {
if (!timestamp) {
return undefined;
}
const m =
/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(\.\d{1,9})?Z$/.exec(
String(timestamp)
);
if (!m) {
return undefined;
}
// m[2] matches the month with January being 01. The Date constructor takes a
// 0-indexed month.
//
// m[7] matches the fractional seconds of timestamp and takes the format
// ".[0-9]{1-9}". This gets an integer number of milliseconds given m[7].
return new Date(
Date.UTC(
Number(m[1]),
Number(m[2]) - 1,
Number(m[3]),
Number(m[4]),
Number(m[5]),
Number(m[6]),
Math.round(Number(m[7] || '0') * 1000)
)
);
}
/**
* Returns the full resource name for a given Buildbucket host and build ID.
*/
export function createBuildResourceName(host: string, buildId: string): string {
return `//${host}/build/${buildId}`;
}
/**
* Returns a url to the build page for a given Buildbucket host and build ID.
*/
export function createBuildUrl(host: string, buildId: string): string {
return encodeURI(`https:${createBuildResourceName(host, buildId)}`);
}
/**
* Returns a link to the builder page for the given build.
*/
export function createBuilderLink(build: Build): string {
const builder = build.builder;
return encodeURI(
`https://ci.chromium.org/p/${builder.project}/builders/` +
`${builder.bucket}/${builder.builder}`
);
}