blob: 3a5cbdc84c69c42313685d758f283eae1c0f1186 [file] [log] [blame] [edit]
/**
* @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} from './code-owners-model.js';
import {OwnersProvider, OwnerStatus, FetchStatus} from './code-owners-fetcher.js';
import {CodeOwnersApi, CodeOwnersCacheApi} from './code-owners-api.js';
/**
* Specifies status for a change. The same as ChangeStatus enum in gerrit
*
* @enum
*/
const ChangeStatus = {
ABANDONED: 'ABANDONED',
MERGED: 'MERGED',
NEW: 'NEW',
};
/**
* Service for the data layer used in the plugin UI.
*/
export class CodeOwnerService {
constructor(restApi, change, options = {}) {
this.restApi = restApi;
this.change = change;
const codeOwnersApi = new CodeOwnersApi(restApi);
this.codeOwnersCacheApi = new CodeOwnersCacheApi(codeOwnersApi, change);
const providerOptions = {
maxConcurrentRequests: options.maxConcurrentRequests || 10,
};
this.ownersProviders = {
[SuggestionsType.BEST_SUGGESTIONS]: new OwnersProvider(restApi, change, {
...providerOptions,
ownersLimit: BestSuggestionsLimit,
}),
[SuggestionsType.ALL_SUGGESTIONS]: new OwnersProvider(restApi, change, {
...providerOptions,
ownersLimit: AllSuggestionsLimit,
}),
};
}
/**
* Prefetch data
*/
async prefetch() {
try {
await Promise.all([
this.codeOwnersCacheApi.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() {
const account = await this.codeOwnersCacheApi.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;
}
_accountInReviewers(reviewers, account) {
if (!reviewers) {
return false;
}
return reviewers.some(reviewer =>
reviewer._account_id === account._account_id);
}
async getStatus() {
const status = await this._getStatus();
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();
this.prefetch();
return await this.getStatus();
}
return status;
}
async _getStatus() {
const enabled = await this.isCodeOwnerEnabled();
if (!enabled) {
return {
patchsetNumber: 0,
enabled: false,
codeOwnerStatusMap: new Map(),
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),
};
}
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);
});
}
/**
* Gets owner suggestions.
*
* @returns {{
* finished?: boolean,
* progress?: string,
* suggestions: Array<{
* groupName: {
* name: string,
* prefix: string
* },
* error?: Error,
* owners?: Array,
* files: Array,
* }>
* }}
*/
async getSuggestedOwners(suggestionsType) {
const {codeOwnerStatusMap} = await this.getStatus();
const ownersProvider = this.ownersProviders[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) {
const {codeOwnerStatusMap} = await this.getStatus();
const ownersProvider = this.ownersProviders[suggestionsType];
return {
finished: ownersProvider.getStatus() === FetchStatus.FINISHED,
status: ownersProvider.getStatus(),
progress: ownersProvider.getProgressString(),
files: this._getFilesWithStatuses(codeOwnerStatusMap,
ownersProvider.getFiles()),
};
}
pauseSuggestedOwnersLoading(suggestionsType) {
this.ownersProviders[suggestionsType].pause();
}
resumeSuggestedOwnersLoading(suggestionsType) {
this.ownersProviders[suggestionsType].resume();
}
_formatStatuses(statuses) {
// 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,
});
}
if (newPathStatus) {
prev.set(newPathStatus.path, {
changeType: cur.change_type,
status: newPathStatus.status,
oldPath: oldPathStatus ? oldPathStatus.path : null,
});
}
return prev;
}, new Map());
}
_computeFileStatus(fileStatusMap, path) {
// empty for modified files and old-name files
// Show `Renamed` for renamed file
const status = fileStatusMap.get(path);
if (status.oldPath) {
return 'Renamed';
}
return;
}
_getFilesWithStatuses(codeOwnerStatusMap, files) {
return files.map(file => {
return {
path: file.path,
info: file.info,
status: this._computeFileStatus(codeOwnerStatusMap, file.path),
};
});
}
_isOnNewerPatchset(patchsetId) {
const latestRevision = this.change.revisions[this.change.current_revision];
return patchsetId > latestRevision._number;
}
_isOnOlderPatchset(patchsetId) {
const latestRevision = this.change.revisions[this.change.current_revision];
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.codeOwnersCacheApi.getBranchConfig();
return config && !config.disabled;
}
static getOwnerService(restApi, change) {
if (!this.ownerService || this.ownerService.change !== change) {
this.ownerService = new CodeOwnerService(restApi, change, {
// Chrome has a limit of 6 connections per host name, and a max of 10 connections.
maxConcurrentRequests: 6,
});
this.ownerService.prefetch();
}
return this.ownerService;
}
}