blob: 33fa3fa1d126dc9b895c305999e581b55fd9ef65 [file] [log] [blame]
/**
* @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} 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 change: ChangeInfo,
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.change,
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,
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;
}
}