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>;