blob: cf9f1f57093e0fee21f2ed7f90f32bc09468da5d [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.
*/
/**
* All statuses returned for owner status.
*
* @enum
*/
export const OwnerStatus = {
INSUFFICIENT_REVIEWERS: 'INSUFFICIENT_REVIEWERS',
PENDING: 'PENDING',
APPROVED: 'APPROVED',
};
/**
* @enum
*/
const FetchStatus = {
NOT_STARTED: 0,
FETCHING: 1,
FINISHED: 2,
ABORT: 3,
};
/**
* Specifies status for a change. The same as ChangeStatus enum in gerrit
*
* @enum
*/
const ChangeStatus = {
ABANDONED: 'ABANDONED',
MERGED: 'MERGED',
NEW: 'NEW',
};
/**
* @enum
*/
const UserRole = {
ANONYMOUS: 'ANONYMOUS',
AUTHOR: 'AUTHOR',
CHANGE_OWNER: 'CHANGE_OWNER',
REVIEWER: 'REVIEWER',
CC: 'CC',
REMOVED_REVIEWER: 'REMOVED_REVIEWER',
OTHER: 'OTHER',
};
/**
* Responsible for communicating with the rest-api
*
* @see resources/Documentation/rest-api.md
*/
class CodeOwnerApi {
constructor(restApi) {
this.restApi = restApi;
}
/**
* Returns a promise fetching the owner statuses for all files within the change.
*
* @doc https://gerrit.googlesource.com/plugins/code-owners/+/refs/heads/master/resources/Documentation/rest-api.md#change-endpoints
* @param {string} changeId
*/
listOwnerStatus(changeId) {
return this.restApi.get(`/changes/${changeId}/code_owners.status`);
}
/**
* Returns a promise fetching the owners for a given path.
*
* @doc https://gerrit.googlesource.com/plugins/code-owners/+/refs/heads/master/resources/Documentation/rest-api.md#list-code-owners-for-path-in-branch
* @param {string} changeId
* @param {string} path
*/
listOwnersForPath(changeId, path) {
return this.restApi.get(
`/changes/${changeId}/revisions/current/code_owners` +
`/${encodeURIComponent(path)}?limit=5&o=DETAILS`
);
}
/**
* Returns a promise fetching the owners config for a given path.
*
* @doc https://gerrit.googlesource.com/plugins/code-owners/+/refs/heads/master/resources/Documentation/rest-api.md#branch-endpoints
* @param {string} project
* @param {string} branch
* @param {string} path
*/
getConfigForPath(project, branch, path) {
return this.restApi.get(
`/projects/${encodeURIComponent(project)}/` +
`branches/${encodeURIComponent(branch)}/` +
`code_owners.config/${encodeURIComponent(path)}`
);
}
/**
* Returns a promise fetching the owners config for a given branch.
*
* @doc https://gerrit.googlesource.com/plugins/code-owners/+/refs/heads/master/resources/Documentation/rest-api.md#branch-endpoints
* @param {string} project
* @param {string} branch
*/
async getBranchConfig(project, branch) {
const config = await this.restApi.get(
`/projects/${encodeURIComponent(project)}/` +
`branches/${encodeURIComponent(branch)}/` +
`code_owners.branch_config`
);
if (config.override_approval && !(config.override_approval instanceof Array)) {
// In the upcoming backend changes, the override_approval will be changed
// to array with (possible) multiple items.
// While this transition is in progress, the frontend supports both API -
// the old one and the new one.
return {...config, override_approval: [config.override_approval]};
}
return config;
}
}
/**
* Wrapper around codeOwnerApi, sends each requests only once and then cache
* the response. A new CodeOwnersCacheApi instance is created every time when a
* new change object is assigned.
* Gerrit never updates existing change object, but instead always assigns a new
* change object. Particularly, a new change object is assigned when a change
* is updated and user clicks reload toasts to see the updated change.
* As a result, the lifetime of a cache is the same as a lifetime of an assigned
* change object.
* Periodical cache invalidation can lead to inconsistency in UI, i.e.
* user can see the old reviewers list (reflects a state when a change was
* loaded) and code-owners status for the current reviewer list. To avoid
* this inconsistency, the cache doesn't invalidate.
*/
export class CodeOwnersCacheApi {
constructor(codeOwnerApi, change) {
this.codeOwnerApi = codeOwnerApi;
this.change = change;
this.promises = {};
}
_fetchOnce(cacheKey, asyncFn) {
if (!this.promises[cacheKey]) {
this.promises[cacheKey] = asyncFn();
}
return this.promises[cacheKey];
}
getAccount() {
return this._fetchOnce('getAccount', () => this._getAccount());
}
async _getAccount() {
const loggedIn = await this.codeOwnerApi.restApi.getLoggedIn();
if (!loggedIn) return undefined;
return await this.codeOwnerApi.restApi.getAccount();
}
listOwnerStatus() {
return this._fetchOnce('listOwnerStatus',
() => this.codeOwnerApi.listOwnerStatus(this.change._number));
}
getBranchConfig() {
return this._fetchOnce('getBranchConfig',
() => this.codeOwnerApi.getBranchConfig(this.change.project,
this.change.branch));
}
listOwnersForPath(path) {
return this._fetchOnce(`listOwnersForPath:${path}`,
() => this.codeOwnerApi.listOwnersForPath(this.change.id, path));
}
}
export class OwnersFetcher {
constructor(restApi, change, options) {
// fetched files and fetching status
this._fetchedOwners = new Map();
this._fetchStatus = FetchStatus.NOT_STARTED;
this._totalFetchCount = 0;
this.change = change;
this.options = options;
this.codeOwnerApi = new CodeOwnerApi(restApi);
}
getStatus() {
return this._fetchStatus;
}
getProgressString() {
return this._totalFetchCount === 0 ?
`Loading suggested owners ...` :
`${this._fetchedOwners.size} out of ${this._totalFetchCount} files have returned suggested owners.`;
}
getFiles() {
const result = [];
for (const [path, info] of this._fetchedOwners.entries()) {
result.push({path, info});
}
return result;
}
async fetchSuggestedOwners(codeOwnerStatusMap) {
// reset existing temporary storage
this._fetchedOwners = new Map();
this._fetchStatus = FetchStatus.FETCHING;
this._totalFetchCount = 0;
// only fetch those not approved yet
const filesGroupByStatus = [...codeOwnerStatusMap.keys()].reduce(
(list, file) => {
const status = codeOwnerStatusMap
.get(file).status;
if (status === OwnerStatus.INSUFFICIENT_REVIEWERS) {
list.missing.push(file);
} else if (status === OwnerStatus.PENDING) {
list.pending.push(file);
}
return list;
}
, {pending: [], missing: []});
// always fetch INSUFFICIENT_REVIEWERS first and then pending
const filesToFetch = filesGroupByStatus.missing
.concat(filesGroupByStatus.pending);
this._totalFetchCount = filesToFetch.length;
await this._batchFetchCodeOwners(filesToFetch);
this._fetchStatus = FetchStatus.FINISHED;
}
/**
* Recursively fetches code owners for all files until finished.
*
* @param {!Array<string>} files
*/
async _batchFetchCodeOwners(files) {
if (this._fetchStatus === FetchStatus.ABORT) {
return this._fetchedOwners;
}
const batchRequests = [];
const maxConcurrentRequests = this.options.maxConcurrentRequests;
for (let i = 0; i < maxConcurrentRequests; i++) {
const filePath = files[i];
if (filePath) {
this._fetchedOwners.set(filePath, {});
batchRequests.push(this._fetchOwnersForPath(this.change.id, filePath));
}
}
const resPromise = Promise.all(batchRequests);
await resPromise;
if (files.length > maxConcurrentRequests) {
return await this._batchFetchCodeOwners(
files.slice(maxConcurrentRequests));
}
return this._fetchedOwners;
}
async _fetchOwnersForPath(changeId, filePath) {
try {
const owners = await this.codeOwnerApi.listOwnersForPath(changeId,
filePath);
// In the upcoming backend changes, the api will return an object instead
// of array. While this transition is in progress, the frontend supports
// both API - the old one and the new one.
this._fetchedOwners.get(filePath).owners = new Set(
owners instanceof Array ? owners : owners.code_owners);
} catch (e) {
this._fetchedOwners.get(filePath).error = e;
}
}
abort() {
this._fetchStatus = FetchStatus.ABORT;
this._fetchedOwners = new Map();
this._totalFetchCount = 0;
}
}
/**
* Service for the data layer used in the plugin UI.
*/
export class CodeOwnerService {
constructor(restApi, change, options = {}) {
this.restApi = restApi;
this.change = change;
const codeOwnerApi = new CodeOwnerApi(restApi);
this.codeOwnerCacheApi = new CodeOwnersCacheApi(codeOwnerApi, change);
const fetcherOptions = {
maxConcurrentRequests: options.maxConcurrentRequests || 10,
};
this.ownersFetcher = new OwnersFetcher(restApi, change, fetcherOptions);
}
/**
* Prefetch data
*/
async prefetch() {
try {
await Promise.all([
this.codeOwnerCacheApi.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.codeOwnerCacheApi.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.isOnLatestPatchset(status.patchsetNumber)) {
// status is outdated, abort and re-init
this.abort();
this.prefetch();
return await this.codeOwnerCacheApi.getStatus();
}
return status;
}
async _getStatus() {
const enabled = await this.isCodeOwnerEnabled();
if (!enabled) {
return {
patchsetNumber: 0,
enabled: false,
codeOwnerStatusMap: new Map(),
rawStatuses: [],
};
}
const onwerStatus = await this.codeOwnerCacheApi.listOwnerStatus();
return {
enabled: true,
patchsetNumber: onwerStatus.patch_set_number,
codeOwnerStatusMap: this._formatStatuses(
onwerStatus.file_code_owner_statuses
),
rawStatuses: onwerStatus.file_code_owner_statuses,
};
}
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() {
const {codeOwnerStatusMap} = await this.getStatus();
// In case its aborted due to outdated patches
// should kick start the fetching again
// Note: we currently are not reusing the instance when switching changes,
// so if its `abort` due to different changes, the whole instance will be
// outdated and not used.
if (this.ownersFetcher.getStatus() === FetchStatus.NOT_STARTED
|| this.ownersFetcher.getStatus() === FetchStatus.ABORT) {
await this.ownersFetcher.fetchSuggestedOwners(codeOwnerStatusMap);
}
return {
finished: this.ownersFetcher.getStatus() === FetchStatus.FINISHED,
status: this.ownersFetcher.getStatus(),
progress: this.ownersFetcher.getProgressString(),
suggestions: this._groupFilesByOwners(codeOwnerStatusMap,
this.ownersFetcher.getFiles()),
};
}
async getSuggestedOwnersProgress() {
const {codeOwnerStatusMap} = await this.getStatus();
return {
finished: this.ownersFetcher.getStatus() === FetchStatus.FINISHED,
status: this.ownersFetcher.getStatus(),
progress: this.ownersFetcher.getProgressString(),
suggestions: this._groupFilesByOwners(codeOwnerStatusMap,
this.ownersFetcher.getFiles()),
};
}
_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;
}
_groupFilesByOwners(codeOwnerStatusMap, files) {
// Note: for renamed or moved files, they will have two entries in the map
// we will treat them as two entries when group as well
const ownersFilesMap = new Map();
const failedToFetchFiles = new Set();
for (const file of files) {
const fileInfo = {
path: file.path,
status: this._computeFileStatus(codeOwnerStatusMap, file.path),
};
// for files failed to fetch, add them to the special group
if (file.info.error) {
failedToFetchFiles.add(fileInfo);
continue;
}
// do not include files still in fetching
if (!file.info.owners) {
continue;
}
const owners = [...file.info.owners];
const ownersKey = owners
.map(owner => owner.account._account_id)
.sort()
.join(',');
ownersFilesMap.set(
ownersKey,
ownersFilesMap.get(ownersKey) || {files: [], owners}
);
ownersFilesMap.get(ownersKey).files.push(fileInfo);
}
const groupedItems = [];
for (const ownersKey of ownersFilesMap.keys()) {
const groupName = this.getGroupName(ownersFilesMap.get(ownersKey).files);
groupedItems.push({
groupName,
files: ownersFilesMap.get(ownersKey).files,
owners: ownersFilesMap.get(ownersKey).owners,
});
}
if (failedToFetchFiles.size > 0) {
const failedFiles = [...failedToFetchFiles];
groupedItems.push({
groupName: this.getGroupName(failedFiles),
files: failedFiles,
error: new Error(
'Failed to fetch code owner info. Try to refresh the page.'),
});
}
return groupedItems;
}
getGroupName(files) {
const fileName = files[0].path.split('/').pop();
return {
name: fileName,
prefix: files.length > 1 ? `+ ${files.length - 1} more` : '',
};
}
isOnLatestPatchset(patchsetId) {
const latestRevision = this.change.revisions[this.change.current_revision];
return `${latestRevision._number}` === `${patchsetId}`;
}
abort() {
this.ownersFetcher.abort();
const codeOwnerApi = new CodeOwnerApi(this.restApi);
this.codeOwnerCacheApi = new CodeOwnersCacheApi(codeOwnerApi, change);
}
async getBranchConfig() {
return this.codeOwnerCacheApi.getBranchConfig();
}
async isCodeOwnerEnabled() {
if (this.change.status === ChangeStatus.ABANDONED ||
this.change.status === ChangeStatus.MERGED) {
return false;
}
const config = await this.codeOwnerCacheApi.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;
}
}