Merge "Update gerritcodereview for Soy template imports."
diff --git a/Documentation/config-gerrit.txt b/Documentation/config-gerrit.txt
index 3dcee80..618621b 100644
--- a/Documentation/config-gerrit.txt
+++ b/Documentation/config-gerrit.txt
@@ -1319,17 +1319,6 @@
 +
 The default is false.
 
-[[change.largeChange]]change.largeChange::
-+
-Number of changed lines from which on a change is considered as a large
-change. The number of changed lines of a change is the sum of the lines
-that were inserted and deleted in the change.
-+
-The specified value is used to visualize the change sizes in the Web UI
-in change tables and user dashboards.
-+
-By default 500.
-
 [[change.maxComments]]change.maxComments::
 +
 Maximum number of comments (regular plus robot) allowed per change. Additional
@@ -3423,9 +3412,9 @@
 [[experiments]]
 === Section experiments
 
-This section covers experimental new features. Gerrit's frontend uses experiments
-to research new behavior. Once the research is done, the experimental feature
-either stays and the experimentation flag gets removed, or the feature as a whole
+This section covers experimental new features. Gerrit uses experiments
+to research new behavior in frontend and core backend. Once the research is done, the experimental
+feature either stays and the experimentation flag gets removed, or the feature as a whole
 gets removed
 
 [[experiments.enabled]]experiments.enabled::
diff --git a/Documentation/rest-api-changes.txt b/Documentation/rest-api-changes.txt
index 9876b53..096b068 100644
--- a/Documentation/rest-api-changes.txt
+++ b/Documentation/rest-api-changes.txt
@@ -14,10 +14,11 @@
 'POST /changes/'
 --
 
-The change input link:#change-input[ChangeInput] entity must be provided in the
-request body. It is not allowed to create changes on refs/tags/* or Gerrit
-internal refs such as refs/changes/*, refs/meta/external-ids/*, refs/users/*,
-etc.. and the request would fail with `400 Bad Request` in this case.
+The change input link:#change-input[ChangeInput] entity must be
+provided in the request body. It is not allowed to create changes
+under `refs/tags/` or Gerrit internal ref namespaces such as
+`refs/changes/`, `refs/meta/external-ids/`, and `refs/users/`. The
+request would fail with `400 Bad Request` in this case.
 
 To create a change the calling user must be allowed to
 link:access-control.html#category_push_review[upload to code review].
diff --git a/Documentation/rest-api-config.txt b/Documentation/rest-api-config.txt
index a62ed47..9764c8a 100644
--- a/Documentation/rest-api-config.txt
+++ b/Documentation/rest-api-config.txt
@@ -1552,9 +1552,6 @@
 |`allow_blame`        |not set if `false`|
 link:config-gerrit.html#change.allowBlame[Whether blame on side by side diff is
 allowed].
-|`large_change`       ||
-link:config-gerrit.html#change.largeChange[Number of changed lines from
-which on a change is considered as a large change].
 |`reply_label`        ||
 link:config-gerrit.html#change.replyTooltip[Label name for the reply
 button].
diff --git a/WORKSPACE b/WORKSPACE
index 2244dca..0767907 100644
--- a/WORKSPACE
+++ b/WORKSPACE
@@ -66,17 +66,17 @@
 
 http_archive(
     name = "build_bazel_rules_nodejs",
-    sha256 = "dd4dc46066e2ce034cba0c81aa3e862b27e8e8d95871f567359f7a534cccb666",
-    urls = ["https://github.com/bazelbuild/rules_nodejs/releases/download/3.1.0/rules_nodejs-3.1.0.tar.gz"],
+    sha256 = "fcc6dccb39ca88d481224536eb8f9fa754619676c6163f87aa6af94059b02b12",
+    urls = ["https://github.com/bazelbuild/rules_nodejs/releases/download/3.2.0/rules_nodejs-3.2.0.tar.gz"],
 )
 
 # Golang support for PolyGerrit local dev server.
 http_archive(
     name = "io_bazel_rules_go",
-    sha256 = "a8d6b1b354d371a646d2f7927319974e0f9e52f73a2452d2b3877118169eb6bb",
+    sha256 = "4d838e2d70b955ef9dd0d0648f673141df1bc1d7ecf5c2d621dcc163f47dd38a",
     urls = [
-        "https://mirror.bazel.build/github.com/bazelbuild/rules_go/releases/download/v0.23.3/rules_go-v0.23.3.tar.gz",
-        "https://github.com/bazelbuild/rules_go/releases/download/v0.23.3/rules_go-v0.23.3.tar.gz",
+        "https://mirror.bazel.build/github.com/bazelbuild/rules_go/releases/download/v0.24.12/rules_go-v0.24.12.tar.gz",
+        "https://github.com/bazelbuild/rules_go/releases/download/v0.24.12/rules_go-v0.24.12.tar.gz",
     ],
 )
 
@@ -88,10 +88,10 @@
 
 http_archive(
     name = "bazel_gazelle",
-    sha256 = "cdb02a887a7187ea4d5a27452311a75ed8637379a1287d8eeb952138ea485f7d",
+    sha256 = "222e49f034ca7a1d1231422cdb67066b885819885c356673cb1f72f748a3c9d4",
     urls = [
-        "https://mirror.bazel.build/github.com/bazelbuild/bazel-gazelle/releases/download/v0.21.1/bazel-gazelle-v0.21.1.tar.gz",
-        "https://github.com/bazelbuild/bazel-gazelle/releases/download/v0.21.1/bazel-gazelle-v0.21.1.tar.gz",
+        "https://mirror.bazel.build/github.com/bazelbuild/bazel-gazelle/releases/download/v0.22.3/bazel-gazelle-v0.22.3.tar.gz",
+        "https://github.com/bazelbuild/bazel-gazelle/releases/download/v0.22.3/bazel-gazelle-v0.22.3.tar.gz",
     ],
 )
 
diff --git a/java/com/google/gerrit/acceptance/GerritServer.java b/java/com/google/gerrit/acceptance/GerritServer.java
index b02dc87..f2cc9d1 100644
--- a/java/com/google/gerrit/acceptance/GerritServer.java
+++ b/java/com/google/gerrit/acceptance/GerritServer.java
@@ -50,6 +50,7 @@
 import com.google.gerrit.server.config.GerritRuntime;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.SitePath;
+import com.google.gerrit.server.experiments.ConfigExperimentFeatures;
 import com.google.gerrit.server.git.receive.AsyncReceiveCommits;
 import com.google.gerrit.server.schema.JdbcAccountPatchReviewStore;
 import com.google.gerrit.server.ssh.NoSshModule;
@@ -437,7 +438,8 @@
               protected void configure() {
                 bind(GerritRuntime.class).toInstance(GerritRuntime.DAEMON);
               }
-            }));
+            },
+            new ConfigExperimentFeatures.Module()));
     daemon.addAdditionalSysModuleForTesting(
         new ReindexProjectsAtStartup.Module(), new ReindexGroupsAtStartup.Module());
     daemon.start();
diff --git a/java/com/google/gerrit/entities/RefNames.java b/java/com/google/gerrit/entities/RefNames.java
index b6efcbf..2263aba 100644
--- a/java/com/google/gerrit/entities/RefNames.java
+++ b/java/com/google/gerrit/entities/RefNames.java
@@ -140,7 +140,7 @@
   }
 
   /**
-   * Warning: Change refs have to manually be advertised in {@link
+   * Warning: Change refs have to manually be advertised in {@code
    * com.google.gerrit.server.permissions.DefaultRefFilter}; this should be done when adding new
    * change refs.
    */
