| /** |
| * @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', |
| }; |
| |
| /** |
| * New or old path for renamed files. |
| * |
| * @enum |
| */ |
| export const RenamedFileChip = { |
| NEW: 'Renamed - New', |
| OLD: 'Renamed - Old', |
| }; |
| |
| /** |
| * 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. |
| * |
| * @param {string} changeId |
| */ |
| listOwnerStatus(changeId) { |
| return this.restApi.get(`/changes/${changeId}/code_owners.status`); |
| } |
| |
| /** |
| * Returns a promise fetching the owners for a given path. |
| * |
| * @param {string} project |
| * @param {string} branch |
| * @param {string} path |
| */ |
| listOwnersForPath(project, branch, path) { |
| return this.restApi.get( |
| `/projects/${project}/branches/${branch}/code_owners/${encodeURIComponent(path)}?limit=5&o=DETAILS` |
| ); |
| } |
| |
| /** |
| * Returns a promise fetching the owners config for a given path. |
| * |
| * @param {string} project |
| * @param {string} branch |
| * @param {string} path |
| */ |
| getConfigForPath(project, branch, path) { |
| // TODO: may need to handle the 204 when does not exist |
| return this.restApi.get( |
| `/projects/${project}/branches/${branch}/code_owners.config/${path}` |
| ); |
| } |
| } |
| |
| // TODO(taoalpha): error flows |
| /** |
| * 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); |
| |
| this.init(); |
| } |
| |
| /** |
| * Initial fetches. |
| */ |
| init() { |
| this.statusPromise = this.codeOwnerApi |
| .listOwnerStatus(this.change._number) |
| .then(res => { |
| return { |
| patchsetNumber: res.patch_set_number, |
| codeOwnerStatusMap: this._formatStatuses( |
| res.file_code_owner_statuses |
| ), |
| rawStatuses: res.file_code_owner_statuses, |
| }; |
| }); |
| } |
| |
| getStatus() { |
| return this.statusPromise.then(res => { |
| if (!this.isOnLatestPatchset(res.patchsetNumber)) { |
| // status is outdated, re-init |
| this.init(); |
| return this.statusPromise; |
| } |
| return res; |
| }); |
| } |
| |
| /** |
| * Gets owner suggestions. |
| * |
| * @param {!Object} opt |
| */ |
| getSuggestedOwners(opt = {}) { |
| return this.getStatus() |
| .then(({codeOwnerStatusMap}) => { |
| // only fetch those not approved yet |
| let filesToFetchOwners = [...codeOwnerStatusMap.keys()].filter( |
| file => codeOwnerStatusMap.get(file).status !== OwnerStatus.APPROVED |
| ); |
| return this.batchFetchCodeOwners(filesToFetchOwners) |
| .then(ownersMap => |
| this._groupFilesByOwners(ownersMap, codeOwnerStatusMap) |
| ); |
| }); |
| } |
| |
| _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 |
| // `renamed - old` for renamed files (old path) |
| // `renamed - new` for renamed files (new path) |
| const status = fileStatusMap.get(path); |
| if (status.newPath) { |
| return RenamedFileChip.OLD; |
| } |
| if (status.oldPath) { |
| return RenamedFileChip.NEW; |
| } |
| return; |
| } |
| |
| _groupFilesByOwners(fileOwnersMap, 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 = Object.keys(fileOwnersMap); |
| 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 (fileOwnersMap[fileInfo.path].error) { |
| failedToFetchFiles.add(fileInfo); |
| continue; |
| } |
| |
| const owners = [...fileOwnersMap[fileInfo.path].owners]; |
| const ownersKey = owners |
| .map(account => 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 owner info") |
| }); |
| } |
| |
| return groupedItems; |
| } |
| |
| getGroupName(files) { |
| const fileName = files[0].path.split('/').pop(); |
| return `${ |
| files.length > 1 ? `(${files.length} files) ${fileName}, ...` : fileName |
| }`; |
| } |
| |
| /** |
| * Returns a promise with whether status is for latest patchset or not. |
| */ |
| isStatusOnLatestPatchset() { |
| return this.statusPromise.then(({patch_set_id}) => { |
| return this.isOnLatestPatchset(patch_set_id); |
| }); |
| } |
| |
| 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, ownersMap = {}) { |
| const batchRequests = []; |
| const maxConcurrentRequests = this.options.maxConcurrentRequests; |
| for (let i = 0; i < maxConcurrentRequests; i++) { |
| const filePath = files[i]; |
| if (filePath) { |
| ownersMap[filePath] = {}; |
| batchRequests.push( |
| this.codeOwnerApi |
| .listOwnersForPath( |
| this.change.project, |
| this.change.branch, |
| filePath |
| ) |
| .then(owners => { |
| // use Set to de-dup |
| ownersMap[filePath].owners = new Set(owners); |
| }) |
| .catch(e => { |
| ownersMap[filePath].error = e; |
| }) |
| ); |
| } |
| } |
| const resPromise = Promise.all(batchRequests); |
| if (files.length > maxConcurrentRequests) { |
| return resPromise.then(() => |
| this.batchFetchCodeOwners(files.slice(maxConcurrentRequests), ownersMap) |
| ); |
| } |
| return resPromise.then(() => ownersMap); |
| } |
| |
| static getOwnerService(restApi, change) { |
| if (!this.ownerService || this.ownerService.change !== change) { |
| this.ownerService = new CodeOwnerService(restApi, change, { |
| maxConcurrentRequests: 2, |
| }); |
| } |
| return this.ownerService; |
| } |
| } |