blob: 923ef91b8d420357b9eb90e5edb76d5fba3b6d71 [file] [log] [blame]
// Copyright 2020 The LUCI Authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { autorun, computed, observable } from 'mobx';
import { fromPromise, IPromiseBasedObservable } from 'mobx-utils';
import { getGitilesRepoURL, renderBuildBugTemplate } from '../libs/build_utils';
import { createContextLink } from '../libs/context';
import * as iter from '../libs/iter_utils';
import { unwrapObservable } from '../libs/utils';
import { BuildExt } from '../models/build_ext';
import {
Build,
BUILD_FIELD_MASK,
BuilderID,
BuilderItem,
GetBuildRequest,
GitilesCommit,
SEARCH_BUILD_FIELD_MASK,
} from '../services/buildbucket';
import { Project, QueryBlamelistRequest, QueryBlamelistResponse } from '../services/milo_internal';
import { getInvIdFromBuildId, getInvIdFromBuildNum } from '../services/resultdb';
import { AppState } from './app_state';
/**
* Records state of a build.
*/
export class BuildState {
@observable.ref builderIdParam?: BuilderID;
@observable.ref buildNumOrIdParam?: string;
/**
* Indicates whether a computed invocation ID should be used.
* Computed invocation ID may not work on older builds.
*/
@observable.ref useComputedInvId = true;
@computed get builderId() {
return this.builderIdParam || this.build?.builder;
}
/**
* buildNum is defined when this.buildNumOrId is defined and doesn't start
* with 'b'.
*/
@computed get buildNum() {
return this.buildNumOrIdParam?.startsWith('b') === false ? Number(this.buildNumOrIdParam) : null;
}
/**
* buildId is defined when this.buildNumOrId is defined and starts with 'b',
* or we have a matching cached build ID in appState.
*/
@computed get buildId() {
const cached =
this.builderIdParam && this.buildNum !== null
? this.appState.getBuildId(this.builderIdParam, this.buildNum)
: null;
return cached || (this.buildNumOrIdParam?.startsWith('b') ? this.buildNumOrIdParam.slice(1) : null);
}
@computed private get invocationId$(): IPromiseBasedObservable<string> {
if (!this.useComputedInvId) {
if (this.build === null) {
return fromPromise(Promise.race([]));
}
const invIdFromBuild = this.build.infra?.resultdb?.invocation?.slice('invocations/'.length) || '';
return fromPromise(Promise.resolve(invIdFromBuild));
} else if (this.buildId) {
// Favor ID over builder + number to ensure cache hit when the build page
// is redirected from a short build link to a long build link.
return fromPromise(Promise.resolve(getInvIdFromBuildId(this.buildId)));
} else if (this.builderIdParam && this.buildNum) {
return fromPromise(getInvIdFromBuildNum(this.builderIdParam, this.buildNum));
} else {
return fromPromise(Promise.race([]));
}
}
@computed get invocationId() {
return unwrapObservable(this.invocationId$, null);
}
private disposers: Array<() => void> = [];
constructor(private appState: AppState) {
this.disposers.push(
autorun(() => {
if (!this.build) {
return;
}
// If the associated gitiles commit is in the blamelist pins, select it.
// Otherwise, select the first blamelist pin.
const buildInputCommitRepo = this.build.associatedGitilesCommit
? getGitilesRepoURL(this.build.associatedGitilesCommit)
: null;
let selectedBlamelistPinIndex =
this.build.blamelistPins.findIndex((pin) => getGitilesRepoURL(pin) === buildInputCommitRepo) || 0;
if (selectedBlamelistPinIndex === -1) {
selectedBlamelistPinIndex = 0;
}
this.selectedBlamelistPinIndex = selectedBlamelistPinIndex;
})
);
}
@observable.ref private isDisposed = false;
/**
* Perform cleanup.
* Must be called before the object is GCed.
*/
dispose() {
this.isDisposed = true;
// Evaluates @computed({keepAlive: true}) properties after this.isDisposed
// is set to true so they no longer subscribes to any external observable.
this.build$;
this.relatedBuilds$;
this.queryBlamelistResIterFns;
this.permittedActions$;
this.projectCfg$;
this.disposers.reverse().forEach((disposer) => disposer());
this.disposers = [];
}
private buildQueryTime = this.appState.timestamp;
@computed({ keepAlive: true })
private get build$(): IPromiseBasedObservable<BuildExt> {
if (
this.isDisposed ||
!this.appState.buildsService ||
(!this.buildId && (!this.builderIdParam || !this.buildNum))
) {
// Returns a promise that never resolves when the dependencies aren't
// ready.
return fromPromise(Promise.race([]));
}
// If we use a simple boolean property here,
// 1. the boolean property cannot be an observable because we don't want to
// update observables in a computed property, and
// 2. we still need an observable (like this.timestamp) to trigger the
// update, and
// 3. this.refresh() will need to reset the boolean properties of all
// time-sensitive computed value.
//
// If we record the query time instead, no other code will need to read
// or update the query time.
const cacheOpt = {
acceptCache: this.buildQueryTime >= this.appState.timestamp,
};
this.buildQueryTime = this.appState.timestamp;
// Favor ID over builder + number to ensure cache hit when the build page is
// redirected from a short build link to a long build link.
const req: GetBuildRequest = this.buildId
? { id: this.buildId, fields: BUILD_FIELD_MASK }
: { builder: this.builderIdParam, buildNumber: this.buildNum!, fields: BUILD_FIELD_MASK };
return fromPromise(this.appState.buildsService.getBuild(req, cacheOpt).then((b) => new BuildExt(b)));
}
@computed
get build(): BuildExt | null {
return unwrapObservable(this.build$, null);
}
@computed({ keepAlive: true })
private get relatedBuilds$(): IPromiseBasedObservable<readonly BuildExt[]> {
if (this.isDisposed || !this.build) {
return fromPromise(Promise.race([]));
}
const buildsPromises = this.build.buildSets
// Remove the commit/git/ buildsets because we know they're redundant with
// the commit/gitiles/ buildsets, and we don't need to ask Buildbucket
// twice.
.filter((b) => !b.startsWith('commit/git/'))
.map((b) =>
this.appState
.buildsService!.searchBuilds({
predicate: { tags: [{ key: 'buildset', value: b }] },
fields: SEARCH_BUILD_FIELD_MASK,
pageSize: 1000,
})
.then((res) => res.builds)
);
return fromPromise(
Promise.all(buildsPromises).then((buildArrays) => {
const buildMap = new Map<string, Build>();
for (const builds of buildArrays) {
for (const build of builds) {
// Filter out duplicate builds by overwriting them.
buildMap.set(build.id, build);
}
}
return [...buildMap.values()]
.sort((b1, b2) => (b1.id.length === b2.id.length ? b1.id.localeCompare(b2.id) : b1.id.length - b2.id.length))
.map((b) => new BuildExt(b));
})
);
}
@computed
get relatedBuilds(): readonly BuildExt[] | null {
return unwrapObservable(this.relatedBuilds$, null);
}
@observable.ref selectedBlamelistPinIndex = 0;
private getQueryBlamelistResIterFn(gitilesCommit: GitilesCommit, multiProjectSupport = false) {
if (!this.appState.milo || !this.build) {
// eslint-disable-next-line require-yield
return async function* () {
await Promise.race([]);
};
}
let req: QueryBlamelistRequest = {
gitilesCommit,
builder: this.build.builder,
multiProjectSupport,
};
const milo = this.appState.milo;
async function* streamBlamelist() {
let res: QueryBlamelistResponse;
do {
res = await milo.queryBlamelist(req);
req = { ...req, pageToken: res.nextPageToken };
yield res;
} while (res.nextPageToken);
}
return iter.teeAsync(streamBlamelist());
}
@computed
private get gitilesCommitRepo() {
if (!this.build?.associatedGitilesCommit) {
return null;
}
return getGitilesRepoURL(this.build.associatedGitilesCommit);
}
@computed({ keepAlive: true })
get queryBlamelistResIterFns() {
if (this.isDisposed || !this.build) {
return [];
}
return this.build.blamelistPins.map((pin) => {
const pinRepo = getGitilesRepoURL(pin);
return this.getQueryBlamelistResIterFn(pin, pinRepo !== this.gitilesCommitRepo);
});
}
@computed({ keepAlive: true })
private get builder$() {
// We should not merge this with the if statement below because no other
// observables should be accessed when this.isDisposed is set to true.
if (this.isDisposed) {
return fromPromise(Promise.race([]));
}
if (!this.appState.buildersService || !this.builderId) {
return fromPromise(Promise.race([]));
}
return fromPromise(this.appState.buildersService.getBuilder({ id: this.builderId }));
}
@computed
get builder(): BuilderItem | null {
return unwrapObservable(this.builder$, null);
}
@computed private get bucketResourceId() {
if (!this.builderId) {
return null;
}
return `luci.${this.builderId.project}.${this.builderId.bucket}`;
}
@computed({ keepAlive: true })
private get permittedActions$() {
if (this.isDisposed || !this.appState.accessService || !this.bucketResourceId) {
// Returns a promise that never resolves when the dependencies aren't
// ready.
return fromPromise(Promise.race([]));
}
// Establish a dependency on the timestamp.
this.appState.timestamp;
return fromPromise(
this.appState.accessService?.permittedActions({
resourceKind: 'bucket',
resourceIds: [this.bucketResourceId],
})
);
}
@computed
get permittedActions(): Set<string> {
const permittedActionRes = unwrapObservable(this.permittedActions$, null);
return new Set(permittedActionRes?.permitted[this.bucketResourceId!].actions || []);
}
@computed({ keepAlive: true })
private get projectCfg$() {
if (this.isDisposed || !this.appState.milo || !this.builderId?.project) {
// Returns a promise that never resolves when the dependencies aren't
// ready.
return fromPromise(Promise.race([]));
}
// Establishes a dependency on the timestamp.
this.appState.timestamp;
return fromPromise(
this.appState.milo.getProjectCfg({
project: this.builderId.project,
})
);
}
@computed
private get projectCfg(): Project | null {
return unwrapObservable(this.projectCfg$, null);
}
@computed
get customBugLink(): string | null {
const bugTemplate = this.projectCfg?.buildBugTemplate;
if (!bugTemplate?.monorailProject || !this.build) {
return null;
}
const components = bugTemplate.components?.join(',');
const searchParam = new URLSearchParams({
summary: renderBuildBugTemplate(bugTemplate.summary || '', this.build),
description: renderBuildBugTemplate(bugTemplate.description || '', this.build),
...(components ? { components } : {}),
});
return `https://bugs.chromium.org/p/${bugTemplate.monorailProject}/issues/entry?${searchParam}`;
}
}
export const [provideBuildState, consumeBuildState] = createContextLink<BuildState>();