diff --git a/java/com/google/gerrit/exceptions/InternalServerWithUserMessageException.java b/java/com/google/gerrit/exceptions/InternalServerWithUserMessageException.java
index 9f74e9d..452192c 100644
--- a/java/com/google/gerrit/exceptions/InternalServerWithUserMessageException.java
+++ b/java/com/google/gerrit/exceptions/InternalServerWithUserMessageException.java
@@ -15,6 +15,8 @@
 package com.google.gerrit.exceptions;
 
 public class InternalServerWithUserMessageException extends RuntimeException {
+  private static final long serialVersionUID = 1L;
+
   public InternalServerWithUserMessageException(String msg, Throwable cause) {
     super(msg, cause);
   }
diff --git a/java/com/google/gerrit/extensions/common/ChangeConfigInfo.java b/java/com/google/gerrit/extensions/common/ChangeConfigInfo.java
index a441bfd..b387017 100644
--- a/java/com/google/gerrit/extensions/common/ChangeConfigInfo.java
+++ b/java/com/google/gerrit/extensions/common/ChangeConfigInfo.java
@@ -19,7 +19,6 @@
   public Boolean allowBlame;
   public Boolean showAssigneeInChangesTable;
   public Boolean disablePrivateChanges;
-  public int largeChange;
   public String replyLabel;
   public String replyTooltip;
   public int updateDelay;
diff --git a/java/com/google/gerrit/httpd/raw/IndexHtmlUtil.java b/java/com/google/gerrit/httpd/raw/IndexHtmlUtil.java
index 46dde41..8d52f5a 100644
--- a/java/com/google/gerrit/httpd/raw/IndexHtmlUtil.java
+++ b/java/com/google/gerrit/httpd/raw/IndexHtmlUtil.java
@@ -20,7 +20,6 @@
 
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableMap;
-import com.google.common.collect.ImmutableSet;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.UsedAt;
@@ -31,6 +30,7 @@
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.json.OutputFormat;
+import com.google.gerrit.server.experiments.ExperimentFeatures;
 import com.google.gson.Gson;
 import com.google.template.soy.data.SanitizedContent;
 import java.net.URI;
@@ -38,21 +38,15 @@
 import java.util.Arrays;
 import java.util.Collections;
 import java.util.HashMap;
-import java.util.HashSet;
 import java.util.Map;
 import java.util.Set;
 import java.util.function.Function;
-import org.eclipse.jgit.lib.Config;
 
 /** Helper for generating parts of {@code index.html}. */
 @UsedAt(Project.GOOGLE)
 public class IndexHtmlUtil {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  static final ImmutableSet<String> DEFAULT_EXPERIMENTS =
-      ImmutableSet.of(
-          "UiFeature__patchset_comments", "UiFeature__patchset_choice_for_comment_links");
-
   private static final Gson GSON = OutputFormat.JSON_COMPACT.newGson();
   /**
    * Returns both static and dynamic parameters of {@code index.html}. The result is to be used when
@@ -60,7 +54,7 @@
    */
   public static ImmutableMap<String, Object> templateData(
       GerritApi gerritApi,
-      Config gerritServerConfig,
+      ExperimentFeatures experimentFeatures,
       String canonicalURL,
       String cdnPath,
       String faviconPath,
@@ -73,14 +67,8 @@
             staticTemplateData(
                 canonicalURL, cdnPath, faviconPath, urlParameterMap, urlInScriptTagOrdainer))
         .putAll(dynamicTemplateData(gerritApi, requestedURL));
+    Set<String> enabledExperiments = experimentFeatures.getEnabledExperimentFeatures();
 
-    Set<String> enabledExperiments = new HashSet<>();
-    Arrays.stream(gerritServerConfig.getStringList("experiments", null, "enabled"))
-        .forEach(enabledExperiments::add);
-    DEFAULT_EXPERIMENTS.forEach(enabledExperiments::add);
-    Arrays.stream(gerritServerConfig.getStringList("experiments", null, "disabled"))
-        .forEach(enabledExperiments::remove);
-    experimentData(urlParameterMap).forEach(enabledExperiments::add);
     if (!enabledExperiments.isEmpty()) {
       data.put("enabledExperiments", serializeObject(GSON, enabledExperiments).toString());
     }
diff --git a/java/com/google/gerrit/httpd/raw/IndexServlet.java b/java/com/google/gerrit/httpd/raw/IndexServlet.java
index b2bdf7c..3f2c202 100644
--- a/java/com/google/gerrit/httpd/raw/IndexServlet.java
+++ b/java/com/google/gerrit/httpd/raw/IndexServlet.java
@@ -22,6 +22,7 @@
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.api.GerritApi;
 import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.server.experiments.ExperimentFeatures;
 import com.google.template.soy.SoyFileSet;
 import com.google.template.soy.data.SanitizedContent;
 import com.google.template.soy.data.UnsafeSanitizedContentOrdainer;
@@ -34,7 +35,6 @@
 import javax.servlet.http.HttpServlet;
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
-import org.eclipse.jgit.lib.Config;
 
 public class IndexServlet extends HttpServlet {
   private static final long serialVersionUID = 1L;
@@ -43,7 +43,7 @@
   @Nullable private final String cdnPath;
   @Nullable private final String faviconPath;
   private final GerritApi gerritApi;
-  private final Config gerritServerConfig;
+  private final ExperimentFeatures experimentFeatures;
   private final SoySauce soySauce;
   private final Function<String, SanitizedContent> urlOrdainer;
 
@@ -52,12 +52,12 @@
       @Nullable String cdnPath,
       @Nullable String faviconPath,
       GerritApi gerritApi,
-      Config gerritServerConfig) {
+      ExperimentFeatures experimentFeatures) {
     this.canonicalUrl = canonicalUrl;
     this.cdnPath = cdnPath;
     this.faviconPath = faviconPath;
     this.gerritApi = gerritApi;
-    this.gerritServerConfig = gerritServerConfig;
+    this.experimentFeatures = experimentFeatures;
     this.soySauce =
         SoyFileSet.builder()
             .add(Resources.getResource("com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy"))
@@ -79,7 +79,7 @@
       ImmutableMap<String, Object> templateData =
           IndexHtmlUtil.templateData(
               gerritApi,
-              gerritServerConfig,
+              experimentFeatures,
               canonicalUrl,
               cdnPath,
               faviconPath,
diff --git a/java/com/google/gerrit/httpd/raw/StaticModule.java b/java/com/google/gerrit/httpd/raw/StaticModule.java
index 66e107b..cac716f 100644
--- a/java/com/google/gerrit/httpd/raw/StaticModule.java
+++ b/java/com/google/gerrit/httpd/raw/StaticModule.java
@@ -30,6 +30,7 @@
 import com.google.gerrit.server.config.GerritOptions;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.experiments.ExperimentFeatures;
 import com.google.inject.Inject;
 import com.google.inject.Key;
 import com.google.inject.Provides;
@@ -221,11 +222,12 @@
     HttpServlet getPolyGerritUiIndexServlet(
         @CanonicalWebUrl @Nullable String canonicalUrl,
         @GerritServerConfig Config cfg,
-        GerritApi gerritApi) {
+        GerritApi gerritApi,
+        ExperimentFeatures experimentFeatures) {
       String cdnPath =
           options.useDevCdn() ? options.devCdn() : cfg.getString("gerrit", null, "cdnPath");
       String faviconPath = cfg.getString("gerrit", null, "faviconPath");
-      return new IndexServlet(canonicalUrl, cdnPath, faviconPath, gerritApi, cfg);
+      return new IndexServlet(canonicalUrl, cdnPath, faviconPath, gerritApi, experimentFeatures);
     }
 
     @Provides
diff --git a/java/com/google/gerrit/pgm/util/SiteProgram.java b/java/com/google/gerrit/pgm/util/SiteProgram.java
index 98558fb..c3be0a4 100644
--- a/java/com/google/gerrit/pgm/util/SiteProgram.java
+++ b/java/com/google/gerrit/pgm/util/SiteProgram.java
@@ -28,6 +28,7 @@
 import com.google.gerrit.server.config.GerritRuntime;
 import com.google.gerrit.server.config.GerritServerConfigModule;
 import com.google.gerrit.server.config.SitePath;
+import com.google.gerrit.server.experiments.ConfigExperimentFeatures;
 import com.google.gerrit.server.git.GitRepositoryManagerModule;
 import com.google.gerrit.server.git.SystemReaderInstaller;
 import com.google.gerrit.server.schema.SchemaModule;
@@ -128,6 +129,9 @@
 
     modules.add(new SchemaModule());
     modules.add(cfgInjector.getInstance(GitRepositoryManagerModule.class));
+    // The only implementation of experiments is available in all programs that can use
+    // gerrit.config
+    modules.add(new ConfigExperimentFeatures.Module());
 
     try {
       return Guice.createInjector(
diff --git a/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java b/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java
index 36d48033..573f2f5 100644
--- a/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java
+++ b/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java
@@ -106,7 +106,7 @@
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevWalk;
 
-class RevisionApiImpl implements RevisionApi {
+class RevisionApiImpl extends RevisionApi.NotImplemented {
   interface Factory {
     RevisionApiImpl create(RevisionResource r);
   }
@@ -687,11 +687,6 @@
   }
 
   @Override
-  public String etag() throws RestApiException {
-    return revisionActions.getETag(revision);
-  }
-
-  @Override
   public BinaryResult getArchive(ArchiveFormat format) throws RestApiException {
     GetArchive getArchive = getArchiveProvider.get();
     getArchive.setFormat(format != null ? format.name().toLowerCase(Locale.US) : null);
diff --git a/java/com/google/gerrit/server/change/FileInfoJsonNewImpl.java b/java/com/google/gerrit/server/change/FileInfoJsonNewImpl.java
index 0dd71507..0d3dcff 100644
--- a/java/com/google/gerrit/server/change/FileInfoJsonNewImpl.java
+++ b/java/com/google/gerrit/server/change/FileInfoJsonNewImpl.java
@@ -48,9 +48,8 @@
       if (base == null) {
         return asFileInfo(
             diffs.listModifiedFilesAgainstParent(change.getProject(), objectId, null));
-      } else {
-        return asFileInfo(diffs.listModifiedFiles(change.getProject(), base.commitId(), objectId));
       }
+      return asFileInfo(diffs.listModifiedFiles(change.getProject(), base.commitId(), objectId));
     } catch (DiffNotAvailableException e) {
       convertException(e);
       return null; // unreachable. handleAndThrow will throw an exception anyway
@@ -90,8 +89,8 @@
       FileDiffOutput fileDiff = fileDiffs.get(path);
       FileInfo fileInfo = new FileInfo();
       fileInfo.status =
-          fileDiff.changeType().get() != Patch.ChangeType.MODIFIED
-              ? fileDiff.changeType().get().getCode()
+          fileDiff.changeType() != Patch.ChangeType.MODIFIED
+              ? fileDiff.changeType().getCode()
               : null;
       fileInfo.oldPath = fileDiff.oldPath().orElse(null);
       fileInfo.sizeDelta = fileDiff.sizeDelta();
diff --git a/java/com/google/gerrit/server/change/RebaseChangeOp.java b/java/com/google/gerrit/server/change/RebaseChangeOp.java
index a548262..b43996e 100644
--- a/java/com/google/gerrit/server/change/RebaseChangeOp.java
+++ b/java/com/google/gerrit/server/change/RebaseChangeOp.java
@@ -219,8 +219,13 @@
             .setFireRevisionCreated(fireRevisionCreated)
             .setCheckAddPatchSetPermission(checkAddPatchSetPermission)
             .setValidate(validate)
-            .setSendEmail(sendEmail)
-            .setWorkInProgress(!rebasedCommit.getFilesWithGitConflicts().isEmpty());
+            .setSendEmail(sendEmail);
+
+    if (!rebasedCommit.getFilesWithGitConflicts().isEmpty()
+        && !notes.getChange().isWorkInProgress()) {
+      patchSetInserter.setWorkInProgress(true);
+    }
+
     if (postMessage) {
       patchSetInserter.setMessage(
           messageForRebasedChange(rebasedPatchSetId, originalPatchSet.id(), rebasedCommit));
diff --git a/java/com/google/gerrit/server/experiments/ConfigExperimentFeatures.java b/java/com/google/gerrit/server/experiments/ConfigExperimentFeatures.java
new file mode 100644
index 0000000..f526935
--- /dev/null
+++ b/java/com/google/gerrit/server/experiments/ConfigExperimentFeatures.java
@@ -0,0 +1,63 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.experiments;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.inject.AbstractModule;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.Set;
+import org.eclipse.jgit.lib.Config;
+
+/**
+ * An implementation of {@link ExperimentFeatures} that uses gerrit.config to evaluate the status of
+ * the feature.
+ */
+@Singleton
+public class ConfigExperimentFeatures implements ExperimentFeatures {
+
+  public static class Module extends AbstractModule {
+    @Override
+    protected void configure() {
+      bind(ExperimentFeatures.class).to(ConfigExperimentFeatures.class);
+    }
+  }
+
+  private ImmutableSet<String> enabledExperimentFeatures;
+
+  @Inject
+  public ConfigExperimentFeatures(@GerritServerConfig Config gerritServerConfig) {
+    Set<String> enabledExperiments = new HashSet<>();
+    Arrays.stream(gerritServerConfig.getStringList("experiments", null, "enabled"))
+        .forEach(enabledExperiments::add);
+    ExperimentFeaturesConstants.DEFAULT_ENABLED_FEATURES.forEach(enabledExperiments::add);
+    Arrays.stream(gerritServerConfig.getStringList("experiments", null, "disabled"))
+        .forEach(enabledExperiments::remove);
+    enabledExperimentFeatures = ImmutableSet.copyOf(enabledExperiments);
+  }
+
+  @Override
+  public boolean isFeatureEnabled(String featureFlag) {
+    return getEnabledExperimentFeatures().contains(featureFlag);
+  }
+
+  @Override
+  public ImmutableSet<String> getEnabledExperimentFeatures() {
+    return enabledExperimentFeatures;
+  }
+}
diff --git a/java/com/google/gerrit/server/experiments/ExperimentFeatures.java b/java/com/google/gerrit/server/experiments/ExperimentFeatures.java
new file mode 100644
index 0000000..dc9148a
--- /dev/null
+++ b/java/com/google/gerrit/server/experiments/ExperimentFeatures.java
@@ -0,0 +1,44 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.experiments;
+
+import com.google.common.collect.ImmutableSet;
+
+/**
+ * Features that can be enabled/disabled on Gerrit (e. g. experiments to research new behavior in
+ * the current release).
+ *
+ * <p>It may depend on the implementation if the result is decided on the per-request basis or not,
+ * so the outcomes should not be persisted in {@link com.google.inject.Singleton}.
+ */
+public interface ExperimentFeatures {
+
+  /**
+   * Given the name of the feature, returns if it is enabled on the Gerrit server.
+   *
+   * <p>Depending on the implementation, it can be more efficient than filtering the results of
+   * {@link ExperimentFeatures#getEnabledExperimentFeatures}.
+   *
+   * @param featureFlag the name of the feature to test.
+   * @return if the feature is enabled.
+   */
+  boolean isFeatureEnabled(String featureFlag);
+
+  /**
+   * Returns the names of the features that are enabled on Gerrit instance (either by default or via
+   * gerrit.config).
+   */
+  ImmutableSet<String> getEnabledExperimentFeatures();
+}
diff --git a/java/com/google/gerrit/server/experiments/ExperimentFeaturesConstants.java b/java/com/google/gerrit/server/experiments/ExperimentFeaturesConstants.java
new file mode 100644
index 0000000..af49438
--- /dev/null
+++ b/java/com/google/gerrit/server/experiments/ExperimentFeaturesConstants.java
@@ -0,0 +1,28 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.experiments;
+
+import com.google.common.collect.ImmutableSet;
+
+/** Constants for Gerrit {@link ExperimentFeatures} */
+public class ExperimentFeaturesConstants {
+
+  /** Features that are known experiments and can be referenced in the code. */
+  public static String UI_FEATURE_PATCHSET_COMMENTS = "UiFeature__patchset_comments";
+
+  /** Features, enabled by default in the current release. */
+  public static final ImmutableSet<String> DEFAULT_ENABLED_FEATURES =
+      ImmutableSet.of(UI_FEATURE_PATCHSET_COMMENTS);
+}
diff --git a/java/com/google/gerrit/server/patch/AutoMerger.java b/java/com/google/gerrit/server/patch/AutoMerger.java
index 2e0214c..fe915c5 100644
--- a/java/com/google/gerrit/server/patch/AutoMerger.java
+++ b/java/com/google/gerrit/server/patch/AutoMerger.java
@@ -67,6 +67,8 @@
  * is that these refs should never be deleted.
  */
 public class AutoMerger {
+  public static final String AUTO_MERGE_MSG_PREFIX = "Auto-merge of ";
+
   @UsedAt(UsedAt.Project.GOOGLE)
   public static boolean cacheAutomerge(Config cfg) {
     return cfg.getBoolean("change", null, "cacheAutomerge", true);
@@ -198,7 +200,7 @@
     cb.setAuthor(ident);
     cb.setCommitter(ident);
     cb.setTreeId(tree);
-    cb.setMessage("Auto-merge of " + merge.name() + '\n');
+    cb.setMessage(AUTO_MERGE_MSG_PREFIX + merge.name() + '\n');
     for (RevCommit p : merge.getParents()) {
       cb.addParentId(p);
     }
diff --git a/java/com/google/gerrit/server/patch/DiffOperations.java b/java/com/google/gerrit/server/patch/DiffOperations.java
index 1d8438e..8b90531 100644
--- a/java/com/google/gerrit/server/patch/DiffOperations.java
+++ b/java/com/google/gerrit/server/patch/DiffOperations.java
@@ -17,6 +17,7 @@
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Patch;
 import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.client.DiffPreferencesInfo;
 import com.google.gerrit.server.patch.filediff.FileDiffOutput;
 import java.util.Map;
 import org.eclipse.jgit.lib.ObjectId;
@@ -73,34 +74,47 @@
   /**
    * Returns the diff for a single file between a patchset commit against its parent or the
    * auto-merge commit. For deleted files, the {@code fileName} parameter should contain the old
-   * name of the file.
+   * name of the file. This method will return {@link FileDiffOutput#empty(String)} if the requested
+   * file identified by {@code fileName} has unchanged content or does not exist at both commits.
    *
    * @param project a project name representing a git repository.
    * @param newCommit 20 bytes SHA-1 of the new commit used in the diff.
    * @param parentNum integer specifying which parent to use as base. If null, the only parent will
    *     be used or the auto-merge if {@code newCommit} is a merge commit.
    * @param fileName the file name for which the diff should be evaluated.
+   * @param whitespace preference controlling whitespace effect in diff computation.
    * @return the diff for the single file between the two commits.
    * @throws DiffNotAvailableException if an internal error occurred in Git while evaluating the
    *     diff, or if an exception happened while parsing the base commit.
    */
   FileDiffOutput getModifiedFileAgainstParent(
-      Project.NameKey project, ObjectId newCommit, @Nullable Integer parentNum, String fileName)
+      Project.NameKey project,
+      ObjectId newCommit,
+      @Nullable Integer parentNum,
+      String fileName,
+      @Nullable DiffPreferencesInfo.Whitespace whitespace)
       throws DiffNotAvailableException;
 
   /**
    * Returns the diff for a single file between two patchset commits. For deleted files, the {@code
-   * fileName} parameter should contain the old name of the file.
+   * fileName} parameter should contain the old name of the file. This method will return {@link
+   * FileDiffOutput#empty(String)} if the requested file identified by {@code fileName} has
+   * unchanged content or does not exist at both commits.
    *
    * @param project a project name representing a git repository.
    * @param oldCommit 20 bytes SHA-1 of the old commit used in the diff.
    * @param newCommit 20 bytes SHA-1 of the new commit used in the diff.
    * @param fileName the file name for which the diff should be evaluated.
+   * @param whitespace preference controlling whitespace effect in diff computation.
    * @return the diff for the single file between the two commits.
    * @throws DiffNotAvailableException if an internal error occurred in Git while evaluating the
    *     diff.
    */
   FileDiffOutput getModifiedFile(
-      Project.NameKey project, ObjectId oldCommit, ObjectId newCommit, String fileName)
+      Project.NameKey project,
+      ObjectId oldCommit,
+      ObjectId newCommit,
+      String fileName,
+      @Nullable DiffPreferencesInfo.Whitespace whitespace)
       throws DiffNotAvailableException;
 }
diff --git a/java/com/google/gerrit/server/patch/DiffOperationsImpl.java b/java/com/google/gerrit/server/patch/DiffOperationsImpl.java
index 2d98d42..16bd135 100644
--- a/java/com/google/gerrit/server/patch/DiffOperationsImpl.java
+++ b/java/com/google/gerrit/server/patch/DiffOperationsImpl.java
@@ -25,6 +25,7 @@
 import com.google.gerrit.entities.Patch;
 import com.google.gerrit.entities.Patch.ChangeType;
 import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.client.DiffPreferencesInfo;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
 import com.google.gerrit.server.cache.CacheModule;
 import com.google.gerrit.server.patch.diff.ModifiedFilesCache;
@@ -55,6 +56,7 @@
 
   private static final int RENAME_SCORE = 60;
   private static final DiffAlgorithm DEFAULT_DIFF_ALGORITHM = DiffAlgorithm.HISTOGRAM;
+  private static final Whitespace DEFAULT_WHITESPACE = Whitespace.IGNORE_NONE;
 
   private final ModifiedFilesCache modifiedFilesCache;
   private final FileDiffCache fileDiffCache;
@@ -112,13 +114,18 @@
 
   @Override
   public FileDiffOutput getModifiedFileAgainstParent(
-      Project.NameKey project, ObjectId newCommit, @Nullable Integer parent, String fileName)
+      Project.NameKey project,
+      ObjectId newCommit,
+      @Nullable Integer parent,
+      String fileName,
+      @Nullable DiffPreferencesInfo.Whitespace whitespace)
       throws DiffNotAvailableException {
     try {
       DiffParameters diffParams = computeDiffParameters(project, newCommit, parent);
       FileDiffCacheKey key =
-          createFileDiffCacheKey(project, diffParams.baseCommit(), newCommit, fileName);
-      return getModifiedFilesForKeys(ImmutableList.of(key)).get(fileName);
+          createFileDiffCacheKey(project, diffParams.baseCommit(), newCommit, fileName, whitespace);
+      Map<String, FileDiffOutput> result = getModifiedFilesForKeys(ImmutableList.of(key));
+      return result.containsKey(fileName) ? result.get(fileName) : FileDiffOutput.empty(fileName);
     } catch (IOException e) {
       throw new DiffNotAvailableException(
           "Failed to evaluate the parent/base commit for commit " + newCommit, e);
@@ -127,10 +134,16 @@
 
   @Override
   public FileDiffOutput getModifiedFile(
-      Project.NameKey project, ObjectId oldCommit, ObjectId newCommit, String fileName)
+      Project.NameKey project,
+      ObjectId oldCommit,
+      ObjectId newCommit,
+      String fileName,
+      @Nullable DiffPreferencesInfo.Whitespace whitespace)
       throws DiffNotAvailableException {
-    FileDiffCacheKey key = createFileDiffCacheKey(project, oldCommit, newCommit, fileName);
-    return getModifiedFilesForKeys(ImmutableList.of(key)).get(fileName);
+    FileDiffCacheKey key =
+        createFileDiffCacheKey(project, oldCommit, newCommit, fileName, whitespace);
+    Map<String, FileDiffOutput> result = getModifiedFilesForKeys(ImmutableList.of(key));
+    return result.containsKey(fileName) ? result.get(fileName) : FileDiffOutput.empty(fileName);
   }
 
   private Map<String, FileDiffOutput> getModifiedFiles(
@@ -144,10 +157,14 @@
           modifiedFilesCache.get(createModifiedFilesKey(project, oldCommit, newCommit));
 
       List<FileDiffCacheKey> fileCacheKeys = new ArrayList<>();
-      fileCacheKeys.add(createFileDiffCacheKey(project, oldCommit, newCommit, COMMIT_MSG));
+      fileCacheKeys.add(
+          createFileDiffCacheKey(
+              project, oldCommit, newCommit, COMMIT_MSG, /* whitespace= */ null));
 
       if (cmp.isAgainstAutoMerge() || isMergeAgainstParent(cmp, project, newCommit)) {
-        fileCacheKeys.add(createFileDiffCacheKey(project, oldCommit, newCommit, MERGE_LIST));
+        fileCacheKeys.add(
+            createFileDiffCacheKey(
+                project, oldCommit, newCommit, MERGE_LIST, /*whitespace = */ null));
       }
 
       if (diffParams.skipFiles() == null) {
@@ -160,7 +177,8 @@
                         newCommit,
                         entity.newPath().isPresent()
                             ? entity.newPath().get()
-                            : entity.oldPath().get()))
+                            : entity.oldPath().get(),
+                        /* whitespace= */ null))
             .forEach(fileCacheKeys::add);
       }
       return getModifiedFilesForKeys(fileCacheKeys);
@@ -178,7 +196,7 @@
       if (fileDiffOutput.isEmpty() || allDueToRebase(fileDiffOutput)) {
         continue;
       }
-      if (fileDiffOutput.changeType().get() == Patch.ChangeType.DELETED) {
+      if (fileDiffOutput.changeType() == ChangeType.DELETED) {
         files.put(fileDiffOutput.oldPath().get(), fileDiffOutput);
       } else {
         files.put(fileDiffOutput.newPath().get(), fileDiffOutput);
@@ -189,8 +207,8 @@
 
   private static boolean allDueToRebase(FileDiffOutput fileDiffOutput) {
     return fileDiffOutput.allEditsDueToRebase()
-        && (!(fileDiffOutput.changeType().get() == ChangeType.RENAMED
-            || fileDiffOutput.changeType().get() == ChangeType.COPIED));
+        && (!(fileDiffOutput.changeType() == ChangeType.RENAMED
+            || fileDiffOutput.changeType() == ChangeType.COPIED));
   }
 
   private boolean isMergeAgainstParent(ComparisonType cmp, Project.NameKey project, ObjectId commit)
@@ -209,7 +227,12 @@
   }
 
   private static FileDiffCacheKey createFileDiffCacheKey(
-      Project.NameKey project, ObjectId aCommit, ObjectId bCommit, String newPath) {
+      Project.NameKey project,
+      ObjectId aCommit,
+      ObjectId bCommit,
+      String newPath,
+      @Nullable Whitespace whitespace) {
+    whitespace = whitespace == null ? DEFAULT_WHITESPACE : whitespace;
     return FileDiffCacheKey.builder()
         .project(project)
         .oldCommit(aCommit)
@@ -217,7 +240,7 @@
         .newFilePath(newPath)
         .renameScore(RENAME_SCORE)
         .diffAlgorithm(DEFAULT_DIFF_ALGORITHM)
-        .whitespace(Whitespace.IGNORE_NONE)
+        .whitespace(whitespace)
         .build();
   }
 
diff --git a/java/com/google/gerrit/server/patch/filediff/FileDiffCacheImpl.java b/java/com/google/gerrit/server/patch/filediff/FileDiffCacheImpl.java
index ad61753..63f311b 100644
--- a/java/com/google/gerrit/server/patch/filediff/FileDiffCacheImpl.java
+++ b/java/com/google/gerrit/server/patch/filediff/FileDiffCacheImpl.java
@@ -31,6 +31,7 @@
 import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
 import com.google.gerrit.server.cache.CacheModule;
 import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.patch.AutoMerger;
 import com.google.gerrit.server.patch.ComparisonType;
 import com.google.gerrit.server.patch.DiffNotAvailableException;
 import com.google.gerrit.server.patch.DiffUtil;
@@ -184,7 +185,8 @@
       return result.build();
     }
 
-    private ComparisonType getComparisonType(RevWalk rw, ObjectId oldCommitId, ObjectId newCommitId)
+    private ComparisonType getComparisonType(
+        RevWalk rw, ObjectReader reader, ObjectId oldCommitId, ObjectId newCommitId)
         throws IOException {
       RevCommit oldCommit = DiffUtil.getRevCommit(rw, oldCommitId);
       RevCommit newCommit = DiffUtil.getRevCommit(rw, newCommitId);
@@ -193,7 +195,16 @@
           return ComparisonType.againstParent(i + 1);
         }
       }
-      if (newCommit.getParentCount() > 0) {
+      // TODO(ghareeb): it's not trivial to distinguish if diff with old commit is against another
+      // patchset or auto-merge. Looking at the commit message of old commit gives a strong
+      // signal that we are diffing against auto-merge, though not 100% accurate (e.g. if old commit
+      // has the auto-merge prefix in the commit message). A better resolution would be to move the
+      // COMMIT_MSG and MERGE_LIST evaluations outside of the diff cache. For more details, see
+      // discussion in
+      // https://gerrit-review.googlesource.com/c/gerrit/+/280519/6..18/java/com/google/gerrit/server/patch/FileDiffCache.java#b540
+      Text oldCommitMsgTxt = Text.forCommit(reader, oldCommit);
+      if (oldCommitMsgTxt.size() > 0
+          && oldCommitMsgTxt.getString(0).startsWith(AutoMerger.AUTO_MERGE_MSG_PREFIX)) {
         return ComparisonType.againstAutoMerge();
       }
       return ComparisonType.againstOtherPatchSet();
@@ -206,7 +217,8 @@
         FileDiffCacheKey key, ObjectReader reader, RevWalk rw, MagicPath magicPath) {
       try {
         RawTextComparator cmp = comparatorFor(key.whitespace());
-        ComparisonType comparisonType = getComparisonType(rw, key.oldCommit(), key.newCommit());
+        ComparisonType comparisonType =
+            getComparisonType(rw, reader, key.oldCommit(), key.newCommit());
         RevCommit aCommit =
             comparisonType.isAgainstParentOrAutoMerge()
                 ? null
@@ -217,7 +229,7 @@
             : createMergeListEntry(
                 reader, aCommit, bCommit, comparisonType, cmp, key.diffAlgorithm());
       } catch (IOException e) {
-        logger.atWarning().log("Failed to compute commit entry for key " + key);
+        logger.atWarning().log("Failed to compute commit entry for key %s", key);
       }
       return FileDiffOutput.empty(key.newFilePath());
     }
@@ -288,7 +300,7 @@
       return FileDiffOutput.builder()
           .oldPath(FileHeaderUtil.getOldPath(fileHeader))
           .newPath(FileHeaderUtil.getNewPath(fileHeader))
-          .changeType(Optional.of(changeType))
+          .changeType(changeType)
           .patchType(Optional.of(FileHeaderUtil.getPatchType(fileHeader)))
           .headerLines(FileHeaderUtil.getHeaderLines(fileHeader))
           .edits(
@@ -342,7 +354,7 @@
         GitFileDiff mainGitDiff = allDiffs.mainDiff().gitDiff();
 
         Long oldSize =
-            mainGitDiff.oldPath().isPresent()
+            mainGitDiff.oldMode().isPresent() && mainGitDiff.oldPath().isPresent()
                 ? new FileSizeEvaluator(reader, aTree)
                     .compute(
                         mainGitDiff.oldId(),
@@ -350,7 +362,7 @@
                         mainGitDiff.oldPath().get())
                 : 0;
         Long newSize =
-            mainGitDiff.newPath().isPresent()
+            mainGitDiff.newMode().isPresent() && mainGitDiff.newPath().isPresent()
                 ? new FileSizeEvaluator(reader, bTree)
                     .compute(
                         mainGitDiff.newId(),
diff --git a/java/com/google/gerrit/server/patch/filediff/FileDiffOutput.java b/java/com/google/gerrit/server/patch/filediff/FileDiffOutput.java
index 46ce28b..3348033 100644
--- a/java/com/google/gerrit/server/patch/filediff/FileDiffOutput.java
+++ b/java/com/google/gerrit/server/patch/filediff/FileDiffOutput.java
@@ -47,7 +47,7 @@
   public abstract Optional<String> newPath();
 
   /** The change type of the underlying file, e.g. added, deleted, renamed, etc... */
-  public abstract Optional<Patch.ChangeType> changeType();
+  public abstract Patch.ChangeType changeType();
 
   /** The patch type of the underlying file, e.g. unified, binary , etc... */
   public abstract Optional<Patch.PatchType> patchType();
@@ -95,10 +95,11 @@
   }
 
   /** Returns an entity representing an unchanged file between two commits. */
-  static FileDiffOutput empty(String filePath) {
+  public static FileDiffOutput empty(String filePath) {
     return builder()
         .oldPath(Optional.empty())
         .newPath(Optional.of(filePath))
+        .changeType(ChangeType.MODIFIED)
         .headerLines(ImmutableList.of())
         .edits(ImmutableList.of())
         .size(0)
@@ -123,9 +124,7 @@
     if (newPath().isPresent()) {
       result += stringSize(newPath().get());
     }
-    if (changeType().isPresent()) {
-      result += 4;
-    }
+    result += 4; // changeType
     if (patchType().isPresent()) {
       result += 4;
     }
@@ -145,7 +144,7 @@
 
     public abstract Builder newPath(Optional<String> value);
 
-    public abstract Builder changeType(Optional<ChangeType> value);
+    public abstract Builder changeType(ChangeType value);
 
     public abstract Builder patchType(Optional<PatchType> value);
 
@@ -169,9 +168,6 @@
     private static final FieldDescriptor NEW_PATH_DESCRIPTOR =
         FileDiffOutputProto.getDescriptor().findFieldByNumber(2);
 
-    private static final FieldDescriptor CHANGE_TYPE_DESCRIPTOR =
-        FileDiffOutputProto.getDescriptor().findFieldByNumber(3);
-
     private static final FieldDescriptor PATCH_TYPE_DESCRIPTOR =
         FileDiffOutputProto.getDescriptor().findFieldByNumber(4);
 
@@ -182,6 +178,7 @@
               .setSize(fileDiff.size())
               .setSizeDelta(fileDiff.sizeDelta())
               .addAllHeaderLines(fileDiff.headerLines())
+              .setChangeType(fileDiff.changeType().name())
               .addAllEdits(
                   fileDiff.edits().stream()
                       .map(
@@ -206,10 +203,6 @@
         builder.setNewPath(fileDiff.newPath().get());
       }
 
-      if (fileDiff.changeType().isPresent()) {
-        builder.setChangeType(fileDiff.changeType().get().name());
-      }
-
       if (fileDiff.patchType().isPresent()) {
         builder.setPatchType(fileDiff.patchType().get().name());
       }
@@ -225,6 +218,7 @@
           .size(proto.getSize())
           .sizeDelta(proto.getSizeDelta())
           .headerLines(proto.getHeaderLinesList().stream().collect(ImmutableList.toImmutableList()))
+          .changeType(ChangeType.valueOf(proto.getChangeType()))
           .edits(
               proto.getEditsList().stream()
                   .map(
@@ -244,9 +238,6 @@
       if (proto.hasField(NEW_PATH_DESCRIPTOR)) {
         builder.newPath(Optional.of(proto.getNewPath()));
       }
-      if (proto.hasField(CHANGE_TYPE_DESCRIPTOR)) {
-        builder.changeType(Optional.of(Patch.ChangeType.valueOf(proto.getChangeType())));
-      }
       if (proto.hasField(PATCH_TYPE_DESCRIPTOR)) {
         builder.patchType(Optional.of(Patch.PatchType.valueOf(proto.getPatchType())));
       }
diff --git a/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiff.java b/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiff.java
index 81c0e5d..a01d447 100644
--- a/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiff.java
+++ b/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiff.java
@@ -77,7 +77,7 @@
         .fileHeader(FileHeaderUtil.toString(fileHeader))
         .oldPath(FileHeaderUtil.getOldPath(fileHeader))
         .newPath(FileHeaderUtil.getNewPath(fileHeader))
-        .changeType(Optional.of(FileHeaderUtil.getChangeType(fileHeader)))
+        .changeType(FileHeaderUtil.getChangeType(fileHeader))
         .patchType(Optional.of(FileHeaderUtil.getPatchType(fileHeader)))
         .oldMode(Optional.of(mapFileMode(diffEntry.getOldMode())))
         .newMode(Optional.of(mapFileMode(diffEntry.getNewMode())))
@@ -96,6 +96,7 @@
         .oldId(oldId)
         .newId(newId)
         .newPath(Optional.of(newFilePath))
+        .changeType(ChangeType.MODIFIED)
         .edits(ImmutableList.of())
         .fileHeader("")
         .build();
@@ -126,7 +127,7 @@
   public abstract Optional<Patch.FileMode> newMode();
 
   /** The change type associated with the file. */
-  public abstract Optional<ChangeType> changeType();
+  public abstract ChangeType changeType();
 
   /** The patch type associated with the file. */
   public abstract Optional<PatchType> patchType();
@@ -150,9 +151,7 @@
     if (newPath().isPresent()) {
       result += stringSize(newPath().get());
     }
-    if (changeType().isPresent()) {
-      result += 4;
-    }
+    result += 4;
     if (patchType().isPresent()) {
       result += 4;
     }
@@ -188,7 +187,7 @@
 
     public abstract Builder newMode(Optional<Patch.FileMode> value);
 
-    public abstract Builder changeType(Optional<ChangeType> value);
+    public abstract Builder changeType(ChangeType value);
 
     public abstract Builder patchType(Optional<PatchType> value);
 
@@ -223,7 +222,8 @@
           GitFileDiffProto.newBuilder()
               .setFileHeader(gitFileDiff.fileHeader())
               .setOldId(idConverter.toByteString(gitFileDiff.oldId().toObjectId()))
-              .setNewId(idConverter.toByteString(gitFileDiff.newId().toObjectId()));
+              .setNewId(idConverter.toByteString(gitFileDiff.newId().toObjectId()))
+              .setChangeType(gitFileDiff.changeType().name());
       gitFileDiff
           .edits()
           .forEach(
@@ -246,9 +246,6 @@
       if (gitFileDiff.newMode().isPresent()) {
         builder.setNewMode(gitFileDiff.newMode().get().name());
       }
-      if (gitFileDiff.changeType().isPresent()) {
-        builder.setChangeType(gitFileDiff.changeType().get().name());
-      }
       if (gitFileDiff.patchType().isPresent()) {
         builder.setPatchType(gitFileDiff.patchType().get().name());
       }
@@ -267,7 +264,8 @@
                   .collect(toImmutableList()))
           .fileHeader(proto.getFileHeader())
           .oldId(AbbreviatedObjectId.fromObjectId(idConverter.fromByteString(proto.getOldId())))
-          .newId(AbbreviatedObjectId.fromObjectId(idConverter.fromByteString(proto.getNewId())));
+          .newId(AbbreviatedObjectId.fromObjectId(idConverter.fromByteString(proto.getNewId())))
+          .changeType(ChangeType.valueOf(proto.getChangeType()));
 
       if (proto.hasField(OLD_PATH_DESCRIPTOR)) {
         builder.oldPath(Optional.of(proto.getOldPath()));
@@ -281,9 +279,6 @@
       if (proto.hasField(NEW_MODE_DESCRIPTOR)) {
         builder.newMode(Optional.of(Patch.FileMode.valueOf(proto.getNewMode())));
       }
-      if (proto.hasField(CHANGE_TYPE_DESCRIPTOR)) {
-        builder.changeType(Optional.of(Patch.ChangeType.valueOf(proto.getChangeType())));
-      }
       if (proto.hasField(PATCH_TYPE_DESCRIPTOR)) {
         builder.patchType(Optional.of(Patch.PatchType.valueOf(proto.getPatchType())));
       }
diff --git a/java/com/google/gerrit/server/permissions/DefaultRefFilter.java b/java/com/google/gerrit/server/permissions/DefaultRefFilter.java
index e88a840..5092e12 100644
--- a/java/com/google/gerrit/server/permissions/DefaultRefFilter.java
+++ b/java/com/google/gerrit/server/permissions/DefaultRefFilter.java
@@ -50,9 +50,7 @@
 import java.util.stream.Collectors;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.Constants;
-import org.eclipse.jgit.lib.ObjectIdRef;
 import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.lib.Ref.Storage;
 import org.eclipse.jgit.lib.Repository;
 
 class DefaultRefFilter {
@@ -129,15 +127,6 @@
         "Project state %s permits read = %s",
         projectState.getProject().getState(), projectState.statePermitsRead());
 
-    // If we anyway always return all available (most recent) changes in the change index and cache,
-    // we shouldn't care about refs/changes.
-    if (opts.returnMostRecentRefChanges()) {
-      refs =
-          refs.stream()
-              .filter(r -> !RefNames.isRefsChanges(r.getName()))
-              .collect(Collectors.toList());
-    }
-
     // See if we can get away with a single, cheap ref evaluation.
     if (refs.size() == 1) {
       String refName = Iterables.getOnlyElement(refs).getName();
@@ -145,7 +134,7 @@
         logger.atFinest().log("Filter out metadata ref %s", refName);
         return ImmutableList.of();
       }
-      if (RefNames.isRefsChanges(refName) && !opts.returnMostRecentRefChanges()) {
+      if (RefNames.isRefsChanges(refName)) {
         boolean isChangeRefVisisble = canSeeSingleChangeRef(refName);
         if (isChangeRefVisisble) {
           logger.atFinest().log("Change ref %s is visible", refName);
@@ -188,39 +177,10 @@
       }
     }
 
-    if (opts.returnMostRecentRefChanges()) {
-      visibleChangesCache.cachedVisibleChanges().values().stream()
-          .forEach(n -> addAllChangeAndPatchsetRefs(visibleRefs, n));
-    }
-
     logger.atFinest().log("visible refs = %s", visibleRefs);
     return visibleRefs;
   }
 
-  private void addAllChangeAndPatchsetRefs(Collection<Ref> refs, ChangeNotes changeNotes) {
-    refs.add(
-        new ObjectIdRef.PeeledNonTag(
-            Storage.PACKED,
-            RefNames.changeMetaRef(changeNotes.getChangeId()),
-            changeNotes.getMetaId()));
-    changeNotes
-        .getPatchSets()
-        .values()
-        .forEach(
-            p ->
-                refs.add(
-                    new ObjectIdRef.PeeledNonTag(
-                        Storage.PACKED, RefNames.patchSetRef(p.id()), p.commitId())));
-    if (changeNotes.getRobotCommentNotes() != null
-        && changeNotes.getRobotCommentNotes().getMetaId() != null) {
-      refs.add(
-          new ObjectIdRef.PeeledNonTag(
-              Storage.PACKED,
-              RefNames.robotCommentsRef(changeNotes.getChangeId()),
-              changeNotes.getRobotCommentNotes().getMetaId()));
-    }
-  }
-
   /**
    * Filters refs by visibility. Returns tags where visibility can't be trivially computed
    * separately for later rev-walk-based visibility computation. Tags where visibility is trivial to
@@ -359,7 +319,7 @@
       try {
         // Default to READ_PRIVATE_CHANGES as there is no special permission for reading edits.
         permissionBackendForProject
-            .ref(visibleChangesCache.cachedVisibleChanges().get(id).getChange().getDest().branch())
+            .ref(visibleChangesCache.getBranchNameKey(id).branch())
             .check(RefPermission.READ_PRIVATE_CHANGES);
         logger.atFinest().log("Foreign change edit ref is visible: %s", name);
         return true;
diff --git a/java/com/google/gerrit/server/permissions/PermissionBackend.java b/java/com/google/gerrit/server/permissions/PermissionBackend.java
index 6b50228..27c6793 100644
--- a/java/com/google/gerrit/server/permissions/PermissionBackend.java
+++ b/java/com/google/gerrit/server/permissions/PermissionBackend.java
@@ -330,13 +330,6 @@
     public abstract boolean filterMeta();
 
     /**
-     * Return all of the visible change refs that are available in the change index (which are the
-     * most recent changes), even if they are not part of the List<Ref> passed. This allows the
-     * caller not to send all the refs/changes.
-     */
-    public abstract boolean returnMostRecentRefChanges();
-
-    /**
      * Select only refs with names matching prefixes per {@link
      * org.eclipse.jgit.lib.RefDatabase#getRefsByPrefix}.
      */
@@ -347,7 +340,6 @@
     public static Builder builder() {
       return new AutoValue_PermissionBackend_RefFilterOptions.Builder()
           .setFilterMeta(false)
-          .setReturnMostRecentRefChanges(false)
           .setPrefixes(Collections.singletonList(""));
     }
 
@@ -355,8 +347,6 @@
     public abstract static class Builder {
       public abstract Builder setFilterMeta(boolean val);
 
-      public abstract Builder setReturnMostRecentRefChanges(boolean val);
-
       public abstract Builder setPrefixes(List<String> prefixes);
 
       public abstract RefFilterOptions build();
diff --git a/java/com/google/gerrit/server/permissions/VisibleChangesCache.java b/java/com/google/gerrit/server/permissions/VisibleChangesCache.java
index 6284442..2e47576 100644
--- a/java/com/google/gerrit/server/permissions/VisibleChangesCache.java
+++ b/java/com/google/gerrit/server/permissions/VisibleChangesCache.java
@@ -17,9 +17,9 @@
 import static com.google.common.collect.ImmutableList.toImmutableList;
 
 import com.google.common.collect.ImmutableList;
-import com.google.common.collect.Maps;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.exceptions.StorageException;
@@ -32,7 +32,6 @@
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 import java.io.IOException;
-import java.util.Collections;
 import java.util.HashMap;
 import java.util.Map;
 import org.eclipse.jgit.lib.Repository;
@@ -54,7 +53,7 @@
   private final PermissionBackend.ForProject permissionBackendForProject;
 
   private final Repository repository;
-  private Map<Change.Id, ChangeNotes> visibleChanges;
+  private Map<Change.Id, BranchNameKey> visibleChanges;
 
   @Inject
   VisibleChangesCache(
@@ -76,49 +75,59 @@
    * by looking at the cached visible changes.
    */
   public boolean isVisible(Change.Id changeId) throws PermissionBackendException {
-    return cachedVisibleChanges().containsKey(changeId);
+    cachedVisibleChanges();
+    return visibleChanges.containsKey(changeId);
   }
 
   /**
    * Returns the visible changes in the repository {@code repo}. If not cached, computes the visible
    * changes and caches them.
    */
-  public Map<Change.Id, ChangeNotes> cachedVisibleChanges() throws PermissionBackendException {
+  public Map<Change.Id, BranchNameKey> cachedVisibleChanges() throws PermissionBackendException {
     if (visibleChanges == null) {
       if (changeCache == null) {
-        visibleChanges = visibleChangesByScan();
+        visibleChangesByScan();
       } else {
-        visibleChanges = visibleChangesBySearch();
+        visibleChangesBySearch();
       }
       logger.atFinest().log("Visible changes: %s", visibleChanges.keySet());
     }
     return visibleChanges;
   }
 
-  private Map<Change.Id, ChangeNotes> visibleChangesBySearch() throws PermissionBackendException {
+  /**
+   * Returns the {@code BranchNameKey} for {@code changeId}. If not cached, computes *all* visible
+   * changes and caches them before returning this specific change. If not visible or not found,
+   * returns {@code null}.
+   */
+  @Nullable
+  public BranchNameKey getBranchNameKey(Change.Id changeId) throws PermissionBackendException {
+    return cachedVisibleChanges().get(changeId);
+  }
+
+  private void visibleChangesBySearch() throws PermissionBackendException {
+    visibleChanges = new HashMap<>();
     Project.NameKey project = projectState.getNameKey();
     try {
-      Map<Change.Id, ChangeNotes> visibleChanges = new HashMap<>();
       for (ChangeData cd : changeCache.getChangeData(project)) {
         if (!projectState.statePermitsRead()) {
           continue;
         }
         try {
           permissionBackendForProject.change(cd).check(ChangePermission.READ);
-          visibleChanges.put(cd.getId(), cd.notes());
+          visibleChanges.put(cd.getId(), cd.change().getDest());
         } catch (AuthException e) {
           // Do nothing.
         }
       }
-      return visibleChanges;
     } catch (StorageException e) {
       logger.atSevere().withCause(e).log(
           "Cannot load changes for project %s, assuming no changes are visible", project);
-      return Collections.emptyMap();
     }
   }
 
-  private Map<Change.Id, ChangeNotes> visibleChangesByScan() throws PermissionBackendException {
+  private void visibleChangesByScan() throws PermissionBackendException {
+    visibleChanges = new HashMap<>();
     Project.NameKey p = projectState.getNameKey();
     ImmutableList<ChangeNotesResult> changes;
     try {
@@ -126,17 +135,15 @@
     } catch (IOException e) {
       logger.atSevere().withCause(e).log(
           "Cannot load changes for project %s, assuming no changes are visible", p);
-      return Collections.emptyMap();
+      return;
     }
 
-    Map<Change.Id, ChangeNotes> result = Maps.newHashMapWithExpectedSize(changes.size());
     for (ChangeNotesResult notesResult : changes) {
       ChangeNotes notes = toNotes(notesResult);
       if (notes != null) {
-        result.put(notes.getChangeId(), notes);
+        visibleChanges.put(notes.getChangeId(), notes.getChange().getDest());
       }
     }
-    return result;
   }
 
   @Nullable
diff --git a/java/com/google/gerrit/server/restapi/change/GetRevisionActions.java b/java/com/google/gerrit/server/restapi/change/GetRevisionActions.java
index c4da3b6..527129c 100644
--- a/java/com/google/gerrit/server/restapi/change/GetRevisionActions.java
+++ b/java/com/google/gerrit/server/restapi/change/GetRevisionActions.java
@@ -14,67 +14,26 @@
 
 package com.google.gerrit.server.restapi.change;
 
-import com.google.common.hash.Hasher;
-import com.google.common.hash.Hashing;
-import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.common.ActionInfo;
-import com.google.gerrit.extensions.restapi.ETagView;
 import com.google.gerrit.extensions.restapi.Response;
-import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.change.ActionJson;
-import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.RevisionResource;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gerrit.server.submit.ChangeSet;
-import com.google.gerrit.server.submit.MergeSuperSet;
 import com.google.inject.Inject;
-import com.google.inject.Provider;
 import com.google.inject.Singleton;
-import java.io.IOException;
 import java.util.Map;
-import org.eclipse.jgit.lib.Config;
 
 @Singleton
-public class GetRevisionActions implements ETagView<RevisionResource> {
+public class GetRevisionActions implements RestReadView<RevisionResource> {
   private final ActionJson delegate;
-  private final Config config;
-  private final Provider<MergeSuperSet> mergeSuperSet;
-  private final ChangeResource.Factory changeResourceFactory;
 
   @Inject
-  GetRevisionActions(
-      ActionJson delegate,
-      Provider<MergeSuperSet> mergeSuperSet,
-      ChangeResource.Factory changeResourceFactory,
-      @GerritServerConfig Config config) {
+  GetRevisionActions(ActionJson delegate) {
     this.delegate = delegate;
-    this.mergeSuperSet = mergeSuperSet;
-    this.changeResourceFactory = changeResourceFactory;
-    this.config = config;
   }
 
   @Override
   public Response<Map<String, ActionInfo>> apply(RevisionResource rsrc) {
     return Response.withMustRevalidate(delegate.format(rsrc));
   }
-
-  @Override
-  public String getETag(RevisionResource rsrc) {
-    Hasher h = Hashing.murmur3_128().newHasher();
-    CurrentUser user = rsrc.getUser();
-    try {
-      rsrc.getChangeResource().prepareETag(h, user);
-      h.putBoolean(MergeSuperSet.wholeTopicEnabled(config));
-      ChangeSet cs = mergeSuperSet.get().completeChangeSet(rsrc.getChange(), user);
-      for (ChangeData cd : cs.changes()) {
-        changeResourceFactory.create(cd.notes(), user).prepareETag(h, user);
-      }
-      h.putBoolean(cs.furtherHiddenChanges());
-    } catch (IOException | PermissionBackendException e) {
-      throw new StorageException(e);
-    }
-    return h.hash().toString();
-  }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/PutMessage.java b/java/com/google/gerrit/server/restapi/change/PutMessage.java
index 37318d0..1ed7fd7 100644
--- a/java/com/google/gerrit/server/restapi/change/PutMessage.java
+++ b/java/com/google/gerrit/server/restapi/change/PutMessage.java
@@ -20,7 +20,6 @@
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.common.CommitMessageInput;
-import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
@@ -34,7 +33,6 @@
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.NotifyResolver;
 import com.google.gerrit.server.change.PatchSetInserter;
-import com.google.gerrit.server.config.UrlFormatter;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.permissions.ChangePermission;
@@ -72,7 +70,6 @@
   private final PatchSetUtil psUtil;
   private final NotifyResolver notifyResolver;
   private final ProjectCache projectCache;
-  private final DynamicItem<UrlFormatter> urlFormatter;
 
   @Inject
   PutMessage(
@@ -84,8 +81,7 @@
       @GerritPersonIdent PersonIdent gerritIdent,
       PatchSetUtil psUtil,
       NotifyResolver notifyResolver,
-      ProjectCache projectCache,
-      DynamicItem<UrlFormatter> urlFormatter) {
+      ProjectCache projectCache) {
     this.updateFactory = updateFactory;
     this.repositoryManager = repositoryManager;
     this.userProvider = userProvider;
@@ -95,7 +91,6 @@
     this.psUtil = psUtil;
     this.notifyResolver = notifyResolver;
     this.projectCache = projectCache;
-    this.urlFormatter = urlFormatter;
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/restapi/config/GetServerInfo.java b/java/com/google/gerrit/server/restapi/config/GetServerInfo.java
index 780c60a..5459ede 100644
--- a/java/com/google/gerrit/server/restapi/config/GetServerInfo.java
+++ b/java/com/google/gerrit/server/restapi/config/GetServerInfo.java
@@ -222,7 +222,6 @@
     info.showAssigneeInChangesTable =
         toBoolean(
             config.getBoolean("change", "showAssigneeInChangesTable", false) && hasAssigneeInIndex);
-    info.largeChange = config.getInt("change", "largeChange", 500);
     info.replyTooltip =
         Optional.ofNullable(config.getString("change", null, "replyTooltip"))
                 .orElse("Reply and score")
diff --git a/java/com/google/gerrit/server/restapi/project/ProjectsCollection.java b/java/com/google/gerrit/server/restapi/project/ProjectsCollection.java
index f92624f..efc739c 100644
--- a/java/com/google/gerrit/server/restapi/project/ProjectsCollection.java
+++ b/java/com/google/gerrit/server/restapi/project/ProjectsCollection.java
@@ -41,11 +41,9 @@
 import com.google.gerrit.server.project.ProjectState;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
-import com.google.inject.Singleton;
 import java.io.IOException;
 import java.util.Optional;
 
-@Singleton
 public class ProjectsCollection
     implements RestCollection<TopLevelResource, ProjectResource>, NeedsParams {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
diff --git a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
index 1c7b54b..50b7a7c 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
@@ -1360,6 +1360,39 @@
   }
 
   @Test
+  public void rebaseDoesNotAddWorkInProgress() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    // create an unrelated change so that we can rebase
+    testRepo.reset("HEAD~1");
+    PushOneCommit.Result unrelated = createChange();
+    gApi.changes().id(unrelated.getChangeId()).current().review(ReviewInput.approve());
+    gApi.changes().id(unrelated.getChangeId()).current().submit();
+
+    gApi.changes().id(r.getChangeId()).rebase();
+
+    // change is still ready for review after rebase
+    assertThat(gApi.changes().id(r.getChangeId()).get().workInProgress).isNull();
+  }
+
+  @Test
+  public void rebaseDoesNotRemoveWorkInProgress() throws Exception {
+    PushOneCommit.Result r = createChange();
+    change(r).setWorkInProgress();
+
+    // create an unrelated change so that we can rebase
+    testRepo.reset("HEAD~1");
+    PushOneCommit.Result unrelated = createChange();
+    gApi.changes().id(unrelated.getChangeId()).current().review(ReviewInput.approve());
+    gApi.changes().id(unrelated.getChangeId()).current().submit();
+
+    gApi.changes().id(r.getChangeId()).rebase();
+
+    // change is still work in progress after rebase
+    assertThat(gApi.changes().id(r.getChangeId()).get().workInProgress).isTrue();
+  }
+
+  @Test
   public void rebaseConflict_conflictsAllowed() throws Exception {
     String patchSetSubject = "patch set change";
     String patchSetContent = "patch set content";
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/BUILD b/javatests/com/google/gerrit/acceptance/api/revision/BUILD
index 3bfe2f0..517b041 100644
--- a/javatests/com/google/gerrit/acceptance/api/revision/BUILD
+++ b/javatests/com/google/gerrit/acceptance/api/revision/BUILD
@@ -4,4 +4,12 @@
     srcs = [f],
     group = f[:f.index(".")],
     labels = ["api"],
+    deps = [":revision-diff-it"],
 ) for f in glob(["*IT.java"])]
+
+# This is needed because RevisionDiffIT has subclasses that depend on it
+java_library(
+    name = "revision-diff-it",
+    srcs = ["RevisionDiffIT.java"],
+    deps = ["//java/com/google/gerrit/acceptance:lib"],
+)
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java b/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java
index b298497..68bb66c 100644
--- a/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java
@@ -42,7 +42,6 @@
 import com.google.gerrit.extensions.common.FileInfo;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.BinaryResult;
-import com.google.gerrit.testing.ConfigSuite;
 import java.awt.image.BufferedImage;
 import java.io.ByteArrayOutputStream;
 import java.io.IOException;
@@ -55,7 +54,6 @@
 import java.util.function.Function;
 import java.util.stream.IntStream;
 import javax.imageio.ImageIO;
-import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.revwalk.RevCommit;
@@ -66,7 +64,8 @@
 public class RevisionDiffIT extends AbstractDaemonTest {
   // @RunWith(Parameterized.class) can't be used as AbstractDaemonTest is annotated with another
   // runner. Using different configs is a workaround to achieve the same.
-  private static final String TEST_PARAMETER_MARKER = "test_only_parameter";
+  protected static final String TEST_PARAMETER_MARKER = "test_only_parameter";
+
   private static final String CURRENT = "current";
   private static final String FILE_NAME = "some_file.txt";
   private static final String FILE_NAME2 = "another_file.txt";
@@ -83,20 +82,6 @@
   private String changeId;
   private String initialPatchSetId;
 
-  @ConfigSuite.Config
-  public static Config intralineConfig() {
-    Config config = new Config();
-    config.setBoolean(TEST_PARAMETER_MARKER, null, "intraline", true);
-    return config;
-  }
-
-  @ConfigSuite.Config
-  public static Config newDiffCacheConfig() {
-    Config config = new Config();
-    config.setBoolean("cache", "diff_cache", "useNewDiffCache", true);
-    return config;
-  }
-
   @Before
   public void setUp() throws Exception {
     // Reduce flakiness of tests. (If tests aren't fast enough, we would use a fall-back
@@ -105,7 +90,7 @@
     baseConfig.setString("cache", "diff_intraline", "timeout", "1 minute");
 
     intraline = baseConfig.getBoolean(TEST_PARAMETER_MARKER, "intraline", false);
-    useNewDiffCache = baseConfig.getBoolean("cache", "diff_cache", "useNewDiffCache", true);
+    useNewDiffCache = baseConfig.getBoolean("cache", "diff_cache", "useNewDiffCache", false);
 
     ObjectId headCommit = testRepo.getRepository().resolve("HEAD");
     commit1 =
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIntralineIT.java b/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIntralineIT.java
new file mode 100644
index 0000000..ff4ac8e
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIntralineIT.java
@@ -0,0 +1,28 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.api.revision;
+
+import com.google.gerrit.testing.ConfigSuite;
+import org.eclipse.jgit.lib.Config;
+
+/** Runs the {@link RevisionDiffIT} tests with the intraline config enabled. */
+public class RevisionDiffIntralineIT extends RevisionDiffIT {
+  @ConfigSuite.Default
+  public static Config intralineConfig() {
+    Config config = new Config();
+    config.setBoolean(TEST_PARAMETER_MARKER, null, "intraline", true);
+    return config;
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java b/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
index 0d92c60..67e62dd 100644
--- a/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
@@ -88,15 +88,12 @@
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.BinaryResult;
-import com.google.gerrit.extensions.restapi.ETagView;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.extensions.webui.PatchSetWebLink;
-import com.google.gerrit.server.change.RevisionResource;
 import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gerrit.server.restapi.change.GetRevisionActions;
 import com.google.gerrit.testing.FakeEmailSender;
 import com.google.inject.Inject;
 import java.io.ByteArrayOutputStream;
@@ -122,7 +119,6 @@
 import org.junit.Test;
 
 public class RevisionIT extends AbstractDaemonTest {
-  @Inject private GetRevisionActions getRevisionActions;
   @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
   @Inject private ExtensionRegistry extensionRegistry;
@@ -1828,23 +1824,6 @@
   }
 
   @Test
-  public void actionsETag() throws Exception {
-    PushOneCommit.Result r1 = createChange();
-    PushOneCommit.Result r2 = createChange();
-
-    String oldETag = checkETag(getRevisionActions, r2, null);
-    current(r2).review(ReviewInput.approve());
-    oldETag = checkETag(getRevisionActions, r2, oldETag);
-
-    // Dependent change is included in ETag.
-    current(r1).review(ReviewInput.approve());
-    oldETag = checkETag(getRevisionActions, r2, oldETag);
-
-    current(r2).submit();
-    checkETag(getRevisionActions, r2, oldETag);
-  }
-
-  @Test
   public void deleteVoteOnNonCurrentPatchSet() throws Exception {
     PushOneCommit.Result r = createChange(); // patch set 1
     gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.approve());
@@ -2015,13 +1994,6 @@
     return gApi.changes().id(r.getChangeId()).current();
   }
 
-  private String checkETag(ETagView<RevisionResource> view, PushOneCommit.Result r, String oldETag)
-      throws Exception {
-    String eTag = view.getETag(parseRevisionResource(r));
-    assertThat(eTag).isNotEqualTo(oldETag);
-    return eTag;
-  }
-
   private PushOneCommit.Result createCherryPickableMerge(
       String parent1FileName, String parent2FileName) throws Exception {
     RevCommit initialCommit = getHead(repo(), "HEAD");
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/RevisionNewDiffCacheIT.java b/javatests/com/google/gerrit/acceptance/api/revision/RevisionNewDiffCacheIT.java
new file mode 100644
index 0000000..4b85c30
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/revision/RevisionNewDiffCacheIT.java
@@ -0,0 +1,31 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.api.revision;
+
+import com.google.gerrit.testing.ConfigSuite;
+import org.eclipse.jgit.lib.Config;
+
+/**
+ * Runs the {@link RevisionDiffIT} tests with the new diff cache. This is temporary until the new
+ * diff cache is fully deployed. The new diff cache will become the default in the future.
+ */
+public class RevisionNewDiffCacheIT extends RevisionDiffIT {
+  @ConfigSuite.Default
+  public static Config newDiffCacheConfig() {
+    Config config = new Config();
+    config.setBoolean("cache", "diff_cache", "useNewDiffCache", true);
+    return config;
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/git/AbstractGitOverHttpServlet.java b/javatests/com/google/gerrit/acceptance/git/AbstractGitOverHttpServlet.java
index 7f01fb9..a22759f 100644
--- a/javatests/com/google/gerrit/acceptance/git/AbstractGitOverHttpServlet.java
+++ b/javatests/com/google/gerrit/acceptance/git/AbstractGitOverHttpServlet.java
@@ -19,6 +19,7 @@
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.acceptance.FakeGroupAuditService;
 import com.google.gerrit.acceptance.Sandboxed;
+import com.google.gerrit.acceptance.TestProjectInput;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.pgm.http.jetty.JettyServer;
 import com.google.gerrit.server.audit.HttpAuditEvent;
@@ -80,6 +81,7 @@
   }
 
   @Test
+  @TestProjectInput(createEmptyCommit = false)
   public void authenticatedUploadPackAuditEventLog() throws Exception {
     String remote = "authenticated";
     Config cfg = testRepo.git().getRepository().getConfig();
@@ -92,6 +94,7 @@
   }
 
   @Test
+  @TestProjectInput(createEmptyCommit = false)
   public void anonymousUploadPackAuditEventLog() throws Exception {
     String remote = "anonymous";
     Config cfg = testRepo.git().getRepository().getConfig();
@@ -110,16 +113,18 @@
    */
   private void uploadPackAuditEventLog(String remote, Optional<Account.Id> accountId)
       throws Exception {
+    // Make a server-side change to have a common base.
+    createCommit("foo");
+    testRepo.git().fetch().call();
+
+    // Make a server-side change so we have something to fetch.
+    createCommit("bar");
+
     auditService.drainHttpAuditEvents();
-    // testRepo is already a clone. Make a server-side change so we have something to fetch.
-    try (Repository repo = repoManager.openRepository(project);
-        TestRepository<?> testRepo = new TestRepository<>(repo)) {
-      testRepo.branch("master").commit().create();
-    }
     testRepo.git().fetch().setRemote(remote).call();
 
     ImmutableList<HttpAuditEvent> auditEvents = auditService.drainHttpAuditEvents();
-    assertThat(auditEvents).hasSize(4);
+    assertThat(auditEvents).hasSize(3);
 
     // Protocol V2 Capability advertisement
     // https://git-scm.com/docs/protocol-v2#_capability_advertisement
@@ -147,11 +152,13 @@
     assertThat(uploadPackFetch.what).endsWith("/git-upload-pack");
     assertThat(uploadPackFetch.params).isEmpty();
     assertThat(uploadPackFetch.httpStatus).isEqualTo(HttpServletResponse.SC_OK);
-    HttpAuditEvent uploadPackDone = auditEvents.get(3);
-
-    assertThat(uploadPackDone.what).endsWith("/git-upload-pack");
-    assertThat(uploadPackDone.params).isEmpty();
-    assertThat(uploadPackDone.httpStatus).isEqualTo(HttpServletResponse.SC_OK);
     assertThat(jettyServer.numActiveSessions()).isEqualTo(0);
   }
+
+  private void createCommit(String message) throws Exception {
+    try (Repository repo = repoManager.openRepository(project);
+        TestRepository<Repository> tr = new TestRepository<>(repo)) {
+      tr.branch("master").commit().message(message).create();
+    }
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/git/PushPermissionsIT.java b/javatests/com/google/gerrit/acceptance/git/PushPermissionsIT.java
index 78be4ab..385780b 100644
--- a/javatests/com/google/gerrit/acceptance/git/PushPermissionsIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/PushPermissionsIT.java
@@ -82,17 +82,14 @@
         .update();
   }
 
-  @SuppressWarnings("TruthIncompatibleType")
   @Test
   public void mixingMagicAndRegularPush() throws Exception {
     testRepo.branch("HEAD").commit().create();
     PushResult r = push("HEAD:refs/heads/master", "HEAD:refs/for/master");
 
     String msg = "cannot combine normal pushes and magic pushes";
-    assertThat(r.getRemoteUpdate("refs/heads/master"))
-        .isNotEqualTo(/* expected: RemoteRefUpdate, actual: Status */ Status.OK);
-    assertThat(r.getRemoteUpdate("refs/for/master"))
-        .isNotEqualTo(/* expected: RemoteRefUpdate, actual: Status */ Status.OK);
+    assertThat(r.getRemoteUpdate("refs/heads/master").getStatus()).isNotEqualTo(Status.OK);
+    assertThat(r.getRemoteUpdate("refs/for/master").getStatus()).isNotEqualTo(Status.OK);
     assertThat(r.getRemoteUpdate("refs/for/master").getMessage()).isEqualTo(msg);
   }
 
diff --git a/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java b/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java
index 5699a04..b7acbe2 100644
--- a/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java
@@ -45,7 +45,6 @@
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.changes.DraftInput;
-import com.google.gerrit.extensions.api.changes.ReviewInput.RobotCommentInput;
 import com.google.gerrit.extensions.api.groups.GroupInput;
 import com.google.gerrit.extensions.api.projects.BranchInput;
 import com.google.gerrit.extensions.restapi.RestApiException;
@@ -59,7 +58,6 @@
 import com.google.gerrit.server.permissions.PermissionBackend.RefFilterOptions;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.testing.ConfigSuite;
-import com.google.gerrit.testing.TestCommentHelper;
 import com.google.inject.Inject;
 import java.util.ArrayList;
 import java.util.Arrays;
@@ -88,7 +86,6 @@
   @Inject private PermissionBackend permissionBackend;
   @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
-  @Inject private TestCommentHelper testCommentHelper;
 
   private AccountGroup.UUID admins;
   private AccountGroup.UUID nonInteractiveUsers;
@@ -1426,258 +1423,6 @@
   }
 
   @Test
-  public void advertiseMostRecentRefChangesEvenWhenNotInInputWithRefStarPermission()
-      throws Exception {
-    // admin has refs/* permission.
-    requestScopeOperations.setApiUser(admin.id());
-
-    try (Repository repo = repoManager.openRepository(project)) {
-      PermissionBackend.ForProject forProject = newFilter(project, admin);
-      assertThat(
-              names(
-                  forProject.filter(
-                      // set empty list of refs to filter
-                      new ArrayList<>(),
-                      repo,
-                      RefFilterOptions.builder().setReturnMostRecentRefChanges(true).build())))
-          // all the change refs are still returned since returnMostRecentRefChanges = true
-          .containsExactlyElementsIn(
-              ImmutableList.of(
-                  psRef1, metaRef1, psRef2, metaRef2, psRef3, metaRef3, psRef4, metaRef4));
-    }
-  }
-
-  @Test
-  public void advertiseMostRecentRefChangesEvenWhenNotInInputWithoutRefStarPermission()
-      throws Exception {
-    projectOperations
-        .project(project)
-        .forUpdate()
-        .add(allow(Permission.READ).ref("refs/heads/master").group(REGISTERED_USERS))
-        .update();
-
-    // user doesn't have refs/* permission.
-    requestScopeOperations.setApiUser(user.id());
-
-    try (Repository repo = repoManager.openRepository(project)) {
-      PermissionBackend.ForProject forProject = newFilter(project, admin);
-      assertThat(
-              names(
-                  forProject.filter(
-                      // set empty list of refs to filter
-                      new ArrayList<>(),
-                      repo,
-                      RefFilterOptions.builder().setReturnMostRecentRefChanges(true).build())))
-          // all the change refs are still returned since returnMostRecentRefChanges = true
-          .containsExactlyElementsIn(
-              ImmutableList.of(
-                  psRef1, metaRef1, psRef2, metaRef2, psRef3, metaRef3, psRef4, metaRef4));
-    }
-  }
-
-  @Test
-  public void advertiseMostRecentRefChangesOnlyOnceWithRefStarPermission() throws Exception {
-    // admin has refs/* permission.
-    requestScopeOperations.setApiUser(admin.id());
-
-    try (Repository repo = repoManager.openRepository(project)) {
-      PermissionBackend.ForProject forProject = newFilter(project, admin);
-      assertThat(
-              names(
-                  forProject.filter(
-                      repo.getRefDatabase().getRefs(),
-                      repo,
-                      RefFilterOptions.builder().setReturnMostRecentRefChanges(true).build())))
-          // all the change refs are still returned since returnMostRecentRefChanges = true. Make
-          // sure they are only returned once.
-          .containsExactlyElementsIn(
-              ImmutableList.of(
-                  "HEAD",
-                  psRef1,
-                  metaRef1,
-                  psRef2,
-                  metaRef2,
-                  psRef3,
-                  metaRef3,
-                  psRef4,
-                  metaRef4,
-                  "refs/heads/branch",
-                  "refs/heads/master",
-                  "refs/meta/config",
-                  "refs/tags/branch-tag",
-                  "refs/tags/master-tag",
-                  "refs/tags/tree-tag"));
-    }
-  }
-
-  @Test
-  public void advertiseMostRecentRefChangesOnlyOnceWithoutRefStarPermission() throws Exception {
-    projectOperations
-        .project(project)
-        .forUpdate()
-        .add(allow(Permission.READ).ref("refs/heads/master").group(REGISTERED_USERS))
-        .update();
-
-    // user doesn't have refs/* permission.
-    requestScopeOperations.setApiUser(user.id());
-
-    try (Repository repo = repoManager.openRepository(project)) {
-      PermissionBackend.ForProject forProject = newFilter(project, admin);
-      assertThat(
-              names(
-                  forProject.filter(
-                      repo.getRefDatabase().getRefs(),
-                      repo,
-                      RefFilterOptions.builder().setReturnMostRecentRefChanges(true).build())))
-          // all the change refs are still returned since returnMostRecentRefChanges = true. Make
-          // sure they are only returned once.
-          .containsExactlyElementsIn(
-              ImmutableList.of(
-                  "HEAD",
-                  psRef1,
-                  metaRef1,
-                  psRef2,
-                  metaRef2,
-                  psRef3,
-                  metaRef3,
-                  psRef4,
-                  metaRef4,
-                  "refs/heads/branch",
-                  "refs/heads/master",
-                  "refs/meta/config",
-                  "refs/tags/branch-tag",
-                  "refs/tags/master-tag",
-                  "refs/tags/tree-tag"));
-    }
-  }
-
-  @Test
-  public void advertiseMostRecentRefChangesWithSingleRequestedRefWithRefStarPermission()
-      throws Exception {
-    // admin has refs/* permission.
-    requestScopeOperations.setApiUser(admin.id());
-
-    try (Repository repo = repoManager.openRepository(project)) {
-      PermissionBackend.ForProject forProject = newFilter(project, admin);
-      assertThat(
-              names(
-                  forProject.filter(
-                      ImmutableList.of(repo.exactRef("HEAD")),
-                      repo,
-                      RefFilterOptions.builder().setReturnMostRecentRefChanges(true).build())))
-          // all the change refs are still returned since returnMostRecentRefChanges = true.
-          .containsExactlyElementsIn(
-              ImmutableList.of(
-                  "HEAD", psRef1, metaRef1, psRef2, metaRef2, psRef3, metaRef3, psRef4, metaRef4));
-    }
-  }
-
-  @Test
-  public void advertiseMostRecentRefChangesWithSingleRefRequetedWithoutRefStarPermission()
-      throws Exception {
-    projectOperations
-        .project(project)
-        .forUpdate()
-        .add(allow(Permission.READ).ref("refs/heads/master").group(REGISTERED_USERS))
-        .update();
-
-    // user doesn't have refs/* permission.
-    requestScopeOperations.setApiUser(user.id());
-
-    try (Repository repo = repoManager.openRepository(project)) {
-      PermissionBackend.ForProject forProject = newFilter(project, admin);
-      assertThat(
-              names(
-                  forProject.filter(
-                      ImmutableList.of(repo.exactRef("HEAD")),
-                      repo,
-                      RefFilterOptions.builder().setReturnMostRecentRefChanges(true).build())))
-          // all the change refs are still returned since returnMostRecentRefChanges = true.
-          .containsExactlyElementsIn(
-              ImmutableList.of(
-                  "HEAD", psRef1, metaRef1, psRef2, metaRef2, psRef3, metaRef3, psRef4, metaRef4));
-    }
-  }
-
-  @Test
-  public void advertiseMostRecentRefChangesWithRobotCommentRef() throws Exception {
-    projectOperations
-        .project(project)
-        .forUpdate()
-        .add(allow(Permission.READ).ref("refs/heads/master").group(REGISTERED_USERS))
-        .update();
-
-    // user doesn't have refs/* permission.
-    requestScopeOperations.setApiUser(user.id());
-    RobotCommentInput input = TestCommentHelper.createRobotCommentInput(Patch.COMMIT_MSG);
-    testCommentHelper.addRobotComment(cd1.getId(), input);
-
-    try (Repository repo = repoManager.openRepository(project)) {
-      PermissionBackend.ForProject forProject = newFilter(project, admin);
-      assertThat(
-              names(
-                  forProject.filter(
-                      repo.getRefDatabase().getRefs(),
-                      repo,
-                      RefFilterOptions.builder().setReturnMostRecentRefChanges(false).build())))
-          .containsExactlyElementsIn(
-              ImmutableList.of(
-                  "HEAD",
-                  RefNames.changeRefPrefix(cd1.getId()) + "robot-comments",
-                  psRef1,
-                  metaRef1,
-                  psRef2,
-                  metaRef2,
-                  psRef3,
-                  metaRef3,
-                  psRef4,
-                  metaRef4,
-                  "refs/heads/branch",
-                  "refs/heads/master",
-                  "refs/meta/config",
-                  "refs/tags/branch-tag",
-                  "refs/tags/master-tag",
-                  "refs/tags/tree-tag"));
-    }
-  }
-
-  @Test
-  public void advertiseMostRecentRefChangesWithRobotCommentRefWithReturnMostRecentRefChanges()
-      throws Exception {
-    projectOperations
-        .project(project)
-        .forUpdate()
-        .add(allow(Permission.READ).ref("refs/heads/master").group(REGISTERED_USERS))
-        .update();
-
-    // user doesn't have refs/* permission.
-    requestScopeOperations.setApiUser(user.id());
-    RobotCommentInput input = TestCommentHelper.createRobotCommentInput(Patch.COMMIT_MSG);
-    testCommentHelper.addRobotComment(cd1.getId(), input);
-
-    try (Repository repo = repoManager.openRepository(project)) {
-      PermissionBackend.ForProject forProject = newFilter(project, admin);
-      assertThat(
-              names(
-                  forProject.filter(
-                      ImmutableList.of(),
-                      repo,
-                      RefFilterOptions.builder().setReturnMostRecentRefChanges(true).build())))
-          .containsExactlyElementsIn(
-              ImmutableList.of(
-                  RefNames.changeRefPrefix(cd1.getId()) + "robot-comments",
-                  psRef1,
-                  metaRef1,
-                  psRef2,
-                  metaRef2,
-                  psRef3,
-                  metaRef3,
-                  psRef4,
-                  metaRef4));
-    }
-  }
-
-  @Test
   public void fetchSingleChangeWithoutIndexAccess() throws Exception {
     PushOneCommit.Result change = createChange();
     String patchSetRef = change.getPatchSetId().toRefName();
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/ActionsIT.java b/javatests/com/google/gerrit/acceptance/rest/change/ActionsIT.java
index c6a2819..e35f758 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/ActionsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/ActionsIT.java
@@ -20,26 +20,20 @@
 import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_REVISION;
 import static com.google.gerrit.truth.MapSubject.assertThatMap;
 
-import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Iterables;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.ExtensionRegistry;
 import com.google.gerrit.acceptance.ExtensionRegistry.Registration;
 import com.google.gerrit.acceptance.PushOneCommit;
-import com.google.gerrit.acceptance.TestProjectInput;
-import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
-import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.ActionVisitor;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.client.ListChangesOption;
-import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.common.ActionInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.RevisionInfo;
 import com.google.gerrit.server.change.RevisionJson;
-import com.google.gerrit.server.change.testing.TestChangeETagComputation;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.testing.ConfigSuite;
 import com.google.inject.Inject;
@@ -56,7 +50,6 @@
     return submitWholeTopicEnabledConfig();
   }
 
-  @Inject private RequestScopeOperations requestScopeOperations;
   @Inject private RevisionJson.Factory revisionJsonFactory;
   @Inject private ExtensionRegistry extensionRegistry;
 
@@ -68,10 +61,6 @@
     return gApi.changes().id(id).get().actions;
   }
 
-  protected String getETag(String id) throws Exception {
-    return gApi.changes().id(id).current().etag();
-  }
-
   @Test
   public void changeActionOneMergedChangeHasOnlyNormalRevert() throws Exception {
     String changeId = createChangeWithTopic().getChangeId();
@@ -138,124 +127,6 @@
   }
 
   @Test
-  public void revisionActionsETag() throws Exception {
-    String parent = createChange().getChangeId();
-    String change = createChangeWithTopic().getChangeId();
-    approve(change);
-    String etag1 = getETag(change);
-
-    approve(parent);
-    String etag2 = getETag(change);
-
-    String changeWithSameTopic = createChangeWithTopic().getChangeId();
-    String etag3 = getETag(change);
-
-    approve(changeWithSameTopic);
-    String etag4 = getETag(change);
-
-    if (isSubmitWholeTopicEnabled()) {
-      assertThat(ImmutableList.of(etag1, etag2, etag3, etag4)).containsNoDuplicates();
-    } else {
-      assertThat(etag2).isNotEqualTo(etag1);
-      assertThat(etag3).isEqualTo(etag2);
-      assertThat(etag4).isEqualTo(etag2);
-    }
-  }
-
-  @Test
-  public void revisionActionsAnonymousETag() throws Exception {
-    String parent = createChange().getChangeId();
-    String change = createChangeWithTopic().getChangeId();
-    approve(change);
-
-    requestScopeOperations.setApiUserAnonymous();
-    String etag1 = getETag(change);
-
-    requestScopeOperations.setApiUser(admin.id());
-    approve(parent);
-
-    requestScopeOperations.setApiUserAnonymous();
-    String etag2 = getETag(change);
-
-    requestScopeOperations.setApiUser(admin.id());
-    String changeWithSameTopic = createChangeWithTopic().getChangeId();
-
-    requestScopeOperations.setApiUserAnonymous();
-    String etag3 = getETag(change);
-
-    requestScopeOperations.setApiUser(admin.id());
-    approve(changeWithSameTopic);
-
-    requestScopeOperations.setApiUserAnonymous();
-    String etag4 = getETag(change);
-
-    if (isSubmitWholeTopicEnabled()) {
-      assertThat(ImmutableList.of(etag1, etag2, etag3, etag4)).containsNoDuplicates();
-    } else {
-      assertThat(etag2).isNotEqualTo(etag1);
-      assertThat(etag3).isEqualTo(etag2);
-      assertThat(etag4).isEqualTo(etag2);
-    }
-  }
-
-  @Test
-  @TestProjectInput(submitType = SubmitType.CHERRY_PICK)
-  public void revisionActionsAnonymousETagCherryPickStrategy() throws Exception {
-    String parent = createChange().getChangeId();
-    String change = createChange().getChangeId();
-    approve(change);
-
-    requestScopeOperations.setApiUserAnonymous();
-    String etag1 = getETag(change);
-
-    requestScopeOperations.setApiUser(admin.id());
-    approve(parent);
-
-    requestScopeOperations.setApiUserAnonymous();
-    String etag2 = getETag(change);
-    assertThat(etag2).isEqualTo(etag1);
-  }
-
-  @Test
-  public void pluginCanContributeToETagComputation() throws Exception {
-    String change = createChange().getChangeId();
-    String oldETag = getETag(change);
-
-    try (Registration registration =
-        extensionRegistry.newRegistration().add(TestChangeETagComputation.withETag("foo"))) {
-      assertThat(getETag(change)).isNotEqualTo(oldETag);
-    }
-
-    assertThat(getETag(change)).isEqualTo(oldETag);
-  }
-
-  @Test
-  public void returningNullFromETagComputationDoesNotBreakGerrit() throws Exception {
-    String change = createChange().getChangeId();
-    String oldETag = getETag(change);
-
-    try (Registration registration =
-        extensionRegistry.newRegistration().add(TestChangeETagComputation.withETag(null))) {
-      assertThat(getETag(change)).isEqualTo(oldETag);
-    }
-  }
-
-  @Test
-  public void throwingExceptionFromETagComputationDoesNotBreakGerrit() throws Exception {
-    String change = createChange().getChangeId();
-    String oldETag = getETag(change);
-
-    try (Registration registration =
-        extensionRegistry
-            .newRegistration()
-            .add(
-                TestChangeETagComputation.withException(
-                    new StorageException("exception during test")))) {
-      assertThat(getETag(change)).isEqualTo(oldETag);
-    }
-  }
-
-  @Test
   public void revisionActionsTwoChangesInTopic_conflicting() throws Exception {
     String changeId = createChangeWithTopic().getChangeId();
     approve(changeId);
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java b/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java
index 9e944a2..7e6a822 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java
@@ -495,6 +495,24 @@
   }
 
   @Test
+  public void rebaseDoesNotAddToAttentionSet() throws Exception {
+    PushOneCommit.Result r = createChange();
+    change(r).setWorkInProgress();
+    change(r).addReviewer(user.email());
+
+    // create an unrelated change so that we can rebase
+    testRepo.reset("HEAD~1");
+    PushOneCommit.Result unrelated = createChange();
+    gApi.changes().id(unrelated.getChangeId()).current().review(ReviewInput.approve());
+    gApi.changes().id(unrelated.getChangeId()).current().submit();
+
+    gApi.changes().id(r.getChangeId()).rebase();
+
+    // rebase has no impact on the attention set
+    assertThat(r.getChange().attentionSet()).isEmpty();
+  }
+
+  @Test
   public void readyForReviewWhileRemovingReviewerRemovesThemToAttentionSet() throws Exception {
     PushOneCommit.Result r = createChange();
     change(r).setWorkInProgress();
diff --git a/javatests/com/google/gerrit/acceptance/rest/config/ServerInfoIT.java b/javatests/com/google/gerrit/acceptance/rest/config/ServerInfoIT.java
index cef66654..0a84db4 100644
--- a/javatests/com/google/gerrit/acceptance/rest/config/ServerInfoIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/config/ServerInfoIT.java
@@ -58,7 +58,6 @@
   @GerritConfig(name = "auth.httpPasswordUrl", value = "https://example.com/password")
 
   // change
-  @GerritConfig(name = "change.largeChange", value = "300")
   @GerritConfig(name = "change.replyTooltip", value = "Publish votes and draft comments")
   @GerritConfig(name = "change.replyLabel", value = "Vote")
   @GerritConfig(name = "change.updateDelay", value = "50s")
@@ -102,7 +101,6 @@
     assertThat(i.auth.httpPasswordUrl).isNull();
 
     // change
-    assertThat(i.change.largeChange).isEqualTo(300);
     assertThat(i.change.replyTooltip).startsWith("Publish votes and draft comments");
     assertThat(i.change.replyLabel).isEqualTo("Vote\u2026");
     assertThat(i.change.updateDelay).isEqualTo(50);
@@ -170,7 +168,6 @@
     assertThat(i.auth.httpPasswordUrl).isNull();
 
     // change
-    assertThat(i.change.largeChange).isEqualTo(500);
     assertThat(i.change.replyTooltip).startsWith("Reply and score");
     assertThat(i.change.replyLabel).isEqualTo("Reply\u2026");
     assertThat(i.change.updateDelay).isEqualTo(300);
diff --git a/javatests/com/google/gerrit/acceptance/server/experiments/BUILD b/javatests/com/google/gerrit/acceptance/server/experiments/BUILD
new file mode 100644
index 0000000..0f01ffa
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/experiments/BUILD
@@ -0,0 +1,7 @@
+load("//javatests/com/google/gerrit/acceptance:tests.bzl", "acceptance_tests")
+
+acceptance_tests(
+    srcs = glob(["*IT.java"]),
+    group = "server_experiments",
+    labels = ["server"],
+)
diff --git a/javatests/com/google/gerrit/acceptance/server/experiments/ExperimentFeaturesIT.java b/javatests/com/google/gerrit/acceptance/server/experiments/ExperimentFeaturesIT.java
new file mode 100644
index 0000000..09e6dfe
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/experiments/ExperimentFeaturesIT.java
@@ -0,0 +1,77 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.server.experiments;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.config.GerritConfig;
+import com.google.gerrit.server.experiments.ExperimentFeatures;
+import com.google.gerrit.server.experiments.ExperimentFeaturesConstants;
+import com.google.inject.Inject;
+import org.junit.Test;
+
+/** Tests for {@link ExperimentFeatures} */
+public class ExperimentFeaturesIT extends AbstractDaemonTest {
+
+  @Inject ExperimentFeatures experimentFeatures;
+
+  @Test
+  public void emptyConfig_defaultFeatures_enabled() {
+    for (String defaultFeature : ExperimentFeaturesConstants.DEFAULT_ENABLED_FEATURES) {
+      assertThat(experimentFeatures.isFeatureEnabled(defaultFeature)).isTrue();
+    }
+
+    assertThat(experimentFeatures.getEnabledExperimentFeatures())
+        .isEqualTo(ExperimentFeaturesConstants.DEFAULT_ENABLED_FEATURES);
+  }
+
+  @Test
+  @GerritConfig(
+      name = "experiments.enabled",
+      values = {"enabledFeature", "enabledThenDisabledFeature"})
+  @GerritConfig(
+      name = "experiments.disabled",
+      values = {"enabledThenDisabledFeature"})
+  public void configOverride_anyFeatureAllowed() {
+    assertThat(experimentFeatures.isFeatureEnabled("enabledFeature")).isTrue();
+    assertThat(experimentFeatures.isFeatureEnabled("enabledThenDisabledFeature")).isFalse();
+    assertThat(experimentFeatures.isFeatureEnabled("unknownFeature")).isFalse();
+    ImmutableSet<String> expectedEnabledFeatures =
+        new ImmutableSet.Builder<String>()
+            .addAll(ExperimentFeaturesConstants.DEFAULT_ENABLED_FEATURES)
+            .add("enabledFeature")
+            .build();
+    assertThat(experimentFeatures.getEnabledExperimentFeatures())
+        .isEqualTo(expectedEnabledFeatures);
+  }
+
+  @Test
+  @GerritConfig(
+      name = "experiments.enabled",
+      values = {"enabledFeature"})
+  @GerritConfig(
+      name = "experiments.disabled",
+      values = {"UiFeature__patchset_comments"})
+  public void configOverride_defaultFeatureDisabled() {
+    assertThat(experimentFeatures.isFeatureEnabled("enabledFeature")).isTrue();
+    assertThat(
+            experimentFeatures.isFeatureEnabled(
+                ExperimentFeaturesConstants.UI_FEATURE_PATCHSET_COMMENTS))
+        .isFalse();
+    assertThat(experimentFeatures.getEnabledExperimentFeatures()).containsExactly("enabledFeature");
+  }
+}
diff --git a/javatests/com/google/gerrit/httpd/raw/IndexServletTest.java b/javatests/com/google/gerrit/httpd/raw/IndexServletTest.java
index ba9475f..634231f 100644
--- a/javatests/com/google/gerrit/httpd/raw/IndexServletTest.java
+++ b/javatests/com/google/gerrit/httpd/raw/IndexServletTest.java
@@ -15,19 +15,24 @@
 package com.google.gerrit.httpd.raw;
 
 import static com.google.common.truth.Truth.assertThat;
-import static java.util.stream.Collectors.joining;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.when;
 
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.extensions.api.GerritApi;
 import com.google.gerrit.extensions.api.accounts.Accounts;
 import com.google.gerrit.extensions.api.config.Config;
 import com.google.gerrit.extensions.api.config.Server;
 import com.google.gerrit.extensions.common.ServerInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.server.experiments.ConfigExperimentFeatures;
+import com.google.gerrit.server.experiments.ExperimentFeatures;
+import com.google.gerrit.server.experiments.ExperimentFeaturesConstants;
 import com.google.gerrit.util.http.testutil.FakeHttpServletRequest;
 import com.google.gerrit.util.http.testutil.FakeHttpServletResponse;
+import java.util.ArrayList;
+import java.util.List;
 import org.junit.Test;
 
 public class IndexServletTest {
@@ -55,14 +60,19 @@
     String testCdnPath = "bar-cdn";
     String testFaviconURL = "zaz-url";
 
-    String disabledDefault = IndexHtmlUtil.DEFAULT_EXPERIMENTS.asList().get(0);
+    // Pick any known experiment enabled by default;
+    String disabledDefault = ExperimentFeaturesConstants.UI_FEATURE_PATCHSET_COMMENTS;
+    assertThat(ExperimentFeaturesConstants.DEFAULT_ENABLED_FEATURES).contains(disabledDefault);
+
     org.eclipse.jgit.lib.Config serverConfig = new org.eclipse.jgit.lib.Config();
     serverConfig.setStringList(
         "experiments", null, "enabled", ImmutableList.of("NewFeature", "DisabledFeature"));
     serverConfig.setStringList(
         "experiments", null, "disabled", ImmutableList.of("DisabledFeature", disabledDefault));
+    ExperimentFeatures experimentFeatures = new ConfigExperimentFeatures(serverConfig);
     IndexServlet servlet =
-        new IndexServlet(testCanonicalUrl, testCdnPath, testFaviconURL, gerritApi, serverConfig);
+        new IndexServlet(
+            testCanonicalUrl, testCdnPath, testFaviconURL, gerritApi, experimentFeatures);
 
     FakeHttpServletResponse response = new FakeHttpServletResponse();
 
@@ -85,14 +95,17 @@
                 + "\\x22\\/config\\/server\\/info\\x22: \\x7b\\x22default_theme\\x22:"
                 + "\\x22my-default-theme\\x22\\x7d, \\x22\\/config\\/server\\/top-menus\\x22: "
                 + "\\x5b\\x5d\\x7d');");
-    String enabledDefaults =
-        IndexHtmlUtil.DEFAULT_EXPERIMENTS.stream()
+    ImmutableSet<String> enabledDefaults =
+        ExperimentFeaturesConstants.DEFAULT_ENABLED_FEATURES.stream()
             .filter(e -> !e.equals(disabledDefault))
-            .collect(joining("\\x22,"));
+            .collect(ImmutableSet.toImmutableSet());
+    List<String> expectedEnabled = new ArrayList<>();
+    expectedEnabled.add("NewFeature");
+    expectedEnabled.addAll(enabledDefaults);
     assertThat(output)
         .contains(
-            "window.ENABLED_EXPERIMENTS = JSON.parse('\\x5b\\x22NewFeature\\x22,\\x22"
-                + enabledDefaults
+            "window.ENABLED_EXPERIMENTS = JSON.parse('\\x5b\\x22"
+                + String.join("\\x22,", expectedEnabled)
                 + "\\x22\\x5d');</script>");
   }
 }
diff --git a/javatests/com/google/gerrit/server/cache/serialize/entities/FileDiffOutputSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/entities/FileDiffOutputSerializerTest.java
index 4307954..44ea55a 100644
--- a/javatests/com/google/gerrit/server/cache/serialize/entities/FileDiffOutputSerializerTest.java
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/FileDiffOutputSerializerTest.java
@@ -37,7 +37,7 @@
         FileDiffOutput.builder()
             .oldPath(Optional.of("old_file_path.txt"))
             .newPath(Optional.empty())
-            .changeType(Optional.of(ChangeType.DELETED))
+            .changeType(ChangeType.DELETED)
             .patchType(Optional.of(PatchType.UNIFIED))
             .size(23)
             .sizeDelta(10)
diff --git a/javatests/com/google/gerrit/server/cache/serialize/entities/GitFileDiffSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/entities/GitFileDiffSerializerTest.java
index 8030818..93441a4 100644
--- a/javatests/com/google/gerrit/server/cache/serialize/entities/GitFileDiffSerializerTest.java
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/GitFileDiffSerializerTest.java
@@ -47,7 +47,7 @@
             .newPath(Optional.empty())
             .oldId(AbbreviatedObjectId.fromObjectId(OLD_ID))
             .newId(AbbreviatedObjectId.fromObjectId(NEW_ID))
-            .changeType(Optional.of(ChangeType.DELETED))
+            .changeType(ChangeType.DELETED)
             .patchType(Optional.of(PatchType.UNIFIED))
             .oldMode(Optional.of(FileMode.REGULAR_FILE))
             .newMode(Optional.of(FileMode.REGULAR_FILE))
diff --git a/javatests/com/google/gerrit/server/restapi/change/ListChangeCommentsTest.java b/javatests/com/google/gerrit/server/restapi/change/ListChangeCommentsTest.java
index 5cefe74..b3e0c56 100644
--- a/javatests/com/google/gerrit/server/restapi/change/ListChangeCommentsTest.java
+++ b/javatests/com/google/gerrit/server/restapi/change/ListChangeCommentsTest.java
@@ -25,6 +25,7 @@
 import java.sql.Timestamp;
 import java.util.ArrayList;
 import java.util.List;
+import java.util.Set;
 import java.util.stream.Collectors;
 import org.junit.Test;
 import org.junit.runner.RunWith;
@@ -33,7 +34,6 @@
 @RunWith(JUnit4.class)
 public class ListChangeCommentsTest {
 
-  @SuppressWarnings("TruthIncompatibleType")
   @Test
   public void commentsLinkedToChangeMessagesIgnoreGerritAutoGenTaggedMessages() {
     /* Comments should not be linked to Gerrit's autogenerated messages */
@@ -55,10 +55,10 @@
         .isEqualTo(getChangeMessage(changeMessages, "cm3").getKey().uuid());
 
     // Make sure no comment is linked to the auto-gen message
-    assertThat(comments.stream().map(c -> c.changeMessageId).collect(Collectors.toSet()))
-        .doesNotContain(
-            /* expected: String, actual: ChangeMessage */ getChangeMessage(
-                changeMessages, "cmAutoGenByGerrit"));
+    Set<String> changeMessageIds =
+        comments.stream().map(c -> c.changeMessageId).collect(Collectors.toSet());
+    assertThat(changeMessageIds)
+        .doesNotContain(getChangeMessage(changeMessages, "cmAutoGenByGerrit").getKey().uuid());
   }
 
   @Test
diff --git a/package.json b/package.json
index ab23092..fc4161b 100644
--- a/package.json
+++ b/package.json
@@ -3,9 +3,9 @@
   "version": "3.1.0-SNAPSHOT",
   "description": "Gerrit Code Review",
   "dependencies": {
-    "@bazel/rollup": "^3.1.0",
-    "@bazel/terser": "^3.1.0",
-    "@bazel/typescript": "^3.1.0"
+    "@bazel/rollup": "^3.2.0",
+    "@bazel/terser": "^3.2.0",
+    "@bazel/typescript": "^3.2.0"
   },
   "devDependencies": {
     "@typescript-eslint/eslint-plugin": "^4.11.0",
diff --git a/polygerrit-ui/app/.eslintrc.js b/polygerrit-ui/app/.eslintrc.js
index 764d5e8..a3da3cf 100644
--- a/polygerrit-ui/app/.eslintrc.js
+++ b/polygerrit-ui/app/.eslintrc.js
@@ -55,6 +55,7 @@
     }],
     // https://eslint.org/docs/rules/eol-last
     'eol-last': 'off',
+    'guard-for-in': 'error',
     // https://eslint.org/docs/rules/indent
     'indent': ['error', 2, {
       MemberExpression: 2,
diff --git a/polygerrit-ui/app/api/admin.ts b/polygerrit-ui/app/api/admin.ts
new file mode 100644
index 0000000..a7b549d
--- /dev/null
+++ b/polygerrit-ui/app/api/admin.ts
@@ -0,0 +1,29 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/** Interface for menu link */
+export interface MenuLink {
+  text: string;
+  url: string;
+  capability: string | null;
+}
+
+export interface AdminPluginApi {
+  addMenuLink(text: string, url: string, capability?: string): void;
+
+  getMenuLinks(): MenuLink[];
+}
diff --git a/polygerrit-ui/app/api/annotation.ts b/polygerrit-ui/app/api/annotation.ts
new file mode 100644
index 0000000..c046b4f
--- /dev/null
+++ b/polygerrit-ui/app/api/annotation.ts
@@ -0,0 +1,130 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {CoverageRange, Side} from './diff';
+import {StyleObject} from './styles';
+
+export type AddLayerFunc = (ctx: AnnotationContext) => void;
+
+export type NotifyFunc = (
+  path: string,
+  start: number,
+  end: number,
+  side: Side
+) => void;
+
+export type CoverageProvider = (
+  changeNum: number,
+  path: string,
+  basePatchNum?: number,
+  patchNum?: number,
+  /**
+   * This is a ChangeInfo object as defined here:
+   * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#change-info
+   * We neither want to repeat it nor add a dependency on it here.
+   */
+  change?: unknown
+) => Promise<Array<CoverageRange>>;
+
+export interface AnnotationContext {
+  /**
+   * Method to add annotations to a content line.
+   *
+   * @param offset The char offset where the update starts.
+   * @param length The number of chars that the update covers.
+   * @param styleObject The style object for the range.
+   * @param side The side of the update. ('left' or 'right')
+   */
+  annotateRange(
+    offset: number,
+    length: number,
+    styleObject: StyleObject,
+    side: string
+  ): void;
+
+  /**
+   * Method to add a CSS class to the line number TD element.
+   *
+   * @param styleObject The style object for the range.
+   * @param side The side of the update. ('left' or 'right')
+   */
+  annotateLineNumber(styleObject: StyleObject, side: string): void;
+}
+
+export interface AnnotationPluginApi {
+  /**
+   * Register a function to call to apply annotations. Plugins should use
+   * GrAnnotationActionsContext.annotateRange and
+   * GrAnnotationActionsContext.annotateLineNumber to apply a CSS class to the
+   * line content or the line number.
+   *
+   * @param addLayerFunc The function
+   * that will be called when the AnnotationLayer is ready to annotate.
+   */
+  addLayer(addLayerFunc: AddLayerFunc): AnnotationPluginApi;
+
+  /**
+   * The specified function will be called with a notify function for the plugin
+   * to call when it has all required data for annotation. Optional.
+   *
+   * @param notifyFunc See doc of the notify function below to see what it does.
+   */
+  addNotifier(notifyFunc: (n: NotifyFunc) => void): AnnotationPluginApi;
+
+  /**
+   * The specified function will be called when a gr-diff component is built,
+   * and feeds the returned coverage data into the diff. Optional.
+   *
+   * Be sure to call this only once and only from one plugin. Multiple coverage
+   * providers are not supported. A second call will just overwrite the
+   * provider of the first call.
+   */
+  setCoverageProvider(coverageProvider: CoverageProvider): AnnotationPluginApi;
+
+  /**
+   * Returns a checkbox HTMLElement that can be used to toggle annotations
+   * on/off. The checkbox will be initially disabled. Plugins should enable it
+   * when data is ready and should add a click handler to toggle CSS on/off.
+   *
+   * Note1: Calling this method from multiple plugins will only work for the
+   * 1st call. It will print an error message for all subsequent calls
+   * and will not invoke their onAttached functions.
+   * Note2: This method will be deprecated and eventually removed when
+   * https://bugs.chromium.org/p/gerrit/issues/detail?id=8077 is
+   * implemented.
+   *
+   * @param checkboxLabel Will be used as the label for the checkbox.
+   * Optional. "Enable" is used if this is not specified.
+   * @param onAttached The function that will be called
+   * when the checkbox is attached to the page.
+   */
+  enableToggleCheckbox(
+    checkboxLabel: string,
+    onAttached: (checkboxEl: Element | null) => void
+  ): AnnotationPluginApi;
+
+  /**
+   * The notify function will call the listeners of all required annotation
+   * layers. Intended to be called by the plugin when all required data for
+   * annotation is available.
+   *
+   * @param path The file path whose listeners should be notified.
+   * @param start The line where the update starts.
+   * @param end The line where the update ends.
+   * @param side The side of the update ('left' or 'right').
+   */
+  notify(path: string, start: number, end: number, side: Side): void;
+}
diff --git a/polygerrit-ui/app/api/attribute-helper.ts b/polygerrit-ui/app/api/attribute-helper.ts
new file mode 100644
index 0000000..cd52259
--- /dev/null
+++ b/polygerrit-ui/app/api/attribute-helper.ts
@@ -0,0 +1,39 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+export interface AttributeHelperPluginApi {
+  /**
+   * Binds callback to property updates.
+   *
+   * @param name Property name.
+   * @return Unbind function.
+   */
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  bind(name: string, callback: (value: any) => void): () => any;
+
+  /**
+   * Get value of the property from wrapped object. Waits for the property
+   * to be initialized if it isn't defined.
+   */
+  get(name: string): Promise<unknown>;
+
+  /**
+   * Sets value and dispatches event to force notify.
+   */
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  set(name: string, value: any): void;
+}
diff --git a/polygerrit-ui/app/api/change-actions.ts b/polygerrit-ui/app/api/change-actions.ts
new file mode 100644
index 0000000..792f31e
--- /dev/null
+++ b/polygerrit-ui/app/api/change-actions.ts
@@ -0,0 +1,113 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {HttpMethod} from './rest';
+
+export interface ActionInfo {
+  method?: HttpMethod;
+  label?: string;
+  title?: string;
+  enabled?: boolean;
+}
+
+export enum ActionType {
+  CHANGE = 'change',
+  REVISION = 'revision',
+}
+
+export enum ActionPriority {
+  CHANGE = 2,
+  DEFAULT = 0,
+  PRIMARY = 3,
+  REVIEW = -3,
+  REVISION = 1,
+}
+
+export enum ChangeActions {
+  ABANDON = 'abandon',
+  DELETE = '/',
+  DELETE_EDIT = 'deleteEdit',
+  EDIT = 'edit',
+  FOLLOW_UP = 'followup',
+  IGNORE = 'ignore',
+  MOVE = 'move',
+  PRIVATE = 'private',
+  PRIVATE_DELETE = 'private.delete',
+  PUBLISH_EDIT = 'publishEdit',
+  REBASE = 'rebase',
+  REBASE_EDIT = 'rebaseEdit',
+  READY = 'ready',
+  RESTORE = 'restore',
+  REVERT = 'revert',
+  REVERT_SUBMISSION = 'revert_submission',
+  REVIEWED = 'reviewed',
+  STOP_EDIT = 'stopEdit',
+  SUBMIT = 'submit',
+  UNIGNORE = 'unignore',
+  UNREVIEWED = 'unreviewed',
+  WIP = 'wip',
+}
+
+export enum RevisionActions {
+  CHERRYPICK = 'cherrypick',
+  REBASE = 'rebase',
+  SUBMIT = 'submit',
+  DOWNLOAD = 'download',
+}
+
+export type PrimaryActionKey = ChangeActions | RevisionActions;
+
+export interface ChangeActionsPluginApi {
+  addPrimaryActionKey(key: PrimaryActionKey): void;
+
+  removePrimaryActionKey(key: string): void;
+
+  hideQuickApproveAction(): void;
+
+  setActionOverflow(type: ActionType, key: string, overflow: boolean): void;
+
+  setActionPriority(
+    type: ActionType,
+    key: string,
+    priority: ActionPriority
+  ): void;
+
+  setActionHidden(type: ActionType, key: string, hidden: boolean): void;
+
+  add(type: ActionType, label: string): string;
+
+  remove(key: string): void;
+
+  addTapListener(
+    key: string,
+    handler: EventListenerOrEventListenerObject
+  ): void;
+
+  removeTapListener(
+    key: string,
+    handler: EventListenerOrEventListenerObject
+  ): void;
+
+  setLabel(key: string, text: string): void;
+
+  setTitle(key: string, text: string): void;
+
+  setEnabled(key: string, enabled: boolean): void;
+
+  setIcon(key: string, icon: string): void;
+
+  getActionDetails(action: string): ActionInfo | undefined;
+}
diff --git a/polygerrit-ui/app/api/change-metadata.ts b/polygerrit-ui/app/api/change-metadata.ts
new file mode 100644
index 0000000..effe661
--- /dev/null
+++ b/polygerrit-ui/app/api/change-metadata.ts
@@ -0,0 +1,20 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+export interface ChangeMetadataPluginApi {
+  onLabelsChanged(callback: (value: unknown) => void): ChangeMetadataPluginApi;
+}
diff --git a/polygerrit-ui/app/api/change-reply.ts b/polygerrit-ui/app/api/change-reply.ts
new file mode 100644
index 0000000..37c96ee
--- /dev/null
+++ b/polygerrit-ui/app/api/change-reply.ts
@@ -0,0 +1,39 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+export interface LabelsChangedDetail {
+  name: string;
+  value: string;
+}
+export interface ValueChangedDetail {
+  value: string;
+}
+export type ReplyChangedCallback = (text: string) => void;
+export type LabelsChangedCallback = (detail: LabelsChangedDetail) => void;
+
+export interface ChangeReplyPluginApi {
+  getLabelValue(label: string): string;
+
+  setLabelValue(label: string, value: string): void;
+
+  send(includeComments?: boolean): void;
+
+  addReplyTextChangedCallback(handler: ReplyChangedCallback): void;
+
+  addLabelValuesChangedCallback(handler: LabelsChangedCallback): void;
+
+  showMessage(message: string): void;
+}
diff --git a/polygerrit-ui/app/api/checks.ts b/polygerrit-ui/app/api/checks.ts
index 859d82d..143fbd1 100644
--- a/polygerrit-ui/app/api/checks.ts
+++ b/polygerrit-ui/app/api/checks.ts
@@ -20,7 +20,7 @@
 // Changes to all type and interfaces are expected.
 // !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
 
-export interface GrChecksApiInterface {
+export interface ChecksPluginApi {
   /**
    * Must only be called once. You cannot register twice. You cannot unregister.
    */
@@ -224,8 +224,8 @@
 export type ActionCallback = (
   change: number,
   patchset: number,
-  attempt: number,
-  externalId: string,
+  attempt: number | undefined,
+  externalId: string | undefined,
   /** Identical to 'checkName' property of CheckRun. */
   checkName: string,
   /** Identical to 'name' property of Action entity. */
diff --git a/polygerrit-ui/app/api/diff.ts b/polygerrit-ui/app/api/diff.ts
index 7e619c2..5d7125c 100644
--- a/polygerrit-ui/app/api/diff.ts
+++ b/polygerrit-ui/app/api/diff.ts
@@ -225,6 +225,9 @@
   code_range: LineRange;
 }
 
+/** LOST LineNumber is for ported comments without a range, they have their own
+ *  line number and are added on top of the FILE row in gr-diff
+ */
 export declare type LineNumber = number | 'FILE' | 'LOST';
 
 /** The detail of the 'create-comment' event dispatched by gr-diff. */
diff --git a/polygerrit-ui/app/api/event-helper.ts b/polygerrit-ui/app/api/event-helper.ts
new file mode 100644
index 0000000..c4a559b
--- /dev/null
+++ b/polygerrit-ui/app/api/event-helper.ts
@@ -0,0 +1,49 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+export type UnsubscribeCallback = () => void;
+
+export interface EventHelperPluginApi {
+  /**
+   * Add a callback to arbitrary event.
+   * The callback may return false to prevent event bubbling.
+   */
+  on(event: string, callback: (event: Event) => boolean): UnsubscribeCallback;
+
+  /**
+   * Alias for @see onClick
+   */
+  onTap(callback: (event: Event) => boolean): UnsubscribeCallback;
+
+  /**
+   * Add a callback to element click or touch.
+   * The callback may return false to prevent event bubbling.
+   */
+  onClick(callback: (event: Event) => boolean): UnsubscribeCallback;
+
+  /**
+   * Alias for @see captureClick
+   */
+  captureTap(callback: (event: Event) => boolean): UnsubscribeCallback;
+
+  /**
+   * Add a callback to element click or touch ahead of normal flow.
+   * Callback is installed on parent during capture phase.
+   * https://www.w3.org/TR/DOM-Level-3-Events/#event-flow
+   * The callback may return false to cancel regular event listeners.
+   */
+  captureClick(callback: (event: Event) => boolean): UnsubscribeCallback;
+}
diff --git a/polygerrit-ui/app/api/hook.ts b/polygerrit-ui/app/api/hook.ts
new file mode 100644
index 0000000..179b967
--- /dev/null
+++ b/polygerrit-ui/app/api/hook.ts
@@ -0,0 +1,52 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+interface GerritElementExtensions {
+  content?: HTMLElement & {hidden?: boolean};
+  change?: unknown;
+  revision?: unknown;
+  token?: string;
+  repoName?: string;
+  /**
+   * This is a ConfigInfo object as defined here:
+   * https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#config-info
+   * We neither want to repeat it nor add a dependency on it here.
+   */
+  config?: unknown;
+}
+
+export type HookCallback = (el: HTMLElement & GerritElementExtensions) => void;
+
+export interface RegisterOptions {
+  slot?: string;
+  replace: unknown;
+}
+
+export interface HookApi {
+  onAttached(callback: HookCallback): HookApi;
+
+  onDetached(callback: HookCallback): HookApi;
+
+  getAllAttached(): HTMLElement[];
+
+  getLastAttached(): Promise<HTMLElement>;
+
+  getModuleName(): string;
+
+  handleInstanceDetached(instance: HTMLElement): void;
+
+  handleInstanceAttached(instance: HTMLElement): void;
+}
diff --git a/polygerrit-ui/app/api/plugin.ts b/polygerrit-ui/app/api/plugin.ts
new file mode 100644
index 0000000..cd742a2
--- /dev/null
+++ b/polygerrit-ui/app/api/plugin.ts
@@ -0,0 +1,92 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {AdminPluginApi} from './admin';
+import {AnnotationPluginApi} from './annotation';
+import {AttributeHelperPluginApi} from './attribute-helper';
+import {ChangeMetadataPluginApi} from './change-metadata';
+import {ChangeReplyPluginApi} from './change-reply';
+import {ChecksPluginApi} from './checks';
+import {EventHelperPluginApi} from './event-helper';
+import {PopupPluginApi} from './popup';
+import {RepoPluginApi} from './repo';
+import {ReportingPluginApi} from './reporting';
+import {SettingsPluginApi} from './settings';
+import {StylesPluginApi} from './styles';
+import {ThemePluginApi} from './theme';
+import {ChangeActionsPluginApi} from './change-actions';
+import {RestPluginApi} from './rest';
+import {HookApi, RegisterOptions} from './hook';
+
+export enum TargetElement {
+  CHANGE_ACTIONS = 'changeactions',
+  REPLY_DIALOG = 'replydialog',
+}
+
+// Note: for new events, naming convention should be: `a-b`
+export enum EventType {
+  HISTORY = 'history',
+  LABEL_CHANGE = 'labelchange',
+  SHOW_CHANGE = 'showchange',
+  SUBMIT_CHANGE = 'submitchange',
+  SHOW_REVISION_ACTIONS = 'show-revision-actions',
+  COMMIT_MSG_EDIT = 'commitmsgedit',
+  COMMENT = 'comment',
+  REVERT = 'revert',
+  REVERT_SUBMISSION = 'revert_submission',
+  POST_REVERT = 'postrevert',
+  ANNOTATE_DIFF = 'annotatediff',
+  ADMIN_MENU_LINKS = 'admin-menu-links',
+  HIGHLIGHTJS_LOADED = 'highlightjs-loaded',
+}
+
+export interface PluginApi {
+  _url?: URL;
+  admin(): AdminPluginApi;
+  annotationApi(): AnnotationPluginApi;
+  attributeHelper(element: Element): AttributeHelperPluginApi;
+  changeActions(): ChangeActionsPluginApi;
+  changeMetadata(): ChangeMetadataPluginApi;
+  changeReply(): ChangeReplyPluginApi;
+  checks(): ChecksPluginApi;
+  eventHelper(element: Node): EventHelperPluginApi;
+  getPluginName(): string;
+  hook(endpointName: string, opt_options?: RegisterOptions): HookApi;
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  on(eventName: EventType, target: any): void;
+  popup(): Promise<PopupPluginApi>;
+  popup(moduleName: string): Promise<PopupPluginApi>;
+  popup(moduleName?: string): Promise<PopupPluginApi | null>;
+  project(): RepoPluginApi;
+  registerCustomComponent(
+    endpointName: string,
+    moduleName?: string,
+    options?: RegisterOptions
+  ): HookApi;
+  registerDynamicCustomComponent(
+    endpointName: string,
+    moduleName?: string,
+    options?: RegisterOptions
+  ): HookApi;
+  registerStyleModule(endpoint: string, moduleName: string): void;
+  reporting(): ReportingPluginApi;
+  restApi(): RestPluginApi;
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  screen(screenName: string, moduleName?: string): any;
+  settings(): SettingsPluginApi;
+  styles(): StylesPluginApi;
+  theme(): ThemePluginApi;
+}
diff --git a/polygerrit-ui/app/api/popup.ts b/polygerrit-ui/app/api/popup.ts
new file mode 100644
index 0000000..60772cc
--- /dev/null
+++ b/polygerrit-ui/app/api/popup.ts
@@ -0,0 +1,30 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+export interface PopupPluginApi {
+  /**
+   * Opens the popup, inserts it into DOM over current UI.
+   * Creates the popup if not previously created. Creates popup content element,
+   * if it was provided with constructor.
+   */
+  open(): Promise<PopupPluginApi>;
+
+  /**
+   * Hides the popup.
+   */
+  close(): void;
+}
diff --git a/polygerrit-ui/app/api/repo.ts b/polygerrit-ui/app/api/repo.ts
new file mode 100644
index 0000000..a626471
--- /dev/null
+++ b/polygerrit-ui/app/api/repo.ts
@@ -0,0 +1,31 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+export type RepoCommandCallback = (
+  repo?: string,
+  /**
+   * This is a ConfigInfo object as defined here:
+   * https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#config-info
+   * We neither want to repeat it nor add a dependency on it here.
+   */
+  config?: unknown
+) => boolean;
+
+export interface RepoPluginApi {
+  createCommand(title: string, callback: RepoCommandCallback): RepoPluginApi;
+
+  onTap(callback: (event: Event) => boolean): RepoPluginApi;
+}
diff --git a/polygerrit-ui/app/api/reporting.ts b/polygerrit-ui/app/api/reporting.ts
new file mode 100644
index 0000000..65bdc3f
--- /dev/null
+++ b/polygerrit-ui/app/api/reporting.ts
@@ -0,0 +1,25 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export type EventDetails = any;
+
+export interface ReportingPluginApi {
+  reportInteraction(eventName: string, details?: EventDetails): void;
+
+  reportLifeCycle(eventName: string, details?: EventDetails): void;
+}
diff --git a/polygerrit-ui/app/api/rest.ts b/polygerrit-ui/app/api/rest.ts
new file mode 100644
index 0000000..fd9cada
--- /dev/null
+++ b/polygerrit-ui/app/api/rest.ts
@@ -0,0 +1,106 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+export type RequestPayload = string | object;
+
+export enum HttpMethod {
+  HEAD = 'HEAD',
+  POST = 'POST',
+  GET = 'GET',
+  DELETE = 'DELETE',
+  PUT = 'PUT',
+}
+
+export type ErrorCallback = (response?: Response | null, err?: Error) => void;
+
+export interface RestPluginApi {
+  getLoggedIn(): Promise<boolean>;
+
+  getVersion(): Promise<string | undefined>;
+
+  /**
+   * Returns a ServerInfo object as defined here:
+   * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#server-info
+   * We neither want to repeat it nor add a dependency on it here.
+   */
+  getConfig(): Promise<unknown>;
+
+  invalidateReposCache(): void;
+
+  fetch(
+    method: HttpMethod,
+    url: string,
+    payload?: RequestPayload,
+    errFn?: undefined,
+    contentType?: string
+  ): Promise<Response>;
+
+  fetch(
+    method: HttpMethod,
+    url: string,
+    payload: RequestPayload | undefined,
+    errFn: ErrorCallback,
+    contentType?: string
+  ): Promise<Response | void>;
+
+  fetch(
+    method: HttpMethod,
+    url: string,
+    payload: RequestPayload | undefined,
+    errFn?: ErrorCallback,
+    contentType?: string
+  ): Promise<Response | void>;
+
+  /**
+   * Fetch and return native browser REST API Response.
+   */
+  fetch(
+    method: HttpMethod,
+    url: string,
+    payload?: RequestPayload,
+    errFn?: ErrorCallback,
+    contentType?: string
+  ): Promise<Response | void>;
+
+  /**
+   * Fetch and parse REST API response, if request succeeds.
+   */
+  send(
+    method: HttpMethod,
+    url: string,
+    payload?: RequestPayload,
+    errFn?: ErrorCallback,
+    contentType?: string
+  ): Promise<unknown>;
+
+  get(url: string): Promise<unknown>;
+
+  post(
+    url: string,
+    payload?: RequestPayload,
+    errFn?: ErrorCallback,
+    contentType?: string
+  ): Promise<unknown>;
+
+  put(
+    url: string,
+    payload?: RequestPayload,
+    errFn?: ErrorCallback,
+    contentType?: string
+  ): Promise<unknown>;
+
+  delete(url: string): Promise<Response>;
+}
diff --git a/polygerrit-ui/app/api/settings.ts b/polygerrit-ui/app/api/settings.ts
new file mode 100644
index 0000000..03cf474
--- /dev/null
+++ b/polygerrit-ui/app/api/settings.ts
@@ -0,0 +1,27 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {HookApi} from './hook';
+
+export interface SettingsPluginApi {
+  title(newTitle: string): SettingsPluginApi;
+
+  token(newToken: string): SettingsPluginApi;
+
+  module(newModuleName: string): SettingsPluginApi;
+
+  build(): HookApi;
+}
diff --git a/polygerrit-ui/app/api/styles.ts b/polygerrit-ui/app/api/styles.ts
new file mode 100644
index 0000000..233c3e2
--- /dev/null
+++ b/polygerrit-ui/app/api/styles.ts
@@ -0,0 +1,35 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+export interface StyleObject {
+  /**
+   * Creates a new unique CSS class and injects it in a root node of the element
+   * if it hasn't been added yet. A root node is an document or is the
+   * associated shadowRoot. This class can be added to any element with the same
+   * root node.
+   */
+  getClassName(element: Element): string;
+
+  /**
+   * Apply shared style to the element.
+   */
+  apply(element: Element): void;
+}
+
+export interface StylesPluginApi {
+  css(ruleStr: string): StyleObject;
+}
diff --git a/polygerrit-ui/app/api/theme.ts b/polygerrit-ui/app/api/theme.ts
new file mode 100644
index 0000000..70ffcb3
--- /dev/null
+++ b/polygerrit-ui/app/api/theme.ts
@@ -0,0 +1,20 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+export interface ThemePluginApi {
+  setHeaderLogoAndTitle(logoUrl: string, title: string): void;
+}
diff --git a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.ts b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.ts
index 0b1e27e..2124949 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.ts
+++ b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.ts
@@ -34,7 +34,6 @@
   InheritedBooleanInfo,
 } from '../../../types/common';
 import {InheritedBooleanInfoConfiguredValue} from '../../../constants/constants';
-import {hasOwnProperty} from '../../../utils/common-util';
 import {GrAutocomplete} from '../../shared/gr-autocomplete/gr-autocomplete';
 import {IronAutogrowTextareaElement} from '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
 import {appContext} from '../../../services/app-context';
@@ -174,19 +173,12 @@
       .then(response => {
         if (!response) return [];
         const branches = [];
-        let branch;
-        for (const key in response) {
-          if (!hasOwnProperty(response, key)) {
-            continue;
+        for (const branchInfo of response) {
+          let name: string = branchInfo.ref;
+          if (name.startsWith('refs/heads/')) {
+            name = name.substring('refs/heads/'.length);
           }
-          if (response[key].ref.startsWith('refs/heads/')) {
-            branch = response[key].ref.substring('refs/heads/'.length);
-          } else {
-            branch = response[key].ref;
-          }
-          branches.push({
-            name: branch,
-          });
+          branches.push({name});
         }
         return branches;
       });
diff --git a/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog.ts b/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog.ts
index 1b16052..c9fd241 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog.ts
+++ b/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog.ts
@@ -28,7 +28,6 @@
 import {page} from '../../../utils/page-wrapper-utils';
 import {customElement, observe, property} from '@polymer/decorators';
 import {ProjectInput, RepoName} from '../../../types/common';
-import {hasOwnProperty} from '../../../utils/common-util';
 import {AutocompleteQuery} from '../../shared/gr-autocomplete/gr-autocomplete';
 import {appContext} from '../../../services/app-context';
 
@@ -115,14 +114,8 @@
   _getRepoSuggestions(input: string) {
     return this.restApiService.getSuggestedProjects(input).then(response => {
       const repos = [];
-      for (const key in response) {
-        if (!hasOwnProperty(response, key)) {
-          continue;
-        }
-        repos.push({
-          name: key,
-          value: response[key].id,
-        });
+      for (const [name, project] of Object.entries(response ?? {})) {
+        repos.push({name, value: project.id});
       }
       return repos;
     });
@@ -131,14 +124,8 @@
   _getGroupSuggestions(input: string) {
     return this.restApiService.getSuggestedGroups(input).then(response => {
       const groups = [];
-      for (const key in response) {
-        if (!hasOwnProperty(response, key)) {
-          continue;
-        }
-        groups.push({
-          name: key,
-          value: decodeURIComponent(response[key].id),
-        });
+      for (const [name, group] of Object.entries(response ?? {})) {
+        groups.push({name, value: decodeURIComponent(group.id)});
       }
       return groups;
     });
diff --git a/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log.ts b/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log.ts
index 3aeea3b..bc4750f 100644
--- a/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log.ts
+++ b/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log.ts
@@ -26,7 +26,6 @@
 import {ListViewMixin} from '../../../mixins/gr-list-view-mixin/gr-list-view-mixin';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 import {customElement, property} from '@polymer/decorators';
-import {ErrorCallback} from '../../../services/gr-rest-api/gr-rest-api';
 import {
   GroupInfo,
   AccountInfo,
@@ -35,6 +34,7 @@
 } from '../../../types/common';
 import {firePageError, fireTitleChange} from '../../../utils/event-util';
 import {appContext} from '../../../services/app-context';
+import {ErrorCallback} from '../../../api/rest';
 
 const GROUP_EVENTS = ['ADD_GROUP', 'REMOVE_GROUP'];
 
diff --git a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.ts b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.ts
index 888647f..451139c 100644
--- a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.ts
+++ b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.ts
@@ -30,7 +30,6 @@
 import {htmlTemplate} from './gr-group-members_html';
 import {getBaseUrl} from '../../../utils/url-util';
 import {customElement, property} from '@polymer/decorators';
-import {ErrorCallback} from '../../../services/gr-rest-api/gr-rest-api';
 import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
 import {
   GroupId,
@@ -39,15 +38,18 @@
   GroupInfo,
   GroupName,
 } from '../../../types/common';
-import {AutocompleteQuery} from '../../shared/gr-autocomplete/gr-autocomplete';
+import {
+  AutocompleteQuery,
+  AutocompleteSuggestion,
+} from '../../shared/gr-autocomplete/gr-autocomplete';
 import {PolymerDomRepeatEvent} from '../../../types/types';
-import {hasOwnProperty} from '../../../utils/common-util';
 import {
   fireAlert,
   firePageError,
   fireTitleChange,
 } from '../../../utils/event-util';
 import {appContext} from '../../../services/app-context';
+import {ErrorCallback} from '../../../api/rest';
 
 const SUGGESTIONS_LIMIT = 15;
 const SAVING_ERROR_TEXT =
@@ -339,23 +341,18 @@
     return this.restApiService
       .getSuggestedAccounts(input, SUGGESTIONS_LIMIT)
       .then(accounts => {
+        if (!accounts) return [];
         const accountSuggestions = [];
-        let nameAndEmail;
-        if (!accounts) {
-          return [];
-        }
-        for (const key in accounts) {
-          if (!hasOwnProperty(accounts, key)) {
-            continue;
-          }
-          if (accounts[key].email !== undefined) {
-            nameAndEmail = `${accounts[key].name} <${accounts[key].email}>`;
+        for (const account of accounts) {
+          let nameAndEmail;
+          if (account.email !== undefined) {
+            nameAndEmail = `${account.name} <${account.email}>`;
           } else {
-            nameAndEmail = accounts[key].name;
+            nameAndEmail = account.name;
           }
           accountSuggestions.push({
             name: nameAndEmail,
-            value: accounts[key]._account_id?.toString(),
+            value: account._account_id?.toString(),
           });
         }
         return accountSuggestions;
@@ -364,15 +361,9 @@
 
   _getGroupSuggestions(input: string) {
     return this.restApiService.getSuggestedGroups(input).then(response => {
-      const groups = [];
-      for (const key in response) {
-        if (!hasOwnProperty(response, key)) {
-          continue;
-        }
-        groups.push({
-          name: key,
-          value: decodeURIComponent(response[key].id),
-        });
+      const groups: AutocompleteSuggestion[] = [];
+      for (const [name, group] of Object.entries(response ?? {})) {
+        groups.push({name, value: decodeURIComponent(group.id)});
       }
       return groups;
     });
diff --git a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_test.js b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_test.js
index 654d24a..672ac07 100644
--- a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_test.js
+++ b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_test.js
@@ -100,7 +100,7 @@
           },
         ]);
       } else {
-        return Promise.resolve({});
+        return Promise.resolve([]);
       }
     });
     stubRestApi('getSuggestedGroups').callsFake(input => {
diff --git a/polygerrit-ui/app/elements/admin/gr-group/gr-group.ts b/polygerrit-ui/app/elements/admin/gr-group/gr-group.ts
index cee803c..6f00445 100644
--- a/polygerrit-ui/app/elements/admin/gr-group/gr-group.ts
+++ b/polygerrit-ui/app/elements/admin/gr-group/gr-group.ts
@@ -32,14 +32,13 @@
   AutocompleteQuery,
 } from '../../shared/gr-autocomplete/gr-autocomplete';
 import {GroupId, GroupInfo, GroupName} from '../../../types/common';
-import {ErrorCallback} from '../../../services/gr-rest-api/gr-rest-api';
-import {hasOwnProperty} from '../../../utils/common-util';
 import {
   fireEvent,
   firePageError,
   fireTitleChange,
 } from '../../../utils/event-util';
 import {appContext} from '../../../services/app-context';
+import {ErrorCallback} from '../../../api/rest';
 
 const INTERNAL_GROUP_REGEX = /^[\da-f]{40}$/;
 
@@ -301,14 +300,8 @@
   _getGroupSuggestions(input: string) {
     return this.restApiService.getSuggestedGroups(input).then(response => {
       const groups: AutocompleteSuggestion[] = [];
-      for (const key in response) {
-        if (!hasOwnProperty(response, key)) {
-          continue;
-        }
-        groups.push({
-          name: key,
-          value: decodeURIComponent(response[key].id),
-        });
+      for (const [name, group] of Object.entries(response ?? {})) {
+        groups.push({name, value: decodeURIComponent(group.id)});
       }
       return groups;
     });
diff --git a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.ts b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.ts
index 65a3b00..5702bfb 100644
--- a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.ts
+++ b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.ts
@@ -33,7 +33,6 @@
   PermissionArray,
 } from '../../../utils/access-util';
 import {customElement, property, observe} from '@polymer/decorators';
-import {hasOwnProperty} from '../../../utils/common-util';
 import {
   LabelNameToLabelTypeInfoMap,
   LabelTypeInfoValues,
@@ -333,14 +332,8 @@
       .getSuggestedGroups(this._groupFilter || '', MAX_AUTOCOMPLETE_RESULTS)
       .then(response => {
         const groups: GroupSuggestion[] = [];
-        for (const key in response) {
-          if (!hasOwnProperty(response, key)) {
-            continue;
-          }
-          groups.push({
-            name: key,
-            value: response[key],
-          });
+        for (const [name, value] of Object.entries(response ?? {})) {
+          groups.push({name, value});
         }
         // Does not return groups in which we already have rules for.
         return groups
diff --git a/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list.ts b/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list.ts
index ceb08b6..fc1ceee 100644
--- a/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list.ts
+++ b/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list.ts
@@ -26,11 +26,11 @@
   ListViewParams,
 } from '../../../mixins/gr-list-view-mixin/gr-list-view-mixin';
 import {customElement, property} from '@polymer/decorators';
-import {ErrorCallback} from '../../../services/gr-rest-api/gr-rest-api';
 import {PluginInfo} from '../../../types/common';
 import {firePageError} from '../../../utils/event-util';
 import {fireTitleChange} from '../../../utils/event-util';
 import {appContext} from '../../../services/app-context';
+import {ErrorCallback} from '../../../api/rest';
 
 interface PluginInfoWithName extends PluginInfo {
   name: string;
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.ts b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.ts
index 9fb79d3..935d091 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.ts
@@ -37,7 +37,6 @@
   UrlEncodedRepoName,
   ProjectAccessGroups,
 } from '../../../types/common';
-import {hasOwnProperty} from '../../../utils/common-util';
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {GrAccessSection} from '../gr-access-section/gr-access-section';
 import {
@@ -370,11 +369,9 @@
   /**
    * Used to recursively remove any objects with a 'deleted' bit.
    */
-  _recursivelyRemoveDeleted(obj: PropertyTreeNode) {
-    for (const k in obj) {
-      if (!hasOwnProperty(obj, k)) {
-        continue;
-      }
+  _recursivelyRemoveDeleted(obj?: PropertyTreeNode) {
+    if (!obj) return;
+    for (const k of Object.keys(obj)) {
       const node = obj[k];
       if (typeof node === 'object') {
         if (node.deleted) {
@@ -387,17 +384,15 @@
   }
 
   _recursivelyUpdateAddRemoveObj(
-    obj: PropertyTreeNode,
+    obj: PropertyTreeNode | undefined,
     addRemoveObj: {
       add: PropertyTreeNode;
       remove: PropertyTreeNode;
     },
     path: string[] = []
   ) {
-    for (const k in obj) {
-      if (!hasOwnProperty(obj, k)) {
-        continue;
-      }
+    if (!obj) return;
+    for (const k of Object.keys(obj)) {
       const node = obj[k];
       if (typeof node === 'object') {
         const updatedId = node.updatedId;
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands.ts b/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands.ts
index 9bc0466..14cf234 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands.ts
@@ -29,7 +29,6 @@
 import {htmlTemplate} from './gr-repo-commands_html';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 import {customElement, property} from '@polymer/decorators';
-import {ErrorCallback} from '../../../services/gr-rest-api/gr-rest-api';
 import {
   BranchName,
   ConfigInfo,
@@ -44,6 +43,7 @@
   fireTitleChange,
 } from '../../../utils/event-util';
 import {appContext} from '../../../services/app-context';
+import {ErrorCallback} from '../../../api/rest';
 
 const GC_MESSAGE = 'Garbage collection completed successfully.';
 const CONFIG_BRANCH = 'refs/meta/config' as BranchName;
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards.ts b/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards.ts
index b30d1f4..5f6cd29 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards.ts
@@ -24,9 +24,9 @@
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 import {customElement, property} from '@polymer/decorators';
 import {RepoName, DashboardId, DashboardInfo} from '../../../types/common';
-import {ErrorCallback} from '../../../services/gr-rest-api/gr-rest-api';
 import {firePageError} from '../../../utils/event-util';
 import {appContext} from '../../../services/app-context';
+import {ErrorCallback} from '../../../api/rest';
 
 interface DashboardRef {
   section: string;
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list.ts b/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list.ts
index 5325dc7..a486e27 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list.ts
@@ -35,7 +35,6 @@
 import {ListViewMixin} from '../../../mixins/gr-list-view-mixin/gr-list-view-mixin';
 import {encodeURL} from '../../../utils/url-util';
 import {customElement, property} from '@polymer/decorators';
-import {ErrorCallback} from '../../../services/gr-rest-api/gr-rest-api';
 import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
 import {GrCreatePointerDialog} from '../gr-create-pointer-dialog/gr-create-pointer-dialog';
 import {
@@ -51,6 +50,7 @@
 import {RepoDetailView} from '../../core/gr-navigation/gr-navigation';
 import {firePageError} from '../../../utils/event-util';
 import {appContext} from '../../../services/app-context';
+import {ErrorCallback} from '../../../api/rest';
 
 const PGP_START = '-----BEGIN PGP SIGNATURE-----';
 
diff --git a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts
index 1c30e96..bcc6039 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts
@@ -30,7 +30,6 @@
 import {htmlTemplate} from './gr-repo_html';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 import {customElement, property, observe} from '@polymer/decorators';
-import {ErrorCallback} from '../../../services/gr-rest-api/gr-rest-api';
 import {
   ConfigInfo,
   RepoName,
@@ -47,6 +46,7 @@
 import {firePageError, fireTitleChange} from '../../../utils/event-util';
 import {appContext} from '../../../services/app-context';
 import {WebLinkInfo} from '../../../types/diff';
+import {ErrorCallback} from '../../../api/rest';
 
 const STATES = {
   active: {value: ProjectState.ACTIVE, label: 'Active'},
@@ -392,21 +392,14 @@
     schemesObj?: SchemesInfoMap,
     _selectedScheme?: string
   ) {
-    if (!schemesObj || !repo || !_selectedScheme) {
-      return [];
-    }
+    if (!schemesObj || !repo || !_selectedScheme) return [];
+    if (!hasOwnProperty(schemesObj, _selectedScheme)) return [];
+    const commandObj = schemesObj[_selectedScheme].clone_commands;
     const commands = [];
-    let commandObj: {[title: string]: string} = {};
-    if (hasOwnProperty(schemesObj, _selectedScheme)) {
-      commandObj = schemesObj[_selectedScheme].clone_commands;
-    }
-    for (const title in commandObj) {
-      if (!hasOwnProperty(commandObj, title)) {
-        continue;
-      }
+    for (const [title, command] of Object.entries(commandObj)) {
       commands.push({
         title,
-        command: commandObj[title]
+        command: command
           .replace(/\${project}/gi, encodeURI(repo))
           .replace(
             /\${project-base-name}/gi,
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.ts
index 973ccc8..441d514 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.ts
@@ -37,17 +37,16 @@
 } from '../../../types/common';
 import {ChangeListToggleReviewedDetail} from '../gr-change-list-item/gr-change-list-item';
 import {ChangeStarToggleStarDetail} from '../../shared/gr-change-star/gr-change-star';
-import {hasOwnProperty} from '../../../utils/common-util';
 import {ChangeListViewState} from '../../../types/types';
 import {fireTitleChange} from '../../../utils/event-util';
 import {appContext} from '../../../services/app-context';
 import {GerritView} from '../../../services/router/router-model';
 
-const LookupQueryPatterns = {
-  CHANGE_ID: /^\s*i?[0-9a-f]{7,40}\s*$/i,
-  CHANGE_NUM: /^\s*[1-9][0-9]*\s*$/g,
-  COMMIT: /[0-9a-f]{40}/,
-};
+const LOOKUP_QUERY_PATTERNS: RegExp[] = [
+  /^\s*i?[0-9a-f]{7,40}\s*$/i, // CHANGE_ID
+  /^\s*[1-9][0-9]*\s*$/g, // CHANGE_NUM
+  /[0-9a-f]{40}/, // COMMIT
+];
 
 const USER_QUERY_PATTERN = /^owner:\s?("[^"]+"|[^ ]+)$/;
 
@@ -159,12 +158,8 @@
       .then(changes => {
         changes = changes || [];
         if (this._query && changes.length === 1) {
-          let query: keyof typeof LookupQueryPatterns;
-          for (query in LookupQueryPatterns) {
-            if (
-              hasOwnProperty(LookupQueryPatterns, query) &&
-              this._query.match(LookupQueryPatterns[query])
-            ) {
+          for (const queryPattern of LOOKUP_QUERY_PATTERNS) {
+            if (this._query.match(queryPattern)) {
               // "Back"/"Forward" buttons work correctly only with
               // opt_redirect options
               GerritNav.navigateToChange(
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.ts b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.ts
index 9ad0b05f..f36df84 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.ts
@@ -55,6 +55,7 @@
 } from '../../../utils/attention-set-util';
 import {CustomKeyboardEvent} from '../../../types/events';
 import {fireEvent} from '../../../utils/event-util';
+import {windowLocationReload} from '../../../utils/dom-util';
 
 const NUMBER_FIXED_COLUMNS = 3;
 const CLOSED_STATUS = ['MERGED', 'ABANDONED'];
@@ -478,7 +479,7 @@
   }
 
   _reloadWindow() {
-    window.location.reload();
+    windowLocationReload();
   }
 
   _toggleChangeStar(e: CustomKeyboardEvent) {
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts
index 96714e3..89ca4ef 100644
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts
@@ -50,17 +50,9 @@
   HttpMethod,
   NotifyType,
 } from '../../../constants/constants';
-import {
-  EventType as PluginEventType,
-  TargetElement,
-} from '../../plugins/gr-plugin-types';
+import {EventType as PluginEventType, TargetElement} from '../../../api/plugin';
 import {customElement, observe, property} from '@polymer/decorators';
 import {
-  ActionPriority,
-  ActionType,
-  ErrorCallback,
-} from '../../../services/gr-rest-api/gr-rest-api';
-import {
   AccountInfo,
   ActionInfo,
   ActionNameToActionInfoMap,
@@ -102,10 +94,7 @@
 import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {
-  ChangeActions,
   GrChangeActionsElement,
-  PrimaryActionKey,
-  RevisionActions,
   UIActionInfo,
 } from '../../shared/gr-js-api-interface/gr-change-actions-js-api';
 import {fireAlert} from '../../../utils/event-util';
@@ -116,6 +105,14 @@
 } from '../../../utils/label-util';
 import {CommentThread} from '../../../utils/comment-util';
 import {ShowAlertEventDetail} from '../../../types/events';
+import {
+  ActionPriority,
+  ActionType,
+  ChangeActions,
+  PrimaryActionKey,
+  RevisionActions,
+} from '../../../api/change-actions';
+import {ErrorCallback} from '../../../api/rest';
 
 const ERR_BRANCH_EMPTY = 'The destination branch can’t be empty.';
 const ERR_COMMIT_EMPTY = 'The commit message can’t be empty.';
@@ -940,14 +937,14 @@
       return null;
     }
     let result;
-    for (const label in this.change.labels) {
+    for (const [label, labelInfo] of Object.entries(this.change.labels)) {
       if (!(label in this.change.permitted_labels)) {
         continue;
       }
       if (this.change.permitted_labels[label].length === 0) {
         continue;
       }
-      const status = this._getLabelStatus(this.change.labels[label]);
+      const status = this._getLabelStatus(labelInfo);
       if (status === LabelStatus.NEED) {
         if (result) {
           // More than one label is missing, so it's unclear which to quick
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_html.ts b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_html.ts
index 918e5e4..cd17271 100644
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_html.ts
@@ -110,6 +110,10 @@
     .metadata-header {
       display: flex;
       justify-content: space-between;
+      align-items: flex-end;
+      /* The goal is to achieve alignment of the owner account chip and the
+         commit message box. Their top border should be on the same line. */
+      margin-bottom: var(--spacing-s);
     }
   </style>
   <gr-external-style id="externalStyle" name="change-metadata">
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.ts b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.ts
index 11f1050..59287b2 100644
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.ts
@@ -61,7 +61,7 @@
 import {RestApiService} from '../../../services/gr-rest-api/gr-rest-api';
 import {tap} from '@polymer/iron-test-helpers/mock-interactions';
 import {GrEditableLabel} from '../../shared/gr-editable-label/gr-editable-label';
-import {PluginApi} from '../../plugins/gr-plugin-types';
+import {PluginApi} from '../../../api/plugin';
 import {GrEndpointDecorator} from '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
 import {stubRestApi} from '../../../test/test-utils.js';
 import {ParsedChangeInfo} from '../../../types/types';
diff --git a/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements.ts b/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements.ts
index c03884e..c0e87f3 100644
--- a/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements.ts
@@ -34,7 +34,6 @@
   LabelNameToInfoMap,
   LabelInfo,
 } from '../../../types/common';
-import {hasOwnProperty} from '../../../utils/common-util';
 import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
 import {appContext} from '../../../services/app-context';
 import {KnownExperimentId} from '../../../services/flags/flags';
@@ -51,6 +50,7 @@
 }
 
 interface Label {
+  labelName: string;
   labelInfo: LabelInfo;
   icon: string;
   style: string;
@@ -133,22 +133,19 @@
       LabelNameToInfoMap
     >
   ) {
-    const labels = labelsRecord.base;
-    this._optionalLabels = [];
-    this._requiredLabels = [];
+    const labels = labelsRecord.base || {};
+    const allLabels: Label[] = [];
 
-    for (const label of Object.keys(labels || {}).sort()) {
-      if (!hasOwnProperty(labels, label)) {
-        continue;
-      }
-
-      const labelInfo = labels[label];
-      const icon = this._computeLabelIcon(labelInfo);
-      const style = this._computeLabelClass(labelInfo);
-      const path = labelInfo.optional ? '_optionalLabels' : '_requiredLabels';
-
-      this.push(path, {label, icon, style, labelInfo});
+    for (const label of Object.keys(labels).sort()) {
+      allLabels.push({
+        labelName: label,
+        icon: this._computeLabelIcon(labels[label]),
+        style: this._computeLabelClass(labels[label]),
+        labelInfo: labels[label],
+      });
     }
+    this._optionalLabels = allLabels.filter(label => label.labelInfo.optional);
+    this._requiredLabels = allLabels.filter(label => !label.labelInfo.optional);
   }
 
   /**
diff --git a/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_html.ts b/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_html.ts
index 310b8bf..f172ccc 100644
--- a/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_html.ts
@@ -138,7 +138,7 @@
         <gr-limited-text
           class="name"
           limit="25"
-          text="[[item.label]]"
+          text="[[item.labelName]]"
         ></gr-limited-text>
       </div>
       <div class="value">
@@ -146,7 +146,7 @@
           change="{{change}}"
           account="[[account]]"
           mutable="[[mutable]]"
-          label="[[item.label]]"
+          label="[[item.labelName]]"
           label-info="[[item.labelInfo]]"
         ></gr-label-info>
       </div>
@@ -206,7 +206,7 @@
         <gr-limited-text
           class="name"
           limit="25"
-          text="[[item.label]]"
+          text="[[item.labelName]]"
         ></gr-limited-text>
       </div>
       <div class="value">
@@ -214,7 +214,7 @@
           change="{{change}}"
           account="[[account]]"
           mutable="[[mutable]]"
-          label="[[item.label]]"
+          label="[[item.labelName]]"
           label-info="[[item.labelInfo]]"
         ></gr-label-info>
       </div>
diff --git a/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_test.js b/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_test.js
index c2fc72d..fda8cb2 100644
--- a/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_test.js
+++ b/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_test.js
@@ -75,7 +75,7 @@
     assert.equal(element._optionalLabels.length, 1);
     assert.equal(element._requiredLabels.length, 1);
 
-    assert.equal(element._optionalLabels[0].label, 'opt_test');
+    assert.equal(element._optionalLabels[0].labelName, 'opt_test');
     assert.equal(element._optionalLabels[0].icon, 'gr-icons:schedule');
     assert.equal(element._optionalLabels[0].style, '');
     assert.ok(element._optionalLabels[0].labelInfo);
diff --git a/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary.ts b/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary.ts
index 88481a4..021d7c4 100644
--- a/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary.ts
@@ -47,6 +47,8 @@
   isResolved,
   isUnresolved,
   getFirstComment,
+  isRobotThread,
+  hasHumanReply,
 } from '../../../utils/comment-util';
 import {pluralize} from '../../../utils/string-util';
 import {AccountInfo} from '../../../types/common';
@@ -77,8 +79,9 @@
           color: var(--chip-color);
           cursor: pointer;
           display: inline-block;
-          padding: var(--spacing-xxs) var(--spacing-s) var(--spacing-xxs)
+          padding: var(--spacing-xxs) var(--spacing-m) var(--spacing-xxs)
             var(--spacing-s);
+          margin-right: var(--spacing-s);
           border-radius: 12px;
           border: 1px solid gray;
           vertical-align: top;
@@ -372,9 +375,11 @@
 
   render() {
     this.detailsQuota = DETAILS_QUOTA;
-    const countResolvedComments =
-      this.commentThreads?.filter(isResolved).length ?? 0;
-    const unresolvedThreads = this.commentThreads?.filter(isUnresolved) ?? [];
+    const commentThreads =
+      this.commentThreads?.filter(t => !isRobotThread(t) || hasHumanReply(t)) ??
+      [];
+    const countResolvedComments = commentThreads.filter(isResolved).length;
+    const unresolvedThreads = commentThreads.filter(isUnresolved);
     const countUnresolvedComments = unresolvedThreads.length;
     const unresolvedAuthors = this.getAccounts(unresolvedThreads);
     const draftCount = this.changeComments?.computeDraftCount() ?? 0;
@@ -406,15 +411,13 @@
                 !!countUnresolvedComments}
               >
                 No Comments</gr-summary-chip
-              >
-              <gr-summary-chip
+              ><gr-summary-chip
                 styleType=${SummaryChipStyles.WARNING}
                 icon="edit"
                 ?hidden=${!draftCount}
               >
                 ${pluralize(draftCount, 'draft')}</gr-summary-chip
-              >
-              <gr-summary-chip
+              ><gr-summary-chip
                 styleType=${SummaryChipStyles.WARNING}
                 icon="message"
                 ?hidden=${!countUnresolvedComments}
@@ -428,8 +431,7 @@
                     ></gr-avatar>`
                 )}
                 ${countUnresolvedComments} unresolved</gr-summary-chip
-              >
-              <gr-summary-chip
+              ><gr-summary-chip
                 styleType=${SummaryChipStyles.CHECK}
                 icon="markChatRead"
                 ?hidden=${!countResolvedComments}
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
index 4a92869..61006f9 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
@@ -55,7 +55,10 @@
 } from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
 import {GrEditConstants} from '../../edit/gr-edit-constants';
 import {pluralize} from '../../../utils/string-util';
-import {getComputedStyleValue} from '../../../utils/dom-util';
+import {
+  getComputedStyleValue,
+  windowLocationReload,
+} from '../../../utils/dom-util';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 import {getPluginEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints';
 import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
@@ -75,7 +78,7 @@
   PatchSet,
 } from '../../../utils/patch-set-util';
 import {changeStatuses, changeStatusString} from '../../../utils/change-util';
-import {EventType as PluginEventType} from '../../plugins/gr-plugin-types';
+import {EventType as PluginEventType} from '../../../api/plugin';
 import {customElement, property, observe} from '@polymer/decorators';
 import {GrApplyFixDialog} from '../../diff/gr-apply-fix-dialog/gr-apply-fix-dialog';
 import {GrFileListHeader} from '../gr-file-list-header/gr-file-list-header';
@@ -236,6 +239,10 @@
 
 export type ChangeViewPatchRange = Partial<PatchRange>;
 
+const DEBOUNCER_REPLY_OVERLAY_REFIT = 'reply-overlay-refit';
+
+const DEBOUNCER_SCROLL = 'scroll';
+
 @customElement('gr-change-view')
 export class GrChangeView extends KeyboardShortcutMixin(
   GestureEventListeners(LegacyElementMixin(PolymerElement))
@@ -707,6 +714,8 @@
     super.detached();
     this.unlisten(window, 'scroll', '_handleScroll');
     this.unlisten(document, 'visibilitychange', '_handleVisibilityChange');
+    this.cancelDebouncer(DEBOUNCER_REPLY_OVERLAY_REFIT);
+    this.cancelDebouncer(DEBOUNCER_SCROLL);
 
     if (this._updateCheckTimerHandle) {
       this._cancelUpdateCheckTimer();
@@ -896,7 +905,7 @@
   }
 
   _reloadWindow() {
-    window.location.reload();
+    windowLocationReload();
   }
 
   _handleCommitMessageCancel() {
@@ -1227,7 +1236,7 @@
   _handleReplyAutogrow() {
     // If the textarea resizes, we need to re-fit the overlay.
     this.debounce(
-      'reply-overlay-refit',
+      DEBOUNCER_REPLY_OVERLAY_REFIT,
       () => {
         this.$.replyOverlay.refit();
       },
@@ -1245,7 +1254,7 @@
 
   _handleScroll() {
     this.debounce(
-      'scroll',
+      DEBOUNCER_SCROLL,
       () => {
         this.viewState.scrollTop = document.body.scrollTop;
       },
@@ -2074,21 +2083,15 @@
   }
 
   _getLatestRevisionSHA(change: ChangeInfo | ParsedChangeInfo) {
-    if (change.current_revision) {
-      return change.current_revision;
-    }
+    if (change.current_revision) return change.current_revision;
     // current_revision may not be present in the case where the latest rev is
     // a draft and the user doesn’t have permission to view that rev.
     let latestRev = null;
     let latestPatchNum = -1 as PatchSetNum;
-    for (const rev in change.revisions) {
-      if (!hasOwnProperty(change.revisions, rev)) {
-        continue;
-      }
-
-      if (change.revisions[rev]._number > latestPatchNum) {
+    for (const [rev, revInfo] of Object.entries(change.revisions ?? {})) {
+      if (revInfo._number > latestPatchNum) {
         latestRev = rev;
-        latestPatchNum = change.revisions[rev]._number;
+        latestPatchNum = revInfo._number;
       }
     }
     return latestRev;
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_html.ts b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_html.ts
index 3c37ec7..81aff90 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_html.ts
@@ -65,7 +65,7 @@
       margin-left: var(--spacing-s);
     }
     #replyBtn {
-      margin-bottom: var(--spacing-l);
+      margin-bottom: var(--spacing-m);
     }
     gr-change-star {
       margin-left: var(--spacing-s);
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts
index b9e925c..99e5356 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts
@@ -33,7 +33,7 @@
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
 import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit';
-import {EventType, PluginApi} from '../../plugins/gr-plugin-types';
+import {EventType, PluginApi} from '../../../api/plugin';
 
 import 'lodash/lodash';
 import {
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.ts b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.ts
index 2855716..10cffba 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.ts
@@ -37,7 +37,6 @@
 import {customElement, property, observe} from '@polymer/decorators';
 import {AutocompleteSuggestion} from '../../shared/gr-autocomplete/gr-autocomplete';
 import {HttpMethod, ChangeStatus} from '../../../constants/constants';
-import {hasOwnProperty} from '../../../utils/common-util';
 import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
 
 const SUGGESTIONS_LIMIT = 15;
@@ -399,21 +398,16 @@
     return this.restApiService
       .getRepoBranches(input, this.project, SUGGESTIONS_LIMIT)
       .then((response: BranchInfo[] | undefined) => {
-        const branches = [];
         if (!response) return [];
-        let branch;
-        for (const key in response) {
-          if (!hasOwnProperty(response, key)) {
-            continue;
-          }
-          if (response[key].ref.startsWith('refs/heads/')) {
-            branch = response[key].ref.substring('refs/heads/'.length);
+        const branches = [];
+        for (const branchInfo of response) {
+          let branch;
+          if (branchInfo.ref.startsWith('refs/heads/')) {
+            branch = branchInfo.ref.substring('refs/heads/'.length);
           } else {
-            branch = response[key].ref;
+            branch = branchInfo.ref;
           }
-          branches.push({
-            name: branch,
-          });
+          branches.push({name: branch});
         }
         return branches;
       });
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_test.js b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_test.js
index e4ed533..536a4ab 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_test.js
+++ b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_test.js
@@ -39,7 +39,7 @@
           },
         ]);
       } else {
-        return Promise.resolve({});
+        return Promise.resolve([]);
       }
     });
     element = basicFixture.instantiate();
@@ -77,11 +77,9 @@
     assert.equal(element.message, myNewMessage);
   });
 
-  test('_getProjectBranchesSuggestions empty', done => {
-    element._getProjectBranchesSuggestions('nonexistent').then(branches => {
-      assert.equal(branches.length, 0);
-      done();
-    });
+  test('_getProjectBranchesSuggestions empty', async () => {
+    const branches = await element._getProjectBranchesSuggestions('asdf');
+    assert.isEmpty(branches);
   });
 
   suite('cherry pick topic', () => {
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.ts b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.ts
index 9e7bdb4..9dcd849 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.ts
@@ -22,7 +22,6 @@
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-confirm-rebase-dialog_html';
 import {customElement, property, observe} from '@polymer/decorators';
-import {hasOwnProperty} from '../../../utils/common-util';
 import {NumericChangeId, BranchName} from '../../../types/common';
 import {
   GrAutocomplete,
@@ -109,13 +108,10 @@
       .then(response => {
         if (!response) return [];
         const changes: RebaseChange[] = [];
-        for (const key in response) {
-          if (!hasOwnProperty(response, key)) {
-            continue;
-          }
+        for (const change of response) {
           changes.push({
-            name: `${response[key]._number}: ${response[key].subject}`,
-            value: response[key]._number,
+            name: `${change._number}: ${change.subject}`,
+            value: change._number,
           });
         }
         this._recentChanges = changes;
diff --git a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.ts b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.ts
index 7fbe4b5..ab1e4e6 100644
--- a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.ts
@@ -122,14 +122,8 @@
       }
     }
     const commands = [];
-    for (const title in commandObj) {
-      if (!commandObj || !hasOwnProperty(commandObj, title)) {
-        continue;
-      }
-      commands.push({
-        title,
-        command: commandObj[title],
-      });
+    for (const [title, command] of Object.entries(commandObj ?? {})) {
+      commands.push({title, command});
     }
     return commands;
   }
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts
index d41a662..a5c0624 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts
@@ -67,7 +67,6 @@
 import {DiffPreferencesInfo} from '../../../types/diff';
 import {GrDiffHost} from '../../diff/gr-diff-host/gr-diff-host';
 import {GrDiffPreferencesDialog} from '../../diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog';
-import {hasOwnProperty} from '../../../utils/common-util';
 import {GrDiffCursor} from '../../diff/gr-diff-cursor/gr-diff-cursor';
 import {GrCursorManager} from '../../shared/gr-cursor-manager/gr-cursor-manager';
 import {PolymerSpliceChange} from '@polymer/polymer/interfaces';
@@ -153,6 +152,8 @@
 
 export type FileNameToReviewedFileInfoMap = {[name: string]: ReviewedFileInfo};
 
+const DEBOUNCER_LOADING_CHANGE = 'loading-change';
+
 /**
  * Type for FileInfo
  *
@@ -407,6 +408,7 @@
   detached() {
     super.detached();
     this._cancelDiffs();
+    this.cancelDebouncer(DEBOUNCER_LOADING_CHANGE);
   }
 
   /**
@@ -1241,13 +1243,9 @@
     const files: FileNameToReviewedFileInfoMap = {...filesByPath};
     addUnmodifiedFiles(files, commentedPaths);
     const reviewedSet = new Set(reviewed || []);
-    for (const filePath in files) {
-      if (!hasOwnProperty(files, filePath)) {
-        continue;
-      }
-      files[filePath].isReviewed = reviewedSet.has(filePath);
+    for (const [filePath, reviewedFileInfo] of Object.entries(files)) {
+      reviewedFileInfo.isReviewed = reviewedSet.has(filePath);
     }
-
     this._files = this._normalizeChangeFilesResponse(files);
   }
 
@@ -1598,7 +1596,7 @@
    */
   _loadingChanged(loading?: boolean) {
     this.debounce(
-      'loading-change',
+      DEBOUNCER_LOADING_CHANGE,
       () => {
         // Only show set the loading if there have been files loaded to show. In
         // this way, the gray loading style is not shown on initial loads.
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.js b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.js
index 77855df..2d38dcc 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.js
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.js
@@ -306,11 +306,8 @@
         '-1073741824': '-1 GiB',
         '0': '+/-0 B',
       };
-
-      for (const bytes in table) {
-        if (table.hasOwnProperty(bytes)) {
-          assert.equal(element._formatBytes(Number(bytes)), table[bytes]);
-        }
+      for (const [bytes, expected] of Object.entries(table)) {
+        assert.equal(element._formatBytes(Number(bytes)), expected);
       }
     });
 
@@ -590,12 +587,8 @@
         flush();
         assert.equal(element.diffs.length, paths.length);
         assert.equal(element._expandedFiles.length, paths.length);
-        for (const index in element.diffs) {
-          if (!element.diffs.hasOwnProperty(index)) { continue; }
-          assert.isTrue(
-              element._expandedFiles
-                  .some(f => f.path === element.diffs[index].path)
-          );
+        for (const diff of element.diffs) {
+          assert.isTrue(element._expandedFiles.some(f => f.path === diff.path));
         }
 
         MockInteractions.keyUpOn(element, 73, 'shift', 'i');
diff --git a/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.ts b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.ts
index b994fa5..7af9d66 100644
--- a/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.ts
+++ b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.ts
@@ -32,11 +32,12 @@
 } from '../../../types/common';
 import {
   GrLabelScoreRow,
+  Label,
   LabelValuesMap,
 } from '../gr-label-score-row/gr-label-score-row';
 import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
+import {appContext} from '../../../services/app-context';
 
-type Labels = {[label: string]: number};
 @customElement('gr-label-scores')
 export class GrLabelScores extends GestureEventListeners(
   LegacyElementMixin(PolymerElement)
@@ -46,7 +47,7 @@
   }
 
   @property({type: Array, computed: '_computeLabels(change.labels.*, account)'})
-  _labels?: Labels;
+  _labels: Label[] = [];
 
   @property({type: Object, observer: '_computeColumns'})
   permittedLabels?: LabelNameToValueMap;
@@ -60,36 +61,25 @@
   @property({type: Object})
   _labelValues?: LabelValuesMap;
 
+  private readonly reporting = appContext.reportingService;
+
   getLabelValues(includeDefaults = true): LabelNameToValuesMap {
     const labels: LabelNameToValuesMap = {};
     if (this.shadowRoot === null || !this.change) {
       return labels;
     }
-    for (const label in this.permittedLabels) {
-      if (!hasOwnProperty(this.permittedLabels, label)) {
-        continue;
-      }
-
+    for (const label of Object.keys(this.permittedLabels ?? {})) {
       const selectorEl = this.shadowRoot.querySelector(
         `gr-label-score-row[name="${label}"]`
       ) as null | GrLabelScoreRow;
-      if (!selectorEl) {
-        continue;
-      }
-
-      // The user may have not voted on this label.
-      if (!selectorEl.selectedItem) {
-        continue;
-      }
+      if (!selectorEl?.selectedItem) continue;
 
       const selectedVal =
         typeof selectorEl.selectedValue === 'string'
           ? Number(selectorEl.selectedValue)
           : selectorEl.selectedValue;
 
-      if (selectedVal === undefined) {
-        continue;
-      }
+      if (selectedVal === undefined) continue;
 
       const defValNum = this._getDefaultValue(this.change.labels, label);
       if (includeDefaults || selectedVal !== defValNum) {
@@ -103,13 +93,16 @@
     labels: LabelNameToInfoMap,
     labelName: string,
     numberValue?: number
-  ) {
-    for (const k in (labels[labelName] as DetailedLabelInfo).values) {
-      if (Number(k) === numberValue) {
-        return k;
+  ): string {
+    const detailedInfo = labels[labelName] as DetailedLabelInfo;
+    for (const labelValue of Object.keys(detailedInfo.values)) {
+      if (Number(labelValue) === numberValue) {
+        return labelValue;
       }
     }
-    return numberValue;
+    const stringVal = `${numberValue}`;
+    this.reporting.reportExecution('label-value-not-found', {value: stringVal});
+    return stringVal;
   }
 
   _getDefaultValue(labels?: LabelNameToInfoMap, labelName?: string) {
@@ -122,7 +115,7 @@
     labels: LabelNameToInfoMap | undefined,
     labelName: string,
     account?: AccountInfo
-  ) {
+  ): string | null {
     if (!labels) return null;
     const votes = labels[labelName] as DetailedLabelInfo;
     if (votes.all && votes.all.length > 0) {
@@ -147,16 +140,10 @@
       LabelNameToInfoMap
     >,
     account?: AccountInfo
-  ) {
-    // Polymer 2: check for undefined
-    if ([labelRecord, account].includes(undefined)) {
-      return undefined;
-    }
-
+  ): Label[] {
+    if (!account) return [];
+    if (!labelRecord?.base) return [];
     const labelsObj = labelRecord.base;
-    if (!labelsObj) {
-      return [];
-    }
     return Object.keys(labelsObj)
       .sort()
       .map(key => {
diff --git a/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores_test.js b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores_test.js
index 5135e439..ef123c9 100644
--- a/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores_test.js
+++ b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores_test.js
@@ -85,12 +85,10 @@
   });
 
   test('get and set label scores', () => {
-    for (const label in element.permittedLabels) {
-      if (element.permittedLabels.hasOwnProperty(label)) {
-        const row = element.shadowRoot
-            .querySelector('gr-label-score-row[name="' + label + '"]');
-        row.setSelectedValue(-1);
-      }
+    for (const label of Object.keys(element.permittedLabels)) {
+      const row = element.shadowRoot
+          .querySelector('gr-label-score-row[name="' + label + '"]');
+      row.setSelectedValue(-1);
     }
     assert.deepEqual(element.getLabelValues(), {
       'Code-Review': -1,
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
index ca3161f..36764f3 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
@@ -46,9 +46,8 @@
 import {accountKey, removeServiceUsers} from '../../../utils/account-util';
 import {getDisplayName} from '../../../utils/display-name-util';
 import {IronA11yAnnouncer} from '@polymer/iron-a11y-announcer/iron-a11y-announcer';
-import {TargetElement} from '../../plugins/gr-plugin-types';
+import {TargetElement} from '../../../api/plugin';
 import {customElement, observe, property} from '@polymer/decorators';
-import {ErrorCallback} from '../../../services/gr-rest-api/gr-rest-api';
 import {FixIronA11yAnnouncer} from '../../../types/types';
 import {
   AccountAddition,
@@ -110,6 +109,7 @@
 import {isUnresolved} from '../../../utils/comment-util';
 import {pluralize} from '../../../utils/string-util';
 import {fireAlert, fireEvent, fireServerError} from '../../../utils/event-util';
+import {ErrorCallback} from '../../../api/rest';
 
 const STORAGE_DEBOUNCE_INTERVAL_MS = 400;
 
@@ -137,7 +137,7 @@
 };
 
 const ButtonTooltips = {
-  SAVE: 'Save but do not send notification or change review state',
+  SAVE: 'Send changes and comments as work in progress but do not start review',
   START_REVIEW: 'Mark as ready for review and send reply',
   SEND: 'Send reply',
   DISABLED_COMMENT_EDITING: 'Save draft comments to enable send',
@@ -168,6 +168,8 @@
   };
 }
 
+const DEBOUNCER_STORE = 'store';
+
 @customElement('gr-reply-dialog')
 export class GrReplyDialog extends KeyboardShortcutMixin(
   GestureEventListeners(LegacyElementMixin(PolymerElement))
@@ -425,6 +427,11 @@
     this.jsAPI.addElement(TargetElement.REPLY_DIALOG, this);
   }
 
+  /** @override */
+  detached() {
+    this.cancelDebouncer(DEBOUNCER_STORE);
+  }
+
   open(focusTarget?: FocusTarget) {
     if (!this.change) throw new Error('missing required change property');
     this.knownLatestState = LatestPatchState.CHECKING;
@@ -1332,7 +1339,7 @@
 
   _draftChanged(newDraft: string, oldDraft?: string) {
     this.debounce(
-      'store',
+      DEBOUNCER_STORE,
       () => {
         if (!newDraft.length && oldDraft) {
           // If the draft has been modified to be empty, then erase the storage
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_html.ts b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_html.ts
index 1235512..79569bc 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_html.ts
@@ -577,7 +577,7 @@
               has-tooltip=""
               title="[[_saveTooltip]]"
               on-click="_saveClickHandler"
-              >Save</gr-button
+              >Send As WIP</gr-button
             >
           </template>
           <gr-button
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.js b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.js
index 2b92ca6..6682bfb 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.js
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.js
@@ -1001,8 +1001,8 @@
       };
     };
     const checkObjEmpty = function(obj) {
-      for (const prop in obj) {
-        if (obj.hasOwnProperty(prop) && obj[prop].length) { return false; }
+      for (const prop of Object.keys(obj)) {
+        if (obj[prop].length) { return false; }
       }
       return true;
     };
diff --git a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.ts b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.ts
index 254ecca..30931c3 100644
--- a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.ts
@@ -231,7 +231,7 @@
     }
     let result: AccountInfo[] = [];
     const reviewers = changeRecord.base;
-    for (const key in reviewers) {
+    for (const key of Object.keys(reviewers)) {
       if (this.reviewersOnly && key !== 'REVIEWER') {
         continue;
       }
diff --git a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.ts b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.ts
index 7cea964..22b2e43 100644
--- a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.ts
@@ -34,9 +34,10 @@
 import {
   CommentThread,
   isDraft,
-  UIRobot,
   isUnresolved,
   isDraftThread,
+  isRobotThread,
+  hasHumanReply,
 } from '../../../utils/comment-util';
 import {pluralize} from '../../../utils/string-util';
 import {fireThreadListModifiedEvent} from '../../../utils/event-util';
@@ -136,8 +137,12 @@
   _computeResolvedCommentsMessage(
     threads: CommentThread[],
     displayedThreads: CommentThread[],
-    unresolvedOnly: boolean
+    unresolvedOnly: boolean,
+    onlyShowRobotCommentsWithHumanReply: boolean
   ) {
+    if (onlyShowRobotCommentsWithHumanReply) {
+      threads = this.filterRobotThreadsWithoutHumanReply(threads) ?? [];
+    }
     if (unresolvedOnly && threads.length && !displayedThreads.length) {
       return `Show ${pluralize(threads.length, 'resolved comment')}`;
     }
@@ -401,15 +406,9 @@
     const lastComment = comments.length
       ? comments[comments.length - 1]
       : undefined;
-    let hasRobotComment = false;
-    let hasHumanReplyToRobotComment = false;
-    comments.forEach(comment => {
-      if ((comment as UIRobot).robot_id) {
-        hasRobotComment = true;
-      } else if (hasRobotComment) {
-        hasHumanReplyToRobotComment = true;
-      }
-    });
+    const hasRobotComment = isRobotThread(thread);
+    const hasHumanReplyToRobotComment =
+      hasRobotComment && hasHumanReply(thread);
     let updated = undefined;
     if (lastComment) {
       if (isDraft(lastComment)) updated = lastComment.__date;
@@ -472,15 +471,21 @@
   }
 
   _countUnresolved(threads?: CommentThread[]) {
-    return threads?.filter(isUnresolved).length ?? 0;
+    return (
+      this.filterRobotThreadsWithoutHumanReply(threads)?.filter(isUnresolved)
+        .length ?? 0
+    );
   }
 
   _countAllThreads(threads?: CommentThread[]) {
-    return threads?.length ?? 0;
+    return this.filterRobotThreadsWithoutHumanReply(threads)?.length ?? 0;
   }
 
   _countDrafts(threads?: CommentThread[]) {
-    return threads?.filter(isDraftThread).length ?? 0;
+    return (
+      this.filterRobotThreadsWithoutHumanReply(threads)?.filter(isDraftThread)
+        .length ?? 0
+    );
   }
 
   /**
@@ -489,6 +494,10 @@
   _onTapUnresolvedToggle(e: Event) {
     e.preventDefault();
   }
+
+  filterRobotThreadsWithoutHumanReply(threads?: CommentThread[]) {
+    return threads?.filter(t => !isRobotThread(t) || hasHumanReply(t));
+  }
 }
 
 declare global {
diff --git a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_html.ts b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_html.ts
index 28e8da8..8a52555 100644
--- a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_html.ts
@@ -150,7 +150,7 @@
               link
               on-click="_handleResolvedCommentsMessageClick">
                 [[_computeResolvedCommentsMessage(threads, _displayedThreads,
-                unresolvedOnly)]]
+                unresolvedOnly, onlyShowRobotCommentsWithHumanReply)]]
             </gr-button>
           </template>
         </span>
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-runs.ts b/polygerrit-ui/app/elements/checks/gr-checks-runs.ts
index 6638c5f..09a2319 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-runs.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-runs.ts
@@ -18,11 +18,12 @@
 import {classMap} from 'lit-html/directives/class-map';
 import {css, customElement, property} from 'lit-element';
 import {GrLitElement} from '../lit/gr-lit-element';
-import {CheckRun, RunStatus} from '../../api/checks';
+import {Action, CheckRun, RunStatus} from '../../api/checks';
 import {sharedStyles} from '../../styles/shared-styles';
 import {
   compareByWorstCategory,
   iconForRun,
+  primaryRunAction,
 } from '../../services/checks/checks-util';
 import {
   allRuns$,
@@ -59,6 +60,33 @@
   );
 }
 
+export interface ActionTriggeredEventDetail {
+  action: Action;
+  run: CheckRun;
+}
+
+export type ActionTriggeredEvent = CustomEvent<ActionTriggeredEventDetail>;
+
+declare global {
+  interface HTMLElementEventMap {
+    'action-triggered': ActionTriggeredEvent;
+  }
+}
+
+function fireActionTriggered(
+  target: EventTarget,
+  action: Action,
+  run: CheckRun
+) {
+  target.dispatchEvent(
+    new CustomEvent('action-triggered', {
+      detail: {action, run},
+      composed: true,
+      bubbles: true,
+    })
+  );
+}
+
 @customElement('gr-checks-run')
 export class GrChecksRun extends GrLitElement {
   static get styles() {
@@ -70,14 +98,17 @@
           --thick-border: 6px;
         }
         .chip {
-          display: block;
-          font-weight: var(--font-weight-bold);
+          display: flex;
+          justify-content: space-between;
           border: 1px solid var(--border-color);
           border-radius: var(--border-radius);
           padding: var(--spacing-s) var(--spacing-m);
           margin-top: var(--spacing-s);
           cursor: default;
         }
+        .name {
+          font-weight: var(--font-weight-bold);
+        }
         .chip.error {
           border-left: var(--thick-border) solid var(--error-foreground);
         }
@@ -117,6 +148,14 @@
         div.chip.selected iron-icon {
           color: var(--selected-foreground);
         }
+        gr-button.action {
+          --padding: var(--spacing-xs) var(--spacing-m);
+          /* The button should fit into the 20px line-height. The negative
+             margin provides the extra space needed for the vertical padding.
+             Alternatively we could have set the vertical padding to 0, but
+             that would not have been a nice click target. */
+          margin: calc(0px - var(--spacing-xs));
+        }
       `,
     ];
   }
@@ -130,20 +169,39 @@
   render() {
     const icon = this.selected ? 'check-circle' : iconForRun(this.run);
     const classes = {chip: true, [icon]: true, selected: this.selected};
+    const action = primaryRunAction(this.run);
 
     return html`
-      <div @click="${this._handleChipClick}" class="${classMap(classes)}">
-        <iron-icon icon="gr-icons:${icon}"></iron-icon>
-        <span>${this.run.checkName}</span>
+      <div @click="${this.handleChipClick}" class="${classMap(classes)}">
+        <div class="left">
+          <iron-icon icon="gr-icons:${icon}"></iron-icon>
+          <span class="name">${this.run.checkName}</span>
+        </div>
+        <div class="right">
+          ${action
+            ? html`<gr-button
+                class="action"
+                link
+                @click="${(e: MouseEvent) => this.handleAction(e, action)}"
+                >${action.name}</gr-button
+              >`
+            : ''}
+        </div>
       </div>
     `;
   }
 
-  _handleChipClick(e: MouseEvent) {
+  private handleChipClick(e: MouseEvent) {
     e.stopPropagation();
     e.preventDefault();
     fireRunSelected(this, this.run.checkName);
   }
+
+  private handleAction(e: MouseEvent, action: Action) {
+    e.stopPropagation();
+    e.preventDefault();
+    fireActionTriggered(this, action, this.run);
+  }
 }
 
 @customElement('gr-checks-runs')
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-tab.ts b/polygerrit-ui/app/elements/checks/gr-checks-tab.ts
index 4844593..d31ec53 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-tab.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-tab.ts
@@ -17,13 +17,14 @@
 import {html} from 'lit-html';
 import {css, customElement, property} from 'lit-element';
 import {GrLitElement} from '../lit/gr-lit-element';
-import {CheckResult, CheckRun} from '../../api/checks';
+import {Action, CheckResult, CheckRun} from '../../api/checks';
 import {allResults$, allRuns$} from '../../services/checks/checks-model';
 import './gr-checks-runs';
 import './gr-checks-results';
 import {sharedStyles} from '../../styles/shared-styles';
-import {currentPatchNum$} from '../../services/change/change-model';
-import {PatchSetNum} from '../../types/common';
+import {changeNum$, currentPatchNum$} from '../../services/change/change-model';
+import {NumericChangeId, PatchSetNum} from '../../types/common';
+import {ActionTriggeredEvent} from './gr-checks-runs';
 
 /**
  * The "Checks" tab on the Gerrit change page. Gets its data from plugins that
@@ -39,11 +40,19 @@
   @property()
   currentPatchNum: PatchSetNum | undefined = undefined;
 
+  @property()
+  changeNum: NumericChangeId | undefined = undefined;
+
   constructor() {
     super();
     this.subscribe('runs', allRuns$);
     this.subscribe('results', allResults$);
     this.subscribe('currentPatchNum', currentPatchNum$);
+    this.subscribe('changeNum', changeNum$);
+
+    this.addEventListener('action-triggered', (e: ActionTriggeredEvent) =>
+      this.handleActionTriggered(e.detail.action, e.detail.run)
+    );
   }
 
   static get styles() {
@@ -70,7 +79,7 @@
           display: flex;
         }
         .runs {
-          min-width: 250px;
+          min-width: 300px;
           min-height: 400px;
           border-right: 1px solid var(--border-color);
         }
@@ -105,6 +114,22 @@
       </div>
     `;
   }
+
+  private handleActionTriggered(action: Action, run: CheckRun) {
+    if (!this.changeNum) return;
+    if (!this.currentPatchNum) return;
+    // TODO(brohlfs): The callback is supposed to be returning a promise.
+    // A toast should be displayed until the promise completes. And then the
+    // data should be updated.
+    action.callback(
+      this.changeNum,
+      this.currentPatchNum as number,
+      run.attempt,
+      run.externalId,
+      run.checkName,
+      action.name
+    );
+  }
 }
 
 declare global {
diff --git a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.ts b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.ts
index 264034c..4a86005 100644
--- a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.ts
+++ b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.ts
@@ -38,6 +38,7 @@
   ServerErrorEvent,
   ShowAlertEvent,
 } from '../../../types/events';
+import {windowLocationReload} from '../../../utils/dom-util';
 
 const HIDE_ALERT_TIMEOUT_MS = 5000;
 const CHECK_SIGN_IN_INTERVAL_MS = 60 * 1000;
@@ -72,6 +73,9 @@
     errorOverlay: GrOverlay;
   };
 }
+
+const DEBOUNCER_CHECK_LOGGED_IN = 'checkLoggedIn';
+
 @customElement('gr-error-manager')
 export class GrErrorManager extends GestureEventListeners(
   LegacyElementMixin(PolymerElement)
@@ -147,6 +151,7 @@
     this.unlisten(document, 'show-error', '_handleShowErrorDialog');
     this.unlisten(document, 'visibilitychange', '_handleVisibilityChange');
     this.unlisten(document, 'show-auth-required', '_handleAuthRequired');
+    this.cancelDebouncer(DEBOUNCER_CHECK_LOGGED_IN);
 
     if (this._authErrorHandlerDeregistrationHook) {
       this._authErrorHandlerDeregistrationHook();
@@ -363,6 +368,7 @@
       this._createLoginPopup()
     );
     this.fire('iron-announce', {text: errorText}, {bubbles: true});
+    this.reporting.reportInteraction('show-auth-error', {text: errorText});
     this._refreshingCredentials = true;
     this._requestCheckLoggedIn();
     if (!document.hidden) {
@@ -378,11 +384,8 @@
   }
 
   _handleVisibilityChange() {
-    // Ignore when the page is transitioning to hidden (or hidden is
-    // undefined).
-    if (document.hidden !== false) {
-      return;
-    }
+    // Ignore when the page is transitioning to hidden (or hidden is undefined).
+    if (document.hidden !== false) return;
 
     // If not currently refreshing credentials and the credentials are old,
     // request them to confirm their validity or (display an auth toast if it
@@ -393,6 +396,7 @@
       this.knownAccountId !== undefined &&
       timeSinceLastCheck > STALE_CREDENTIAL_THRESHOLD_MS
     ) {
+      this.reporting.reportInteraction('visibility-sign-in-check');
       this._lastCredentialCheck = Date.now();
 
       // check auth status in case:
@@ -404,7 +408,7 @@
 
   _requestCheckLoggedIn() {
     this.debounce(
-      'checkLoggedIn',
+      DEBOUNCER_CHECK_LOGGED_IN,
       this._checkSignedIn,
       CHECK_SIGN_IN_INTERVAL_MS
     );
@@ -418,7 +422,6 @@
     this._authService.clearCache();
 
     this.restApiService.getLoggedIn().then(isLoggedIn => {
-      // do nothing if its refreshing
       if (!this._refreshingCredentials) return;
 
       if (!isLoggedIn) {
@@ -428,12 +431,15 @@
         // in case #2, auth-error is taken care of separately
         this._requestCheckLoggedIn();
       } else {
-        // check account
         this.restApiService.getAccount().then(account => {
           if (this._refreshingCredentials) {
-            // If the credentials were refreshed but the account is different
+            // If the credentials were refreshed but the account is different,
             // then reload the page completely.
             if (account?._account_id !== this.knownAccountId) {
+              this.reporting.reportInteraction('sign-in-window-reload', {
+                oldAccount: !!this.knownAccountId,
+                newAccount: !!account?._account_id,
+              });
               this._reloadPage();
               return;
             }
@@ -446,7 +452,7 @@
   }
 
   _reloadPage() {
-    window.location.reload();
+    windowLocationReload();
   }
 
   _createLoginPopup() {
@@ -478,7 +484,7 @@
   }
 
   _handleWindowFocus() {
-    this.flushDebouncer('checkLoggedIn');
+    this.flushDebouncer(DEBOUNCER_CHECK_LOGGED_IN);
   }
 
   _handleShowErrorDialog(e: CustomEvent) {
diff --git a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_html.ts b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_html.ts
index e8a1512..cf7d3ae 100644
--- a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_html.ts
+++ b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_html.ts
@@ -79,6 +79,7 @@
       flex-grow: 1;
       margin: 0 var(--spacing-m);
       max-width: 500px;
+      min-width: 150px;
     }
     gr-dropdown,
     .browse {
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-router.ts b/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
index 8a23004..3a76112 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
@@ -67,6 +67,7 @@
 import {GerritView, updateState} from '../../../services/router/router-model';
 import {firePageError} from '../../../utils/event-util';
 import {addQuotesWhen} from '../../../utils/string-util';
+import {windowLocationReload} from '../../../utils/dom-util';
 
 const RoutePattern = {
   ROOT: '/',
@@ -1725,7 +1726,7 @@
    * by the catchall _handleDefaultRoute handler.
    */
   _handlePassThroughRoute() {
-    location.reload();
+    windowLocationReload();
   }
 
   /**
@@ -1762,7 +1763,7 @@
 
   _handleDocumentationRedirectRoute(data: PageContextWithQueryMap) {
     if (data.params[1]) {
-      location.reload();
+      windowLocationReload();
     } else {
       // Redirect /Documentation to /Documentation/index.html
       this._redirect('/Documentation/index.html');
diff --git a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.ts b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.ts
index 1085fbc..468c8ca 100644
--- a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.ts
+++ b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.ts
@@ -32,7 +32,6 @@
   FileInfo,
   ParentPatchSetNum,
 } from '../../../types/common';
-import {hasOwnProperty} from '../../../utils/common-util';
 import {
   Comment,
   CommentMap,
@@ -149,15 +148,11 @@
     ];
     const commentMap: CommentMap = {};
     for (const response of responses) {
-      for (const path in response) {
+      for (const [path, comments] of Object.entries(response)) {
         if (
-          hasOwnProperty(response, path) &&
-          response[path].some(c => {
+          comments.some(c => {
             // If don't care about patch range, we know that the path exists.
-            if (!patchRange) {
-              return true;
-            }
-            return isInPatchRange(c, patchRange);
+            return !patchRange || isInPatchRange(c, patchRange);
           })
         ) {
           commentMap[path] = true;
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.ts b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.ts
index d5c7d8e..094f2d7 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.ts
@@ -54,6 +54,8 @@
   rootId: string;
 }
 
+const DEBOUNCER_SELECTION_CHANGE = 'selectionChange';
+
 @customElement('gr-diff-highlight')
 export class GrDiffHighlight extends GestureEventListeners(
   LegacyElementMixin(PolymerElement)
@@ -88,6 +90,11 @@
     );
   }
 
+  /** @override */
+  detached() {
+    this.cancelDebouncer(DEBOUNCER_SELECTION_CHANGE);
+  }
+
   get diffBuilder() {
     if (!this._cachedDiffBuilder) {
       this._cachedDiffBuilder = this.querySelector(
@@ -123,7 +130,7 @@
     // ms, then you will have about 50 _handleSelection calls when doing a
     // simple drag for select.
     this.debounce(
-      'selectionChange',
+      DEBOUNCER_SELECTION_CHANGE,
       () => this._handleSelection(selection, isMouseUp),
       10
     );
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts
index 1b1e71c..ae1e438 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts
@@ -72,7 +72,6 @@
 import {FilesWebLinks} from '../gr-patch-range-select/gr-patch-range-select';
 import {LineNumber, FILE} from '../gr-diff/gr-diff-line';
 import {GrCommentThread} from '../../shared/gr-comment-thread/gr-comment-thread';
-import {KnownExperimentId} from '../../../services/flags/flags';
 import {
   firePageError,
   fireAlert,
@@ -266,8 +265,6 @@
 
   private readonly reporting = appContext.reportingService;
 
-  private readonly flags = appContext.flagsService;
-
   private readonly restApiService = appContext.restApiService;
 
   private readonly jsAPI = appContext.jsApiService;
@@ -1147,10 +1144,6 @@
   _showNewlineWarningRight(diff?: DiffInfo) {
     return this._hasTrailingNewlines(diff, false) === false;
   }
-
-  _useNewContextControls() {
-    return this.flags.isEnabled(KnownExperimentId.NEW_CONTEXT_CONTROLS);
-  }
 }
 
 declare global {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_html.ts b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_html.ts
index 9d5555d..84a2e4a 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_html.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_html.ts
@@ -40,7 +40,6 @@
     diff="[[diff]]"
     show-newline-warning-left="[[_showNewlineWarningLeft(diff)]]"
     show-newline-warning-right="[[_showNewlineWarningRight(diff)]]"
-    use-new-context-controls="[[_useNewContextControls()]]"
   >
   </gr-diff>
   <gr-syntax-layer
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor.ts b/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor.ts
index 034081d..9fafcfa 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor.ts
@@ -64,6 +64,8 @@
  */
 const MAX_GROUP_SIZE = 120;
 
+const DEBOUNCER_RESET_IS_SCROLLING = 'resetIsScrolling';
+
 /**
  * Converts the API's `DiffContent`s  to `GrDiffGroup`s for rendering.
  *
@@ -123,6 +125,7 @@
   /** @override */
   detached() {
     super.detached();
+    this.cancelDebouncer(DEBOUNCER_RESET_IS_SCROLLING);
     this.cancel();
     this.unlisten(window, 'scroll', '_handleWindowScroll');
   }
@@ -130,7 +133,7 @@
   _handleWindowScroll() {
     this._isScrolling = true;
     this.debounce(
-      'resetIsScrolling',
+      DEBOUNCER_RESET_IS_SCROLLING,
       () => {
         this._isScrolling = false;
       },
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts
index 1e9b1cc..62b34e9 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts
@@ -85,7 +85,6 @@
 import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
 import {GrDiffCursor} from '../gr-diff-cursor/gr-diff-cursor';
 import {CommentSide, DiffViewMode, Side} from '../../../constants/constants';
-import {hasOwnProperty} from '../../../utils/common-util';
 import {GrApplyFixDialog} from '../gr-apply-fix-dialog/gr-apply-fix-dialog';
 import {LineOfInterest} from '../gr-diff/gr-diff';
 import {RevisionInfo as RevisionInfoObj} from '../../shared/revision-info/revision-info';
@@ -896,9 +895,8 @@
     let baseCommit: CommitId | undefined;
     if (!this._change) return;
     if (!this._patchRange || !this._patchRange.patchNum) return;
-    for (const commitSha in this._change.revisions) {
-      if (!hasOwnProperty(this._change.revisions, commitSha)) continue;
-      const revision = this._change.revisions[commitSha];
+    const revisions = this._change.revisions ?? {};
+    for (const [commitSha, revision] of Object.entries(revisions)) {
       const patchNum = revision._number;
       if (patchNum === this._patchRange.patchNum) {
         commit = commitSha as CommitId;
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.js b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.js
index 8fe06f8..34e74b6 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.js
@@ -812,10 +812,7 @@
     }
 
     test('edit visible only when logged and status NEW', async () => {
-      for (const changeStatus in ChangeStatus) {
-        if (!ChangeStatus.hasOwnProperty(changeStatus)) {
-          continue;
-        }
+      for (const changeStatus of Object.keys(ChangeStatus)) {
         assert.isFalse(await isEditVisibile({loggedIn: false, changeStatus}),
             `loggedIn: false, changeStatus: ${changeStatus}`);
 
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.ts b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.ts
index 9fb5e74..7946061 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.ts
@@ -67,6 +67,7 @@
 import * as shadow from 'shadow-selection-polyfill/shadow.js';
 
 import {CreateCommentEventDetail as CreateCommentEventDetailApi} from '../../../api/diff';
+import {isSafari} from '../../../utils/dom-util';
 
 const NO_NEWLINE_BASE = 'No newline at end of base file.';
 const NO_NEWLINE_REVISION = 'No newline at end of revision file.';
@@ -290,6 +291,7 @@
 
   /** @override */
   detached() {
+    this.cancelDebouncer(RENDER_DIFF_TABLE_DEBOUNCE_NAME);
     super.detached();
     this._unobserveIncrementalNodes();
     this._unobserveNodes();
@@ -355,11 +357,13 @@
   _getShadowOrDocumentSelection() {
     // When using native shadow DOM, the selection returned by
     // document.getSelection() cannot reference the actual DOM elements making
-    // up the diff, because they are in the shadow DOM of the gr-diff element.
-    // This takes the shadow DOM selection if one exists.
+    // up the diff in Safari because they are in the shadow DOM of the gr-diff
+    // element. This takes the shadow DOM selection if one exists.
     return this.root instanceof ShadowRoot && this.root.getSelection
       ? this.root.getSelection()
-      : shadow.getRange(this.root);
+      : isSafari()
+      ? shadow.getRange(this.root)
+      : document.getSelection();
   }
 
   _observeNodes() {
diff --git a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.ts b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.ts
index 84ca10a..f6f4395 100644
--- a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.ts
+++ b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.ts
@@ -32,7 +32,6 @@
 } from '../../core/gr-navigation/gr-navigation';
 import {computeTruncatedPath} from '../../../utils/path-list-util';
 import {customElement, property} from '@polymer/decorators';
-import {ErrorCallback} from '../../../services/gr-rest-api/gr-rest-api';
 import {
   ChangeInfo,
   PatchSetNum,
@@ -45,6 +44,7 @@
 import {HttpMethod, NotifyType} from '../../../constants/constants';
 import {fireAlert, fireTitleChange} from '../../../utils/event-util';
 import {appContext} from '../../../services/app-context';
+import {ErrorCallback} from '../../../api/rest';
 
 const RESTORED_MESSAGE = 'Content restored from a previous edit.';
 const SAVING_MESSAGE = 'Saving changes...';
@@ -55,6 +55,8 @@
 
 const STORAGE_DEBOUNCE_INTERVAL_MS = 100;
 
+const DEBOUNCER_STORE = 'store';
+
 @customElement('gr-editor-view')
 export class GrEditorView extends KeyboardShortcutMixin(
   GestureEventListeners(LegacyElementMixin(PolymerElement))
@@ -145,6 +147,11 @@
     });
   }
 
+  /** @override */
+  detached() {
+    this.cancelDebouncer(DEBOUNCER_STORE);
+  }
+
   get storageKey() {
     return `c${this._changeNum}_ps${this._patchNum}_${this._path}`;
   }
@@ -348,7 +355,7 @@
 
   _handleContentChange(e: CustomEvent<{value: string}>) {
     this.debounce(
-      'store',
+      DEBOUNCER_STORE,
       () => {
         const content = e.detail.value;
         if (content) {
diff --git a/polygerrit-ui/app/elements/gr-app-element.ts b/polygerrit-ui/app/elements/gr-app-element.ts
index 72e24f7..cdc9f98 100644
--- a/polygerrit-ui/app/elements/gr-app-element.ts
+++ b/polygerrit-ui/app/elements/gr-app-element.ts
@@ -79,6 +79,7 @@
 import {ViewState} from '../types/types';
 import {EventType} from '../utils/event-util';
 import {GerritView} from '../services/router/router-model';
+import {windowLocationReload} from '../utils/dom-util';
 
 interface ErrorInfo {
   text: string;
@@ -241,7 +242,7 @@
     // Ideally individual views should handle this event and respond with a soft
     // reload. This is a catch-all for all views that cannot or have not
     // implemented that.
-    this.addEventListener('reload', () => window.location.reload());
+    this.addEventListener('reload', () => windowLocationReload());
   }
 
   /** @override */
diff --git a/polygerrit-ui/app/elements/plugins/gr-admin-api/gr-admin-api.ts b/polygerrit-ui/app/elements/plugins/gr-admin-api/gr-admin-api.ts
index 1332118..897be67 100644
--- a/polygerrit-ui/app/elements/plugins/gr-admin-api/gr-admin-api.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-admin-api/gr-admin-api.ts
@@ -14,27 +14,20 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-
-import {PluginApi} from '../gr-plugin-types';
-
-/** Interface for menu link */
-export interface MenuLink {
-  text: string;
-  url: string;
-  capability: string | null;
-}
+import {EventType, PluginApi} from '../../../api/plugin';
+import {AdminPluginApi, MenuLink} from '../../../api/admin';
 
 /**
  * GrAdminApi class.
  *
  * Defines common methods to register / retrieve menu links.
  */
-export class GrAdminApi {
+export class GrAdminApi implements AdminPluginApi {
   // TODO(TS): maybe define as enum if its a limited set
   private menuLinks: MenuLink[] = [];
 
   constructor(private readonly plugin: PluginApi) {
-    this.plugin.on('admin-menu-links', this);
+    this.plugin.on(EventType.ADMIN_MENU_LINKS, this);
   }
 
   addMenuLink(text: string, url: string, capability?: string) {
diff --git a/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper.ts b/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper.ts
index 0641b49..e0b4ee9 100644
--- a/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper.ts
@@ -14,8 +14,9 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
+import {AttributeHelperPluginApi} from '../../../api/attribute-helper';
 
-export class GrAttributeHelper {
+export class GrAttributeHelper implements AttributeHelperPluginApi {
   // eslint-disable-next-line @typescript-eslint/no-explicit-any
   private readonly _promises = new Map<string, Promise<any>>();
 
diff --git a/polygerrit-ui/app/elements/plugins/gr-change-metadata-api/gr-change-metadata-api.ts b/polygerrit-ui/app/elements/plugins/gr-change-metadata-api/gr-change-metadata-api.ts
index 322d32e..3a61bce 100644
--- a/polygerrit-ui/app/elements/plugins/gr-change-metadata-api/gr-change-metadata-api.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-change-metadata-api/gr-change-metadata-api.ts
@@ -14,9 +14,11 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import {HookApi, PluginApi} from '../gr-plugin-types';
+import {PluginApi} from '../../../api/plugin';
+import {ChangeMetadataPluginApi} from '../../../api/change-metadata';
+import {HookApi} from '../../../api/hook';
 
-export class GrChangeMetadataApi {
+export class GrChangeMetadataApi implements ChangeMetadataPluginApi {
   private _hook: HookApi | null;
 
   public plugin: PluginApi;
diff --git a/polygerrit-ui/app/elements/plugins/gr-checks-api/gr-checks-api.ts b/polygerrit-ui/app/elements/plugins/gr-checks-api/gr-checks-api.ts
index 82c1087..404fc71 100644
--- a/polygerrit-ui/app/elements/plugins/gr-checks-api/gr-checks-api.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-checks-api/gr-checks-api.ts
@@ -14,11 +14,11 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import {PluginApi} from '../gr-plugin-types';
+import {PluginApi} from '../../../api/plugin';
 import {
   ChecksApiConfig,
   ChecksProvider,
-  GrChecksApiInterface,
+  ChecksPluginApi,
 } from '../../../api/checks';
 import {appContext} from '../../../services/app-context';
 
@@ -38,7 +38,7 @@
  * Plugins normally just call register() once at startup and then wait for
  * fetch() being called on the provider interface.
  */
-export class GrChecksApi implements GrChecksApiInterface {
+export class GrChecksApi implements ChecksPluginApi {
   private state = State.NOT_REGISTERED;
 
   private readonly checksService = appContext.checksService;
diff --git a/polygerrit-ui/app/elements/plugins/gr-checks-api/gr-checks-api_test.ts b/polygerrit-ui/app/elements/plugins/gr-checks-api/gr-checks-api_test.ts
index 45cbb47..3ab3efb 100644
--- a/polygerrit-ui/app/elements/plugins/gr-checks-api/gr-checks-api_test.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-checks-api/gr-checks-api_test.ts
@@ -18,13 +18,13 @@
 import '../../../test/common-test-setup-karma.js';
 import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
 import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit.js';
-import {GrChecksApi} from './gr-checks-api';
-import {PluginApi} from '../gr-plugin-types';
+import {PluginApi} from '../../../api/plugin';
+import {ChecksPluginApi} from '../../../api/checks';
 
 const gerritPluginApi = _testOnly_initGerritPluginApi();
 
 suite('gr-settings-api tests', () => {
-  let checksApi: GrChecksApi | undefined;
+  let checksApi: ChecksPluginApi | undefined;
 
   setup(() => {
     let pluginApi: PluginApi | undefined = undefined;
diff --git a/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks.ts b/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks.ts
index dd76be4..d2568ad 100644
--- a/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks.ts
@@ -15,7 +15,8 @@
  * limitations under the License.
  */
 import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {HookApi, HookCallback, PluginApi} from '../gr-plugin-types';
+import {PluginApi} from '../../../api/plugin';
+import {HookApi, HookCallback} from '../../../api/hook';
 
 export class GrDomHooksManager {
   private _hooks: Record<string, GrDomHook>;
diff --git a/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator.ts b/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator.ts
index 12863fd..423cff9 100644
--- a/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator.ts
@@ -24,7 +24,8 @@
 } from '../../shared/gr-js-api-interface/gr-plugin-endpoints';
 import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
 import {customElement, property} from '@polymer/decorators';
-import {HookApi, PluginApi} from '../gr-plugin-types';
+import {PluginApi} from '../../../api/plugin';
+import {HookApi} from '../../../api/hook';
 
 const INIT_PROPERTIES_TIMEOUT_MS = 10000;
 
diff --git a/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper.ts b/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper.ts
index 5a4d2ae..4b34d56 100644
--- a/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper.ts
@@ -14,13 +14,17 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
+import {
+  EventHelperPluginApi,
+  UnsubscribeCallback,
+} from '../../../api/event-helper';
 
 export interface ListenOptions {
   event?: string;
   capture?: boolean;
 }
 
-export class GrEventHelper {
+export class GrEventHelper implements EventHelperPluginApi {
   constructor(readonly element: HTMLElement) {}
 
   /**
@@ -50,7 +54,7 @@
    * Alias for @see captureClick
    */
   captureTap(callback: (event: Event) => boolean) {
-    this.captureClick(callback);
+    return this.captureClick(callback);
   }
 
   /**
@@ -68,7 +72,7 @@
     container: HTMLElement,
     callback: (event: Event) => boolean,
     options?: ListenOptions | null
-  ) {
+  ): UnsubscribeCallback {
     const capture = options?.capture;
     const event = options?.event || 'click';
     const handler = (e: Event) => {
diff --git a/polygerrit-ui/app/elements/plugins/gr-plugin-types.ts b/polygerrit-ui/app/elements/plugins/gr-plugin-types.ts
deleted file mode 100644
index 2cd0f78..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-plugin-types.ts
+++ /dev/null
@@ -1,105 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {GrAttributeHelper} from './gr-attribute-helper/gr-attribute-helper';
-import {GrPluginRestApi} from '../shared/gr-js-api-interface/gr-plugin-rest-api';
-import {GrEventHelper} from './gr-event-helper/gr-event-helper';
-import {GrPopupInterface} from './gr-popup-interface/gr-popup-interface';
-import {ConfigInfo} from '../../types/common';
-import {GrChecksApi} from './gr-checks-api/gr-checks-api';
-
-interface GerritElementExtensions {
-  content?: HTMLElement & {hidden?: boolean};
-  change?: unknown;
-  revision?: unknown;
-  token?: string;
-  repoName?: string;
-  config?: ConfigInfo;
-}
-export type HookCallback = (el: HTMLElement & GerritElementExtensions) => void;
-
-export interface HookApi {
-  onAttached(callback: HookCallback): HookApi;
-  onDetached(callback: HookCallback): HookApi;
-  getAllAttached(): HTMLElement[];
-  getLastAttached(): Promise<HTMLElement>;
-  getModuleName(): string;
-  handleInstanceDetached(instance: HTMLElement): void;
-  handleInstanceAttached(instance: HTMLElement): void;
-}
-
-export enum TargetElement {
-  CHANGE_ACTIONS = 'changeactions',
-  REPLY_DIALOG = 'replydialog',
-}
-
-// Note: for new events, naming convention should be: `a-b`
-export enum EventType {
-  HISTORY = 'history',
-  LABEL_CHANGE = 'labelchange',
-  SHOW_CHANGE = 'showchange',
-  SUBMIT_CHANGE = 'submitchange',
-  SHOW_REVISION_ACTIONS = 'show-revision-actions',
-  COMMIT_MSG_EDIT = 'commitmsgedit',
-  COMMENT = 'comment',
-  REVERT = 'revert',
-  REVERT_SUBMISSION = 'revert_submission',
-  POST_REVERT = 'postrevert',
-  ANNOTATE_DIFF = 'annotatediff',
-  ADMIN_MENU_LINKS = 'admin-menu-links',
-  HIGHLIGHTJS_LOADED = 'highlightjs-loaded',
-}
-
-export interface RegisterOptions {
-  slot?: string;
-  replace: unknown;
-}
-
-export interface PanelInfo {
-  body: Element;
-  // eslint-disable-next-line @typescript-eslint/no-explicit-any
-  p: {[key: string]: any};
-  onUnload: () => void;
-}
-
-export interface SettingsInfo {
-  body: Element;
-  token?: string;
-  onUnload: () => void;
-  setTitle: () => void;
-  setWindowTitle: () => void;
-  show: () => void;
-}
-
-export interface PluginApi {
-  _url?: URL;
-  popup(): Promise<GrPopupInterface>;
-  popup(moduleName: string): Promise<GrPopupInterface>;
-  popup(moduleName?: string): Promise<GrPopupInterface | null>;
-  hook(endpointName: string, opt_options?: RegisterOptions): HookApi;
-  getPluginName(): string;
-  // eslint-disable-next-line @typescript-eslint/no-explicit-any
-  on(eventName: string, target: any): void;
-  attributeHelper(element: Element): GrAttributeHelper;
-  checks(): GrChecksApi;
-  restApi(): GrPluginRestApi;
-  eventHelper(element: Node): GrEventHelper;
-  registerDynamicCustomComponent(
-    endpointName: string,
-    moduleName?: string,
-    options?: RegisterOptions
-  ): HookApi;
-}
diff --git a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface.ts b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface.ts
index d45c263..07d11ec 100644
--- a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface.ts
@@ -17,7 +17,8 @@
 import './gr-plugin-popup';
 import {dom, flush} from '@polymer/polymer/lib/legacy/polymer.dom';
 import {GrPluginPopup} from './gr-plugin-popup';
-import {PluginApi} from '../gr-plugin-types';
+import {PluginApi} from '../../../api/plugin';
+import {PopupPluginApi} from '../../../api/popup';
 
 interface CustomPolymerPluginEl extends HTMLElement {
   plugin: PluginApi;
@@ -29,7 +30,7 @@
  * opt_moduleName is a name of custom element that will be automatically
  * inserted on popup opening.
  */
-export class GrPopupInterface {
+export class GrPopupInterface implements PopupPluginApi {
   private _openingPromise: Promise<GrPopupInterface> | null = null;
 
   private _popup: GrPluginPopup | null = null;
@@ -50,7 +51,7 @@
    * Creates the popup if not previously created. Creates popup content element,
    * if it was provided with constructor.
    */
-  open(): Promise<GrPopupInterface> {
+  open(): Promise<PopupPluginApi> {
     if (!this._openingPromise) {
       this._openingPromise = this.plugin
         .hook('plugin-overlay')
diff --git a/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-repo-api.ts b/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-repo-api.ts
index 701a560..e42ca08 100644
--- a/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-repo-api.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-repo-api.ts
@@ -16,9 +16,9 @@
  */
 import './gr-plugin-repo-command';
 import {ConfigInfo} from '../../../types/common';
-import {HookApi, PluginApi} from '../gr-plugin-types';
-
-type RepoCommandCallback = (repo?: string, config?: ConfigInfo) => boolean;
+import {PluginApi} from '../../../api/plugin';
+import {RepoCommandCallback, RepoPluginApi} from '../../../api/repo';
+import {HookApi} from '../../../api/hook';
 
 /**
  * Parameters provided on repo-command endpoint
@@ -28,7 +28,7 @@
   config: ConfigInfo;
 }
 
-export class GrRepoApi {
+export class GrRepoApi implements RepoPluginApi {
   private _hook?: HookApi;
 
   constructor(readonly plugin: PluginApi) {}
@@ -45,7 +45,7 @@
   createCommand(title: string, callback: RepoCommandCallback) {
     if (this._hook) {
       console.warn('Already set up.');
-      return this._hook;
+      return this;
     }
     this._hook = this._createHook(title);
     this._hook.onAttached(element => {
diff --git a/polygerrit-ui/app/elements/plugins/gr-settings-api/gr-settings-api.ts b/polygerrit-ui/app/elements/plugins/gr-settings-api/gr-settings-api.ts
index c7f1ecd..4bdd40e 100644
--- a/polygerrit-ui/app/elements/plugins/gr-settings-api/gr-settings-api.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-settings-api/gr-settings-api.ts
@@ -16,9 +16,10 @@
  */
 import '../../settings/gr-settings-view/gr-settings-item';
 import '../../settings/gr-settings-view/gr-settings-menu-item';
-import {PluginApi} from '../gr-plugin-types';
+import {PluginApi} from '../../../api/plugin';
+import {SettingsPluginApi} from '../../../api/settings';
 
-export class GrSettingsApi {
+export class GrSettingsApi implements SettingsPluginApi {
   private _token: string;
 
   private _title = '(no title)';
diff --git a/polygerrit-ui/app/elements/plugins/gr-styles-api/gr-styles-api.ts b/polygerrit-ui/app/elements/plugins/gr-styles-api/gr-styles-api.ts
index 5c57208..a91b8d3 100644
--- a/polygerrit-ui/app/elements/plugins/gr-styles-api/gr-styles-api.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-styles-api/gr-styles-api.ts
@@ -14,6 +14,7 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
+import {StyleObject, StylesPluginApi} from '../../../api/styles';
 
 /**
  * @fileoverview We should consider dropping support for this API:
@@ -30,7 +31,7 @@
   };
 }
 
-export class GrStyleObject {
+export class GrStyleObject implements StyleObject {
   private className = '';
 
   constructor(private readonly rulesStr: string) {
@@ -66,7 +67,6 @@
 
   /**
    * Apply shared style to the element.
-   *
    */
   apply(element: Element) {
     element.classList.add(this.getClassName(element));
@@ -76,7 +76,7 @@
 /**
  * TODO(TS): move to util
  */
-export class GrStylesApi {
+export class GrStylesApi implements StylesPluginApi {
   /**
    * Creates a new GrStyleObject with specified style properties.
    */
diff --git a/polygerrit-ui/app/elements/plugins/gr-styles-api/gr-styles-api_test.js b/polygerrit-ui/app/elements/plugins/gr-styles-api/gr-styles-api_test.js
index c41b551..1c606cd 100644
--- a/polygerrit-ui/app/elements/plugins/gr-styles-api/gr-styles-api_test.js
+++ b/polygerrit-ui/app/elements/plugins/gr-styles-api/gr-styles-api_test.js
@@ -172,12 +172,10 @@
     }
 
     function assertDisplayPropertyValues(elements, expectedDisplayValues) {
-      for (const key in elements) {
-        if (elements.hasOwnProperty(key)) {
-          assert.equal(
-              getComputedStyle(elements[key]).getPropertyValue('display'),
-              expectedDisplayValues[key]);
-        }
+      for (let i = 0; i < elements.length; i++) {
+        assert.equal(
+            getComputedStyle(elements[i]).getPropertyValue('display'),
+            expectedDisplayValues[i]);
       }
     }
   });
diff --git a/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-theme-api.ts b/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-theme-api.ts
index 821e4bf..894ec6c 100644
--- a/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-theme-api.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-theme-api.ts
@@ -16,12 +16,13 @@
  */
 import './gr-custom-plugin-header';
 import {GrCustomPluginHeader} from './gr-custom-plugin-header';
-import {PluginApi} from '../gr-plugin-types';
+import {PluginApi} from '../../../api/plugin';
+import {ThemePluginApi} from '../../../api/theme';
 
 /**
  * Defines api for theme, can be used to set header logo and title.
  */
-export class GrThemeApi {
+export class GrThemeApi implements ThemePluginApi {
   constructor(private readonly plugin: PluginApi) {}
 
   setHeaderLogoAndTitle(logoUrl: string, title: string) {
diff --git a/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog_test.js b/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog_test.js
index ce9c106..1088585 100644
--- a/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog_test.js
+++ b/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog_test.js
@@ -61,10 +61,8 @@
   });
 
   teardown(() => {
-    for (const eventType in _listeners) {
-      if (_listeners.hasOwnProperty(eventType)) {
-        element.removeEventListener(eventType, _listeners[eventType]);
-      }
+    for (const [eventType, listeners] of Object.entries(_listeners)) {
+      element.removeEventListener(eventType, listeners);
     }
   });
 
diff --git a/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor.ts b/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor.ts
index 36444a3..a45e160 100644
--- a/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor.ts
+++ b/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor.ts
@@ -121,14 +121,8 @@
   _getProjectSuggestions(input: string) {
     return this.restApiService.getSuggestedProjects(input).then(response => {
       const projects: AutocompleteSuggestion[] = [];
-      for (const key in response) {
-        if (!hasOwnProperty(response, key)) {
-          continue;
-        }
-        projects.push({
-          name: key,
-          value: response[key].id,
-        });
+      for (const [name, project] of Object.entries(response ?? {})) {
+        projects.push({name, value: project.id});
       }
       return projects;
     });
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.ts b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.ts
index 6151a1d9..f4eb053 100644
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.ts
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.ts
@@ -68,6 +68,8 @@
   AutocompleteCommitEventDetail
 >;
 
+const DEBOUNCER_UPDATE_SUGGESTIONS = 'update-suggestions';
+
 @customElement('gr-autocomplete')
 export class GrAutocomplete extends KeyboardShortcutMixin(
   GestureEventListeners(LegacyElementMixin(PolymerElement))
@@ -217,7 +219,7 @@
   detached() {
     super.detached();
     this.unlisten(document.body, 'click', '_handleBodyClick');
-    this.cancelDebouncer('update-suggestions');
+    this.cancelDebouncer(DEBOUNCER_UPDATE_SUGGESTIONS);
   }
 
   get focusStart() {
@@ -330,7 +332,7 @@
     if (noDebounce) {
       update();
     } else {
-      this.debounce('update-suggestions', update, DEBOUNCE_WAIT_MS);
+      this.debounce(DEBOUNCER_UPDATE_SUGGESTIONS, update, DEBOUNCE_WAIT_MS);
     }
   }
 
diff --git a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts
index cb64001..6ee82c9 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts
@@ -326,6 +326,10 @@
     return this.restApiService.getLoggedIn();
   }
 
+  _getUnresolvedLabel(unresolved?: boolean) {
+    return unresolved ? 'Unresolved' : 'Resolved';
+  }
+
   @observe('comments.*')
   _commentsChanged() {
     this._orderedComments = sortComments(this.comments);
diff --git a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_html.ts b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_html.ts
index ba541be..1a89a79 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_html.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_html.ts
@@ -132,7 +132,7 @@
       id="commentInfoContainer"
       hidden$="[[_hideActions(_showActions, _lastComment)]]"
     >
-      <span id="unresolvedLabel" hidden$="[[!unresolved]]">Unresolved</span>
+      <span id="unresolvedLabel">[[_getUnresolvedLabel(unresolved)]]</span>
       <div id="actions">
         <gr-button
           id="replyBtn"
diff --git a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.ts b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.ts
index e1c6bff..f2df89d 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.ts
@@ -836,7 +836,7 @@
     element.unresolved = false;
     const label = element.shadowRoot?.querySelector('#unresolvedLabel');
     assert.isOk(label);
-    assert.isTrue(label!.hasAttribute('hidden'));
+    assert.isFalse(label!.hasAttribute('hidden'));
     element.unresolved = true;
     assert.isFalse(label!.hasAttribute('hidden'));
   });
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
index 5d5f4f3..898aff3 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
@@ -100,6 +100,12 @@
   };
 }
 
+const DEBOUNCER_FIRE_UPDATE = 'fire-update';
+
+const DEBOUNCER_STORE = 'store';
+
+const DEBOUNCER_DRAFT_TOAST = 'draft-toast';
+
 @customElement('gr-comment')
 export class GrComment extends KeyboardShortcutMixin(
   GestureEventListeners(LegacyElementMixin(PolymerElement))
@@ -293,7 +299,9 @@
   /** @override */
   detached() {
     super.detached();
-    this.cancelDebouncer('fire-update');
+    this.cancelDebouncer(DEBOUNCER_FIRE_UPDATE);
+    this.cancelDebouncer(DEBOUNCER_STORE);
+    this.cancelDebouncer(DEBOUNCER_DRAFT_TOAST);
     if (this.textarea) {
       this.textarea.closeDropdown();
     }
@@ -486,7 +494,7 @@
   _eraseDraftComment() {
     // Prevents a race condition in which removing the draft comment occurs
     // prior to it being saved.
-    this.cancelDebouncer('store');
+    this.cancelDebouncer(DEBOUNCER_STORE);
 
     if (!this.comment?.path) throw new Error('Cannot erase Draft Comment');
     if (this.changeNum === undefined) {
@@ -538,7 +546,7 @@
   }
 
   _fireUpdate() {
-    this.debounce('fire-update', () => {
+    this.debounce(DEBOUNCER_FIRE_UPDATE, () => {
       this.dispatchEvent(
         new CustomEvent('comment-update', {
           detail: this._getEventPayload(),
@@ -647,7 +655,7 @@
     const {path, line, range} = this.comment;
     if (path) {
       this.debounce(
-        'store',
+        DEBOUNCER_STORE,
         () => {
           const message = this._messageText;
           if (this.changeNum === undefined) {
@@ -729,7 +737,7 @@
   }
 
   _fireDiscard() {
-    this.cancelDebouncer('fire-update');
+    this.cancelDebouncer(DEBOUNCER_FIRE_UPDATE);
     this.dispatchEvent(
       new CustomEvent('comment-discard', {
         detail: this._getEventPayload(),
@@ -852,7 +860,7 @@
 
     // Cancel the debouncer so that error toasts from the error-manager will
     // not be overridden.
-    this.cancelDebouncer('draft-toast');
+    this.cancelDebouncer(DEBOUNCER_DRAFT_TOAST);
     this._updateRequestToast(
       this._numPendingDraftRequests.number,
       /* requestFailed=*/ true
@@ -862,7 +870,7 @@
   _updateRequestToast(numPending: number, requestFailed?: boolean) {
     const message = this._getSavingMessage(numPending, requestFailed);
     this.debounce(
-      'draft-toast',
+      DEBOUNCER_DRAFT_TOAST,
       () => {
         // Note: the event is fired on the body rather than this element because
         // this element may not be attached by the time this executes, in which
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.ts b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.ts
index e825d21..c744eab 100644
--- a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.ts
+++ b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.ts
@@ -37,6 +37,8 @@
   }
 }
 
+const DEBOUNCER_STORE = 'store';
+
 @customElement('gr-editable-content')
 export class GrEditableContent extends GestureEventListeners(
   LegacyElementMixin(PolymerElement)
@@ -126,6 +128,11 @@
     );
   }
 
+  /** @override */
+  detached() {
+    this.cancelDebouncer(DEBOUNCER_STORE);
+  }
+
   _contentChanged() {
     /* A changed content means that either a different change has been loaded
      * or new content was saved. Either way, let's reset the component.
@@ -143,7 +150,7 @@
     const storageKey = this.storageKey;
 
     this.debounce(
-      'store',
+      DEBOUNCER_STORE,
       () => {
         if (newContent.length) {
           this.storage.setEditableContentItem(storageKey, newContent);
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_html.ts b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_html.ts
index 6605394..374cc62 100644
--- a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_html.ts
+++ b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_html.ts
@@ -32,13 +32,13 @@
       padding: var(--spacing-m);
     }
     :host([collapsed]) .viewer,
-    .viewer[collapsed] {
+    .viewer.new-change-summary-true[collapsed] {
       max-height: var(--collapsed-max-height, 300px);
       overflow: hidden;
     }
     .editor.new-change-summary-true iron-autogrow-textarea,
     .viewer.new-change-summary-true {
-      min-height: 160px;
+      min-height: 100px;
     }
     .editor iron-autogrow-textarea {
       background-color: var(--view-background-color);
diff --git a/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard-behavior.ts b/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard-behavior.ts
index 94f99d3..e67e1f6 100644
--- a/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard-behavior.ts
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard-behavior.ts
@@ -143,6 +143,8 @@
 
       detached() {
         super.detached();
+        this.cancelShowDebouncer();
+        this.cancelHideDebouncer();
         this.unlock();
       }
 
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-context.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-context.ts
index a493e51..6a4da7b 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-context.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-context.ts
@@ -19,6 +19,7 @@
 import {GrStyleObject} from '../../plugins/gr-styles-api/gr-styles-api';
 import {GrDiffLine} from '../../diff/gr-diff/gr-diff-line';
 import {appContext} from '../../../services/app-context';
+import {AnnotationContext} from '../../../api/annotation';
 
 /**
  * Used to create a context for GrAnnotationActionsInterface.
@@ -32,7 +33,7 @@
  * @param changeNum The Gerrit change number.
  * @param patchNum The Gerrit patch number.
  */
-export class GrAnnotationActionsContext {
+export class GrAnnotationActionsContext implements AnnotationContext {
   private _contentEl: HTMLElement;
 
   private _lineNumberEl: HTMLElement;
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api.ts
index f160807..4abc6e1 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api.ts
@@ -16,34 +16,18 @@
  */
 import {GrAnnotationActionsContext} from './gr-annotation-actions-context';
 import {GrDiffLine} from '../../diff/gr-diff/gr-diff-line';
-import {
-  CoverageRange,
-  DiffLayer,
-  DiffLayerListener,
-} from '../../../types/types';
+import {DiffLayer, DiffLayerListener} from '../../../types/types';
 import {Side} from '../../../constants/constants';
-import {PluginApi} from '../../plugins/gr-plugin-types';
-import {ChangeInfo, NumericChangeId} from '../../../types/common';
+import {EventType, PluginApi} from '../../../api/plugin';
 import {appContext} from '../../../services/app-context';
+import {
+  AddLayerFunc,
+  AnnotationPluginApi,
+  CoverageProvider,
+  NotifyFunc,
+} from '../../../api/annotation';
 
-type AddLayerFunc = (ctx: GrAnnotationActionsContext) => void;
-
-type NotifyFunc = (
-  path: string,
-  start: number,
-  end: number,
-  side: Side
-) => void;
-
-export type CoverageProvider = (
-  changeNum: NumericChangeId,
-  path: string,
-  basePatchNum?: number,
-  patchNum?: number,
-  change?: ChangeInfo
-) => Promise<Array<CoverageRange>>;
-
-export class GrAnnotationActionsInterface {
+export class GrAnnotationActionsInterface implements AnnotationPluginApi {
   // Collect all annotation layers instantiated by getLayer. Will be used when
   // notifying their listeners in the notify function.
   private annotationLayers: AnnotationLayer[] = [];
@@ -57,7 +41,7 @@
 
   constructor(private readonly plugin: PluginApi) {
     // Return this instance when there is an annotatediff event.
-    plugin.on('annotatediff', this);
+    plugin.on(EventType.ANNOTATE_DIFF, this);
   }
 
   /**
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api.ts
index 9ae2900..2f2b5ce 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api.ts
@@ -14,47 +14,17 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import {
-  ActionType,
-  ActionPriority,
-} from '../../../services/gr-rest-api/gr-rest-api';
-import {PluginApi, TargetElement} from '../../plugins/gr-plugin-types';
+import {PluginApi, TargetElement} from '../../../api/plugin';
 import {ActionInfo, RequireProperties} from '../../../types/common';
 import {appContext} from '../../../services/app-context';
-
-export enum ChangeActions {
-  ABANDON = 'abandon',
-  DELETE = '/',
-  DELETE_EDIT = 'deleteEdit',
-  EDIT = 'edit',
-  FOLLOW_UP = 'followup',
-  IGNORE = 'ignore',
-  MOVE = 'move',
-  PRIVATE = 'private',
-  PRIVATE_DELETE = 'private.delete',
-  PUBLISH_EDIT = 'publishEdit',
-  REBASE = 'rebase',
-  REBASE_EDIT = 'rebaseEdit',
-  READY = 'ready',
-  RESTORE = 'restore',
-  REVERT = 'revert',
-  REVERT_SUBMISSION = 'revert_submission',
-  REVIEWED = 'reviewed',
-  STOP_EDIT = 'stopEdit',
-  SUBMIT = 'submit',
-  UNIGNORE = 'unignore',
-  UNREVIEWED = 'unreviewed',
-  WIP = 'wip',
-}
-
-export enum RevisionActions {
-  CHERRYPICK = 'cherrypick',
-  REBASE = 'rebase',
-  SUBMIT = 'submit',
-  DOWNLOAD = 'download',
-}
-
-export type PrimaryActionKey = ChangeActions | RevisionActions;
+import {
+  ActionPriority,
+  ActionType,
+  ChangeActions,
+  ChangeActionsPluginApi,
+  PrimaryActionKey,
+  RevisionActions,
+} from '../../../api/change-actions';
 
 export interface UIActionInfo extends RequireProperties<ActionInfo, 'label'> {
   __key: string;
@@ -89,7 +59,7 @@
   getActionDetails(actionName: string): ActionInfo | undefined;
 }
 
-export class GrChangeActionsInterface {
+export class GrChangeActionsInterface implements ChangeActionsPluginApi {
   private _el?: GrChangeActionsElement;
 
   RevisionActions = RevisionActions;
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api.ts
index 74130af..effebe1 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api.ts
@@ -16,25 +16,20 @@
  */
 
 import {GrReplyDialog} from '../../../services/gr-rest-api/gr-rest-api';
-import {PluginApi, TargetElement} from '../../plugins/gr-plugin-types';
+import {PluginApi, TargetElement} from '../../../api/plugin';
 import {JsApiService} from './gr-js-api-types';
-
-// TODO(TS): maybe move interfaces\types to other files when convertion complete
-interface LabelsChangedDetail {
-  name: string;
-  value: string;
-}
-interface ValueChangedDetail {
-  value: string;
-}
-
-type ReplyChangedCallback = (text: string) => void;
-type LabelsChangedCallback = (detail: LabelsChangedDetail) => void;
+import {
+  ChangeReplyPluginApi,
+  LabelsChangedCallback,
+  LabelsChangedDetail,
+  ReplyChangedCallback,
+  ValueChangedDetail,
+} from '../../../api/change-reply';
 
 /**
  * GrChangeReplyInterface, provides a set of handy methods on reply dialog.
  */
-export class GrChangeReplyInterface {
+export class GrChangeReplyInterface implements ChangeReplyPluginApi {
   constructor(
     readonly plugin: PluginApi,
     readonly sharedApiElement: JsApiService
@@ -46,7 +41,7 @@
     ) as unknown) as GrReplyDialog;
   }
 
-  getLabelValue(label: string) {
+  getLabelValue(label: string): string {
     return this._el.getLabelValue(label);
   }
 
@@ -100,6 +95,6 @@
   }
 
   showMessage(message: string) {
-    return this._el.setPluginMessage(message);
+    this._el.setPluginMessage(message);
   }
 }
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit.ts
index 32a9238..27bc591 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit.ts
@@ -26,7 +26,7 @@
 } from './gr-plugin-loader';
 import {send} from './gr-api-utils';
 import {appContext} from '../../../services/app-context';
-import {PluginApi} from '../../plugins/gr-plugin-types';
+import {PluginApi} from '../../../api/plugin';
 import {HttpMethod} from '../../../constants/constants';
 import {RequestPayload} from '../../../types/common';
 import {
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface-element.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface-element.ts
index 412165d..830fb92 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface-element.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface-element.ts
@@ -23,16 +23,17 @@
   RevisionInfo,
 } from '../../../types/common';
 import {GrAnnotationActionsInterface} from './gr-annotation-actions-js-api';
-import {GrAdminApi, MenuLink} from '../../plugins/gr-admin-api/gr-admin-api';
+import {GrAdminApi} from '../../plugins/gr-admin-api/gr-admin-api';
 import {
   JsApiService,
   EventCallback,
   ShowChangeDetail,
   ShowRevisionActionsDetail,
 } from './gr-js-api-types';
-import {EventType, TargetElement} from '../../plugins/gr-plugin-types';
+import {EventType, TargetElement} from '../../../api/plugin';
 import {DiffLayer, HighlightJS, ParsedChangeInfo} from '../../../types/types';
 import {appContext} from '../../../services/app-context';
+import {MenuLink} from '../../../api/admin';
 
 const elements: {[key: string]: HTMLElement} = {};
 const eventCallbacks: {[key: string]: EventCallback[]} = {};
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.js
index 47b6006..6a8a0dd 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.js
@@ -19,7 +19,7 @@
 import './gr-js-api-interface.js';
 import {GrPopupInterface} from '../../plugins/gr-popup-interface/gr-popup-interface.js';
 import {GrSettingsApi} from '../../plugins/gr-settings-api/gr-settings-api.js';
-import {EventType} from '../../plugins/gr-plugin-types.js';
+import {EventType} from '../../../api/plugin.js';
 import {PLUGIN_LOADING_TIMEOUT_MS} from './gr-api-utils.js';
 import {getPluginLoader} from './gr-plugin-loader.js';
 import {_testOnly_initGerritPluginApi} from './gr-gerrit.js';
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-types.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-types.ts
index 37db662..0b28c5e 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-types.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-types.ts
@@ -21,10 +21,10 @@
   ReviewInput,
   RevisionInfo,
 } from '../../../types/common';
-import {EventType, TargetElement} from '../../plugins/gr-plugin-types';
+import {EventType, TargetElement} from '../../../api/plugin';
 import {DiffLayer, ParsedChangeInfo} from '../../../types/types';
 import {GrAnnotationActionsInterface} from './gr-annotation-actions-js-api';
-import {MenuLink} from '../../plugins/gr-admin-api/gr-admin-api';
+import {MenuLink} from '../../../api/admin';
 
 export interface ShowChangeDetail {
   change: ChangeInfo;
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context.ts
index 2149fe4..21e4876 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context.ts
@@ -17,19 +17,18 @@
 
 import {RevisionInfo, ChangeInfo, RequestPayload} from '../../../types/common';
 import {ShowAlertEventDetail} from '../../../types/events';
-import {PluginApi} from '../../plugins/gr-plugin-types';
+import {PluginApi} from '../../../api/plugin';
 import {UIActionInfo} from './gr-change-actions-js-api';
-
-interface GrPopupInterface {
-  close(): void;
-}
+import {windowLocationReload} from '../../../utils/dom-util';
+import {PopupPluginApi} from '../../../api/popup';
+import {GrPopupInterface} from '../../plugins/gr-popup-interface/gr-popup-interface';
 
 interface ButtonCallBacks {
   onclick: (event: Event) => boolean;
 }
 
 export class GrPluginActionContext {
-  private _popups: GrPopupInterface[] = [];
+  private _popups: PopupPluginApi[] = [];
 
   constructor(
     public readonly plugin: PluginApi,
@@ -40,7 +39,7 @@
 
   popup(element: Node) {
     this.plugin.popup().then(popApi => {
-      const popupEl = popApi._getElement();
+      const popupEl = (popApi as GrPopupInterface)._getElement();
       if (!popupEl) {
         throw new Error('Popup element not found');
       }
@@ -57,7 +56,7 @@
   }
 
   refresh() {
-    window.location.reload();
+    windowLocationReload();
   }
 
   textfield(): HTMLElement {
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints.ts
index da19e5b..2752c74 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints.ts
@@ -16,8 +16,9 @@
  */
 
 import {importHref} from '../../../scripts/import-href';
-import {HookApi, PluginApi} from '../../plugins/gr-plugin-types';
+import {PluginApi} from '../../../api/plugin';
 import {notUndefined} from '../../../types/types';
+import {HookApi} from '../../../api/hook';
 
 // eslint-disable-next-line @typescript-eslint/no-explicit-any
 type Callback = (value: any) => void;
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader.ts
index aacef0e..8c0fce26 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader.ts
@@ -24,7 +24,7 @@
 import {Plugin} from './gr-public-js-api';
 import {getBaseUrl} from '../../../utils/url-util';
 import {getPluginEndpoints} from './gr-plugin-endpoints';
-import {PluginApi} from '../../plugins/gr-plugin-types';
+import {PluginApi} from '../../../api/plugin';
 import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
 import {hasOwnProperty} from '../../../utils/common-util';
 import {ShowAlertEventDetail} from '../../../types/events';
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api.ts
index e640ba1..cd35d4e 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api.ts
@@ -14,10 +14,10 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import {ErrorCallback} from '../../../services/gr-rest-api/gr-rest-api';
 import {HttpMethod} from '../../../constants/constants';
 import {RequestPayload} from '../../../types/common';
 import {appContext} from '../../../services/app-context';
+import {ErrorCallback, RestPluginApi} from '../../../api/rest';
 
 async function getErrorMessage(response: Response): Promise<string> {
   const text = await response.text();
@@ -33,7 +33,7 @@
   }
 }
 
-export class GrPluginRestApi {
+export class GrPluginRestApi implements RestPluginApi {
   private readonly restApi = appContext.restApiService;
 
   constructor(private readonly prefix = '') {}
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.ts
index f21ddc6..45ffdcd9 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.ts
@@ -32,20 +32,28 @@
 import {GrStylesApi} from '../../plugins/gr-styles-api/gr-styles-api';
 import {getPluginEndpoints} from './gr-plugin-endpoints';
 
-import {PRELOADED_PROTOCOL, getPluginNameFromUrl, send} from './gr-api-utils';
+import {getPluginNameFromUrl, PRELOADED_PROTOCOL, send} from './gr-api-utils';
 import {GrReportingJsApi} from './gr-reporting-js-api';
-import {
-  EventType,
-  HookApi,
-  PluginApi,
-  RegisterOptions,
-  TargetElement,
-} from '../../plugins/gr-plugin-types';
+import {EventType, PluginApi, TargetElement} from '../../../api/plugin';
 import {RequestPayload} from '../../../types/common';
 import {HttpMethod} from '../../../constants/constants';
 import {GrChangeActions} from '../../change/gr-change-actions/gr-change-actions';
 import {GrChecksApi} from '../../plugins/gr-checks-api/gr-checks-api';
 import {appContext} from '../../../services/app-context';
+import {AdminPluginApi} from '../../../api/admin';
+import {AnnotationPluginApi} from '../../../api/annotation';
+import {StylesPluginApi} from '../../../api/styles';
+import {ThemePluginApi} from '../../../api/theme';
+import {EventHelperPluginApi} from '../../../api/event-helper';
+import {PopupPluginApi} from '../../../api/popup';
+import {SettingsPluginApi} from '../../../api/settings';
+import {ReportingPluginApi} from '../../../api/reporting';
+import {ChangeActionsPluginApi} from '../../../api/change-actions';
+import {ChangeMetadataPluginApi} from '../../../api/change-metadata';
+import {RepoPluginApi} from '../../../api/repo';
+import {ChangeReplyPluginApi} from '../../../api/change-reply';
+import {RestPluginApi} from '../../../api/rest';
+import {HookApi, RegisterOptions} from '../../../api/hook';
 
 /**
  * Plugin-provided custom components can affect content in extension
@@ -232,11 +240,11 @@
       });
   }
 
-  annotationApi() {
+  annotationApi(): AnnotationPluginApi {
     return new GrAnnotationActionsInterface(this);
   }
 
-  changeActions() {
+  changeActions(): ChangeActionsPluginApi {
     return new GrChangeActionsInterface(
       this,
       (this.jsApi.getElement(
@@ -245,7 +253,7 @@
     );
   }
 
-  changeReply() {
+  changeReply(): ChangeReplyPluginApi {
     return new GrChangeReplyInterface(this, this.jsApi);
   }
 
@@ -253,42 +261,35 @@
     return new GrChecksApi(this);
   }
 
-  reporting() {
+  reporting(): ReportingPluginApi {
     return new GrReportingJsApi(this);
   }
 
-  theme() {
+  theme(): ThemePluginApi {
     return new GrThemeApi(this);
   }
 
-  project() {
+  project(): RepoPluginApi {
     return new GrRepoApi(this);
   }
 
-  changeMetadata() {
+  changeMetadata(): ChangeMetadataPluginApi {
     return new GrChangeMetadataApi(this);
   }
 
-  admin() {
+  admin(): AdminPluginApi {
     return new GrAdminApi(this);
   }
 
-  settings() {
+  settings(): SettingsPluginApi {
     return new GrSettingsApi(this);
   }
 
-  styles() {
+  styles(): StylesPluginApi {
     return new GrStylesApi();
   }
 
-  /**
-   * To make REST requests for plugin-provided endpoints, use
-   *
-   * @example
-   * const pluginRestApi = plugin.restApi(plugin.url());
-   * @param prefix url for subsequent .get(), .post() etc requests.
-   */
-  restApi(prefix?: string) {
+  restApi(prefix?: string): RestPluginApi {
     return new GrPluginRestApi(prefix);
   }
 
@@ -296,15 +297,15 @@
     return new GrAttributeHelper(element);
   }
 
-  eventHelper(element: HTMLElement) {
+  eventHelper(element: HTMLElement): EventHelperPluginApi {
     return new GrEventHelper(element);
   }
 
-  popup(): Promise<GrPopupInterface>;
+  popup(): Promise<PopupPluginApi>;
 
-  popup(moduleName: string): Promise<GrPopupInterface>;
+  popup(moduleName: string): Promise<PopupPluginApi>;
 
-  popup(moduleName?: string): Promise<GrPopupInterface | null> {
+  popup(moduleName?: string): Promise<PopupPluginApi | null> {
     if (moduleName !== undefined && typeof moduleName !== 'string') {
       console.error('.popup(element) deprecated, use .popup(moduleName)!');
       return Promise.resolve(null);
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-reporting-js-api.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-reporting-js-api.ts
index 87b320c4..d4b51a8 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-reporting-js-api.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-reporting-js-api.ts
@@ -16,26 +16,26 @@
  */
 
 import {appContext} from '../../../services/app-context';
-import {EventDetails} from '../../../services/gr-reporting/gr-reporting';
-import {PluginApi} from '../../plugins/gr-plugin-types';
+import {PluginApi} from '../../../api/plugin';
+import {EventDetails, ReportingPluginApi} from '../../../api/reporting';
 
 /**
  * Defines all methods that will be exported to plugin from reporting service.
  */
-export class GrReportingJsApi {
+export class GrReportingJsApi implements ReportingPluginApi {
   private readonly reporting = appContext.reportingService;
 
   constructor(private readonly plugin: PluginApi) {}
 
   reportInteraction(eventName: string, details?: EventDetails) {
-    return this.reporting.reportInteraction(
+    this.reporting.reportInteraction(
       `${this.plugin.getPluginName()}-${eventName}`,
       details
     );
   }
 
   reportLifeCycle(eventName: string, details?: EventDetails) {
-    return this.reporting.reportLifeCycle(
+    this.reporting.reportLifeCycle(
       `${this.plugin.getPluginName()}-${eventName}`,
       details
     );
diff --git a/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader.ts b/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader.ts
index 329cc7e..655acde 100644
--- a/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader.ts
+++ b/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader.ts
@@ -15,7 +15,7 @@
  * limitations under the License.
  */
 import '../gr-js-api-interface/gr-js-api-interface';
-import {EventType} from '../../plugins/gr-plugin-types';
+import {EventType} from '../../../api/plugin';
 import {HighlightJS} from '../../../types/types';
 import {appContext} from '../../../services/app-context';
 
diff --git a/polygerrit-ui/app/elements/shared/gr-linked-text/link-text-parser.ts b/polygerrit-ui/app/elements/shared/gr-linked-text/link-text-parser.ts
index 9066911..36f518b 100644
--- a/polygerrit-ui/app/elements/shared/gr-linked-text/link-text-parser.ts
+++ b/polygerrit-ui/app/elements/shared/gr-linked-text/link-text-parser.ts
@@ -350,27 +350,27 @@
     // The outputArray is used to store all of the matches found for all
     // patterns.
     const outputArray: CommentLinkItem[] = [];
-    for (const p in config) {
+    for (const [configName, linkInfo] of Object.entries(config)) {
       // TODO(TS): it seems, the following line can be rewritten as:
       // if(enabled === false || enabled === 0 || enabled === '')
       // Should be double-checked before update
       // eslint-disable-next-line eqeqeq
-      if (config[p].enabled != null && config[p].enabled == false) {
+      if (linkInfo.enabled != null && linkInfo.enabled == false) {
         continue;
       }
       // PolyGerrit doesn't use hash-based navigation like the GWT UI.
       // Account for this.
-      const html = config[p].html;
-      const link = config[p].link;
+      const html = linkInfo.html;
+      const link = linkInfo.link;
       if (html) {
-        config[p].html = html.replace(/<a href="#\//g, '<a href="/');
+        linkInfo.html = html.replace(/<a href="#\//g, '<a href="/');
       } else if (link) {
         if (link[0] === '#') {
-          config[p].link = link.substr(1);
+          linkInfo.link = link.substr(1);
         }
       }
 
-      const pattern = new RegExp(config[p].match, 'g');
+      const pattern = new RegExp(linkInfo.match, 'g');
 
       let match;
       let textToCheck = text;
@@ -382,10 +382,10 @@
           pattern,
           // Either html or link has a value. Otherwise an exception is thrown
           // in the code below.
-          (config[p].html || config[p].link)!
+          (linkInfo.html || linkInfo.link)!
         );
 
-        if (config[p].html) {
+        if (linkInfo.html) {
           let i;
           // Skip portion of replacement string that is equal to original to
           // allow overlapping patterns.
@@ -402,7 +402,7 @@
             match[0].length - i,
             outputArray
           );
-        } else if (config[p].link) {
+        } else if (linkInfo.link) {
           this.addLink(
             match[0],
             result,
@@ -413,7 +413,7 @@
         } else {
           throw Error(
             'linkconfig entry ' +
-              p +
+              configName +
               ' doesn’t contain a link or html attribute.'
           );
         }
diff --git a/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view.ts b/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view.ts
index 97b45ac..bf532ef 100644
--- a/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view.ts
+++ b/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view.ts
@@ -35,6 +35,8 @@
   }
 }
 
+const DEBOUNCER_RELOAD = 'reload';
+
 @customElement('gr-list-view')
 class GrListView extends GestureEventListeners(
   LegacyElementMixin(PolymerElement)
@@ -67,7 +69,7 @@
   /** @override */
   detached() {
     super.detached();
-    this.cancelDebouncer('reload');
+    this.cancelDebouncer(DEBOUNCER_RELOAD);
   }
 
   _filterChanged(newFilter?: string, oldFilter?: string) {
@@ -81,7 +83,7 @@
 
   _debounceReload(filter?: string) {
     this.debounce(
-      'reload',
+      DEBOUNCER_RELOAD,
       () => {
         if (this.path) {
           if (filter) {
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.ts b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.ts
index 355691f..6e17c75 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.ts
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.ts
@@ -139,7 +139,6 @@
 } from '../../../types/diff';
 import {
   CancelConditionCallback,
-  ErrorCallback,
   GetDiffCommentsOutput,
   GetDiffRobotCommentsOutput,
   RestApiService,
@@ -155,6 +154,7 @@
 } from '../../../constants/constants';
 import {firePageError, fireServerError} from '../../../utils/event-util';
 import {ParsedChangeInfo} from '../../../types/types';
+import {ErrorCallback} from '../../../api/rest';
 
 const MAX_PROJECT_RESULTS = 25;
 // This value is somewhat arbitrary and not based on research or calculations.
@@ -259,27 +259,6 @@
 type SendChangeRequest = SendRawChangeRequest | SendJSONChangeRequest;
 
 export function _testOnlyResetGrRestApiSharedObjects() {
-  // TODO(TS): The commented code below didn't do anything.
-  // It is impossible to reject an existing promise. Should be rewritten in a
-  // different way
-  // const fetchPromisesCacheData = fetchPromisesCache.testOnlyGetData();
-  // for (const key in fetchPromisesCacheData) {
-  //   if (hasOwnProperty(fetchPromisesCacheData, key)) {
-  //     // reject already fulfilled promise does nothing
-  //     fetchPromisesCacheData[key]!.reject();
-  //   }
-  // }
-  //
-  // for (const key in pendingRequest) {
-  //   if (!hasOwnProperty(pendingRequest, key)) {
-  //     continue;
-  //   }
-  //   for (const req of pendingRequest[key]) {
-  //     // reject already fulfilled promise does nothing
-  //     req.reject();
-  //   }
-  // }
-
   siteBasedCache = new SiteBasedCache();
   fetchPromisesCache = new FetchPromisesCache();
   pendingRequest = {};
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper.ts b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper.ts
index d6b6645..fa2a28e 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper.ts
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper.ts
@@ -15,15 +15,11 @@
  * limitations under the License.
  */
 import {getBaseUrl} from '../../../../utils/url-util';
-import {
-  CancelConditionCallback,
-  ErrorCallback,
-} from '../../../../services/gr-rest-api/gr-rest-api';
+import {CancelConditionCallback} from '../../../../services/gr-rest-api/gr-rest-api';
 import {
   AuthRequestInit,
   AuthService,
 } from '../../../../services/gr-auth/gr-auth';
-import {hasOwnProperty} from '../../../../utils/common-util';
 import {
   AccountDetailInfo,
   EmailInfo,
@@ -34,6 +30,7 @@
 import {RpcLogEventDetail} from '../../../../types/events';
 import {fireNetworkError, fireServerError} from '../../../../utils/event-util';
 import {FetchRequest} from '../../../../types/types';
+import {ErrorCallback} from '../../../../api/rest';
 
 export const JSON_PREFIX = ")]}'";
 
@@ -379,11 +376,7 @@
     }
 
     const params: Array<string | number | boolean> = [];
-    for (const p in fetchParams) {
-      if (!hasOwnProperty(fetchParams, p)) {
-        continue;
-      }
-      const paramValue = fetchParams[p];
+    for (const [p, paramValue] of Object.entries(fetchParams)) {
       // TODO(TS): Replace == null with === and check for null and undefined
       // eslint-disable-next-line eqeqeq
       if (paramValue == null) {
@@ -482,11 +475,8 @@
       if (!options.headers) {
         options.headers = new Headers();
       }
-      for (const header in req.headers) {
-        if (!hasOwnProperty(req.headers, header)) {
-          continue;
-        }
-        options.headers.set(header, req.headers[header]);
+      for (const [name, value] of Object.entries(req.headers)) {
+        options.headers.set(name, value);
       }
     }
     const url = req.url.startsWith('http') ? req.url : getBaseUrl() + req.url;
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser.ts b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser.ts
index 809b78b..1a1062c 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser.ts
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser.ts
@@ -25,7 +25,6 @@
   ReviewerUpdateInfo,
   Timestamp,
 } from '../../../types/common';
-import {hasOwnProperty} from '../../../utils/common-util';
 import {accountKey} from '../../../utils/account-util';
 import {
   FormattedReviewerUpdateInfo,
@@ -122,12 +121,10 @@
    */
   private _completeBatch(batch: ParserBatch) {
     const items = [];
-    for (const accountId in this._updateItems) {
-      if (!hasOwnProperty(this._updateItems, accountId)) continue;
-      const updateItem = this._updateItems[accountId];
-      if (this._lastState[accountId] !== updateItem.state) {
-        this._lastState[accountId] = updateItem.state;
-        items.push(updateItem);
+    for (const [accountId, item] of Object.entries(this._updateItems ?? {})) {
+      if (this._lastState[accountId] !== item.state) {
+        this._lastState[accountId] = item.state;
+        items.push(item);
       }
     }
     if (items.length) {
@@ -233,15 +230,10 @@
     const reviewerUpdates = (this.result
       .reviewer_updates as unknown) as ParserBatchWithNonEmptyUpdates[];
     for (const update of reviewerUpdates) {
-      const grouppedReviewers = this._groupUpdatesByMessage(update.updates);
+      const groupedReviewers = this._groupUpdatesByMessage(update.updates);
       const newUpdates: {message: string; reviewers: AccountInfo[]}[] = [];
-      for (const message in grouppedReviewers) {
-        if (hasOwnProperty(grouppedReviewers, message)) {
-          newUpdates.push({
-            message,
-            reviewers: grouppedReviewers[message],
-          });
-        }
+      for (const [message, reviewers] of Object.entries(groupedReviewers)) {
+        newUpdates.push({message, reviewers});
       }
       ((update as unknown) as FormattedReviewerUpdateInfo).updates = newUpdates;
     }
diff --git a/polygerrit-ui/app/node_modules_licenses/BUILD b/polygerrit-ui/app/node_modules_licenses/BUILD
index ad5c676..44b9811 100644
--- a/polygerrit-ui/app/node_modules_licenses/BUILD
+++ b/polygerrit-ui/app/node_modules_licenses/BUILD
@@ -15,7 +15,7 @@
     tsconfig = "tsconfig.json",
     deps = [
         "//tools/node_tools/node_modules_licenses:licenses-map",
-        "@tools_npm//:node_modules",
+        "@tools_npm//@bazel/typescript",
         "@tools_npm//@types/node",
     ],
 )
diff --git a/polygerrit-ui/app/services/checks/checks-util.ts b/polygerrit-ui/app/services/checks/checks-util.ts
index 86b9b47..30b82b6 100644
--- a/polygerrit-ui/app/services/checks/checks-util.ts
+++ b/polygerrit-ui/app/services/checks/checks-util.ts
@@ -14,7 +14,7 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import {Category, CheckRun, RunStatus} from '../../api/checks';
+import {Action, Category, CheckRun, RunStatus} from '../../api/checks';
 import {assertNever} from '../../utils/common-util';
 
 export function worstCategory(run: CheckRun) {
@@ -37,6 +37,42 @@
   }
 }
 
+enum PRIMARY_STATUS_ACTIONS {
+  RERUN = 'rerun',
+  RUN = 'run',
+  CANCEL = 'cancel',
+}
+
+export function toCanonicalAction(action: Action, status: RunStatus) {
+  let name = action.name.toLowerCase();
+  if (status === RunStatus.COMPLETED && (name === 'run' || name === 're-run')) {
+    name = PRIMARY_STATUS_ACTIONS.RERUN;
+  }
+  if (status === RunStatus.RUNNING && name === 'stop') {
+    name = PRIMARY_STATUS_ACTIONS.CANCEL;
+  }
+  return {...action, name};
+}
+
+export function primaryActionName(status: RunStatus) {
+  switch (status) {
+    case RunStatus.COMPLETED:
+      return PRIMARY_STATUS_ACTIONS.RERUN;
+    case RunStatus.RUNNABLE:
+      return PRIMARY_STATUS_ACTIONS.RUN;
+    case RunStatus.RUNNING:
+      return PRIMARY_STATUS_ACTIONS.CANCEL;
+    default:
+      assertNever(status, `Unsupported status: ${status}`);
+  }
+}
+
+export function primaryRunAction(run: CheckRun): Action | undefined {
+  return (run.actions ?? [])
+    .map(action => toCanonicalAction(action, run.status))
+    .filter(action => action.name === primaryActionName(run.status))[0];
+}
+
 export function iconForRun(run: CheckRun) {
   const category = worstCategory(run);
   return category ? iconForCategory(category) : iconForStatus(run.status);
diff --git a/polygerrit-ui/app/services/flags/flags.ts b/polygerrit-ui/app/services/flags/flags.ts
index bf62d0d0..f96afaa 100644
--- a/polygerrit-ui/app/services/flags/flags.ts
+++ b/polygerrit-ui/app/services/flags/flags.ts
@@ -24,11 +24,11 @@
  * @desc Experiment ids used in Gerrit.
  */
 export enum KnownExperimentId {
-  NEW_CONTEXT_CONTROLS = 'UiFeature__new_context_controls',
   // Note that this flag is not supposed to be used by Gerrit itself, but can
   // be used by plugins. The new Checks UI will show up, if a plugin registers
   // with the new Checks plugin API.
   CI_REBOOT_CHECKS = 'UiFeature__ci_reboot_checks',
   NEW_CHANGE_SUMMARY_UI = 'UiFeature__new_change_summary_ui',
   PORTING_COMMENTS = 'UiFeature__porting_comments',
+  NEW_IMAGE_DIFF_UI = 'UiFeature__new_image_diff_ui',
 }
diff --git a/polygerrit-ui/app/services/gr-reporting/gr-reporting.ts b/polygerrit-ui/app/services/gr-reporting/gr-reporting.ts
index 7035f26..4ca983a 100644
--- a/polygerrit-ui/app/services/gr-reporting/gr-reporting.ts
+++ b/polygerrit-ui/app/services/gr-reporting/gr-reporting.ts
@@ -16,13 +16,10 @@
  */
 
 import {NumericChangeId} from '../../types/common';
+import {EventDetails} from '../../api/reporting';
 
 export type EventValue = string | number | {error?: Error};
 
-// TODO(dmfilippov): TS-fix-any use more specific type instead if possible
-// eslint-disable-next-line @typescript-eslint/no-explicit-any
-export type EventDetails = any;
-
 export interface Timer {
   reset(): this;
   end(): this;
diff --git a/polygerrit-ui/app/services/gr-reporting/gr-reporting_impl.ts b/polygerrit-ui/app/services/gr-reporting/gr-reporting_impl.ts
index 657a3f4..f80cb75 100644
--- a/polygerrit-ui/app/services/gr-reporting/gr-reporting_impl.ts
+++ b/polygerrit-ui/app/services/gr-reporting/gr-reporting_impl.ts
@@ -16,14 +16,10 @@
  */
 import {AppContext} from '../app-context';
 import {FlagsService} from '../flags/flags';
-import {
-  EventDetails,
-  EventValue,
-  ReportingService,
-  Timer,
-} from './gr-reporting';
+import {EventValue, ReportingService, Timer} from './gr-reporting';
 import {hasOwnProperty} from '../../utils/common-util';
 import {NumericChangeId} from '../../types/common';
+import {EventDetails} from '../../api/reporting';
 
 // Latency reporting constants.
 
diff --git a/polygerrit-ui/app/services/gr-reporting/gr-reporting_mock.ts b/polygerrit-ui/app/services/gr-reporting/gr-reporting_mock.ts
index a6472d1..484ce45 100644
--- a/polygerrit-ui/app/services/gr-reporting/gr-reporting_mock.ts
+++ b/polygerrit-ui/app/services/gr-reporting/gr-reporting_mock.ts
@@ -15,6 +15,7 @@
  * limitations under the License.
  */
 import {ReportingService, Timer} from './gr-reporting';
+import {EventDetails} from '../../api/reporting';
 
 export class MockTimer implements Timer {
   end(): this {
@@ -30,6 +31,10 @@
   }
 }
 
+const log = function (msg: string) {
+  console.info(`ReportingMock.${msg}`);
+};
+
 export const grReportingMock: ReportingService = {
   appStarted: () => {},
   beforeLocationChanged: () => {},
@@ -43,17 +48,29 @@
   getTimer: () => {
     return new MockTimer();
   },
-  locationChanged: () => {},
-  onVisibilityChange: () => {},
+  locationChanged: (page: string) => {
+    log(`locationChanged: ${page}`);
+  },
+  onVisibilityChange: () => {
+    log('onVisibilityChange');
+  },
   pluginLoaded: () => {},
   pluginsLoaded: () => {},
   recordDraftInteraction: () => {},
   reporter: () => {},
-  reportErrorDialog: () => {},
-  error: () => {},
-  reportExecution: () => {},
+  reportErrorDialog: (message: string) => {
+    log(`reportErrorDialog: ${message}`);
+  },
+  error: () => {
+    log('error');
+  },
+  reportExecution: (id: string, details: EventDetails) => {
+    log(`reportExecution '${id}': ${JSON.stringify(details)}`);
+  },
   reportExtension: () => {},
-  reportInteraction: () => {},
+  reportInteraction: (eventName: string, details?: EventDetails) => {
+    log(`reportInteraction '${eventName}': ${JSON.stringify(details)}`);
+  },
   reportLifeCycle: () => {},
   reportRpcTiming: () => {},
   setRepoName: () => {},
diff --git a/polygerrit-ui/app/services/gr-rest-api/gr-rest-api.ts b/polygerrit-ui/app/services/gr-rest-api/gr-rest-api.ts
index 6fec5f0..4742e65 100644
--- a/polygerrit-ui/app/services/gr-rest-api/gr-rest-api.ts
+++ b/polygerrit-ui/app/services/gr-rest-api/gr-rest-api.ts
@@ -17,88 +17,88 @@
 
 import {HttpMethod} from '../../constants/constants';
 import {
+  AccountCapabilityInfo,
   AccountDetailInfo,
   AccountExternalIdInfo,
+  AccountId,
   AccountInfo,
-  NumericChangeId,
-  ServerInfo,
-  ProjectInfo,
-  AccountCapabilityInfo,
-  SuggestedReviewerInfo,
-  GroupNameToGroupInfoMap,
-  ParsedJSON,
-  PatchSetNum,
-  RequestPayload,
-  PreferencesInput,
-  EditPreferencesInfo,
-  DiffPreferenceInput,
-  SshKeyInfo,
-  RepoName,
-  BranchName,
-  BranchInput,
-  TagInput,
-  GpgKeysInput,
-  GpgKeyId,
-  GpgKeyInfo,
-  PreferencesInfo,
-  EmailInfo,
-  ProjectAccessInfo,
-  CapabilityInfoMap,
-  ProjectAccessInput,
-  ChangeInfo,
-  ProjectInfoWithName,
-  GroupId,
-  GroupInfo,
-  GroupOptionsInput,
+  ActionNameToActionInfoMap,
+  Base64FileContent,
+  BlameInfo,
   BranchInfo,
-  ConfigInfo,
-  ReviewInput,
-  EditInfo,
+  BranchInput,
+  BranchName,
+  CapabilityInfoMap,
   ChangeId,
-  DashboardInfo,
-  ProjectAccessInfoMap,
-  IncludedInInfo,
-  RobotCommentInfo,
+  ChangeInfo,
+  ChangeMessageId,
   CommentInfo,
-  PathToCommentsInfoMap,
-  PathToRobotCommentsInfoMap,
   CommentInput,
-  GroupInput,
-  PluginInfo,
-  DocResult,
+  CommitInfo,
+  ConfigInfo,
+  ConfigInput,
   ContributorAgreementInfo,
   ContributorAgreementInput,
-  Password,
-  ProjectWatchInfo,
-  NameToProjectInfoMap,
-  ProjectInput,
-  AccountId,
-  ChangeMessageId,
-  GroupAuditEventInfo,
-  EncodedGroupId,
-  Base64FileContent,
-  UrlEncodedCommentId,
-  TagInfo,
-  GitRef,
-  ConfigInput,
-  RelatedChangesInfo,
-  SubmittedTogetherInfo,
-  EmailAddress,
-  FixId,
-  FilePathToDiffInfoMap,
-  BlameInfo,
-  PatchRange,
-  ImagesForDiff,
-  ActionNameToActionInfoMap,
-  RevisionId,
-  GroupName,
   DashboardId,
-  HashtagsInput,
-  Hashtag,
+  DashboardInfo,
+  DiffPreferenceInput,
+  DocResult,
+  EditInfo,
+  EditPreferencesInfo,
+  EmailAddress,
+  EmailInfo,
+  EncodedGroupId,
   FileNameToFileInfoMap,
-  TopMenuEntryInfo,
+  FilePathToDiffInfoMap,
+  FixId,
+  GitRef,
+  GpgKeyId,
+  GpgKeyInfo,
+  GpgKeysInput,
+  GroupAuditEventInfo,
+  GroupId,
+  GroupInfo,
+  GroupInput,
+  GroupName,
+  GroupNameToGroupInfoMap,
+  GroupOptionsInput,
+  Hashtag,
+  HashtagsInput,
+  ImagesForDiff,
+  IncludedInInfo,
   MergeableInfo,
-  CommitInfo,
+  NameToProjectInfoMap,
+  NumericChangeId,
+  ParsedJSON,
+  Password,
+  PatchRange,
+  PatchSetNum,
+  PathToCommentsInfoMap,
+  PathToRobotCommentsInfoMap,
+  PluginInfo,
+  PreferencesInfo,
+  PreferencesInput,
+  ProjectAccessInfo,
+  ProjectAccessInfoMap,
+  ProjectAccessInput,
+  ProjectInfo,
+  ProjectInfoWithName,
+  ProjectInput,
+  ProjectWatchInfo,
+  RelatedChangesInfo,
+  RepoName,
+  RequestPayload,
+  ReviewInput,
+  RevisionId,
+  RobotCommentInfo,
+  ServerInfo,
+  SshKeyInfo,
+  SubmittedTogetherInfo,
+  SuggestedReviewerInfo,
+  TagInfo,
+  TagInput,
+  TopMenuEntryInfo,
+  UrlEncodedCommentId,
 } from '../../types/common';
 import {
   DiffInfo,
@@ -106,8 +106,8 @@
   IgnoreWhitespaceType,
 } from '../../types/diff';
 import {ParsedChangeInfo} from '../../types/types';
+import {ErrorCallback} from '../../api/rest';
 
-export type ErrorCallback = (response?: Response | null, err?: Error) => void;
 export type CancelConditionCallback = () => boolean;
 
 // TODO(TS): remove when GrReplyDialog converted to typescript
@@ -118,21 +118,6 @@
   setPluginMessage(message: string): void;
 }
 
-// Copied from gr-change-actions.js
-export enum ActionType {
-  CHANGE = 'change',
-  REVISION = 'revision',
-}
-
-// Copied from gr-change-actions.js
-export enum ActionPriority {
-  CHANGE = 2,
-  DEFAULT = 0,
-  PRIMARY = 3,
-  REVIEW = -3,
-  REVISION = 1,
-}
-
 export interface GetDiffCommentsOutput {
   baseComments: CommentInfo[];
   comments: CommentInfo[];
diff --git a/polygerrit-ui/app/test/common-test-setup.ts b/polygerrit-ui/app/test/common-test-setup.ts
index beba88e..0f394be 100644
--- a/polygerrit-ui/app/test/common-test-setup.ts
+++ b/polygerrit-ui/app/test/common-test-setup.ts
@@ -43,7 +43,6 @@
   _testOnly_defaultResinReportHandler,
   installPolymerResin,
 } from '../scripts/polymer-resin-install';
-import {hasOwnProperty} from '../utils/common-util';
 
 declare global {
   interface Window {
@@ -132,12 +131,9 @@
   // This method is inspired by web-component-tester method
   const proto = document.createElement(tagName).constructor
     .prototype as HTMLElementTagNameMap[T];
-  let key: keyof HTMLElementTagNameMap[T];
   const stubs: SinonSpy[] = [];
-  for (key in implementation) {
-    if (hasOwnProperty(implementation, key)) {
-      stubs.push(sinon.stub(proto, key).callsFake(implementation[key]));
-    }
+  for (const [key, value] of Object.entries(implementation)) {
+    stubs.push(sinon.stub(proto, key).callsFake(value));
   }
   registerTestCleanup(() => {
     stubs.forEach(stub => {
@@ -199,5 +195,10 @@
   checkGlobalSpace();
   removeIronOverlayBackdropStyleEl();
   // Clean Polymer debouncer queue, so next tests will not be affected.
+  // WARNING! This will most likely not do what you expect. `flushDebouncers()`
+  // will only flush debouncers that were added using `enqueueDebouncer()`. So
+  // this will not affect "normal" debouncers that were added using
+  // `this.debounce()`. For those please be careful and cancel them using
+  // `this.cancelDebouncer()` in the `detached()` lifecycle hook.
   flushDebouncers();
 });
diff --git a/polygerrit-ui/app/utils/admin-nav-util.ts b/polygerrit-ui/app/utils/admin-nav-util.ts
index b144313..275f9e6 100644
--- a/polygerrit-ui/app/utils/admin-nav-util.ts
+++ b/polygerrit-ui/app/utils/admin-nav-util.ts
@@ -25,9 +25,9 @@
   AccountDetailInfo,
   AccountCapabilityInfo,
 } from '../types/common';
-import {MenuLink} from '../elements/plugins/gr-admin-api/gr-admin-api';
 import {hasOwnProperty} from './common-util';
 import {GerritView} from '../services/router/router-model';
+import {MenuLink} from '../api/admin';
 
 const ADMIN_LINKS: NavLink[] = [
   {
diff --git a/polygerrit-ui/app/utils/comment-util.ts b/polygerrit-ui/app/utils/comment-util.ts
index d7e7595..0c4a3c6 100644
--- a/polygerrit-ui/app/utils/comment-util.ts
+++ b/polygerrit-ui/app/utils/comment-util.ts
@@ -177,6 +177,10 @@
   return thread?.comments?.[0];
 }
 
+export function countComments(thread?: CommentThread) {
+  return thread?.comments?.length ?? 0;
+}
+
 export function isUnresolved(thread?: CommentThread): boolean {
   return !isResolved(thread);
 }
@@ -189,6 +193,14 @@
   return isDraft(getLastComment(thread));
 }
 
+export function isRobotThread(thread?: CommentThread): boolean {
+  return isRobot(getFirstComment(thread));
+}
+
+export function hasHumanReply(thread?: CommentThread): boolean {
+  return countComments(thread) > 1 && !isRobot(getLastComment(thread));
+}
+
 /**
  * Whether the given comment should be included in the base side of the
  * given patch range.
diff --git a/polygerrit-ui/app/utils/dom-util.ts b/polygerrit-ui/app/utils/dom-util.ts
index c9a7d6b..aa83173 100644
--- a/polygerrit-ui/app/utils/dom-util.ts
+++ b/polygerrit-ui/app/utils/dom-util.ts
@@ -153,6 +153,12 @@
   return [...results];
 }
 
+export function windowLocationReload() {
+  const e = new Error();
+  console.info(`Calling window.location.realod(): ${e.stack}`);
+  window.location.reload();
+}
+
 /**
  * Retrieves the dom path of the current event.
  *
@@ -244,3 +250,11 @@
   }
   return root.activeElement as HTMLElement;
 }
+
+// Whether the browser is Safari. Used for polyfilling unique browser behavior.
+export function isSafari() {
+  return (
+    /^((?!chrome|android).)*safari/i.test(navigator.userAgent) ||
+    (/iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream)
+  );
+}
diff --git a/tools/node_tools/node_modules_licenses/BUILD b/tools/node_tools/node_modules_licenses/BUILD
index 051cfe8..b88ec24 100644
--- a/tools/node_tools/node_modules_licenses/BUILD
+++ b/tools/node_tools/node_modules_licenses/BUILD
@@ -10,7 +10,7 @@
     compiler = "//tools/node_tools:tsc_wrapped-bin",
     tsconfig = "tsconfig.json",
     deps = [
-        "@tools_npm//:node_modules",
+        "@tools_npm//@bazel/typescript",
         "@tools_npm//@types/node",
     ],
 )
diff --git a/tools/node_tools/package.json b/tools/node_tools/package.json
index ba93c94..9acbd07 100644
--- a/tools/node_tools/package.json
+++ b/tools/node_tools/package.json
@@ -3,8 +3,8 @@
   "description": "Gerrit Build Tools",
   "browser": false,
   "dependencies": {
-    "@bazel/rollup": "^3.1.0",
-    "@bazel/typescript": "^3.1.0",
+    "@bazel/rollup": "^3.2.0",
+    "@bazel/typescript": "^3.2.0",
     "@types/node": "^10.17.12",
     "@types/parse5": "^4.0.0",
     "@types/parse5-html-rewriting-stream": "^5.1.2",
diff --git a/tools/node_tools/yarn.lock b/tools/node_tools/yarn.lock
index 28d787e..45a0c89 100644
--- a/tools/node_tools/yarn.lock
+++ b/tools/node_tools/yarn.lock
@@ -492,15 +492,15 @@
     lodash "^4.17.13"
     to-fast-properties "^2.0.0"
 
-"@bazel/rollup@^3.1.0":
-  version "3.1.0"
-  resolved "https://registry.yarnpkg.com/@bazel/rollup/-/rollup-3.1.0.tgz#36346f052b2ce3c1e31e5ebb05ed80464548eb00"
-  integrity sha512-lmgPhlR1VsJRsSE83Jlv+WT26Lso0/0FqXknlVuOmvCWFwSUKlriws729fqJZsvV5O2muAgJKuQl/zk+gqGCug==
+"@bazel/rollup@^3.2.0":
+  version "3.2.0"
+  resolved "https://registry.yarnpkg.com/@bazel/rollup/-/rollup-3.2.0.tgz#4241a5767e12e57b01279a539af2537c2d01924a"
+  integrity sha512-Wkw6L+hor/+FzpDswri7IlWAbKyShnUZRx59fG06+qqVhpNaS3V3lnZqVytMlLLT4oSP8YSIzoXC5GkXgLI2/Q==
 
-"@bazel/typescript@^3.1.0":
-  version "3.1.0"
-  resolved "https://registry.yarnpkg.com/@bazel/typescript/-/typescript-3.1.0.tgz#a07999ad7956b8c624604a521e653570bba32025"
-  integrity sha512-sEWuvkUGIDeRhjLENHtJyop7wu4UqKN8h/nSgUwc5gkpWXQiT2wGH5jKVxBqODOBHB+IInEMtAjyRmCT+HbSHA==
+"@bazel/typescript@^3.2.0":
+  version "3.2.0"
+  resolved "https://registry.yarnpkg.com/@bazel/typescript/-/typescript-3.2.0.tgz#299bd173fe04f98407ab9be4f654662c1c28470e"
+  integrity sha512-RKdy9ThbcUAqZR3AJK7AR/nxbJqdHi7pPayIGUSMIpxVkeTxVRQpf1aGe2H02HdZ9fR/uk1xXhO/Ff9TLvTgHQ==
   dependencies:
     protobufjs "6.8.8"
     semver "5.6.0"
diff --git a/tools/nongoogle.bzl b/tools/nongoogle.bzl
index 0abaa54..3f5ef20 100644
--- a/tools/nongoogle.bzl
+++ b/tools/nongoogle.bzl
@@ -1,8 +1,8 @@
 load("//tools/bzl:maven_jar.bzl", "maven_jar")
 
-GUAVA_VERSION = "29.0-jre"
+GUAVA_VERSION = "30.1-jre"
 
-GUAVA_BIN_SHA1 = "801142b4c3d0f0770dd29abea50906cacfddd447"
+GUAVA_BIN_SHA1 = "00d0c3ce2311c9e36e73228da25a6e99b2ab826f"
 
 GUAVA_DOC_URL = "https://google.github.io/guava/releases/" + GUAVA_VERSION + "/api/docs/"
 
diff --git a/yarn.lock b/yarn.lock
index 170b18a..a424d79 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -485,20 +485,20 @@
     lodash "^4.17.11"
     to-fast-properties "^2.0.0"
 
-"@bazel/rollup@^3.1.0":
-  version "3.1.0"
-  resolved "https://registry.yarnpkg.com/@bazel/rollup/-/rollup-3.1.0.tgz#36346f052b2ce3c1e31e5ebb05ed80464548eb00"
-  integrity sha512-lmgPhlR1VsJRsSE83Jlv+WT26Lso0/0FqXknlVuOmvCWFwSUKlriws729fqJZsvV5O2muAgJKuQl/zk+gqGCug==
+"@bazel/rollup@^3.2.0":
+  version "3.2.0"
+  resolved "https://registry.yarnpkg.com/@bazel/rollup/-/rollup-3.2.0.tgz#4241a5767e12e57b01279a539af2537c2d01924a"
+  integrity sha512-Wkw6L+hor/+FzpDswri7IlWAbKyShnUZRx59fG06+qqVhpNaS3V3lnZqVytMlLLT4oSP8YSIzoXC5GkXgLI2/Q==
 
-"@bazel/terser@^3.1.0":
-  version "3.1.0"
-  resolved "https://registry.yarnpkg.com/@bazel/terser/-/terser-3.1.0.tgz#5801e83d4ac648fb1a8824a77a1a1f32c3af0c1e"
-  integrity sha512-8oXZwy5G5dbr4zltBzLjfPw4ZARDEysB2E25dCqAo64XJ26ptS+D3/UnE3uZU9KuM/3ka1U+YIpit+f9SqCgTA==
+"@bazel/terser@^3.2.0":
+  version "3.2.0"
+  resolved "https://registry.yarnpkg.com/@bazel/terser/-/terser-3.2.0.tgz#e53ad32733a0b231323b9eb55ebc2a3c65b10223"
+  integrity sha512-/yq4gST3t1mETkP6NjC05yEyIIL//4mbfLI56hE3CC/mm/xJ6UeooFVpUdlJREQEDRAdNWoiMesQ1ZtgpNPzFg==
 
-"@bazel/typescript@^3.1.0":
-  version "3.1.0"
-  resolved "https://registry.yarnpkg.com/@bazel/typescript/-/typescript-3.1.0.tgz#a07999ad7956b8c624604a521e653570bba32025"
-  integrity sha512-sEWuvkUGIDeRhjLENHtJyop7wu4UqKN8h/nSgUwc5gkpWXQiT2wGH5jKVxBqODOBHB+IInEMtAjyRmCT+HbSHA==
+"@bazel/typescript@^3.2.0":
+  version "3.2.0"
+  resolved "https://registry.yarnpkg.com/@bazel/typescript/-/typescript-3.2.0.tgz#299bd173fe04f98407ab9be4f654662c1c28470e"
+  integrity sha512-RKdy9ThbcUAqZR3AJK7AR/nxbJqdHi7pPayIGUSMIpxVkeTxVRQpf1aGe2H02HdZ9fR/uk1xXhO/Ff9TLvTgHQ==
   dependencies:
     protobufjs "6.8.8"
     semver "5.6.0"