| /** |
| * @license |
| * Copyright (C) 2021 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 {HttpMethod, RestPluginApi} from '@gerritcodereview/typescript-api/rest'; |
| import { |
| AccountDetailInfo, |
| AccountInfo, |
| BranchName, |
| ChangeInfo, |
| RepoName, |
| } from '@gerritcodereview/typescript-api/rest-api'; |
| |
| // 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(readonly response: Response) { |
| super(); |
| } |
| } |
| |
| export class ServerConfigurationError extends Error { |
| constructor(msg: string) { |
| super(msg); |
| } |
| } |
| |
| async function getErrorMessage(response: Response) { |
| const text = await response.text(); |
| return text ? `${response.status}: ${text}` : `${response.status}`; |
| } |
| |
| export enum MergeCommitStrategy { |
| ALL_CHANGED_FILES = 'ALL_CHANGED_FILES', |
| FILES_WITH_CONFLICT_RESOLUTION = 'FILES_WITH_CONFLICT_RESOLUTION', |
| } |
| |
| export enum FallbackCodeOwners { |
| NONE = 'NONE', |
| ALL_USERS = 'ALL_USERS', |
| } |
| |
| export interface GeneralInfo { |
| file_extension?: string; |
| merge_commit_strategy: MergeCommitStrategy; |
| implicit_approvals?: boolean; |
| override_info_url?: string; |
| invalid_code_owner_config_info_url?: string; |
| fallback_code_owners: FallbackCodeOwners; |
| } |
| |
| export interface CodeOwnerBranchConfigInfo { |
| general?: GeneralInfo; |
| disabled?: boolean; |
| backend_id?: string; |
| required_approval?: Array<RequiredApprovalInfo>; |
| override_approval?: Array<RequiredApprovalInfo>; |
| } |
| |
| export interface RequiredApprovalInfo { |
| label: string; |
| value: number; |
| } |
| |
| export enum ChangeType { |
| ADDED = 'ADDED', |
| MODIFIED = 'MODIFIED', |
| DELETED = 'DELETED', |
| RENAMED = 'RENAMED', |
| COPIED = 'COPIED', |
| } |
| |
| export enum OwnerStatus { |
| INSUFFICIENT_REVIEWERS = 'INSUFFICIENT_REVIEWERS', |
| PENDING = 'PENDING', |
| APPROVED = 'APPROVED', |
| } |
| export interface PathCodeOwnerStatusInfo { |
| path: string; |
| status: OwnerStatus; |
| reasons?: Array<string>; |
| } |
| |
| export interface FileCodeOwnerStatusInfo { |
| change_type: ChangeType; |
| old_path_status?: PathCodeOwnerStatusInfo; |
| new_path_status?: PathCodeOwnerStatusInfo; |
| } |
| |
| export interface CodeOwnerStatusInfo { |
| patch_set_number: number; |
| file_code_owner_statuses: Array<FileCodeOwnerStatusInfo>; |
| more?: boolean; |
| accounts?: {[account_id: number]: AccountInfo}; |
| } |
| |
| export interface CodeOwnersStatusInfo { |
| disabled?: boolean; |
| disabled_branches?: Array<string>; |
| } |
| |
| export interface CodeOwnerInfo { |
| account?: AccountInfo; |
| selected?: boolean; |
| } |
| export interface CodeOwnersInfo { |
| code_owners: Array<CodeOwnerInfo>; |
| owned_by_all_users?: boolean; |
| } |
| |
| export interface FetchedOwner { |
| owners?: CodeOwnersInfo; |
| error?: unknown; |
| } |
| |
| export interface FetchedFile { |
| path: string; |
| info: FetchedOwner; |
| status?: string; |
| } |
| |
| export interface OwnedPathInfo { |
| path: string; |
| owned?: boolean; |
| owners?: Array<AccountInfo>; |
| } |
| |
| export interface OwnedChangedFileInfo { |
| new_path?: OwnedPathInfo; |
| old_path?: OwnedPathInfo; |
| } |
| |
| export interface OwnedPathsInfo { |
| owned_changed_files?: Array<OwnedChangedFileInfo>; |
| } |
| |
| function changeBaseURL(change: ChangeInfo): string { |
| return `/changes/${encodeURIComponent(change.project)}~${change._number}`; |
| } |
| |
| /** |
| * Responsible for communicating with the rest-api |
| * |
| * @see resources/Documentation/rest-api.md |
| */ |
| export class CodeOwnersApi { |
| constructor(readonly restApi: RestPluginApi) {} |
| |
| /** |
| * Send a get request and provides custom response-code handling |
| */ |
| private async get(url: string): Promise<unknown> { |
| const errFn = (response?: Response | null, error?: Error) => { |
| if (error) throw error; |
| if (response) throw new ResponseError(response); |
| throw new Error('Generic REST API error'); |
| }; |
| try { |
| return await this.restApi.send(HttpMethod.GET, url, undefined, errFn); |
| } catch (err) { |
| if (err instanceof ResponseError && err.response.status === 409) { |
| return getErrorMessage(err.response).then(msg => { |
| throw new ServerConfigurationError(msg); |
| }); |
| } |
| throw err; |
| } |
| } |
| |
| /** |
| * Returns a promise fetching the owner statuses for all files within the |
| * change. |
| * |
| * @doc |
| * https://gerrit.googlesource.com/plugins/code-owners/+/HEAD/resources/Documentation/rest-api.md#change-endpoints |
| */ |
| listOwnerStatus(change: ChangeInfo): Promise<CodeOwnerStatusInfo> { |
| return this.get( |
| `${changeBaseURL(change)}/code_owners.status?limit=100000` |
| ) as Promise<CodeOwnerStatusInfo>; |
| } |
| |
| /** |
| * Returns a promise fetching which files are owned by a given user as well |
| * as which reviewers own which files. |
| */ |
| listOwnedPaths(change: ChangeInfo, account: AccountInfo) { |
| if (!account.email && !account._account_id) |
| return Promise.resolve(undefined); |
| const user = account.email ?? account._account_id; |
| return this.get( |
| `${changeBaseURL( |
| change |
| )}/revisions/current/owned_paths?user=${user}&limit=10000&check_reviewers` |
| ) as Promise<OwnedPathsInfo>; |
| } |
| |
| /** |
| * Returns a promise fetching the owners for a given path. |
| * |
| * @doc |
| * https://gerrit.googlesource.com/plugins/code-owners/+/HEAD/resources/Documentation/rest-api.md#list-code-owners-for-path-in-branch |
| */ |
| listOwnersForPath( |
| change: ChangeInfo, |
| path: string, |
| limit: number |
| ): Promise<CodeOwnersInfo> { |
| return this.get( |
| `${changeBaseURL(change)}/revisions/current/code_owners` + |
| `/${encodeURIComponent(path)}?limit=${limit}&o=DETAILS` |
| ) as Promise<CodeOwnersInfo>; |
| } |
| |
| /** |
| * Returns a promise fetching the owners config for a given path. |
| * |
| * @doc |
| * https://gerrit.googlesource.com/plugins/code-owners/+/HEAD/resources/Documentation/rest-api.md#branch-endpoints |
| */ |
| getConfigForPath(project: string, branch: string, path: string) { |
| return this.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/+/HEAD/resources/Documentation/rest-api.md#branch-endpoints |
| */ |
| async getBranchConfig(project: RepoName, branch: BranchName) { |
| try { |
| const config = (await this.get( |
| `/projects/${encodeURIComponent(project)}/` + |
| `branches/${encodeURIComponent(branch)}/` + |
| 'code_owners.branch_config' |
| )) as CodeOwnerBranchConfigInfo; |
| 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 { |
| private promises = new Map<string, Promise<unknown>>(); |
| |
| constructor( |
| private readonly codeOwnerApi: CodeOwnersApi, |
| private readonly change: ChangeInfo |
| ) {} |
| |
| private fetchOnce( |
| cacheKey: string, |
| asyncFn: () => Promise<unknown> |
| ): Promise<unknown> { |
| let promise = this.promises.get(cacheKey); |
| if (promise) return promise; |
| promise = asyncFn(); |
| this.promises.set(cacheKey, promise); |
| return promise; |
| } |
| |
| getAccount(): Promise<AccountDetailInfo | undefined> { |
| return this.fetchOnce('getAccount', () => this.getAccountImpl()) as Promise< |
| AccountDetailInfo | undefined |
| >; |
| } |
| |
| private async getAccountImpl() { |
| const loggedIn = await this.codeOwnerApi.restApi.getLoggedIn(); |
| if (!loggedIn) return undefined; |
| return await this.codeOwnerApi.restApi.getAccount(); |
| } |
| |
| listOwnerStatus(): Promise<CodeOwnerStatusInfo> { |
| return this.fetchOnce('listOwnerStatus', () => |
| this.codeOwnerApi.listOwnerStatus(this.change) |
| ) as Promise<CodeOwnerStatusInfo>; |
| } |
| |
| async listOwnedPaths(): Promise<OwnedPathsInfo | undefined> { |
| const account = await this.getAccount(); |
| if (!account) return undefined; |
| return this.fetchOnce('listOwnedPaths', () => |
| this.codeOwnerApi.listOwnedPaths(this.change, account) |
| ) as Promise<OwnedPathsInfo | undefined>; |
| } |
| |
| getBranchConfig(): Promise<CodeOwnerBranchConfigInfo> { |
| return this.fetchOnce('getBranchConfig', () => |
| this.codeOwnerApi.getBranchConfig(this.change.project, this.change.branch) |
| ) as Promise<CodeOwnerBranchConfigInfo>; |
| } |
| } |