Add `getAllFilesApproved` and `getFileOwners` functions to service
Introduce `getAllFilesApproved` function to determine if file owners
should be obtained from plugin and displayed to the user. It returns:
* `undefined` for all cases when file owners are not relevant to the
change in question: change is not viewed by logged user, change is
abandoned or merged and finally change has no `Owner-Approval`
submit requirement
* `true` when `Owner-Approval` is `SATISFIED` and `false` otherwise
Introduce the `getFileOwners` function to the `OwnersService` that
returns:
* `undefined` when `getAllFilesApproved` returns `undefined` or `true`
as there is no point for calling plugin REST api for not relevant or
already approved change
* files owners from plugin REST API
New functions are covered with unit tests (add `deepEqual` function to
compare objects and their content - it is copied from Gerrit utils).
Bug: Issue 373151160
Change-Id: Ie4cef9addc2af6963aea9000f195d19665e35dfa
diff --git a/owners/web/gr-owners.ts b/owners/web/gr-owners.ts
index b29fd27..76b89d9 100644
--- a/owners/web/gr-owners.ts
+++ b/owners/web/gr-owners.ts
@@ -22,7 +22,7 @@
ChangeInfo,
ChangeStatus,
} from '@gerritcodereview/typescript-api/rest-api';
-import {OwnersService} from './owners-service';
+import {OWNERS_SUBMIT_REQUIREMENT, OwnersService} from './owners-service';
import {RestPluginApi} from '@gerritcodereview/typescript-api/rest';
import {ModelLoader, OwnersModel, PatchRange, UserRole} from './owners-model';
@@ -187,7 +187,7 @@
// show owners when they apply to the change and for logged in user
if (
change.submit_requirements &&
- change.submit_requirements.find(r => r.name === 'Owner-Approval')
+ change.submit_requirements.find(r => r.name === OWNERS_SUBMIT_REQUIREMENT)
) {
return !userRole || userRole === UserRole.ANONYMOUS;
}
diff --git a/owners/web/owners-service.ts b/owners/web/owners-service.ts
index 6abf6e9..91107bf 100644
--- a/owners/web/owners-service.ts
+++ b/owners/web/owners-service.ts
@@ -15,13 +15,55 @@
* limitations under the License.
*/
-import {RestPluginApi} from '@gerritcodereview/typescript-api/rest';
+import {HttpMethod, RestPluginApi} from '@gerritcodereview/typescript-api/rest';
import {
AccountDetailInfo,
ChangeInfo,
+ ChangeStatus,
+ NumericChangeId,
+ RepoName,
+ SubmitRequirementStatus,
} from '@gerritcodereview/typescript-api/rest-api';
import {UserRole} from './owners-model';
+export interface GroupOwner {
+ name: string;
+}
+
+export interface Owner extends GroupOwner {
+ id: number;
+}
+
+export type FileOwner = Owner | GroupOwner;
+
+export interface OwnedFiles {
+ [fileName: string]: FileOwner[];
+}
+
+export interface OwnersLabels {
+ [id: string]: {
+ [label: string]: number;
+ };
+}
+
+export interface FilesOwners {
+ files: OwnedFiles;
+ owners_labels: OwnersLabels;
+}
+
+export const OWNERS_SUBMIT_REQUIREMENT = 'Owner-Approval';
+
+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) {}
@@ -30,6 +72,43 @@
if (!loggedIn) return undefined;
return await this.restApi.getAccount();
}
+
+ /**
+ * Returns the list of owners associated to each file that needs a review,
+ * 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
+ ): Promise<FilesOwners> {
+ return this.get(
+ `/changes/${encodeURIComponent(
+ repoName
+ )}~${changeId}/revisions/current/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;
+ }
+ }
}
let service: OwnersService | undefined;
@@ -52,6 +131,45 @@
return UserRole.OTHER;
}
+ async getAllFilesApproved(): Promise<boolean | undefined> {
+ if (!(await this.isLoggedIn())) {
+ return Promise.resolve(undefined);
+ }
+
+ if (
+ this.change?.status === ChangeStatus.ABANDONED ||
+ this.change?.status === ChangeStatus.MERGED
+ ) {
+ return Promise.resolve(undefined);
+ }
+
+ const ownersSr = this.change?.submit_requirements?.find(
+ r => r.name === OWNERS_SUBMIT_REQUIREMENT
+ );
+ if (!ownersSr) {
+ return Promise.resolve(undefined);
+ }
+
+ return Promise.resolve(
+ ownersSr.status === SubmitRequirementStatus.SATISFIED
+ );
+ }
+
+ async getFilesOwners(): Promise<FilesOwners | undefined> {
+ const allFilesApproved = await this.getAllFilesApproved();
+
+ if (allFilesApproved === undefined || allFilesApproved) {
+ return Promise.resolve(undefined);
+ }
+
+ return this.api.getFilesOwners(this.change.project, this.change._number);
+ }
+
+ private async isLoggedIn(): Promise<boolean> {
+ const userRole = await this.getLoggedInUserRole();
+ return userRole && userRole !== UserRole.ANONYMOUS;
+ }
+
static getOwnersService(restApi: RestPluginApi, change: ChangeInfo) {
if (!service || service.change !== change) {
service = new OwnersService(restApi, change);
diff --git a/owners/web/owners-service_test.ts b/owners/web/owners-service_test.ts
index f593304..3ac8590 100644
--- a/owners/web/owners-service_test.ts
+++ b/owners/web/owners-service_test.ts
@@ -15,11 +15,20 @@
* limitations under the License.
*/
-import {OwnersService} from './owners-service';
-import {RestPluginApi} from '@gerritcodereview/typescript-api/rest';
-import {ChangeInfo} from '@gerritcodereview/typescript-api/rest-api';
+import {FilesOwners, OwnersService} from './owners-service';
+import {
+ RequestPayload,
+ RestPluginApi,
+} from '@gerritcodereview/typescript-api/rest';
+import {
+ ChangeInfo,
+ ChangeStatus,
+ HttpMethod,
+ SubmitRequirementResultInfo,
+} from '@gerritcodereview/typescript-api/rest-api';
import {assert} from '@open-wc/testing';
import {UserRole} from './owners-model';
+import {deepEqual} from './utils';
suite('owners service tests', () => {
const fakeRestApi = {} as unknown as RestPluginApi;
@@ -92,6 +101,192 @@
assert.equal(userRole, UserRole.CHANGE_OWNER);
});
});
+
+ suite('files owners tests', () => {
+ teardown(() => {
+ sinon.restore();
+ });
+
+ function setupRestApiForLoggedIn(loggedIn: boolean): RestPluginApi {
+ return {
+ getLoggedIn() {
+ return Promise.resolve(loggedIn);
+ },
+ send(
+ _method: HttpMethod,
+ _url: string,
+ _payload?: RequestPayload,
+ _errFn?: ErrorCallback,
+ _contentType?: string
+ ) {
+ return Promise.resolve({});
+ },
+ } as unknown as RestPluginApi;
+ }
+
+ const notLoggedInRestApiService = setupRestApiForLoggedIn(false);
+
+ function loggedInRestApiService(acc: number): RestPluginApi {
+ return {
+ ...setupRestApiForLoggedIn(true),
+ getAccount() {
+ return Promise.resolve(account(acc));
+ },
+ } as unknown as RestPluginApi;
+ }
+
+ let service: OwnersService;
+ let getApiStub: sinon.SinonStub;
+
+ function setup(
+ loggedIn: boolean,
+ changeStats: ChangeStatus = ChangeStatus.NEW,
+ submitRequirementsSatisfied?: boolean,
+ response = {}
+ ) {
+ const acc = account(1);
+ const base_change = {
+ ...fakeChange,
+ _number: 1,
+ status: changeStats,
+ project: 'test_repo',
+ owner: acc,
+ } as unknown as ChangeInfo;
+ const change =
+ submitRequirementsSatisfied === undefined
+ ? base_change
+ : {
+ ...base_change,
+ submit_requirements: [
+ {
+ name: 'Owner-Approval',
+ status: submitRequirementsSatisfied
+ ? 'SATISFIED'
+ : 'UNSATISFIED',
+ } as unknown as SubmitRequirementResultInfo,
+ ],
+ };
+ const restApi = loggedIn
+ ? loggedInRestApiService(1)
+ : notLoggedInRestApiService;
+
+ getApiStub = sinon.stub(restApi, 'send');
+ getApiStub
+ .withArgs(
+ sinon.match.any,
+ `/changes/${change.project}~${change._number}/revisions/current/owners~files-owners`,
+ sinon.match.any,
+ sinon.match.any
+ )
+ .returns(Promise.resolve(response));
+ service = OwnersService.getOwnersService(restApi, change);
+ }
+
+ const isLoggedIn = true;
+ const changeMerged = ChangeStatus.MERGED;
+ const changeNew = ChangeStatus.NEW;
+ const ownersSubmitRequirementsSatisfied = true;
+
+ function setupGetAllFilesApproved_undefined() {
+ setup(!isLoggedIn);
+ }
+
+ test('should have getAllFilesApproved `undefined` for no change owner', async () => {
+ setupGetAllFilesApproved_undefined();
+
+ const response = await service.getAllFilesApproved();
+ assert.equal(response, undefined);
+ });
+
+ test('should have getAllFilesApproved `undefined` for `MERGED` change', async () => {
+ setup(isLoggedIn, changeMerged);
+
+ const response = await service.getAllFilesApproved();
+ assert.equal(response, undefined);
+ });
+
+ test('should have getAllFilesApproved `undefined` when no submit requirements', async () => {
+ setup(isLoggedIn, changeNew);
+
+ const response = await service.getAllFilesApproved();
+ assert.equal(response, undefined);
+ });
+
+ function setupGetAllFilesApproved_false(response = {}) {
+ setup(
+ isLoggedIn,
+ changeNew,
+ !ownersSubmitRequirementsSatisfied,
+ response
+ );
+ }
+
+ test('should have getAllFilesApproved `false` when no submit requirements are not satisfied', async () => {
+ setupGetAllFilesApproved_false();
+
+ const response = await service.getAllFilesApproved();
+ assert.equal(response, false);
+ });
+
+ function setupGetAllFilesApproved_true() {
+ setup(isLoggedIn, changeNew, ownersSubmitRequirementsSatisfied);
+ }
+ test('should have getAllFilesApproved `true` when no submit requirements are satisfied', async () => {
+ setupGetAllFilesApproved_true();
+
+ const response = await service.getAllFilesApproved();
+ assert.equal(response, true);
+ });
+
+ test('should not call getFilesOwners when getAllFilesApproved is `undefined`', async () => {
+ setupGetAllFilesApproved_undefined();
+
+ const response = await service.getFilesOwners();
+ await flush();
+ assert.equal(getApiStub.callCount, 0);
+ assert.equal(response, undefined);
+ });
+
+ test('should not call getFilesOwners when getAllFilesApproved is `true`', async () => {
+ setupGetAllFilesApproved_true();
+
+ const response = await service.getFilesOwners();
+ await flush();
+ assert.equal(getApiStub.callCount, 0);
+ assert.equal(response, undefined);
+ });
+
+ test('should call getFilesOwners when getAllFilesApproved is `false`', async () => {
+ const expected = {
+ files: {
+ 'AJavaFile.java': [{name: 'Bob', id: 1000001}],
+ 'Aptyhonfileroot.py': [
+ {name: 'John', id: 1000002},
+ {name: 'Bob', id: 1000001},
+ {name: 'Jack', id: 1000003},
+ ],
+ },
+ owners_labels: {
+ '1000002': {
+ Verified: 1,
+ 'Code-Review': 0,
+ },
+ '1000001': {
+ 'Code-Review': 2,
+ },
+ },
+ };
+ setupGetAllFilesApproved_false(expected);
+
+ const response = await service.getFilesOwners();
+ await flush();
+ assert.equal(getApiStub.callCount, 1);
+ assert.equal(
+ deepEqual(response, {...expected} as unknown as FilesOwners),
+ true
+ );
+ });
+ });
});
function account(id: number) {
@@ -99,3 +294,9 @@
_account_id: id,
};
}
+
+function flush() {
+ return new Promise((resolve, _reject) => {
+ setTimeout(resolve, 0);
+ });
+}
diff --git a/owners/web/utils.ts b/owners/web/utils.ts
new file mode 100644
index 0000000..349f5fb
--- /dev/null
+++ b/owners/web/utils.ts
@@ -0,0 +1,56 @@
+/**
+ * @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.
+ */
+
+export function deepEqual<T>(a: T, b: T): boolean {
+ if (a === b) return true;
+ if (a === undefined || b === undefined) return false;
+ if (a === null || b === null) return false;
+ if (a instanceof Date || b instanceof Date) {
+ if (!(a instanceof Date && b instanceof Date)) return false;
+ return a.getTime() === b.getTime();
+ }
+
+ if (a instanceof Set || b instanceof Set) {
+ if (!(a instanceof Set && b instanceof Set)) return false;
+ if (a.size !== b.size) return false;
+ for (const ai of a) if (!b.has(ai)) return false;
+ return true;
+ }
+ if (a instanceof Map || b instanceof Map) {
+ if (!(a instanceof Map && b instanceof Map)) return false;
+ if (a.size !== b.size) return false;
+ for (const [aKey, aValue] of a.entries()) {
+ if (!b.has(aKey) || !deepEqual(aValue, b.get(aKey))) return false;
+ }
+ return true;
+ }
+
+ if (typeof a === 'object') {
+ if (typeof b !== 'object') return false;
+ const aObj = a as Record<string, unknown>;
+ const bObj = b as Record<string, unknown>;
+ const aKeys = Object.keys(aObj);
+ const bKeys = Object.keys(bObj);
+ if (aKeys.length !== bKeys.length) return false;
+ for (const key of aKeys) {
+ if (!deepEqual(aObj[key], bObj[key])) return false;
+ }
+ return true;
+ }
+
+ return false;
+}