|  | /** | 
|  | * @license | 
|  | * Copyright (C) 2021 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 { | 
|  | ChangeInfo, | 
|  | NumericChangeId, | 
|  | } from '@gerritcodereview/typescript-api/rest-api.js'; | 
|  | import {RestPluginApi} from '@gerritcodereview/typescript-api/rest.js'; | 
|  | import {CodeOwnersApi, FetchedOwner, OwnerStatus} from './code-owners-api.js'; | 
|  | import {FileStatus} from './code-owners-model.js'; | 
|  |  | 
|  | /** | 
|  | * @enum | 
|  | */ | 
|  | export const FetchStatus = { | 
|  | /** Fetch hasn't been started */ | 
|  | NOT_STARTED: 0, | 
|  | /** | 
|  | * Fetch has been started, but not all files has been finished. | 
|  | * Pausing during fetching doesn't change state. | 
|  | */ | 
|  | FETCHING: 1, | 
|  | /** | 
|  | * All owners has been loaded. resume/pause call doesn't change state. | 
|  | */ | 
|  | FINISHED: 2, | 
|  | }; | 
|  |  | 
|  | /** | 
|  | * Fetch owners for files. The class fetches owners in parallel and allows to | 
|  | * pause/resume fetch. | 
|  | */ | 
|  | class OwnersFetcher { | 
|  | private paused = true; | 
|  |  | 
|  | private fetchedOwners = new Map<string, FetchedOwner>(); | 
|  |  | 
|  | private pausedFilesFetcher: Array<() => void> = []; | 
|  |  | 
|  | private fetchFilesPromises: Array<Promise<void>> = []; | 
|  |  | 
|  | /** | 
|  | * Creates a fetcher in paused state. Actual fetching starts after resume() | 
|  | * is called. | 
|  | */ | 
|  | constructor( | 
|  | private readonly codeOwnerApi: CodeOwnersApi, | 
|  | private readonly changeId: NumericChangeId, | 
|  | private readonly filesToFetch: Array<string>, | 
|  | private readonly ownersLimit: number, | 
|  | maxConcurrentRequest: number | 
|  | ) { | 
|  | for (let i = 0; i < maxConcurrentRequest; i++) { | 
|  | this.fetchFilesPromises.push(this.fetchFiles()); | 
|  | } | 
|  | } | 
|  |  | 
|  | private async fetchFiles() { | 
|  | for (;;) { | 
|  | const filePath = await this.getNextFilePath(); | 
|  | if (!filePath) return; | 
|  | try { | 
|  | this.fetchedOwners.set(filePath, { | 
|  | owners: await this.codeOwnerApi.listOwnersForPath( | 
|  | this.changeId, | 
|  | filePath, | 
|  | this.ownersLimit | 
|  | ), | 
|  | }); | 
|  | } catch (error) { | 
|  | this.fetchedOwners.set(filePath, {error}); | 
|  | } | 
|  | } | 
|  | } | 
|  |  | 
|  | private async getNextFilePath() { | 
|  | if (this.paused) { | 
|  | await new Promise<void>(resolve => this.pausedFilesFetcher.push(resolve)); | 
|  | } | 
|  | if (this.filesToFetch.length === 0) return null; | 
|  | return this.filesToFetch.splice(0, 1)[0]; | 
|  | } | 
|  |  | 
|  | async waitFetchComplete() { | 
|  | // Simplified polyfill allSettled | 
|  | // TODO: Replace with allSettled once minimal node version is updated. | 
|  | await Promise.all( | 
|  | this.fetchFilesPromises.map(p => | 
|  | p.then(() => 'succeeded').catch(_reason => 'rejected') | 
|  | ) | 
|  | ); | 
|  | } | 
|  |  | 
|  | resume() { | 
|  | if (!this.paused) return; | 
|  | this.paused = false; | 
|  | for (const fetcher of this.pausedFilesFetcher.splice( | 
|  | 0, | 
|  | this.pausedFilesFetcher.length | 
|  | )) { | 
|  | fetcher(); | 
|  | } | 
|  | } | 
|  |  | 
|  | pause() { | 
|  | this.paused = true; | 
|  | } | 
|  |  | 
|  | getFetchedOwners() { | 
|  | return this.fetchedOwners; | 
|  | } | 
|  |  | 
|  | getFiles(): Array<{path: string; info: FetchedOwner}> { | 
|  | const result = []; | 
|  | for (const [path, info] of this.fetchedOwners.entries()) { | 
|  | result.push({path, info}); | 
|  | } | 
|  | return result; | 
|  | } | 
|  | } | 
|  |  | 
|  | export class OwnersProvider { | 
|  | private status = FetchStatus.NOT_STARTED; | 
|  |  | 
|  | private totalFetchCount = 0; | 
|  |  | 
|  | private codeOwnerApi: CodeOwnersApi; | 
|  |  | 
|  | private ownersFetcher?: OwnersFetcher; | 
|  |  | 
|  | constructor( | 
|  | restApi: RestPluginApi, | 
|  | private readonly change: ChangeInfo, | 
|  | private readonly options: { | 
|  | maxConcurrentRequests: number; | 
|  | ownersLimit: number; | 
|  | } | 
|  | ) { | 
|  | this.codeOwnerApi = new CodeOwnersApi(restApi); | 
|  | } | 
|  |  | 
|  | getStatus() { | 
|  | return this.status; | 
|  | } | 
|  |  | 
|  | getProgressString() { | 
|  | return !this.ownersFetcher || this.totalFetchCount === 0 | 
|  | ? 'Loading suggested owners ...' | 
|  | : `${this.ownersFetcher.getFetchedOwners().size} out of ` + | 
|  | `${this.totalFetchCount} files have returned suggested owners.`; | 
|  | } | 
|  |  | 
|  | getFiles() { | 
|  | if (!this.ownersFetcher) return []; | 
|  | return this.ownersFetcher.getFiles(); | 
|  | } | 
|  |  | 
|  | async fetchSuggestedOwners(codeOwnerStatusMap: Map<string, FileStatus>) { | 
|  | if (this.status !== FetchStatus.NOT_STARTED) { | 
|  | await this.ownersFetcher!.waitFetchComplete(); | 
|  | return; | 
|  | } | 
|  | const filesToFetch = this._getFilesToFetch(codeOwnerStatusMap); | 
|  | this.totalFetchCount = filesToFetch.length; | 
|  | this.ownersFetcher = new OwnersFetcher( | 
|  | this.codeOwnerApi, | 
|  | this.change._number, | 
|  | filesToFetch, | 
|  | this.options.ownersLimit, | 
|  | this.options.maxConcurrentRequests | 
|  | ); | 
|  | this.status = FetchStatus.FETCHING; | 
|  | this.ownersFetcher.resume(); | 
|  | await this.ownersFetcher.waitFetchComplete(); | 
|  | this.status = FetchStatus.FINISHED; | 
|  | } | 
|  |  | 
|  | _getFilesToFetch(codeOwnerStatusMap: Map<string, FileStatus>) { | 
|  | // only fetch those not approved yet | 
|  | const filesGroupByStatus = [...codeOwnerStatusMap.entries()].reduce( | 
|  | (list, [file, fileInfo]) => { | 
|  | if (list[fileInfo.status]) list[fileInfo.status].push(file); | 
|  | return list; | 
|  | }, | 
|  | { | 
|  | [OwnerStatus.PENDING]: [] as Array<string>, | 
|  | [OwnerStatus.INSUFFICIENT_REVIEWERS]: [] as Array<string>, | 
|  | [OwnerStatus.APPROVED]: [] as Array<string>, | 
|  | } | 
|  | ); | 
|  | // always fetch INSUFFICIENT_REVIEWERS first, then pending and then approved | 
|  | return filesGroupByStatus[OwnerStatus.INSUFFICIENT_REVIEWERS] | 
|  | .concat(filesGroupByStatus[OwnerStatus.PENDING]) | 
|  | .concat(filesGroupByStatus[OwnerStatus.APPROVED]); | 
|  | } | 
|  |  | 
|  | pause() { | 
|  | if (!this.ownersFetcher) return; | 
|  | this.ownersFetcher.pause(); | 
|  | } | 
|  |  | 
|  | resume() { | 
|  | if (!this.ownersFetcher) return; | 
|  | this.ownersFetcher.resume(); | 
|  | } | 
|  |  | 
|  | reset() { | 
|  | this.totalFetchCount = 0; | 
|  | this.ownersFetcher = undefined; | 
|  | this.status = FetchStatus.NOT_STARTED; | 
|  | } | 
|  | } |