| /** |
| * @license |
| * Copyright (C) 2020 The Android Open Source Project |
| * |
| * 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 { |
| SuggestionsType, |
| BestSuggestionsLimit, |
| AllSuggestionsLimit, |
| UserRole, |
| Status, |
| FileStatus, |
| } from './code-owners-model'; |
| import {OwnersProvider, FetchStatus} from './code-owners-fetcher'; |
| import { |
| CodeOwnersApi, |
| CodeOwnersCacheApi, |
| FetchedFile, |
| FetchedOwner, |
| FileCodeOwnerStatusInfo, |
| OwnerStatus, |
| } from './code-owners-api'; |
| import {RestPluginApi} from '@gerritcodereview/typescript-api/rest'; |
| import { |
| AccountDetailInfo, |
| AccountInfo, |
| ChangeInfo, |
| } from '@gerritcodereview/typescript-api/rest-api'; |
| |
| /** |
| * Specifies status for a change. The same as ChangeStatus enum in gerrit |
| * |
| * @enum |
| */ |
| const ChangeStatus = { |
| ABANDONED: 'ABANDONED', |
| MERGED: 'MERGED', |
| NEW: 'NEW', |
| }; |
| |
| let ownerService: CodeOwnerService | undefined; |
| |
| interface CodeOwnerServiceOptions { |
| maxConcurrentRequests?: number; |
| } |
| |
| function noAwait(_promise: Promise<unknown>) {} |
| |
| /** |
| * Service for the data layer used in the plugin UI. |
| */ |
| export class CodeOwnerService { |
| private codeOwnersCacheApi: CodeOwnersCacheApi; |
| |
| private ownersProviders: Map<SuggestionsType, OwnersProvider>; |
| |
| constructor( |
| readonly restApi: RestPluginApi, |
| readonly change: ChangeInfo, |
| options: CodeOwnerServiceOptions = {} |
| ) { |
| const codeOwnersApi = new CodeOwnersApi(restApi); |
| this.codeOwnersCacheApi = new CodeOwnersCacheApi(codeOwnersApi, change); |
| |
| const providerOptions = { |
| maxConcurrentRequests: options.maxConcurrentRequests || 10, |
| }; |
| this.ownersProviders = new Map(); |
| this.ownersProviders.set( |
| SuggestionsType.BEST_SUGGESTIONS, |
| new OwnersProvider(restApi, change, { |
| ...providerOptions, |
| ownersLimit: BestSuggestionsLimit, |
| }) |
| ); |
| this.ownersProviders.set( |
| SuggestionsType.ALL_SUGGESTIONS, |
| new OwnersProvider(restApi, change, { |
| ...providerOptions, |
| ownersLimit: AllSuggestionsLimit, |
| }) |
| ); |
| } |
| |
| /** |
| * Fetch the account. |
| */ |
| getAccount(): Promise<AccountDetailInfo | undefined> { |
| return this.codeOwnersCacheApi.getAccount(); |
| } |
| |
| /** |
| * Prefetch data |
| */ |
| async prefetch() { |
| try { |
| await Promise.all([this.getAccount(), this.getStatus()]); |
| } catch { |
| // Ignore any errors during prefetch. |
| // The same call from a different place throws the same exception |
| // again. The CodeOwnerService is not responsible for error processing. |
| } |
| } |
| |
| /** |
| * Returns the role of the current user. The returned value reflects the |
| * role of the user at the time when the change is loaded. |
| * For example, if a user removes themselves as a reviewer, the returned |
| * role 'REVIEWER' remains unchanged until the change view is reloaded. |
| */ |
| async getLoggedInUserInitialRole(): Promise<UserRole> { |
| const account = await this.getAccount(); |
| if (!account) { |
| return UserRole.ANONYMOUS; |
| } |
| const change = this.change; |
| if ( |
| change.revisions && |
| change.current_revision && |
| change.revisions[change.current_revision] |
| ) { |
| const commit = change.revisions[change.current_revision].commit; |
| if ( |
| commit && |
| commit.author && |
| account.email && |
| commit.author.email === account.email |
| ) { |
| return UserRole.AUTHOR; |
| } |
| } |
| if (change.owner._account_id === account._account_id) { |
| return UserRole.CHANGE_OWNER; |
| } |
| if (change.reviewers) { |
| if (this.accountInReviewers(change.reviewers.REVIEWER, account)) { |
| return UserRole.REVIEWER; |
| } else if (this.accountInReviewers(change.reviewers.CC, account)) { |
| return UserRole.CC; |
| } else if (this.accountInReviewers(change.reviewers.REMOVED, account)) { |
| return UserRole.REMOVED_REVIEWER; |
| } |
| } |
| return UserRole.OTHER; |
| } |
| |
| private accountInReviewers( |
| reviewers: AccountInfo[] | undefined, |
| account: AccountDetailInfo |
| ) { |
| if (!reviewers) { |
| return false; |
| } |
| return reviewers.some( |
| reviewer => reviewer._account_id === account._account_id |
| ); |
| } |
| |
| async getStatus(): Promise<Status> { |
| const status = await this.getStatusImpl(); |
| if (status.enabled && this.isOnOlderPatchset(status.patchsetNumber)) { |
| // status is returned for an older patchset. Abort, re-init and refetch |
| // new status - it is expected, that after several retry a status |
| // for the newest patchset is returned |
| this.reset(); |
| noAwait(this.prefetch()); |
| return await this.getStatus(); |
| } |
| return status; |
| } |
| |
| private async getStatusImpl() { |
| const enabled = await this.isCodeOwnerEnabled(); |
| if (!enabled) { |
| return { |
| patchsetNumber: 0, |
| enabled: false, |
| codeOwnerStatusMap: new Map<string, FileStatus>(), |
| rawStatuses: [], |
| newerPatchsetUploaded: false, |
| }; |
| } |
| |
| const ownerStatus = await this.codeOwnersCacheApi.listOwnerStatus(); |
| |
| return { |
| enabled: true, |
| patchsetNumber: ownerStatus.patch_set_number, |
| codeOwnerStatusMap: this.formatStatuses( |
| ownerStatus.file_code_owner_statuses |
| ), |
| rawStatuses: ownerStatus.file_code_owner_statuses, |
| newerPatchsetUploaded: this.isOnNewerPatchset( |
| ownerStatus.patch_set_number |
| ), |
| accounts: ownerStatus.accounts, |
| }; |
| } |
| |
| async areAllFilesApproved() { |
| const {rawStatuses} = await this.getStatus(); |
| return !rawStatuses.some(status => { |
| const oldPathStatus = status.old_path_status; |
| const newPathStatus = status.new_path_status; |
| // For deleted files, no new_path_status exists |
| return ( |
| (newPathStatus && newPathStatus.status !== OwnerStatus.APPROVED) || |
| (oldPathStatus && oldPathStatus.status !== OwnerStatus.APPROVED) |
| ); |
| }); |
| } |
| |
| private ownersProvider(suggestionsType: SuggestionsType) { |
| return this.ownersProviders.get(suggestionsType)!; |
| } |
| |
| /** |
| * Gets which files are owned by the given user. |
| */ |
| async getOwnedPaths() { |
| const enabled = await this.isCodeOwnerEnabled(); |
| if (!enabled) { |
| return Promise.resolve(undefined); |
| } |
| return this.codeOwnersCacheApi.listOwnedPaths(); |
| } |
| |
| /** |
| * Gets owner suggestions. |
| */ |
| async getSuggestedOwners(suggestionsType: SuggestionsType) { |
| const {codeOwnerStatusMap} = await this.getStatus(); |
| const ownersProvider = this.ownersProvider(suggestionsType); |
| |
| await ownersProvider!.fetchSuggestedOwners(codeOwnerStatusMap); |
| |
| return { |
| finished: ownersProvider.getStatus() === FetchStatus.FINISHED, |
| status: ownersProvider.getStatus(), |
| progress: ownersProvider.getProgressString(), |
| files: this.getFilesWithStatuses( |
| codeOwnerStatusMap, |
| ownersProvider.getFiles() |
| ), |
| }; |
| } |
| |
| async getSuggestedOwnersProgress(suggestionsType: SuggestionsType) { |
| const {codeOwnerStatusMap} = await this.getStatus(); |
| const ownersProvider = this.ownersProvider(suggestionsType); |
| return { |
| finished: ownersProvider.getStatus() === FetchStatus.FINISHED, |
| status: ownersProvider.getStatus(), |
| progress: ownersProvider.getProgressString(), |
| files: this.getFilesWithStatuses( |
| codeOwnerStatusMap, |
| ownersProvider.getFiles() |
| ), |
| }; |
| } |
| |
| pauseSuggestedOwnersLoading(suggestionsType: SuggestionsType) { |
| this.ownersProvider(suggestionsType).pause(); |
| } |
| |
| resumeSuggestedOwnersLoading(suggestionsType: SuggestionsType) { |
| this.ownersProvider(suggestionsType).resume(); |
| } |
| |
| private formatStatuses(statuses?: Array<FileCodeOwnerStatusInfo>) { |
| // convert the array of statuses to map between file path -> status |
| return (statuses ?? []).reduce((prev, cur) => { |
| const newPathStatus = cur.new_path_status; |
| const oldPathStatus = cur.old_path_status; |
| if (oldPathStatus) { |
| prev.set(oldPathStatus.path, { |
| changeType: cur.change_type, |
| status: oldPathStatus.status, |
| newPath: newPathStatus ? newPathStatus.path : null, |
| reasons: oldPathStatus.reasons, |
| }); |
| } |
| if (newPathStatus) { |
| prev.set(newPathStatus.path, { |
| changeType: cur.change_type, |
| status: newPathStatus.status, |
| oldPath: oldPathStatus ? oldPathStatus.path : null, |
| reasons: newPathStatus.reasons, |
| }); |
| } |
| return prev; |
| }, new Map<string, FileStatus>()); |
| } |
| |
| private computeFileStatus( |
| fileStatusMap: Map<string, FileStatus>, |
| path: string |
| ) { |
| // empty for modified files and old-name files |
| // Show `Renamed` for renamed file |
| const status = fileStatusMap.get(path); |
| if (status && status.oldPath) { |
| return 'Renamed'; |
| } |
| return; |
| } |
| |
| private getFilesWithStatuses( |
| codeOwnerStatusMap: Map<string, FileStatus>, |
| files: Array<{ |
| path: string; |
| info: FetchedOwner; |
| }> |
| ): Array<FetchedFile> { |
| return files.map(file => { |
| return { |
| path: file.path, |
| info: file.info, |
| status: this.computeFileStatus(codeOwnerStatusMap, file.path), |
| }; |
| }); |
| } |
| |
| private isOnNewerPatchset(patchsetId: number) { |
| if (this.change.current_revision === undefined) return false; |
| const latestRevision = this.change.revisions![this.change.current_revision]; |
| if (latestRevision._number === 'edit') { |
| return false; |
| } |
| return patchsetId > latestRevision._number; |
| } |
| |
| private isOnOlderPatchset(patchsetId: number) { |
| if (this.change.current_revision === undefined) return false; |
| const latestRevision = this.change.revisions![this.change.current_revision]; |
| if (latestRevision._number === 'edit') { |
| return false; |
| } |
| return patchsetId < latestRevision._number; |
| } |
| |
| reset() { |
| for (const provider of Object.values(this.ownersProviders)) { |
| provider.reset(); |
| } |
| const codeOwnersApi = new CodeOwnersApi(this.restApi); |
| this.codeOwnersCacheApi = new CodeOwnersCacheApi( |
| codeOwnersApi, |
| this.change |
| ); |
| } |
| |
| async getBranchConfig() { |
| return this.codeOwnersCacheApi.getBranchConfig(); |
| } |
| |
| async isCodeOwnerEnabled() { |
| if ( |
| this.change.status === ChangeStatus.ABANDONED || |
| this.change.status === ChangeStatus.MERGED |
| ) { |
| return false; |
| } |
| const config = await this.getBranchConfig(); |
| return config && !config.disabled; |
| } |
| |
| static getOwnerService(restApi: RestPluginApi, change: ChangeInfo) { |
| if (!ownerService || ownerService.change !== change) { |
| ownerService = new CodeOwnerService(restApi, change, { |
| // Chrome has a limit of 6 connections per host name, and a max of 10 connections. |
| maxConcurrentRequests: 6, |
| }); |
| ownerService.prefetch(); |
| } |
| return ownerService; |
| } |
| |
| // Only used for tests |
| static reset() { |
| if (!ownerService) return; |
| ownerService.reset(); |
| ownerService = undefined; |
| } |
| } |