Merge branch 'stable-3.8' into stable-3.9
* commit '1d329c43841815f04ccb9a28f99225e8997de15f':
Fix IT tests for `GetFilesOwners`
Expose files that are approved by owners in REST API
Display owned files as links to the diff
Introduce `Owned Files` tab to the change screen
Reorganize UI components to introduce owned files tab
Reuse account details that are part of the change
Display `Copy owner's email` button for owner
Display file owner's vote value in the File Owners summary
Display file owners when a owner's status icon is hovered over
Indicate file that needs file owner's review
Introduce `allFilesApproved` and `filesOwners` states to plugin UI
Add `getAllFilesApproved` and `getFileOwners` functions to service
Introduce the skeleton of UI for file owners status display
Fix cache invalidation of path_owners_entries
Change-Id: I706b36f5cf840bfd932cecf48e0fa01fab9e1937
diff --git a/owners-common/src/main/java/com/googlesource/gerrit/owners/common/PathOwnersEntriesCacheImpl.java b/owners-common/src/main/java/com/googlesource/gerrit/owners/common/PathOwnersEntriesCacheImpl.java
index 3af7a58..c882414 100644
--- a/owners-common/src/main/java/com/googlesource/gerrit/owners/common/PathOwnersEntriesCacheImpl.java
+++ b/owners-common/src/main/java/com/googlesource/gerrit/owners/common/PathOwnersEntriesCacheImpl.java
@@ -20,6 +20,7 @@
import com.google.common.cache.LoadingCache;
import com.google.common.collect.HashMultimap;
import com.google.common.collect.Multimap;
+import com.google.gerrit.entities.RefNames;
import com.google.inject.Inject;
import com.google.inject.Singleton;
import com.google.inject.name.Named;
@@ -88,6 +89,6 @@
}
private String indexKey(String project, String branch) {
- return new StringBuilder(project).append('@').append(branch).toString();
+ return new StringBuilder(project).append('@').append(RefNames.fullName(branch)).toString();
}
}
diff --git a/owners/BUILD b/owners/BUILD
index f08ec84..c1dac46 100644
--- a/owners/BUILD
+++ b/owners/BUILD
@@ -38,7 +38,9 @@
"Implementation-URL: https://gerrit.googlesource.com/plugins/owners",
"Gerrit-PluginName: owners",
"Gerrit-Module: com.googlesource.gerrit.owners.OwnersModule",
+ "Gerrit-HttpModule: com.googlesource.gerrit.owners.HttpModule",
],
+ resource_jars = ["//plugins/owners/web:gr-owners"],
resources = glob(["src/main/resources/**/*"]),
deps = [
":gerrit-owners-predicates",
diff --git a/owners/src/main/java/com/googlesource/gerrit/owners/HttpModule.java b/owners/src/main/java/com/googlesource/gerrit/owners/HttpModule.java
new file mode 100644
index 0000000..10759c0
--- /dev/null
+++ b/owners/src/main/java/com/googlesource/gerrit/owners/HttpModule.java
@@ -0,0 +1,26 @@
+// 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.
+package com.googlesource.gerrit.owners;
+
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.extensions.webui.JavaScriptPlugin;
+import com.google.gerrit.extensions.webui.WebUiPlugin;
+import com.google.inject.servlet.ServletModule;
+
+public class HttpModule extends ServletModule {
+ @Override
+ protected void configureServlets() {
+ DynamicSet.bind(binder(), WebUiPlugin.class).toInstance(new JavaScriptPlugin("gr-owners.js"));
+ }
+}
diff --git a/owners/src/main/java/com/googlesource/gerrit/owners/entities/FilesOwnersResponse.java b/owners/src/main/java/com/googlesource/gerrit/owners/entities/FilesOwnersResponse.java
index 30c9c73..3f6c937 100644
--- a/owners/src/main/java/com/googlesource/gerrit/owners/entities/FilesOwnersResponse.java
+++ b/owners/src/main/java/com/googlesource/gerrit/owners/entities/FilesOwnersResponse.java
@@ -24,11 +24,15 @@
public final Map<String, Set<GroupOwner>> files;
public final Map<Integer, Map<String, Integer>> ownersLabels;
+ public final Map<String, Set<GroupOwner>> filesApproved;
public FilesOwnersResponse(
- Map<Integer, Map<String, Integer>> ownersLabels, Map<String, Set<GroupOwner>> files) {
+ Map<Integer, Map<String, Integer>> ownersLabels,
+ Map<String, Set<GroupOwner>> files,
+ Map<String, Set<GroupOwner>> filesApproved) {
this.ownersLabels = ownersLabels;
this.files = files;
+ this.filesApproved = filesApproved;
}
@Override
@@ -36,16 +40,25 @@
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
FilesOwnersResponse that = (FilesOwnersResponse) o;
- return Objects.equal(files, that.files) && Objects.equal(ownersLabels, that.ownersLabels);
+ return Objects.equal(files, that.files)
+ && Objects.equal(ownersLabels, that.ownersLabels)
+ && Objects.equal(filesApproved, that.filesApproved);
}
@Override
public int hashCode() {
- return Objects.hashCode(files, ownersLabels);
+ return Objects.hashCode(files, ownersLabels, filesApproved);
}
@Override
public String toString() {
- return "FilesOwnersResponse{" + "files=" + files + ", ownersLabels=" + ownersLabels + '}';
+ return "FilesOwnersResponse{"
+ + "files="
+ + files
+ + ", ownersLabels="
+ + ownersLabels
+ + ", filesApproved="
+ + filesApproved
+ + '}';
}
}
diff --git a/owners/src/main/java/com/googlesource/gerrit/owners/restapi/GetFilesOwners.java b/owners/src/main/java/com/googlesource/gerrit/owners/restapi/GetFilesOwners.java
index bbe3007..005e83f 100644
--- a/owners/src/main/java/com/googlesource/gerrit/owners/restapi/GetFilesOwners.java
+++ b/owners/src/main/java/com/googlesource/gerrit/owners/restapi/GetFilesOwners.java
@@ -145,7 +145,14 @@
!isApprovedByOwner(
fileExpandedOwners.get(fileOwnerEntry.getKey()), ownersLabels, label));
- return Response.ok(new FilesOwnersResponse(ownersLabels, filesWithPendingOwners));
+ Map<String, Set<GroupOwner>> filesApprovedByOwners =
+ Maps.filterEntries(
+ fileToOwners,
+ (fileOwnerEntry) ->
+ isApprovedByOwner(
+ fileExpandedOwners.get(fileOwnerEntry.getKey()), ownersLabels, label));
+
+ return Response.ok(new FilesOwnersResponse(ownersLabels, filesWithPendingOwners, filesApprovedByOwners));
} catch (InvalidOwnersFileException e) {
logger.atSevere().withCause(e).log("Reading/parsing OWNERS file error.");
throw new ResourceConflictException(e.getMessage(), e);
diff --git a/owners/src/main/resources/Documentation/rest-api.md b/owners/src/main/resources/Documentation/rest-api.md
index 68fb463..3505d6c 100644
--- a/owners/src/main/resources/Documentation/rest-api.md
+++ b/owners/src/main/resources/Documentation/rest-api.md
@@ -1,7 +1,8 @@
# Rest API
-The @PLUGIN@ exposes a Rest API endpoint to list the owners associated to each file that
-needs a review, and, for each owner, its current labels and votes:
+The @PLUGIN@ exposes a Rest API endpoint to list the owners associated with each file that
+needs approval (`file` field), is approved (`files_approved`) and, for each owner,
+its current labels and votes (`owners_labels`):
```bash
GET /changes/{change-id}/revisions/{revision-id}/owners~files-owners
@@ -18,6 +19,12 @@
{ "name":"Jack", "id": 1000003 }
]
},
+ "files_approved": {
+ "NewBuild.build":[
+ { "name":"John", "id": 1000004 },
+ { "name":"Release Engineer", "id": 1000001 }
+ ]
+ },
"owners_labels" : {
"1000002": {
"Verified": 1,
diff --git a/owners/src/test/java/com/googlesource/gerrit/owners/restapi/GetFilesOwnersITAbstract.java b/owners/src/test/java/com/googlesource/gerrit/owners/restapi/GetFilesOwnersITAbstract.java
index c39974c..1633783 100644
--- a/owners/src/test/java/com/googlesource/gerrit/owners/restapi/GetFilesOwnersITAbstract.java
+++ b/owners/src/test/java/com/googlesource/gerrit/owners/restapi/GetFilesOwnersITAbstract.java
@@ -42,6 +42,7 @@
import com.googlesource.gerrit.owners.entities.Owner;
import com.googlesource.gerrit.owners.restapi.GetFilesOwners.LabelNotFoundException;
import java.util.Arrays;
+import java.util.Map;
import javax.servlet.http.HttpServletResponse;
import org.apache.commons.compress.utils.Sets;
import org.eclipse.jgit.internal.storage.dfs.DfsRepositoryDescription;
@@ -102,7 +103,7 @@
}
@Test
- public void shouldReturnOwnersLabelsWhenNotApprovedByOwners() throws Exception {
+ public void shouldReturnEmptyOwnersLabelsWhenNotApprovedByOwners() throws Exception {
addOwnerFileToRoot(true);
String changeId = createChange().getChangeId();
@@ -116,7 +117,21 @@
}
@Test
- public void shouldReturnEmptyResponseWhenApprovedByOwners() throws Exception {
+ public void shouldReturnNonEmptyOwnersLabelsWhenApprovedByOwners() throws Exception {
+ addOwnerFileToRoot(true);
+ String changeId = createChange().getChangeId();
+ approve(changeId);
+
+ Response<FilesOwnersResponse> resp =
+ assertResponseOk(ownersApi.apply(parseCurrentRevisionResource(changeId)));
+
+ assertThat(resp.value().ownersLabels)
+ .containsExactly(admin.id().get(), Map.of(LabelId.CODE_REVIEW, 2));
+ }
+
+ @Test
+ public void shouldReturnEmptyFilesAndNonEmptyFilesApprovedResponseWhenApprovedByOwners()
+ throws Exception {
addOwnerFileToRoot(true);
String changeId = createChange().getChangeId();
approve(changeId);
@@ -125,6 +140,8 @@
assertResponseOk(ownersApi.apply(parseCurrentRevisionResource(changeId)));
assertThat(resp.value().files).isEmpty();
+ assertThat(resp.value().filesApproved)
+ .containsExactly("a.txt", Sets.newHashSet(new Owner(admin.fullName(), admin.id().get())));
}
@Test
@@ -138,12 +155,14 @@
assertThat(resp.value().files)
.containsExactly("a.txt", Sets.newHashSet(new GroupOwner(admin.username())));
+ assertThat(resp.value().filesApproved).isEmpty();
}
@Test
@GlobalPluginConfig(pluginName = "owners", name = "owners.expandGroups", value = "false")
- public void shouldReturnEmptyResponseWhenApprovedByOwnersWithUnexpandedFileOwners()
- throws Exception {
+ public void
+ shouldReturnEmptyFilesAndNonEmptyFilesApprovedResponseWhenApprovedByOwnersWithUnexpandedFileOwners()
+ throws Exception {
addOwnerFileToRoot(true);
String changeId = createChange().getChangeId();
approve(changeId);
@@ -152,6 +171,8 @@
assertResponseOk(ownersApi.apply(parseCurrentRevisionResource(changeId)));
assertThat(resp.value().files).isEmpty();
+ assertThat(resp.value().filesApproved)
+ .containsExactly("a.txt", Sets.newHashSet(new GroupOwner(admin.username())));
}
@Test
@@ -165,6 +186,7 @@
assertThat(resp.value().files)
.containsExactly("a.txt", Sets.newHashSet(new GroupOwner(admin.username())));
+ assertThat(resp.value().filesApproved).isEmpty();
}
@Test
@@ -192,6 +214,7 @@
addOwnerFileToProjectConfig(allProjects, true, user);
resp = assertResponseOk(ownersApi.apply(parseCurrentRevisionResource(changeId)));
assertThat(resp.value().files).containsExactly("a.txt", Sets.newHashSet(projectOwner));
+ assertThat(resp.value().filesApproved).isEmpty();
}
@Test
@@ -265,6 +288,7 @@
assertResponseOk(ownersApi.apply(parseCurrentRevisionResource(changeId)));
assertThat(resp.value().files).containsExactly("a.txt", Sets.newHashSet(rootOwner));
+ assertThat(resp.value().filesApproved).isEmpty();
}
private void assertInheritFromProject(Project.NameKey projectNameKey) throws Exception {
@@ -277,6 +301,7 @@
assertThat(resp.value().files)
.containsExactly("a.txt", Sets.newHashSet(rootOwner, projectOwner));
+ assertThat(resp.value().filesApproved).isEmpty();
}
private void addBrokenOwnersFileToRoot() throws Exception {
diff --git a/owners/src/test/java/com/googlesource/gerrit/owners/restapi/GetFilesOwnersSubmitRequirementsIT.java b/owners/src/test/java/com/googlesource/gerrit/owners/restapi/GetFilesOwnersSubmitRequirementsIT.java
index 8172611..f5c80b7 100644
--- a/owners/src/test/java/com/googlesource/gerrit/owners/restapi/GetFilesOwnersSubmitRequirementsIT.java
+++ b/owners/src/test/java/com/googlesource/gerrit/owners/restapi/GetFilesOwnersSubmitRequirementsIT.java
@@ -72,6 +72,7 @@
assertThat(resp.value().files)
.containsExactly("foo", Sets.newHashSet(new Owner(admin.fullName(), admin.id().get())));
assertThat(resp.value().ownersLabels).isEmpty();
+ assertThat(resp.value().filesApproved).isEmpty();
// give CR+1 as requested
recommend(changeId);
@@ -80,6 +81,8 @@
assertThat(resp.value().files).isEmpty();
assertThat(resp.value().ownersLabels)
.containsExactly(admin.id().get(), Map.of(LabelId.CODE_REVIEW, 1));
+ assertThat(resp.value().filesApproved)
+ .containsExactly("foo", Sets.newHashSet(new Owner(admin.fullName(), admin.id().get())));
}
@Test
@@ -96,6 +99,7 @@
assertThat(resp.value().files)
.containsExactly("foo", Sets.newHashSet(new Owner(admin.fullName(), admin.id().get())));
assertThat(resp.value().ownersLabels).isEmpty();
+ assertThat(resp.value().filesApproved).isEmpty();
// give LabelFoo+1 as requested
gApi.changes().id(changeId).current().review(new ReviewInput().label(label, 1));
@@ -103,6 +107,8 @@
resp = assertResponseOk(ownersApi.apply(parseCurrentRevisionResource(changeId)));
assertThat(resp.value().files).isEmpty();
assertThat(resp.value().ownersLabels).containsEntry(admin.id().get(), Map.of(label, 1));
+ assertThat(resp.value().filesApproved)
+ .containsExactly("foo", Sets.newHashSet(new Owner(admin.fullName(), admin.id().get())));
}
private void addOwnerFileToRoot(LabelDefinition label, TestAccount u) throws Exception {
diff --git a/owners/web/.eslintrc.js b/owners/web/.eslintrc.js
new file mode 100644
index 0000000..776d84e
--- /dev/null
+++ b/owners/web/.eslintrc.js
@@ -0,0 +1,20 @@
+/**
+ * @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.
+ */
+__plugindir = 'owners/web';
+module.exports = {
+ extends: '../../.eslintrc.js',
+};
\ No newline at end of file
diff --git a/owners/web/BUILD b/owners/web/BUILD
new file mode 100644
index 0000000..56ab697
--- /dev/null
+++ b/owners/web/BUILD
@@ -0,0 +1,68 @@
+load("@npm//@bazel/typescript:index.bzl", "ts_config", "ts_project")
+load("//tools/bzl:js.bzl", "gerrit_js_bundle", "karma_test")
+load("//tools/js:eslint.bzl", "plugin_eslint")
+
+package_group(
+ name = "visibility",
+ packages = ["//plugins/owners/..."],
+)
+
+package(default_visibility = [":visibility"])
+
+ts_config(
+ name = "tsconfig",
+ src = "tsconfig.json",
+ deps = [
+ "//plugins:tsconfig-plugins-base.json",
+ ],
+)
+
+ts_project(
+ name = "owners-ts",
+ srcs = glob(
+ ["**/*.ts"],
+ exclude = [
+ "**/*test*",
+ "**/test-utils.ts",
+ ],
+ ),
+ incremental = True,
+ out_dir = "_bazel_ts_out",
+ tsc = "//tools/node_tools:tsc-bin",
+ tsconfig = ":tsconfig",
+ deps = [
+ "@plugins_npm//@gerritcodereview/typescript-api",
+ "@plugins_npm//lit",
+ "@plugins_npm//rxjs",
+ ],
+)
+
+ts_project(
+ name = "owners-ts-tests",
+ srcs = glob([
+ "**/*.ts",
+ "**/test-utils.ts",
+ ]),
+ incremental = True,
+ out_dir = "_bazel_ts_out_tests",
+ tsc = "//tools/node_tools:tsc-bin",
+ tsconfig = ":tsconfig",
+ deps = [
+ "@plugins_npm//:node_modules",
+ "@ui_dev_npm//:node_modules",
+ ],
+)
+
+gerrit_js_bundle(
+ name = "gr-owners",
+ srcs = [":owners-ts"],
+ entry_point = "_bazel_ts_out/plugin.js",
+)
+
+karma_test(
+ name = "karma_test",
+ srcs = ["karma_test.sh"],
+ data = [":owners-ts-tests"],
+)
+
+plugin_eslint()
diff --git a/owners/web/gerrit-model.ts b/owners/web/gerrit-model.ts
new file mode 100644
index 0000000..da0d42c
--- /dev/null
+++ b/owners/web/gerrit-model.ts
@@ -0,0 +1,54 @@
+/**
+ * @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 {AccountInfo} from '@gerritcodereview/typescript-api/rest-api';
+
+/**
+ * A provider for a value. Copied from Gerrit core.
+ */
+export type Provider<T> = () => T;
+
+/**
+ * Partial Gerrit's `AccountsModel` interface that defines functions needed in the owners plugin
+ */
+export interface AccountsModel {
+ fillDetails(account: AccountInfo): AccountInfo;
+}
+
+/**
+ * Parital Gerrit's `GrAccountLabel` interface that provides access to the `AccountsModel`
+ */
+export interface GrAccountLabel extends Element {
+ getAccountsModel: Provider<AccountsModel>;
+}
+
+/**
+ * Partial Gerrit's `SpecialFilePath` enum
+ */
+export enum SpecialFilePath {
+ COMMIT_MESSAGE = '/COMMIT_MSG',
+ MERGE_LIST = '/MERGE_LIST',
+}
+
+/**
+ * Partial Gerrit's `Window` interface defintion so that `getBaseUrl` function can work.
+ */
+declare global {
+ interface Window {
+ CANONICAL_PATH?: string;
+ }
+}
diff --git a/owners/web/gr-files.ts b/owners/web/gr-files.ts
new file mode 100644
index 0000000..1f0c7ef
--- /dev/null
+++ b/owners/web/gr-files.ts
@@ -0,0 +1,484 @@
+/**
+ * @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 {css, html, LitElement, nothing, PropertyValues, CSSResult} from 'lit';
+import {customElement, property} from 'lit/decorators';
+import {
+ AccountInfo,
+ ApprovalInfo,
+ ChangeInfo,
+ ChangeStatus,
+ LabelInfo,
+ isDetailedLabelInfo,
+ EmailAddress,
+ ReviewerState,
+} from '@gerritcodereview/typescript-api/rest-api';
+import {
+ FilesOwners,
+ isOwner,
+ OwnersLabels,
+ OWNERS_SUBMIT_REQUIREMENT,
+} from './owners-service';
+import {FileOwnership, FileStatus, PatchRange, UserRole} from './owners-model';
+import {query} from './utils';
+import {GrAccountLabel} from './gerrit-model';
+import {OwnersMixin} from './owners-mixin';
+
+const STATUS_CODE = {
+ MISSING: 'missing',
+};
+
+const STATUS_ICON = {
+ [STATUS_CODE.MISSING]: 'schedule',
+};
+
+const FILE_STATUS = {
+ [FileStatus.NEEDS_APPROVAL]: STATUS_CODE.MISSING,
+};
+
+const DISPLAY_OWNERS_FOR_FILE_LIMIT = 5;
+const LIMITED_FILES_OWNERS_TOOLTIP = `File owners limited to first ${DISPLAY_OWNERS_FOR_FILE_LIMIT} accounts.`;
+
+const common = OwnersMixin(LitElement);
+
+class FilesCommon extends common {
+ protected override willUpdate(changedProperties: PropertyValues): void {
+ super.willUpdate(changedProperties);
+
+ this.hidden = shouldHide(
+ this.change,
+ this.patchRange,
+ this.allFilesApproved,
+ this.user?.role
+ );
+ }
+
+ protected static commonStyles(): CSSResult[] {
+ return [window?.Gerrit?.styles.font as CSSResult];
+ }
+}
+
+export const FILES_OWNERS_COLUMN_HEADER = 'files-owners-column-header';
+@customElement(FILES_OWNERS_COLUMN_HEADER)
+export class FilesColumnHeader extends FilesCommon {
+ static override get styles() {
+ return [
+ ...FilesCommon.commonStyles(),
+ css`
+ :host() {
+ display: block;
+ padding-right: var(--spacing-m);
+ width: 4em;
+ }
+ :host[hidden] {
+ display: none;
+ }
+ `,
+ ];
+ }
+
+ override render() {
+ if (this.hidden) return nothing;
+ return html`<div>Status</div>`;
+ }
+}
+
+/**
+ * It has to be part of this file as components defined in dedicated files are not visible
+ */
+@customElement('gr-owner')
+export class GrOwner extends LitElement {
+ @property({type: Object})
+ owner?: AccountInfo;
+
+ @property({type: Object})
+ approval?: ApprovalInfo;
+
+ @property({type: Object})
+ info?: LabelInfo;
+
+ @property({type: String})
+ email?: EmailAddress;
+
+ static override get styles() {
+ return [
+ css`
+ .container {
+ display: flex;
+ }
+ gr-vote-chip {
+ margin-left: 5px;
+ --gr-vote-chip-width: 14px;
+ --gr-vote-chip-height: 14px;
+ }
+ gr-tooltip-content {
+ display: inline-block;
+ }
+ `,
+ ];
+ }
+
+ override render() {
+ if (!this.owner) {
+ return nothing;
+ }
+
+ const voteChip = this.approval
+ ? html` <gr-vote-chip
+ slot="vote-chip"
+ .vote=${this.approval}
+ .label=${this.info}
+ circle-shape
+ ></gr-vote-chip>`
+ : nothing;
+
+ const copyEmail = this.email
+ ? html` <gr-copy-clipboard
+ .text=${this.email}
+ hasTooltip
+ hideinput
+ buttonTitle=${"Copy owner's email to clipboard"}
+ ></gr-copy-clipboard>`
+ : nothing;
+
+ return html`
+ <div class="container">
+ <gr-account-label .account=${this.owner}></gr-account-label>
+ ${voteChip} ${copyEmail}
+ </div>
+ `;
+ }
+
+ override async updated(changedProperties: PropertyValues) {
+ super.updated(changedProperties);
+
+ if (changedProperties.has('owner')) {
+ if (this.owner && !this.email) {
+ const accountLabel = query<GrAccountLabel>(this, 'gr-account-label');
+ if (!accountLabel) {
+ return;
+ }
+
+ const updateOwner = await accountLabel
+ ?.getAccountsModel()
+ ?.fillDetails(this.owner);
+ this.email = updateOwner?.email;
+ }
+ }
+ }
+}
+
+export const FILES_OWNERS_COLUMN_CONTENT = 'files-owners-column-content';
+@customElement(FILES_OWNERS_COLUMN_CONTENT)
+export class FilesColumnContent extends FilesCommon {
+ @property({type: String})
+ path?: string;
+
+ @property({type: String})
+ oldPath?: string;
+
+ @property({type: String, reflect: true, attribute: 'file-status'})
+ fileStatus?: string;
+
+ private owners?: AccountInfo[];
+
+ // taken from Gerrit's common-util.ts
+ private uniqueId = Math.random().toString(36).substring(2);
+
+ static override get styles() {
+ return [
+ ...FilesCommon.commonStyles(),
+ css`
+ :host {
+ display: flex;
+ padding-right: var(--spacing-m);
+ width: 4em;
+ text-align: center;
+ }
+ :host[hidden] {
+ display: none;
+ }
+ gr-icon {
+ padding: var(--spacing-xs) 0px;
+ }
+ :host([file-status='missing']) gr-icon.status {
+ color: #ffa62f;
+ }
+ `,
+ ];
+ }
+
+ override render() {
+ if (this.hidden || !this.fileStatus) {
+ return nothing;
+ }
+
+ const icon = STATUS_ICON[this.fileStatus];
+ return html`
+ <gr-icon
+ id="${this.pathId()}"
+ class="status"
+ icon=${icon}
+ aria-hidden="true"
+ ></gr-icon>
+ ${this.renderFileOwners()}
+ `;
+ }
+
+ private pathId(): string {
+ return `path-${this.uniqueId}`;
+ }
+
+ private renderFileOwners() {
+ const owners = this.owners ?? [];
+ const splicedOwners = owners.splice(0, DISPLAY_OWNERS_FOR_FILE_LIMIT);
+ const showEllipsis = owners.length > DISPLAY_OWNERS_FOR_FILE_LIMIT;
+ // inlining <style> here is ugly but an alternative would be to copy the `HovercardMixin` from Gerrit and implement hoover from scratch
+ return html`<gr-hovercard for="${this.pathId()}">
+ <style>
+ #file-owners-hoovercard {
+ min-width: 256px;
+ max-width: 256px;
+ margin: -10px;
+ padding: var(--spacing-xl) 0 var(--spacing-m) 0;
+ }
+ h3 {
+ font: inherit;
+ }
+ .heading-3 {
+ margin-left: -2px;
+ font-family: var(--header-font-family);
+ font-size: var(--font-size-h3);
+ font-weight: var(--font-weight-h3);
+ line-height: var(--line-height-h3);
+ }
+ .row {
+ display: flex;
+ }
+ div.section {
+ margin: 0 var(--spacing-xl) var(--spacing-m) var(--spacing-xl);
+ display: flex;
+ align-items: center;
+ }
+ div.sectionIcon {
+ flex: 0 0 30px;
+ }
+ div.sectionIcon gr-icon {
+ position: relative;
+ }
+ div.sectionContent .row {
+ margin-left: 2px;
+ }
+ div.sectionContent .notLast {
+ margin-bottom: 2px;
+ }
+ div.sectionContent .ellipsis {
+ margin-left: 5px;
+ }
+ </style>
+ <div id="file-owners-hoovercard">
+ <div class="section">
+ <div class="sectionIcon">
+ <gr-icon class="status" icon="info" aria-hidden="true"></gr-icon>
+ </div>
+ <div class="sectionContent">
+ <h3 class="name heading-3">
+ <span>Needs Owners' Approval</span>
+ </h3>
+ </div>
+ </div>
+ <div class="section">
+ <div class="sectionContent">
+ ${splicedOwners.map((owner, idx) => {
+ const [approval, info] =
+ computeApprovalAndInfo(
+ owner,
+ this.filesOwners?.owners_labels ?? {},
+ this.change
+ ) ?? [];
+ return html`
+ <div
+ class="row ${showEllipsis || idx + 1 < splicedOwners.length
+ ? 'notLast'
+ : ''}"
+ >
+ <gr-owner
+ .owner=${owner}
+ .approval=${approval}
+ .info=${info}
+ .email=${owner.email}
+ ></gr-owner>
+ </div>
+ `;
+ })}
+ ${showEllipsis
+ ? html`
+ <gr-tooltip-content
+ title=${LIMITED_FILES_OWNERS_TOOLTIP}
+ aria-label="limited-file-onwers"
+ aria-description=${LIMITED_FILES_OWNERS_TOOLTIP}
+ has-tooltip
+ >
+ <div class="row ellipsis">...</div>
+ </gt-tooltip-content>`
+ : nothing}
+ </div>
+ </div>
+ </div>
+ </gr-hovercard>`;
+ }
+
+ protected override willUpdate(changedProperties: PropertyValues): void {
+ super.willUpdate(changedProperties);
+ this.computeFileState();
+ }
+
+ private computeFileState(): void {
+ const fileOwnership = getFileOwnership(
+ this.path,
+ this.allFilesApproved,
+ this.filesOwners
+ );
+ if (
+ !fileOwnership ||
+ fileOwnership.fileStatus === FileStatus.NOT_OWNED_OR_APPROVED
+ ) {
+ this.fileStatus = undefined;
+ this.owners = undefined;
+ return;
+ }
+
+ this.fileStatus = FILE_STATUS[fileOwnership.fileStatus];
+ const accounts = getChangeAccounts(this.change);
+
+ // TODO for the time being filter out or group owners - to be decided what/how to display them
+ this.owners = (fileOwnership.owners ?? [])
+ .filter(isOwner)
+ .map(
+ o =>
+ accounts.get(o.id) ?? ({_account_id: o.id} as unknown as AccountInfo)
+ );
+ }
+}
+
+export function shouldHide(
+ change?: ChangeInfo,
+ patchRange?: PatchRange,
+ allFilesApproved?: boolean,
+ userRole?: UserRole
+): boolean {
+ // don't show owners when no change or change is merged
+ if (change === undefined || patchRange === undefined) {
+ return true;
+ }
+ if (
+ change.status === ChangeStatus.ABANDONED ||
+ change.status === ChangeStatus.MERGED
+ ) {
+ return true;
+ }
+
+ // Note: in some special cases, patchNum is undefined on latest patchset
+ // like after publishing the edit, still show for them
+ // TODO: this should be fixed in Gerrit
+ if (patchRange?.patchNum === undefined) return false;
+
+ // only show if its latest patchset
+ const latestPatchset = change.revisions![change.current_revision!];
+ if (`${patchRange.patchNum}` !== `${latestPatchset._number}`) {
+ return true;
+ }
+
+ // show owners when they apply to the change and for logged in user
+ if (
+ !allFilesApproved &&
+ change.submit_requirements &&
+ change.submit_requirements.find(r => r.name === OWNERS_SUBMIT_REQUIREMENT)
+ ) {
+ return !userRole || userRole === UserRole.ANONYMOUS;
+ }
+ return true;
+}
+
+export function getFileOwnership(
+ path?: string,
+ allFilesApproved?: boolean,
+ filesOwners?: FilesOwners
+): FileOwnership | undefined {
+ if (path === undefined || filesOwners === undefined) {
+ return undefined;
+ }
+
+ const fileOwners = (filesOwners.files ?? {})[path];
+ return (allFilesApproved || !fileOwners
+ ? {fileStatus: FileStatus.NOT_OWNED_OR_APPROVED}
+ : {
+ fileStatus: FileStatus.NEEDS_APPROVAL,
+ owners: fileOwners,
+ }) as unknown as FileOwnership;
+}
+
+export function computeApprovalAndInfo(
+ fileOwner: AccountInfo,
+ labels: OwnersLabels,
+ change?: ChangeInfo
+): [ApprovalInfo, LabelInfo] | undefined {
+ if (!change?.labels) {
+ return;
+ }
+ const ownersLabel = labels[`${fileOwner._account_id}`];
+ if (!ownersLabel) {
+ return;
+ }
+
+ for (const label of Object.keys(ownersLabel)) {
+ const info = change.labels[label];
+ if (!info || !isDetailedLabelInfo(info)) {
+ return;
+ }
+
+ const vote = ownersLabel[label];
+ if ((info.default_value && info.default_value === vote) || vote === 0) {
+ // ignore default value
+ return;
+ }
+
+ const approval = info.all?.filter(
+ x => x._account_id === fileOwner._account_id
+ )[0];
+ return approval ? [approval, info] : undefined;
+ }
+
+ return;
+}
+
+export function getChangeAccounts(
+ change?: ChangeInfo
+): Map<number, AccountInfo> {
+ const accounts = new Map();
+ if (!change) {
+ return accounts;
+ }
+
+ [
+ change.owner,
+ ...(change.submitter ? [change.submitter] : []),
+ ...(change.reviewers[ReviewerState.REVIEWER] ?? []),
+ ...(change.reviewers[ReviewerState.CC] ?? []),
+ ].forEach(account => accounts.set(account._account_id, account));
+ return accounts;
+}
diff --git a/owners/web/gr-files_test.ts b/owners/web/gr-files_test.ts
new file mode 100644
index 0000000..0da0a35
--- /dev/null
+++ b/owners/web/gr-files_test.ts
@@ -0,0 +1,410 @@
+/**
+ * @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 {assert} from '@open-wc/testing';
+
+import {
+ computeApprovalAndInfo,
+ getChangeAccounts,
+ getFileOwnership,
+ shouldHide,
+} from './gr-files';
+import {FileOwnership, FileStatus, PatchRange, UserRole} from './owners-model';
+import {
+ AccountInfo,
+ ApprovalInfo,
+ ChangeInfo,
+ ChangeStatus,
+ DetailedLabelInfo,
+ SubmitRequirementResultInfo,
+ ReviewerState,
+} from '@gerritcodereview/typescript-api/rest-api';
+import {FilesOwners, OwnersLabels} from './owners-service';
+import {deepEqual} from './utils';
+import {getRandom} from './test-utils';
+
+suite('owners status tests', () => {
+ const allFilesApproved = true;
+
+ suite('shouldHide tests', () => {
+ const loggedIn = getRandom(UserRole.CHANGE_OWNER, UserRole.OTHER);
+
+ test('shouldHide - should be `true` when change is not defined', () => {
+ const undefinedChange = undefined;
+ const definedPatchRange = {} as unknown as PatchRange;
+ assert.equal(
+ shouldHide(
+ undefinedChange,
+ definedPatchRange,
+ allFilesApproved,
+ loggedIn
+ ),
+ true
+ );
+ });
+
+ test('shouldHide - should be `true` when patch range is not defined', () => {
+ const definedChange = {} as unknown as ChangeInfo;
+ const undefinedPatchRange = undefined;
+ assert.equal(
+ shouldHide(
+ definedChange,
+ undefinedPatchRange,
+ allFilesApproved,
+ loggedIn
+ ),
+ true
+ );
+ });
+
+ test('shouldHide - should be `true` when change is abandoned', () => {
+ const abandonedChange = {
+ status: ChangeStatus.ABANDONED,
+ } as unknown as ChangeInfo;
+ const definedPatchRange = {} as unknown as PatchRange;
+ assert.equal(
+ shouldHide(
+ abandonedChange,
+ definedPatchRange,
+ allFilesApproved,
+ loggedIn
+ ),
+ true
+ );
+ });
+
+ test('shouldHide - should be `true` when change is merged', () => {
+ const mergedChange = {
+ status: ChangeStatus.MERGED,
+ } as unknown as ChangeInfo;
+ const definedPatchRange = {} as unknown as PatchRange;
+ assert.equal(
+ shouldHide(mergedChange, definedPatchRange, allFilesApproved, loggedIn),
+ true
+ );
+ });
+
+ test('shouldHide - should be `true` if not on the latest PS', () => {
+ const changeWithPs2 = {
+ status: ChangeStatus.NEW,
+ revisions: {
+ current_rev: {_number: 2},
+ },
+ current_revision: 'current_rev',
+ } as unknown as ChangeInfo;
+ const patchRangeOnPs1 = {patchNum: 1} as unknown as PatchRange;
+ assert.equal(
+ shouldHide(changeWithPs2, patchRangeOnPs1, allFilesApproved, loggedIn),
+ true
+ );
+ });
+
+ const change = {
+ status: ChangeStatus.NEW,
+ revisions: {
+ current_rev: {_number: 1},
+ },
+ current_revision: 'current_rev',
+ } as unknown as ChangeInfo;
+ const patchRange = {patchNum: 1} as unknown as PatchRange;
+
+ test('shouldHide - should be `true` when change has no submit requirements', () => {
+ assert.equal(
+ shouldHide(change, patchRange, !allFilesApproved, loggedIn),
+ true
+ );
+ });
+
+ test('shouldHide - should be `true` when change has no `Owner-Approval` submit requirements', () => {
+ const changeWithDifferentSubmitReqs = {
+ ...change,
+ submit_requirements: [
+ {name: 'other'},
+ ] as unknown as SubmitRequirementResultInfo[],
+ };
+ assert.equal(
+ shouldHide(
+ changeWithDifferentSubmitReqs,
+ patchRange,
+ !allFilesApproved,
+ loggedIn
+ ),
+ true
+ );
+ });
+
+ const changeWithSubmitRequirements = {
+ ...change,
+ submit_requirements: [
+ {name: 'Owner-Approval'},
+ ] as unknown as SubmitRequirementResultInfo[],
+ };
+
+ test('shouldHide - should be `true` when user is not change owner', () => {
+ const anonymous = UserRole.ANONYMOUS;
+ assert.equal(
+ shouldHide(
+ changeWithSubmitRequirements,
+ patchRange,
+ !allFilesApproved,
+ anonymous
+ ),
+ true
+ );
+ });
+
+ test('shouldHide - should be `true` when change has submit requirements and has all files approved even if user is logged in', () => {
+ assert.equal(
+ shouldHide(
+ changeWithSubmitRequirements,
+ patchRange,
+ allFilesApproved,
+ loggedIn
+ ),
+ true
+ );
+ });
+
+ test('shouldHide - should be `false` when change has submit requirements, has no all files approved and user is logged in', () => {
+ assert.equal(
+ shouldHide(
+ changeWithSubmitRequirements,
+ patchRange,
+ !allFilesApproved,
+ loggedIn
+ ),
+ false
+ );
+ });
+
+ test('shouldHide - should be `false` when in edit mode', () => {
+ const patchRangeWithoutPatchNum = {} as unknown as PatchRange;
+ assert.equal(
+ shouldHide(
+ change,
+ patchRangeWithoutPatchNum,
+ allFilesApproved,
+ loggedIn
+ ),
+ false
+ );
+ });
+ });
+
+ suite('getFileOwnership tests', () => {
+ const path = 'readme.md';
+ const emptyFilesOwners = {} as unknown as FilesOwners;
+ const fileOwnersWithPath = {
+ files: {[path]: [{name: 'John', id: 1}]},
+ } as unknown as FilesOwners;
+
+ test('getFileOwnership - should be `undefined` when path is `undefined', () => {
+ const undefinedPath = undefined;
+ assert.equal(
+ getFileOwnership(undefinedPath, allFilesApproved, emptyFilesOwners),
+ undefined
+ );
+ });
+
+ test('getFileOwnership - should be `undefined` when file owners are `undefined', () => {
+ const undefinedFileOwners = undefined;
+ assert.equal(
+ getFileOwnership(path, allFilesApproved, undefinedFileOwners),
+ undefined
+ );
+ });
+
+ test('getFileOwnership - should return `FileOwnership` with `NOT_OWNED_OR_APPROVED` fileStatus when `allFilesApproved`', () => {
+ assert.equal(
+ deepEqual(
+ getFileOwnership(path, allFilesApproved, fileOwnersWithPath),
+ {fileStatus: FileStatus.NOT_OWNED_OR_APPROVED} as FileOwnership
+ ),
+ true
+ );
+ });
+
+ test('getFileOwnership - should return `FileOwnership` with `NOT_OWNED_OR_APPROVED` fileStatus when file has no owner', () => {
+ assert.equal(
+ deepEqual(getFileOwnership(path, !allFilesApproved, emptyFilesOwners), {
+ fileStatus: FileStatus.NOT_OWNED_OR_APPROVED,
+ } as FileOwnership),
+ true
+ );
+ });
+
+ test('getFileOwnership - should return `FileOwnership` with `NEEDS_APPROVAL` fileStatus when file has owner', () => {
+ assert.equal(
+ deepEqual(
+ getFileOwnership(path, !allFilesApproved, fileOwnersWithPath),
+ {
+ fileStatus: FileStatus.NEEDS_APPROVAL,
+ owners: [{name: 'John', id: 1}],
+ } as FileOwnership
+ ),
+ true
+ );
+ });
+ });
+
+ suite('computeApprovalAndInfo tests', () => {
+ const account = 1;
+ const fileOwner = {_account_id: account} as unknown as AccountInfo;
+ const label = 'Code-Review';
+ const crPlus1OwnersVote = {
+ [`${account}`]: {[label]: 1},
+ } as unknown as OwnersLabels;
+ const changeWithLabels = {
+ labels: {
+ [label]: {
+ all: [
+ {
+ value: 1,
+ date: '2024-10-22 17:26:21.000000000',
+ permitted_voting_range: {
+ min: -2,
+ max: 2,
+ },
+ _account_id: account,
+ },
+ ],
+ values: {
+ '-2': 'This shall not be submitted',
+ '-1': 'I would prefer this is not submitted as is',
+ ' 0': 'No score',
+ '+1': 'Looks good to me, but someone else must approve',
+ '+2': 'Looks good to me, approved',
+ },
+ description: '',
+ default_value: 0,
+ },
+ },
+ } as unknown as ChangeInfo;
+
+ test('computeApprovalAndInfo - should be `undefined` when change is `undefined', () => {
+ const undefinedChange = undefined;
+ assert.equal(
+ computeApprovalAndInfo(fileOwner, crPlus1OwnersVote, undefinedChange),
+ undefined
+ );
+ });
+
+ test('computeApprovalAndInfo - should be `undefined` when there is no owners vote', () => {
+ const emptyOwnersVote = {};
+ assert.equal(
+ computeApprovalAndInfo(fileOwner, emptyOwnersVote, changeWithLabels),
+ undefined
+ );
+ });
+
+ test('computeApprovalAndInfo - should be `undefined` for default owners vote', () => {
+ const defaultOwnersVote = {[label]: 0} as unknown as OwnersLabels;
+ assert.equal(
+ computeApprovalAndInfo(fileOwner, defaultOwnersVote, changeWithLabels),
+ undefined
+ );
+ });
+
+ test('computeApprovalAndInfo - should be computed for CR+1 owners vote', () => {
+ const expectedApproval = {
+ value: 1,
+ date: '2024-10-22 17:26:21.000000000',
+ permitted_voting_range: {
+ min: -2,
+ max: 2,
+ },
+ _account_id: account,
+ } as unknown as ApprovalInfo;
+ const expectedInfo = {
+ all: [expectedApproval],
+ values: {
+ '-2': 'This shall not be submitted',
+ '-1': 'I would prefer this is not submitted as is',
+ ' 0': 'No score',
+ '+1': 'Looks good to me, but someone else must approve',
+ '+2': 'Looks good to me, approved',
+ },
+ description: '',
+ default_value: 0,
+ } as unknown as DetailedLabelInfo;
+
+ assert.equal(
+ deepEqual(
+ computeApprovalAndInfo(
+ fileOwner,
+ crPlus1OwnersVote,
+ changeWithLabels
+ ),
+ [expectedApproval, expectedInfo]
+ ),
+ true
+ );
+ });
+ });
+
+ suite('getChangeAccounts tests', () => {
+ test('getChangeAccounts - should return empty map when change is `undefined', () => {
+ const undefinedChange = undefined;
+ assert.equal(getChangeAccounts(undefinedChange).size, 0);
+ });
+
+ test('getChangeAccounts - should return map with owner when change has only owner and empty reviewers defined', () => {
+ const owner = account(1);
+ const changeWithOwner = {
+ owner,
+ reviewers: {},
+ } as unknown as ChangeInfo;
+ assert.equal(
+ deepEqual(getChangeAccounts(changeWithOwner), new Map([[1, owner]])),
+ true
+ );
+ });
+
+ test('getChangeAccounts - should return map with owner, submitter and reviewers', () => {
+ const owner = account(1);
+ const submitter = account(2);
+ const reviewer = account(3);
+ const ccReviewer = account(4);
+ const change = {
+ owner,
+ submitter,
+ reviewers: {
+ [ReviewerState.REVIEWER]: [reviewer],
+ [ReviewerState.CC]: [ccReviewer],
+ },
+ } as unknown as ChangeInfo;
+ assert.equal(
+ deepEqual(
+ getChangeAccounts(change),
+ new Map([
+ [1, owner],
+ [2, submitter],
+ [3, reviewer],
+ [4, ccReviewer],
+ ])
+ ),
+ true
+ );
+ });
+ });
+});
+
+function account(id: number) {
+ return {
+ _account_id: id,
+ } as unknown as AccountInfo;
+}
diff --git a/owners/web/gr-owned-files.ts b/owners/web/gr-owned-files.ts
new file mode 100644
index 0000000..969d91c
--- /dev/null
+++ b/owners/web/gr-owned-files.ts
@@ -0,0 +1,331 @@
+/**
+ * @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 {css, html, LitElement, PropertyValues, nothing, CSSResult} from 'lit';
+import {ifDefined} from 'lit/directives/if-defined.js';
+import {OwnersMixin} from './owners-mixin';
+import {customElement, property} from 'lit/decorators';
+import {
+ AccountInfo,
+ ChangeInfo,
+ ChangeStatus,
+ RevisionInfo,
+ EDIT,
+} from '@gerritcodereview/typescript-api/rest-api';
+import {User, UserRole} from './owners-model';
+import {isOwner, OwnedFiles, OWNERS_SUBMIT_REQUIREMENT} from './owners-service';
+import {
+ computeDisplayPath,
+ diffFilePaths,
+ encodeURL,
+ getBaseUrl,
+ truncatePath,
+} from './utils';
+
+const common = OwnersMixin(LitElement);
+
+class OwnedFilesCommon extends common {
+ @property({type: Object})
+ revision?: RevisionInfo;
+
+ protected ownedFiles?: string[];
+
+ protected override willUpdate(changedProperties: PropertyValues): void {
+ super.willUpdate(changedProperties);
+ this.computeOwnedFiles();
+
+ this.hidden = shouldHide(
+ this.change,
+ this.revision,
+ this.allFilesApproved,
+ this.user,
+ this.ownedFiles
+ );
+ }
+
+ static commonStyles(): CSSResult[] {
+ return [window?.Gerrit?.styles.font as CSSResult];
+ }
+
+ private computeOwnedFiles() {
+ this.ownedFiles = ownedFiles(this.user?.account, this.filesOwners?.files);
+ }
+}
+
+export const OWNED_FILES_TAB_HEADER = 'owned-files-tab-header';
+@customElement(OWNED_FILES_TAB_HEADER)
+export class OwnedFilesTabHeader extends OwnedFilesCommon {
+ static override get styles() {
+ return [...OwnedFilesCommon.commonStyles()];
+ }
+
+ override render() {
+ if (this.hidden) return nothing;
+ return html`<div>Owned Files</div>`;
+ }
+}
+
+@customElement('gr-owned-files-list')
+export class GrOwnedFilesList extends LitElement {
+ @property({type: Object})
+ change?: ChangeInfo;
+
+ @property({type: Object})
+ revision?: RevisionInfo;
+
+ @property({type: Array})
+ ownedFiles?: string[];
+
+ static override get styles() {
+ return [
+ ...OwnedFilesCommon.commonStyles(),
+ css`
+ :host {
+ display: block;
+ }
+ .row {
+ align-items: center;
+ border-top: 1px solid var(--border-color);
+ display: flex;
+ min-height: calc(var(--line-height-normal) + 2 * var(--spacing-s));
+ padding: var(--spacing-xs) var(--spacing-l);
+ }
+ .header-row {
+ background-color: var(--background-color-secondary);
+ }
+ .file-row {
+ cursor: pointer;
+ }
+ .file-row:hover {
+ background-color: var(--hover-background-color);
+ }
+ .file-row.selected {
+ background-color: var(--selection-background-color);
+ }
+ .path {
+ cursor: pointer;
+ flex: 1;
+ /* Wrap it into multiple lines if too long. */
+ white-space: normal;
+ word-break: break-word;
+ }
+ .matchingFilePath {
+ color: var(--deemphasized-text-color);
+ }
+ .newFilePath {
+ color: var(--primary-text-color);
+ }
+ .fileName {
+ color: var(--link-color);
+ }
+ .truncatedFileName {
+ display: none;
+ }
+ .row:focus {
+ outline: none;
+ }
+ .row:hover {
+ opacity: 100;
+ }
+ .pathLink {
+ display: inline-block;
+ margin: -2px 0;
+ padding: var(--spacing-s) 0;
+ text-decoration: none;
+ }
+ .pathLink:hover span.fullFileName,
+ .pathLink:hover span.truncatedFileName {
+ text-decoration: underline;
+ }
+ `,
+ ];
+ }
+
+ override render() {
+ if (!this.change || !this.revision || !this.ownedFiles) return nothing;
+
+ return html`
+ <div id="container" role="grid" aria-label="Owned files list">
+ ${this.renderOwnedFilesHeaderRow()}
+ ${this.ownedFiles?.map((ownedFile, index) =>
+ this.renderOwnedFileRow(ownedFile, index)
+ )}
+ </div>
+ `;
+ }
+
+ private renderOwnedFilesHeaderRow() {
+ return html`
+ <div class="header-row row" role="row">
+ <div class="path" role="columnheader">File</div>
+ </div>
+ `;
+ }
+
+ private renderOwnedFileRow(ownedFile: string, index: number) {
+ return html`
+ <div
+ class="file-row row"
+ tabindex="-1"
+ role="row"
+ aria-label=${ownedFile}
+ >
+ ${this.renderFilePath(ownedFile, index)}
+ </div>
+ `;
+ }
+
+ private renderFilePath(file: string, index: number) {
+ const displayPath = computeDisplayPath(file);
+ const previousFile = (this.ownedFiles ?? [])[index - 1];
+ return html`
+ <span class="path" role="gridcell">
+ <a
+ class="pathLink"
+ href=${ifDefined(computeDiffUrl(file, this.change, this.revision))}
+ >
+ <span title=${displayPath} class="fullFileName">
+ ${this.renderStyledPath(file, previousFile)}
+ </span>
+ <span title=${displayPath} class="truncatedFileName">
+ ${truncatePath(displayPath)}
+ </span>
+ </a>
+ </span>
+ `;
+ }
+
+ private renderStyledPath(filePath: string, previousFilePath?: string) {
+ const {matchingFolders, newFolders, fileName} = diffFilePaths(
+ filePath,
+ previousFilePath
+ );
+ return [
+ matchingFolders.length > 0
+ ? html`<span class="matchingFilePath">${matchingFolders}</span>`
+ : nothing,
+ newFolders.length > 0
+ ? html`<span class="newFilePath">${newFolders}</span>`
+ : nothing,
+ html`<span class="fileName">${fileName}</span>`,
+ ];
+ }
+}
+
+export const OWNED_FILES_TAB_CONTENT = 'owned-files-tab-content';
+@customElement(OWNED_FILES_TAB_CONTENT)
+export class OwnedFilesTabContent extends OwnedFilesCommon {
+ static override get styles() {
+ return [
+ ...OwnedFilesCommon.commonStyles(),
+ css`
+ :host {
+ display: block;
+ }
+ `,
+ ];
+ }
+
+ override render() {
+ if (this.hidden) return nothing;
+
+ return html`
+ <gr-owned-files-list
+ id="ownedFilesList"
+ .change=${this.change}
+ .revision=${this.revision}
+ .ownedFiles=${this.ownedFiles}
+ >
+ </gr-owned-files-list>
+ `;
+ }
+}
+
+export function shouldHide(
+ change?: ChangeInfo,
+ revision?: RevisionInfo,
+ allFilesApproved?: boolean,
+ user?: User,
+ ownedFiles?: string[]
+) {
+ // don't show owned files when no change or change is abandoned/merged or being edited
+ if (
+ change === undefined ||
+ change.status === ChangeStatus.ABANDONED ||
+ change.status === ChangeStatus.MERGED ||
+ revision === undefined ||
+ revision._number === EDIT
+ ) {
+ return true;
+ }
+
+ // show owned files if user owns anything
+ if (
+ !allFilesApproved &&
+ change.submit_requirements &&
+ change.submit_requirements.find(r => r.name === OWNERS_SUBMIT_REQUIREMENT)
+ ) {
+ return (
+ !user ||
+ user.role === UserRole.ANONYMOUS ||
+ (ownedFiles ?? []).length === 0
+ );
+ }
+ return true;
+}
+
+export function ownedFiles(
+ owner?: AccountInfo,
+ files?: OwnedFiles
+): string[] | undefined {
+ if (!owner || !files) {
+ return;
+ }
+
+ const ownedFiles = [];
+ for (const file of Object.keys(files)) {
+ if (
+ files[file].find(
+ fileOwner => isOwner(fileOwner) && fileOwner.id === owner._account_id
+ )
+ ) {
+ ownedFiles.push(file);
+ }
+ }
+
+ return ownedFiles;
+}
+
+export function computeDiffUrl(
+ file: string,
+ change?: ChangeInfo,
+ revision?: RevisionInfo
+) {
+ if (change === undefined || revision?._number === undefined) {
+ return;
+ }
+
+ let repo = '';
+ if (change.project) repo = `${encodeURL(change.project)}/+/`;
+
+ // TODO it can be a range of patchsets but `PatchRange` is not passed to the `change-view-tab-content` :/ fix in Gerrit?
+ const range = `/${revision._number}`;
+
+ const path = `/${encodeURL(file)}`;
+
+ return `${getBaseUrl()}/c/${repo}${change._number}${range}${path}`;
+}
diff --git a/owners/web/gr-owned-files_test.ts b/owners/web/gr-owned-files_test.ts
new file mode 100644
index 0000000..0437ab9
--- /dev/null
+++ b/owners/web/gr-owned-files_test.ts
@@ -0,0 +1,185 @@
+/**
+ * @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 {assert} from '@open-wc/testing';
+import {
+ AccountInfo,
+ ChangeInfo,
+ ChangeStatus,
+ RevisionInfo,
+ EDIT,
+ SubmitRequirementResultInfo,
+} from '@gerritcodereview/typescript-api/rest-api';
+import {ownedFiles, shouldHide} from './gr-owned-files';
+import {OwnedFiles, Owner} from './owners-service';
+import {deepEqual} from './utils';
+import {User, UserRole} from './owners-model';
+import {getRandom} from './test-utils';
+
+suite('owned files tests', () => {
+ suite('ownedFiles tests', () => {
+ const ownerAccountId = 1;
+ const owner = account(ownerAccountId);
+
+ const ownedFile = 'README.md';
+ const files = {
+ [ownedFile]: [fileOwner(1)],
+ 'some.text': [fileOwner(6)],
+ } as unknown as OwnedFiles;
+
+ test('ownedFiles - should be `undefined` when owner is `undefined`', () => {
+ const undefinedOwner = undefined;
+ assert.equal(ownedFiles(undefinedOwner, files), undefined);
+ });
+
+ test('ownedFiles - should be `undefined` when files are `undefined`', () => {
+ const undefinedFiles = undefined;
+ assert.equal(ownedFiles(owner, undefinedFiles), undefined);
+ });
+
+ test('ownedFiles - should return empty owned file when no files are owned by user', () => {
+ const user = account(2);
+ assert.equal(deepEqual(ownedFiles(user, files), []), true);
+ });
+
+ test('ownedFiles - should return owned files', () => {
+ assert.equal(deepEqual(ownedFiles(owner, files), [ownedFile]), true);
+ });
+ });
+
+ suite('shouldHide tests', () => {
+ const change = {
+ status: ChangeStatus.NEW,
+ submit_requirements: [
+ {name: 'Owner-Approval'},
+ ] as unknown as SubmitRequirementResultInfo[],
+ } as unknown as ChangeInfo;
+ const revisionInfo = {_number: 1} as unknown as RevisionInfo;
+ const allFilesApproved = true;
+ const user = {account: account(1), role: UserRole.OTHER};
+ const ownedFiles = ['README.md'];
+
+ test('shouldHide - should be `true` when change is `undefined`', () => {
+ const undefinedChange = undefined;
+ assert.equal(
+ shouldHide(
+ undefinedChange,
+ revisionInfo,
+ !allFilesApproved,
+ user,
+ ownedFiles
+ ),
+ true
+ );
+ });
+
+ test('shouldHide - should be `true` when change is `ABANDONED` or `MERGED`', () => {
+ const abandonedOrMergedChange = {
+ ...change,
+ status: getRandom(ChangeStatus.ABANDONED, ChangeStatus.MERGED),
+ };
+ assert.equal(
+ shouldHide(
+ abandonedOrMergedChange,
+ revisionInfo,
+ !allFilesApproved,
+ user,
+ ownedFiles
+ ),
+ true
+ );
+ });
+
+ test('shouldHide - should be `true` when revisionInfo is `undefined` or in `EDIT` mode', () => {
+ const undefinedOrEditRevisionInfo = getRandom(undefined, {
+ _number: EDIT,
+ } as unknown as RevisionInfo);
+ assert.equal(
+ shouldHide(
+ change,
+ undefinedOrEditRevisionInfo,
+ !allFilesApproved,
+ user,
+ ownedFiles
+ ),
+ true
+ );
+ });
+
+ test('shouldHide - should be `true` when change has different submit requirements', () => {
+ const changeWithOtherSubmitRequirements = {
+ ...change,
+ submit_requirements: [
+ {name: 'Other'},
+ ] as unknown as SubmitRequirementResultInfo[],
+ };
+ assert.equal(
+ shouldHide(
+ changeWithOtherSubmitRequirements,
+ revisionInfo,
+ !allFilesApproved,
+ user,
+ ownedFiles
+ ),
+ true
+ );
+ });
+
+ test('shouldHide - should be `true` when all files are approved', () => {
+ assert.equal(
+ shouldHide(change, revisionInfo, allFilesApproved, user, ownedFiles),
+ true
+ );
+ });
+
+ test('shouldHide - should be `true` when user is `undefined` or `ANONYMOUS`', () => {
+ const undefinedOrAnonymousUser = getRandom(undefined, {
+ role: UserRole.ANONYMOUS,
+ } as unknown as User);
+ assert.equal(
+ shouldHide(
+ change,
+ revisionInfo,
+ !allFilesApproved,
+ undefinedOrAnonymousUser,
+ ownedFiles
+ ),
+ true
+ );
+ });
+
+ test('shouldHide - should be `false` when user owns files', () => {
+ assert.equal(
+ shouldHide(change, revisionInfo, !allFilesApproved, user, ownedFiles),
+ false
+ );
+ });
+ });
+});
+
+function account(id: number): AccountInfo {
+ return {
+ _account_id: id,
+ } as unknown as AccountInfo;
+}
+
+function fileOwner(id: number): Owner {
+ return {
+ id,
+ name: `name for account: ${id}`,
+ } as unknown as Owner;
+}
diff --git a/owners/web/karma_test.sh b/owners/web/karma_test.sh
new file mode 100755
index 0000000..7cecdae
--- /dev/null
+++ b/owners/web/karma_test.sh
@@ -0,0 +1,6 @@
+#!/bin/bash
+set -euo pipefail
+./$1 start $2 --single-run \
+ --root 'plugins/owners/web/_bazel_ts_out_tests/' \
+ --test-files '*_test.js' \
+ --browsers ${3:-ChromeHeadless}
diff --git a/owners/web/owners-mixin.ts b/owners/web/owners-mixin.ts
new file mode 100644
index 0000000..0bc813a
--- /dev/null
+++ b/owners/web/owners-mixin.ts
@@ -0,0 +1,135 @@
+/**
+ * @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 {Subscription} from 'rxjs';
+import {LitElement, PropertyValues} from 'lit';
+import {property, state} from 'lit/decorators';
+import {ChangeInfo} from '@gerritcodereview/typescript-api/rest-api';
+import {FilesOwners, OwnersService} from './owners-service';
+import {RestPluginApi} from '@gerritcodereview/typescript-api/rest';
+import {ModelLoader, OwnersModel, PatchRange, User} from './owners-model';
+
+// Lit mixin definition as described in https://lit.dev/docs/composition/mixins/
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export type Constructor<T> = new (...args: any[]) => T;
+
+export interface OwnersInterface extends LitElement {
+ change?: ChangeInfo;
+ patchRange?: PatchRange;
+ restApi?: RestPluginApi;
+ user?: User;
+ allFilesApproved?: boolean;
+ filesOwners?: FilesOwners;
+
+ onModelUpdate(): void;
+}
+
+export const OwnersMixin = <T extends Constructor<LitElement>>(
+ superClass: T
+) => {
+ class Mixin extends superClass {
+ @property({type: Object})
+ change?: ChangeInfo;
+
+ @property({type: Object})
+ patchRange?: PatchRange;
+
+ @property({type: Object})
+ restApi?: RestPluginApi;
+
+ @state()
+ user?: User;
+
+ @state()
+ allFilesApproved?: boolean;
+
+ @state()
+ filesOwners?: FilesOwners;
+
+ private _model?: OwnersModel;
+
+ modelLoader?: ModelLoader;
+
+ private subscriptions: Array<Subscription> = [];
+
+ get model() {
+ return this._model;
+ }
+
+ set model(model: OwnersModel | undefined) {
+ if (this._model === model) return;
+ for (const s of this.subscriptions) {
+ s.unsubscribe();
+ }
+ this.subscriptions = [];
+ this._model = model;
+ if (!model) return;
+
+ this.subscriptions.push(
+ model.state$.subscribe(s => {
+ this.user = s.user;
+ })
+ );
+
+ this.subscriptions.push(
+ model.state$.subscribe(s => {
+ this.allFilesApproved = s.allFilesApproved;
+ })
+ );
+
+ this.subscriptions.push(
+ model.state$.subscribe(s => {
+ this.filesOwners = s.filesOwners;
+ })
+ );
+
+ this.onModelUpdate();
+ }
+
+ protected override willUpdate(changedProperties: PropertyValues): void {
+ super.willUpdate(changedProperties);
+
+ if (changedProperties.has('change') || changedProperties.has('restApi')) {
+ if (!this.restApi || !this.change) {
+ this.model = undefined;
+ this.modelLoader = undefined;
+ return;
+ }
+ const service = OwnersService.getOwnersService(
+ this.restApi,
+ this.change
+ );
+ const model = OwnersModel.getModel(this.change);
+ this.modelLoader = new ModelLoader(service, model);
+ this.model = model;
+ }
+ }
+
+ protected onModelUpdate() {
+ this.modelLoader?.loadUser();
+ this.modelLoader?.loadAllFilesApproved();
+ this.modelLoader?.loadFilesOwners();
+ }
+
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ constructor(...args: any[]) {
+ super(...args);
+ }
+ }
+
+ return Mixin as unknown as T & Constructor<OwnersInterface>;
+};
diff --git a/owners/web/owners-model.ts b/owners/web/owners-model.ts
new file mode 100644
index 0000000..6d048e0
--- /dev/null
+++ b/owners/web/owners-model.ts
@@ -0,0 +1,164 @@
+/**
+ * @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 {BehaviorSubject, Observable} from 'rxjs';
+import {
+ AccountInfo,
+ BasePatchSetNum,
+ ChangeInfo,
+ RevisionPatchSetNum,
+} from '@gerritcodereview/typescript-api/rest-api';
+import {FileOwner, FilesOwners, OwnersService} from './owners-service';
+import {deepEqual} from './utils';
+
+export interface PatchRange {
+ patchNum: RevisionPatchSetNum;
+ basePatchNum: BasePatchSetNum;
+}
+
+export enum UserRole {
+ ANONYMOUS = 'ANONYMOUS',
+ CHANGE_OWNER = 'CHANGE_OWNER',
+ OTHER = 'OTHER',
+}
+
+export interface User {
+ account?: AccountInfo;
+ role: UserRole;
+}
+
+export interface OwnersState {
+ user?: User;
+ allFilesApproved?: boolean;
+ filesOwners?: FilesOwners;
+}
+
+/**
+ * TODO: The plugin's REST endpoint returns only files that still need to be reviewed which means that file can only have two states:
+ * * needs approval
+ * * was not subject of OWNERS file or is already approved
+ */
+export enum FileStatus {
+ NEEDS_APPROVAL = 'NEEDS_APPROVAL',
+ NOT_OWNED_OR_APPROVED = 'NOT_OWNED_OR_APPROVED',
+}
+
+/**
+ * TODO: extend FileOwnership with owners when it will be used by UI elements
+ */
+export interface FileOwnership {
+ fileStatus: FileStatus;
+ owners?: FileOwner[];
+}
+
+let ownersModel: OwnersModel | undefined;
+
+export class OwnersModel extends EventTarget {
+ private subject$: BehaviorSubject<OwnersState> = new BehaviorSubject(
+ {} as OwnersState
+ );
+
+ public state$: Observable<OwnersState> = this.subject$.asObservable();
+
+ constructor(readonly change: ChangeInfo) {
+ super();
+ }
+
+ get state() {
+ return this.subject$.getValue();
+ }
+
+ private setState(state: OwnersState) {
+ this.subject$.next(Object.freeze(state));
+ }
+
+ setUser(user: User) {
+ const current = this.subject$.getValue();
+ if (current.user === user) return;
+ this.setState({...current, user});
+ }
+
+ setAllFilesApproved(allFilesApproved: boolean | undefined) {
+ const current = this.subject$.getValue();
+ if (current.allFilesApproved === allFilesApproved) return;
+ this.setState({...current, allFilesApproved});
+ }
+
+ setFilesOwners(filesOwners: FilesOwners | undefined) {
+ const current = this.subject$.getValue();
+ if (deepEqual(current.filesOwners, filesOwners)) return;
+ this.setState({...current, filesOwners});
+ }
+
+ static getModel(change: ChangeInfo) {
+ if (!ownersModel || ownersModel.change !== change) {
+ ownersModel = new OwnersModel(change);
+ }
+ return ownersModel;
+ }
+}
+
+export class ModelLoader {
+ constructor(
+ private readonly service: OwnersService,
+ private readonly model: OwnersModel
+ ) {}
+
+ async loadUser() {
+ await this._loadProperty(
+ 'user',
+ () => this.service.getLoggedInUser(),
+ value => this.model.setUser(value)
+ );
+ }
+
+ async loadAllFilesApproved() {
+ await this._loadProperty(
+ 'allFilesApproved',
+ () => this.service.getAllFilesApproved(),
+ value => this.model.setAllFilesApproved(value)
+ );
+ }
+
+ async loadFilesOwners() {
+ await this._loadProperty(
+ 'filesOwners',
+ () => this.service.getFilesOwners(),
+ value => this.model.setFilesOwners(value)
+ );
+ }
+
+ private async _loadProperty<K extends keyof OwnersState, T>(
+ propertyName: K,
+ propertyLoader: () => Promise<T>,
+ propertySetter: (value: T) => void
+ ) {
+ if (this.model.state[propertyName] !== undefined) return;
+ let newValue: T;
+ try {
+ newValue = await propertyLoader();
+ } catch (e) {
+ console.error(e);
+ return;
+ }
+ // It is possible, that several requests is made in parallel.
+ // Store only the first result and discard all other results.
+ // (also, due to the CodeOwnersCacheApi all result must be identical)
+ if (this.model.state[propertyName] !== undefined) return;
+ propertySetter(newValue);
+ }
+}
diff --git a/owners/web/owners-service.ts b/owners/web/owners-service.ts
new file mode 100644
index 0000000..f1bb4fd
--- /dev/null
+++ b/owners/web/owners-service.ts
@@ -0,0 +1,190 @@
+/**
+ * @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,
+ ChangeStatus,
+ NumericChangeId,
+ RepoName,
+ SubmitRequirementStatus,
+} 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;
+ 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) {}
+
+ 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,
+ * 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;
+ }
+ }
+}
+
+let service: OwnersService | undefined;
+
+export class OwnersService {
+ private api: OwnersApi;
+
+ constructor(readonly restApi: RestPluginApi, readonly change: ChangeInfo) {
+ this.api = new OwnersApi(restApi);
+ }
+
+ async getLoggedInUser(): 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;
+ }
+
+ 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,
+ this.change.current_revision ?? 'current'
+ );
+ }
+
+ private async isLoggedIn(): Promise<boolean> {
+ const user = await this.getLoggedInUser();
+ return user && user.role !== UserRole.ANONYMOUS;
+ }
+
+ static getOwnersService(restApi: RestPluginApi, change: ChangeInfo) {
+ if (!service || service.change !== change) {
+ service = new OwnersService(restApi, change);
+ }
+ return service;
+ }
+}
diff --git a/owners/web/owners-service_test.ts b/owners/web/owners-service_test.ts
new file mode 100644
index 0000000..0b77f66
--- /dev/null
+++ b/owners/web/owners-service_test.ts
@@ -0,0 +1,308 @@
+/**
+ * @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 {FilesOwners, OwnersService} from './owners-service';
+import {
+ RequestPayload,
+ RestPluginApi,
+} from '@gerritcodereview/typescript-api/rest';
+import {
+ AccountInfo,
+ 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;
+ const fakeChange = {} as unknown as ChangeInfo;
+
+ suite('basic api request tests', () => {
+ test('getOwnersService - same change returns the same instance', () => {
+ assert.equal(
+ OwnersService.getOwnersService(fakeRestApi, fakeChange),
+ OwnersService.getOwnersService(fakeRestApi, fakeChange)
+ );
+ });
+
+ test('getOwnersService - modified change returns new instance', () => {
+ assert.notEqual(
+ OwnersService.getOwnersService(fakeRestApi, {...fakeChange}),
+ OwnersService.getOwnersService(fakeRestApi, {...fakeChange})
+ );
+ });
+ });
+
+ suite('user role tests', () => {
+ test('getLoggedInUser - returns ANONYMOUS when user not logged in', async () => {
+ const notLoggedInApi = {
+ getLoggedIn() {
+ return Promise.resolve(false);
+ },
+ } as unknown as RestPluginApi;
+
+ const service = OwnersService.getOwnersService(
+ notLoggedInApi,
+ fakeChange
+ );
+ const user = await service.getLoggedInUser();
+ assert.equal(user.role, UserRole.ANONYMOUS);
+ assert.equal(user.account, undefined);
+ });
+
+ test('getLoggedInUser - returns OTHER for logged in user that is NOT change owner', async () => {
+ const loggedUser = account(2);
+ const userLoggedInApi = {
+ getLoggedIn() {
+ return Promise.resolve(true);
+ },
+ getAccount() {
+ return Promise.resolve(loggedUser);
+ },
+ } as unknown as RestPluginApi;
+ const change = {owner: account(1)} as unknown as ChangeInfo;
+
+ const service = OwnersService.getOwnersService(userLoggedInApi, change);
+ const user = await service.getLoggedInUser();
+ assert.equal(user.role, UserRole.OTHER);
+ assert.equal(user.account, loggedUser);
+ });
+
+ test('getLoggedInUser - returns CHANGE_OWNER for logged in user that is a change owner', async () => {
+ const owner = account(1);
+ const changeOwnerLoggedInApi = {
+ getLoggedIn() {
+ return Promise.resolve(true);
+ },
+ getAccount() {
+ return Promise.resolve(owner);
+ },
+ } as unknown as RestPluginApi;
+ const change = {owner} as unknown as ChangeInfo;
+
+ const service = OwnersService.getOwnersService(
+ changeOwnerLoggedInApi,
+ change
+ );
+ const user = await service.getLoggedInUser();
+ assert.equal(user.role, UserRole.CHANGE_OWNER);
+ assert.equal(user.account, 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): AccountInfo {
+ return {
+ _account_id: id,
+ } as unknown as AccountInfo;
+}
+
+function flush() {
+ return new Promise((resolve, _reject) => {
+ setTimeout(resolve, 0);
+ });
+}
diff --git a/owners/web/plugin.ts b/owners/web/plugin.ts
new file mode 100644
index 0000000..596c8ab
--- /dev/null
+++ b/owners/web/plugin.ts
@@ -0,0 +1,66 @@
+/**
+ * @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 '@gerritcodereview/typescript-api/gerrit';
+import {
+ FILES_OWNERS_COLUMN_CONTENT,
+ FILES_OWNERS_COLUMN_HEADER,
+ FilesColumnContent,
+ FilesColumnHeader,
+} from './gr-files';
+import {
+ OWNED_FILES_TAB_CONTENT,
+ OWNED_FILES_TAB_HEADER,
+ OwnedFilesTabContent,
+ OwnedFilesTabHeader,
+} from './gr-owned-files';
+
+window.Gerrit.install(plugin => {
+ const restApi = plugin.restApi();
+
+ plugin
+ .registerDynamicCustomComponent(
+ 'change-view-file-list-header-prepend',
+ FILES_OWNERS_COLUMN_HEADER
+ )
+ .onAttached(view => {
+ (view as unknown as FilesColumnHeader).restApi = restApi;
+ });
+ plugin
+ .registerDynamicCustomComponent(
+ 'change-view-file-list-content-prepend',
+ FILES_OWNERS_COLUMN_CONTENT
+ )
+ .onAttached(view => {
+ (view as unknown as FilesColumnContent).restApi = restApi;
+ });
+ plugin
+ .registerDynamicCustomComponent(
+ 'change-view-tab-header',
+ OWNED_FILES_TAB_HEADER
+ )
+ .onAttached(view => {
+ (view as unknown as OwnedFilesTabHeader).restApi = restApi;
+ });
+ plugin
+ .registerDynamicCustomComponent(
+ 'change-view-tab-content',
+ OWNED_FILES_TAB_CONTENT
+ )
+ .onAttached(view => {
+ (view as unknown as OwnedFilesTabContent).restApi = restApi;
+ });
+});
diff --git a/owners/web/test-utils.ts b/owners/web/test-utils.ts
new file mode 100644
index 0000000..8f0c8a6
--- /dev/null
+++ b/owners/web/test-utils.ts
@@ -0,0 +1,21 @@
+/**
+ * @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 getRandom<T>(...values: T[]): T {
+ const idx = Math.floor(Math.random() * values.length);
+ return values[idx];
+}
diff --git a/owners/web/tsconfig.json b/owners/web/tsconfig.json
new file mode 100644
index 0000000..0a15ca9
--- /dev/null
+++ b/owners/web/tsconfig.json
@@ -0,0 +1,15 @@
+{
+ "extends": "../../tsconfig-plugins-base.json",
+ "compilerOptions": {
+ "outDir": "../../../.ts-out/plugins/owners", /* overridden by bazel */
+ "lib": [
+ "dom",
+ "dom.iterable",
+ "es2021",
+ "webworker"
+ ],
+ },
+ "include": [
+ "**/*.ts"
+ ],
+}
diff --git a/owners/web/utils.ts b/owners/web/utils.ts
new file mode 100644
index 0000000..81e0adb
--- /dev/null
+++ b/owners/web/utils.ts
@@ -0,0 +1,222 @@
+/**
+ * @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 {SpecialFilePath} from './gerrit-model';
+
+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;
+}
+
+/**
+ * Queries for child element specified with a selector. Copied from Gerrit's common-util.ts.
+ */
+export function query<E extends Element = Element>(
+ el: Element | null | undefined,
+ selector: string
+): E | undefined {
+ if (!el) return undefined;
+ if (el.shadowRoot) {
+ const r = el.shadowRoot.querySelector<E>(selector);
+ if (r) return r;
+ }
+ return el.querySelector<E>(selector) ?? undefined;
+}
+
+/**
+ * Copied from Gerrit's path-list-util.ts.
+ */
+export function computeDisplayPath(path?: string) {
+ if (path === SpecialFilePath.COMMIT_MESSAGE) {
+ return 'Commit message';
+ } else if (path === SpecialFilePath.MERGE_LIST) {
+ return 'Merge list';
+ }
+ return path ?? '';
+}
+
+/**
+ * Separates a path into:
+ * - The part that matches another path,
+ * - The part that does not match the other path,
+ * - The file name
+ *
+ * For example:
+ * diffFilePaths('same/part/new/part/foo.js', 'same/part/different/foo.js');
+ * yields: {
+ * matchingFolders: 'same/part/',
+ * newFolders: 'new/part/',
+ * fileName: 'foo.js',
+ * }
+ *
+ * Copied from Gerrit's string-util.ts.
+ */
+export function diffFilePaths(filePath: string, otherFilePath?: string) {
+ // Separate each string into an array of folder names + file name.
+ const displayPath = computeDisplayPath(filePath);
+ const previousFileDisplayPath = computeDisplayPath(otherFilePath);
+ const displayPathParts = displayPath.split('/');
+ const previousFileDisplayPathParts = previousFileDisplayPath.split('/');
+
+ // Construct separate strings for matching folders, new folders, and file
+ // name.
+ const firstDifferencePartIndex = displayPathParts.findIndex(
+ (part, index) => previousFileDisplayPathParts[index] !== part
+ );
+ const matchingSection = displayPathParts
+ .slice(0, firstDifferencePartIndex)
+ .join('/');
+ const newFolderSection = displayPathParts
+ .slice(firstDifferencePartIndex, -1)
+ .join('/');
+ const fileNameSection = displayPathParts[displayPathParts.length - 1];
+
+ // Note: folder sections need '/' appended back.
+ return {
+ matchingFolders: matchingSection.length > 0 ? `${matchingSection}/` : '',
+ newFolders: newFolderSection.length > 0 ? `${newFolderSection}/` : '',
+ fileName: fileNameSection,
+ };
+}
+
+/**
+ * Truncates URLs to display filename only
+ * Example
+ * // returns '.../text.html'
+ * util.truncatePath.('dir/text.html');
+ * Example
+ * // returns 'text.html'
+ * util.truncatePath.('text.html');
+ *
+ * Copied from Gerrit's path-list-util.ts.
+ */
+export function truncatePath(path: string, threshold = 1) {
+ const pathPieces = path.split('/');
+
+ if (pathPieces.length <= threshold) {
+ return path;
+ }
+
+ const index = pathPieces.length - threshold;
+ // Character is an ellipsis.
+ return `\u2026/${pathPieces.slice(index).join('/')}`;
+}
+
+/**
+ * Encodes *parts* of a URL. See inline comments below for the details.
+ * Note specifically that ? & = # are encoded. So this is very close to
+ * encodeURIComponent() with some tweaks.
+ *
+ * Copied from Gerrit's url-util.ts.
+ */
+export function encodeURL(url: string): string {
+ // gr-page decodes the entire URL, and then decodes once more the
+ // individual regex matching groups. It uses `decodeURIComponent()`, which
+ // will choke on singular `%` chars without two trailing digits. We prefer
+ // to not double encode *everything* (just for readaiblity and simplicity),
+ // but `%` *must* be double encoded.
+ let output = url.replaceAll('%', '%25'); // `replaceAll` function requires `es2021` hence the `lib` section was added to `tsconfig.json`
+ // `+` also requires double encoding, because `%2B` would be decoded to `+`
+ // and then replaced by ` `.
+ output = output.replaceAll('+', '%2B');
+
+ // This escapes ALL characters EXCEPT:
+ // A–Z a–z 0–9 - _ . ! ~ * ' ( )
+ // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/encodeURIComponent
+ output = encodeURIComponent(output);
+
+ // If we would use `encodeURI()` instead of `encodeURIComponent()`, then we
+ // would also NOT encode:
+ // ; / ? : @ & = + $ , #
+ //
+ // That would be more readable, but for example ? and & have special meaning
+ // in the URL, so they must be encoded. Let's discuss all these chars and
+ // decide whether we have to encode them or not.
+ //
+ // ? & = # have to be encoded. Otherwise we might mess up the URL.
+ //
+ // : @ do not have to be encoded, because we are only dealing with path,
+ // query and fragment of the URL, not with scheme, user, host, port.
+ // For search queries it is much nicer to not encode those chars, think of
+ // searching for `owner:spearce@spearce.org`.
+ //
+ // / does not have to be encoded, because we don't care about individual path
+ // components. File path and repo names are so much nicer to read without /
+ // being encoded!
+ //
+ // + must be encoded, because we want to use it instead of %20 for spaces, see
+ // below.
+ //
+ // ; $ , probably don't have to be encoded, but we don't bother about them
+ // much, so we don't reverse the encoding here, but we don't think it would
+ // cause any harm, if we did.
+ output = output.replace(/%3A/g, ':');
+ output = output.replace(/%40/g, '@');
+ output = output.replace(/%2F/g, '/');
+
+ // gr-page replaces `+` by ` ` in addition to calling `decodeURIComponent()`.
+ // So we can use `+` to increase readability.
+ output = output.replace(/%20/g, '+');
+
+ return output;
+}
+
+/**
+ * Function to obtain the base URL.
+ *
+ * Copied from Gerrit's url-util.ts.
+ */
+export function getBaseUrl(): string {
+ // window is not defined in service worker, therefore no CANONICAL_PATH
+ if (typeof window === 'undefined') return '';
+ return self.CANONICAL_PATH || '';
+}