blob: 17b16ba7899fc4a39aeb197bff6e106cd2512113 [file] [log] [blame]
/**
* @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
),
};
}
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,
});
}
if (newPathStatus) {
prev.set(newPathStatus.path, {
changeType: cur.change_type,
status: newPathStatus.status,
oldPath: oldPathStatus ? oldPathStatus.path : null,
});
}
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];
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];
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;
}
}