| /** |
| * @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', |
| }; |
| |
| // TODO: Try to remove it. The ResponseError and getErrorMessage duplicates |
| // code from the gr-plugin-rest-api.ts. This code is required because |
| // we want custom error processing in some functions. For details see |
| // the original gr-plugin-rest-api.ts file/ |
| |
| class ResponseError extends Error { |
| constructor(response) { |
| super(); |
| this.response = response; |
| } |
| } |
| |
| async function getErrorMessage(response) { |
| const text = await response.text(); |
| return text ? |
| `${response.status}: ${text}` : |
| `${response.status}`; |
| } |
| |
| /** |
| * 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 errFn = (response, error) => { |
| if (error) throw error; |
| if (response) throw new ResponseError(response); |
| throw new Error('Generic REST API error'); |
| } |
| try { |
| const config = await this.restApi.send( |
| 'GET', |
| `/projects/${encodeURIComponent(project)}/` + |
| `branches/${encodeURIComponent(branch)}/` + |
| `code_owners.branch_config`, |
| undefined, |
| errFn |
| ); |
| 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; |
| } catch(err) { |
| if (err instanceof ResponseError) { |
| if (err.response.status === 404) { |
| // The 404 error means that the branch doesn't exist and |
| // the plugin should be disabled. |
| return {disabled: true}; |
| } |
| return getErrorMessage(err.response).then(msg => { |
| throw new Error(msg); |
| }); |
| } |
| throw err; |
| } |
| } |
| } |
| |
| /** |
| * 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; |
| } |
| } |
| |