go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/milo/ui/src/common/store/build_page/build_page.ts (about) 1 // Copyright 2022 The LUCI Authors. 2 // 3 // Licensed under the Apache License, Version 2.0 (the "License"); 4 // you may not use this file except in compliance with the License. 5 // You may obtain a copy of the License at 6 // 7 // http://www.apache.org/licenses/LICENSE-2.0 8 // 9 // Unless required by applicable law or agreed to in writing, software 10 // distributed under the License is distributed on an "AS IS" BASIS, 11 // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 // See the License for the specific language governing permissions and 13 // limitations under the License. 14 15 import { GrpcError, RpcCode } from '@chopsui/prpc-client'; 16 import stableStringify from 'fast-json-stable-stringify'; 17 import { reaction } from 'mobx'; 18 import { 19 addDisposer, 20 cast, 21 Instance, 22 SnapshotIn, 23 SnapshotOut, 24 types, 25 } from 'mobx-state-tree'; 26 import { fromPromise } from 'mobx-utils'; 27 28 import { 29 NEVER_OBSERVABLE, 30 POTENTIALLY_EXPIRED, 31 } from '@/common/constants/legacy'; 32 import { 33 Build, 34 BUILD_FIELD_MASK, 35 BuilderID, 36 GetBuildRequest, 37 PERM_BUILDS_ADD, 38 PERM_BUILDS_CANCEL, 39 PERM_BUILDS_GET, 40 PERM_BUILDS_GET_LIMITED, 41 TEST_PRESENTATION_KEY, 42 Trinary, 43 } from '@/common/services/buildbucket'; 44 import { 45 getInvIdFromBuildId, 46 getInvIdFromBuildNum, 47 PERM_INVOCATIONS_GET, 48 PERM_TEST_EXONERATIONS_LIST, 49 PERM_TEST_EXONERATIONS_LIST_LIMITED, 50 PERM_TEST_RESULTS_LIST, 51 PERM_TEST_RESULTS_LIST_LIMITED, 52 } from '@/common/services/resultdb'; 53 import { BuildState } from '@/common/store/build_state'; 54 import { InvocationState } from '@/common/store/invocation_state'; 55 import { ServicesStore } from '@/common/store/services'; 56 import { Timestamp } from '@/common/store/timestamp'; 57 import { UserConfig } from '@/common/store/user_config'; 58 import { getGitilesRepoURL } from '@/common/tools/gitiles_utils'; 59 import { 60 aliveFlow, 61 keepAliveComputed, 62 unwrapObservable, 63 } from '@/generic_libs/tools/mobx_utils'; 64 import { attachTags, InnerTag, TAG_SOURCE } from '@/generic_libs/tools/tag'; 65 66 export const enum SearchTarget { 67 Builders, 68 Tests, 69 } 70 71 export class GetBuildError extends Error implements InnerTag { 72 readonly [TAG_SOURCE]: Error; 73 74 constructor(source: Error) { 75 super(source.message); 76 this[TAG_SOURCE] = source; 77 } 78 } 79 80 export const BuildPage = types 81 .model('BuildPage', { 82 currentTime: types.safeReference(Timestamp), 83 refreshTime: types.safeReference(Timestamp), 84 services: types.safeReference(ServicesStore), 85 userConfig: types.safeReference(UserConfig), 86 87 /** 88 * The builder ID of the build. 89 * Ignored when build `buildNumOrIdParam` is a build ID string (i.e. begins 90 * with 'b'). 91 */ 92 builderIdParam: types.maybe(types.frozen<BuilderID>()), 93 buildNumOrIdParam: types.maybe(types.string), 94 95 /** 96 * Indicates whether a computed invocation ID should be used. 97 * Computed invocation ID may not work on older builds. 98 */ 99 useComputedInvId: true, 100 invocation: types.optional(InvocationState, {}), 101 102 // Properties that provide a mounting point for computed models so they can 103 // have references to some other properties in the tree. 104 _build: types.maybe(BuildState), 105 }) 106 .volatile(() => { 107 const cachedBuildId = new Map<string, string>(); 108 return { 109 setBuildId(builderId: BuilderID, buildNum: number, buildId: string) { 110 cachedBuildId.set(stableStringify([builderId, buildNum]), buildId); 111 }, 112 getBuildId(builderId: BuilderID, buildNum: number) { 113 return cachedBuildId.get(stableStringify([builderId, buildNum])); 114 }, 115 }; 116 }) 117 .views((self) => ({ 118 /** 119 * buildNum is defined when this.buildNumOrId is defined and doesn't start 120 * with 'b'. 121 */ 122 get buildNum() { 123 return self.buildNumOrIdParam?.startsWith('b') === false 124 ? Number(self.buildNumOrIdParam) 125 : null; 126 }, 127 /** 128 * buildId is defined when this.buildNumOrId is defined and starts with 'b', 129 * or we have a matching cached build ID in appState. 130 */ 131 get buildId() { 132 const cached = 133 self.builderIdParam && this.buildNum !== null 134 ? self.getBuildId(self.builderIdParam, this.buildNum) 135 : null; 136 return ( 137 cached || 138 (self.buildNumOrIdParam?.startsWith('b') 139 ? self.buildNumOrIdParam.slice(1) 140 : null) 141 ); 142 }, 143 get hasInvocation() { 144 return Boolean(self._build?.data.infra?.resultdb?.invocation); 145 }, 146 })) 147 .actions((self) => ({ 148 _setBuild(build: Build) { 149 self._build = cast({ 150 data: build, 151 currentTime: self.currentTime?.id, 152 userConfig: self.userConfig?.id, 153 }); 154 }, 155 })) 156 .views((self) => { 157 let buildQueryTime: number | null = null; 158 const build = keepAliveComputed(self, () => { 159 if ( 160 !self.services?.builds || 161 (!self.buildId && (!self.builderIdParam || !self.buildNum)) || 162 !self.refreshTime 163 ) { 164 return null; 165 } 166 167 // If we use a simple boolean property here, 168 // 1. the boolean property cannot be an observable because we don't want 169 // to update observables in a computed property, and 170 // 2. we still need an observable (like this.timestamp) to trigger the 171 // update, and 172 // 3. this.refresh() will need to reset the boolean properties of all 173 // time-sensitive computed value. 174 // 175 // If we record the query time instead, no other code will need to read 176 // or update the query time. 177 const cacheOpt = { 178 acceptCache: 179 buildQueryTime === null || buildQueryTime >= self.refreshTime.value, 180 }; 181 buildQueryTime = self.refreshTime.value; 182 183 // Favor ID over builder + number to ensure cache hit when the build 184 // page is redirected from a short build link to a long build link. 185 const req: GetBuildRequest = self.buildId 186 ? { id: self.buildId, fields: BUILD_FIELD_MASK } 187 : { 188 builder: self.builderIdParam, 189 buildNumber: self.buildNum!, 190 fields: BUILD_FIELD_MASK, 191 }; 192 193 return fromPromise( 194 self.services.builds 195 .getBuild(req, cacheOpt) 196 .catch((e) => { 197 if (e instanceof GrpcError && e.code === RpcCode.NOT_FOUND) { 198 attachTags(e, POTENTIALLY_EXPIRED); 199 } 200 throw new GetBuildError(e); 201 }) 202 .then((b) => { 203 self._setBuild(b); 204 return self._build!; 205 }), 206 ); 207 }); 208 return { 209 get build() { 210 return unwrapObservable(build.get() || NEVER_OBSERVABLE, null); 211 }, 212 }; 213 }) 214 .views((self) => { 215 const invocationId = keepAliveComputed(self, () => { 216 if (!self.useComputedInvId) { 217 if (self.build === null) { 218 return null; 219 } 220 const invIdFromBuild = 221 self.build?.data.infra?.resultdb?.invocation?.slice( 222 'invocations/'.length, 223 ) ?? null; 224 return fromPromise(Promise.resolve(invIdFromBuild)); 225 } else if (self.buildId) { 226 // Favor ID over builder + number to ensure cache hit when the build 227 // page is redirected from a short build link to a long build link. 228 return fromPromise(Promise.resolve(getInvIdFromBuildId(self.buildId))); 229 } else if (self.builderIdParam && self.buildNum) { 230 return fromPromise( 231 getInvIdFromBuildNum(self.builderIdParam, self.buildNum), 232 ); 233 } else { 234 return null; 235 } 236 }); 237 238 const permittedActions = keepAliveComputed(self, () => { 239 if (!self.services?.milo || !self.build?.data.builder) { 240 return null; 241 } 242 243 // Establish a dependency on the timestamp. 244 self.refreshTime?.value; 245 246 return fromPromise( 247 self.services.milo.batchCheckPermissions({ 248 realm: `${self.build.data.builder.project}:${self.build.data.builder.bucket}`, 249 permissions: [ 250 PERM_BUILDS_CANCEL, 251 PERM_BUILDS_ADD, 252 PERM_BUILDS_GET, 253 PERM_BUILDS_GET_LIMITED, 254 PERM_INVOCATIONS_GET, 255 PERM_TEST_EXONERATIONS_LIST, 256 PERM_TEST_RESULTS_LIST, 257 PERM_TEST_EXONERATIONS_LIST_LIMITED, 258 PERM_TEST_RESULTS_LIST_LIMITED, 259 ], 260 }), 261 ); 262 }); 263 264 return { 265 get invocationId() { 266 return unwrapObservable(invocationId.get() || NEVER_OBSERVABLE, null); 267 }, 268 get _permittedActions(): { readonly [key: string]: boolean | undefined } { 269 const permittedActionRes = unwrapObservable( 270 permittedActions.get() || NEVER_OBSERVABLE, 271 null, 272 ); 273 return permittedActionRes?.results || {}; 274 }, 275 get canRetry() { 276 return Boolean( 277 self.build?.data.retriable !== Trinary.No && 278 this._permittedActions[PERM_BUILDS_ADD], 279 ); 280 }, 281 get canCancel() { 282 return this._permittedActions[PERM_BUILDS_CANCEL] || false; 283 }, 284 get canReadFullBuild() { 285 return this._permittedActions[PERM_BUILDS_GET] || false; 286 }, 287 get canReadTestVerdicts() { 288 return ( 289 this._permittedActions[PERM_TEST_EXONERATIONS_LIST_LIMITED] && 290 this._permittedActions[PERM_TEST_RESULTS_LIST_LIMITED] 291 ); 292 }, 293 get gitilesCommitRepo() { 294 if (!self.build?.associatedGitilesCommit) { 295 return null; 296 } 297 return getGitilesRepoURL(self.build.associatedGitilesCommit); 298 }, 299 }; 300 }) 301 .actions((self) => ({ 302 setDependencies( 303 deps: Partial< 304 Pick< 305 typeof self, 306 'currentTime' | 'refreshTime' | 'services' | 'userConfig' 307 > 308 >, 309 ) { 310 Object.assign<typeof self, Partial<typeof self>>(self, deps); 311 }, 312 setUseComputedInvId(useComputed: boolean) { 313 self.useComputedInvId = useComputed; 314 }, 315 setParams(builderId: BuilderID | undefined, buildNumOrId: string) { 316 self.builderIdParam = builderId; 317 self.buildNumOrIdParam = buildNumOrId; 318 }, 319 retryBuild: aliveFlow(self, function* () { 320 if (!self.build?.data.id || !self.services?.builds) { 321 return null; 322 } 323 324 const call = self.services.builds.scheduleBuild({ 325 templateBuildId: self.build.data.id, 326 }); 327 const build: Awaited<typeof call> = yield call; 328 return build; 329 }), 330 cancelBuild: aliveFlow(self, function* (reason: string) { 331 if (!self.build?.data.id || !reason || !self.services?.builds) { 332 return; 333 } 334 335 yield self.services.builds.cancelBuild({ 336 id: self.build.data.id, 337 summaryMarkdown: reason, 338 }); 339 self.refreshTime?.refresh(); 340 }), 341 afterCreate() { 342 addDisposer( 343 self, 344 reaction( 345 () => self.services, 346 (services) => { 347 self.invocation.setDependencies({ 348 services, 349 }); 350 }, 351 { fireImmediately: true }, 352 ), 353 ); 354 355 self.invocation.setDependencies({ 356 invocationIdGetter: () => self.invocationId, 357 presentationConfigGetter: () => 358 self.build?.data.output?.properties?.[TEST_PRESENTATION_KEY] || 359 self.build?.data.input?.properties?.[TEST_PRESENTATION_KEY] || 360 {}, 361 warningGetter: () => 362 self.build?.buildOrStepInfraFailed 363 ? 'Test results displayed here are likely incomplete because some steps have infra failed.' 364 : '', 365 }); 366 }, 367 })); 368 369 export type BuildPageInstance = Instance<typeof BuildPage>; 370 export type BuildPageSnapshotIn = SnapshotIn<typeof BuildPage>; 371 export type BuildPageSnapshotOut = SnapshotOut<typeof BuildPage>;