blob: d690fcb4d276d62c6ccdc399cb7631c7772ec287 [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 project_config for code owners.
*
* @doc https://gerrit.googlesource.com/plugins/code-owners/+/refs/heads/master/resources/Documentation/rest-api.md#get-code-owner-project-config
* @param {string} project
*/
getProjectConfig(project) {
return this.restApi.get(
`/projects/${encodeURIComponent(project)}/code_owners.project_config`
);
}
}
/**
* Service for the data layer used in the plugin UI.
*/
export class CodeOwnerService {
constructor(restApi, change, options = {}) {
this.restApi = restApi;
this.change = change;
this.options = {maxConcurrentRequests: 10, ...options};
this.codeOwnerApi = new CodeOwnerApi(restApi);
// fetched files and fetching status
this._fetchedOwners = new Map();
this._fetchStatus = FetchStatus.NOT_STARTED;
this._totalFetchCount = 0;
this.init();
}
/**
* Initial fetches.
*/
init() {
this.accountPromise = this.restApi.getLoggedIn().then(loggedIn => {
if (!loggedIn) {
return undefined;
}
return this.restApi.getAccount();
});
this.statusPromise = this.isCodeOwnerEnabled().then(enabled => {
if (!enabled) {
return {
patchsetNumber: 0,
enabled: false,
codeOwnerStatusMap: new Map(),
rawStatuses: [],
};
}
return this.codeOwnerApi
.listOwnerStatus(this.change._number)
.then(res => {
return {
enabled: true,
patchsetNumber: res.patch_set_number,
codeOwnerStatusMap: this._formatStatuses(
res.file_code_owner_statuses
),
rawStatuses: res.file_code_owner_statuses,
};
});
});
}
/**
* 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.
*/
getLoggedInUserInitialRole() {
return this.accountPromise.then(account => {
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);
}
getStatus() {
return this.statusPromise.then(res => {
if (res.enabled && !this.isOnLatestPatchset(res.patchsetNumber)) {
// status is outdated, abort and re-init
this.abort();
this.init();
return this.statusPromise;
}
return res;
});
}
areAllFilesApproved() {
return this.getStatus().then(({rawStatuses}) => {
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,
* }>
* }}
*/
getSuggestedOwners() {
// 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._fetchStatus === FetchStatus.NOT_STARTED
|| this._fetchStatus === FetchStatus.ABORT) {
this._fetchSuggestedOwners().then(() => {
this._fetchStatus = FetchStatus.FINISHED;
});
}
return this.getStatus().then(({codeOwnerStatusMap}) => {
return {
finished: this._fetchStatus === FetchStatus.FINISHED,
status: this._fetchStatus,
progress: this._totalFetchCount === 0 ?
`Loading suggested owners ...` :
`${this._fetchedOwners.size} out of ${this._totalFetchCount} files have returned suggested owners.`,
suggestions: this._groupFilesByOwners(codeOwnerStatusMap),
};
});
}
_fetchSuggestedOwners() {
// reset existing temporary storage
this._fetchedOwners = new Map();
this._fetchStatus = FetchStatus.FETCHING;
this._totalFetchCount = 0;
return this.getStatus()
.then(({codeOwnerStatusMap}) => {
// 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;
return this._batchFetchCodeOwners(filesToFetch);
});
}
_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) {
// 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 allFiles = [...this._fetchedOwners.keys()];
const ownersFilesMap = new Map();
const failedToFetchFiles = new Set();
for (let i = 0; i < allFiles.length; i++) {
const fileInfo = {
path: allFiles[i],
status: this._computeFileStatus(codeOwnerStatusMap, allFiles[i]),
};
// for files failed to fetch, add them to the special group
if (this._fetchedOwners.get(fileInfo.path).error) {
failedToFetchFiles.add(fileInfo);
continue;
}
// do not include files still in fetching
if (!this._fetchedOwners.get(fileInfo.path).owners) {
continue;
}
const owners = [...this._fetchedOwners.get(fileInfo.path).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}`;
}
/**
* Recursively fetches code owners for all files until finished.
*
* @param {!Array<string>} files
*/
_batchFetchCodeOwners(files) {
if (this._fetchStatus === FetchStatus.ABORT) {
return Promise.resolve(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.codeOwnerApi
.listOwnersForPath(
this.change.id,
filePath
)
.then(owners => {
// use Set to de-dup
this._fetchedOwners.get(filePath).owners = new Set(owners);
})
.catch(e => {
this._fetchedOwners.get(filePath).error = e;
})
);
}
}
const resPromise = Promise.all(batchRequests);
if (files.length > maxConcurrentRequests) {
return resPromise.then(() => {
return this._batchFetchCodeOwners(files.slice(maxConcurrentRequests));
});
}
return resPromise.then(() => this._fetchedOwners);
}
abort() {
this._fetchStatus = FetchStatus.ABORT;
this._fetchedOwners = new Map();
this._totalFetchCount = 0;
}
getProjectConfig() {
if (!this.getProjectConfigPromise) {
this.getProjectConfigPromise =
this.codeOwnerApi.getProjectConfig(this.change.project);
}
return this.getProjectConfigPromise;
}
isCodeOwnerEnabled() {
if (this.change.status === ChangeStatus.ABANDONED ||
this.change.status === ChangeStatus.MERGED) {
return Promise.resolve(false);
}
return this.getProjectConfig().then(config => {
if (config.status && config.status.disabled) {
return false;
}
if (config.status
&& config.status.disabled_branches
&& config.status.disabled_branches.includes(this.change.branch)) {
return false;
}
return true;
});
}
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,
});
}
return this.ownerService;
}
}