| // 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>(); |