blob: 974aa58966a93847510bd2e472f111ddd73dcbdd [file]
/**
* @license
* Copyright (C) 2024 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,
ChangeInfo,
NumericChangeId,
RepoName,
} from '@gerritcodereview/typescript-api/rest-api';
import {User, UserRole} from './owners-model';
export interface GroupOwner {
name: string;
}
export interface Owner extends GroupOwner {
id: number;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function isOwner(o: any): o is Owner {
return o && typeof o.id === 'number' && typeof o.name === 'string';
}
export type FileOwner = Owner | GroupOwner;
export interface OwnedFiles {
[fileName: string]: FileOwner[];
}
export interface OwnersLabels {
[id: string]: {
[label: string]: number;
};
}
export interface FilesOwners {
files: OwnedFiles;
files_approved: OwnedFiles;
files_auto_approved?: OwnedFiles;
owners_labels: OwnersLabels;
}
const OWNERS_SUBMIT_REQUIREMENT = 'has:approval_owners';
const OWNERS_SUBMIT_RULE = 'owners~OwnersSubmitRequirement';
export function hasOwnersSubmitRequirement(change: ChangeInfo): boolean {
return (
change.submit_requirements !== undefined &&
change.submit_requirements.find(r => {
const expression = r.submittability_expression_result.expression;
return (
expression.includes(OWNERS_SUBMIT_REQUIREMENT) ||
expression.includes(OWNERS_SUBMIT_RULE)
);
}) !== undefined
);
}
class ResponseError extends Error {
constructor(readonly response: Response) {
super();
}
}
async function getErrorMessage(response: Response) {
const text = await response.text();
return text ? `${response.status}: ${text}` : `${response.status}`;
}
class OwnersApi {
constructor(readonly restApi: RestPluginApi) {}
async getAccount(): Promise<AccountDetailInfo | undefined> {
const loggedIn = await this.restApi.getLoggedIn();
if (!loggedIn) return undefined;
return await this.restApi.getAccount();
}
/**
* Returns the list of owners associated to each file that needs a review or were approved,
* and, for each owner, its current labels and votes.
*
* @doc
* https://gerrit.googlesource.com/plugins/owners/+/refs/heads/master/owners/src/main/resources/Documentation/rest-api.md
*/
getFilesOwners(
repoName: RepoName,
changeId: NumericChangeId,
revision: String
): Promise<FilesOwners> {
return this.get(
`/changes/${encodeURIComponent(
repoName
)}~${changeId}/revisions/${revision}/owners~files-owners`
) as Promise<FilesOwners>;
}
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) {
getErrorMessage(err.response).then(msg => {
// TODO handle 409 in UI - show banner with error
console.error(`Plugin configuration errot: ${msg}`);
});
}
throw err;
}
}
}
/**
* Calls to REST API can be safely cached cuz:
* 1. Gerrit doesn't update the existing change object but obtains and passes new instead
* 2. `OwnersService.getOwnersService` guarantees that whenever new change object is retrieved the new service instance is created (and cache is created with it)
*/
class OwnersApiCache {
private loggedInUser?: Promise<User>;
private filesOwners?: Promise<FilesOwners | undefined>;
constructor(
private readonly api: OwnersApi,
private readonly change: ChangeInfo
) {}
getLoggedInUser(): Promise<User> {
if (this.loggedInUser === undefined) {
this.loggedInUser = this.getLoggedInUserImpl();
}
return this.loggedInUser;
}
getFilesOwners(): Promise<FilesOwners | undefined> {
if (this.filesOwners === undefined) {
this.filesOwners = this.getFilesOwnersImpl();
}
return this.filesOwners;
}
private async getLoggedInUserImpl(): Promise<User> {
const account = await this.api.getAccount();
if (!account) {
return {role: UserRole.ANONYMOUS} as unknown as User;
}
const role =
this.change.owner._account_id === account._account_id
? UserRole.CHANGE_OWNER
: UserRole.OTHER;
return {account, role} as unknown as User;
}
private async getFilesOwnersImpl(): Promise<FilesOwners | undefined> {
return this.api.getFilesOwners(
this.change.project,
this.change._number,
this.change.current_revision ?? 'current'
);
}
}
let service: OwnersService | undefined;
export class OwnersService {
private apiCache: OwnersApiCache;
constructor(readonly restApi: RestPluginApi, readonly change: ChangeInfo) {
this.apiCache = new OwnersApiCache(new OwnersApi(restApi), change);
}
getLoggedInUser(): Promise<User> {
return this.apiCache.getLoggedInUser();
}
getFilesOwners(): Promise<FilesOwners | undefined> {
return this.apiCache.getFilesOwners();
}
static getOwnersService(restApi: RestPluginApi, change: ChangeInfo) {
if (!service || service.change !== change) {
service = new OwnersService(restApi, change);
}
return service;
}
}