Merge "Move some of the old diff cache entities to a new package"
diff --git a/Documentation/access-control.txt b/Documentation/access-control.txt
index 529718a..38720fe 100644
--- a/Documentation/access-control.txt
+++ b/Documentation/access-control.txt
@@ -869,6 +869,11 @@
 private changes (even without having the `View Private Changes` access
 right assigned).
 
+**NOTE**: If link:config-gerrit.html#auth.skipFullRefEvaluationIfAllRefsAreVisible[
+auth.skipFullRefEvaluationIfAllRefsAreVisible] is `true` (which is the case by
+default) privates changes and all change edit refs are also visible to users
+that have read access on `refs/*`.
+
 [[category_toggle_work_in_progress_state]]
 === Toggle Work In Progress state
 
diff --git a/Documentation/concept-patch-sets.txt b/Documentation/concept-patch-sets.txt
index 8609afd..274fbb0 100644
--- a/Documentation/concept-patch-sets.txt
+++ b/Documentation/concept-patch-sets.txt
@@ -89,7 +89,7 @@
 set description does not become a part of the project's history.
 
 To add a patch set description, click *Add a patch set description*, located in
-the file list.
+the file list, or provide it link:user-upload.html#patch_set_description[on upload].
 
 GERRIT
 ------
diff --git a/Documentation/config-accounts.txt b/Documentation/config-accounts.txt
index b4a5cef..c6d9fb4 100644
--- a/Documentation/config-accounts.txt
+++ b/Documentation/config-accounts.txt
@@ -343,6 +343,12 @@
 The `accountId` field is mandatory. The `email` and `password` fields
 are optional.
 
+Note that git will automatically nest these notes at varying levels. If
+refs/meta/external-ids:7c/2a55657d911109dbc930836e7a770fb946e8ef is not
+found then check
+refs/meta/external-ids:7c/2a/55657d911109dbc930836e7a770fb946e8ef and
+so on.
+
 The external IDs are maintained by Gerrit. This means users are not
 allowed to manually edit their external IDs. Only users with the
 link:access-control.html#capability_accessDatabase[Access Database]
diff --git a/Documentation/config-gerrit.txt b/Documentation/config-gerrit.txt
index 5ed8ccf..ac5e3b7 100644
--- a/Documentation/config-gerrit.txt
+++ b/Documentation/config-gerrit.txt
@@ -684,8 +684,11 @@
 
 [[auth.skipFullRefEvaluationIfAllRefsAreVisible]]auth.skipFullRefEvaluationIfAllRefsAreVisible::
 +
-Whether to skip the full ref visibility checks as a performance shortcut when all refs are
-visible to a user. Full ref filtering would filter out things like pending edits.
+Whether to skip the full ref visibility checks as a performance shortcut when a
+user has READ permission for all refs.
++
+The full ref filtering would filter out refs for pending edits, private changes
+and auto merge commits.
 +
 By default, true.
 
@@ -742,6 +745,24 @@
 +
 Default is false.
 
+[[cache.openFiles]]cache.openFiles::
++
+The number of file descriptors to add to the limit set by the Gerrit daemon.
++
+Persistent caches are stored on the file system and as such participate in the
+file descriptors utilization. The number of file descriptors can vary depending
+on the cache configuration and the specific backend used.
++
+The additional file descriptors required by the cache should be accounted for
+via this setting, so that the Gerrit daemon can adjust the ulimit accordingly.
++
+If you increase this to a larger setting you may need to also adjust
+the ulimit on file descriptors for the host JVM, as Gerrit needs
+additional file descriptors available for network sockets and other
+repository data manipulation.
++
+Default is 0.
+
 [[cache.name.maxAge]]cache.<name>.maxAge::
 +
 Maximum age to keep an entry in the cache. Entries are removed from
@@ -1705,6 +1726,12 @@
 ----
   javaOptions = -Dlog4j.configuration=file:///home/gerrit/site/etc/log4j.properties
 ----
++
+Gerrit built-in loggers are then ignored: error logger (`error_log` file),
+link:#httpd.requestLog[httpd.requestLog] and
+link:#sshd.requestLog[sshd.requestLog]. The
+link:#log.jsonLogging[log.jsonLogging] and
+link:#log.textLogging[log.textLogging] options are also ignored.
 
 [[container.daemonOpt]]container.daemonOpt::
 +
@@ -3768,8 +3795,13 @@
 
 [[log.jsonLogging]]log.jsonLogging::
 +
-If set to true, enables error, ssh and http logging in JSON format (file name:
-"logs/{error|sshd|httpd}_log.json").
+If set to true, enables error, ssh and http logging in JSON format (file names:
+`logs/error_log.json`, `logs/sshd_log.json` and `logs/httpd_log.json`).
++
+The option only applies to Gerrit built-in loggers. It is ignored when a log4j
+configuration is specified via
+link:#container.javaOptions[container.javaOptions], for example
+`-Dlog4j.configuration=file://etc/log4j.properties`.
 +
 Defaults to false.
 
@@ -3778,6 +3810,11 @@
 If set to true, enables error logging in regular plain text format. Can only be disabled
 if `jsonLogging` is enabled.
 +
+The option only applies to Gerrit built-in loggers. It is ignored when a log4j
+configuration is specified via
+link:#container.javaOptions[container.javaOptions], for example
+`-Dlog4j.configuration=file://etc/log4j.properties`.
++
 Defaults to true.
 
 [[log.compress]]log.compress::
diff --git a/Documentation/dev-bazel.txt b/Documentation/dev-bazel.txt
index 96cc67f..0ae038a 100644
--- a/Documentation/dev-bazel.txt
+++ b/Documentation/dev-bazel.txt
@@ -287,6 +287,18 @@
   bazel-bin/withdocs.war
 ----
 
+Alternatively, one can generate the documentation as flat files:
+
+----
+  bazel build Documentation:Documentation
+----
+
+The html, css, js files are placed in:
+
+----
+ `bazel-bin/Documentation/`
+----
+
 [[tests]]
 == Running Unit Tests
 
diff --git a/Documentation/dev-plugins.txt b/Documentation/dev-plugins.txt
index b2e1589..2f4c46c 100644
--- a/Documentation/dev-plugins.txt
+++ b/Documentation/dev-plugins.txt
@@ -2241,7 +2241,8 @@
   private String imageUrl = "http://placehold.it/16x16.gif";
 
   @Override
-  public WebLinkInfo getPatchSetWebLink(String projectName, String commit) {
+  public WebLinkInfo getPatchSetWebLink(String projectName, String commit,
+   String subject, String branchName) {
     return new WebLinkInfo(name,
         imageUrl,
         String.format(placeHolderUrlProjectCommit, project, commit),
@@ -2729,8 +2730,8 @@
 [source, java]
 ----
 import java.util.Optional;
-import com.google.gerrit.common.data.SubmitRecord;
-import com.google.gerrit.common.data.SubmitRecord.Status;
+import com.google.gerrit.entities.SubmitRecord;
+import com.google.gerrit.entities.SubmitRecord.Status;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.rules.SubmitRule;
 
diff --git a/Documentation/metrics.txt b/Documentation/metrics.txt
index 3040348..8a95bab 100644
--- a/Documentation/metrics.txt
+++ b/Documentation/metrics.txt
@@ -66,6 +66,11 @@
 * `caches/disk_hit_ratio`: Disk hit ratio for persistent cache.
 * `caches/refresh_count`: The number of refreshes per cache with an indicator if a reload was necessary.
 
+Cache disk metrics are expensive to compute on larger installations and are not
+computed by default. They can be enabled via the
+link:config.gerrit.html#cache.enableDiskStatMetrics[`cache.enableDiskStatMetrics`]
+setting.
+
 === Change
 
 * `change/submit_rule_evaluation`: Latency for evaluating submit rules on a change.
diff --git a/Documentation/rest-api-changes.txt b/Documentation/rest-api-changes.txt
index d128613..43c3b9e 100644
--- a/Documentation/rest-api-changes.txt
+++ b/Documentation/rest-api-changes.txt
@@ -216,15 +216,18 @@
 [[labels]]
 --
 * `LABELS`: a summary of each label required for submit, and
-  approvers that have granted (or rejected) with that label.
+  approvers that have granted (or rejected) with that label
+  as well as all reviewers by state, and reviewers that may
+  be removed by the current user.
 --
 
 [[detailed-labels]]
 --
 * `DETAILED_LABELS`: detailed label information, including numeric
   values of all existing approvals, recognized label values, values
-  permitted to be set by the current user, all reviewers by state, and
-  reviewers that may be removed by the current user.
+  permitted to be set by any reviewer and the change owner, all
+  reviewers by state, and reviewers that may be removed by the
+  current user.
 --
 
 [[current-revision]]
@@ -6412,7 +6415,8 @@
 |`removable_reviewers`|optional|
 The reviewers that can be removed by the calling user as a list of
 link:rest-api-accounts.html#account-info[AccountInfo] entities. +
-Only set if link:#detailed-labels[detailed labels] are requested.
+Only set if link:#labels[labels] or
+link:#detailed-labels[detailed labels] are requested.
 |`reviewers`          |optional|
 The reviewers as a map that maps a reviewer state to a list of
 link:rest-api-accounts.html#account-info[AccountInfo] entities.
@@ -6421,13 +6425,15 @@
 `CC`: Users that were added to the change, but have not voted. +
 `REMOVED`: Users that were previously reviewers on the change, but have
 been removed. +
-Only set if link:#detailed-labels[detailed labels] are requested.
+Only set if link:#labels[labels] or
+link:#detailed-labels[detailed labels] are requested.
 |`pending_reviewers`  |optional|
 Updates to `reviewers` that have been made while the change was in the
 WIP state. Only present on WIP changes and only if there are pending
 reviewer updates to report. These are reviewers who have not yet been
 notified about being added to or removed from the change. +
-Only set if link:#detailed-labels[detailed labels] are requested.
+Only set if link:#labels[labels] or
+link:#detailed-labels[detailed labels] are requested.
 |`reviewer_updates`|optional|
 Updates to reviewers set for the change as
 link:#review-update-info[ReviewerUpdateInfo] entities.
diff --git a/Documentation/rest-api-projects.txt b/Documentation/rest-api-projects.txt
index d34ccb4..6889de3 100644
--- a/Documentation/rest-api-projects.txt
+++ b/Documentation/rest-api-projects.txt
@@ -4196,7 +4196,9 @@
 repository.<name>.defaultSubmitType] is set to a different value.
 |`branches`                  |optional|
 A list of branches that should be initially created. +
-For the branch names the `refs/heads/` prefix can be omitted.
+For the branch names the `refs/heads/` prefix can be omitted. +
+The first entry of the list will be the default branch (ie. the target +
+of the `HEAD` symbolic ref).
 |`owners`                    |optional|
 A list of groups that should be assigned as project owner. +
 Each group in the list must be specified as
diff --git a/Documentation/user-upload.txt b/Documentation/user-upload.txt
index 926aa71..cdaf155 100644
--- a/Documentation/user-upload.txt
+++ b/Documentation/user-upload.txt
@@ -315,11 +315,11 @@
 preference is set so the default behavior is to create `work-in-progress`
 changes, this can be overridden with the `ready` option.
 
-[[message]]
-==== Message
+[[patch_set_description]]
+==== Patch Set Description
 
-A comment message can be applied to the change by using the `message` (or `m`)
-option:
+A link:concept-patch-sets.html#_description[patch set description] can be
+applied by using the `message` (or `m`) option:
 
 ----
   git push ssh://john.doe@git.example.com:29418/kernel/common HEAD:refs/for/experimental%m=This_is_a_rebase_on_master%21
diff --git a/WORKSPACE b/WORKSPACE
index 3c30026..01decd5 100644
--- a/WORKSPACE
+++ b/WORKSPACE
@@ -51,10 +51,10 @@
 
 http_archive(
     name = "com_google_protobuf",
-    sha256 = "71030a04aedf9f612d2991c1c552317038c3c5a2b578ac4745267a45e7037c29",
-    strip_prefix = "protobuf-3.12.3",
+    sha256 = "d0f5f605d0d656007ce6c8b5a82df3037e1d8fe8b121ed42e536f569dec16113",
+    strip_prefix = "protobuf-3.14.0",
     urls = [
-        "https://github.com/protocolbuffers/protobuf/archive/v3.12.3.tar.gz",
+        "https://github.com/protocolbuffers/protobuf/archive/v3.14.0.tar.gz",
     ],
 )
 
@@ -211,14 +211,6 @@
     sha1 = "f645ed69d595b24d4cf8b3fbb64cc505bede8829",
 )
 
-load("//lib:guava.bzl", "GUAVA_BIN_SHA1", "GUAVA_VERSION")
-
-maven_jar(
-    name = "guava",
-    artifact = "com.google.guava:guava:" + GUAVA_VERSION,
-    sha1 = GUAVA_BIN_SHA1,
-)
-
 CAFFEINE_VERS = "2.8.5"
 
 maven_jar(
diff --git a/java/com/google/gerrit/acceptance/AbstractDynamicOptionsTest.java b/java/com/google/gerrit/acceptance/AbstractDynamicOptionsTest.java
new file mode 100644
index 0000000..a4ed80a
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/AbstractDynamicOptionsTest.java
@@ -0,0 +1,117 @@
+// 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.
+
+package com.google.gerrit.acceptance;
+
+import static com.google.gerrit.sshd.CommandMetaData.Mode.MASTER_OR_SLAVE;
+
+import com.google.common.collect.Lists;
+import com.google.gerrit.extensions.annotations.Exports;
+import com.google.gerrit.json.OutputFormat;
+import com.google.gerrit.server.DynamicOptions;
+import com.google.gerrit.sshd.CommandMetaData;
+import com.google.gerrit.sshd.CommandModule;
+import com.google.gerrit.sshd.SshCommand;
+import com.google.gson.reflect.TypeToken;
+import com.google.inject.AbstractModule;
+import com.google.inject.Inject;
+import java.io.BufferedWriter;
+import java.io.OutputStream;
+import java.io.OutputStreamWriter;
+import java.io.PrintWriter;
+import java.util.Collections;
+import java.util.List;
+
+public class AbstractDynamicOptionsTest extends AbstractDaemonTest {
+  protected static final String LS_SAMPLES = "ls-samples";
+
+  protected interface Bean {
+    void setSamples(List<String> samples);
+  }
+
+  protected static class ListSamples implements Bean, DynamicOptions.BeanReceiver {
+    protected List<String> samples = Collections.emptyList();
+
+    @Override
+    public void setSamples(List<String> samples) {
+      this.samples = samples;
+    }
+
+    public void display(OutputStream displayOutputStream) throws Exception {
+      PrintWriter stdout =
+          new PrintWriter(new BufferedWriter(new OutputStreamWriter(displayOutputStream, "UTF-8")));
+      try {
+        OutputFormat.JSON
+            .newGson()
+            .toJson(samples, new TypeToken<List<String>>() {}.getType(), stdout);
+        stdout.print('\n');
+      } finally {
+        stdout.flush();
+      }
+    }
+
+    @Override
+    public void setDynamicBean(String plugin, DynamicOptions.DynamicBean dynamicBean) {}
+  }
+
+  @CommandMetaData(name = LS_SAMPLES, runsAt = MASTER_OR_SLAVE)
+  protected static class ListSamplesCommand extends SshCommand {
+    @Inject private ListSamples impl;
+
+    @Override
+    protected void run() throws Exception {
+      impl.display(out);
+    }
+
+    @Override
+    protected void parseCommandLine(DynamicOptions pluginOptions) throws UnloggedFailure {
+      parseCommandLine(impl, pluginOptions);
+    }
+  }
+
+  public static class PluginOneSshModule extends CommandModule {
+    @Override
+    public void configure() {
+      command(LS_SAMPLES).to(ListSamplesCommand.class);
+    }
+  }
+
+  protected static class ListSamplesOptions implements DynamicOptions.BeanParseListener {
+    @Override
+    public void onBeanParseStart(String plugin, Object bean) {
+      ((Bean) bean).setSamples(Lists.newArrayList("sample1", "sample2"));
+    }
+
+    @Override
+    public void onBeanParseEnd(String plugin, Object bean) {}
+  }
+
+  protected static class PluginTwoModule extends AbstractModule {
+    @Override
+    public void configure() {
+      bind(DynamicOptions.DynamicBean.class)
+          .annotatedWith(
+              Exports.named("com.google.gerrit.acceptance.AbstractDynamicOptionsTest.ListSamples"))
+          .to(ListSamplesOptionsClassNameProvider.class);
+    }
+  }
+
+  protected static class ListSamplesOptionsClassNameProvider
+      implements DynamicOptions.ClassNameProvider {
+    @Override
+    public String getClassName() {
+      return "com.google.gerrit.acceptance.AbstractDynamicOptionsTest$ListSamplesOptions";
+    }
+  }
+}
diff --git a/java/com/google/gerrit/entities/CoreDownloadSchemes.java b/java/com/google/gerrit/entities/CoreDownloadSchemes.java
index 37c10f1..9bcd365 100644
--- a/java/com/google/gerrit/entities/CoreDownloadSchemes.java
+++ b/java/com/google/gerrit/entities/CoreDownloadSchemes.java
@@ -21,6 +21,7 @@
   public static final String HTTP = "http";
   public static final String SSH = "ssh";
   public static final String REPO_DOWNLOAD = "repo";
+  public static final String REPO = "repo";
 
   private CoreDownloadSchemes() {}
 }
diff --git a/java/com/google/gerrit/entities/RefNames.java b/java/com/google/gerrit/entities/RefNames.java
index 400861c..5595bc7 100644
--- a/java/com/google/gerrit/entities/RefNames.java
+++ b/java/com/google/gerrit/entities/RefNames.java
@@ -288,10 +288,16 @@
    * Whether the ref is managed by Gerrit. Covers all Gerrit-internal refs like refs/cache-automerge
    * and refs/meta as well as refs/changes. Does not cover user-created refs like branches or custom
    * ref namespaces like refs/my-company.
+   *
+   * <p>Any ref for which this method evaluates to true will be served to users who have the {@code
+   * ACCESS_DATABASE} capability.
+   *
+   * <p><b>Caution</b>Any ref not in this list will be served if the user was granted a READ
+   * permission on it using Gerrit's permission model.
    */
   public static boolean isGerritRef(String ref) {
     return ref.startsWith(REFS_CHANGES)
-        || ref.startsWith(REFS_META)
+        || ref.startsWith(REFS_EXTERNAL_IDS)
         || ref.startsWith(REFS_CACHE_AUTOMERGE)
         || ref.startsWith(REFS_DRAFT_COMMENTS)
         || ref.startsWith(REFS_DELETED_GROUPS)
@@ -299,7 +305,8 @@
         || ref.startsWith(REFS_GROUPS)
         || ref.startsWith(REFS_GROUPNAMES)
         || ref.startsWith(REFS_USERS)
-        || ref.startsWith(REFS_STARRED_CHANGES);
+        || ref.startsWith(REFS_STARRED_CHANGES)
+        || ref.startsWith(REFS_REJECT_COMMITS);
   }
 
   static Integer parseShardedRefPart(String name) {
diff --git a/java/com/google/gerrit/extensions/BUILD b/java/com/google/gerrit/extensions/BUILD
index da5dc8b..21949f7 100644
--- a/java/com/google/gerrit/extensions/BUILD
+++ b/java/com/google/gerrit/extensions/BUILD
@@ -1,5 +1,5 @@
 load("@rules_java//java:defs.bzl", "java_binary", "java_library")
-load("//lib:guava.bzl", "GUAVA_DOC_URL")
+load("//tools:nongoogle.bzl", "GUAVA_DOC_URL")
 load("//tools/bzl:javadoc.bzl", "java_doc")
 
 _DOC_VERS = "5.5.0.201909110433-r"
diff --git a/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java b/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java
index 681d0bd..4cb52b7 100644
--- a/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java
+++ b/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java
@@ -27,7 +27,6 @@
 
   /** Preferred method to download a change. */
   public enum DownloadCommand {
-    REPO_DOWNLOAD,
     PULL,
     CHECKOUT,
     CHERRY_PICK,
diff --git a/java/com/google/gerrit/extensions/webui/ParentWebLink.java b/java/com/google/gerrit/extensions/webui/ParentWebLink.java
index dfc970d..2176e78 100644
--- a/java/com/google/gerrit/extensions/webui/ParentWebLink.java
+++ b/java/com/google/gerrit/extensions/webui/ParentWebLink.java
@@ -30,10 +30,13 @@
    *
    * <p>
    *
-   * @param projectName Name of the project
-   * @param commit Commit sha1 of the parent revision
+   * @param projectName name of the project
+   * @param commit commit sha1 of the parent revision
+   * @param subject first line of the commit message
+   * @param branchName target branch of the change
    * @return WebLinkInfo that links to parent commit in external service, null if there should be no
    *     link.
    */
-  WebLinkInfo getParentWebLink(String projectName, String commit);
+  WebLinkInfo getParentWebLink(
+      String projectName, String commit, String subject, String branchName);
 }
diff --git a/java/com/google/gerrit/extensions/webui/PatchSetWebLink.java b/java/com/google/gerrit/extensions/webui/PatchSetWebLink.java
index 93fe8e1..b8ba5c4 100644
--- a/java/com/google/gerrit/extensions/webui/PatchSetWebLink.java
+++ b/java/com/google/gerrit/extensions/webui/PatchSetWebLink.java
@@ -30,10 +30,13 @@
    *
    * <p>
    *
-   * @param projectName Name of the project
-   * @param commit Commit of the patch set
+   * @param projectName name of the project
+   * @param commit commit of the patch set
+   * @param subject first line of the commit message
+   * @param branchName target branch of the change
    * @return WebLinkInfo that links to patch set in external service, null if there should be no
    *     link.
    */
-  WebLinkInfo getPatchSetWebLink(String projectName, String commit);
+  WebLinkInfo getPatchSetWebLink(
+      String projectName, String commit, String subject, String branchName);
 }
diff --git a/java/com/google/gerrit/httpd/raw/CatServlet.java b/java/com/google/gerrit/httpd/raw/CatServlet.java
index 7a4f4e6..f5d72b2 100644
--- a/java/com/google/gerrit/httpd/raw/CatServlet.java
+++ b/java/com/google/gerrit/httpd/raw/CatServlet.java
@@ -123,7 +123,7 @@
     final Change.Id changeId = patchKey.patchSetId().changeId();
     String revision;
     try {
-      ChangeNotes notes = changeNotesFactory.createChecked(changeId);
+      ChangeNotes notes = changeNotesFactory.createCheckedUsingIndexLookup(changeId);
       permissionBackend.currentUser().change(notes).check(ChangePermission.READ);
       projectCache
           .get(notes.getProjectName())
diff --git a/java/com/google/gerrit/httpd/raw/IndexPreloadingUtil.java b/java/com/google/gerrit/httpd/raw/IndexPreloadingUtil.java
index c17cd97..f1da6b7 100644
--- a/java/com/google/gerrit/httpd/raw/IndexPreloadingUtil.java
+++ b/java/com/google/gerrit/httpd/raw/IndexPreloadingUtil.java
@@ -123,9 +123,7 @@
     options.add(ListChangesOption.LABELS);
     options.add(ListChangesOption.DETAILED_ACCOUNTS);
 
-    if (isEnabledAttentionSet(serverApi)) {
-      options.add(ListChangesOption.DETAILED_LABELS);
-    } else {
+    if (!isEnabledAttentionSet(serverApi)) {
       options.add(ListChangesOption.REVIEWED);
     }
     return ListOption.toHex(options);
diff --git a/java/com/google/gerrit/httpd/restapi/ParameterParser.java b/java/com/google/gerrit/httpd/restapi/ParameterParser.java
index 95d99f0..326cab8 100644
--- a/java/com/google/gerrit/httpd/restapi/ParameterParser.java
+++ b/java/com/google/gerrit/httpd/restapi/ParameterParser.java
@@ -161,6 +161,8 @@
       HttpServletResponse res)
       throws IOException {
     CmdLineParser clp = parserFactory.create(param);
+    pluginOptions.setBean(param);
+    pluginOptions.startLifecycleListeners();
     pluginOptions.parseDynamicBeans(clp);
     pluginOptions.setDynamicBeans();
     pluginOptions.onBeanParseStart();
diff --git a/java/com/google/gerrit/httpd/restapi/RestApiServlet.java b/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
index 4d55b36..0e525ce 100644
--- a/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
+++ b/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
@@ -507,7 +507,7 @@
           }
 
           try (DynamicOptions pluginOptions =
-              new DynamicOptions(viewData.view, globals.injector, globals.dynamicBeans)) {
+              new DynamicOptions(globals.injector, globals.dynamicBeans)) {
             if (!globals
                 .paramParser
                 .get()
diff --git a/java/com/google/gerrit/server/DynamicOptions.java b/java/com/google/gerrit/server/DynamicOptions.java
index 1d36ff0..db0aa70 100644
--- a/java/com/google/gerrit/server/DynamicOptions.java
+++ b/java/com/google/gerrit/server/DynamicOptions.java
@@ -193,6 +193,7 @@
   protected Object bean;
   protected Map<String, DynamicBean> beansByPlugin;
   protected Injector injector;
+  protected DynamicMap<DynamicBean> dynamicBeans;
   protected LifecycleManager lifecycleManager;
 
   /**
@@ -200,7 +201,9 @@
    * this class so the following methods can be called if desired:
    *
    * <pre>
-   *    DynamicOptions pluginOptions = new DynamicOptions(bean, injector, dynamicBeans);
+   *    DynamicOptions pluginOptions = new DynamicOptions(injector, dynamicBeans);
+   *    pluginOptions.setBean(bean);
+   *    pluginOptions.startLifecycleListeners();
    *    pluginOptions.parseDynamicBeans(clp);
    *    pluginOptions.setDynamicBeans();
    *    pluginOptions.onBeanParseStart();
@@ -210,11 +213,15 @@
    *    pluginOptions.onBeanParseEnd();
    * </pre>
    */
-  public DynamicOptions(Object bean, Injector injector, DynamicMap<DynamicBean> dynamicBeans) {
-    this.bean = bean;
+  public DynamicOptions(Injector injector, DynamicMap<DynamicBean> dynamicBeans) {
     this.injector = injector;
+    this.dynamicBeans = dynamicBeans;
     lifecycleManager = new LifecycleManager();
     beansByPlugin = new HashMap<>();
+  }
+
+  public void setBean(Object bean) {
+    this.bean = bean;
     Class<?> beanClass =
         (bean instanceof BeanReceiver)
             ? ((BeanReceiver) bean).getExportedBeanReceiver()
@@ -226,7 +233,6 @@
         beansByPlugin.put(plugin, getDynamicBean(bean, provider.get()));
       }
     }
-    startLifecycleListeners();
   }
 
   @SuppressWarnings("unchecked")
diff --git a/java/com/google/gerrit/server/WebLinks.java b/java/com/google/gerrit/server/WebLinks.java
index 88b0b21..47ba325 100644
--- a/java/com/google/gerrit/server/WebLinks.java
+++ b/java/com/google/gerrit/server/WebLinks.java
@@ -86,19 +86,29 @@
   /**
    * @param project Project name.
    * @param commit SHA1 of commit.
+   * @param subject subject of the commit.
+   * @param branchName branch of the commit.
    * @return Links for patch sets.
    */
-  public ImmutableList<WebLinkInfo> getPatchSetLinks(Project.NameKey project, String commit) {
-    return filterLinks(patchSetLinks, webLink -> webLink.getPatchSetWebLink(project.get(), commit));
+  public ImmutableList<WebLinkInfo> getPatchSetLinks(
+      Project.NameKey project, String commit, String subject, String branchName) {
+    return filterLinks(
+        patchSetLinks,
+        webLink -> webLink.getPatchSetWebLink(project.get(), commit, subject, branchName));
   }
 
   /**
    * @param project Project name.
    * @param revision SHA1 of the parent revision.
+   * @param subject subject of the parent revision.
+   * @param branchName branch of the revision (and parent revision).
    * @return Links for patch sets.
    */
-  public ImmutableList<WebLinkInfo> getParentLinks(Project.NameKey project, String revision) {
-    return filterLinks(parentLinks, webLink -> webLink.getParentWebLink(project.get(), revision));
+  public ImmutableList<WebLinkInfo> getParentLinks(
+      Project.NameKey project, String revision, String subject, String branchName) {
+    return filterLinks(
+        parentLinks,
+        webLink -> webLink.getParentWebLink(project.get(), revision, subject, branchName));
   }
 
   /**
diff --git a/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java b/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
index 0992bcd..d349dda 100644
--- a/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
+++ b/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
@@ -177,6 +177,8 @@
   private final Provider<GetPureRevert> getPureRevertProvider;
   private final StarredChangesUtil stars;
   private final DynamicOptionParser dynamicOptionParser;
+  private final Injector injector;
+  private final DynamicMap<DynamicOptions.DynamicBean> dynamicBeans;
 
   @Inject
   ChangeApiImpl(
@@ -230,7 +232,9 @@
       Provider<GetPureRevert> getPureRevertProvider,
       StarredChangesUtil stars,
       DynamicOptionParser dynamicOptionParser,
-      @Assisted ChangeResource change) {
+      @Assisted ChangeResource change,
+      Injector injector,
+      DynamicMap<DynamicOptions.DynamicBean> dynamicBeans) {
     this.changeApi = changeApi;
     this.revert = revert;
     this.revertSubmission = revertSubmission;
@@ -282,6 +286,8 @@
     this.stars = stars;
     this.dynamicOptionParser = dynamicOptionParser;
     this.change = change;
+    this.injector = injector;
+    this.dynamicBeans = dynamicBeans;
   }
 
   @Override
@@ -500,10 +506,10 @@
   public ChangeInfo get(
       EnumSet<ListChangesOption> options, ImmutableListMultimap<String, String> pluginOptions)
       throws RestApiException {
-    try {
+    try (DynamicOptions dynamicOptions = new DynamicOptions(injector, dynamicBeans)) {
       GetChange getChange = getChangeProvider.get();
       options.forEach(getChange::addOption);
-      dynamicOptionParser.parseDynamicOptions(getChange, pluginOptions);
+      dynamicOptionParser.parseDynamicOptions(getChange, pluginOptions, dynamicOptions);
       return getChange.apply(change).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot retrieve change", e);
@@ -759,8 +765,6 @@
   @Singleton
   static class DynamicOptionParser {
     private final CmdLineParser.Factory cmdLineParserFactory;
-    private final Injector injector;
-    private final DynamicMap<DynamicOptions.DynamicBean> dynamicBeans;
 
     @Inject
     DynamicOptionParser(
@@ -768,14 +772,14 @@
         Injector injector,
         DynamicMap<DynamicOptions.DynamicBean> dynamicBeans) {
       this.cmdLineParserFactory = cmdLineParserFactory;
-      this.injector = injector;
-      this.dynamicBeans = dynamicBeans;
     }
 
-    void parseDynamicOptions(Object bean, ListMultimap<String, String> pluginOptions)
+    void parseDynamicOptions(
+        Object bean, ListMultimap<String, String> pluginOptions, DynamicOptions dynamicOptions)
         throws BadRequestException {
       CmdLineParser clp = cmdLineParserFactory.create(bean);
-      DynamicOptions dynamicOptions = new DynamicOptions(bean, injector, dynamicBeans);
+      dynamicOptions.setBean(bean);
+      dynamicOptions.startLifecycleListeners();
       dynamicOptions.parseDynamicBeans(clp);
       dynamicOptions.setDynamicBeans();
       dynamicOptions.onBeanParseStart();
diff --git a/java/com/google/gerrit/server/api/changes/ChangesImpl.java b/java/com/google/gerrit/server/api/changes/ChangesImpl.java
index d6ef61c..0596524 100644
--- a/java/com/google/gerrit/server/api/changes/ChangesImpl.java
+++ b/java/com/google/gerrit/server/api/changes/ChangesImpl.java
@@ -26,15 +26,18 @@
 import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.ChangeInput;
+import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
 import com.google.gerrit.extensions.restapi.Url;
+import com.google.gerrit.server.DynamicOptions;
 import com.google.gerrit.server.api.changes.ChangeApiImpl.DynamicOptionParser;
 import com.google.gerrit.server.restapi.change.ChangesCollection;
 import com.google.gerrit.server.restapi.change.CreateChange;
 import com.google.gerrit.server.restapi.change.QueryChanges;
 import com.google.inject.Inject;
+import com.google.inject.Injector;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.util.List;
@@ -46,6 +49,8 @@
   private final CreateChange createChange;
   private final DynamicOptionParser dynamicOptionParser;
   private final Provider<QueryChanges> queryProvider;
+  private final Injector injector;
+  private final DynamicMap<DynamicOptions.DynamicBean> dynamicBeans;
 
   @Inject
   ChangesImpl(
@@ -53,12 +58,16 @@
       ChangeApiImpl.Factory api,
       CreateChange createChange,
       DynamicOptionParser dynamicOptionParser,
-      Provider<QueryChanges> queryProvider) {
+      Provider<QueryChanges> queryProvider,
+      Injector injector,
+      DynamicMap<DynamicOptions.DynamicBean> dynamicBeans) {
     this.changes = changes;
     this.api = api;
     this.createChange = createChange;
     this.dynamicOptionParser = dynamicOptionParser;
     this.queryProvider = queryProvider;
+    this.injector = injector;
+    this.dynamicBeans = dynamicBeans;
   }
 
   @Override
@@ -123,34 +132,36 @@
   }
 
   private List<ChangeInfo> get(QueryRequest q) throws RestApiException {
-    QueryChanges qc = queryProvider.get();
-    if (q.getQuery() != null) {
-      qc.addQuery(q.getQuery());
-    }
-    qc.setLimit(q.getLimit());
-    qc.setStart(q.getStart());
-    qc.setNoLimit(q.getNoLimit());
-    for (ListChangesOption option : q.getOptions()) {
-      qc.addOption(option);
-    }
-    dynamicOptionParser.parseDynamicOptions(qc, q.getPluginOptions());
-
-    try {
-      List<?> result = qc.apply(TopLevelResource.INSTANCE).value();
-      if (result.isEmpty()) {
-        return ImmutableList.of();
+    try (DynamicOptions dynamicOptions = new DynamicOptions(injector, dynamicBeans)) {
+      QueryChanges qc = queryProvider.get();
+      if (q.getQuery() != null) {
+        qc.addQuery(q.getQuery());
       }
+      qc.setLimit(q.getLimit());
+      qc.setStart(q.getStart());
+      qc.setNoLimit(q.getNoLimit());
+      for (ListChangesOption option : q.getOptions()) {
+        qc.addOption(option);
+      }
+      dynamicOptionParser.parseDynamicOptions(qc, q.getPluginOptions(), dynamicOptions);
 
-      // Check type safety of result; the extension API should be safer than the
-      // REST API in this case, since it's intended to be used in Java.
-      Object first = requireNonNull(result.iterator().next());
-      checkState(first instanceof ChangeInfo);
-      @SuppressWarnings("unchecked")
-      List<ChangeInfo> infos = (List<ChangeInfo>) result;
+      try {
+        List<?> result = qc.apply(TopLevelResource.INSTANCE).value();
+        if (result.isEmpty()) {
+          return ImmutableList.of();
+        }
 
-      return ImmutableList.copyOf(infos);
-    } catch (Exception e) {
-      throw asRestApiException("Cannot query changes", e);
+        // Check type safety of result; the extension API should be safer than the
+        // REST API in this case, since it's intended to be used in Java.
+        Object first = requireNonNull(result.iterator().next());
+        checkState(first instanceof ChangeInfo);
+        @SuppressWarnings("unchecked")
+        List<ChangeInfo> infos = (List<ChangeInfo>) result;
+
+        return ImmutableList.copyOf(infos);
+      } catch (Exception e) {
+        throw asRestApiException("Cannot query changes", e);
+      }
     }
   }
 }
diff --git a/java/com/google/gerrit/server/change/ChangeJson.java b/java/com/google/gerrit/server/change/ChangeJson.java
index 0cfc6cb..6ab0c61 100644
--- a/java/com/google/gerrit/server/change/ChangeJson.java
+++ b/java/com/google/gerrit/server/change/ChangeJson.java
@@ -602,7 +602,9 @@
                 ? labelsJson.permittedLabels(user.getAccountId(), cd)
                 : ImmutableMap.of();
       }
+    }
 
+    if (has(LABELS) || has(DETAILED_LABELS)) {
       out.reviewers = reviewerMap(cd.reviewers(), cd.reviewersByEmail(), false);
       out.pendingReviewers = reviewerMap(cd.pendingReviewers(), cd.pendingReviewersByEmail(), true);
       out.removableReviewers = removableReviewers(cd, out);
diff --git a/java/com/google/gerrit/server/change/DeleteReviewerOp.java b/java/com/google/gerrit/server/change/DeleteReviewerOp.java
index 07cb04f..bf00d27 100644
--- a/java/com/google/gerrit/server/change/DeleteReviewerOp.java
+++ b/java/com/google/gerrit/server/change/DeleteReviewerOp.java
@@ -40,6 +40,7 @@
 import com.google.gerrit.server.mail.send.DeleteReviewerSender;
 import com.google.gerrit.server.mail.send.MessageIdGenerator;
 import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gerrit.server.notedb.ReviewerStateInternal;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.RemoveReviewerControl;
@@ -132,9 +133,15 @@
     for (LabelType lt : labelTypes.getLabelTypes()) {
       newApprovals.put(lt.getName(), (short) 0);
     }
-
+    String ccOrReviewer =
+        approvalsUtil
+                .getReviewers(ctx.getNotes())
+                .byState(ReviewerStateInternal.CC)
+                .contains(reviewerId)
+            ? "cc"
+            : "reviewer";
     StringBuilder msg = new StringBuilder();
-    msg.append("Removed reviewer " + reviewer.account().fullName());
+    msg.append(String.format("Removed %s %s", ccOrReviewer, reviewer.account().fullName()));
     StringBuilder removedVotesMsg = new StringBuilder();
     removedVotesMsg.append(" with the following votes:\n\n");
     boolean votesRemoved = false;
diff --git a/java/com/google/gerrit/server/change/RevisionJson.java b/java/com/google/gerrit/server/change/RevisionJson.java
index 414107f..90caea0 100644
--- a/java/com/google/gerrit/server/change/RevisionJson.java
+++ b/java/com/google/gerrit/server/change/RevisionJson.java
@@ -164,7 +164,12 @@
    * RevWalk and assumes it is backed by an open repository.
    */
   public CommitInfo getCommitInfo(
-      Project.NameKey project, RevWalk rw, RevCommit commit, boolean addLinks, boolean fillCommit)
+      Project.NameKey project,
+      RevWalk rw,
+      RevCommit commit,
+      boolean addLinks,
+      boolean fillCommit,
+      String branchName)
       throws IOException {
     CommitInfo info = new CommitInfo();
     if (fillCommit) {
@@ -177,7 +182,8 @@
     info.message = commit.getFullMessage();
 
     if (addLinks) {
-      ImmutableList<WebLinkInfo> links = webLinks.getPatchSetLinks(project, commit.name());
+      ImmutableList<WebLinkInfo> links =
+          webLinks.getPatchSetLinks(project, commit.name(), commit.getShortMessage(), branchName);
       info.webLinks = links.isEmpty() ? null : links;
     }
 
@@ -187,7 +193,8 @@
       i.commit = parent.name();
       i.subject = parent.getShortMessage();
       if (addLinks) {
-        ImmutableList<WebLinkInfo> parentLinks = webLinks.getParentLinks(project, parent.name());
+        ImmutableList<WebLinkInfo> parentLinks =
+            webLinks.getParentLinks(project, parent.name(), parent.getShortMessage(), branchName);
         i.webLinks = parentLinks.isEmpty() ? null : parentLinks;
       }
       info.parents.add(i);
@@ -288,11 +295,12 @@
       String rev = in.commitId().name();
       RevCommit commit = rw.parseCommit(ObjectId.fromString(rev));
       rw.parseBody(commit);
+      String branchName = cd.change().getDest().branch();
       if (setCommit) {
-        out.commit = getCommitInfo(project, rw, commit, has(WEB_LINKS), fillCommit);
+        out.commit = getCommitInfo(project, rw, commit, has(WEB_LINKS), fillCommit, branchName);
       }
       if (addFooters) {
-        Ref ref = repo.exactRef(cd.change().getDest().branch());
+        Ref ref = repo.exactRef(branchName);
         RevCommit mergeTip = null;
         if (ref != null) {
           mergeTip = rw.parseCommit(ref.getObjectId());
diff --git a/java/com/google/gerrit/server/config/ConfigUtil.java b/java/com/google/gerrit/server/config/ConfigUtil.java
index 43c05e0..27ded63 100644
--- a/java/com/google/gerrit/server/config/ConfigUtil.java
+++ b/java/com/google/gerrit/server/config/ConfigUtil.java
@@ -16,6 +16,7 @@
 
 import static java.util.Objects.requireNonNull;
 
+import com.google.common.flogger.FluentLogger;
 import java.lang.reflect.Field;
 import java.lang.reflect.InvocationTargetException;
 import java.lang.reflect.Modifier;
@@ -30,6 +31,8 @@
 import org.eclipse.jgit.lib.Config;
 
 public class ConfigUtil {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
   @SuppressWarnings("unchecked")
   private static <T> T[] allValuesOf(T defaultValue) {
     try {
@@ -138,7 +141,12 @@
     } else {
       for (String string : values) {
         if (string != null) {
-          list.add(getEnum(section, subsection, setting, string, all));
+          try {
+            list.add(getEnum(section, subsection, setting, string, all));
+          } catch (IllegalArgumentException ex) {
+            // It's better to ignore a wrongly configured enum, rather than fail to load Gerrit.
+            logger.atWarning().log(ex.getMessage());
+          }
         }
       }
     }
diff --git a/java/com/google/gerrit/server/config/GitwebConfig.java b/java/com/google/gerrit/server/config/GitwebConfig.java
index b5f09fd..3340a52 100644
--- a/java/com/google/gerrit/server/config/GitwebConfig.java
+++ b/java/com/google/gerrit/server/config/GitwebConfig.java
@@ -327,8 +327,10 @@
     }
 
     @Override
-    public WebLinkInfo getPatchSetWebLink(String projectName, String commit) {
+    public WebLinkInfo getPatchSetWebLink(
+        String projectName, String commit, String subject, String branchName) {
       if (revision != null) {
+        // subject and branchName are not needed, hence not used.
         return link(
             revision
                 .replace("project", encode(projectName))
@@ -339,9 +341,10 @@
     }
 
     @Override
-    public WebLinkInfo getParentWebLink(String projectName, String commit) {
+    public WebLinkInfo getParentWebLink(
+        String projectName, String commit, String subject, String branchName) {
       // For Gitweb treat parent revision links the same as patch set links
-      return getPatchSetWebLink(projectName, commit);
+      return getPatchSetWebLink(projectName, commit, subject, branchName);
     }
 
     @Override
diff --git a/java/com/google/gerrit/server/events/StreamEventsApiListener.java b/java/com/google/gerrit/server/events/StreamEventsApiListener.java
index 1f90187..439f53e 100644
--- a/java/com/google/gerrit/server/events/StreamEventsApiListener.java
+++ b/java/com/google/gerrit/server/events/StreamEventsApiListener.java
@@ -140,7 +140,8 @@
 
   private ChangeNotes getNotes(ChangeInfo info) {
     try {
-      return changeNotesFactory.createChecked(Change.id(info._number));
+      return changeNotesFactory.createChecked(
+          Project.nameKey(info.project), Change.id(info._number));
     } catch (NoSuchChangeException e) {
       throw new StorageException(e);
     }
diff --git a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
index 2ec9a8d..fb8a9d3 100644
--- a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
+++ b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
@@ -2692,7 +2692,7 @@
   private void readChangesForReplace() {
     try (TraceTimer traceTimer = newTimer("readChangesForReplace")) {
       Collection<ChangeNotes> allNotes =
-          notesFactory.create(
+          notesFactory.createUsingIndexLookup(
               replaceByChange.values().stream().map(r -> r.ontoChange).collect(toList()));
       for (ChangeNotes notes : allNotes) {
         replaceByChange.get(notes.getChangeId()).notes = notes;
diff --git a/java/com/google/gerrit/server/mail/send/SmtpEmailSender.java b/java/com/google/gerrit/server/mail/send/SmtpEmailSender.java
index af00b20..1ad94be 100644
--- a/java/com/google/gerrit/server/mail/send/SmtpEmailSender.java
+++ b/java/com/google/gerrit/server/mail/send/SmtpEmailSender.java
@@ -391,11 +391,7 @@
   }
 
   private SMTPClient open() throws EmailException {
-    final AuthSMTPClient client = new AuthSMTPClient(UTF_8.name());
-
-    if (smtpEncryption == Encryption.SSL) {
-      client.enableSSL(sslVerify);
-    }
+    final AuthSMTPClient client = new AuthSMTPClient(smtpEncryption == Encryption.SSL, sslVerify);
 
     client.setConnectTimeout(connectTimeout);
     try {
@@ -411,7 +407,7 @@
       }
 
       if (smtpEncryption == Encryption.TLS) {
-        if (!client.startTLS(smtpHost, smtpPort, sslVerify)) {
+        if (!client.execTLS()) {
           throw new EmailException("SMTP server does not support TLS");
         }
         if (!client.login()) {
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotes.java b/java/com/google/gerrit/server/notedb/ChangeNotes.java
index 41263a4..d48cbc4 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotes.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotes.java
@@ -118,19 +118,6 @@
       return new ChangeNotes(args, change, true, null).load();
     }
 
-    public ChangeNotes createChecked(Change.Id changeId) {
-      InternalChangeQuery query = queryProvider.get().noFields();
-      List<ChangeData> changes = query.byLegacyChangeId(changeId);
-      if (changes.isEmpty()) {
-        throw new NoSuchChangeException(changeId);
-      }
-      if (changes.size() != 1) {
-        logger.atSevere().log("Multiple changes found for %d", changeId.get());
-        throw new NoSuchChangeException(changeId);
-      }
-      return changes.get(0).notes();
-    }
-
     public static Change newChange(Project.NameKey project, Change.Id changeId) {
       return new Change(
           null, changeId, null, BranchNameKey.create(project, "INVALID_NOTE_DB_ONLY"), null);
@@ -149,11 +136,33 @@
       return new ChangeNotes(args, change, true, refs).load();
     }
 
-    public List<ChangeNotes> create(Collection<Change.Id> changeIds) {
+    /**
+     * Create change notes based on a {@link Change.Id}. This requires using the Change index and
+     * should only be used when {@link Project.NameKey} and the numeric change ID are not available.
+     */
+    public ChangeNotes createCheckedUsingIndexLookup(Change.Id changeId) {
+      InternalChangeQuery query = queryProvider.get().noFields();
+      List<ChangeData> changes = query.byLegacyChangeId(changeId);
+      if (changes.isEmpty()) {
+        throw new NoSuchChangeException(changeId);
+      }
+      if (changes.size() != 1) {
+        logger.atSevere().log("Multiple changes found for %d", changeId.get());
+        throw new NoSuchChangeException(changeId);
+      }
+      return changes.get(0).notes();
+    }
+
+    /**
+     * Create change notes based on a list of {@link Change.Id}s. This requires using the Change
+     * index and should only be used when {@link Project.NameKey} and the numeric change ID are not
+     * available.
+     */
+    public List<ChangeNotes> createUsingIndexLookup(Collection<Change.Id> changeIds) {
       List<ChangeNotes> notes = new ArrayList<>();
       for (Change.Id changeId : changeIds) {
         try {
-          notes.add(createChecked(changeId));
+          notes.add(createCheckedUsingIndexLookup(changeId));
         } catch (NoSuchChangeException e) {
           // Ignore missing changes to match Access#get(Iterable) behavior.
         }
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotesCache.java b/java/com/google/gerrit/server/notedb/ChangeNotesCache.java
index 1650421..220e683 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotesCache.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotesCache.java
@@ -121,12 +121,18 @@
     // Single Timestamp overhead.
     private static final int T = O + 8;
 
+    /**
+     * {@inheritDoc}
+     *
+     * <p>Take all columns and all collection sizes into account, but use estimated average element
+     * sizes rather than iterating over collections. Numbers are largely hand-wavy based on
+     * http://stackoverflow.com/questions/258120/what-is-the-memory-consumption-of-an-object-in-java
+     *
+     * <p>Should be kept up to date with {@link ChangeNotesState}. Please, keep weights listed in
+     * the same order as fields.
+     */
     @Override
     public int weigh(Key key, ChangeNotesState state) {
-      // Take all columns and all collection sizes into account, but use
-      // estimated average element sizes rather than iterating over collections.
-      // Numbers are largely hand-wavy based on
-      // http://stackoverflow.com/questions/258120/what-is-the-memory-consumption-of-an-object-in-java
       return P
           + O
           + 20 // metaId
@@ -138,6 +144,7 @@
           + K // owner
           + P
           + str(state.columns().branch())
+          + P // status
           + P
           + patchSetId() // currentPatchSetId
           + P
@@ -148,9 +155,16 @@
           + str(state.columns().originalSubject())
           + P
           + str(state.columns().submissionId())
-          + P // status
+          + 1 // isPrivate
+          + 1 // workInProgress
+          + 1 // reviewStarted
+          + P
+          + K // revertOf
+          + P
+          + patchSetId() // cherryPickOf
           + P
           + set(state.hashtags(), str(10))
+          + str(state.serverId()) // serverId
           + P
           + list(state.patchSets(), patchSet())
           + P
@@ -177,9 +191,6 @@
           + list(state.changeMessages(), changeMessage())
           + P
           + map(state.publishedComments().asMap(), comment())
-          + 1 // isPrivate
-          + 1 // workInProgress
-          + 1 // reviewStarted
           + I; // updateCount
     }
 
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotesState.java b/java/com/google/gerrit/server/notedb/ChangeNotesState.java
index fa32686..27cfb70 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotesState.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotesState.java
@@ -81,6 +81,9 @@
  * <p>One instance is the output of a single {@link ChangeNotesParser}, and contains types required
  * to support public methods on {@link ChangeNotes}. It is intended to be cached in-process.
  *
+ * <p>When new fields are added to the {@link ChangeNotesState}, {@link
+ * ChangeNotesCache.Weigher#weigh} should be updated.
+ *
  * <p>Note that {@link ChangeNotes} contains more than just a single {@code ChangeNoteState}, such
  * as per-draft information, so that class is not cached directly.
  */
diff --git a/java/com/google/gerrit/server/permissions/ChangeControl.java b/java/com/google/gerrit/server/permissions/ChangeControl.java
index 0b4828b..37c773a 100644
--- a/java/com/google/gerrit/server/permissions/ChangeControl.java
+++ b/java/com/google/gerrit/server/permissions/ChangeControl.java
@@ -61,11 +61,12 @@
   }
 
   /** Can this user see this change? */
-  private boolean isVisible(ChangeData cd) {
-    if (getChange().isPrivate() && !isPrivateVisible(cd)) {
+  boolean isVisible() {
+    if (getChange().isPrivate() && !isPrivateVisible(changeData)) {
       return false;
     }
-    return refControl.isVisible();
+    // Does the user have READ permission on the destination?
+    return refControl.asForRef().testOrFalse(RefPermission.READ);
   }
 
   /** Can this user abandon this change? */
@@ -201,17 +202,13 @@
 
     private ForChangeImpl() {}
 
-    private ChangeData changeData() {
-      return changeData;
-    }
-
     @Override
     public String resourcePath() {
       if (resourcePath == null) {
         resourcePath =
             String.format(
                 "/projects/%s/+changes/%s",
-                getProjectControl().getProjectState().getName(), changeData().getId().get());
+                getProjectControl().getProjectState().getName(), changeData.getId().get());
       }
       return resourcePath;
     }
@@ -256,7 +253,7 @@
       try {
         switch (perm) {
           case READ:
-            return isVisible(changeData());
+            return isVisible();
           case ABANDON:
             return canAbandon();
           case DELETE:
diff --git a/java/com/google/gerrit/server/permissions/DefaultRefFilter.java b/java/com/google/gerrit/server/permissions/DefaultRefFilter.java
index defec4b..edd3cb1 100644
--- a/java/com/google/gerrit/server/permissions/DefaultRefFilter.java
+++ b/java/com/google/gerrit/server/permissions/DefaultRefFilter.java
@@ -17,10 +17,7 @@
 import static com.google.common.base.Preconditions.checkState;
 import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.common.flogger.LazyArgs.lazy;
-import static com.google.gerrit.entities.RefNames.REFS_CACHE_AUTOMERGE;
 import static com.google.gerrit.entities.RefNames.REFS_CONFIG;
-import static com.google.gerrit.entities.RefNames.REFS_USERS_SELF;
-import static java.util.Objects.requireNonNull;
 import static java.util.stream.Collectors.toCollection;
 
 import com.google.auto.value.AutoValue;
@@ -30,8 +27,6 @@
 import com.google.common.collect.Maps;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.entities.Account;
-import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Project;
@@ -42,13 +37,10 @@
 import com.google.gerrit.metrics.Description;
 import com.google.gerrit.metrics.MetricMaker;
 import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.account.GroupCache;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.git.SearchingChangeCacheImpl;
 import com.google.gerrit.server.git.TagCache;
 import com.google.gerrit.server.git.TagMatcher;
-import com.google.gerrit.server.group.InternalGroup;
 import com.google.gerrit.server.logging.TraceContext;
 import com.google.gerrit.server.logging.TraceContext.TraceTimer;
 import com.google.gerrit.server.notedb.ChangeNotes;
@@ -80,8 +72,8 @@
   private final TagCache tagCache;
   private final ChangeNotes.Factory changeNotesFactory;
   @Nullable private final SearchingChangeCacheImpl changeCache;
-  private final GroupCache groupCache;
   private final PermissionBackend permissionBackend;
+  private final RefVisibilityControl refVisibilityControl;
   private final ProjectControl projectControl;
   private final CurrentUser user;
   private final ProjectState projectState;
@@ -97,16 +89,16 @@
       TagCache tagCache,
       ChangeNotes.Factory changeNotesFactory,
       @Nullable SearchingChangeCacheImpl changeCache,
-      GroupCache groupCache,
       PermissionBackend permissionBackend,
+      RefVisibilityControl refVisibilityControl,
       @GerritServerConfig Config config,
       MetricMaker metricMaker,
       @Assisted ProjectControl projectControl) {
     this.tagCache = tagCache;
     this.changeNotesFactory = changeNotesFactory;
     this.changeCache = changeCache;
-    this.groupCache = groupCache;
     this.permissionBackend = permissionBackend;
+    this.refVisibilityControl = refVisibilityControl;
     this.skipFullRefEvaluationIfAllRefsAreVisible =
         config.getBoolean("auth", "skipFullRefEvaluationIfAllRefsAreVisible", true);
     this.projectControl = projectControl;
@@ -227,131 +219,56 @@
     logger.atFinest().log("Doing full ref filtering");
     fullFilterCount.increment();
 
-    boolean viewMetadata;
-    boolean isAdmin;
-    Account.Id userId;
-    IdentifiedUser identifiedUser;
-    PermissionBackend.WithUser withUser = permissionBackend.user(user);
-    if (user.isIdentifiedUser()) {
-      viewMetadata = withUser.testOrFalse(GlobalPermission.ACCESS_DATABASE);
-      isAdmin = withUser.testOrFalse(GlobalPermission.ADMINISTRATE_SERVER);
-      identifiedUser = user.asIdentifiedUser();
-      userId = identifiedUser.getAccountId();
-      logger.atFinest().log(
-          "Account = %d; can view metadata = %s; is admin = %s",
-          userId.get(), viewMetadata, isAdmin);
-    } else {
-      logger.atFinest().log("User is anonymous");
-      viewMetadata = false;
-      isAdmin = false;
-      userId = null;
-      identifiedUser = null;
-    }
-
+    boolean hasAccessDatabase =
+        permissionBackend
+            .user(projectControl.getUser())
+            .testOrFalse(GlobalPermission.ACCESS_DATABASE);
     List<Ref> resultRefs = new ArrayList<>(refs.size());
     List<Ref> deferredTags = new ArrayList<>();
     for (Ref ref : refs) {
-      String name = ref.getName();
+      String refName = ref.getName();
       Change.Id changeId;
-      Account.Id accountId;
-      AccountGroup.UUID accountGroupUuid;
-      if (name.startsWith(REFS_CACHE_AUTOMERGE)) {
-        continue;
-      } else if (opts.filterMeta() && isMetadata(name)) {
-        logger.atFinest().log("Filter out metadata ref %s", name);
-        continue;
-      } else if (RefNames.isRefsEdit(name)) {
-        // Edits are visible only to the owning user, if change is visible.
-        if (viewMetadata || visibleEdit(repo, name)) {
-          logger.atFinest().log("Include edit ref %s", name);
-          resultRefs.add(ref);
-        } else {
-          logger.atFinest().log("Filter out edit ref %s", name);
-        }
-      } else if ((changeId = Change.Id.fromRef(name)) != null) {
-        // Change ref is visible only if the change is visible.
-        if (viewMetadata || visible(repo, changeId)) {
-          logger.atFinest().log("Include change ref %s", name);
-          resultRefs.add(ref);
-        } else {
-          logger.atFinest().log("Filter out change ref %s", name);
-        }
-      } else if ((accountId = Account.Id.fromRef(name)) != null) {
-        // Account ref is visible only to the corresponding account.
-        if (viewMetadata || (accountId.equals(userId) && canReadRef(name))) {
-          logger.atFinest().log("Include user ref %s", name);
-          resultRefs.add(ref);
-        } else {
-          logger.atFinest().log("Filter out user ref %s", name);
-        }
-      } else if ((accountGroupUuid = AccountGroup.UUID.fromRef(name)) != null) {
-        // Group ref is visible only to the corresponding owner group.
-        InternalGroup group = groupCache.get(accountGroupUuid).orElse(null);
-        if (viewMetadata
-            || (group != null
-                && isGroupOwner(group, identifiedUser, isAdmin)
-                && canReadRef(name))) {
-          logger.atFinest().log("Include group ref %s", name);
-          resultRefs.add(ref);
-        } else {
-          logger.atFinest().log("Filter out group ref %s", name);
-        }
+      if (opts.filterMeta() && isMetadata(refName)) {
+        logger.atFinest().log("Filter out metadata ref %s", refName);
       } else if (isTag(ref)) {
         if (hasReadOnRefsStar) {
-          // The user has READ on refs/*. This is the broadest permission one can assign. There is
-          // no way to grant access to (specific) tags in Gerrit, so we have to assume that these
-          // users can see all tags because there could be tags that aren't reachable by any visible
-          // ref while the user can see all non-Gerrit refs. This matches Gerrit's historic
-          // behavior.
+          // The user has READ on refs/* with no effective block permission. This is the broadest
+          // permission one can assign. There is no way to grant access to (specific) tags in
+          // Gerrit,
+          // so we have to assume that these users can see all tags because there could be tags that
+          // aren't reachable by any visible ref while the user can see all non-Gerrit refs. This
+          // matches Gerrit's historic behavior.
           // This makes it so that these users could see commits that they can't see otherwise
           // (e.g. a private change ref) if a tag was attached to it. Tags are meant to be used on
           // the regular Git tree that users interact with, not on any of the Gerrit trees, so this
           // is a negligible risk.
-          logger.atFinest().log("Include tag ref %s because user has read on refs/*", name);
+          logger.atFinest().log("Include tag ref %s because user has read on refs/*", refName);
           resultRefs.add(ref);
         } else {
           // If its a tag, consider it later.
           if (ref.getObjectId() != null) {
-            logger.atFinest().log("Defer tag ref %s", name);
+            logger.atFinest().log("Defer tag ref %s", refName);
             deferredTags.add(ref);
           } else {
-            logger.atFinest().log("Filter out tag ref %s that is not a tag", name);
+            logger.atFinest().log("Filter out tag ref %s that is not a tag", refName);
           }
         }
-      } else if (name.startsWith(RefNames.REFS_SEQUENCES)) {
-        // Sequences are internal database implementation details.
-        if (viewMetadata) {
-          logger.atFinest().log("Include sequence ref %s", name);
+      } else if ((changeId = Change.Id.fromRef(refName)) != null) {
+        // This is a mere performance optimization. RefVisibilityControl could determine the
+        // visibility of these refs just fine. But instead, we use highly-optimized logic that
+        // looks only on the last 10k most recent changes using the change index and a cache.
+        if (hasAccessDatabase) {
           resultRefs.add(ref);
+        } else if (!visible(repo, changeId)) {
+          logger.atFinest().log("Filter out invisible change ref %s", refName);
+        } else if (RefNames.isRefsEdit(refName) && !visibleEdit(repo, refName)) {
+          logger.atFinest().log("Filter out invisible change edit ref %s", refName);
         } else {
-          logger.atFinest().log("Filter out sequence ref %s", name);
-        }
-      } else if (projectState.isAllUsers()
-          && (name.equals(RefNames.REFS_EXTERNAL_IDS) || name.equals(RefNames.REFS_GROUPNAMES))) {
-        // The notes branches with the external IDs / group names must not be exposed to normal
-        // users.
-        if (viewMetadata) {
-          logger.atFinest().log("Include external IDs branch %s", name);
+          // Change is visible
           resultRefs.add(ref);
-        } else {
-          logger.atFinest().log("Filter out external IDs branch %s", name);
         }
-      } else if (canReadRef(ref.getLeaf().getName())) {
-        // Use the leaf to lookup the control data. If the reference is
-        // symbolic we want the control around the final target. If its
-        // not symbolic then getLeaf() is a no-op returning ref itself.
-        logger.atFinest().log(
-            "Include ref %s because its leaf %s is readable", name, ref.getLeaf().getName());
+      } else if (refVisibilityControl.isVisible(projectControl, ref.getLeaf().getName())) {
         resultRefs.add(ref);
-      } else if (isRefsUsersSelf(ref)) {
-        // viewMetadata allows to see all account refs, hence refs/users/self should be included as
-        // well
-        if (viewMetadata) {
-          logger.atFinest().log("Include ref %s", REFS_USERS_SELF);
-          resultRefs.add(ref);
-        }
-      } else {
-        logger.atFinest().log("Filter out ref %s", name);
       }
     }
     Result result = new AutoValue_DefaultRefFilter_Result(resultRefs, deferredTags);
@@ -374,7 +291,8 @@
               r ->
                   !RefNames.isGerritRef(r.getName())
                       && !r.getName().startsWith(RefNames.REFS_TAGS)
-                      && !r.isSymbolic())
+                      && !r.isSymbolic()
+                      && !r.getName().equals(RefNames.REFS_CONFIG))
           // Don't use the default Java Collections.toList() as that is not size-aware and would
           // expand an array list as new elements are added. Instead, provide a list that has the
           // right size. This spares incremental list expansion which is quadratic in complexity.
@@ -520,10 +438,6 @@
     return ref.getLeaf().getName().startsWith(Constants.R_TAGS);
   }
 
-  private static boolean isRefsUsersSelf(Ref ref) {
-    return ref.getName().startsWith(REFS_USERS_SELF);
-  }
-
   private boolean canReadRef(String ref) throws PermissionBackendException {
     try {
       permissionBackendForProject.ref(ref).check(RefPermission.READ);
@@ -544,17 +458,6 @@
     return true;
   }
 
-  private boolean isGroupOwner(
-      InternalGroup group, @Nullable IdentifiedUser user, boolean isAdmin) {
-    requireNonNull(group);
-
-    // Keep this logic in sync with GroupControl#isOwner().
-    boolean isGroupOwner =
-        isAdmin || (user != null && user.getEffectiveGroups().contains(group.getOwnerGroupUUID()));
-    logger.atFinest().log("User is owner of group %s = %s", group.getGroupUUID(), isGroupOwner);
-    return isGroupOwner;
-  }
-
   /**
    * Returns true if the user can see the provided change ref. Uses NoteDb for evaluation, hence
    * does not suffer from the limitations documented in {@link SearchingChangeCacheImpl}.
diff --git a/java/com/google/gerrit/server/permissions/PermissionBackend.java b/java/com/google/gerrit/server/permissions/PermissionBackend.java
index eceb970..27c6793 100644
--- a/java/com/google/gerrit/server/permissions/PermissionBackend.java
+++ b/java/com/google/gerrit/server/permissions/PermissionBackend.java
@@ -60,9 +60,9 @@
  * <p>{@code PermissionBackend} is a singleton for the server, acting as a factory for lightweight
  * request instances. Implementation classes may cache supporting data inside of {@link WithUser},
  * {@link ForProject}, {@link ForRef}, and {@link ForChange} instances, in addition to storing
- * within {@link CurrentUser} using a {@link com.google.gerrit.server.CurrentUser.PropertyKey}.
- * {@link GlobalPermission} caching for {@link WithUser} may best cached inside {@link CurrentUser}
- * as {@link WithUser} instances are frequently created.
+ * within {@link CurrentUser} using a {@link com.google.gerrit.server.PropertyMap.Key}. {@link
+ * GlobalPermission} caching for {@link WithUser} may best cached inside {@link CurrentUser} as
+ * {@link WithUser} instances are frequently created.
  *
  * <p>Example use:
  *
diff --git a/java/com/google/gerrit/server/permissions/ProjectControl.java b/java/com/google/gerrit/server/permissions/ProjectControl.java
index 724017db..a92fde0 100644
--- a/java/com/google/gerrit/server/permissions/ProjectControl.java
+++ b/java/com/google/gerrit/server/permissions/ProjectControl.java
@@ -36,8 +36,10 @@
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.account.GroupMembership;
+import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.config.GitReceivePackGroups;
 import com.google.gerrit.server.config.GitUploadPackGroups;
+import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.group.SystemGroupBackend;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.permissions.PermissionBackend.ForChange;
@@ -68,11 +70,14 @@
   private final Set<AccountGroup.UUID> uploadGroups;
   private final Set<AccountGroup.UUID> receiveGroups;
   private final PermissionBackend permissionBackend;
+  private final RefVisibilityControl refVisibilityControl;
+  private final GitRepositoryManager repositoryManager;
   private final CurrentUser user;
   private final ProjectState state;
   private final PermissionCollection.Factory permissionFilter;
   private final DefaultRefFilter.Factory refFilterFactory;
   private final ChangeData.Factory changeDataFactory;
+  private final AllUsersName allUsersName;
 
   private List<SectionMatcher> allSections;
   private Map<String, RefControl> refControls;
@@ -84,16 +89,22 @@
       @GitReceivePackGroups Set<AccountGroup.UUID> receiveGroups,
       PermissionCollection.Factory permissionFilter,
       PermissionBackend permissionBackend,
+      RefVisibilityControl refVisibilityControl,
+      GitRepositoryManager repositoryManager,
       DefaultRefFilter.Factory refFilterFactory,
       ChangeData.Factory changeDataFactory,
+      AllUsersName allUsersName,
       @Assisted CurrentUser who,
       @Assisted ProjectState ps) {
     this.uploadGroups = uploadGroups;
     this.receiveGroups = receiveGroups;
     this.permissionFilter = permissionFilter;
     this.permissionBackend = permissionBackend;
+    this.refVisibilityControl = refVisibilityControl;
+    this.repositoryManager = repositoryManager;
     this.refFilterFactory = refFilterFactory;
     this.changeDataFactory = changeDataFactory;
+    this.allUsersName = allUsersName;
     user = who;
     state = ps;
   }
@@ -117,7 +128,9 @@
     RefControl ctl = refControls.get(refName);
     if (ctl == null) {
       PermissionCollection relevant = permissionFilter.filter(access(), refName, user);
-      ctl = new RefControl(changeDataFactory, this, refName, relevant);
+      ctl =
+          new RefControl(
+              changeDataFactory, refVisibilityControl, this, repositoryManager, refName, relevant);
       refControls.put(refName, ctl);
     }
     return ctl;
@@ -164,7 +177,9 @@
   }
 
   boolean allRefsAreVisible(Set<String> ignore) {
-    return user.isInternalUser() || canPerformOnAllRefs(Permission.READ, ignore);
+    return user.isInternalUser()
+        || (!getProject().getNameKey().equals(allUsersName)
+            && canPerformOnAllRefs(Permission.READ, ignore));
   }
 
   /** Can the user run upload pack? */
@@ -442,7 +457,7 @@
           return canPushToAtLeastOneRef();
 
         case READ_CONFIG:
-          return controlForRef(RefNames.REFS_CONFIG).isVisible();
+          return controlForRef(RefNames.REFS_CONFIG).hasReadPermissionOnRef(false);
 
         case BAN_COMMIT:
         case READ_REFLOG:
diff --git a/java/com/google/gerrit/server/permissions/RefControl.java b/java/com/google/gerrit/server/permissions/RefControl.java
index e704a99..ad4188f 100644
--- a/java/com/google/gerrit/server/permissions/RefControl.java
+++ b/java/com/google/gerrit/server/permissions/RefControl.java
@@ -16,6 +16,7 @@
 
 import static com.google.common.base.Preconditions.checkArgument;
 
+import com.google.common.collect.ImmutableList;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Permission;
@@ -28,6 +29,7 @@
 import com.google.gerrit.extensions.conditions.BooleanCondition;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.logging.CallerFinder;
 import com.google.gerrit.server.logging.LoggingContext;
 import com.google.gerrit.server.notedb.ChangeNotes;
@@ -35,17 +37,24 @@
 import com.google.gerrit.server.permissions.PermissionBackend.ForRef;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.util.MagicBranch;
+import java.io.IOException;
 import java.util.Collection;
 import java.util.EnumSet;
 import java.util.List;
 import java.util.Set;
+import java.util.concurrent.TimeUnit;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
 
 /** Manages access control for Git references (aka branches, tags). */
 class RefControl {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   private final ChangeData.Factory changeDataFactory;
+  private final RefVisibilityControl refVisibilityControl;
   private final ProjectControl projectControl;
+  private final GitRepositoryManager repositoryManager;
   private final String refName;
 
   /** All permissions that apply to this reference. */
@@ -58,15 +67,19 @@
   private Boolean owner;
   private Boolean canForgeAuthor;
   private Boolean canForgeCommitter;
-  private Boolean isVisible;
+  private Boolean hasReadPermissionOnRef;
 
   RefControl(
       ChangeData.Factory changeDataFactory,
+      RefVisibilityControl refVisibilityControl,
       ProjectControl projectControl,
+      GitRepositoryManager repositoryManager,
       String ref,
       PermissionCollection relevant) {
     this.changeDataFactory = changeDataFactory;
+    this.refVisibilityControl = refVisibilityControl;
     this.projectControl = projectControl;
+    this.repositoryManager = repositoryManager;
     this.refName = ref;
     this.relevant = relevant;
     this.callerFinder =
@@ -99,12 +112,27 @@
     return owner;
   }
 
-  /** Can this user see this reference exists? */
-  boolean isVisible() {
-    if (isVisible == null) {
-      isVisible = getUser().isInternalUser() || canPerform(Permission.READ);
+  /**
+   * Returns {@code true} if the user has permission to read the ref. This method evaluates {@link
+   * RefPermission#READ} only. Hence, it is not authoritative. For example, it does not tell if the
+   * user can see NoteDb refs such as {@code refs/meta/external-ids} which requires {@link
+   * GlobalPermission#ACCESS_DATABASE} and deny access in this case.
+   */
+  boolean hasReadPermissionOnRef(boolean allowNoteDbRefs) {
+    // Don't allow checking for NoteDb refs unless instructed otherwise.
+    if (!allowNoteDbRefs
+        && (refName.startsWith(Constants.R_TAGS) || RefNames.isGerritRef(refName))) {
+      logger.atWarning().atMostEvery(30, TimeUnit.SECONDS).log(
+          "%s: Can't determine visibility of %s in RefControl. Denying access. "
+              + "This case should have been handled before.",
+          projectControl.getProject().getName(), refName);
+      return false;
     }
-    return isVisible;
+
+    if (hasReadPermissionOnRef == null) {
+      hasReadPermissionOnRef = getUser().isInternalUser() || canPerform(Permission.READ);
+    }
+    return hasReadPermissionOnRef;
   }
 
   /** @return true if this user can add a new patch set to this ref */
@@ -591,7 +619,10 @@
     private boolean can(RefPermission perm) throws PermissionBackendException {
       switch (perm) {
         case READ:
-          return isVisible();
+          if (refName.startsWith(Constants.R_TAGS)) {
+            return isTagVisible();
+          }
+          return refVisibilityControl.isVisible(projectControl, refName);
         case CREATE:
           // TODO This isn't an accurate test.
           return canPerform(refPermissionName(perm));
@@ -641,6 +672,38 @@
       }
       throw new PermissionBackendException(perm + " unsupported");
     }
+
+    private boolean isTagVisible() throws PermissionBackendException {
+      if (projectControl.asForProject().test(ProjectPermission.READ)) {
+        // The user has READ on refs/* with no effective block permission. This is the broadest
+        // permission one can assign. There is no way to grant access to (specific) tags in Gerrit,
+        // so we have to assume that these users can see all tags because there could be tags that
+        // aren't reachable by any visible ref while the user can see all non-Gerrit refs. This
+        // matches Gerrit's historic behavior.
+        // This makes it so that these users could see commits that they can't see otherwise
+        // (e.g. a private change ref) if a tag was attached to it. Tags are meant to be used on
+        // the regular Git tree that users interact with, not on any of the Gerrit trees, so this
+        // is a negligible risk.
+        return true;
+      }
+
+      try (Repository repo =
+          repositoryManager.openRepository(projectControl.getProject().getNameKey())) {
+        // Tag visibility requires going through RefFilter because it entails loading all taggable
+        // refs and filtering them all by visibility.
+        Ref resolvedRef = repo.getRefDatabase().exactRef(refName);
+        if (resolvedRef == null) {
+          return false;
+        }
+        return projectControl.asForProject()
+            .filter(
+                ImmutableList.of(resolvedRef), repo, PermissionBackend.RefFilterOptions.defaults())
+            .stream()
+            .anyMatch(r -> refName.equals(r.getName()));
+      } catch (IOException e) {
+        throw new PermissionBackendException(e);
+      }
+    }
   }
 
   private static String refPermissionName(RefPermission refPermission) {
diff --git a/java/com/google/gerrit/server/permissions/RefVisibilityControl.java b/java/com/google/gerrit/server/permissions/RefVisibilityControl.java
new file mode 100644
index 0000000..4744037
--- /dev/null
+++ b/java/com/google/gerrit/server/permissions/RefVisibilityControl.java
@@ -0,0 +1,181 @@
+// 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.
+
+package com.google.gerrit.server.permissions;
+
+import static com.google.common.base.Preconditions.checkState;
+import static com.google.gerrit.entities.RefNames.REFS_CACHE_AUTOMERGE;
+
+import com.google.common.base.Throwables;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.exceptions.NoSuchGroupException;
+import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.account.GroupControl;
+import com.google.gerrit.server.project.NoSuchChangeException;
+import com.google.gerrit.server.query.change.ChangeData;
+import javax.inject.Inject;
+import javax.inject.Singleton;
+import org.eclipse.jgit.lib.Constants;
+
+/**
+ * This class is a component that is internal to {@link DefaultPermissionBackend}. It can
+ * authoritatively tell if a ref is accessible by a user.
+ */
+@Singleton
+class RefVisibilityControl {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  private final PermissionBackend permissionBackend;
+  private final GroupControl.GenericFactory groupControlFactory;
+  private final ChangeData.Factory changeDataFactory;
+
+  @Inject
+  RefVisibilityControl(
+      PermissionBackend permissionBackend,
+      GroupControl.GenericFactory groupControlFactory,
+      ChangeData.Factory changeDataFactory) {
+    this.permissionBackend = permissionBackend;
+    this.groupControlFactory = groupControlFactory;
+    this.changeDataFactory = changeDataFactory;
+  }
+
+  /**
+   * Returns an authoritative answer if the ref is visible to the user. Does not have support for
+   * tags and will throw a {@link PermissionBackendException} if asked for tags visibility.
+   */
+  boolean isVisible(ProjectControl projectControl, String refName)
+      throws PermissionBackendException {
+    if (refName.startsWith(Constants.R_TAGS)) {
+      throw new PermissionBackendException(
+          "can't check tags through RefVisibilityControl. Use PermissionBackend#filter instead.");
+    }
+    if (!RefNames.isGerritRef(refName)) {
+      // This is not a special Gerrit ref and not a NoteDb ref. Likely, it's just a ref under
+      // refs/heads or another ref the user created. Apply the regular permissions with inheritance.
+      return projectControl.controlForRef(refName).hasReadPermissionOnRef(false);
+    }
+
+    if (refName.startsWith(REFS_CACHE_AUTOMERGE)) {
+      // Internal cache state that is accessible to no one.
+      return false;
+    }
+
+    boolean hasAccessDatabase =
+        permissionBackend
+            .user(projectControl.getUser())
+            .testOrFalse(GlobalPermission.ACCESS_DATABASE);
+    if (hasAccessDatabase) {
+      return true;
+    }
+
+    // Change and change edit visibility
+    Change.Id changeId;
+    if ((changeId = Change.Id.fromRef(refName)) != null) {
+      // Change ref is visible only if the change is visible.
+      ChangeData cd;
+      try {
+        cd = changeDataFactory.create(projectControl.getProject().getNameKey(), changeId);
+        checkState(cd.change().getId().equals(changeId));
+      } catch (StorageException e) {
+        if (Throwables.getCausalChain(e).stream()
+            .anyMatch(e2 -> e2 instanceof NoSuchChangeException)) {
+          // The change was deleted or is otherwise not accessible anymore.
+          // If the caller can see all refs and is allowed to see private changes on refs/, allow
+          // access. This is an escape hatch for receivers of "ref deleted" events.
+          PermissionBackend.ForProject forProject = projectControl.asForProject();
+          return forProject.test(ProjectPermission.READ)
+              && forProject.ref("refs/").test(RefPermission.READ_PRIVATE_CHANGES);
+        }
+        throw new PermissionBackendException(e);
+      }
+      if (RefNames.isRefsEdit(refName)) {
+        // Edits are visible only to the owning user, if change is visible.
+        return visibleEdit(refName, projectControl, cd);
+      }
+      return projectControl.controlFor(cd).isVisible();
+    }
+
+    // Account visibility
+    CurrentUser user = projectControl.getUser();
+    Account.Id currentUserAccountId = user.isIdentifiedUser() ? user.getAccountId() : null;
+    Account.Id accountId;
+    if ((accountId = Account.Id.fromRef(refName)) != null) {
+      // Account ref is visible only to the corresponding account.
+      if (accountId.equals(currentUserAccountId)
+          && projectControl.controlForRef(refName).hasReadPermissionOnRef(true)) {
+        return true;
+      }
+      return false;
+    }
+
+    // Group visibility
+    AccountGroup.UUID accountGroupUuid;
+    if ((accountGroupUuid = AccountGroup.UUID.fromRef(refName)) != null) {
+      // Group ref is visible only to the corresponding owner group.
+      try {
+        return projectControl.controlForRef(refName).hasReadPermissionOnRef(true)
+            && groupControlFactory.controlFor(user, accountGroupUuid).isOwner();
+      } catch (NoSuchGroupException e) {
+        // The group is broken, but the ref is still around. Pretend the ref is not visible.
+        logger.atWarning().withCause(e).log("Found group ref %s but group isn't parsable", refName);
+        return false;
+      }
+    }
+
+    // We are done checking all cases where we would allow access to Gerrit-managed refs. Deny
+    // access in case we got this far.
+    logger.atFine().log(
+        "Denying access to %s because user doesn't have access to this Gerrit ref", refName);
+    return false;
+  }
+
+  private boolean visibleEdit(String refName, ProjectControl projectControl, ChangeData cd)
+      throws PermissionBackendException {
+    Change.Id id = Change.Id.fromEditRefPart(refName);
+    if (id == null) {
+      throw new IllegalStateException("unable to parse change id from edit ref " + refName);
+    }
+
+    if (!projectControl.controlFor(cd).isVisible()) {
+      // The user can't see the change so they can't see any edits.
+      return false;
+    }
+
+    if (projectControl.getUser().isIdentifiedUser()
+        && refName.startsWith(
+            RefNames.refsEditPrefix(projectControl.getUser().asIdentifiedUser().getAccountId()))) {
+      logger.atFinest().log("Own change edit ref is visible: %s", refName);
+      return true;
+    }
+
+    try {
+      // Default to READ_PRIVATE_CHANGES as there is no special permission for reading edits.
+      projectControl
+          .asForProject()
+          .ref(cd.change().getDest().branch())
+          .check(RefPermission.READ_PRIVATE_CHANGES);
+      logger.atFinest().log("Foreign change edit ref is visible: %s", refName);
+      return true;
+    } catch (AuthException e) {
+      logger.atFinest().log("Foreign change edit ref is not visible: %s", refName);
+      return false;
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/project/ProjectConfig.java b/java/com/google/gerrit/server/project/ProjectConfig.java
index 54d9176..89038e2 100644
--- a/java/com/google/gerrit/server/project/ProjectConfig.java
+++ b/java/com/google/gerrit/server/project/ProjectConfig.java
@@ -1425,7 +1425,7 @@
           if (group.getUUID() != null) {
             keepGroups.add(group.getUUID());
           }
-          rules.add(rule.asString(needRange));
+          rules.add(rule.toBuilder().setGroup(group).build().asString(needRange));
         }
         rc.setStringList(CAPABILITY, null, permission.getName(), rules);
       }
@@ -1470,7 +1470,7 @@
           if (group.getUUID() != null) {
             keepGroups.add(group.getUUID());
           }
-          rules.add(rule.asString(needRange));
+          rules.add(rule.toBuilder().setGroup(group).build().asString(needRange));
         }
         rc.setStringList(ACCESS, refName, permission.getName(), rules);
       }
diff --git a/java/com/google/gerrit/server/restapi/change/DeleteChangeMessage.java b/java/com/google/gerrit/server/restapi/change/DeleteChangeMessage.java
index f79209d..5b44957 100644
--- a/java/com/google/gerrit/server/restapi/change/DeleteChangeMessage.java
+++ b/java/com/google/gerrit/server/restapi/change/DeleteChangeMessage.java
@@ -23,6 +23,7 @@
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.changes.DeleteChangeMessageInput;
 import com.google.gerrit.extensions.common.ChangeMessageInfo;
 import com.google.gerrit.extensions.common.Input;
@@ -93,13 +94,17 @@
     }
 
     ChangeMessageInfo updatedMessageInfo =
-        createUpdatedChangeMessageInfo(resource.getChangeId(), resource.getChangeMessageIndex());
+        createUpdatedChangeMessageInfo(
+            resource.getChangeResource().getId(),
+            resource.getChangeResource().getProject(),
+            resource.getChangeMessageIndex());
     return Response.created(updatedMessageInfo);
   }
 
-  private ChangeMessageInfo createUpdatedChangeMessageInfo(Change.Id id, int targetIdx)
-      throws PermissionBackendException {
-    List<ChangeMessage> messages = changeMessagesUtil.byChange(notesFactory.createChecked(id));
+  private ChangeMessageInfo createUpdatedChangeMessageInfo(
+      Change.Id cId, Project.NameKey project, int targetIdx) throws PermissionBackendException {
+    List<ChangeMessage> messages =
+        changeMessagesUtil.byChange(notesFactory.createChecked(project, cId));
     ChangeMessage updatedChangeMessage = messages.get(targetIdx);
     AccountLoader accountLoader = accountLoaderFactory.create(true);
     ChangeMessageInfo info = createChangeMessageInfo(updatedChangeMessage, accountLoader);
diff --git a/java/com/google/gerrit/server/restapi/change/DeleteComment.java b/java/com/google/gerrit/server/restapi/change/DeleteComment.java
index 8580229..044fd77 100644
--- a/java/com/google/gerrit/server/restapi/change/DeleteComment.java
+++ b/java/com/google/gerrit/server/restapi/change/DeleteComment.java
@@ -89,7 +89,9 @@
     }
 
     ChangeNotes updatedNotes =
-        notesFactory.createChecked(rsrc.getRevisionResource().getChange().getId());
+        notesFactory.createChecked(
+            rsrc.getRevisionResource().getProject(),
+            rsrc.getRevisionResource().getChangeResource().getId());
     List<HumanComment> changeComments = commentsUtil.publishedHumanCommentsByChange(updatedNotes);
     Optional<HumanComment> updatedComment =
         changeComments.stream().filter(c -> c.key.equals(rsrc.getComment().key)).findFirst();
diff --git a/java/com/google/gerrit/server/restapi/change/GetCommit.java b/java/com/google/gerrit/server/restapi/change/GetCommit.java
index 21a08dc..d76ce04 100644
--- a/java/com/google/gerrit/server/restapi/change/GetCommit.java
+++ b/java/com/google/gerrit/server/restapi/change/GetCommit.java
@@ -58,7 +58,13 @@
       rw.parseBody(commit);
       CommitInfo info =
           json.create(ImmutableSet.of())
-              .getCommitInfo(rsrc.getProject(), rw, commit, addLinks, true);
+              .getCommitInfo(
+                  rsrc.getProject(),
+                  rw,
+                  commit,
+                  addLinks,
+                  /* fillCommit= */ true,
+                  rsrc.getChange().getDest().branch());
       Response<CommitInfo> r = Response.ok(info);
       if (rsrc.isCacheable()) {
         r.caching(CacheControl.PRIVATE(7, TimeUnit.DAYS));
diff --git a/java/com/google/gerrit/server/restapi/change/GetMergeList.java b/java/com/google/gerrit/server/restapi/change/GetMergeList.java
index 0c67fd6..f0639b5 100644
--- a/java/com/google/gerrit/server/restapi/change/GetMergeList.java
+++ b/java/com/google/gerrit/server/restapi/change/GetMergeList.java
@@ -81,7 +81,14 @@
       List<CommitInfo> result = new ArrayList<>(commits.size());
       RevisionJson changeJson = json.create(ImmutableSet.of());
       for (RevCommit c : commits) {
-        result.add(changeJson.getCommitInfo(rsrc.getProject(), rw, c, addLinks, true));
+        result.add(
+            changeJson.getCommitInfo(
+                rsrc.getProject(),
+                rw,
+                c,
+                addLinks,
+                /* fillCommit= */ true,
+                rsrc.getChange().getDest().branch()));
       }
       return createResponse(rsrc, result);
     }
diff --git a/java/com/google/gerrit/server/restapi/change/RevertSubmission.java b/java/com/google/gerrit/server/restapi/change/RevertSubmission.java
index ca39a57..cb91faa 100644
--- a/java/com/google/gerrit/server/restapi/change/RevertSubmission.java
+++ b/java/com/google/gerrit/server/restapi/change/RevertSubmission.java
@@ -358,7 +358,11 @@
         commitUtil.createRevertChange(changeNotes, user.get(), revertInput, timestamp);
     results.add(json.noOptions().format(changeNotes.getProjectName(), revertId));
     cherryPickInput.base =
-        changeNotesFactory.createChecked(revertId).getCurrentPatchSet().commitId().getName();
+        changeNotesFactory
+            .createChecked(changeNotes.getProjectName(), revertId)
+            .getCurrentPatchSet()
+            .commitId()
+            .getName();
   }
 
   private CherryPickInput createCherryPickInput(RevertInput revertInput) {
@@ -591,7 +595,7 @@
       // save the commit as base for next cherryPick of that branch
       cherryPickInput.base =
           changeNotesFactory
-              .createChecked(cherryPickResult.changeId())
+              .createChecked(ctx.getProject(), cherryPickResult.changeId())
               .getCurrentPatchSet()
               .commitId()
               .getName();
@@ -612,7 +616,9 @@
     @Override
     public void postUpdate(Context ctx) throws Exception {
       changeReverted.fire(
-          change, changeNotesFactory.createChecked(revertChangeId).getChange(), ctx.getWhen());
+          change,
+          changeNotesFactory.createChecked(ctx.getProject(), revertChangeId).getChange(),
+          ctx.getWhen());
       try {
         RevertedSender emailSender = revertedSenderFactory.create(ctx.getProject(), change.getId());
         emailSender.setFrom(ctx.getAccountId());
diff --git a/java/com/google/gerrit/sshd/BaseCommand.java b/java/com/google/gerrit/sshd/BaseCommand.java
index 0dbae0a..fe03770 100644
--- a/java/com/google/gerrit/sshd/BaseCommand.java
+++ b/java/com/google/gerrit/sshd/BaseCommand.java
@@ -235,6 +235,8 @@
   protected void parseCommandLine(Object options, DynamicOptions pluginOptions)
       throws UnloggedFailure {
     final CmdLineParser clp = newCmdLineParser(options);
+    pluginOptions.setBean(options);
+    pluginOptions.startLifecycleListeners();
     pluginOptions.parseDynamicBeans(clp);
     pluginOptions.setDynamicBeans();
     pluginOptions.onBeanParseStart();
@@ -468,8 +470,7 @@
 
           try {
             if (thunk instanceof ProjectCommandRunnable) {
-              try (DynamicOptions pluginOptions =
-                  new DynamicOptions(BaseCommand.this, injector, dynamicBeans)) {
+              try (DynamicOptions pluginOptions = new DynamicOptions(injector, dynamicBeans)) {
                 ((ProjectCommandRunnable) thunk).executeParseCommand(pluginOptions);
                 projectName = ((ProjectCommandRunnable) thunk).getProjectName();
                 thunk.run();
diff --git a/java/com/google/gerrit/sshd/ChangeArgumentParser.java b/java/com/google/gerrit/sshd/ChangeArgumentParser.java
index 4a1489739..92019ad 100644
--- a/java/com/google/gerrit/sshd/ChangeArgumentParser.java
+++ b/java/com/google/gerrit/sshd/ChangeArgumentParser.java
@@ -116,7 +116,7 @@
   }
 
   private List<ChangeNotes> changeFromNotesFactory(String id) throws UnloggedFailure {
-    return changeNotesFactory.create(parseId(id));
+    return changeNotesFactory.createUsingIndexLookup(parseId(id));
   }
 
   private List<Change.Id> parseId(String id) throws UnloggedFailure {
diff --git a/java/com/google/gerrit/sshd/DispatchCommand.java b/java/com/google/gerrit/sshd/DispatchCommand.java
index a45cd31..54171a3 100644
--- a/java/com/google/gerrit/sshd/DispatchCommand.java
+++ b/java/com/google/gerrit/sshd/DispatchCommand.java
@@ -72,8 +72,7 @@
 
   @Override
   public void start(ChannelSession channel, Environment env) throws IOException {
-    try (DynamicOptions pluginOptions =
-        new DynamicOptions(DispatchCommand.this, injector, dynamicBeans)) {
+    try (DynamicOptions pluginOptions = new DynamicOptions(injector, dynamicBeans)) {
       parseCommandLine(pluginOptions);
       if (Strings.isNullOrEmpty(commandName)) {
         StringWriter msg = new StringWriter();
diff --git a/java/com/google/gerrit/sshd/SshCommand.java b/java/com/google/gerrit/sshd/SshCommand.java
index 3ef7061..c94b25c 100644
--- a/java/com/google/gerrit/sshd/SshCommand.java
+++ b/java/com/google/gerrit/sshd/SshCommand.java
@@ -50,8 +50,7 @@
   public void start(ChannelSession channel, Environment env) throws IOException {
     startThread(
         () -> {
-          try (DynamicOptions pluginOptions =
-              new DynamicOptions(SshCommand.this, injector, dynamicBeans)) {
+          try (DynamicOptions pluginOptions = new DynamicOptions(injector, dynamicBeans)) {
             parseCommandLine(pluginOptions);
             stdout = toPrintWriter(out);
             stderr = toPrintWriter(err);
diff --git a/java/com/google/gerrit/sshd/SuExec.java b/java/com/google/gerrit/sshd/SuExec.java
index bf785bb..3c6e8c2 100644
--- a/java/com/google/gerrit/sshd/SuExec.java
+++ b/java/com/google/gerrit/sshd/SuExec.java
@@ -93,7 +93,7 @@
 
   @Override
   public void start(ChannelSession channel, Environment env) throws IOException {
-    try (DynamicOptions pluginOptions = new DynamicOptions(SuExec.this, injector, dynamicBeans)) {
+    try (DynamicOptions pluginOptions = new DynamicOptions(injector, dynamicBeans)) {
       checkCanRunAs();
       parseCommandLine(pluginOptions);
 
diff --git a/java/com/google/gerrit/sshd/commands/StreamEvents.java b/java/com/google/gerrit/sshd/commands/StreamEvents.java
index 188cc83..c47d24c 100644
--- a/java/com/google/gerrit/sshd/commands/StreamEvents.java
+++ b/java/com/google/gerrit/sshd/commands/StreamEvents.java
@@ -108,8 +108,7 @@
 
   @Override
   public void start(ChannelSession channel, Environment env) throws IOException {
-    try (DynamicOptions pluginOptions =
-        new DynamicOptions(StreamEvents.this, injector, dynamicBeans)) {
+    try (DynamicOptions pluginOptions = new DynamicOptions(injector, dynamicBeans)) {
       try {
         parseCommandLine(pluginOptions);
       } catch (UnloggedFailure e) {
diff --git a/java/com/google/gerrit/util/ssl/BlindSSLSocketFactory.java b/java/com/google/gerrit/util/ssl/BlindSSLSocketFactory.java
index 6dc1006..88845ef 100644
--- a/java/com/google/gerrit/util/ssl/BlindSSLSocketFactory.java
+++ b/java/com/google/gerrit/util/ssl/BlindSSLSocketFactory.java
@@ -20,7 +20,6 @@
 import java.net.UnknownHostException;
 import java.security.GeneralSecurityException;
 import java.security.SecureRandom;
-import java.security.cert.X509Certificate;
 import javax.net.SocketFactory;
 import javax.net.ssl.SSLContext;
 import javax.net.ssl.SSLSocketFactory;
@@ -32,19 +31,7 @@
   private static final BlindSSLSocketFactory INSTANCE;
 
   static {
-    final X509TrustManager dummyTrustManager =
-        new X509TrustManager() {
-          @Override
-          public X509Certificate[] getAcceptedIssuers() {
-            return null;
-          }
-
-          @Override
-          public void checkClientTrusted(X509Certificate[] chain, String authType) {}
-
-          @Override
-          public void checkServerTrusted(X509Certificate[] chain, String authType) {}
-        };
+    final X509TrustManager dummyTrustManager = new BlindTrustManager();
 
     try {
       final SSLContext context = SSLContext.getInstance("SSL");
diff --git a/java/com/google/gerrit/util/ssl/BlindTrustManager.java b/java/com/google/gerrit/util/ssl/BlindTrustManager.java
new file mode 100644
index 0000000..2db091a
--- /dev/null
+++ b/java/com/google/gerrit/util/ssl/BlindTrustManager.java
@@ -0,0 +1,33 @@
+// 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.
+
+package com.google.gerrit.util.ssl;
+
+import java.security.cert.X509Certificate;
+import javax.net.ssl.X509TrustManager;
+
+/** TrustManager implementation that accepts all certificates without validation. */
+public class BlindTrustManager implements X509TrustManager {
+
+  @Override
+  public X509Certificate[] getAcceptedIssuers() {
+    return null;
+  }
+
+  @Override
+  public void checkClientTrusted(X509Certificate[] chain, String authType) {}
+
+  @Override
+  public void checkServerTrusted(X509Certificate[] chain, String authType) {}
+}
diff --git a/java/org/apache/commons/net/smtp/AuthSMTPClient.java b/java/org/apache/commons/net/smtp/AuthSMTPClient.java
index 85e4dbf..0f8c1f4 100644
--- a/java/org/apache/commons/net/smtp/AuthSMTPClient.java
+++ b/java/org/apache/commons/net/smtp/AuthSMTPClient.java
@@ -17,68 +17,66 @@
 import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.common.io.BaseEncoding;
-import com.google.gerrit.util.ssl.BlindSSLSocketFactory;
-import java.io.BufferedReader;
-import java.io.BufferedWriter;
+import com.google.gerrit.util.ssl.BlindTrustManager;
 import java.io.IOException;
-import java.io.InputStreamReader;
-import java.io.OutputStreamWriter;
 import java.io.UnsupportedEncodingException;
-import java.net.SocketException;
 import java.security.InvalidKeyException;
 import java.security.NoSuchAlgorithmException;
 import java.util.Arrays;
 import java.util.List;
 import javax.crypto.Mac;
 import javax.crypto.spec.SecretKeySpec;
-import javax.net.ssl.SSLParameters;
-import javax.net.ssl.SSLSocket;
-import javax.net.ssl.SSLSocketFactory;
 
-public class AuthSMTPClient extends SMTPClient {
+/**
+ * SMTP Client with authentication support and optional SSL processing and verification. {@link
+ * org.apache.commons.net.smtp.SMTPSClient} is used for the SSL handshake and hostname verification.
+ *
+ * <p>If shouldHandshakeOnConnect mode is selected, SSL/TLS negotiation starts right after the
+ * connection has been established. Otherwise SSL/TLS negotiation will only occur if {@link
+ * AuthSMTPClient#execTLS} is explicitly called and the server accepts the command.
+ *
+ * <p>Examples:
+ *
+ * <ul>
+ *   <li>For SSL connection:
+ *       <pre>
+ *       AuthSMTPClient c = new AuthSMTPClient(true, sslVerify);
+ *       c.connect("127.0.0.1", 465);
+ *     </pre>
+ *   <li>For TLS connection:
+ *       <pre>
+ *       AuthSMTPClient c = new AuthSMTPClient(false, sslVerify);
+ *       c.connect("127.0.0.1", 25);
+ *       if (c.execTLS()) { /rest of the commands here/ }
+ *     </pre>
+ *   <li>If SSL encryption is not required:
+ *       <pre>
+ *       AuthSMTPClient c = new AuthSMTPClient(false, false);
+ *       c.connect("127.0.0.1", port);
+ *     </pre>
+ */
+public class AuthSMTPClient extends SMTPSClient {
+
   private String authTypes;
 
-  public AuthSMTPClient(String charset) {
-    super(charset);
-  }
-
-  public void enableSSL(boolean verify) {
-    _socketFactory_ = sslFactory(verify);
-  }
-
-  public boolean startTLS(String hostname, int port, boolean verify)
-      throws SocketException, IOException {
-    if (sendCommand("STARTTLS") != 220) {
-      return false;
+  /**
+   * Constructs AuthSMTPClient.
+   *
+   * @param shouldHandshakeOnConnect the SSL processing mode, {@code true} if SSL negotiation should
+   *     start right after connect, {@code false} if it will be started by the user explicitly or
+   *     SSL negotiation is not required.
+   * @param sslVerificationEnabled {@code true} if the SMTP server's SSL certificate and hostname
+   *     should be verified, {@code false} otherwise.
+   */
+  public AuthSMTPClient(boolean shouldHandshakeOnConnect, boolean sslVerificationEnabled) {
+    // If SSL Encryption is required, SMTPSClient is used for the handshake.
+    // Otherwise, use  SMTPSClient in 'explicit' mode without calling execTLS().
+    // See SMTPSClient._connectAction_ in commons-net-3.6.
+    super("TLS", shouldHandshakeOnConnect, UTF_8.name());
+    this.setEndpointCheckingEnabled(sslVerificationEnabled);
+    if (!sslVerificationEnabled) {
+      this.setTrustManager(new BlindTrustManager());
     }
-
-    _socket_ = sslFactory(verify).createSocket(_socket_, hostname, port, true);
-
-    if (verify) {
-      SSLParameters sslParams = new SSLParameters();
-      sslParams.setEndpointIdentificationAlgorithm("HTTPS");
-      ((SSLSocket) _socket_).setSSLParameters(sslParams);
-    }
-
-    // XXX: Can't call _connectAction_() because SMTP server doesn't
-    // give banner information again after STARTTLS, thus SMTP._connectAction_()
-    // will wait on __getReply() forever, see source code of commons-net-2.2.
-    //
-    // The lines below are copied from SocketClient._connectAction_() and
-    // SMTP._connectAction_() in commons-net-2.2.
-    _socket_.setSoTimeout(_timeout_);
-    _input_ = _socket_.getInputStream();
-    _output_ = _socket_.getOutputStream();
-    _reader = new BufferedReader(new InputStreamReader(_input_, UTF_8));
-    _writer = new BufferedWriter(new OutputStreamWriter(_output_, UTF_8));
-    return true;
-  }
-
-  private static SSLSocketFactory sslFactory(boolean verify) {
-    if (verify) {
-      return (SSLSocketFactory) SSLSocketFactory.getDefault();
-    }
-    return (SSLSocketFactory) BlindSSLSocketFactory.getDefault();
   }
 
   @Override
diff --git a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
index ccfa60e..4015ccb 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
@@ -91,6 +91,7 @@
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.Address;
+import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.LabelFunction;
 import com.google.gerrit.entities.LabelType;
@@ -154,6 +155,7 @@
 import com.google.gerrit.server.StarredChangesUtil;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.testing.TestChangeETagComputation;
+import com.google.gerrit.server.git.ChangeMessageModifier;
 import com.google.gerrit.server.group.SystemGroupBackend;
 import com.google.gerrit.server.index.change.ChangeIndex;
 import com.google.gerrit.server.index.change.ChangeIndexCollection;
@@ -1061,6 +1063,11 @@
       com.google.gerrit.acceptance.TestAccount deleteAs)
       throws Exception {
     try {
+      projectOperations
+          .project(projectName)
+          .forUpdate()
+          .add(allow(Permission.VIEW_PRIVATE_CHANGES).ref("refs/*").group(ANONYMOUS_USERS))
+          .update();
       requestScopeOperations.setApiUser(owner.id());
       ChangeInput in = new ChangeInput();
       in.project = projectName.get();
@@ -2253,6 +2260,10 @@
     assertThat(message.body()).contains("Removed reviewer " + user.fullName() + ".");
     assertThat(message.body()).doesNotContain("with the following votes");
 
+    // Make sure the change message for removing a reviewer is correct.
+    assertThat(Iterables.getLast(gApi.changes().id(changeId).messages()).message)
+        .contains("Removed reviewer " + user.fullName());
+
     // Make sure the reviewer can still be added again.
     gApi.changes().id(changeId).addReviewer(user.id().toString());
     c = gApi.changes().id(changeId).get();
@@ -2268,6 +2279,31 @@
   }
 
   @Test
+  public void removeCC() throws Exception {
+    PushOneCommit.Result result = createChange();
+    String changeId = result.getChangeId();
+    // Add a cc
+    AddReviewerInput addReviewerInput = new AddReviewerInput();
+    addReviewerInput.state = CC;
+    addReviewerInput.reviewer = user.id().toString();
+    gApi.changes().id(changeId).addReviewer(addReviewerInput);
+
+    // Remove a cc
+    sender.clear();
+    gApi.changes().id(changeId).reviewer(user.id().toString()).remove();
+    assertThat(gApi.changes().id(changeId).get().reviewers).isEmpty();
+
+    // Make sure the email for removing a cc is correct.
+    assertThat(sender.getMessages()).hasSize(1);
+    Message message = sender.getMessages().get(0);
+    assertThat(message.body()).contains("Removed cc " + user.fullName() + ".");
+
+    // Make sure the change message for removing a reviewer is correct.
+    assertThat(Iterables.getLast(gApi.changes().id(changeId).messages()).message)
+        .contains("Removed cc " + user.fullName());
+  }
+
+  @Test
   public void removeReviewer() throws Exception {
     testRemoveReviewer(true);
   }
@@ -2917,14 +2953,19 @@
   public void customCommitFooters() throws Exception {
     PushOneCommit.Result change = createChange();
     ChangeInfo actual;
-    try (Registration registration =
-        extensionRegistry
-            .newRegistration()
-            .add(
-                (newCommitMessage, original, mergeTip, destination) -> {
-                  assertThat(original.getName()).isNotEqualTo(mergeTip.getName());
-                  return newCommitMessage + "Custom: " + destination.branch();
-                })) {
+    ChangeMessageModifier link =
+        new ChangeMessageModifier() {
+          @Override
+          public String onSubmit(
+              String newCommitMessage,
+              RevCommit original,
+              RevCommit mergeTip,
+              BranchNameKey destination) {
+            assertThat(original.getName()).isNotEqualTo(mergeTip.getName());
+            return newCommitMessage + "Custom: " + destination.branch();
+          }
+        };
+    try (Registration registration = extensionRegistry.newRegistration().add(link)) {
       actual = gApi.changes().id(change.getChangeId()).get(ALL_REVISIONS, COMMIT_FOOTERS);
     }
     List<String> footers =
diff --git a/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java b/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java
index 0d94ce6..0af116a 100644
--- a/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java
@@ -1163,6 +1163,11 @@
 
   @Test
   public void pushToDeletedGroupBranchIsRejectedForAllUsersRepo() throws Exception {
+    // refs/deleted-groups is only visible with ACCESS_DATABASE
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allowCapability(GlobalCapability.ACCESS_DATABASE).group(REGISTERED_USERS))
+        .update();
     String groupRef =
         RefNames.refsDeletedGroups(AccountGroup.uuid(gApi.groups().create(name("foo")).get().id));
     createBranch(allUsers, groupRef);
@@ -1355,6 +1360,11 @@
 
   @Test
   public void cannotDeleteDeletedGroupBranch() throws Exception {
+    // refs/deleted-groups is only visible with ACCESS_DATABASE
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allowCapability(GlobalCapability.ACCESS_DATABASE).group(REGISTERED_USERS))
+        .update();
     String groupRef = RefNames.refsDeletedGroups(AccountGroup.uuid(name("foo")));
     createBranch(allUsers, groupRef);
     testCannotDeleteGroupBranch(RefNames.REFS_DELETED_GROUPS + "*", groupRef);
diff --git a/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java b/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java
index e99a6f5..d5fc1c1 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.acceptance.api.project;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth8.assertThat;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
 import static com.google.gerrit.server.project.ProjectState.INHERITED_FROM_GLOBAL;
@@ -39,7 +40,9 @@
 import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.GroupReference;
 import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
@@ -55,6 +58,7 @@
 import com.google.gerrit.extensions.client.InheritableBoolean;
 import com.google.gerrit.extensions.client.ProjectState;
 import com.google.gerrit.extensions.client.SubmitType;
+import com.google.gerrit.extensions.common.GroupInfo;
 import com.google.gerrit.extensions.common.ProjectInfo;
 import com.google.gerrit.extensions.events.ChangeIndexedListener;
 import com.google.gerrit.extensions.events.ProjectIndexedListener;
@@ -63,6 +67,7 @@
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.server.config.ProjectConfigEntry;
+import com.google.gerrit.server.git.meta.MetaDataUpdate;
 import com.google.gerrit.server.group.SystemGroupBackend;
 import com.google.gerrit.server.project.ProjectConfig;
 import com.google.inject.AbstractModule;
@@ -70,6 +75,7 @@
 import com.google.inject.Module;
 import java.util.HashMap;
 import java.util.Map;
+import java.util.Optional;
 import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Config;
@@ -939,6 +945,42 @@
                 projectOperations.project(allProjects).getHead(RefNames.REFS_CONFIG).name()));
   }
 
+  @Test
+  public void renamingGroupGetsPersisted() throws Exception {
+    String name = name("Name1");
+    GroupInfo group = gApi.groups().create(name).get();
+
+    // Use group in a permission
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.READ).ref(RefNames.REFS_CONFIG).group(AccountGroup.uuid(group.id)))
+        .update();
+    Optional<String> beforeRename =
+        projectCache.get(project).get().getLocalGroups().stream()
+            .filter(g -> g.getUUID().get().equals(group.id))
+            .map(GroupReference::getName)
+            .findAny();
+    // Groups created with ProjectOperations always have their UUID as local name
+    assertThat(beforeRename).hasValue(group.id);
+
+    // Rename the group directly on the project config
+    String newName = name("Name2");
+    try (MetaDataUpdate md = metaDataUpdateFactory.create(project)) {
+      ProjectConfig config = projectConfigFactory.read(md);
+      config.renameGroup(AccountGroup.uuid(group.id), newName);
+      config.commit(md);
+      projectCache.evict(config.getProject());
+    }
+
+    Optional<String> afterRename =
+        projectCache.get(project).get().getLocalGroups().stream()
+            .filter(g -> g.getUUID().get().equals(group.id))
+            .map(GroupReference::getName)
+            .findAny();
+    assertThat(afterRename).hasValue(newName);
+  }
+
   private CommentLinkInfo commentLinkInfo(String name, String match, String link) {
     CommentLinkInfo info = new CommentLinkInfo();
     info.name = name;
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java b/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
index 839b051..7662a54 100644
--- a/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
@@ -1573,7 +1573,8 @@
     PatchSetWebLink link =
         new PatchSetWebLink() {
           @Override
-          public WebLinkInfo getPatchSetWebLink(String projectName, String commit) {
+          public WebLinkInfo getPatchSetWebLink(
+              String projectName, String commit, String subject, String branchName) {
             return expectedWebLinkInfo;
           }
         };
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByCherryPickIT.java b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByCherryPickIT.java
index 5bcf995..a322faf 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByCherryPickIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByCherryPickIT.java
@@ -25,6 +25,7 @@
 import com.google.gerrit.acceptance.TestProjectInput;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.common.FooterConstants;
+import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.extensions.api.changes.SubmitInput;
 import com.google.gerrit.extensions.client.ChangeStatus;
@@ -32,6 +33,7 @@
 import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.server.git.ChangeMessageModifier;
 import com.google.gerrit.server.submit.CommitMergeStatus;
 import com.google.inject.Inject;
 import java.util.List;
@@ -89,12 +91,18 @@
   @Test
   public void changeMessageOnSubmit() throws Throwable {
     PushOneCommit.Result change = createChange();
-    try (Registration registration =
-        extensionRegistry
-            .newRegistration()
-            .add(
-                (newCommitMessage, original, mergeTip, destination) ->
-                    newCommitMessage + "Custom: " + destination.branch())) {
+    ChangeMessageModifier link =
+        new ChangeMessageModifier() {
+          @Override
+          public String onSubmit(
+              String newCommitMessage,
+              RevCommit original,
+              RevCommit mergeTip,
+              BranchNameKey destination) {
+            return newCommitMessage + "Custom: " + destination.branch();
+          }
+        };
+    try (Registration registration = extensionRegistry.newRegistration().add(link)) {
       submit(change.getChangeId());
     }
     testRepo.git().fetch().setRemote("origin").call();
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/AbstractPushTag.java b/javatests/com/google/gerrit/acceptance/rest/project/AbstractPushTag.java
index 191d5c5..531357a 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/AbstractPushTag.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/AbstractPushTag.java
@@ -31,7 +31,9 @@
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.testing.ConfigSuite;
 import com.google.inject.Inject;
+import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.transport.PushResult;
@@ -52,6 +54,13 @@
     }
   }
 
+  @ConfigSuite.Config
+  public static Config skipFalse() {
+    Config config = new Config();
+    config.setBoolean("auth", null, "skipFullRefEvaluationIfAllRefsAreVisible", false);
+    return config;
+  }
+
   @Inject private ProjectOperations projectOperations;
 
   private RevCommit initialHead;
@@ -93,6 +102,20 @@
   }
 
   @Test
+  public void createTagForExistingCommit_withoutGlobalReadPermissions() throws Exception {
+    removeReadAccessOnRefsStar();
+    grantReadAccessOnRefsHeadsStar();
+    createTagForExistingCommit();
+  }
+
+  @Test
+  public void createTagForNewCommit_withoutGlobalReadPermissions() throws Exception {
+    removeReadAccessOnRefsStar();
+    grantReadAccessOnRefsHeadsStar();
+    createTagForNewCommit();
+  }
+
+  @Test
   public void fastForward() throws Exception {
     allowTagCreation();
     String tagName = pushTagForExistingCommit(Status.OK);
@@ -109,6 +132,15 @@
     fastForwardTagToExistingCommit(tagName, expectedStatus);
     fastForwardTagToNewCommit(tagName, expectedStatus);
 
+    // Above we just fast-forwarded the tag to a new commit which is not part of any branch. By
+    // default this tag is not visible, as users can only see tags that point to commits that are
+    // part of visible branches, which is not the case for this tag. It's odd that we allow the user
+    // to create such a tag that is then not visible to the creator. Below we want to fast-forward
+    // this tag, but this is only possible if the tag is visible. To make it visible we must allow
+    // the user to read all tags, regardless of whether it points to a commit that is part of a
+    // visible branch.
+    allowReadingAllTag();
+
     allowForcePushOnRefsTags();
     fastForwardTagToExistingCommit(tagName, Status.OK);
     fastForwardTagToNewCommit(tagName, Status.OK);
@@ -234,6 +266,49 @@
     assertWithMessage(tagType.name()).that(refUpdate.getStatus()).isEqualTo(expectedStatus);
   }
 
+  private void removeReadAccessOnRefsStar() {
+    projectOperations
+        .project(allProjects)
+        .forUpdate()
+        .remove(permissionKey(Permission.READ).ref("refs/*"))
+        .update();
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .remove(permissionKey(Permission.READ).ref("refs/*"))
+        .update();
+  }
+
+  private void grantReadAccessOnRefsHeadsStar() {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.READ).ref("refs/heads/*").group(REGISTERED_USERS))
+        .update();
+  }
+
+  private void allowReadingAllTag() throws Exception {
+    // Tags are only visible if the commits to which they point are part of a visible branch.
+    // To make all tags visible, including tags that point to commits that are not part of a visible
+    // branch, either auth.skipFullRefEvaluationIfAllRefsAreVisible in gerrit.config needs to be
+    // true, or the user must have READ access for all refs in the repository.
+
+    if (cfg.getBoolean("auth", "skipFullRefEvaluationIfAllRefsAreVisible", true)) {
+      return;
+    }
+
+    // By default READ access in the All-Projects project is granted to registered users on refs/*,
+    // which makes all refs, except refs/meta/config, visible to them. refs/meta/config is not
+    // visible since by default READ access to it is exclusively granted to the project owners only.
+    // This means to make all refs, and thus all tags, visible, we must allow registered users to
+    // see the refs/meta/config branch.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.READ).ref("refs/meta/config").group(REGISTERED_USERS))
+        .update();
+  }
+
   private void allowTagCreation() throws Exception {
     projectOperations
         .project(project)
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/GetBranchIT.java b/javatests/com/google/gerrit/acceptance/rest/project/GetBranchIT.java
new file mode 100644
index 0000000..b4b1be0
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/project/GetBranchIT.java
@@ -0,0 +1,594 @@
+// 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.
+
+package com.google.gerrit.acceptance.rest.project;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowCapability;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.capabilityKey;
+import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.acceptance.testsuite.change.ChangeOperations;
+import com.google.gerrit.acceptance.testsuite.group.GroupOperations;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.common.data.GlobalCapability;
+import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Permission;
+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.projects.BranchInfo;
+import com.google.gerrit.extensions.api.projects.TagInfo;
+import com.google.gerrit.extensions.api.projects.TagInput;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.server.group.SystemGroupBackend;
+import com.google.gerrit.server.notedb.Sequences;
+import com.google.gerrit.testing.ConfigSuite;
+import com.google.inject.Inject;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.Repository;
+import org.junit.Test;
+
+public class GetBranchIT extends AbstractDaemonTest {
+  @Inject private ChangeOperations changeOperations;
+  @Inject private GroupOperations groupOperations;
+  @Inject private ProjectOperations projectOperations;
+  @Inject private RequestScopeOperations requestScopeOperations;
+
+  @ConfigSuite.Config
+  public static Config skipFalse() {
+    Config config = new Config();
+    config.setBoolean("auth", null, "skipFullRefEvaluationIfAllRefsAreVisible", false);
+    return config;
+  }
+
+  @Test
+  public void cannotGetNonExistingBranch() {
+    assertBranchNotFound(project, RefNames.fullName("non-existing"));
+  }
+
+  @Test
+  public void getBranch() throws Exception {
+    requestScopeOperations.setApiUser(user.id());
+    assertBranchFound(project, RefNames.fullName("master"));
+  }
+
+  @Test
+  public void getBranchByShortName() throws Exception {
+    requestScopeOperations.setApiUser(user.id());
+    assertBranchFound(project, "master");
+  }
+
+  @Test
+  public void cannotGetNonVisibleBranch() {
+    String branchName = "master";
+
+    // block read access to the branch
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(Permission.READ).ref(RefNames.fullName(branchName)).group(ANONYMOUS_USERS))
+        .update();
+
+    requestScopeOperations.setApiUser(user.id());
+    assertBranchNotFound(project, RefNames.fullName(branchName));
+  }
+
+  @Test
+  public void cannotGetNonVisibleBranchByShortName() {
+    String branchName = "master";
+
+    // block read access to the branch
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(Permission.READ).ref(RefNames.fullName(branchName)).group(ANONYMOUS_USERS))
+        .update();
+
+    requestScopeOperations.setApiUser(user.id());
+    assertBranchNotFound(project, branchName);
+  }
+
+  @Test
+  public void getChangeRef() throws Exception {
+    // create a change
+    Change.Id changeId = changeOperations.newChange().project(project).create();
+
+    // a user without the 'Access Database' capability can see the change ref
+    requestScopeOperations.setApiUser(user.id());
+    String changeRef = RefNames.patchSetRef(PatchSet.id(changeId, 1));
+    assertBranchFound(project, changeRef);
+  }
+
+  @Test
+  public void getChangeRefOfNonVisibleChange() throws Exception {
+    // create a change
+    String branchName = "master";
+    Change.Id changeId = changeOperations.newChange().project(project).branch(branchName).create();
+
+    // block read access to the branch
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(Permission.READ).ref(RefNames.fullName(branchName)).group(ANONYMOUS_USERS))
+        .update();
+
+    // a user without the 'Access Database' capability cannot see the change ref
+    requestScopeOperations.setApiUser(user.id());
+    String changeRef = RefNames.patchSetRef(PatchSet.id(changeId, 1));
+    assertBranchNotFound(project, changeRef);
+
+    // a user with the 'Access Database' capability can see the change ref
+    testGetRefWithAccessDatabase(project, changeRef);
+  }
+
+  @Test
+  public void getChangeEditRef() throws Exception {
+    TestAccount user2 = accountCreator.user2();
+
+    // create a change
+    Change.Id changeId = changeOperations.newChange().project(project).create();
+
+    // create a change edit by 'user'
+    requestScopeOperations.setApiUser(user.id());
+    gApi.changes().id(changeId.get()).edit().create();
+
+    // every user can see their own change edit refs
+    String changeEditRef = RefNames.refsEdit(user.id(), changeId, PatchSet.id(changeId, 1));
+    assertBranchFound(project, changeEditRef);
+
+    // a user without the 'Access Database' capability cannot see the change edit ref of another
+    // user
+    requestScopeOperations.setApiUser(user2.id());
+    assertBranchNotFound(project, changeEditRef);
+
+    // a user with the 'Access Database' capability can see the change edit ref of another user
+    testGetRefWithAccessDatabase(project, changeEditRef);
+  }
+
+  @Test
+  public void cannotGetChangeEditRefOfNonVisibleChange() throws Exception {
+    // create a change
+    String branchName = "master";
+    Change.Id changeId = changeOperations.newChange().project(project).branch(branchName).create();
+
+    // create a change edit by 'user'
+    requestScopeOperations.setApiUser(user.id());
+    gApi.changes().id(changeId.get()).edit().create();
+
+    // make the change non-visible by blocking read access on the destination
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(Permission.READ).ref(RefNames.fullName(branchName)).group(ANONYMOUS_USERS))
+        .update();
+
+    // user cannot see their own change edit refs if the change is no longer visible
+    String changeEditRef = RefNames.refsEdit(user.id(), changeId, PatchSet.id(changeId, 1));
+    assertBranchNotFound(project, changeEditRef);
+
+    // a user with the 'Access Database' capability can see the change edit ref
+    testGetRefWithAccessDatabase(project, changeEditRef);
+  }
+
+  @Test
+  public void getChangeMetaRef() throws Exception {
+    // create a change
+    Change.Id changeId = changeOperations.newChange().project(project).create();
+
+    // A user without the 'Access Database' capability can see the change meta ref.
+    // This may be surprising, as 'Access Database' guards access to meta refs and the change meta
+    // ref is a meta ref, however change meta refs have been always visible to all users that can
+    // see the change and some tools rely on seeing these refs, so we have to keep the current
+    // behaviour.
+    requestScopeOperations.setApiUser(user.id());
+    String changeMetaRef = RefNames.changeMetaRef(changeId);
+    assertBranchFound(project, changeMetaRef);
+  }
+
+  @Test
+  public void getRefsMetaConfig() throws Exception {
+    // a non-project owner cannot get the refs/meta/config branch
+    requestScopeOperations.setApiUser(user.id());
+    assertBranchNotFound(project, RefNames.REFS_CONFIG);
+
+    // a non-project owner cannot get the refs/meta/config branch even with the 'Access Database'
+    // capability
+    projectOperations
+        .project(allProjects)
+        .forUpdate()
+        .add(allowCapability(GlobalCapability.ACCESS_DATABASE).group(REGISTERED_USERS))
+        .update();
+    try {
+      assertBranchNotFound(project, RefNames.REFS_CONFIG);
+    } finally {
+      projectOperations
+          .allProjectsForUpdate()
+          .remove(
+              capabilityKey(GlobalCapability.ACCESS_DATABASE)
+                  .group(SystemGroupBackend.REGISTERED_USERS))
+          .update();
+    }
+
+    requestScopeOperations.setApiUser(user.id());
+
+    // a project owner can get the refs/meta/config branch
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.OWNER).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+    assertBranchFound(project, RefNames.REFS_CONFIG);
+  }
+
+  @Test
+  public void getUserRefOfOtherUser() throws Exception {
+    String userRef = RefNames.refsUsers(admin.id());
+
+    // a user without the 'Access Database' capability cannot see the user ref of another user
+    requestScopeOperations.setApiUser(user.id());
+    assertBranchNotFound(allUsers, userRef);
+
+    // a user with the 'Access Database' capability can see the user ref of another user
+    testGetRefWithAccessDatabase(allUsers, userRef);
+  }
+
+  @Test
+  public void getOwnUserRef() throws Exception {
+    // every user can see the own user ref
+    requestScopeOperations.setApiUser(user.id());
+    assertBranchFound(allUsers, RefNames.refsUsers(user.id()));
+
+    // TODO: every user can see the own user ref via the magic ref/users/self ref
+    // requestScopeOperations.setApiUser(user.id());
+    // assertBranchFound(allUsers, RefNames.REFS_USERS_SELF);
+  }
+
+  @Test
+  public void getExternalIdsRefs() throws Exception {
+    // a user without the 'Access Database' capability cannot see the refs/meta/external-ids ref
+    requestScopeOperations.setApiUser(user.id());
+    assertBranchNotFound(allUsers, RefNames.REFS_EXTERNAL_IDS);
+
+    // a user with the 'Access Database' capability can see the refs/meta/external-ids ref
+    testGetRefWithAccessDatabase(allUsers, RefNames.REFS_EXTERNAL_IDS);
+  }
+
+  @Test
+  public void getGroupRef() throws Exception {
+    // create a group
+    AccountGroup.UUID ownerGroupUuid =
+        groupOperations.newGroup().name("owner-group").addMember(admin.id()).create();
+    AccountGroup.UUID testGroupUuid =
+        groupOperations.newGroup().name("test-group").ownerGroupUuid(ownerGroupUuid).create();
+
+    // a non-group owner without the 'Access Database' capability cannot see the group ref
+    requestScopeOperations.setApiUser(user.id());
+    String groupRef = RefNames.refsGroups(testGroupUuid);
+    assertBranchNotFound(allUsers, groupRef);
+
+    // a non-group owner with the 'Access Database' capability can see the group ref
+    testGetRefWithAccessDatabase(allUsers, groupRef);
+
+    // a group owner can see the group ref if the group ref is visible
+    groupOperations.group(ownerGroupUuid).forUpdate().addMember(user.id()).update();
+    assertBranchFound(allUsers, groupRef);
+
+    // A group owner cannot see the group ref if the group ref is not visible.
+    // The READ access for refs/groups/* must be blocked on All-Projects rather than All-Users.
+    // This is because READ access for refs/groups/* on All-Users is by default granted to
+    // REGISTERED_USERS, and if an ALLOW rule and a BLOCK rule are on the same project and ref,
+    // the ALLOW rule takes precedence.
+    projectOperations
+        .project(allProjects)
+        .forUpdate()
+        .add(block(Permission.READ).ref("refs/groups/*").group(ANONYMOUS_USERS))
+        .update();
+    assertBranchNotFound(allUsers, groupRef);
+  }
+
+  @Test
+  public void getGroupNamesRef() throws Exception {
+    // a user without the 'Access Database' capability cannot see the refs/meta/group-names ref
+    requestScopeOperations.setApiUser(user.id());
+    assertBranchNotFound(allUsers, RefNames.REFS_GROUPNAMES);
+
+    // a user with the 'Access Database' capability can see the refs/meta/group-names ref
+    testGetRefWithAccessDatabase(allUsers, RefNames.REFS_GROUPNAMES);
+  }
+
+  @Test
+  public void getDeletedGroupRef() throws Exception {
+    // Create a deleted group ref. We must create a directly in the repo, since group deletion is
+    // not supported yet.
+    String deletedGroupRef = RefNames.refsDeletedGroups(AccountGroup.uuid("deleted-group"));
+    try (TestRepository<Repository> testRepo =
+        new TestRepository<>(repoManager.openRepository(allUsers))) {
+      testRepo
+          .branch(deletedGroupRef)
+          .commit()
+          .message("Some Message")
+          .add("group.config", "content")
+          .create();
+    }
+
+    requestScopeOperations.setApiUser(user.id());
+    assertBranchNotFound(allUsers, deletedGroupRef);
+
+    // a user with the 'Access Database' capability can see the deleted group ref
+    testGetRefWithAccessDatabase(allUsers, deletedGroupRef);
+  }
+
+  @Test
+  public void getDraftCommentsRef() throws Exception {
+    TestAccount user2 = accountCreator.user2();
+
+    // create a change
+    String fileName = "a.txt";
+    Change change = createChange("A Change", fileName, "content").getChange().change();
+
+    // create a draft comment by the by 'user'
+    requestScopeOperations.setApiUser(user.id());
+    DraftInput draftInput = new DraftInput();
+    draftInput.path = fileName;
+    draftInput.line = 0;
+    draftInput.message = "Some Comment";
+    gApi.changes().id(change.getChangeId()).current().createDraft(draftInput);
+
+    // every user can see their own draft comments refs
+    // TODO: is this a bug?
+    String draftCommentsRef = RefNames.refsDraftComments(change.getId(), user.id());
+    assertBranchFound(allUsers, draftCommentsRef);
+
+    // a user without the 'Access Database' capability cannot see the draft comments ref of another
+    // user
+    requestScopeOperations.setApiUser(user2.id());
+    assertBranchNotFound(allUsers, draftCommentsRef);
+
+    // a user with the 'Access Database' capability can see the draft comments ref of another user
+    testGetRefWithAccessDatabase(allUsers, draftCommentsRef);
+  }
+
+  @Test
+  public void getStarredChangesRef() throws Exception {
+    TestAccount user2 = accountCreator.user2();
+
+    // create a change
+    Change change = createChange().getChange().change();
+
+    // let user star the change
+    requestScopeOperations.setApiUser(user.id());
+    gApi.accounts().self().starChange(Integer.toString(change.getChangeId()));
+
+    // every user can see their own starred changes refs
+    // TODO: is this a bug?
+    String starredChangesRef = RefNames.refsStarredChanges(change.getId(), user.id());
+    assertBranchFound(allUsers, starredChangesRef);
+
+    // a user without the 'Access Database' capability cannot see the starred changes ref of another
+    // user
+    requestScopeOperations.setApiUser(user2.id());
+    assertBranchNotFound(allUsers, starredChangesRef);
+
+    // a user with the 'Access Database' capability can see the starred changes ref of another user
+    testGetRefWithAccessDatabase(allUsers, starredChangesRef);
+  }
+
+  @Test
+  public void getTagRef() throws Exception {
+    // create a tag
+    TagInput input = new TagInput();
+    input.message = "My Tag";
+    input.revision = projectOperations.project(project).getHead("master").name();
+    TagInfo tagInfo = gApi.projects().name(project.get()).tag("my-tag").create(input).get();
+
+    // any user who can see the project, can see the tag
+    requestScopeOperations.setApiUser(user.id());
+    assertBranchFound(project, tagInfo.ref);
+  }
+
+  @Test
+  public void cannotGetTagRefThatPointsToNonVisibleBranch() throws Exception {
+    // create a tag
+    TagInput input = new TagInput();
+    input.message = "My Tag";
+    input.revision = projectOperations.project(project).getHead("master").name();
+    TagInfo tagInfo = gApi.projects().name(project.get()).tag("my-tag").create(input).get();
+
+    // block read access to the branch
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(Permission.READ).ref(RefNames.fullName("master")).group(ANONYMOUS_USERS))
+        .update();
+
+    // if the user cannot see the project, the tag is not visible
+    requestScopeOperations.setApiUser(user.id());
+    assertBranchNotFound(project, tagInfo.ref);
+  }
+
+  @Test
+  public void getSymbolicRef() throws Exception {
+    // 'HEAD' is visible since it points to 'master' that is visible
+    requestScopeOperations.setApiUser(user.id());
+    assertBranchFound(project, "HEAD");
+  }
+
+  @Test
+  public void cannotGetSymbolicRefThatPointsToNonVisibleBranch() {
+    // block read access to the branch to which HEAD points by default
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(Permission.READ).ref(RefNames.fullName("master")).group(ANONYMOUS_USERS))
+        .update();
+
+    // since 'master' is not visible, 'HEAD' which points to 'master' is also not visible
+    requestScopeOperations.setApiUser(user.id());
+    assertBranchNotFound(project, "HEAD");
+  }
+
+  @Test
+  public void getAccountSequenceRef() throws Exception {
+    // a user without the 'Access Database' capability cannot see the refs/sequences/accounts ref
+    requestScopeOperations.setApiUser(user.id());
+    String accountSequenceRef = RefNames.REFS_SEQUENCES + Sequences.NAME_ACCOUNTS;
+    assertBranchNotFound(allUsers, accountSequenceRef);
+
+    // a user with the 'Access Database' capability can see the refs/sequences/accounts ref
+    testGetRefWithAccessDatabase(allUsers, accountSequenceRef);
+  }
+
+  @Test
+  public void getChangeSequenceRef() throws Exception {
+    // a user without the 'Access Database' capability cannot see the refs/sequences/changes ref
+    requestScopeOperations.setApiUser(user.id());
+    String changeSequenceRef = RefNames.REFS_SEQUENCES + Sequences.NAME_CHANGES;
+    assertBranchNotFound(allProjects, changeSequenceRef);
+
+    // a user with the 'Access Database' capability can see the refs/sequences/changes ref
+    testGetRefWithAccessDatabase(allProjects, changeSequenceRef);
+  }
+
+  @Test
+  public void getGroupSequenceRef() throws Exception {
+    // a user without the 'Access Database' capability cannot see the refs/sequences/groups ref
+    requestScopeOperations.setApiUser(user.id());
+    String groupSequenceRef = RefNames.REFS_SEQUENCES + Sequences.NAME_GROUPS;
+    assertBranchNotFound(allUsers, groupSequenceRef);
+
+    // a user with the 'Access Database' capability can see the refs/sequences/groups ref
+    testGetRefWithAccessDatabase(allUsers, groupSequenceRef);
+  }
+
+  @Test
+  public void getVersionMetaRef() throws Exception {
+    // TODO: a user without the 'Access Database' capability cannot see the refs/meta/version ref
+    // requestScopeOperations.setApiUser(user.id());
+    // assertBranchNotFound(allProjects, RefNames.REFS_VERSION);
+
+    // a user with the 'Access Database' capability can see the refs/meta/vaersion ref
+    testGetRefWithAccessDatabase(allProjects, RefNames.REFS_VERSION);
+  }
+
+  @Test
+  public void cannotGetAutoMergeRef() throws Exception {
+    String file = "foo/a.txt";
+
+    // Create a base change.
+    Change.Id baseChange =
+        changeOperations
+            .newChange()
+            .project(project)
+            .branch("master")
+            .file(file)
+            .content("base content")
+            .create();
+    approve(Integer.toString(baseChange.get()));
+    gApi.changes().id(baseChange.get()).current().submit();
+
+    // Create another branch
+    String branchName = "foo";
+    createBranchWithRevision(
+        BranchNameKey.create(project, branchName),
+        projectOperations.project(project).getHead("master").name());
+
+    // Create a change in master that touches the file.
+    Change.Id changeInMaster =
+        changeOperations
+            .newChange()
+            .project(project)
+            .branch("master")
+            .file(file)
+            .content("master content")
+            .create();
+    approve(Integer.toString(changeInMaster.get()));
+    gApi.changes().id(changeInMaster.get()).current().submit();
+
+    // Create a change in the other branch and that touches the file.
+    Change.Id changeInOtherBranch =
+        changeOperations
+            .newChange()
+            .project(project)
+            .branch(branchName)
+            .file(file)
+            .content("other content")
+            .create();
+    approve(Integer.toString(changeInOtherBranch.get()));
+    gApi.changes().id(changeInOtherBranch.get()).current().submit();
+
+    // Create a merge change with a conflict resolution for the file.
+    Change.Id mergeChange =
+        changeOperations
+            .newChange()
+            .project(project)
+            .branch("master")
+            .mergeOfButBaseOnFirst()
+            .tipOfBranch("master")
+            .and()
+            .tipOfBranch(branchName)
+            .file(file)
+            .content("merged content")
+            .create();
+
+    String mergeRevision =
+        changeOperations.change(mergeChange).currentPatchset().get().commitId().name();
+    assertBranchNotFound(project, RefNames.refsCacheAutomerge(mergeRevision));
+  }
+
+  private void testGetRefWithAccessDatabase(Project.NameKey project, String ref)
+      throws RestApiException {
+    projectOperations
+        .project(allProjects)
+        .forUpdate()
+        .add(allowCapability(GlobalCapability.ACCESS_DATABASE).group(REGISTERED_USERS))
+        .update();
+    try {
+      requestScopeOperations.setApiUser(user.id());
+      assertBranchFound(project, ref);
+    } finally {
+      projectOperations
+          .allProjectsForUpdate()
+          .remove(
+              capabilityKey(GlobalCapability.ACCESS_DATABASE)
+                  .group(SystemGroupBackend.REGISTERED_USERS))
+          .update();
+    }
+  }
+
+  private void assertBranchNotFound(Project.NameKey project, String ref) {
+    ResourceNotFoundException exception =
+        assertThrows(
+            ResourceNotFoundException.class,
+            () -> gApi.projects().name(project.get()).branch(ref).get());
+    assertThat(exception).hasMessageThat().isEqualTo("Not found: " + ref);
+  }
+
+  private void assertBranchFound(Project.NameKey project, String ref) throws RestApiException {
+    BranchInfo branchInfo = gApi.projects().name(project.get()).branch(ref).get();
+    assertThat(branchInfo.ref).isEqualTo(RefNames.fullName(ref));
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/ssh/DynamicOptionsIT.java b/javatests/com/google/gerrit/acceptance/ssh/DynamicOptionsIT.java
new file mode 100644
index 0000000..c0f2b36
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/ssh/DynamicOptionsIT.java
@@ -0,0 +1,52 @@
+// 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.
+
+package com.google.gerrit.acceptance.ssh;
+
+import static com.google.gerrit.server.query.change.OutputStreamQuery.GSON;
+import static junit.framework.TestCase.assertEquals;
+
+import com.google.common.collect.Lists;
+import com.google.gerrit.acceptance.AbstractDynamicOptionsTest;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.UseSsh;
+import com.google.gson.reflect.TypeToken;
+import com.google.inject.Module;
+import java.io.IOException;
+import java.util.List;
+import org.junit.Test;
+
+@NoHttpd
+@UseSsh
+public class DynamicOptionsIT extends AbstractDynamicOptionsTest {
+
+  @Override
+  public Module createSshModule() {
+    return new AbstractDynamicOptionsTest.PluginOneSshModule();
+  }
+
+  @Test
+  public void testDynamicPluginOptions() throws Exception {
+    try (AutoCloseable ignored =
+        installPlugin("my-plugin", AbstractDynamicOptionsTest.PluginTwoModule.class)) {
+      List<String> samples = getSamplesList(adminSshSession.exec("ls-samples"));
+      adminSshSession.assertSuccess();
+      assertEquals(Lists.newArrayList("sample1", "sample2"), samples);
+    }
+  }
+
+  protected List<String> getSamplesList(String sshOutput) throws IOException {
+    return GSON.fromJson(sshOutput, new TypeToken<List<String>>() {}.getType());
+  }
+}
diff --git a/javatests/com/google/gerrit/elasticsearch/BUILD b/javatests/com/google/gerrit/elasticsearch/BUILD
index ab2bb12..e269fc2 100644
--- a/javatests/com/google/gerrit/elasticsearch/BUILD
+++ b/javatests/com/google/gerrit/elasticsearch/BUILD
@@ -17,8 +17,11 @@
         "//lib:junit",
         "//lib/guice",
         "//lib/httpcomponents:httpcore",
+        "//lib/jackson:jackson-annotations",
         "//lib/log:api",
         "//lib/testcontainers",
+        "//lib/testcontainers:docker-java-api",
+        "//lib/testcontainers:docker-java-transport",
         "//lib/testcontainers:testcontainers-elasticsearch",
     ],
 )
diff --git a/javatests/com/google/gerrit/elasticsearch/ElasticContainer.java b/javatests/com/google/gerrit/elasticsearch/ElasticContainer.java
index 86829b9..48295ea 100644
--- a/javatests/com/google/gerrit/elasticsearch/ElasticContainer.java
+++ b/javatests/com/google/gerrit/elasticsearch/ElasticContainer.java
@@ -19,6 +19,7 @@
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.testcontainers.elasticsearch.ElasticsearchContainer;
+import org.testcontainers.utility.DockerImageName;
 
 /* Helper class for running ES integration tests in docker container */
 public class ElasticContainer extends ElasticsearchContainer {
@@ -39,7 +40,7 @@
   private static String getImageName(ElasticVersion version) {
     switch (version) {
       case V6_8:
-        return "blacktop/elasticsearch:6.8.12";
+        return "blacktop/elasticsearch:6.8.13";
       case V7_0:
         return "blacktop/elasticsearch:7.0.1";
       case V7_1:
@@ -63,7 +64,9 @@
   }
 
   private ElasticContainer(ElasticVersion version) {
-    super(getImageName(version));
+    super(
+        DockerImageName.parse(getImageName(version))
+            .asCompatibleSubstituteFor("docker.elastic.co/elasticsearch/elasticsearch"));
   }
 
   @Override
diff --git a/javatests/com/google/gerrit/server/permissions/RefControlTest.java b/javatests/com/google/gerrit/server/permissions/RefControlTest.java
index 81cb732..65196bf 100644
--- a/javatests/com/google/gerrit/server/permissions/RefControlTest.java
+++ b/javatests/com/google/gerrit/server/permissions/RefControlTest.java
@@ -48,6 +48,7 @@
 import com.google.gerrit.server.account.GroupMembership;
 import com.google.gerrit.server.account.ListGroupMembership;
 import com.google.gerrit.server.config.AllProjectsName;
+import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
 import com.google.gerrit.server.index.SingleVersionModule.SingleVersionListener;
 import com.google.gerrit.server.project.ProjectCache;
@@ -63,6 +64,7 @@
 import com.google.inject.Inject;
 import com.google.inject.Injector;
 import java.util.ArrayList;
+import java.util.Collections;
 import java.util.Optional;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Repository;
@@ -90,6 +92,18 @@
     assertWithMessage("not owner").that(u.isOwner()).isFalse();
   }
 
+  private void assertAllRefsAreVisible(ProjectControl u) {
+    assertWithMessage("all refs visible")
+        .that(u.allRefsAreVisible(Collections.emptySet()))
+        .isTrue();
+  }
+
+  private void assertAllRefsAreNotVisible(ProjectControl u) {
+    assertWithMessage("all refs NOT visible")
+        .that(u.allRefsAreVisible(Collections.emptySet()))
+        .isFalse();
+  }
+
   private void assertNotOwner(String ref, ProjectControl u) {
     assertWithMessage("NOT OWN " + ref).that(u.controlForRef(ref).isOwner()).isFalse();
   }
@@ -105,11 +119,21 @@
   }
 
   private void assertCanRead(String ref, ProjectControl u) {
-    assertWithMessage("can read " + ref).that(u.controlForRef(ref).isVisible()).isTrue();
+    assertWithMessage("can read " + ref)
+        .that(
+            u.controlForRef(ref)
+                .hasReadPermissionOnRef(
+                    true)) // This should be false but the test relies on inheritance into refs/tags
+        .isTrue();
   }
 
   private void assertCannotRead(String ref, ProjectControl u) {
-    assertWithMessage("cannot read " + ref).that(u.controlForRef(ref).isVisible()).isFalse();
+    assertWithMessage("cannot read " + ref)
+        .that(
+            u.controlForRef(ref)
+                .hasReadPermissionOnRef(
+                    true)) // This should be false but the test relies on inheritance into refs/tags
+        .isFalse();
   }
 
   private void assertCanSubmit(String ref, ProjectControl u) {
@@ -171,6 +195,7 @@
   private final Project.NameKey parentKey = Project.nameKey("parent");
 
   @Inject private AllProjectsName allProjectsName;
+  @Inject private AllUsersName allUsersName;
   @Inject private InMemoryRepositoryManager repoManager;
   @Inject private MetaDataUpdate.Server metaDataUpdateFactory;
   @Inject private ProjectCache projectCache;
@@ -262,6 +287,32 @@
   }
 
   @Test
+  public void allRefsAreVisibleForRegularProject() throws Exception {
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(allow(READ).ref("refs/*").group(DEVS))
+        .add(allow(READ).ref("refs/groups/*").group(DEVS))
+        .add(allow(READ).ref("refs/users/default").group(DEVS))
+        .update();
+
+    assertAllRefsAreVisible(user(localKey, DEVS));
+  }
+
+  @Test
+  public void allRefsAreNotVisibleForAllUsers() throws Exception {
+    projectOperations
+        .project(allUsersName)
+        .forUpdate()
+        .add(allow(READ).ref("refs/*").group(DEVS))
+        .add(allow(READ).ref("refs/groups/*").group(DEVS))
+        .add(allow(READ).ref("refs/users/default").group(DEVS))
+        .update();
+
+    assertAllRefsAreNotVisible(user(allUsersName, DEVS));
+  }
+
+  @Test
   public void branchDelegation1() throws Exception {
     projectOperations
         .project(localKey)
diff --git a/lib/guava.bzl b/lib/guava.bzl
deleted file mode 100644
index 4de39cb..0000000
--- a/lib/guava.bzl
+++ /dev/null
@@ -1,5 +0,0 @@
-GUAVA_VERSION = "29.0-jre"
-
-GUAVA_BIN_SHA1 = "801142b4c3d0f0770dd29abea50906cacfddd447"
-
-GUAVA_DOC_URL = "https://google.github.io/guava/releases/" + GUAVA_VERSION + "/api/docs/"
diff --git a/lib/jackson/BUILD b/lib/jackson/BUILD
index d5253a0..f11b96d 100644
--- a/lib/jackson/BUILD
+++ b/lib/jackson/BUILD
@@ -1,6 +1,14 @@
 load("@rules_java//java:defs.bzl", "java_library")
 
 java_library(
+    name = "jackson-annotations",
+    testonly = True,
+    data = ["//lib:LICENSE-Apache2.0"],
+    visibility = ["//visibility:public"],
+    exports = ["@jackson-annotations//jar"],
+)
+
+java_library(
     name = "jackson-core",
     data = ["//lib:LICENSE-Apache2.0"],
     visibility = [
diff --git a/lib/nongoogle_test.sh b/lib/nongoogle_test.sh
index 0cdad1a..df8ee66 100755
--- a/lib/nongoogle_test.sh
+++ b/lib/nongoogle_test.sh
@@ -12,6 +12,8 @@
 
 cat << EOF > $TMP/want
 cglib-3_2
+docker-java-api
+docker-java-transport
 dropwizard-core
 duct-tape
 eddsa
@@ -19,9 +21,11 @@
 flogger
 flogger-log4j-backend
 flogger-system-backend
+guava
 httpasyncclient
 httpcore-nio
 j2objc
+jackson-annotations
 jackson-core
 jna
 jruby
diff --git a/lib/testcontainers/BUILD b/lib/testcontainers/BUILD
index a37b733..693a386 100644
--- a/lib/testcontainers/BUILD
+++ b/lib/testcontainers/BUILD
@@ -1,6 +1,22 @@
 load("@rules_java//java:defs.bzl", "java_library")
 
 java_library(
+    name = "docker-java-api",
+    testonly = True,
+    data = ["//lib:LICENSE-Apache2.0"],
+    visibility = ["//visibility:public"],
+    exports = ["@docker-java-api//jar"],
+)
+
+java_library(
+    name = "docker-java-transport",
+    testonly = True,
+    data = ["//lib:LICENSE-Apache2.0"],
+    visibility = ["//visibility:public"],
+    exports = ["@docker-java-transport//jar"],
+)
+
+java_library(
     name = "duct-tape",
     testonly = True,
     data = ["//lib:LICENSE-testcontainers"],
diff --git a/modules/jgit b/modules/jgit
index dd16976..5cd485e 160000
--- a/modules/jgit
+++ b/modules/jgit
@@ -1 +1 @@
-Subproject commit dd169769bf42115e1dee749efeecab84544b28c4
+Subproject commit 5cd485e5dda41d2ef06226a692c64f1aa221eb25
diff --git a/plugins/delete-project b/plugins/delete-project
index 60ce67d..bfe159d 160000
--- a/plugins/delete-project
+++ b/plugins/delete-project
@@ -1 +1 @@
-Subproject commit 60ce67dd53ad64c33a2c34aae31e9ee823979109
+Subproject commit bfe159d3007db0f07e967473b53f679ba8f432df
diff --git a/plugins/download-commands b/plugins/download-commands
index 87e3930..5bd359c 160000
--- a/plugins/download-commands
+++ b/plugins/download-commands
@@ -1 +1 @@
-Subproject commit 87e3930cea7c06aea454998abdddf6515a9f103b
+Subproject commit 5bd359c08e10b93d2c08762f75cde01a14e45fc6
diff --git a/plugins/gitiles b/plugins/gitiles
index 641476e..a33c8b8 160000
--- a/plugins/gitiles
+++ b/plugins/gitiles
@@ -1 +1 @@
-Subproject commit 641476e153143c2b67e334b35626beb9b2534956
+Subproject commit a33c8b8d61b778f8ca84196b1a7cc1fd4fe24946
diff --git a/plugins/replication b/plugins/replication
index 5f5c0d3..1b822fa 160000
--- a/plugins/replication
+++ b/plugins/replication
@@ -1 +1 @@
-Subproject commit 5f5c0d372b0006cdee9b880716ffecabe6b29cef
+Subproject commit 1b822fa63b04596faa93a13df7fcb8682bccb98b
diff --git a/polygerrit-ui/FE_Style_Guide.md b/polygerrit-ui/FE_Style_Guide.md
index c9a5d9b..a636119 100644
--- a/polygerrit-ui/FE_Style_Guide.md
+++ b/polygerrit-ui/FE_Style_Guide.md
@@ -10,11 +10,20 @@
 Additionally to the rules above, Gerrit frontend uses the following rules (some of them have automated checks,
 some don't):
 
+- [Prefer null over undefined](#prefer-null)
 - [Use destructuring imports only](#destructuring-imports-only)
 - [Use classes and services for storing and manipulating global state](#services-for-global-state)
 - [Pass required services in the constructor for plain classes](#pass-dependencies-in-constructor)
 - [Assign required services in a HTML/Polymer element constructor](#assign-dependencies-in-html-element-constructor)
 
+## <a name="prefer-undefined"></a>Prefer `undefined` over `null`
+
+It is more confusing than helpful to work with both `null` and `undefined`. We prefer to only use `undefined` in
+our code base. Try to avoid `null`.
+
+Some browser and library APIs are using `null`, so we cannot remove `null` completely from our code base. But even
+then try to convert return values and leak as few `nulls` as possible.
+
 ## <a name="destructuring-imports-only"></a>Use destructuring imports only
 Always use destructuring import statement and specify all required names explicitly (e.g. `import {a,b,c} from '...'`)
 where possible.
diff --git a/polygerrit-ui/app/.eslintrc.js b/polygerrit-ui/app/.eslintrc.js
index 9834ddc..c8de2eb 100644
--- a/polygerrit-ui/app/.eslintrc.js
+++ b/polygerrit-ui/app/.eslintrc.js
@@ -272,6 +272,7 @@
         }],
         // The following rules is required to match internal google rules
         "@typescript-eslint/restrict-plus-operands": "error",
+        "@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_" }],
         // https://github.com/mysticatea/eslint-plugin-node/blob/master/docs/rules/no-unsupported-features/node-builtins.md
         "node/no-unsupported-features/node-builtins": "off",
         // Disable no-invalid-this for ts files, because it incorrectly reports
diff --git a/polygerrit-ui/app/constants/constants.ts b/polygerrit-ui/app/constants/constants.ts
index 6029dc1..061c5cd 100644
--- a/polygerrit-ui/app/constants/constants.ts
+++ b/polygerrit-ui/app/constants/constants.ts
@@ -21,10 +21,11 @@
 export enum PrimaryTab {
   FILES = 'files',
   /**
-   * When renaming this, the links in UrlFormatter must be updated.
+   * When renaming 'comments' or 'findings', UrlFormatter.java must be updated.
    */
   COMMENT_THREADS = 'comments',
   FINDINGS = 'findings',
+  CHECKS = 'checks',
 }
 
 /**
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.ts b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.ts
index 9d40e28..44ab784 100644
--- a/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.ts
+++ b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.ts
@@ -34,6 +34,7 @@
 import {GroupId, GroupInfo, GroupName} from '../../../types/common';
 import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
 import {GrCreateGroupDialog} from '../gr-create-group-dialog/gr-create-group-dialog';
+import {fireTitleChange} from '../../../utils/event-util';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -100,13 +101,7 @@
   attached() {
     super.attached();
     this._getCreateGroupCapability();
-    this.dispatchEvent(
-      new CustomEvent('title-change', {
-        detail: {title: 'Groups'},
-        composed: true,
-        bubbles: true,
-      })
-    );
+    fireTitleChange(this, 'Groups');
     this._maybeOpenCreateOverlay(this.params);
   }
 
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 f7cffac..e5a1586 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
@@ -37,6 +37,7 @@
   EncodedGroupId,
   GroupAuditEventInfo,
 } from '../../../types/common';
+import {firePageError, fireTitleChange} from '../../../utils/event-util';
 
 const GROUP_EVENTS = ['ADD_GROUP', 'REMOVE_GROUP'];
 
@@ -65,13 +66,7 @@
   /** @override */
   attached() {
     super.attached();
-    this.dispatchEvent(
-      new CustomEvent('title-change', {
-        detail: {title: 'Audit Log'},
-        composed: true,
-        bubbles: true,
-      })
-    );
+    fireTitleChange(this, 'Audit Log');
   }
 
   /** @override */
@@ -86,13 +81,7 @@
     }
 
     const errFn: ErrorCallback = response => {
-      this.dispatchEvent(
-        new CustomEvent('page-error', {
-          detail: {response},
-          composed: true,
-          bubbles: true,
-        })
-      );
+      firePageError(this, response);
     };
 
     return this.$.restAPI
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 ae10c03..01571a2 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
@@ -46,6 +46,11 @@
 import {AutocompleteQuery} 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';
 
 const SUGGESTIONS_LIMIT = 15;
 const SAVING_ERROR_TEXT =
@@ -125,13 +130,7 @@
     super.attached();
     this._loadGroupDetails();
 
-    this.dispatchEvent(
-      new CustomEvent('title-change', {
-        detail: {title: 'Members'},
-        composed: true,
-        bubbles: true,
-      })
-    );
+    fireTitleChange(this, 'Members');
   }
 
   _loadGroupDetails() {
@@ -142,13 +141,7 @@
     const promises: Promise<void>[] = [];
 
     const errFn: ErrorCallback = response => {
-      this.dispatchEvent(
-        new CustomEvent('page-error', {
-          detail: {response},
-          composed: true,
-          bubbles: true,
-        })
-      );
+      firePageError(this, response);
     };
 
     return this.$.restAPI.getGroupConfig(this.groupId, errFn).then(config => {
@@ -297,13 +290,7 @@
         (errResponse, err) => {
           if (errResponse) {
             if (errResponse.status === 404) {
-              this.dispatchEvent(
-                new CustomEvent('show-alert', {
-                  detail: {message: SAVING_ERROR_TEXT},
-                  bubbles: true,
-                  composed: true,
-                })
-              );
+              fireAlert(this, SAVING_ERROR_TEXT);
               return errResponse;
             }
             throw Error(errResponse.statusText);
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 511bf5c..4525543 100644
--- a/polygerrit-ui/app/elements/admin/gr-group/gr-group.ts
+++ b/polygerrit-ui/app/elements/admin/gr-group/gr-group.ts
@@ -38,6 +38,7 @@
   RestApiService,
 } from '../../../services/services/gr-rest-api/gr-rest-api';
 import {hasOwnProperty} from '../../../utils/common-util';
+import {firePageError, fireTitleChange} from '../../../utils/event-util';
 
 const INTERNAL_GROUP_REGEX = /^[\da-f]{40}$/;
 
@@ -145,13 +146,7 @@
     const promises: Promise<unknown>[] = [];
 
     const errFn: ErrorCallback = response => {
-      this.dispatchEvent(
-        new CustomEvent('page-error', {
-          detail: {response},
-          composed: true,
-          bubbles: true,
-        })
-      );
+      firePageError(this, response);
     };
 
     return this.$.restAPI.getGroupConfig(this.groupId, errFn).then(config => {
@@ -183,13 +178,7 @@
       }
       this._groupConfig = config;
 
-      this.dispatchEvent(
-        new CustomEvent('title-change', {
-          detail: {title: config.name},
-          composed: true,
-          bubbles: true,
-        })
-      );
+      fireTitleChange(this, config.name);
 
       return Promise.all(promises).then(() => {
         this._loading = false;
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 5039972..9337042 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
@@ -30,6 +30,8 @@
 import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
 import {ErrorCallback} from '../../../services/services/gr-rest-api/gr-rest-api';
 import {PluginInfo} from '../../../types/common';
+import {firePageError} from '../../../utils/event-util';
+import {fireTitleChange} from '../../../utils/event-util';
 
 interface PluginInfoWithName extends PluginInfo {
   name: string;
@@ -84,13 +86,7 @@
   /** @override */
   attached() {
     super.attached();
-    this.dispatchEvent(
-      new CustomEvent('title-change', {
-        detail: {title: 'Plugins'},
-        composed: true,
-        bubbles: true,
-      })
-    );
+    fireTitleChange(this, 'Plugins');
   }
 
   _paramsChanged(params: ListViewParams) {
@@ -103,13 +99,7 @@
 
   _getPlugins(filter: string, pluginsPerPage: number, offset?: number) {
     const errFn: ErrorCallback = response => {
-      this.dispatchEvent(
-        new CustomEvent('page-error', {
-          detail: {response},
-          composed: true,
-          bubbles: true,
-        })
-      );
+      firePageError(this, response);
     };
     return this.$.restAPI
       .getPlugins(filter, pluginsPerPage, offset, errFn)
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 b96ec2c..31e1f65 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
@@ -52,6 +52,7 @@
   PropertyTreeNode,
   PrimitiveValue,
 } from './gr-repo-access-interfaces';
+import {firePageError, fireAlert} from '../../../utils/event-util';
 
 const NOTHING_TO_SAVE = 'No changes to save.';
 
@@ -155,13 +156,7 @@
 
   _reload(repo: RepoName) {
     const errFn = (response?: Response | null) => {
-      this.dispatchEvent(
-        new CustomEvent('page-error', {
-          detail: {response},
-          composed: true,
-          bubbles: true,
-        })
-      );
+      firePageError(this, response);
     };
 
     this._editing = false;
@@ -516,13 +511,7 @@
       !Object.keys(addRemoveObj.remove).length &&
       !addRemoveObj.parent
     ) {
-      this.dispatchEvent(
-        new CustomEvent('show-alert', {
-          detail: {message: NOTHING_TO_SAVE},
-          bubbles: true,
-          composed: true,
-        })
-      );
+      fireAlert(this, NOTHING_TO_SAVE);
       return;
     }
     const obj: ProjectAccessInput = ({
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 a74f4bb..14cfedd 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
@@ -42,6 +42,11 @@
 } from '../../../types/common';
 import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
 import {GrCreateChangeDialog} from '../gr-create-change-dialog/gr-create-change-dialog';
+import {
+  fireAlert,
+  firePageError,
+  fireTitleChange,
+} from '../../../utils/event-util';
 
 const GC_MESSAGE = 'Garbage collection completed successfully.';
 const CONFIG_BRANCH = 'refs/meta/config' as BranchName;
@@ -95,13 +100,7 @@
     super.attached();
     this._loadRepo();
 
-    this.dispatchEvent(
-      new CustomEvent('title-change', {
-        detail: {title: 'Repo Commands'},
-        composed: true,
-        bubbles: true,
-      })
-    );
+    fireTitleChange(this, 'Repo Commands');
   }
 
   _loadRepo() {
@@ -109,13 +108,7 @@
       // Do not process the error, if the component is not attached to the DOM
       // anymore, which at least in tests can happen.
       if (!this.isConnected) return;
-      this.dispatchEvent(
-        new CustomEvent('page-error', {
-          detail: {response},
-          composed: true,
-          bubbles: true,
-        })
-      );
+      firePageError(this, response);
     };
 
     this.$.restAPI.getProjectConfig(this.repo, errFn).then(config => {
@@ -142,13 +135,7 @@
       .runRepoGC(this.repo)
       .then(response => {
         if (response?.status === 200) {
-          this.dispatchEvent(
-            new CustomEvent('show-alert', {
-              detail: {message: GC_MESSAGE},
-              bubbles: true,
-              composed: true,
-            })
-          );
+          fireAlert(this, GC_MESSAGE);
         }
       })
       .finally(() => {
@@ -190,13 +177,7 @@
         const message = change
           ? CREATE_CHANGE_SUCCEEDED_MESSAGE
           : CREATE_CHANGE_FAILED_MESSAGE;
-        this.dispatchEvent(
-          new CustomEvent('show-alert', {
-            detail: {message},
-            bubbles: true,
-            composed: true,
-          })
-        );
+        fireAlert(this, message);
         if (!change) {
           return;
         }
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 d9d8560..99f85fa 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
@@ -27,6 +27,7 @@
 import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
 import {RepoName, DashboardId, DashboardInfo} from '../../../types/common';
 import {ErrorCallback} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {firePageError} from '../../../utils/event-util';
 
 interface DashboardRef {
   section: string;
@@ -62,13 +63,7 @@
     }
 
     const errFn: ErrorCallback = response => {
-      this.dispatchEvent(
-        new CustomEvent('page-error', {
-          detail: {response},
-          composed: true,
-          bubbles: true,
-        })
-      );
+      firePageError(this, response);
     };
 
     return this.$.restAPI
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 2fce6e1..17414c0 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
@@ -53,6 +53,7 @@
 import {AppElementRepoParams} from '../../gr-app-types';
 import {PolymerDomRepeatEvent} from '../../../types/types';
 import {RepoDetailView} from '../../core/gr-navigation/gr-navigation';
+import {firePageError} from '../../../utils/event-util';
 
 const PGP_START = '-----BEGIN PGP SIGNATURE-----';
 
@@ -182,14 +183,9 @@
     this._items = [];
     flush();
     const errFn: ErrorCallback = response => {
-      this.dispatchEvent(
-        new CustomEvent('page-error', {
-          detail: {response},
-          composed: true,
-          bubbles: true,
-        })
-      );
+      firePageError(this, response);
     };
+
     if (detailType === RepoDetailView.BRANCHES) {
       return this.$.restAPI
         .getRepoBranches(filter, repo, itemsPerPage, offset, errFn)
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list.ts b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list.ts
index ba2d850..a566eb0 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list.ts
@@ -34,6 +34,7 @@
 import {RepoName, ProjectInfoWithName} from '../../../types/common';
 import {GrCreateRepoDialog} from '../gr-create-repo-dialog/gr-create-repo-dialog';
 import {ProjectState} from '../../../constants/constants';
+import {fireTitleChange} from '../../../utils/event-util';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -93,13 +94,7 @@
   attached() {
     super.attached();
     this._getCreateRepoCapability();
-    this.dispatchEvent(
-      new CustomEvent('title-change', {
-        detail: {title: 'Repos'},
-        composed: true,
-        bubbles: true,
-      })
-    );
+    fireTitleChange(this, 'Repos');
     this._maybeOpenCreateOverlay(this.params);
   }
 
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 101c77a..426e512 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts
@@ -48,6 +48,7 @@
 import {ProjectState} from '../../../constants/constants';
 import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
 import {hasOwnProperty} from '../../../utils/common-util';
+import {firePageError, fireTitleChange} from '../../../utils/event-util';
 
 const STATES = {
   active: {value: ProjectState.ACTIVE, label: 'Active'},
@@ -149,13 +150,7 @@
     super.attached();
     this._loadRepo();
 
-    this.dispatchEvent(
-      new CustomEvent('title-change', {
-        detail: {title: this.repo},
-        composed: true,
-        bubbles: true,
-      })
-    );
+    fireTitleChange(this, `${this.repo}`);
   }
 
   _computePluginData(
@@ -182,13 +177,7 @@
     const promises = [];
 
     const errFn: ErrorCallback = response => {
-      this.dispatchEvent(
-        new CustomEvent('page-error', {
-          detail: {response},
-          composed: true,
-          bubbles: true,
-        })
-      );
+      firePageError(this, response);
     };
 
     promises.push(
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.ts
index d70e891..64342f4 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.ts
@@ -152,15 +152,19 @@
     if (!label || category === LabelCategory.NOT_APPLICABLE) {
       return 'Label not applicable';
     }
+    const titleParts: string[] = [];
     if (category === LabelCategory.UNRESOLVED_COMMENTS) {
       const num = change?.unresolved_comment_count ?? 0;
       const plural = num > 1 ? 's' : '';
-      return `${num} unresolved comment${plural}`;
+      titleParts.push(`${num} unresolved comment${plural}`);
     }
     const significantLabel =
       label.rejected || label.approved || label.disliked || label.recommended;
-    if (significantLabel && significantLabel.name) {
-      return `${labelName}\nby ${significantLabel.name}`;
+    if (significantLabel?.name) {
+      titleParts.push(`${labelName} by ${significantLabel.name}`);
+    }
+    if (titleParts.length > 0) {
+      return titleParts.join(',\n');
     }
     return labelName;
   }
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.js b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.js
index d3274f3..38cc772 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.js
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.js
@@ -88,35 +88,35 @@
         'Label not applicable');
     assert.equal(element._computeLabelTitle(
         {labels: {Verified: {approved: {name: 'Diffy'}}}}, 'Verified'),
-    'Verified\nby Diffy');
+    'Verified by Diffy');
     assert.equal(element._computeLabelTitle(
         {labels: {Verified: {approved: {name: 'Diffy'}}}}, 'Code-Review'),
     'Label not applicable');
     assert.equal(element._computeLabelTitle(
         {labels: {Verified: {rejected: {name: 'Diffy'}}}}, 'Verified'),
-    'Verified\nby Diffy');
+    'Verified by Diffy');
     assert.equal(element._computeLabelTitle(
         {labels: {'Code-Review': {disliked: {name: 'Diffy'}, value: -1}}},
-        'Code-Review'), 'Code-Review\nby Diffy');
+        'Code-Review'), 'Code-Review by Diffy');
     assert.equal(element._computeLabelTitle(
         {labels: {'Code-Review': {recommended: {name: 'Diffy'}, value: 1}}},
-        'Code-Review'), 'Code-Review\nby Diffy');
+        'Code-Review'), 'Code-Review by Diffy');
     assert.equal(element._computeLabelTitle(
         {labels: {'Code-Review': {recommended: {name: 'Diffy'},
           rejected: {name: 'Admin'}}}}, 'Code-Review'),
-    'Code-Review\nby Admin');
+    'Code-Review by Admin');
     assert.equal(element._computeLabelTitle(
         {labels: {'Code-Review': {approved: {name: 'Diffy'},
           rejected: {name: 'Admin'}}}}, 'Code-Review'),
-    'Code-Review\nby Admin');
+    'Code-Review by Admin');
     assert.equal(element._computeLabelTitle(
         {labels: {'Code-Review': {recommended: {name: 'Diffy'},
           disliked: {name: 'Admin'}, value: -1}}}, 'Code-Review'),
-    'Code-Review\nby Admin');
+    'Code-Review by Admin');
     assert.equal(element._computeLabelTitle(
         {labels: {'Code-Review': {approved: {name: 'Diffy'},
           disliked: {name: 'Admin'}, value: -1}}}, 'Code-Review'),
-    'Code-Review\nby Diffy');
+    'Code-Review by Diffy');
     assert.equal(element._computeLabelTitle(
         {
           labels: {'Code-Review': {approved: true, value: 1}},
@@ -125,6 +125,12 @@
     '1 unresolved comment');
     assert.equal(element._computeLabelTitle(
         {
+          labels: {'Code-Review': {approved: {name: 'Diffy'}, value: 1}},
+          unresolved_comment_count: 1,
+        }, 'Code-Review'),
+    '1 unresolved comment,\nCode-Review by Diffy');
+    assert.equal(element._computeLabelTitle(
+        {
           labels: {'Code-Review': {approved: true, value: 1}},
           unresolved_comment_count: 2,
         }, 'Code-Review'),
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 927d32f..3391901 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
@@ -41,6 +41,7 @@
 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';
 
 const LookupQueryPatterns = {
   CHANGE_ID: /^\s*i?[0-9a-f]{7,40}\s*$/i,
@@ -143,15 +144,7 @@
 
     // NOTE: This method may be called before attachment. Fire title-change
     // in an async so that attachment to the DOM can take place first.
-    this.async(() =>
-      this.dispatchEvent(
-        new CustomEvent('title-change', {
-          detail: {title: this._query},
-          composed: true,
-          bubbles: true,
-        })
-      )
-    );
+    this.async(() => fireTitleChange(this, this._query));
 
     this.$.restAPI
       .getPreferences()
diff --git a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.ts b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.ts
index 57d3dd9..0cf9357 100644
--- a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.ts
@@ -58,6 +58,7 @@
 import {ChangeListToggleReviewedDetail} from '../gr-change-list-item/gr-change-list-item';
 import {ChangeStarToggleStarDetail} from '../../shared/gr-change-star/gr-change-star';
 import {DashboardViewState} from '../../../types/types';
+import {firePageError, fireTitleChange} from '../../../utils/event-util';
 
 const PROJECT_PLACEHOLDER_PATTERN = /\$\{project\}/g;
 
@@ -150,13 +151,7 @@
     dashboard: DashboardId
   ): Promise<UserDashboard | undefined> {
     const errFn = (response?: Response | null) => {
-      this.dispatchEvent(
-        new CustomEvent('page-error', {
-          detail: {response},
-          composed: true,
-          bubbles: true,
-        })
-      );
+      firePageError(this, response);
     };
     return this.$.restAPI
       .getDashboard(project, dashboard, errFn)
@@ -224,17 +219,13 @@
             )
           );
 
-    const checkForNewUser = !project && user === 'self';
+    // Checking `this.account` to make sure that the user is logged in.
+    // Otherwise sending a query for 'owner:self' will result in an error.
+    const checkForNewUser = !project && !!this.account && user === 'self';
     return dashboardPromise
       .then(res => {
         if (res && res.title) {
-          this.dispatchEvent(
-            new CustomEvent('title-change', {
-              detail: {title: res.title},
-              composed: true,
-              bubbles: true,
-            })
-          );
+          fireTitleChange(this, res.title);
         }
         return this._fetchDashboardChanges(res, checkForNewUser);
       })
@@ -243,15 +234,7 @@
         this.reporting.dashboardDisplayed();
       })
       .catch(err => {
-        this.dispatchEvent(
-          new CustomEvent('title-change', {
-            detail: {
-              title: title || this._computeTitle(user),
-            },
-            composed: true,
-            bubbles: true,
-          })
-        );
+        fireTitleChange(this, title || this._computeTitle(user));
         console.warn(err);
       })
       .then(() => {
diff --git a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.js b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.js
index 44f203d..f788e74 100644
--- a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.js
+++ b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.js
@@ -21,6 +21,7 @@
 import {GerritNav, GerritView} from '../../core/gr-navigation/gr-navigation.js';
 import {changeIsOpen} from '../../../utils/change-util.js';
 import {ChangeStatus} from '../../../constants/constants.js';
+import {createAccountWithId} from '../../../test/test-data-generators.js';
 
 const basicFixture = fixtureFromElement('gr-dashboard-view');
 
@@ -201,9 +202,23 @@
         user: 'self',
       };
       return paramsChangedPromise.then(() => {
-        assert.isTrue(
-            getChangesStub.calledWith(undefined,
-                ['1', '2', 'owner:self limit:1']));
+        assert.isTrue(getChangesStub.calledWith(undefined, ['1', '2']));
+      });
+    });
+
+    test('viewing dashboard when logged in includes owner:self query', () => {
+      element.account = createAccountWithId(1);
+      element.params = {
+        view: GerritNav.View.DASHBOARD,
+        sections: [
+          {query: '1'},
+          {query: '2', selfOnly: true},
+        ],
+        user: 'self',
+      };
+      return paramsChangedPromise.then(() => {
+        assert.isTrue(getChangesStub.calledWith(undefined,
+            ['1', '2', 'owner:self limit:1']));
       });
     });
 
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 3f6dd23..9473700 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
@@ -42,6 +42,7 @@
 import {
   fetchChangeUpdates,
   patchNumEquals,
+  CURRENT,
 } from '../../../utils/patch-set-util';
 import {
   changeIsOpen,
@@ -54,7 +55,10 @@
   HttpMethod,
   NotifyType,
 } from '../../../constants/constants';
-import {EventType, TargetElement} from '../../plugins/gr-plugin-types';
+import {
+  EventType as PluginEventType,
+  TargetElement,
+} from '../../plugins/gr-plugin-types';
 import {customElement, observe, property} from '@polymer/decorators';
 import {GrJsApiInterface} from '../../shared/gr-js-api-interface/gr-js-api-interface-element';
 import {
@@ -110,6 +114,8 @@
   RevisionActions,
   UIActionInfo,
 } from '../../shared/gr-js-api-interface/gr-change-actions-js-api';
+import {fireAlert} from '../../../utils/event-util';
+import {CODE_REVIEW} from '../../../utils/label-util';
 
 const ERR_BRANCH_EMPTY = 'The destination branch can’t be empty.';
 const ERR_COMMIT_EMPTY = 'The commit message can’t be empty.';
@@ -609,13 +615,7 @@
         this._handleLoadingComplete();
       })
       .catch(err => {
-        this.dispatchEvent(
-          new CustomEvent('show-alert', {
-            detail: {message: ERR_REVISION_ACTIONS},
-            composed: true,
-            bubbles: true,
-          })
-        );
+        fireAlert(this, ERR_REVISION_ACTIONS);
         this._loading = false;
         throw err;
       });
@@ -631,7 +631,7 @@
     change: ChangeInfo;
     revisionActions: ActionNameToActionInfoMap;
   }) {
-    this.$.jsAPI.handleEvent(EventType.SHOW_REVISION_ACTIONS, detail);
+    this.$.jsAPI.handleEvent(PluginEventType.SHOW_REVISION_ACTIONS, detail);
   }
 
   @observe('change')
@@ -949,6 +949,18 @@
         return null;
       }
     }
+    // Allow the user to use quick approve to vote the max score on code review
+    // even if it is already granted.
+    if (
+      !result &&
+      this.change.labels[CODE_REVIEW] &&
+      this._getLabelStatus(this.change.labels[CODE_REVIEW]) ===
+        LabelStatus.OK &&
+      this.change.permitted_labels[CODE_REVIEW]
+    ) {
+      result = CODE_REVIEW;
+    }
+
     if (result) {
       const score = this.change.permitted_labels[result].slice(-1)[0];
       const labelInfo = this.change.labels[result];
@@ -1370,23 +1382,11 @@
   _handleCherryPickRestApi(conflicts: boolean) {
     const el = this.$.confirmCherrypick;
     if (!el.branch) {
-      this.dispatchEvent(
-        new CustomEvent('show-alert', {
-          detail: {message: ERR_BRANCH_EMPTY},
-          composed: true,
-          bubbles: true,
-        })
-      );
+      fireAlert(this, ERR_BRANCH_EMPTY);
       return;
     }
     if (!el.message) {
-      this.dispatchEvent(
-        new CustomEvent('show-alert', {
-          detail: {message: ERR_COMMIT_EMPTY},
-          composed: true,
-          bubbles: true,
-        })
-      );
+      fireAlert(this, ERR_COMMIT_EMPTY);
       return;
     }
     this.$.overlay.close();
@@ -1407,13 +1407,7 @@
   _handleMoveConfirm() {
     const el = this.$.confirmMove;
     if (!el.branch) {
-      this.dispatchEvent(
-        new CustomEvent('show-alert', {
-          detail: {message: ERR_BRANCH_EMPTY},
-          composed: true,
-          bubbles: true,
-        })
-      );
+      fireAlert(this, ERR_BRANCH_EMPTY);
       return;
     }
     this.$.overlay.close();
@@ -1600,7 +1594,7 @@
     if (!labels) {
       return Promise.resolve(undefined);
     }
-    return this.$.restAPI.saveChangeReview(newChangeId, 'current', {labels});
+    return this.$.restAPI.saveChangeReview(newChangeId, CURRENT, {labels});
   }
 
   _handleResponse(action: UIActionInfo, response?: Response) {
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.js b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.js
index ae49a57..1098760 100644
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.js
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.js
@@ -1717,6 +1717,31 @@
                 .querySelector('gr-button[data-action-key=\'review\']');
         assert.equal(approveButton.getAttribute('data-label'), 'bar+2');
       });
+
+      test('added when can approve an already-approved code review label',
+          () => {
+            element.change = {
+              current_revision: 'abc1234',
+              labels: {
+                'Code-Review': {
+                  approved: {},
+                  values: {
+                    ' 0': '',
+                    '+1': '',
+                    '+2': '',
+                  },
+                },
+              },
+              permitted_labels: {
+                'Code-Review': [' 0', '+1', '+2'],
+              },
+            };
+            flush();
+            const approveButton =
+              element.shadowRoot
+                  .querySelector('gr-button[data-action-key=\'review\']');
+            assert.isNotNull(approveButton);
+          });
     });
 
     test('adds download revision action', () => {
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.ts b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.ts
index f8a5940..d192060 100644
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.ts
@@ -76,6 +76,13 @@
 import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
 import {GrEditableLabel} from '../../shared/gr-editable-label/gr-editable-label';
 import {GrLinkedChip} from '../../shared/gr-linked-chip/gr-linked-chip';
+import {appContext} from '../../../services/app-context';
+import {KnownExperimentId} from '../../../services/flags/flags';
+import {
+  Metadata,
+  isSectionSet,
+  DisplayRules,
+} from '../../../utils/change-metadata-util';
 
 const HASHTAG_ADD_MESSAGE = 'Add Hashtag';
 
@@ -171,7 +178,7 @@
     type: Object,
     computed: '_computePushCertificateValidation(serverConfig, change)',
   })
-  _pushCertificateValidation: PushCertifacteValidationInfo | null = null;
+  _pushCertificateValidation?: PushCertifacteValidationInfo;
 
   @property({type: Boolean, computed: '_computeShowRequirements(change)'})
   _showRequirements = false;
@@ -194,9 +201,28 @@
   @property({type: Object})
   _CHANGE_ROLE = ChangeRole;
 
+  @property({type: Object})
+  _SECTION = Metadata;
+
+  @property({type: Boolean})
+  _showAllSections = false;
+
+  @property({type: Boolean})
+  _isNewChangeSummaryUiEnabled = false;
+
+  flagsService = appContext.flagsService;
+
+  /** @override */
+  ready() {
+    super.ready();
+    this._isNewChangeSummaryUiEnabled = this.flagsService.isEnabled(
+      KnownExperimentId.NEW_CHANGE_SUMMARY_UI
+    );
+  }
+
   @observe('change.labels')
   _labelsChanged(labels?: LabelNameToInfoMap) {
-    this.labels = {...labels} || null;
+    this.labels = {...labels};
   }
 
   @observe('change')
@@ -238,7 +264,7 @@
   }
 
   /**
-   * @return If array is empty, returns null instead so
+   * @return If array is empty, returns undefined instead so
    * an existential check can be used to hide or show the webLinks
    * section.
    */
@@ -246,9 +272,7 @@
     commitInfo?: CommitInfoWithRequiredCommit,
     serverConfig?: ServerInfo
   ) {
-    if (!commitInfo) {
-      return null;
-    }
+    if (!commitInfo) return undefined;
     const weblinks = GerritNav.getChangeWeblinks(
       this.change ? this.change.project : ('' as RepoName),
       commitInfo.commit,
@@ -257,15 +281,11 @@
         config: serverConfig,
       }
     );
-    return weblinks.length ? weblinks : null;
+    return weblinks.length ? weblinks : undefined;
   }
 
   _isAssigneeEnabled(serverConfig?: ServerInfo) {
-    return (
-      serverConfig &&
-      serverConfig.change &&
-      !!serverConfig.change.enable_assignee
-    );
+    return !!serverConfig?.change?.enable_assignee;
   }
 
   _computeStrategy(change?: ParsedChangeInfo) {
@@ -285,18 +305,13 @@
       throw new Error('change must be set');
     }
     const lastTopic = this.change.topic;
-    const topic = e.detail.length ? e.detail : null;
+    const topic = e.detail.length ? e.detail : undefined;
     this._settingTopic = true;
     const topicChangedForChangeNumber = this.change._number;
     this.$.restAPI
       .setChangeTopic(topicChangedForChangeNumber, topic)
       .then(newTopic => {
-        if (
-          !this.change ||
-          this.change._number !== topicChangedForChangeNumber
-        ) {
-          return;
-        }
+        if (this.change?._number !== topicChangedForChangeNumber) return;
         this._settingTopic = false;
         this.set(['change', 'topic'], newTopic);
         if (newTopic !== lastTopic) {
@@ -311,8 +326,7 @@
     changeRecord: ElementPropertyDeepChange<GrChangeMetadata, 'change'>,
     settingTopic?: boolean
   ) {
-    const hasTopic =
-      !!changeRecord && !!changeRecord.base && !!changeRecord.base.topic;
+    const hasTopic = !!changeRecord?.base?.topic;
     return !hasTopic && !settingTopic;
   }
 
@@ -320,8 +334,7 @@
     changeRecord: ElementPropertyDeepChange<GrChangeMetadata, 'change'>,
     settingTopic?: boolean
   ) {
-    const hasTopic =
-      !!changeRecord && !!changeRecord.base && !!changeRecord.base.topic;
+    const hasTopic = !!changeRecord?.base?.topic;
     return hasTopic && !settingTopic;
   }
 
@@ -329,10 +342,8 @@
     changeRecord: ElementPropertyDeepChange<GrChangeMetadata, 'change'>
   ) {
     const hasCherryPickOf =
-      !!changeRecord &&
-      !!changeRecord.base &&
-      !!changeRecord.base.cherry_pick_of_change &&
-      !!changeRecord.base.cherry_pick_of_patch_set;
+      !!changeRecord?.base?.cherry_pick_of_change &&
+      !!changeRecord?.base?.cherry_pick_of_patch_set;
     return hasCherryPickOf;
   }
 
@@ -359,33 +370,15 @@
   }
 
   _computeTopicReadOnly(mutable?: boolean, change?: ParsedChangeInfo) {
-    return (
-      !mutable ||
-      !change ||
-      !change.actions ||
-      !change.actions.topic ||
-      !change.actions.topic.enabled
-    );
+    return !mutable || !change?.actions?.topic?.enabled;
   }
 
   _computeHashtagReadOnly(mutable?: boolean, change?: ParsedChangeInfo) {
-    return (
-      !mutable ||
-      !change ||
-      !change.actions ||
-      !change.actions.hashtags ||
-      !change.actions.hashtags.enabled
-    );
+    return !mutable || !change?.actions?.hashtags?.enabled;
   }
 
   _computeAssigneeReadOnly(mutable?: boolean, change?: ParsedChangeInfo) {
-    return (
-      !mutable ||
-      !change ||
-      !change.actions ||
-      !change.actions.assignee ||
-      !change.actions.assignee.enabled
-    );
+    return !mutable || !change?.actions?.assignee?.enabled;
   }
 
   _computeTopicPlaceholder(_topicReadOnly?: boolean) {
@@ -419,17 +412,11 @@
   _computePushCertificateValidation(
     serverConfig?: ServerInfo,
     change?: ParsedChangeInfo
-  ): PushCertifacteValidationInfo | null {
-    if (
-      !change ||
-      !serverConfig ||
-      !serverConfig.receive ||
-      !serverConfig.receive.enable_signed_push
-    ) {
-      return null;
-    }
+  ): PushCertifacteValidationInfo | undefined {
+    if (!change || !serverConfig?.receive?.enable_signed_push) return undefined;
+
     const rev = change.revisions[change.current_revision];
-    if (!rev.push_certificate || !rev.push_certificate.key) {
+    if (!rev.push_certificate?.key) {
       return {
         class: 'help',
         icon: 'gr-icons:help',
@@ -472,7 +459,7 @@
   }
 
   _problems(msg: string, key: GpgKeyInfo) {
-    if (!key || !key.problems || key.problems.length === 0) {
+    if (!key?.problems || key.problems.length === 0) {
       return msg;
     }
 
@@ -525,7 +512,7 @@
     const target = (dom(e) as EventApi).rootTarget as GrLinkedChip;
     target.disabled = true;
     this.$.restAPI
-      .setChangeTopic(this.change._number, null)
+      .setChangeTopic(this.change._number)
       .then(() => {
         target.disabled = false;
         this.set(['change', 'topic'], '');
@@ -535,7 +522,6 @@
       })
       .catch(() => {
         target.disabled = false;
-        return;
       });
   }
 
@@ -554,35 +540,55 @@
       })
       .catch(() => {
         target.disabled = false;
-        return;
       });
   }
 
   _computeIsWip(change?: ParsedChangeInfo) {
-    return change && !!change.work_in_progress;
+    return !!change?.work_in_progress;
   }
 
   _computeShowRoleClass(change?: ParsedChangeInfo, role?: ChangeRole) {
     return this._getNonOwnerRole(change, role) ? '' : 'hideDisplay';
   }
 
+  _computeDisplayState(
+    showAllSections: boolean,
+    change: ParsedChangeInfo | undefined,
+    section: Metadata
+  ) {
+    if (
+      !this._isNewChangeSummaryUiEnabled ||
+      showAllSections ||
+      DisplayRules.ALWAYS_SHOW.includes(section) ||
+      (DisplayRules.SHOW_IF_SET.includes(section) &&
+        isSectionSet(section, change))
+    ) {
+      return '';
+    }
+    return 'hideDisplay';
+  }
+
+  _computeShowAllLabelText(showAllSections: boolean) {
+    if (showAllSections) {
+      return 'Show less';
+    } else {
+      return 'Show all';
+    }
+  }
+
+  _onShowAllClick() {
+    this._showAllSections = !this._showAllSections;
+  }
+
   /**
-   * Get the user with the specified role on the change. Returns null if the
+   * Get the user with the specified role on the change. Returns undefined if the
    * user with that role is the same as the owner.
    */
   _getNonOwnerRole(change?: ParsedChangeInfo, role?: ChangeRole) {
-    if (
-      !change ||
-      !change.current_revision ||
-      !change.revisions[change.current_revision]
-    ) {
-      return null;
-    }
+    if (!change?.revisions?.[change.current_revision]) return undefined;
 
     const rev = change.revisions[change.current_revision];
-    if (!rev) {
-      return null;
-    }
+    if (!rev) return undefined;
 
     if (
       role === ChangeRole.UPLOADER &&
@@ -611,7 +617,7 @@
       return rev.commit.committer;
     }
 
-    return null;
+    return undefined;
   }
 
   _computeParents(
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 f1d1127..16b2582 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
@@ -94,18 +94,59 @@
       --account-max-length: 120px;
       max-width: 285px;
     }
+    .metadata-title {
+      font-weight: var(--font-weight-bold);
+      color: var(--deemphasized-text-color);
+      padding-left: var(--metadata-horizontal-padding);
+    }
+    .metadata-header {
+      display: flex;
+      justify-content: space-between;
+    }
   </style>
   <gr-external-style id="externalStyle" name="change-metadata">
-    <section>
+    <template is="dom-if" if="[[_isNewChangeSummaryUiEnabled]]">
+      <div class="metadata-header">
+        <h3 class="metadata-title">Change Info</h3>
+        <gr-button
+          class="show-all-button"
+          on-click="_onShowAllClick"
+          no-uppercase=""
+          >[[_computeShowAllLabelText(_showAllSections)]]</gr-button
+        >
+      </div>
+    </template>
+    <template is="dom-if" if="[[_isNewChangeSummaryUiEnabled]]">
+      <template is="dom-if" if="[[change.submitted]]">
+        <section
+          class$="[[_computeDisplayState(_showAllSections, change, _SECTION.SUBMITTED)]]"
+        >
+          <span class="title">Submitted</span>
+          <span class="value">
+            <gr-date-formatter
+              has-tooltip=""
+              date-str="[[change.submitted]]"
+              show-yesterday=""
+            ></gr-date-formatter>
+          </span>
+        </section>
+      </template>
+    </template>
+    <section
+      class$="[[_computeDisplayState(_showAllSections, change, _SECTION.UPDATED)]]"
+    >
       <span class="title">Updated</span>
       <span class="value">
         <gr-date-formatter
           has-tooltip=""
           date-str="[[change.updated]]"
+          show-yesterday=""
         ></gr-date-formatter>
       </span>
     </section>
-    <section>
+    <section
+      class$="[[_computeDisplayState(_showAllSections, change, _SECTION.OWNER)]]"
+    >
       <span class="title">Owner</span>
       <span class="value">
         <gr-account-chip
@@ -156,7 +197,9 @@
       </span>
     </section>
     <template is="dom-if" if="[[_isAssigneeEnabled(serverConfig)]]">
-      <section class="assignee">
+      <section
+        class$="assignee [[_computeDisplayState(_showAllSections, change, _SECTION.ASSIGNEE)]]"
+      >
         <span class="title">Assignee</span>
         <span class="value">
           <gr-account-list
@@ -172,7 +215,9 @@
         </span>
       </section>
     </template>
-    <section>
+    <section
+      class$="[[_computeDisplayState(_showAllSections, change, _SECTION.REVIEWERS)]]"
+    >
       <span class="title">Reviewers</span>
       <span class="value">
         <gr-reviewer-list
@@ -183,7 +228,9 @@
         ></gr-reviewer-list>
       </span>
     </section>
-    <section>
+    <section
+      class$="[[_computeDisplayState(_showAllSections, change, _SECTION.CC)]]"
+    >
       <span class="title">CC</span>
       <span class="value">
         <gr-reviewer-list
@@ -198,7 +245,9 @@
       is="dom-if"
       if="[[_computeShowRepoBranchTogether(change.project, change.branch)]]"
     >
-      <section>
+      <section
+        class$="[[_computeDisplayState(_showAllSections, change, _SECTION.REPO_BRANCH)]]"
+      >
         <span class="title">Repo | Branch</span>
         <span class="value">
           <a href$="[[_computeProjectUrl(change.project)]]"
@@ -215,7 +264,9 @@
       is="dom-if"
       if="[[!_computeShowRepoBranchTogether(change.project, change.branch)]]"
     >
-      <section>
+      <section
+        class$="[[_computeDisplayState(_showAllSections, change, _SECTION.REPO_BRANCH)]]"
+      >
         <span class="title">Repo</span>
         <span class="value">
           <a href$="[[_computeProjectUrl(change.project)]]">
@@ -226,7 +277,9 @@
           </a>
         </span>
       </section>
-      <section>
+      <section
+        class$="[[_computeDisplayState(_showAllSections, change, _SECTION.REPO_BRANCH)]]"
+      >
         <span class="title">Branch</span>
         <span class="value">
           <a href$="[[_computeBranchUrl(change.project, change.branch)]]">
@@ -238,7 +291,9 @@
         </span>
       </section>
     </template>
-    <section>
+    <section
+      class$="[[_computeDisplayState(_showAllSections, change, _SECTION.PARENT)]]"
+    >
       <span class="title">[[_computeParentsLabel(_currentParents)]]</span>
       <span class="value">
         <ol
@@ -262,7 +317,9 @@
         </ol>
       </span>
     </section>
-    <section class="topic">
+    <section
+      class$="topic [[_computeDisplayState(_showAllSections, change, _SECTION.TOPIC)]]"
+    >
       <span class="title">Topic</span>
       <span class="value">
         <template is="dom-if" if="[[_showTopicChip(change.*, _settingTopic)]]">
@@ -288,7 +345,9 @@
       </span>
     </section>
     <template is="dom-if" if="[[_showCherryPickOf(change.*)]]">
-      <section>
+      <section
+        class$="[[_computeDisplayState(_showAllSections, change, _SECTION.CHERRY_PICK_OF)]]"
+      >
         <span class="title">Cherry pick of</span>
         <span class="value">
           <a
@@ -304,14 +363,16 @@
       </section>
     </template>
     <section
-      class="strategy"
+      class$="strategy [[_computeDisplayState(_showAllSections, change, _SECTION.STRATEGY)]]"
       hidden$="[[_computeHideStrategy(change)]]"
       hidden=""
     >
       <span class="title">Strategy</span>
       <span class="value">[[_computeStrategy(change)]]</span>
     </section>
-    <section class="hashtag">
+    <section
+      class$="hashtag [[_computeDisplayState(_showAllSections, change, _SECTION.HASHTAGS)]]"
+    >
       <span class="title">Hashtags</span>
       <span class="value">
         <template is="dom-repeat" items="[[change.hashtags]]">
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.js b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.js
index 5bdf105..3ed7dd4 100644
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.js
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.js
@@ -716,7 +716,7 @@
       MockInteractions.tap(remove);
       assert.isTrue(chip.disabled);
       assert.isTrue(element.$.restAPI.setChangeTopic.calledWith(
-          42, null));
+          42));
       return element.$.restAPI.setChangeTopic.lastCall.returnValue
           .then(() => {
             assert.isFalse(chip.disabled);
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 2c9b31c..24841fc 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
@@ -43,6 +43,7 @@
 import '../gr-reply-dialog/gr-reply-dialog';
 import '../gr-thread-list/gr-thread-list';
 import '../gr-upload-help-dialog/gr-upload-help-dialog';
+import '../../checks/gr-checks-tab';
 import {flush} from '@polymer/polymer/lib/legacy/polymer.dom';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
@@ -73,7 +74,7 @@
   PatchSet,
 } from '../../../utils/patch-set-util';
 import {changeStatuses, changeStatusString} from '../../../utils/change-util';
-import {EventType} from '../../plugins/gr-plugin-types';
+import {EventType as PluginEventType} from '../../plugins/gr-plugin-types';
 import {customElement, property, observe} from '@polymer/decorators';
 import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
 import {GrJsApiInterface} from '../../shared/gr-js-api-interface/gr-js-api-interface-element';
@@ -145,13 +146,15 @@
   CustomKeyboardEvent,
   EditableContentSaveEvent,
   OpenFixPreviewEvent,
-  ShowAlertEventDetail,
   SwitchTabEvent,
 } from '../../../types/events';
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {GrMessagesList} from '../gr-messages-list/gr-messages-list';
 import {GrThreadList} from '../gr-thread-list/gr-thread-list';
 import {PORTING_COMMENTS_CHANGE_LATENCY_LABEL} from '../../../services/gr-reporting/gr-reporting';
+import {fireAlert, firePageError} from '../../../utils/event-util';
+import {KnownExperimentId} from '../../../services/flags/flags';
+import {fireTitleChange} from '../../../utils/event-util';
 
 const CHANGE_ID_ERROR = {
   MISMATCH: 'mismatch',
@@ -262,6 +265,8 @@
 
   reporting = appContext.reportingService;
 
+  flagsService = appContext.flagsService;
+
   /**
    * URL params passed from the router.
    */
@@ -531,6 +536,8 @@
 
   _throttledToggleChangeStar?: EventListener;
 
+  _isChecksEnabled = false;
+
   keyboardShortcuts() {
     return {
       [Shortcut.SEND_REPLY]: null, // DOC_ONLY binding
@@ -555,6 +562,14 @@
   }
 
   /** @override */
+  ready() {
+    super.ready();
+    this._isChecksEnabled = this.flagsService.isEnabled(
+      KnownExperimentId.CI_REBOOT_CHECKS
+    );
+  }
+
+  /** @override */
   connectedCallback() {
     super.connectedCallback();
     this._throttledToggleChangeStar = this._throttleWrap(e =>
@@ -1297,7 +1312,7 @@
   _sendShowChangeEvent() {
     if (!this._patchRange)
       throw new Error('missing required _patchRange property');
-    this.$.jsAPI.handleEvent(EventType.SHOW_CHANGE, {
+    this.$.jsAPI.handleEvent(PluginEventType.SHOW_CHANGE, {
       change: this._change,
       patchNum: this._patchRange.patchNum,
       info: {mergeable: this._mergeable},
@@ -1474,13 +1489,7 @@
     this.set('_patchRange.basePatchNum', parent);
 
     const title = change.subject + ' (' + change.change_id.substr(0, 9) + ')';
-    this.dispatchEvent(
-      new CustomEvent('title-change', {
-        detail: {title},
-        composed: true,
-        bubbles: true,
-      })
-    );
+    fireTitleChange(this, title);
   }
 
   /**
@@ -1654,15 +1663,7 @@
     if (!this._patchRange)
       throw new Error('missing required _patchRange property');
     if (patchNumEquals(this._patchRange.basePatchNum, ParentPatchSetNum)) {
-      this.dispatchEvent(
-        new CustomEvent('show-alert', {
-          detail: {
-            message: 'Base is already selected.',
-          },
-          composed: true,
-          bubbles: true,
-        })
-      );
+      fireAlert(this, 'Base is already selected.');
       return;
     }
     GerritNav.navigateToChange(this._change, this._patchRange.patchNum);
@@ -1676,15 +1677,7 @@
     if (!this._patchRange)
       throw new Error('missing required _patchRange property');
     if (patchNumEquals(this._patchRange.basePatchNum, ParentPatchSetNum)) {
-      this.dispatchEvent(
-        new CustomEvent('show-alert', {
-          detail: {
-            message: 'Left is already base.',
-          },
-          composed: true,
-          bubbles: true,
-        })
-      );
+      fireAlert(this, 'Left is already base.');
       return;
     }
     GerritNav.navigateToChange(this._change, this._patchRange.basePatchNum);
@@ -1699,16 +1692,7 @@
       throw new Error('missing required _patchRange property');
     const latestPatchNum = computeLatestPatchNum(this._allPatchSets);
     if (patchNumEquals(this._patchRange.patchNum, latestPatchNum)) {
-      const detail: ShowAlertEventDetail = {
-        message: 'Latest is already selected.',
-      };
-      this.dispatchEvent(
-        new CustomEvent('show-alert', {
-          detail,
-          composed: true,
-          bubbles: true,
-        })
-      );
+      fireAlert(this, 'Latest is already selected.');
       return;
     }
     GerritNav.navigateToChange(
@@ -1727,15 +1711,7 @@
     if (!this._patchRange)
       throw new Error('missing required _patchRange property');
     if (patchNumEquals(this._patchRange.patchNum, latestPatchNum)) {
-      this.dispatchEvent(
-        new CustomEvent('show-alert', {
-          detail: {
-            message: 'Right is already latest.',
-          },
-          composed: true,
-          bubbles: true,
-        })
-      );
+      fireAlert(this, 'Right is already latest.');
       return;
     }
     GerritNav.navigateToChange(
@@ -1757,15 +1733,7 @@
       patchNumEquals(this._patchRange.patchNum, latestPatchNum) &&
       patchNumEquals(this._patchRange.basePatchNum, ParentPatchSetNum)
     ) {
-      this.dispatchEvent(
-        new CustomEvent('show-alert', {
-          detail: {
-            message: 'Already diffing base against latest.',
-          },
-          composed: true,
-          bubbles: true,
-        })
-      );
+      fireAlert(this, 'Already diffing base against latest.');
       return;
     }
     GerritNav.navigateToChange(this._change, latestPatchNum);
@@ -1873,7 +1841,7 @@
         changeRecord.path
       );
     }
-    this.$.jsAPI.handleEvent(EventType.LABEL_CHANGE, {
+    this.$.jsAPI.handleEvent(PluginEventType.LABEL_CHANGE, {
       change: this._change,
     });
   }
@@ -1889,13 +1857,7 @@
   }
 
   _handleGetChangeDetailError(response?: Response | null) {
-    this.dispatchEvent(
-      new CustomEvent('page-error', {
-        detail: {response},
-        composed: true,
-        bubbles: true,
-      })
-    );
+    firePageError(this, response);
   }
 
   _getLoggedIn() {
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 efc86bc..fbf01dc 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
@@ -558,6 +558,11 @@
           <span>Comments</span></gr-tooltip-content
         >
       </paper-tab>
+      <template is="dom-if" if="[[_isChecksEnabled]]">
+        <paper-tab data-name$="[[_constants.PrimaryTab.CHECKS]]"
+          >Checks</paper-tab
+        >
+      </template>
       <template
         is="dom-repeat"
         items="[[_dynamicTabHeaderEndpoints]]"
@@ -648,6 +653,12 @@
       </template>
       <template
         is="dom-if"
+        if="[[_isTabActive(_constants.PrimaryTab.CHECKS, _activeTabs)]]"
+      >
+        <gr-checks-tab id="checksTab"></gr-checks-tab>
+      </template>
+      <template
+        is="dom-if"
         if="[[_isTabActive(_constants.PrimaryTab.FINDINGS, _activeTabs)]]"
       >
         <gr-dropdown-list
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 8d06a03..6262300 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
@@ -190,6 +190,7 @@
       path: '/COMMIT_MSG',
       line: 5,
       rootId: 'ecf0b9fa_fe1a5f62' as UrlEncodedCommentId,
+      commentSide: CommentSide.REVISION,
     },
     {
       comments: [
@@ -249,6 +250,7 @@
       path: '/COMMIT_MSG',
       line: 4,
       rootId: '8caddf38_44770ec1' as UrlEncodedCommentId,
+      commentSide: CommentSide.REVISION,
     },
     {
       comments: [
@@ -271,6 +273,7 @@
       path: '/COMMIT_MSG',
       line: 4,
       rootId: 'scaddf38_44770ec1' as UrlEncodedCommentId,
+      commentSide: CommentSide.REVISION,
     },
     {
       comments: [
@@ -291,6 +294,7 @@
       path: '/COMMIT_MSG',
       line: 6,
       rootId: 'zcf0b9fa_fe1a5f62' as UrlEncodedCommentId,
+      commentSide: CommentSide.REVISION,
     },
     {
       comments: [
@@ -314,6 +318,7 @@
       path: '/COMMIT_MSG',
       line: 5,
       rootId: 'rc1' as UrlEncodedCommentId,
+      commentSide: CommentSide.REVISION,
     },
     {
       comments: [
@@ -351,6 +356,7 @@
       path: '/COMMIT_MSG',
       line: 5,
       rootId: 'rc2' as UrlEncodedCommentId,
+      commentSide: CommentSide.REVISION,
     },
   ];
 
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.ts b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.ts
index 5c0b19f..3facde1 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.ts
@@ -25,6 +25,7 @@
 import {customElement, property} from '@polymer/decorators';
 import {JsApiService} from '../../shared/gr-js-api-interface/gr-js-api-types';
 import {ChangeInfo, CommitId} from '../../../types/common';
+import {fireAlert} from '../../../utils/event-util';
 
 const ERR_COMMIT_NOT_FOUND = 'Unable to find the commit hash of this change.';
 const CHANGE_SUBJECT_LIMIT = 50;
@@ -124,13 +125,7 @@
     const originalTitle = (commitMessage || '').split('\n')[0];
     const revertTitle = `Revert "${originalTitle}"`;
     if (!commitHash) {
-      this.dispatchEvent(
-        new CustomEvent('show-alert', {
-          detail: {message: ERR_COMMIT_NOT_FOUND},
-          composed: true,
-          bubbles: true,
-        })
-      );
+      fireAlert(this, ERR_COMMIT_NOT_FOUND);
       return;
     }
     const revertCommitText = `This reverts commit ${commitHash}.`;
@@ -168,13 +163,7 @@
     // Follow the same convention of the revert
     const commitHash = change.current_revision;
     if (!commitHash) {
-      this.dispatchEvent(
-        new CustomEvent('show-alert', {
-          detail: {message: ERR_COMMIT_NOT_FOUND},
-          composed: true,
-          bubbles: true,
-        })
-      );
+      fireAlert(this, ERR_COMMIT_NOT_FOUND);
       return;
     }
     if (!changes || changes.length <= 1) return;
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-revert-submission-dialog/gr-confirm-revert-submission-dialog.ts b/polygerrit-ui/app/elements/change/gr-confirm-revert-submission-dialog/gr-confirm-revert-submission-dialog.ts
index 9754e89..ac52664 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-revert-submission-dialog/gr-confirm-revert-submission-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-revert-submission-dialog/gr-confirm-revert-submission-dialog.ts
@@ -24,6 +24,7 @@
 import {customElement, property} from '@polymer/decorators';
 import {JsApiService} from '../../shared/gr-js-api-interface/gr-js-api-types';
 import {ChangeInfo} from '../../../types/common';
+import {fireAlert} from '../../../utils/event-util';
 
 const ERR_COMMIT_NOT_FOUND = 'Unable to find the commit hash of this change.';
 const CHANGE_SUBJECT_LIMIT = 50;
@@ -86,13 +87,7 @@
     // Follow the same convention of the revert
     const commitHash = change.current_revision;
     if (!commitHash) {
-      this.dispatchEvent(
-        new CustomEvent('show-alert', {
-          detail: {message: ERR_COMMIT_NOT_FOUND},
-          composed: true,
-          bubbles: true,
-        })
-      );
+      fireAlert(this, ERR_COMMIT_NOT_FOUND);
       return;
     }
     const revertTitle = `Revert submission ${change.submission_id}`;
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 e5107f1..d925f96 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
@@ -998,6 +998,7 @@
       return;
     }
     e.preventDefault();
+    this.classList.remove('hideComments');
     this.$.diffCursor.createCommentInPlace();
   }
 
@@ -1557,10 +1558,9 @@
             'changeComments, patchRange and diffPrefs must be set'
           );
         }
-        diffElem.comments = this.changeComments.getCommentsBySideForFile(
+        diffElem.threads = this.changeComments.getThreadsBySideForFile(
           file,
-          this.patchRange,
-          this.projectConfig
+          this.patchRange
         );
         const promises: Array<Promise<unknown>> = [diffElem.reload()];
         if (this._loggedIn && !this.diffPrefs.manual_review) {
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 d85ae4d..c750bd2 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
@@ -1498,6 +1498,7 @@
     const commitMsgComments = [
       {
         patch_set: 2,
+        path: '/p',
         id: 'ecf0b9fa_fe1a5f62',
         line: 20,
         updated: '2018-02-08 18:49:18.000000000',
@@ -1506,6 +1507,7 @@
       },
       {
         patch_set: 2,
+        path: '/p',
         id: '503008e2_0ab203ee',
         line: 10,
         updated: '2018-02-14 22:07:43.000000000',
@@ -1514,6 +1516,7 @@
       },
       {
         patch_set: 2,
+        path: '/p',
         id: 'cc788d2c_cb1d728c',
         line: 20,
         in_reply_to: 'ecf0b9fa_fe1a5f62',
@@ -1888,6 +1891,7 @@
       const commentStubRes1 = [
         {
           patch_set: 2,
+          path: '/p',
           id: '503008e2_0ab203ee',
           line: 20,
           updated: '2018-02-08 18:49:18.000000000',
@@ -1898,6 +1902,7 @@
       const commentStubRes2 = [
         {
           patch_set: 2,
+          path: '/p',
           id: 'ecf0b9fa_fe1a5f62',
           line: 20,
           updated: '2018-02-08 18:49:18.000000000',
@@ -1906,6 +1911,7 @@
         },
         {
           patch_set: 2,
+          path: '/p',
           id: '503008e2_0ab203ee',
           line: 10,
           in_reply_to: 'ecf0b9fa_fe1a5f62',
@@ -1915,6 +1921,7 @@
         },
         {
           patch_set: 2,
+          path: '/p',
           id: '503008e2_0ab203ef',
           line: 20,
           in_reply_to: '503008e2_0ab203ee',
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 7b34263..f42adc6 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
@@ -110,6 +110,7 @@
 import {isAttentionSetEnabled} from '../../../utils/attention-set-util';
 import {CODE_REVIEW, getMaxAccounts} from '../../../utils/label-util';
 import {isUnresolved} from '../../../utils/comment-util';
+import {fireAlert} from '../../../utils/event-util';
 
 const STORAGE_DEBOUNCE_INTERVAL_MS = 400;
 
@@ -548,13 +549,7 @@
             const moveTo = isReviewer ? 'reviewer' : 'CC';
             const id = account.name || account.email || key;
             const message = `${id} moved from ${moveFrom} to ${moveTo}.`;
-            this.dispatchEvent(
-              new CustomEvent('show-alert', {
-                detail: {message},
-                composed: true,
-                bubbles: true,
-              })
-            );
+            fireAlert(this, message);
           }
         }
       }
@@ -1275,13 +1270,7 @@
       return;
     }
     if (this._sendDisabled) {
-      this.dispatchEvent(
-        new CustomEvent('show-alert', {
-          bubbles: true,
-          composed: true,
-          detail: {message: EMPTY_REPLY_MESSAGE},
-        })
-      );
+      fireAlert(this, EMPTY_REPLY_MESSAGE);
       return;
     }
     return this.send(this._includeComments, this.canBeStarted)
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-tab.ts b/polygerrit-ui/app/elements/checks/gr-checks-tab.ts
new file mode 100644
index 0000000..ca73f4f
--- /dev/null
+++ b/polygerrit-ui/app/elements/checks/gr-checks-tab.ts
@@ -0,0 +1,36 @@
+/**
+ * @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 {html} from 'lit-html';
+import {customElement} from 'lit-element';
+import {GrLitElement} from '../lit/gr-lit-element';
+
+/**
+ * The "Checks" tab on the Gerrit change page. Gets its data from plugins that
+ * have registered with the Checks Plugin API.
+ */
+@customElement('gr-checks-tab')
+export class GrChecksTab extends GrLitElement {
+  render() {
+    return html`<span>Hello Checks!</span>`;
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-checks-tab': GrChecksTab;
+  }
+}
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-tab_test.ts b/polygerrit-ui/app/elements/checks/gr-checks-tab_test.ts
new file mode 100644
index 0000000..85183ed
--- /dev/null
+++ b/polygerrit-ui/app/elements/checks/gr-checks-tab_test.ts
@@ -0,0 +1,26 @@
+/**
+ * @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 '../../test/common-test-setup-karma';
+import {GrChecksTab} from './gr-checks-tab';
+
+suite('gr-checks-tab test', () => {
+  test('is defined', () => {
+    const el = document.createElement('gr-checks-tab');
+    assert.instanceOf(el, GrChecksTab);
+  });
+});
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 7a56d1c..e4e4056 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
@@ -39,6 +39,7 @@
 import {FetchRequest} from '../../shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
 import {ErrorType, FixIronA11yAnnouncer} from '../../../types/types';
 import {AccountId} from '../../../types/common';
+import {EventType} from '../../../utils/event-util';
 
 const HIDE_ALERT_TIMEOUT_MS = 5000;
 const CHECK_SIGN_IN_INTERVAL_MS = 60 * 1000;
@@ -129,7 +130,7 @@
     super.attached();
     this.listen(document, 'server-error', '_handleServerError');
     this.listen(document, 'network-error', '_handleNetworkError');
-    this.listen(document, 'show-alert', '_handleShowAlert');
+    this.listen(document, EventType.SHOW_ALERT, '_handleShowAlert');
     this.listen(document, 'hide-alert', '_hideAlert');
     this.listen(document, 'show-error', '_handleShowErrorDialog');
     this.listen(document, 'visibilitychange', '_handleVisibilityChange');
@@ -151,7 +152,7 @@
     this._clearHideAlertHandle();
     this.unlisten(document, 'server-error', '_handleServerError');
     this.unlisten(document, 'network-error', '_handleNetworkError');
-    this.unlisten(document, 'show-alert', '_handleShowAlert');
+    this.unlisten(document, EventType.SHOW_ALERT, '_handleShowAlert');
     this.unlisten(document, 'hide-alert', '_hideAlert');
     this.unlisten(document, 'show-error', '_handleShowErrorDialog');
     this.unlisten(document, 'visibilitychange', '_handleVisibilityChange');
@@ -335,7 +336,7 @@
     const el = this._createToastAlert();
     el.show(text, actionText, actionCallback);
     this._alertElement = el;
-    this.fire('iron-announce', {text}, {bubbles: true});
+    this.fire('iron-announce', {text: `Alert: ${text}`}, {bubbles: true});
     this.reporting.reportInteraction('show-alert', {text});
   }
 
diff --git a/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display_html.ts b/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display_html.ts
index 0a75104..251e30f 100644
--- a/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display_html.ts
+++ b/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display_html.ts
@@ -20,6 +20,7 @@
   <style include="shared-styles">
     .key {
       background-color: var(--chip-background-color);
+      color: var(--primary-text-color);
       border: 1px solid var(--border-color);
       border-radius: var(--border-radius);
       display: inline-block;
diff --git a/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog_html.ts b/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog_html.ts
index 1860f38..3576dfe 100644
--- a/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog_html.ts
+++ b/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog_html.ts
@@ -30,21 +30,39 @@
       display: flex;
       padding: 0 var(--spacing-xxl) var(--spacing-xxl);
     }
+    .column {
+      flex: 50%;
+    }
     header {
       align-items: center;
       border-bottom: 1px solid var(--border-color);
       display: flex;
       justify-content: space-between;
     }
-    table:last-of-type {
-      margin-left: var(--spacing-xxl);
+    table caption {
+      font-weight: var(--font-weight-bold);
+      padding-top: var(--spacing-l);
+      text-align: left;
+    }
+    tr {
+      height: 32px;
     }
     td {
       padding: var(--spacing-xs) 0;
     }
-    td:first-child {
+    td:first-child,
+    th:first-child {
       padding-right: var(--spacing-m);
       text-align: right;
+      width: 160px;
+      color: var(--deemphasized-text-color);
+    }
+    td:second-child {
+      min-width: 200px;
+    }
+    th {
+      color: var(--deemphasized-text-color);
+      text-align: left;
     }
     .header {
       font-weight: var(--font-weight-bold);
@@ -59,33 +77,19 @@
     <gr-button link="" on-click="_handleCloseTap">Close</gr-button>
   </header>
   <main>
-    <table>
-      <tbody>
-        <template is="dom-repeat" items="[[_left]]">
-          <tr>
-            <td></td>
-            <td class="header">[[item.section]]</td>
-          </tr>
-          <template is="dom-repeat" items="[[item.shortcuts]]" as="shortcut">
+    <div class="column">
+      <template is="dom-repeat" items="[[_left]]">
+        <table>
+          <caption>
+            [[item.section]]
+          </caption>
+          <thead>
             <tr>
-              <td>
-                <gr-key-binding-display binding="[[shortcut.binding]]">
-                </gr-key-binding-display>
-              </td>
-              <td>[[shortcut.text]]</td>
+              <th>Key</th>
+              <th>Action</th>
             </tr>
-          </template>
-        </template>
-      </tbody>
-    </table>
-    <template is="dom-if" if="[[_right]]">
-      <table>
-        <tbody>
-          <template is="dom-repeat" items="[[_right]]">
-            <tr>
-              <td></td>
-              <td class="header">[[item.section]]</td>
-            </tr>
+          </thead>
+          <tbody>
             <template is="dom-repeat" items="[[item.shortcuts]]" as="shortcut">
               <tr>
                 <td>
@@ -95,10 +99,36 @@
                 <td>[[shortcut.text]]</td>
               </tr>
             </template>
-          </template>
-        </tbody>
-      </table>
-    </template>
+          </tbody>
+        </table>
+      </template>
+    </div>
+    <div class="column">
+      <template is="dom-repeat" items="[[_right]]">
+        <table>
+          <caption>
+            [[item.section]]
+          </caption>
+          <thead>
+            <tr>
+              <th>Key</th>
+              <th>Action</th>
+            </tr>
+          </thead>
+          <tbody>
+            <template is="dom-repeat" items="[[item.shortcuts]]" as="shortcut">
+              <tr>
+                <td>
+                  <gr-key-binding-display binding="[[shortcut.binding]]">
+                  </gr-key-binding-display>
+                </td>
+                <td>[[shortcut.text]]</td>
+              </tr>
+            </template>
+          </tbody>
+        </table>
+      </template>
+    </div>
   </main>
   <footer></footer>
 `;
diff --git a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.ts b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.ts
index caa0521..c4ed683 100644
--- a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.ts
+++ b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.ts
@@ -348,6 +348,7 @@
     // If not, it sets target='_blank' on the menu item. The server
     // makes assumptions that work for the GWT UI, but not PolyGerrit,
     // so we'll just disable it altogether for now.
+    // eslint-disable-next-line @typescript-eslint/no-unused-vars
     const {target, ...headerLink} = {...linkObj};
 
     // Normalize all urls to PolyGerrit style.
diff --git a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_html.ts b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_html.ts
index 2fbdc7e..2ee0058 100644
--- a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_html.ts
+++ b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_html.ts
@@ -47,6 +47,7 @@
         href$="[[_computeHelpDocLink(docBaseUrl)]]"
         target="_blank"
         class="help"
+        tabindex="-1"
       >
         <iron-icon
           icon="gr-icons:help-outline"
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 367bbd6..d89bb24 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
@@ -23,11 +23,11 @@
   getParentIndex,
   isMergeParent,
   patchNumEquals,
+  CURRENT,
 } from '../../../utils/patch-set-util';
 import {customElement, property} from '@polymer/decorators';
 import {
   CommentBasics,
-  ConfigInfo,
   ParentPatchSetNum,
   PatchRange,
   PatchSetNum,
@@ -38,7 +38,7 @@
   RevisionId,
 } from '../../../types/common';
 import {hasOwnProperty} from '../../../utils/common-util';
-import {CommentSide} from '../../../constants/constants';
+import {CommentSide, Side} from '../../../constants/constants';
 import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
 import {
   Comment,
@@ -46,11 +46,11 @@
   CommentThread,
   DraftInfo,
   isUnresolved,
-  sortComments,
   UIComment,
   UIDraft,
   UIHuman,
   UIRobot,
+  createCommentThreads,
 } from '../../../utils/comment-util';
 import {PatchSetFile, PatchNumOnly, isPatchSetFile} from '../../../types/types';
 
@@ -59,13 +59,6 @@
 };
 
 export interface TwoSidesComments {
-  // TODO(TS): remove meta - it is not used anywhere
-  meta: {
-    changeNum: NumericChangeId;
-    path: string;
-    patchRange: PatchRange;
-    projectConfig?: ConfigInfo;
-  };
   left: UIComment[];
   right: UIComment[];
 }
@@ -77,8 +70,6 @@
 
   private readonly _drafts: {[path: string]: UIDraft[]};
 
-  private readonly _changeNum: NumericChangeId;
-
   /**
    * Construct a change comments object, which can be data-bound to child
    * elements of that which uses the gr-comment-api.
@@ -86,14 +77,11 @@
   constructor(
     comments: {[path: string]: UIHuman[]} | undefined,
     robotComments: {[path: string]: UIRobot[]} | undefined,
-    drafts: {[path: string]: UIDraft[]} | undefined,
-    changeNum: NumericChangeId
+    drafts: {[path: string]: UIDraft[]} | undefined
   ) {
     this._comments = this._addPath(comments);
     this._robotComments = this._addPath(robotComments);
     this._drafts = this._addPath(drafts);
-    // TODO(TS): remove changeNum param - it is not used anywhere
-    this._changeNum = changeNum;
   }
 
   /**
@@ -313,6 +301,27 @@
     return allDrafts;
   }
 
+  _addCommentSide(comments: TwoSidesComments) {
+    const allComments = [];
+    for (const side of [Side.LEFT, Side.RIGHT]) {
+      // This is needed by the threading.
+      for (const comment of comments[side]) {
+        comment.__commentSide = side;
+      }
+      allComments.push(...comments[side]);
+    }
+    return allComments;
+  }
+
+  getThreadsBySideForPath(
+    path: string,
+    patchRange: PatchRange
+  ): CommentThread[] {
+    return createCommentThreads(
+      this._addCommentSide(this.getCommentsBySideForPath(path, patchRange))
+    );
+  }
+
   /**
    * Get the comments (with drafts and robot comments) for a path and
    * patch-range. Returns an object with left and right properties mapping to
@@ -325,8 +334,7 @@
    */
   getCommentsBySideForPath(
     path: string,
-    patchRange: PatchRange,
-    projectConfig?: ConfigInfo
+    patchRange: PatchRange
   ): TwoSidesComments {
     let comments: Comment[] = [];
     let drafts: DraftInfo[] = [];
@@ -360,17 +368,20 @@
     );
 
     return {
-      meta: {
-        changeNum: this._changeNum,
-        path,
-        patchRange,
-        projectConfig,
-      },
       left: baseComments,
       right: revisionComments,
     };
   }
 
+  getThreadsBySideForFile(
+    file: PatchSetFile,
+    patchRange: PatchRange
+  ): CommentThread[] {
+    return createCommentThreads(
+      this._addCommentSide(this.getCommentsBySideForFile(file, patchRange))
+    );
+  }
+
   /**
    * Get the comments (with drafts and robot comments) for a file and
    * patch-range. Returns an object with left and right properties mapping to
@@ -385,19 +396,13 @@
    */
   getCommentsBySideForFile(
     file: PatchSetFile,
-    patchRange: PatchRange,
-    projectConfig?: ConfigInfo
+    patchRange: PatchRange
   ): TwoSidesComments {
-    const comments = this.getCommentsBySideForPath(
-      file.path,
-      patchRange,
-      projectConfig
-    );
+    const comments = this.getCommentsBySideForPath(file.path, patchRange);
     if (file.basePath) {
       const commentsForBasePath = this.getCommentsBySideForPath(
         file.basePath,
-        patchRange,
-        projectConfig
+        patchRange
       );
       // merge in the left and right
       comments.left = comments.left.concat(commentsForBasePath.left);
@@ -448,7 +453,7 @@
       );
     }
 
-    return this.getCommentThreads(comments).length;
+    return createCommentThreads(comments).length;
   }
 
   /**
@@ -480,56 +485,14 @@
     }
 
     comments = comments.concat(drafts);
-    const threads = this.getCommentThreads(sortComments(comments));
+    const threads = createCommentThreads(comments);
     const unresolvedThreads = threads.filter(isUnresolved);
     return unresolvedThreads.length;
   }
 
   getAllThreadsForChange() {
     const comments = this._commentObjToArrayWithFile(this.getAllComments(true));
-    const sortedComments = sortComments(comments);
-    return this.getCommentThreads(sortedComments);
-  }
-
-  /**
-   * Computes all of the comments in thread format.
-   *
-   * @param comments sorted by updated timestamp.
-   */
-  getCommentThreads(comments: UIComment[]) {
-    const threads: CommentThread[] = [];
-    const idThreadMap: CommentIdToCommentThreadMap = {};
-    for (const comment of comments) {
-      if (!comment.id) continue;
-      // If the comment is in reply to another comment, find that comment's
-      // thread and append to it.
-      if (comment.in_reply_to) {
-        const thread = idThreadMap[comment.in_reply_to];
-        if (thread) {
-          thread.comments.push(comment);
-          idThreadMap[comment.id] = thread;
-          continue;
-        }
-      }
-
-      // Otherwise, this comment starts its own thread.
-      if (!comment.__path && !comment.path) {
-        throw new Error('Comment missing required "path".');
-      }
-      const newThread: CommentThread = {
-        comments: [comment],
-        patchNum: comment.patch_set,
-        path: comment.__path || comment.path!,
-        line: comment.line,
-        rootId: comment.id,
-      };
-      if (comment.side) {
-        newThread.commentSide = comment.side;
-      }
-      threads.push(newThread);
-      idThreadMap[comment.id] = newThread;
-    }
-    return threads;
+    return createCommentThreads(comments);
   }
 
   /**
@@ -618,7 +581,7 @@
   }
 
   getPortedComments(changeNum: NumericChangeId, revision?: RevisionId) {
-    if (!revision) revision = 'current';
+    if (!revision) revision = CURRENT;
     return Promise.all([
       this.$.restAPI.getPortedComments(changeNum, revision),
       this.$.restAPI.getPortedDrafts(changeNum, revision),
@@ -648,8 +611,7 @@
         // PathToCommentsInfoMap given its PathToRobotCommentsInfoMap
         // returned from the second promise
         robotComments as PathToRobotCommentsInfoMap,
-        drafts,
-        changeNum
+        drafts
       );
       return this._changeComments;
     });
@@ -669,8 +631,7 @@
       this._changeComments = new ChangeComments(
         oldChangeComments.comments,
         (oldChangeComments.robotComments as unknown) as PathToRobotCommentsInfoMap,
-        drafts,
-        changeNum
+        drafts
       );
       return this._changeComments;
     });
diff --git a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_test.js b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_test.js
index be6f646..c2c1c72 100644
--- a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_test.js
@@ -18,6 +18,7 @@
 import '../../../test/common-test-setup-karma.js';
 import './gr-comment-api.js';
 import {ChangeComments} from './gr-comment-api.js';
+import {CommentSide} from '../../../constants/constants.js';
 
 const basicFixture = fixtureFromElement('gr-comment-api');
 
@@ -351,7 +352,6 @@
         let path = 'file/one';
         let comments = element._changeComments.getCommentsBySideForPath(path,
             patchRange);
-        assert.equal(comments.meta.changeNum, 1234);
         assert.equal(comments.left.length, 0);
         assert.equal(comments.right.length, 0);
 
@@ -538,11 +538,18 @@
                 __path: 'file/one',
               },
             ],
+            diffSide: undefined,
             commentSide: 'PARENT',
             patchNum: 2,
             path: 'file/one',
             line: 1,
             rootId: '01',
+            range: {
+              start_line: 1,
+              start_character: 2,
+              end_line: 2,
+              end_character: 2,
+            },
           }, {
             comments: [
               {
@@ -555,10 +562,12 @@
                 updated: '2013-02-26 15:01:43.986000000',
               },
             ],
-            commentSide: 'PARENT',
             patchNum: 2,
             path: 'file/one',
             line: 2,
+            range: undefined,
+            diffSide: undefined,
+            commentSide: CommentSide.PARENT,
             rootId: '03',
           }, {
             comments: [
@@ -595,6 +604,9 @@
             path: 'file/one',
             line: 1,
             rootId: '04',
+            range: undefined,
+            diffSide: undefined,
+            commentSide: CommentSide.REVISION,
           }, {
             comments: [
               {
@@ -610,6 +622,9 @@
             path: 'file/two',
             line: 2,
             rootId: '05',
+            range: undefined,
+            diffSide: undefined,
+            commentSide: CommentSide.REVISION,
           }, {
             comments: [
               {
@@ -625,6 +640,9 @@
             path: 'file/two',
             line: 2,
             rootId: '06',
+            range: undefined,
+            diffSide: undefined,
+            commentSide: CommentSide.REVISION,
           }, {
             comments: [
               {
@@ -654,6 +672,8 @@
             path: 'file/three',
             line: 1,
             rootId: '07',
+            range: undefined,
+            diffSide: undefined,
           }, {
             comments: [
               {
@@ -669,6 +689,9 @@
             path: 'file/three',
             line: 1,
             rootId: '09',
+            range: undefined,
+            diffSide: undefined,
+            commentSide: CommentSide.REVISION,
           }, {
             comments: [
               {
@@ -686,6 +709,8 @@
             path: 'file/four',
             line: 1,
             rootId: '10',
+            range: undefined,
+            diffSide: undefined,
           }, {
             comments: [
               {
@@ -701,6 +726,9 @@
             patchNum: 5,
             path: 'file/four',
             line: 1,
+            range: undefined,
+            diffSide: undefined,
+            commentSide: CommentSide.REVISION,
           }, {
             comments: [
               {
@@ -717,6 +745,9 @@
             patchNum: 3,
             path: 'file/two',
             line: 1,
+            range: undefined,
+            diffSide: undefined,
+            commentSide: CommentSide.REVISION,
           }, {
             comments: [
               {
@@ -735,6 +766,8 @@
             patchNum: 2,
             path: 'file/one',
             line: 1,
+            range: undefined,
+            diffSide: undefined,
           },
         ];
         const threads = element._changeComments.getAllThreadsForChange();
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-element.ts b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-element.ts
index 9e64ee6..912d59e 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-element.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-element.ts
@@ -48,6 +48,7 @@
 import {GrDiffGroup} from '../gr-diff/gr-diff-group';
 import {PolymerSpliceChange} from '@polymer/polymer/interfaces';
 import {getLineNumber} from '../gr-diff/gr-diff-utils';
+import {fireAlert} from '../../../utils/event-util';
 
 const DiffViewMode = {
   SIDE_BY_SIDE: 'SIDE_BY_SIDE',
@@ -305,7 +306,7 @@
     return this.getContentTdByLine(line, side, row);
   }
 
-  getLineElByNumber(lineNumber: string | number, side?: Side) {
+  getLineElByNumber(lineNumber: LineNumber, side?: Side) {
     const sideSelector = side ? '.' + side : '';
     return this.diffElement.querySelector(
       `.lineNum[data-value="${lineNumber}"]${sideSelector}`
@@ -359,15 +360,7 @@
     const message =
       `The value of the '${pref}' user preference is ` +
       'invalid. Fix in diff preferences';
-    this.dispatchEvent(
-      new CustomEvent('show-alert', {
-        detail: {
-          message,
-        },
-        bubbles: true,
-        composed: true,
-      })
-    );
+    fireAlert(this, message);
     throw Error(`Invalid preference value: ${pref}`);
   }
 
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-side-by-side.ts b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-side-by-side.ts
index efac2b1..ecedb28 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-side-by-side.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-side-by-side.ts
@@ -105,6 +105,7 @@
     row.classList.add('diff-row', 'side-by-side');
     row.setAttribute('left-type', leftLine.type);
     row.setAttribute('right-type', rightLine.type);
+    // TabIndex makes screen reader read a row when navigating with j/k
     row.tabIndex = -1;
 
     row.appendChild(this._createBlameCell(leftLine.beforeNumber));
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified.ts b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified.ts
index c4ea267..2011a59 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified.ts
@@ -104,6 +104,7 @@
   _createRow(line: GrDiffLine) {
     const row = this._createElement('tr', line.type);
     row.classList.add('diff-row', 'unified');
+    // TabIndex makes screen reader read a row when navigating with j/k
     row.tabIndex = -1;
     row.appendChild(this._createBlameCell(line.beforeNumber));
     let lineNumberEl = this._createLineEl(
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified_test.js b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified_test.js
index 24d3635..c08764e 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified_test.js
@@ -116,7 +116,7 @@
       assert.equal(rowEls.length, 3);
       assert.isTrue(moveControlsRow.classList.contains('movedOut'));
       assert.equal(cells.length, 3);
-      assert.isTrue(cells[2].classList.contains('moveDescription'));
+      assert.isTrue(cells[2].classList.contains('moveLabel'));
       assert.equal(cells[2].textContent, 'Moved out');
     });
 
@@ -139,7 +139,7 @@
       assert.equal(rowEls.length, 3);
       assert.isTrue(moveControlsRow.classList.contains('movedIn'));
       assert.equal(cells.length, 3);
-      assert.isTrue(cells[2].classList.contains('moveDescription'));
+      assert.isTrue(cells[2].classList.contains('moveLabel'));
       assert.equal(cells[2].textContent, 'Moved in');
     });
   });
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.ts b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.ts
index f4aa660..f628c47 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.ts
@@ -563,6 +563,7 @@
     if (type === GrDiffBuilder.ContextButtonType.ALL) {
       if (this.useNewContextControls) {
         text = `+${numLines} common line`;
+        button.setAttribute('aria-label', `Show ${numLines} common lines`);
       } else {
         text = `Show ${numLines} common line`;
         const icon = this._createElement('iron-icon', 'showContext');
@@ -583,6 +584,7 @@
       if (this.useNewContextControls) {
         text = `+${context}`;
         button.classList.add('aboveButton');
+        button.setAttribute('aria-label', `Show ${context} lines above`);
       } else {
         text = `+${context} above`;
       }
@@ -591,6 +593,7 @@
       if (this.useNewContextControls) {
         text = `+${context}`;
         button.classList.add('belowButton');
+        button.setAttribute('aria-label', `Show ${context} lines below`);
       } else {
         text = `+${context} below`;
       }
@@ -659,6 +662,9 @@
       button.classList.add(side);
       button.dataset['value'] = number.toString();
       button.textContent = number === 'FILE' ? 'File' : number.toString();
+      if (number === 'FILE') {
+        button.setAttribute('aria-label', 'Add file comment');
+      }
 
       // Add aria-labels for valid line numbers.
       // For unified diff, this method will be called with number set to 0 for
@@ -921,13 +927,20 @@
       controlsClass = 'movedOut';
       descriptionIndex = movedOutIndex;
     }
-    const controls = document.createElement('tr');
+
+    const controls = this._createElement('tr', `moveControls ${controlsClass}`);
     const cells = [...Array(numberOfCells).keys()].map(() =>
-      document.createElement('td')
+      this._createElement('td')
     );
-    controls.classList.add('moveControls', controlsClass);
-    cells[descriptionIndex].classList.add('moveDescription');
-    cells[descriptionIndex].textContent = descriptionText;
+    const moveDescriptionDiv = this._createElement('div', 'moveDescription');
+    const icon = this._createElement('iron-icon');
+    icon.setAttribute('icon', 'gr-icons:move-item');
+    const span = this._createElement('span', '');
+    span.textContent = descriptionText;
+    moveDescriptionDiv.appendChild(icon);
+    moveDescriptionDiv.appendChild(span);
+    cells[descriptionIndex].appendChild(moveDescriptionDiv);
+    cells[descriptionIndex].classList.add('moveLabel');
     cells.forEach(c => {
       controls.appendChild(c);
     });
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.ts b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.ts
index fc7c870..43ed77f 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.ts
@@ -36,6 +36,7 @@
 import {PolymerDomWrapper} from '../../../types/types';
 import {GrDiffGroupType} from '../gr-diff/gr-diff-group';
 import {GrDiff} from '../gr-diff/gr-diff';
+import {fireAlert} from '../../../utils/event-util';
 
 const DiffViewMode = {
   SIDE_BY_SIDE: 'SIDE_BY_SIDE',
@@ -138,6 +139,16 @@
     window.removeEventListener('scroll', this._boundHandleWindowScroll);
   }
 
+  // Don't remove - used by clients embedding gr-diff outside of Gerrit.
+  isAtStart() {
+    return this.$.cursorManager.isAtStart();
+  }
+
+  // Don't remove - used by clients embedding gr-diff outside of Gerrit.
+  isAtEnd() {
+    return this.$.cursorManager.isAtEnd();
+  }
+
   moveLeft() {
     this.side = Side.LEFT;
     if (this._isTargetBlank()) {
@@ -154,21 +165,21 @@
 
   moveDown() {
     if (this._getViewMode() === DiffViewMode.SIDE_BY_SIDE) {
-      this.$.cursorManager.next({
+      return this.$.cursorManager.next({
         filter: (row: Element) => this._rowHasSide(row),
       });
     } else {
-      this.$.cursorManager.next();
+      return this.$.cursorManager.next();
     }
   }
 
   moveUp() {
     if (this._getViewMode() === DiffViewMode.SIDE_BY_SIDE) {
-      this.$.cursorManager.previous({
+      return this.$.cursorManager.previous({
         filter: (row: Element) => this._rowHasSide(row),
       });
     } else {
-      this.$.cursorManager.previous();
+      return this.$.cursorManager.previous();
     }
   }
 
@@ -182,7 +193,10 @@
     }
   }
 
-  moveToNextChunk(clipToTop?: boolean, navigateToNextFile?: boolean) {
+  moveToNextChunk(
+    clipToTop?: boolean,
+    navigateToNextFile?: boolean
+  ): CursorMoveResult {
     const result = this.$.cursorManager.next({
       filter: (row: HTMLElement) => this._isFirstRowOfChunk(row),
       getTargetHeight: target =>
@@ -197,7 +211,7 @@
     if (
       navigateToNextFile &&
       result === CursorMoveResult.CLIPPED &&
-      this.$.cursorManager.isAtEnd()
+      this.isAtEnd()
     ) {
       if (
         this.lastDisplayedNavigateToNextFileToast &&
@@ -212,41 +226,38 @@
             bubbles: true,
           })
         );
+      } else {
+        this.lastDisplayedNavigateToNextFileToast = Date.now();
+        fireAlert(this, 'Press n again to navigate to next unreviewed file');
       }
-      this.lastDisplayedNavigateToNextFileToast = Date.now();
-      this.dispatchEvent(
-        new CustomEvent('show-alert', {
-          detail: {
-            message: 'Press n again to navigate to next unreviewed file',
-          },
-          composed: true,
-          bubbles: true,
-        })
-      );
     }
 
     this._fixSide();
+    return result;
   }
 
-  moveToPreviousChunk() {
-    this.$.cursorManager.previous({
+  moveToPreviousChunk(): CursorMoveResult {
+    const result = this.$.cursorManager.previous({
       filter: (row: HTMLElement) => this._isFirstRowOfChunk(row),
     });
     this._fixSide();
+    return result;
   }
 
-  moveToNextCommentThread() {
-    this.$.cursorManager.next({
+  moveToNextCommentThread(): CursorMoveResult {
+    const result = this.$.cursorManager.next({
       filter: (row: HTMLElement) => this._rowHasThread(row),
     });
     this._fixSide();
+    return result;
   }
 
-  moveToPreviousCommentThread() {
-    this.$.cursorManager.previous({
+  moveToPreviousCommentThread(): CursorMoveResult {
+    const result = this.$.cursorManager.previous({
       filter: (row: HTMLElement) => this._rowHasThread(row),
     });
     this._fixSide();
+    return result;
   }
 
   moveToLineNumber(number: number, side: Side, path?: string) {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor_test.js b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor_test.js
index 718e11b..987a7d0 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor_test.js
@@ -226,8 +226,7 @@
     assert.equal(cursorElement.side, 'left');
   });
 
-  // To be removed as soon due_to_move (deprecated) is removed
-  suite('moved chunks (dueToMove=true)', () => {
+  suite('moved chunks without line range)', () => {
     setup(done => {
       const renderHandler = function() {
         diffElement.removeEventListener('render', renderHandler);
@@ -247,7 +246,7 @@
             'Sagittis tincidunt torquent, tempor nunc amet.',
             'At rhoncus id.',
           ],
-          due_to_move: true,
+          move_details: {changed: false},
         },
         {
           ab: [
@@ -260,7 +259,7 @@
             'Sagittis tincidunt torquent, tempor nunc amet.',
             'At rhoncus id.',
           ],
-          due_to_move: true,
+          move_details: {changed: false},
         },
         {
           ab: [
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 08a567b..c788d76 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
@@ -30,6 +30,7 @@
 import {GrSelectionActionBox} from '../gr-selection-action-box/gr-selection-action-box';
 import {GrDiffBuilderElement} from '../gr-diff-builder/gr-diff-builder-element';
 import {FILE} from '../gr-diff/gr-diff-line';
+import {getRange, getSide} from '../gr-diff/gr-diff-utils';
 
 interface SidedRange {
   side: Side;
@@ -199,13 +200,9 @@
   }
 
   _indexForThreadEl(threadEl: HTMLElement) {
-    const side = threadEl.getAttribute('comment-side') as Side;
-    const rangeString = threadEl.getAttribute('range');
-    if (!rangeString) return undefined;
-    const range = JSON.parse(rangeString) as CommentRange;
-
-    if (!range) return undefined;
-
+    const side = getSide(threadEl);
+    const range = getRange(threadEl);
+    if (!side || !range) return undefined;
     return this._indexOfCommentRange(side, range);
   }
 
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 875b8c8..2223734 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
@@ -24,20 +24,19 @@
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-diff-host_html';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
-import {rangesEqual} from '../gr-diff/gr-diff-utils';
+import {
+  getLine,
+  getRange,
+  getSide,
+  rangesEqual,
+} from '../gr-diff/gr-diff-utils';
 import {appContext} from '../../../services/app-context';
 import {
   getParentIndex,
   isMergeParent,
   isNumber,
 } from '../../../utils/patch-set-util';
-import {
-  Comment,
-  isDraft,
-  sortComments,
-  UIComment,
-} from '../../../utils/comment-util';
-import {TwoSidesComments} from '../gr-comment-api/gr-comment-api';
+import {CommentThread} from '../../../utils/comment-util';
 import {customElement, observe, property} from '@polymer/decorators';
 import {
   CommitRange,
@@ -65,12 +64,13 @@
 import {JsApiService} from '../../shared/gr-js-api-interface/gr-js-api-types';
 import {GrDiff, LineOfInterest} from '../gr-diff/gr-diff';
 import {GrSyntaxLayer} from '../gr-syntax-layer/gr-syntax-layer';
-import {DiffViewMode, Side} from '../../../constants/constants';
+import {DiffViewMode, Side, CommentSide} from '../../../constants/constants';
 import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
 import {FilesWebLinks} from '../gr-patch-range-select/gr-patch-range-select';
-import {LineNumber} from '../gr-diff/gr-diff-line';
+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} from '../../../utils/event-util';
 
 const MSG_EMPTY_BLAME = 'No blame information for this diff.';
 
@@ -108,20 +108,6 @@
   afterNumber?: LineNumber;
 }
 
-// TODO(TS): Consolidate this with the CommentThread interface of comment-api.
-// What is being used here is just a local object for collecting all the data
-// that is needed to create a GrCommentThread component, see
-// _createThreadElement().
-interface CommentThread {
-  comments: UIComment[];
-  // In the context of a diff each thread must have a side!
-  commentSide: Side;
-  patchNum?: PatchSetNum;
-  lineNum?: LineNumber;
-  isOnParent?: boolean;
-  range?: CommentRange;
-}
-
 export interface GrDiffHost {
   $: {
     restAPI: RestApiService & Element;
@@ -210,8 +196,8 @@
   @property({type: Boolean})
   noRenderOnPrefsChange = false;
 
-  @property({type: Object, observer: '_commentsChanged'})
-  comments?: TwoSidesComments;
+  @property({type: Object, observer: '_threadsChanged'})
+  threads?: CommentThread[];
 
   @property({type: Boolean})
   lineWrapping = false;
@@ -288,11 +274,12 @@
       'create-comment',
       e => this._handleCreateComment(e)
     );
-    this.addEventListener('comment-discard', e =>
-      this._handleCommentDiscard(e)
+    this.addEventListener('comment-discard', () =>
+      this._handleCommentSaveOrDiscard()
     );
-    this.addEventListener('comment-update', e => this._handleCommentUpdate(e));
-    this.addEventListener('comment-save', e => this._handleCommentSave(e));
+    this.addEventListener('comment-save', () =>
+      this._handleCommentSaveOrDiscard()
+    );
     this.addEventListener('render-start', () => this._handleRenderStart());
     this.addEventListener('render-content', () => this._handleRenderContent());
     this.addEventListener('normalize-range', event =>
@@ -525,13 +512,7 @@
       .getBlame(this.changeNum, this.patchRange.patchNum, this.path, true)
       .then(blame => {
         if (!blame || !blame.length) {
-          this.dispatchEvent(
-            new CustomEvent('show-alert', {
-              detail: {message: MSG_EMPTY_BLAME},
-              composed: true,
-              bubbles: true,
-            })
-          );
+          fireAlert(this, MSG_EMPTY_BLAME);
           return Promise.reject(MSG_EMPTY_BLAME);
         }
 
@@ -630,13 +611,7 @@
       return;
     }
 
-    this.dispatchEvent(
-      new CustomEvent('page-error', {
-        detail: {response},
-        composed: true,
-        bubbles: true,
-      })
-    );
+    firePageError(this, response);
   }
 
   /**
@@ -703,60 +678,18 @@
     return isImageDiff(diff);
   }
 
-  _commentsChanged(newComments: TwoSidesComments) {
-    const allComments = [];
-    for (const side of [Side.LEFT, Side.RIGHT]) {
-      // This is needed by the threading.
-      for (const comment of newComments[side]) {
-        comment.__commentSide = side;
-      }
-      allComments.push(...newComments[side]);
-    }
+  _threadsChanged(threads: CommentThread[]) {
     // Currently, the only way this is ever changed here is when the initial
-    // comments are loaded, so it's okay performance wise to clear the threads
+    // threads are loaded, so it's okay performance wise to clear the threads
     // and recreate them. If this changes in future, we might want to reuse
     // some DOM nodes here.
     this._clearThreads();
-    const threads = this._createThreads(allComments);
     for (const thread of threads) {
       const threadEl = this._createThreadElement(thread);
       this._attachThreadElement(threadEl);
     }
   }
 
-  _createThreads(comments: UIComment[]): CommentThread[] {
-    const sortedComments = sortComments(comments);
-    const threads = [];
-    for (const comment of sortedComments) {
-      // If the comment is in reply to another comment, find that comment's
-      // thread and append to it.
-      if (comment.in_reply_to) {
-        const thread = threads.find(thread =>
-          thread.comments.some(c => c.id === comment.in_reply_to)
-        );
-        if (thread) {
-          thread.comments.push(comment);
-          continue;
-        }
-      }
-
-      // Otherwise, this comment starts its own thread.
-      if (!comment.__commentSide) throw new Error('Missing "__commentSide".');
-      const newThread: CommentThread = {
-        comments: [comment],
-        commentSide: comment.__commentSide,
-        patchNum: comment.patch_set,
-        lineNum: comment.line,
-        isOnParent: comment.side === 'PARENT',
-      };
-      if (comment.range) {
-        newThread.range = {...comment.range};
-      }
-      threads.push(newThread);
-    }
-    return threads;
-  }
-
   _computeIsBlameLoaded(blame: BlameInfo[] | null) {
     return !!blame;
   }
@@ -772,13 +705,14 @@
   }
 
   _handleCreateComment(e: CustomEvent) {
-    const {lineNum, side, patchNum, isOnParent, range} = e.detail;
+    const {lineNum, side, patchNum, range, path, commentSide} = e.detail;
     const threadEl = this._getOrCreateThread(
       patchNum,
       lineNum,
       side,
-      range,
-      isOnParent
+      commentSide,
+      path,
+      range
     );
     threadEl.addOrEditDraft(lineNum, range);
 
@@ -792,19 +726,21 @@
   _getOrCreateThread(
     patchNum: PatchSetNum,
     lineNum: LineNumber | undefined,
-    commentSide: Side,
-    range?: CommentRange,
-    isOnParent?: boolean
+    diffSide: Side,
+    commentSide: CommentSide,
+    path: string,
+    range?: CommentRange
   ): GrCommentThread {
-    let threadEl = this._getThreadEl(lineNum, commentSide, range);
+    let threadEl = this._getThreadEl(lineNum, diffSide, range);
     if (!threadEl) {
       threadEl = this._createThreadElement({
         comments: [],
+        path,
+        diffSide,
         commentSide,
         patchNum,
-        lineNum,
+        line: lineNum,
         range,
-        isOnParent,
       });
       this._attachThreadElement(threadEl);
     }
@@ -825,18 +761,18 @@
   _createThreadElement(thread: CommentThread) {
     const threadEl = document.createElement('gr-comment-thread');
     threadEl.className = 'comment-thread';
-    threadEl.setAttribute('slot', `${thread.commentSide}-${thread.lineNum}`);
+    threadEl.setAttribute('slot', `${thread.diffSide}-${thread.line}`);
     threadEl.comments = thread.comments;
-    threadEl.commentSide = thread.commentSide;
-    threadEl.isOnParent = !!thread.isOnParent;
+    threadEl.commentSide = thread.diffSide;
+    threadEl.isOnParent = thread.commentSide === CommentSide.PARENT;
     threadEl.parentIndex = this._parentIndex;
     // Use path before renmaing when comment added on the left when comparing
     // two patch sets (not against base)
     if (
       this.file &&
       this.file.basePath &&
-      thread.commentSide === Side.LEFT &&
-      !thread.isOnParent
+      thread.diffSide === Side.LEFT &&
+      !threadEl.isOnParent
     ) {
       threadEl.path = this.file.basePath;
     } else {
@@ -846,7 +782,7 @@
     threadEl.patchNum = thread.patchNum;
     threadEl.showPatchset = false;
     // GrCommentThread does not understand 'FILE', but requires undefined.
-    threadEl.lineNum = thread.lineNum !== 'FILE' ? thread.lineNum : undefined;
+    threadEl.lineNum = thread.line !== 'FILE' ? thread.line : undefined;
     threadEl.projectName = this.projectName;
     threadEl.range = thread.range;
     const threadDiscardListener = (e: Event) => {
@@ -877,11 +813,7 @@
       throw new Error(`Unknown side: ${commentSide}`);
     }
     function matchesRange(threadEl: GrCommentThread) {
-      const rangeAtt = threadEl.getAttribute('range');
-      const threadRange = rangeAtt
-        ? (JSON.parse(rangeAtt) as CommentRange)
-        : undefined;
-      return rangesEqual(threadRange, range);
+      return rangesEqual(getRange(threadEl), range);
     }
 
     const filteredThreadEls = this._filterThreadElsForLocation(
@@ -899,23 +831,18 @@
   ) {
     function matchesLeftLine(threadEl: GrCommentThread) {
       return (
-        threadEl.getAttribute('comment-side') === Side.LEFT &&
-        threadEl.getAttribute('line-num') === String(lineInfo.beforeNumber)
+        getSide(threadEl) === Side.LEFT &&
+        getLine(threadEl) === lineInfo.beforeNumber
       );
     }
     function matchesRightLine(threadEl: GrCommentThread) {
       return (
-        threadEl.getAttribute('comment-side') === Side.RIGHT &&
-        threadEl.getAttribute('line-num') === String(lineInfo.afterNumber)
+        getSide(threadEl) === Side.RIGHT &&
+        getLine(threadEl) === lineInfo.afterNumber
       );
     }
     function matchesFileComment(threadEl: GrCommentThread) {
-      return (
-        threadEl.getAttribute('comment-side') === side &&
-        // line/range comments have 1-based line set, if line is falsy it's
-        // a file comment
-        !threadEl.getAttribute('line-num')
-      );
+      return getSide(threadEl) === side && getLine(threadEl) === FILE;
     }
 
     // Select the appropriate matchers for the desired side and line
@@ -926,7 +853,7 @@
     if (side === Side.RIGHT) {
       matchers.push(matchesRightLine);
     }
-    if (lineInfo.afterNumber === 'FILE' || lineInfo.beforeNumber === 'FILE') {
+    if (lineInfo.afterNumber === FILE || lineInfo.beforeNumber === FILE) {
       matchers.push(matchesFileComment);
     }
     return threadEls.filter(threadEl =>
@@ -988,79 +915,12 @@
       : null;
   }
 
-  _handleCommentSave(e: CustomEvent) {
-    const comment = e.detail.comment;
-    const side = e.detail.comment.__commentSide;
-    const idx = this._findDraftIndex(comment, side);
-    this.set(['comments', side, idx], comment);
-    this._handleCommentSaveOrDiscard();
-  }
-
-  _handleCommentDiscard(e: CustomEvent) {
-    const comment = e.detail.comment;
-    this._removeComment(comment);
-    this._handleCommentSaveOrDiscard();
-  }
-
-  _handleCommentUpdate(e: CustomEvent) {
-    const comment = e.detail.comment;
-    const side = e.detail.comment.__commentSide;
-    let idx = this._findCommentIndex(comment, side);
-    if (idx === -1) {
-      idx = this._findDraftIndex(comment, side);
-    }
-    if (idx !== -1) {
-      // Update draft or comment.
-      this.set(['comments', side, idx], comment);
-    } else {
-      // Create new draft.
-      this.push(['comments', side], comment);
-    }
-  }
-
   _handleCommentSaveOrDiscard() {
     this.dispatchEvent(
       new CustomEvent('diff-comments-modified', {bubbles: true, composed: true})
     );
   }
 
-  _removeComment(comment: UIComment) {
-    const side = comment.__commentSide;
-    if (!side) throw new Error('Missing required "side" in comment.');
-    this._removeCommentFromSide(comment, side);
-  }
-
-  _removeCommentFromSide(comment: Comment, side: Side) {
-    let idx = this._findCommentIndex(comment, side);
-    if (idx === -1) {
-      idx = this._findDraftIndex(comment, side);
-    }
-    if (idx !== -1) {
-      this.splice('comments.' + side, idx, 1);
-    }
-  }
-
-  _findCommentIndex(comment: Comment, side: Side) {
-    if (!comment.id || !this.comments || !this.comments[side]) {
-      return -1;
-    }
-    return this.comments[side].findIndex(item => item.id === comment.id);
-  }
-
-  _findDraftIndex(comment: Comment, side: Side) {
-    if (
-      !isDraft(comment) ||
-      !comment.__draftID ||
-      !this.comments ||
-      !this.comments[side]
-    ) {
-      return -1;
-    }
-    return this.comments[side].findIndex(
-      item => isDraft(item) && item.__draftID === comment.__draftID
-    );
-  }
-
   _isSyntaxHighlightingEnabled(
     preferenceChangeRecord?: PolymerDeepPropertyChange<
       DiffPreferencesInfo,
@@ -1068,18 +928,26 @@
     >,
     diff?: DiffInfo
   ) {
-    if (
-      !preferenceChangeRecord ||
-      !preferenceChangeRecord.base ||
-      !preferenceChangeRecord.base.syntax_highlighting ||
-      !diff
-    ) {
+    if (!preferenceChangeRecord?.base?.syntax_highlighting || !diff) {
       return false;
     }
-    return (
-      !this._anyLineTooLong(diff) &&
-      this.$.diff.getDiffLength(diff) <= SYNTAX_MAX_DIFF_LENGTH
-    );
+    if (this._anyLineTooLong(diff)) {
+      fireAlert(
+        this,
+        `A line is longer than ${SYNTAX_MAX_LINE_LENGTH}.` +
+          ' Syntax Highlighting was turned off.'
+      );
+      return false;
+    }
+    if (this.$.diff.getDiffLength(diff) > SYNTAX_MAX_DIFF_LENGTH) {
+      fireAlert(
+        this,
+        `A diff is longer than ${SYNTAX_MAX_DIFF_LENGTH}.` +
+          ' Syntax Highlighting was turned off.'
+      );
+      return false;
+    }
+    return true;
   }
 
   /**
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.js b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.js
index 1dd2737..402cb52 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.js
@@ -20,9 +20,10 @@
 import {GrDiffBuilderImage} from '../gr-diff-builder/gr-diff-builder-image.js';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
 import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-import {sortComments} from '../../../utils/comment-util.js';
-import {Side} from '../../../constants/constants.js';
+import {sortComments, createCommentThreads} from '../../../utils/comment-util.js';
+import {Side, CommentSide} from '../../../constants/constants.js';
 import {createChange} from '../../../test/test-data-generators.js';
+import {FILE} from '../gr-diff/gr-diff-line.js';
 
 const basicFixture = fixtureFromElement('gr-diff-host');
 
@@ -61,209 +62,21 @@
     });
   });
 
-  suite('handle comment-update', () => {
-    setup(() => {
-      sinon.stub(element, '_commentsChanged');
-      element.comments = {
-        meta: {
-          changeNum: '42',
-          patchRange: {
-            basePatchNum: 'PARENT',
-            patchNum: 3,
-          },
-          path: '/path/to/foo',
-          projectConfig: {foo: 'bar'},
-        },
-        left: [
-          {id: 'bc1', side: 'PARENT', __commentSide: 'left'},
-          {id: 'bc2', side: 'PARENT', __commentSide: 'left'},
-          {id: 'bd1', __draft: true, side: 'PARENT', __commentSide: 'left'},
-          {id: 'bd2', __draft: true, side: 'PARENT', __commentSide: 'left'},
-        ],
-        right: [
-          {id: 'c1', __commentSide: 'right'},
-          {id: 'c2', __commentSide: 'right'},
-          {id: 'd1', __draft: true, __commentSide: 'right'},
-          {id: 'd2', __draft: true, __commentSide: 'right'},
-        ],
-      };
-    });
-
-    test('creating a draft', () => {
-      const comment = {__draft: true, __draftID: 'tempID', side: 'PARENT',
-        __commentSide: 'left'};
-      element.dispatchEvent(
-          new CustomEvent('comment-update', {
-            detail: {comment},
-            composed: true, bubbles: true,
-          }));
-      assert.include(element.comments.left, comment);
-    });
-
-    test('discarding a draft', () => {
-      const draftID = 'tempID';
-      const id = 'savedID';
-      const comment = {
-        __draft: true,
-        __draftID: draftID,
-        side: 'PARENT',
-        __commentSide: 'left',
-      };
-      const diffCommentsModifiedStub = sinon.stub();
-      element.addEventListener('diff-comments-modified',
-          diffCommentsModifiedStub);
-      element.comments.left.push(comment);
-      comment.id = id;
-      element.dispatchEvent(
-          new CustomEvent('comment-discard', {
-            detail: {comment},
-            composed: true, bubbles: true,
-          }));
-      const drafts = element.comments.left
-          .filter(item => item.__draftID === draftID);
-      assert.equal(drafts.length, 0);
-      assert.isTrue(diffCommentsModifiedStub.called);
-    });
-
-    test('saving a draft', () => {
-      const draftID = 'tempID';
-      const id = 'savedID';
-      const comment = {
-        __draft: true,
-        __draftID: draftID,
-        side: 'PARENT',
-        __commentSide: 'left',
-      };
-      const diffCommentsModifiedStub = sinon.stub();
-      element.addEventListener('diff-comments-modified',
-          diffCommentsModifiedStub);
-      element.comments.left.push(comment);
-      comment.id = id;
-      element.dispatchEvent(
-          new CustomEvent('comment-save', {
-            detail: {comment},
-            composed: true, bubbles: true,
-          }));
-      const drafts = element.comments.left
-          .filter(item => item.__draftID === draftID);
-      assert.equal(drafts.length, 1);
-      assert.equal(drafts[0].id, id);
-      assert.isTrue(diffCommentsModifiedStub.called);
-    });
-  });
-
-  test('remove comment', () => {
-    sinon.stub(element, '_commentsChanged');
-    element.comments = {
-      meta: {
-        changeNum: '42',
-        patchRange: {
-          basePatchNum: 'PARENT',
-          patchNum: 3,
-        },
-        path: '/path/to/foo',
-        projectConfig: {foo: 'bar'},
-      },
-      left: [
-        {id: 'bc1', side: 'PARENT', __commentSide: 'left'},
-        {id: 'bc2', side: 'PARENT', __commentSide: 'left'},
-        {id: 'bd1', __draft: true, side: 'PARENT', __commentSide: 'left'},
-        {id: 'bd2', __draft: true, side: 'PARENT', __commentSide: 'left'},
-      ],
-      right: [
-        {id: 'c1', __commentSide: 'right'},
-        {id: 'c2', __commentSide: 'right'},
-        {id: 'd1', __draft: true, __commentSide: 'right'},
-        {id: 'd2', __draft: true, __commentSide: 'right'},
-      ],
-    };
-
-    // Using JSON.stringify because Safari 9.1 (11601.5.17.1) doesn’t seem
-    // to believe that one object deepEquals another even when they do :-/.
-    assert.equal(JSON.stringify(element.comments), JSON.stringify({
-      meta: {
-        changeNum: '42',
-        patchRange: {
-          basePatchNum: 'PARENT',
-          patchNum: 3,
-        },
-        path: '/path/to/foo',
-        projectConfig: {foo: 'bar'},
-      },
-      left: [
-        {id: 'bc1', side: 'PARENT', __commentSide: 'left'},
-        {id: 'bc2', side: 'PARENT', __commentSide: 'left'},
-        {id: 'bd1', __draft: true, side: 'PARENT', __commentSide: 'left'},
-        {id: 'bd2', __draft: true, side: 'PARENT', __commentSide: 'left'},
-      ],
-      right: [
-        {id: 'c1', __commentSide: 'right'},
-        {id: 'c2', __commentSide: 'right'},
-        {id: 'd1', __draft: true, __commentSide: 'right'},
-        {id: 'd2', __draft: true, __commentSide: 'right'},
-      ],
-    }));
-
-    element._removeComment({id: 'bc2', side: 'PARENT',
-      __commentSide: 'left'});
-    assert.deepEqual(element.comments, {
-      meta: {
-        changeNum: '42',
-        patchRange: {
-          basePatchNum: 'PARENT',
-          patchNum: 3,
-        },
-        path: '/path/to/foo',
-        projectConfig: {foo: 'bar'},
-      },
-      left: [
-        {id: 'bc1', side: 'PARENT', __commentSide: 'left'},
-        {id: 'bd1', __draft: true, side: 'PARENT', __commentSide: 'left'},
-        {id: 'bd2', __draft: true, side: 'PARENT', __commentSide: 'left'},
-      ],
-      right: [
-        {id: 'c1', __commentSide: 'right'},
-        {id: 'c2', __commentSide: 'right'},
-        {id: 'd1', __draft: true, __commentSide: 'right'},
-        {id: 'd2', __draft: true, __commentSide: 'right'},
-      ],
-    });
-
-    element._removeComment({id: 'd2', __commentSide: 'right'});
-    assert.deepEqual(element.comments, {
-      meta: {
-        changeNum: '42',
-        patchRange: {
-          basePatchNum: 'PARENT',
-          patchNum: 3,
-        },
-        path: '/path/to/foo',
-        projectConfig: {foo: 'bar'},
-      },
-      left: [
-        {id: 'bc1', side: 'PARENT', __commentSide: 'left'},
-        {id: 'bd1', __draft: true, side: 'PARENT', __commentSide: 'left'},
-        {id: 'bd2', __draft: true, side: 'PARENT', __commentSide: 'left'},
-      ],
-      right: [
-        {id: 'c1', __commentSide: 'right'},
-        {id: 'c2', __commentSide: 'right'},
-        {id: 'd1', __draft: true, __commentSide: 'right'},
-      ],
-    });
-  });
-
   test('thread-discard handling', () => {
-    const threads = element._createThreads([
+    const threads = createCommentThreads([
       {
         id: 4711,
         __commentSide: 'left',
         updated: '2015-12-20 15:01:20.396000000',
+        patch_set: 1,
+        path: 'some/path',
       },
       {
         id: 42,
         __commentSide: 'left',
         updated: '2017-12-20 15:01:20.396000000',
+        patch_set: 1,
+        path: 'some/path',
       },
     ]);
     element._parentIndex = 1;
@@ -1161,6 +974,8 @@
         updated: '2015-12-23 15:00:20.396000000',
         line: 1,
         __commentSide: 'left',
+        patch_set: 1,
+        path: 'some/path',
       }, {
         id: 'jacks_reply',
         message: 'i like you, too',
@@ -1168,6 +983,8 @@
         __commentSide: 'left',
         line: 1,
         in_reply_to: 'sallys_confession',
+        patch_set: 1,
+        path: 'some/path',
       },
       {
         id: 'new_draft',
@@ -1175,25 +992,27 @@
         __commentSide: 'left',
         __draft: true,
         updated: '2015-12-20 15:01:20.396000000',
+        patch_set: 1,
+        path: 'some/path',
       },
     ];
 
-    const actualThreads = element._createThreads(comments);
+    const actualThreads = createCommentThreads(comments);
 
     assert.equal(actualThreads.length, 2);
 
-    assert.equal(actualThreads[0].commentSide, 'left');
+    assert.equal(actualThreads[0].diffSide, 'left');
     assert.equal(actualThreads[0].comments.length, 2);
     assert.deepEqual(actualThreads[0].comments[0], comments[0]);
     assert.deepEqual(actualThreads[0].comments[1], comments[1]);
-    assert.equal(actualThreads[0].patchNum, undefined);
-    assert.equal(actualThreads[0].lineNum, 1);
+    assert.equal(actualThreads[0].patchNum, 1);
+    assert.equal(actualThreads[0].line, 1);
 
-    assert.equal(actualThreads[1].commentSide, 'left');
+    assert.equal(actualThreads[1].diffSide, 'left');
     assert.equal(actualThreads[1].comments.length, 1);
     assert.deepEqual(actualThreads[1].comments[0], comments[2]);
-    assert.equal(actualThreads[1].patchNum, undefined);
-    assert.equal(actualThreads[1].lineNum, undefined);
+    assert.equal(actualThreads[1].patchNum, 1);
+    assert.equal(actualThreads[1].line, FILE);
   });
 
   test('_createThreads inherits patchNum and range', () => {
@@ -1208,15 +1027,20 @@
         end_character: 2,
       },
       patch_set: 5,
+      path: '/p',
       __commentSide: 'left',
       line: 1,
     }];
 
     const expectedThreads = [
       {
-        commentSide: 'left',
+        diffSide: 'left',
+        commentSide: CommentSide.REVISION,
+        path: '/p',
+        rootId: 'betsys_confession',
         comments: [{
           id: 'betsys_confession',
+          path: '/p',
           message: 'i like you, jack',
           updated: '2015-12-24 15:00:10.396000000',
           range: {
@@ -1236,13 +1060,12 @@
           end_line: 1,
           end_character: 2,
         },
-        lineNum: 1,
-        isOnParent: false,
+        line: 1,
       },
     ];
 
     assert.deepEqual(
-        element._createThreads(comments),
+        createCommentThreads(comments),
         expectedThreads);
   });
 
@@ -1254,55 +1077,31 @@
             message: 'i like you, jack',
             updated: '2015-12-23 15:00:20.396000000',
             __commentSide: 'left',
+            path: '/p',
           }, {
             id: 'jacks_reply',
             message: 'i like you, too',
             updated: '2015-12-24 15:01:20.396000000',
             __commentSide: 'left',
+            path: '/p',
           },
         ];
-        assert.equal(element._createThreads(comments).length, 2);
-      });
-
-  test('_createThreads derives isOnParent using  side from first comment',
-      () => {
-        const comments = [
-          {
-            id: 'sallys_confession',
-            message: 'i like you, jack',
-            updated: '2015-12-23 15:00:20.396000000',
-            __commentSide: 'left',
-          }, {
-            id: 'jacks_reply',
-            message: 'i like you, too',
-            updated: '2015-12-24 15:01:20.396000000',
-            __commentSide: 'left',
-            in_reply_to: 'sallys_confession',
-          },
-        ];
-
-        assert.equal(element._createThreads(comments)[0].isOnParent, false);
-
-        comments[0].side = 'REVISION';
-        assert.equal(element._createThreads(comments)[0].isOnParent, false);
-
-        comments[0].side = 'PARENT';
-        assert.equal(element._createThreads(comments)[0].isOnParent, true);
+        assert.equal(createCommentThreads(comments).length, 2);
       });
 
   test('_getOrCreateThread', () => {
-    const commentSide = 'left';
+    const diffSide = 'left';
+    const commentSide = CommentSide.PARENT;
 
     assert.isOk(element._getOrCreateThread('2', 3,
-        commentSide, undefined, false));
+        diffSide, commentSide, '/p'));
 
     let threads = dom(element.$.diff)
         .queryDistributedElements('gr-comment-thread');
 
     assert.equal(threads.length, 1);
-    assert.equal(threads[0].commentSide, commentSide);
+    assert.equal(threads[0].commentSide, diffSide);
     assert.equal(threads[0].range, undefined);
-    assert.equal(threads[0].isOnParent, false);
     assert.equal(threads[0].patchNum, 2);
 
     // Try to fetch a thread with a different range.
@@ -1314,63 +1113,62 @@
     };
 
     assert.isOk(element._getOrCreateThread(
-        '3', 1, commentSide, range, true));
+        '3', 1, diffSide, commentSide, '/p', range));
 
     threads = dom(element.$.diff)
         .queryDistributedElements('gr-comment-thread');
 
     assert.equal(threads.length, 2);
-    assert.equal(threads[1].commentSide, commentSide);
+    assert.equal(threads[1].commentSide, diffSide);
     assert.equal(threads[1].range, range);
-    assert.equal(threads[1].isOnParent, true);
     assert.equal(threads[1].patchNum, 3);
   });
 
-  test('thread should use old file path if first created' +
+  test('thread should use old file path if first created ' +
    'on patch set (left) before renaming', () => {
-    const commentSide = 'left';
+    const diffSide = 'left';
     element.file = {basePath: 'file_renamed.txt', path: element.path};
 
     assert.isOk(element._getOrCreateThread('2', 3,
-        commentSide, undefined, /* isOnParent= */ false));
+        diffSide, CommentSide.REVISION, '/p'));
 
     const threads = dom(element.$.diff)
         .queryDistributedElements('gr-comment-thread');
 
     assert.equal(threads.length, 1);
-    assert.equal(threads[0].commentSide, commentSide);
+    assert.equal(threads[0].commentSide, diffSide);
     assert.equal(threads[0].path, element.file.basePath);
   });
 
   test('thread should use new file path if first created' +
    'on patch set (right) after renaming', () => {
-    const commentSide = 'right';
+    const diffSide = 'right';
     element.file = {basePath: 'file_renamed.txt', path: element.path};
 
     assert.isOk(element._getOrCreateThread('2', 3,
-        commentSide, undefined, /* isOnParent= */ false));
+        diffSide, CommentSide.REVISION, '/p'));
 
     const threads = dom(element.$.diff)
         .queryDistributedElements('gr-comment-thread');
 
     assert.equal(threads.length, 1);
-    assert.equal(threads[0].commentSide, commentSide);
+    assert.equal(threads[0].commentSide, diffSide);
     assert.equal(threads[0].path, element.file.path);
   });
 
   test('thread should use new file path if first created' +
    'on patch set (left) but is base', () => {
-    const commentSide = 'left';
+    const diffSide = 'left';
     element.file = {basePath: 'file_renamed.txt', path: element.path};
 
     assert.isOk(element._getOrCreateThread('2', 3,
-        commentSide, undefined, /* isOnParent= */ true));
+        diffSide, CommentSide.PARENT, '/p', undefined));
 
     const threads = dom(element.$.diff)
         .queryDistributedElements('gr-comment-thread');
 
     assert.equal(threads.length, 1);
-    assert.equal(threads[0].commentSide, commentSide);
+    assert.equal(threads[0].commentSide, diffSide);
     assert.equal(threads[0].path, element.file.path);
   });
 
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 77b5499..c3d758c 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
@@ -366,8 +366,7 @@
     const group = new GrDiffGroup(type, lines);
     group.keyLocation = !!chunk.keyLocation;
     group.dueToRebase = !!chunk.due_to_rebase;
-    group.moveDetails =
-      chunk.move_details || (chunk.due_to_move ? {changed: false} : undefined);
+    group.moveDetails = chunk.move_details;
     group.skip = chunk.skip;
     group.ignoredWhitespaceOnly = !!chunk.common;
     if (chunk.skip) {
@@ -698,9 +697,6 @@
       if (chunk.due_to_rebase) {
         subChunk.due_to_rebase = true;
       }
-      if (chunk.due_to_move) {
-        subChunk.due_to_move = true;
-      }
       if (chunk.move_details) {
         subChunk.move_details = chunk.move_details;
       }
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor_test.js b/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor_test.js
index ce7a3c4..5496b62 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor_test.js
@@ -1019,16 +1019,6 @@
             }
           });
 
-      test('_breakdownChunk keeps due_to_move for broken down additions',
-          () => {
-            sinon.spy(element, '_breakdown');
-            const chunk = {b: ['blah', 'blah', 'blah'], due_to_move: true};
-            const result = element._breakdownChunk(chunk);
-            for (const subResult of result) {
-              assert.isTrue(subResult.due_to_move);
-            }
-          });
-
       test('_breakdown common case', () => {
         const array = 'Lorem ipsum dolor sit amet, suspendisse inceptos'
             .split(' ');
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection.ts b/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection.ts
index 03740ba..08c8226 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection.ts
@@ -30,6 +30,7 @@
 import {DiffInfo} from '../../../types/diff';
 import {Side} from '../../../constants/constants';
 import {GrDiffBuilderElement} from '../gr-diff-builder/gr-diff-builder-element';
+import {getSide, isThreadEl} from '../gr-diff/gr-diff-utils';
 
 /**
  * Possible CSS classes indicating the state of selection. Dynamically added/
@@ -96,10 +97,10 @@
   }
 
   _handleDownOnRangeComment(node: Element) {
-    if (node?.nodeName?.toLowerCase() === 'gr-comment-thread') {
+    if (isThreadEl(node)) {
       this._setClasses([
         SelectionClass.COMMENT,
-        node.getAttribute('comment-side') === Side.LEFT
+        getSide(node) === Side.LEFT
           ? SelectionClass.LEFT
           : SelectionClass.RIGHT,
       ]);
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 fce8c30..984b05a 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
@@ -48,6 +48,7 @@
   computeLatestPatchNum,
   patchNumEquals,
   PatchSet,
+  CURRENT,
 } from '../../../utils/patch-set-util';
 import {
   addUnmodifiedFiles,
@@ -65,11 +66,7 @@
   GrDropdownList,
 } from '../../shared/gr-dropdown-list/gr-dropdown-list';
 import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
-import {
-  ChangeComments,
-  GrCommentApi,
-  TwoSidesComments,
-} from '../gr-comment-api/gr-comment-api';
+import {ChangeComments, GrCommentApi} from '../gr-comment-api/gr-comment-api';
 import {GrDiffModeSelector} from '../gr-diff-mode-selector/gr-diff-mode-selector';
 import {
   ChangeInfo,
@@ -101,6 +98,7 @@
 import {AppElementParams} from '../../gr-app-types';
 import {CustomKeyboardEvent, OpenFixPreviewEvent} from '../../../types/events';
 import {PORTING_COMMENTS_DIFF_LATENCY_LABEL} from '../../../services/gr-reporting/gr-reporting';
+import {fireAlert, fireTitleChange} from '../../../utils/event-util';
 
 const ERR_REVIEW_STATUS = 'Couldn’t change file review status.';
 const MSG_LOADING_BLAME = 'Loading blame...';
@@ -239,9 +237,6 @@
   @property({type: Object})
   _commentMap?: CommentMap;
 
-  @property({type: Object})
-  _commentsForDiff?: TwoSidesComments;
-
   @property({
     type: Object,
     computed: '_computeCommentSkips(_commentMap, _fileList, _path)',
@@ -457,13 +452,7 @@
     this.$.reviewed.checked = reviewed;
     if (!this._patchRange?.patchNum) return;
     this._saveReviewedState(reviewed).catch(err => {
-      this.dispatchEvent(
-        new CustomEvent('show-alert', {
-          detail: {message: ERR_REVIEW_STATUS},
-          composed: true,
-          bubbles: true,
-        })
-      );
+      fireAlert(this, ERR_REVIEW_STATUS);
       throw err;
     });
   }
@@ -605,6 +594,7 @@
     if (this.modifierPressed(e)) return;
 
     e.preventDefault();
+    this.classList.remove('hideComments');
     this.$.cursor.createCommentInPlace();
   }
 
@@ -880,18 +870,11 @@
 
   _displayDiffBaseAgainstLeftToast() {
     if (!this._patchRange) return;
-    this.dispatchEvent(
-      new CustomEvent('show-alert', {
-        detail: {
-          // \u2190 = ←
-          message:
-            `Patchset ${this._patchRange.basePatchNum} vs ` +
-            `${this._patchRange.patchNum} selected. Press v + \u2190 to view ` +
-            `Base vs ${this._patchRange.basePatchNum}`,
-        },
-        composed: true,
-        bubbles: true,
-      })
+    fireAlert(
+      this,
+      `Patchset ${this._patchRange.basePatchNum} vs ` +
+        `${this._patchRange.patchNum} selected. Press v + \u2190 to view ` +
+        `Base vs ${this._patchRange.basePatchNum}`
     );
   }
 
@@ -903,17 +886,11 @@
     )
       ? 'Base'
       : `Patchset ${this._patchRange.basePatchNum}`;
-    this.dispatchEvent(
-      new CustomEvent('show-alert', {
-        detail: {
-          // \u2191 = ↑
-          message: `${leftPatchset} vs
+    fireAlert(
+      this,
+      `${leftPatchset} vs
             ${this._patchRange.patchNum} selected\n. Press v + \u2191 to view
-            ${leftPatchset} vs Patchset ${latestPatchNum}`,
-        },
-        composed: true,
-        bubbles: true,
-      })
+            ${leftPatchset} vs Patchset ${latestPatchNum}`
     );
   }
 
@@ -956,6 +933,23 @@
     this._commitRange = commit && baseCommit ? {commit, baseCommit} : undefined;
   }
 
+  _updateUrlToDiffUrl(lineNum?: number, leftSide?: boolean) {
+    if (!this._change) return;
+    if (!this._patchRange) return;
+    if (!this._changeNum) return;
+    if (!this._path) return;
+    const url = GerritNav.getUrlForDiffById(
+      this._changeNum,
+      this._change.project,
+      this._path,
+      this._patchRange.patchNum,
+      this._patchRange.basePatchNum,
+      lineNum,
+      leftSide
+    );
+    history.replaceState(null, '', url);
+  }
+
   _initPatchRange() {
     let leftSide = false;
     if (!this._change) return;
@@ -965,15 +959,7 @@
         this.params.commentId
       );
       if (!comment) {
-        this.dispatchEvent(
-          new CustomEvent('show-alert', {
-            detail: {
-              message: 'comment not found',
-            },
-            composed: true,
-            bubbles: true,
-          })
-        );
+        fireAlert(this, 'comment not found');
         GerritNav.navigateToChange(this._change);
         return;
       }
@@ -1014,13 +1000,13 @@
     }
     if (!this._patchRange) throw new Error('Failed to initialize patchRange.');
     this._initLineOfInterestAndCursor(leftSide);
-    this._commentMap = this._getPaths(this._patchRange);
 
-    this._commentsForDiff = this._getCommentsForPath(
-      this._path,
-      this._patchRange,
-      this._projectConfig
-    );
+    if (this.params?.commentId) {
+      // url is of type /comment/{commentId} which isn't meaningful
+      this._updateUrlToDiffUrl(this._focusLineNum, leftSide);
+    }
+
+    this._commentMap = this._getPaths(this._patchRange);
   }
 
   _isFileUnchanged(diff: DiffInfo) {
@@ -1062,7 +1048,7 @@
 
     const portedCommentsPromise = this.$.commentAPI.getPortedComments(
       value.changeNum,
-      value.patchNum || 'current'
+      value.patchNum || CURRENT
     );
 
     const promises: Promise<unknown>[] = [];
@@ -1089,7 +1075,12 @@
         this._loading = false;
         this._initPatchRange();
         this._initCommitRange();
-        this.$.diffHost.comments = this._commentsForDiff;
+        if (this._changeComments && this._path && this._patchRange) {
+          this.$.diffHost.threads = this._changeComments.getThreadsBySideForPath(
+            this._path,
+            this._patchRange
+          );
+        }
         portedCommentsPromise.then(() => {
           this.reporting.timeEnd(PORTING_COMMENTS_DIFF_LATENCY_LABEL);
         });
@@ -1122,17 +1113,12 @@
             return;
           }
 
-          this.dispatchEvent(
-            new CustomEvent('show-alert', {
-              detail: {
-                message: `File is unchanged between Patchset
+          fireAlert(
+            this,
+            `File is unchanged between Patchset
                   ${this._patchRange.basePatchNum} and
                   ${this._patchRange.patchNum}. Showing diff of Base vs
-                  ${this._patchRange.basePatchNum}`,
-              },
-              composed: true,
-              bubbles: true,
-            })
+                  ${this._patchRange.basePatchNum}`
           );
           GerritNav.navigateToDiff(
             this._change,
@@ -1230,13 +1216,7 @@
 
   _pathChanged(path: string) {
     if (path) {
-      this.dispatchEvent(
-        new CustomEvent('title-change', {
-          detail: {title: computeTruncatedPath(path)},
-          composed: true,
-          bubbles: true,
-        })
-      );
+      fireTitleChange(this, computeTruncatedPath(path));
     }
 
     if (!this._fileList || this._fileList.length === 0) return;
@@ -1464,26 +1444,12 @@
     _: Event,
     detail: {side: Side | CommentSide; number: number}
   ) {
-    if (!this._change) return;
-    if (!this._path) return;
-    if (!this._changeNum) return;
-    if (!this._patchRange) return;
-
-    const number = detail.number;
     // for on-comment-anchor-tap side can be PARENT/REVISIONS
     // for on-line-selected side can be left/right
-    const leftSide =
-      detail.side === Side.LEFT || detail.side === CommentSide.PARENT;
-    const url = GerritNav.getUrlForDiffById(
-      this._changeNum,
-      this._change.project,
-      this._path,
-      this._patchRange.patchNum,
-      this._patchRange.basePatchNum,
-      number,
-      leftSide
+    this._updateUrlToDiffUrl(
+      detail.number,
+      detail.side === Side.LEFT || detail.side === CommentSide.PARENT
     );
-    history.replaceState(null, '', url);
   }
 
   _computeDownloadDropdownLinks(
@@ -1602,13 +1568,10 @@
 
     const file = files[path];
     if (file && file.old_path) {
-      this._commentsForDiff = this._changeComments.getCommentsBySideForFile(
+      this.$.diffHost.threads = this._changeComments.getThreadsBySideForFile(
         {path, basePath: file.old_path},
-        patchRange,
-        projectConfig
+        patchRange
       );
-
-      this.$.diffHost.comments = this._commentsForDiff;
     }
   }
 
@@ -1617,22 +1580,6 @@
     return this._changeComments.getPaths(patchRange);
   }
 
-  _getCommentsForPath(
-    path?: string,
-    patchRange?: PatchRange,
-    projectConfig?: ConfigInfo
-  ) {
-    if (!path) return undefined;
-    if (!patchRange) return undefined;
-    if (!this._changeComments) return undefined;
-
-    return this._changeComments.getCommentsBySideForPath(
-      path,
-      patchRange,
-      projectConfig
-    );
-  }
-
   _getDiffDrafts() {
     if (!this._changeNum) throw new Error('Missing this._changeNum');
 
@@ -1690,24 +1637,12 @@
 
   _loadBlame() {
     this._isBlameLoading = true;
-    this.dispatchEvent(
-      new CustomEvent('show-alert', {
-        detail: {message: MSG_LOADING_BLAME},
-        composed: true,
-        bubbles: true,
-      })
-    );
+    fireAlert(this, MSG_LOADING_BLAME);
     this.$.diffHost
       .loadBlame()
       .then(() => {
         this._isBlameLoading = false;
-        this.dispatchEvent(
-          new CustomEvent('show-alert', {
-            detail: {message: MSG_LOADED_BLAME},
-            composed: true,
-            bubbles: true,
-          })
-        );
+        fireAlert(this, MSG_LOADED_BLAME);
       })
       .catch(() => {
         this._isBlameLoading = false;
@@ -1753,15 +1688,7 @@
     if (!this._patchRange) return;
 
     if (patchNumEquals(this._patchRange.basePatchNum, ParentPatchSetNum)) {
-      this.dispatchEvent(
-        new CustomEvent('show-alert', {
-          detail: {
-            message: 'Base is already selected.',
-          },
-          composed: true,
-          bubbles: true,
-        })
-      );
+      fireAlert(this, 'Base is already selected.');
       return;
     }
     GerritNav.navigateToDiff(
@@ -1778,15 +1705,7 @@
     if (!this._patchRange) return;
 
     if (patchNumEquals(this._patchRange.basePatchNum, ParentPatchSetNum)) {
-      this.dispatchEvent(
-        new CustomEvent('show-alert', {
-          detail: {
-            message: 'Left is already base.',
-          },
-          composed: true,
-          bubbles: true,
-        })
-      );
+      fireAlert(this, 'Left is already base.');
       return;
     }
     GerritNav.navigateToDiff(
@@ -1808,15 +1727,7 @@
 
     const latestPatchNum = computeLatestPatchNum(this._allPatchSets);
     if (patchNumEquals(this._patchRange.patchNum, latestPatchNum)) {
-      this.dispatchEvent(
-        new CustomEvent('show-alert', {
-          detail: {
-            message: 'Latest is already selected.',
-          },
-          composed: true,
-          bubbles: true,
-        })
-      );
+      fireAlert(this, 'Latest is already selected.');
       return;
     }
 
@@ -1836,15 +1747,7 @@
 
     const latestPatchNum = computeLatestPatchNum(this._allPatchSets);
     if (patchNumEquals(this._patchRange.patchNum, latestPatchNum)) {
-      this.dispatchEvent(
-        new CustomEvent('show-alert', {
-          detail: {
-            message: 'Right is already latest.',
-          },
-          composed: true,
-          bubbles: true,
-        })
-      );
+      fireAlert(this, 'Right is already latest.');
       return;
     }
     GerritNav.navigateToDiff(
@@ -1866,15 +1769,7 @@
       patchNumEquals(this._patchRange.patchNum, latestPatchNum) &&
       patchNumEquals(this._patchRange.basePatchNum, ParentPatchSetNum)
     ) {
-      this.dispatchEvent(
-        new CustomEvent('show-alert', {
-          detail: {
-            message: 'Already diffing base against latest.',
-          },
-          composed: true,
-          bubbles: true,
-        })
-      );
+      fireAlert(this, 'Already diffing base against latest.');
       return;
     }
     GerritNav.navigateToDiff(this._change, this._path, latestPatchNum);
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 9a08723..8e1e5c1 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
@@ -151,8 +151,10 @@
         computeCommentThreadCount: () => {},
         computeUnresolvedNum: () => {},
         getPaths: () => {},
-        getCommentsBySideForPath: () => {},
+        getThreadsBySideForPath: () => {},
+        getThreadsBySideForFile: () => {},
         findCommentById: _testOnly_findCommentById,
+
       }));
       await element._loadComments();
       await flush();
@@ -184,6 +186,8 @@
     test('comment route', () => {
       const initLineOfInterestAndCursorStub =
         sinon.stub(element, '_initLineOfInterestAndCursor');
+      const getUrlStub = sinon.stub(GerritNav, 'getUrlForDiffById');
+      const replaceStateStub = sinon.stub(history, 'replaceState');
       sinon.stub(element, '_getFiles');
       sinon.stub(element.reporting, 'diffViewDisplayed');
       sinon.stub(element.$.diffHost, 'reload').returns(Promise.resolve());
@@ -198,11 +202,6 @@
         commentLink: true,
         commentId: 'c1',
       };
-      sinon.stub(element.$.diffHost, '_commentsChanged');
-      sinon.stub(element, '_getCommentsForPath').returns({
-        left: [{id: 'c1', __commentSide: 'left', line: 10}],
-        right: [{id: 'c2', __commentSide: 'right', line: 11}],
-      });
       element._change = {
         ...createChange(),
         revisions: createRevisions(11),
@@ -213,6 +212,10 @@
         assert.equal(element._focusLineNum, 10);
         assert.equal(element._patchRange.patchNum, 11);
         assert.equal(element._patchRange.basePatchNum, 2);
+
+        assert.isTrue(replaceStateStub.called);
+        assert.isTrue(getUrlStub.calledWithExactly('42', 'test-project',
+            '/COMMIT_MSG', 11, 2, 10, true));
       });
     });
 
@@ -257,7 +260,6 @@
             commentLink: true,
             commentId: 'c1',
           };
-          sinon.stub(element.$.diffHost, '_commentsChanged');
           element._change = {
             ...createChange(),
             revisions: createRevisions(11),
@@ -287,7 +289,6 @@
             commentLink: true,
             commentId: 'c3',
           };
-          sinon.stub(element.$.diffHost, '_commentsChanged');
           element._change = {
             ...createChange(),
             revisions: createRevisions(11),
@@ -1449,7 +1450,6 @@
         await flush();
       });
       test('empty', () => {
-        sinon.stub(element, '_getCommentsForPath');
         sinon.stub(element, '_getPaths').returns(new Map());
         element._initPatchRange();
         assert.equal(Object.keys(element._commentMap).length, 0);
@@ -1461,7 +1461,6 @@
           'path/to/file/one.cpp': [{patch_set: 3, message: 'lorem'}],
           'path-to/file/two.py': [{patch_set: 5, message: 'ipsum'}],
         });
-        sinon.stub(element, '_getCommentsForPath').returns({meta: {}});
         element._changeNum = '42';
         element._patchRange = {
           basePatchNum: 3,
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-group.ts b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-group.ts
index eb11588..11d3ce3 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-group.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-group.ts
@@ -83,8 +83,9 @@
 
   const numHidden = hiddenEnd - hiddenStart;
 
-  // Only collapse if there is more than 1 line to be hidden.
-  if (numHidden > 1) {
+  // Showing a context control row for less than 4 lines does not make much,
+  // because then that row would consume as much space as the collapsed code.
+  if (numHidden > 3) {
     if (hiddenStart) {
       [before, hidden] = _splitCommonGroups(hidden, hiddenStart);
     }
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-group_test.js b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-group_test.js
index 3423834..8182941 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-group_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-group_test.js
@@ -122,17 +122,19 @@
           new GrDiffLine(GrDiffLineType.ADD, 0, 11),
           new GrDiffLine(GrDiffLineType.REMOVE, 10),
           new GrDiffLine(GrDiffLineType.ADD, 0, 12),
+          new GrDiffLine(GrDiffLineType.REMOVE, 11),
+          new GrDiffLine(GrDiffLineType.ADD, 0, 13),
         ]),
         new GrDiffGroup(GrDiffGroupType.BOTH, [
-          new GrDiffLine(GrDiffLineType.BOTH, 11, 13),
           new GrDiffLine(GrDiffLineType.BOTH, 12, 14),
           new GrDiffLine(GrDiffLineType.BOTH, 13, 15),
+          new GrDiffLine(GrDiffLineType.BOTH, 14, 16),
         ]),
       ];
     });
 
     test('hides hidden groups in context control', () => {
-      const collapsedGroups = hideInContextControl(groups, 3, 6);
+      const collapsedGroups = hideInContextControl(groups, 3, 7);
       assert.equal(collapsedGroups.length, 3);
 
       assert.equal(collapsedGroups[0], groups[0]);
@@ -145,7 +147,7 @@
     });
 
     test('splits partially hidden groups', () => {
-      const collapsedGroups = hideInContextControl(groups, 4, 7);
+      const collapsedGroups = hideInContextControl(groups, 4, 8);
       assert.equal(collapsedGroups.length, 4);
       assert.equal(collapsedGroups[0], groups[0]);
 
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-utils.ts b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-utils.ts
index 8984dc8..b13b3c5 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-utils.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-utils.ts
@@ -17,6 +17,7 @@
 
 import {CommentRange} from '../../../types/common';
 import {FILE, LineNumber} from './gr-diff-line';
+import {Side} from '../../../constants/constants';
 
 /**
  * Compare two ranges. Either argument may be falsy, but will only return
@@ -46,3 +47,47 @@
   const lineNumber = Number(lineNumberStr);
   return Number.isInteger(lineNumber) ? lineNumber : null;
 }
+
+export function getLine(threadEl: HTMLElement): LineNumber {
+  const lineAtt = threadEl.getAttribute('line-num');
+  if (!lineAtt || lineAtt === 'FILE') return FILE;
+  const line = Number(lineAtt);
+  if (isNaN(line)) throw new Error(`cannot parse line number: ${lineAtt}`);
+  if (line < 1) throw new Error(`line number smaller than 1: ${line}`);
+  return line;
+}
+
+export function getSide(threadEl: HTMLElement): Side | undefined {
+  const sideAtt = threadEl.getAttribute('comment-side');
+  if (!sideAtt) {
+    console.warn('comment thread without side');
+    return undefined;
+  }
+  if (sideAtt !== Side.LEFT && sideAtt !== Side.RIGHT)
+    throw Error(`unexpected value for side: ${sideAtt}`);
+  return sideAtt as Side;
+}
+
+export function getRange(threadEl: HTMLElement): CommentRange | undefined {
+  const rangeAtt = threadEl.getAttribute('range');
+  if (!rangeAtt) return undefined;
+  const range = JSON.parse(rangeAtt) as CommentRange;
+  if (!range.start_line) throw new Error(`invalid range: ${rangeAtt}`);
+  return range;
+}
+
+// TODO: This type should be exposed to gr-diff clients in a separate type file.
+// For Gerrit these are instances of GrCommentThread, but other gr-diff users
+// have different HTML elements in use for comment threads.
+// TODO: Also document the required HTML attritbutes that thread elements must
+// have, e.g. 'comment-side', 'range', 'line-num', 'data-value'.
+export interface GrDiffThreadElement extends HTMLElement {
+  rootId: string;
+}
+
+export function isThreadEl(node: Node): node is GrDiffThreadElement {
+  return (
+    node.nodeType === Node.ELEMENT_NODE &&
+    (node as Element).classList.contains('comment-thread')
+  );
+}
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 d079639..472a6bd 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.ts
@@ -16,6 +16,7 @@
  */
 import '../../../styles/shared-styles';
 import '../../shared/gr-button/gr-button';
+import '../../shared/gr-icons/gr-icons';
 import '../gr-diff-builder/gr-diff-builder-element';
 import '../gr-diff-highlight/gr-diff-highlight';
 import '../gr-diff-selection/gr-diff-selection';
@@ -26,8 +27,16 @@
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {htmlTemplate} from './gr-diff_html';
-import {FILE, LineNumber} from './gr-diff-line';
-import {getLineNumber, rangesEqual} from './gr-diff-utils';
+import {LineNumber} from './gr-diff-line';
+import {
+  getLine,
+  getLineNumber,
+  getRange,
+  getSide,
+  GrDiffThreadElement,
+  isThreadEl,
+  rangesEqual,
+} from './gr-diff-utils';
 import {getHiddenScroll} from '../../../scripts/hiddenscroll';
 import {isMergeParent, patchNumEquals} from '../../../utils/patch-set-util';
 import {customElement, observe, property} from '@polymer/decorators';
@@ -52,11 +61,12 @@
   PolymerDomWrapper,
 } from '../../../types/types';
 import {CommentRangeLayer} from '../gr-ranged-comment-layer/gr-ranged-comment-layer';
-import {DiffViewMode, Side} from '../../../constants/constants';
+import {CommentSide, DiffViewMode, Side} from '../../../constants/constants';
 import {KeyLocations} from '../gr-diff-processor/gr-diff-processor';
 import {FlattenedNodesObserver} from '@polymer/polymer/lib/utils/flattened-nodes-observer';
 import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
 import {AbortStop} from '../../shared/gr-cursor-manager/gr-cursor-manager';
+import {fireAlert} from '../../../utils/event-util';
 
 const NO_NEWLINE_BASE = 'No newline at end of base file.';
 const NO_NEWLINE_REVISION = 'No newline at end of revision file.';
@@ -65,27 +75,6 @@
 const FULL_CONTEXT = -1;
 const LIMITED_CONTEXT = 10;
 
-function getSide(threadEl: GrCommentThread): Side {
-  const sideAtt = threadEl.getAttribute('comment-side');
-  if (!sideAtt) throw Error('comment thread without side');
-  if (sideAtt !== 'left' && sideAtt !== 'right')
-    throw Error(`unexpected value for side: ${sideAtt}`);
-  return sideAtt as Side;
-}
-
-function isThreadEl(node: Node): node is GrCommentThread {
-  return (
-    node.nodeType === Node.ELEMENT_NODE &&
-    (node as Element).classList.contains('comment-thread')
-  );
-}
-
-// TODO(TS): Replace by proper GrCommentThread once converted.
-type GrCommentThread = PolymerElement & {
-  rootId: string;
-  range: CommentRange;
-};
-
 const COMMIT_MSG_PATH = '/COMMIT_MSG';
 /**
  * 72 is the unofficial length standard for git commit messages.
@@ -378,17 +367,16 @@
   // TODO(brohlfs): Rewrite gr-diff to be agnostic of GrCommentThread, because
   // other users of gr-diff may use different comment widgets.
   _updateRanges(
-    addedThreadEls: GrCommentThread[],
-    removedThreadEls: GrCommentThread[]
+    addedThreadEls: GrDiffThreadElement[],
+    removedThreadEls: GrDiffThreadElement[]
   ) {
     function commentRangeFromThreadEl(
-      threadEl: GrCommentThread
+      threadEl: GrDiffThreadElement
     ): CommentRangeLayer | undefined {
       const side = getSide(threadEl);
-
-      const rangeAtt = threadEl.getAttribute('range');
-      if (!rangeAtt) return undefined;
-      const range = JSON.parse(rangeAtt) as CommentRange;
+      if (!side) return undefined;
+      const range = getRange(threadEl);
+      if (!range) return undefined;
 
       return {side, range, hovering: false, rootId: threadEl.rootId};
     }
@@ -431,12 +419,13 @@
 
     for (const threadEl of threadEls) {
       const side = getSide(threadEl);
-      const lineNum = Number(threadEl.getAttribute('line-num')) || FILE;
-      const commentRange = threadEl.range || {};
+      if (!side) continue;
+      const lineNum = getLine(threadEl);
+      const commentRange = getRange(threadEl);
       keyLocations[side][lineNum] = true;
       // Add start_line as well if exists,
       // the being and end of the range should not be collapsed.
-      if (commentRange.start_line) {
+      if (commentRange?.start_line) {
         keyLocations[side][commentRange.start_line] = true;
       }
     }
@@ -444,7 +433,7 @@
   }
 
   // Dispatch events that are handled by the gr-diff-highlight.
-  _redispatchHoverEvents(addedThreadEls: GrCommentThread[]) {
+  _redispatchHoverEvents(addedThreadEls: HTMLElement[]) {
     for (const threadEl of addedThreadEls) {
       threadEl.addEventListener('mouseenter', () => {
         threadEl.dispatchEvent(
@@ -570,22 +559,11 @@
 
     const lineNum = getLineNumber(el);
     if (lineNum === null) {
-      this.dispatchEvent(
-        new CustomEvent('show-alert', {
-          detail: {message: 'Invalid line number'},
-          composed: true,
-          bubbles: true,
-        })
-      );
+      fireAlert(this, 'Invalid line number');
       return;
     }
 
-    // TODO(TS): existing logic always pass undefined lineNum
-    // for file level comment, the drafts API will reject the
-    // request if file level draft contains the `line: 'FILE'` field
-    // probably should do this inside of the _createComment, this
-    // is just to keep existing behavior.
-    this._createComment(el, lineNum === FILE ? undefined : lineNum);
+    this._createComment(el, lineNum);
   }
 
   createRangeComment() {
@@ -623,13 +601,7 @@
       return false;
     }
     if (!this.patchRange) {
-      this.dispatchEvent(
-        new CustomEvent('show-alert', {
-          detail: {message: 'Cannot create comment. Patch range undefined.'},
-          composed: true,
-          bubbles: true,
-        })
-      );
+      fireAlert(this, 'Cannot create comment. Patch range undefined.');
       return false;
     }
     const patchNum = el.classList.contains(Side.LEFT)
@@ -642,25 +614,11 @@
       patchNumEquals(this.patchRange.patchNum, EditPatchSetNum);
 
     if (isEdit) {
-      this.dispatchEvent(
-        new CustomEvent('show-alert', {
-          detail: {message: 'You cannot comment on an edit.'},
-          composed: true,
-          bubbles: true,
-        })
-      );
+      fireAlert(this, 'You cannot comment on an edit.');
       return false;
     }
     if (isEditBase) {
-      this.dispatchEvent(
-        new CustomEvent('show-alert', {
-          detail: {
-            message: 'You cannot comment on the base patchset of an edit.',
-          },
-          composed: true,
-          bubbles: true,
-        })
-      );
+      fireAlert(this, 'You cannot comment on the base patchset of an edit.');
       return false;
     }
     return true;
@@ -683,6 +641,7 @@
       lineEl,
       contentEl
     );
+    const commentSide = isOnParent ? CommentSide.PARENT : CommentSide.REVISION;
     this.dispatchEvent(
       new CustomEvent('create-comment', {
         bubbles: true,
@@ -690,9 +649,11 @@
         detail: {
           lineNum,
           side,
+          commentSide,
           patchNum: patchForNewThreads,
-          isOnParent,
           range,
+          path: this.path,
+          isOnParent,
         },
       })
     );
@@ -917,10 +878,11 @@
       // for each line from the start.
       let lastEl;
       for (const threadEl of addedThreadEls) {
-        const lineNumString = threadEl.getAttribute('line-num') || 'FILE';
+        const lineNum = getLine(threadEl);
         const commentSide = getSide(threadEl);
+        if (!commentSide) continue;
         const lineEl = this.$.diffBuilder.getLineElByNumber(
-          lineNumString,
+          lineNum,
           commentSide
         );
         // When the line the comment refers to does not exist, log an error
@@ -930,7 +892,7 @@
           console.error(
             'thread attached to line ',
             commentSide,
-            lineNumString,
+            lineNum,
             ' which does not exist.'
           );
           continue;
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_html.ts b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_html.ts
index b587d8a..b9578e8 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_html.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_html.ts
@@ -211,17 +211,45 @@
 
     /* dueToMove */
     .dueToMove .content.add .contentText,
-    .dueToMove .moveControls.movedIn .moveDescription,
+    .dueToMove .moveControls.movedIn .moveLabel,
     .delta.total.dueToMove .content.add .contentText {
-      background-color: var(--light-moved-add-highlight-color);
+      background-color: var(--diff-moved-in-background);
     }
+
     .dueToMove .content.remove .contentText,
-    .dueToMove .moveControls.movedOut .moveDescription,
+    .dueToMove .moveControls.movedOut .moveLabel,
     .delta.total.dueToMove .content.remove .contentText {
-      background-color: var(--light-remove-add-highlight-color);
+      background-color: var(--diff-moved-out-background);
     }
-    .moveControls {
-      text-align: right;
+
+    .delta.dueToMove .movedIn .moveDescription {
+      color: var(--diff-moved-in-background);
+      background-color: var(--diff-moved-in-label-background);
+    }
+    .delta.dueToMove .movedOut .moveDescription {
+      color: var(--diff-moved-out-background);
+      background-color: var(--diff-moved-out-label-background);
+    }
+    .moveLabel {
+      display: flex;
+      justify-content: flex-end;
+      font-family: var(--header-font-family);
+    }
+    .delta.dueToMove .moveDescription {
+      border-radius: var(--spacing-l);
+      padding: var(--spacing-s) var(--spacing-m);
+      margin: var(--spacing-s);
+      font-weight: 500;
+      line-height: var(--spacing-xl);
+      vertical-align: middle;
+      display: flex;
+    }
+
+    .moveDescription iron-icon {
+      color: inherit;
+      margin-right: var(--spacing-s);
+      height: var(--spacing-xl);
+      width: var(--spacing-xl);
     }
 
     /* ignoredWhitespaceOnly */
@@ -283,8 +311,8 @@
       background-color: var(--view-background-color);
     }
     .contextBackground {
-      /* 
-       * One line of background behind the context expanders which they can 
+      /*
+       * One line of background behind the context expanders which they can
        * render on top of, plus some padding.
        */
       height: calc(var(--line-height-normal) + var(--spacing-s));
diff --git a/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search.ts b/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search.ts
index 5967b03..79c4359 100644
--- a/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search.ts
+++ b/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search.ts
@@ -30,6 +30,7 @@
 import {customElement, property} from '@polymer/decorators';
 import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
 import {DocResult} from '../../../types/common';
+import {fireTitleChange} from '../../../utils/event-util';
 
 export interface GrDocumentationSearch {
   $: {
@@ -62,9 +63,7 @@
   /** @override */
   attached() {
     super.attached();
-    this.dispatchEvent(
-      new CustomEvent('title-change', {detail: {title: 'Documentation Search'}})
-    );
+    fireTitleChange(this, 'Documentation Search');
   }
 
   _paramsChanged(params: ListViewParams) {
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 a0562de..60944d0 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
@@ -47,6 +47,7 @@
 } from '../../../types/common';
 import {GrStorage} from '../../shared/gr-storage/gr-storage';
 import {HttpMethod, NotifyType} from '../../../constants/constants';
+import {fireAlert, fireTitleChange} from '../../../utils/event-util';
 
 const RESTORED_MESSAGE = 'Content restored from a previous edit.';
 const SAVING_MESSAGE = 'Saving changes...';
@@ -176,13 +177,7 @@
     // has been queued, the event can bubble up to the handler in gr-app.
     this.async(() => {
       const title = `Editing ${computeTruncatedPath(value.path)}`;
-      this.dispatchEvent(
-        new CustomEvent('title-change', {
-          detail: {title},
-          composed: true,
-          bubbles: true,
-        })
-      );
+      fireTitleChange(this, title);
     });
 
     const promises = [];
@@ -255,13 +250,7 @@
           storedContent.message &&
           storedContent.message !== content
         ) {
-          this.dispatchEvent(
-            new CustomEvent('show-alert', {
-              detail: {message: RESTORED_MESSAGE},
-              bubbles: true,
-              composed: true,
-            })
-          );
+          fireAlert(this, RESTORED_MESSAGE);
 
           this._newContent = storedContent.message;
         } else {
@@ -305,13 +294,7 @@
   }
 
   _showAlert(message: string) {
-    this.dispatchEvent(
-      new CustomEvent('show-alert', {
-        detail: {message},
-        bubbles: true,
-        composed: true,
-      })
-    );
+    fireAlert(this, message);
   }
 
   _computeSaveDisabled(
diff --git a/polygerrit-ui/app/elements/gr-app-element.ts b/polygerrit-ui/app/elements/gr-app-element.ts
index c2fb124..bd38808 100644
--- a/polygerrit-ui/app/elements/gr-app-element.ts
+++ b/polygerrit-ui/app/elements/gr-app-element.ts
@@ -79,6 +79,7 @@
   TitleChangeEventDetail,
 } from '../types/events';
 import {ViewState} from '../types/types';
+import {EventType} from '../utils/event-util';
 
 interface ErrorInfo {
   text: string;
@@ -209,8 +210,12 @@
   created() {
     super.created();
     this._bindKeyboardShortcuts();
-    this.addEventListener('page-error', e => this._handlePageError(e));
-    this.addEventListener('title-change', e => this._handleTitleChange(e));
+    this.addEventListener(EventType.PAGE_ERROR, e => {
+      this._handlePageError(e);
+    });
+    this.addEventListener(EventType.TITLE_CHANGE, e => {
+      this._handleTitleChange(e);
+    });
     this.addEventListener('location-change', e =>
       this._handleLocationChange(e)
     );
@@ -487,6 +492,9 @@
         registrationOverlay.refit();
       });
     }
+    // To fix bug announce read after each new view, we reset announce with
+    // empty space
+    this.fire('iron-announce', {text: ' '}, {bubbles: true});
   }
 
   _handleShortcutTriggered(event: ShortcutTriggeredEvent) {
@@ -499,11 +507,10 @@
     if (e.ctrlKey) key = 'ctrl+' + key;
     if (e.metaKey) key = 'meta+' + key;
     if (e.altKey) key = 'alt+' + key;
+    const path = event.composedPath();
     this.reporting.reportInteraction('shortcut-triggered', {
       key,
-      from:
-        (event.path && event.path[0] && (event.path[0] as Element).nodeName) ??
-        'unknown',
+      from: (path && path[0] && (path[0] as Element).nodeName) ?? 'unknown',
     });
   }
 
diff --git a/polygerrit-ui/app/elements/gr-app-global-var-init.ts b/polygerrit-ui/app/elements/gr-app-global-var-init.ts
index fac5f45..7a27b03 100644
--- a/polygerrit-ui/app/elements/gr-app-global-var-init.ts
+++ b/polygerrit-ui/app/elements/gr-app-global-var-init.ts
@@ -22,61 +22,13 @@
  * expose these variables until plugins switch to direct import from polygerrit.
  */
 
-import {
-  getAccountDisplayName,
-  getDisplayName,
-  getGroupDisplayName,
-  getUserName,
-} from '../utils/display-name-util';
 import {GrAnnotation} from './diff/gr-diff-highlight/gr-annotation';
-import {GrAttributeHelper} from './plugins/gr-attribute-helper/gr-attribute-helper';
 import {GrDiffLine, GrDiffLineType} from './diff/gr-diff/gr-diff-line';
 import {GrDiffGroup, GrDiffGroupType} from './diff/gr-diff/gr-diff-group';
-import {GrDiffBuilder} from './diff/gr-diff-builder/gr-diff-builder';
-import {GrDiffBuilderSideBySide} from './diff/gr-diff-builder/gr-diff-builder-side-by-side';
-import {GrDiffBuilderImage} from './diff/gr-diff-builder/gr-diff-builder-image';
-import {GrDiffBuilderUnified} from './diff/gr-diff-builder/gr-diff-builder-unified';
-import {GrDiffBuilderBinary} from './diff/gr-diff-builder/gr-diff-builder-binary';
-import {GrChangeActionsInterface} from './shared/gr-js-api-interface/gr-change-actions-js-api';
-import {GrChangeReplyInterface} from './shared/gr-js-api-interface/gr-change-reply-js-api';
-import {GrEditConstants} from './edit/gr-edit-constants';
-import {
-  GrDomHooksManager,
-  GrDomHook,
-} from './plugins/gr-dom-hooks/gr-dom-hooks';
-import {GrEtagDecorator} from './shared/gr-rest-api-interface/gr-etag-decorator';
-import {GrThemeApi} from './plugins/gr-theme-api/gr-theme-api';
-import {
-  SiteBasedCache,
-  FetchPromisesCache,
-  GrRestApiHelper,
-} from './shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
-import {GrLinkTextParser} from './shared/gr-linked-text/link-text-parser';
-import {
-  getPluginEndpoints,
-  GrPluginEndpoints,
-} from './shared/gr-js-api-interface/gr-plugin-endpoints';
-import {GrReviewerUpdatesParser} from './shared/gr-rest-api-interface/gr-reviewer-updates-parser';
-import {GrPopupInterface} from './plugins/gr-popup-interface/gr-popup-interface';
-import {GrCountStringFormatter} from './shared/gr-count-string-formatter/gr-count-string-formatter';
-import {
-  GrReviewerSuggestionsProvider,
-  SUGGESTIONS_PROVIDERS_USERS_TYPES,
-} from '../scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider';
+import {getPluginEndpoints} from './shared/gr-js-api-interface/gr-plugin-endpoints';
 import {util} from '../scripts/util';
 import {page} from '../utils/page-wrapper-utils';
 import {appContext} from '../services/app-context';
-import {GrAdminApi} from './plugins/gr-admin-api/gr-admin-api';
-import {GrAnnotationActionsContext} from './shared/gr-js-api-interface/gr-annotation-actions-context';
-import {GrAnnotationActionsInterface} from './shared/gr-js-api-interface/gr-annotation-actions-js-api';
-import {GrChangeMetadataApi} from './plugins/gr-change-metadata-api/gr-change-metadata-api';
-import {GrEmailSuggestionsProvider} from '../scripts/gr-email-suggestions-provider/gr-email-suggestions-provider';
-import {GrGroupSuggestionsProvider} from '../scripts/gr-group-suggestions-provider/gr-group-suggestions-provider';
-import {GrEventHelper} from './plugins/gr-event-helper/gr-event-helper';
-import {GrPluginRestApi} from './shared/gr-js-api-interface/gr-plugin-rest-api';
-import {GrRepoApi} from './plugins/gr-repo-api/gr-repo-api';
-import {GrSettingsApi} from './plugins/gr-settings-api/gr-settings-api';
-import {GrStylesApi} from './plugins/gr-styles-api/gr-styles-api';
 import {
   getPluginLoader,
   PluginLoader,
@@ -92,60 +44,18 @@
 import {getBaseUrl} from '../utils/url-util';
 import {GerritNav} from './core/gr-navigation/gr-navigation';
 import {getRootElement} from '../scripts/rootElement';
-import {rangesEqual} from './diff/gr-diff/gr-diff-utils';
 import {RevisionInfo} from './shared/revision-info/revision-info';
-import {CoverageType} from '../types/types';
-import {_setHiddenScroll, getHiddenScroll} from '../scripts/hiddenscroll';
 
 export function initGlobalVariables() {
-  window.GrDisplayNameUtils = {
-    getUserName,
-    getDisplayName,
-    getAccountDisplayName,
-    getGroupDisplayName,
-  };
   window.GrAnnotation = GrAnnotation;
-  window.GrAttributeHelper = GrAttributeHelper;
   window.GrDiffLine = GrDiffLine;
   window.GrDiffLineType = GrDiffLineType;
   window.GrDiffGroup = GrDiffGroup;
   window.GrDiffGroupType = GrDiffGroupType;
-  window.GrDiffBuilder = GrDiffBuilder;
-  window.GrDiffBuilderSideBySide = GrDiffBuilderSideBySide;
-  window.GrDiffBuilderImage = GrDiffBuilderImage;
-  window.GrDiffBuilderUnified = GrDiffBuilderUnified;
-  window.GrDiffBuilderBinary = GrDiffBuilderBinary;
-  window.GrChangeActionsInterface = GrChangeActionsInterface;
-  window.GrChangeReplyInterface = GrChangeReplyInterface;
-  window.GrEditConstants = GrEditConstants;
-  window.GrDomHooksManager = GrDomHooksManager;
-  window.GrDomHook = GrDomHook;
-  window.GrEtagDecorator = GrEtagDecorator;
-  window.GrThemeApi = GrThemeApi;
-  window.SiteBasedCache = SiteBasedCache;
-  window.FetchPromisesCache = FetchPromisesCache;
-  window.GrRestApiHelper = GrRestApiHelper;
-  window.GrLinkTextParser = GrLinkTextParser;
-  window.GrPluginEndpoints = GrPluginEndpoints;
-  window.GrReviewerUpdatesParser = GrReviewerUpdatesParser;
-  window.GrPopupInterface = GrPopupInterface;
-  window.GrCountStringFormatter = GrCountStringFormatter;
-  window.GrReviewerSuggestionsProvider = GrReviewerSuggestionsProvider;
   window.util = util;
   window.page = page;
   window.Auth = appContext.authService;
   window.EventEmitter = appContext.eventEmitter;
-  window.GrAdminApi = GrAdminApi;
-  window.GrAnnotationActionsContext = GrAnnotationActionsContext;
-  window.GrAnnotationActionsInterface = GrAnnotationActionsInterface;
-  window.GrChangeMetadataApi = GrChangeMetadataApi;
-  window.GrEmailSuggestionsProvider = GrEmailSuggestionsProvider;
-  window.GrGroupSuggestionsProvider = GrGroupSuggestionsProvider;
-  window.GrEventHelper = GrEventHelper;
-  window.GrPluginRestApi = GrPluginRestApi;
-  window.GrRepoApi = GrRepoApi;
-  window.GrSettingsApi = GrSettingsApi;
-  window.GrStylesApi = GrStylesApi;
   window.PluginLoader = PluginLoader;
   window.GrPluginActionContext = GrPluginActionContext;
 
@@ -167,14 +77,5 @@
   // TODO: should define as a getter
   window.Gerrit._endpoints = getPluginEndpoints();
 
-  // TODO(TS): seems not used, probably just remove
-  window.Gerrit.slotToContent = (slot: any) => slot;
-  window.Gerrit.rangesEqual = rangesEqual;
-  window.Gerrit.SUGGESTIONS_PROVIDERS_USERS_TYPES = SUGGESTIONS_PROVIDERS_USERS_TYPES;
   window.Gerrit.RevisionInfo = RevisionInfo;
-  window.Gerrit.CoverageType = CoverageType;
-  Object.defineProperty(window.Gerrit, 'hiddenscroll', {
-    get: getHiddenScroll,
-    set: _setHiddenScroll,
-  });
 }
diff --git a/polygerrit-ui/app/elements/plugins/gr-checks-api/gr-checks-api-types.ts b/polygerrit-ui/app/elements/plugins/gr-checks-api/gr-checks-api-types.ts
new file mode 100644
index 0000000..063d89d
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-checks-api/gr-checks-api-types.ts
@@ -0,0 +1,370 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Settings
+ *
+ * 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.
+ */
+
+// IMPORTANT !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
+// The entire API is currently in DRAFT state.
+// Changes to all type and interfaces are expected.
+// !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
+
+export interface GrChecksApiInterface {
+  /**
+   * Must only be called once. You cannot register twice. You cannot unregister.
+   */
+  register(provider: ChecksProvider, config?: ChecksApiConfig): void;
+
+  /**
+   * Forces Gerrit to call fetch() on the registered provider. Can be called
+   * when the provider has gotten an update and does not want wait for the next
+   * polling interval to pass.
+   */
+  announceUpdate(): void;
+}
+
+export interface ChecksApiConfig {
+  /**
+   * How often should the provider be called for new CheckData while the user
+   * navigates change related pages and the browser tab remains visible?
+   * Set to 0 to disable polling. Default is 60 seconds.
+   */
+  fetchPollingIntervalSeconds: number;
+}
+
+export interface ChecksProvider {
+  /**
+   * Gerrit calls this method when ...
+   * - ... the change or diff page is loaded.
+   * - ... the user switches back to a Gerrit tab with a change or diff page.
+   * - ... while the tab is visible in a regular polling interval, see
+   *       ChecksApiConfig.
+   */
+  fetch(change: number, patchset: number): Promise<FetchResponse>;
+}
+
+export interface FetchResponse {
+  responseCode: ResponseCode;
+
+  /** Only relevant when the responseCode is ERROR. */
+  errorMessage?: string;
+
+  /**
+   * Only relevant when the responseCode is NOT_LOGGED_IN.
+   * Gerrit displays a "Login" button and calls this callback when the user
+   * clicks on the button.
+   */
+  loginCallback?: () => void;
+
+  runs: CheckRun[];
+  results: CheckResult[];
+}
+
+export enum ResponseCode {
+  OK,
+  ERROR,
+  NOT_LOGGED_IN,
+}
+
+/**
+ * A CheckRun models an entity that has start/end timestamps and can be in
+ * either of the states RUNNABLE, RUNNING, COMPLETED. By itself it cannot model
+ * an error, neither can it be failed or successful by itself. A run can be
+ * associated with 0 to n results (see below). So until runs are completed the
+ * runs are more interesting for the user: What is going on at the moment? When
+ * runs are completed the users' interest shifts to results: What do I have to
+ * fix? The only actions that can be associated with runs are RUN and CANCEL.
+ */
+export interface CheckRun {
+  /**
+   * Gerrit requests check runs and results from the plugin by change number and
+   * patchset number. So these two properties can as well be left empty when
+   * returning results to the Gerrit UI and are thus optional.
+   */
+  change?: number;
+  /**
+   * Typically only runs for the latest patchset are requested and presented.
+   * Older runs and their results are only available on request, e.g. by
+   * switching to another patchset in a dropdown
+   *
+   * TBD: Check data providers may decide that runs and results are applicable
+   * to a newer patchset, even if they were produced for an older, e.g. because
+   * only the commit message was changed. Maybe that warrants the addition of
+   * another optional field, e.g. `original_patchset`.
+   */
+  patchset?: number;
+  /**
+   * The UI will focus on just the latest attempt per run. Former attempts are
+   * accessible, but initially collapsed/hidden. Lower number means older
+   * attempt. Every run has its own attempt numbering, so attempt 3 of run A is
+   * not directly related to attempt 3 of run B.
+   *
+   * RUNNABLE runs must use `undefined` as attempt.
+   * COMPLETED and RUNNING runs must use an attempt number >=0.
+   *
+   * TBD: Optionally providing aggregate information about former attempts will
+   * probably be a useful feature, but we are deferring the exact data modeling
+   * of that to later.
+   */
+  attempt?: number;
+
+  /**
+   * An optional opaque identifier not used by Gerrit directly, but might be
+   * used by plugin extensions and callbacks.
+   */
+  externalId?: string;
+
+  // The following 3 properties are independent of this run *instance*. They
+  // just describe what the check is about and will be identical for other
+  // attempts or patchsets or changes.
+
+  /**
+   * The unique name of the check. There can’t be two runs with the same
+   * change/patchset/attempt/checkName combination.
+   * Multiple attempts of the same run must have the same checkName.
+   * It should be expected that this string is cut off at ~30 chars in the UI.
+   * The full name will then be shown in a tooltip.
+   */
+  checkName: string;
+  /**
+   * Optional description of the check. Only shown as a tooltip or in a
+   * hovercard.
+   */
+  checkDescription?: string;
+  /**
+   * Optional http link to an external page with more detailed information about
+   * this run. Must begin with 'http'.
+   */
+  checkLink?: string;
+
+  /**
+   * RUNNABLE:  Not run (yet). Mostly useful for runs that the user can trigger
+   *            (see actions). Cannot contain results.
+   * RUNNING:   Subsumes "scheduled".
+   * COMPLETED: The attempt of the run has finished. Does not indicate at all
+   *            whether the run was successful or not. Outcomes can and should
+   *            be modeled using the CheckResult entity.
+   */
+  status: RunStatus;
+  /**
+   * Optional short description of the run status. This is a plain string
+   * without styling or formatting options. It will only be shown as a tooltip
+   * or in a hovercard.
+   *
+   * Examples:
+   * "40 tests running, 30 completed: 0 failing so far",
+   * "Scheduled 5 minutes ago, not running yet".
+   */
+  statusDescription?: string;
+  /**
+   * Optional http link to an external page with more detailed information about
+   * the run status. Must begin with 'http'.
+   */
+  statusLink?: string;
+
+  /**
+   * Optional reference to a Gerrit label (e.g. "Verified") that this result
+   * influences. Allows the user to understand and navigate the relationship
+   * between check runs/results and submit requirements,
+   * see also https://gerrit-review.googlesource.com/c/homepage/+/279176.
+   */
+  labelName?: string;
+
+  /**
+   * Optional callbacks to the plugin. Must be implemented individually by
+   * each plugin. The most important actions (which get special UI treatment)
+   * are:
+   * "Run" for RUNNABLE and COMPLETED runs.
+   * "Cancel" for RUNNING runs.
+   */
+  actions: Action[];
+
+  scheduledTimestamp?: Date;
+  startedTimestamp?: Date;
+  finishedTimestamp?: Date;
+
+  /**
+   * List of results produced by this run.
+   * RUNNABLE runs must not have results.
+   * RUNNING runs can contain (intermediate) results.
+   * Nesting the results in runs enforces that:
+   * - A run can have 0-n results.
+   * - A result is associated with exactly one run.
+   */
+  results: CheckResult[];
+}
+
+export interface Action {
+  name: string;
+  tooltip?: string;
+  /**
+   * Primary actions will get a more prominent treatment in the UI. For example
+   * primary actions might be rendered as buttons versus just menu entries in
+   * an overflow menu.
+   */
+  primary: boolean;
+  callback: ActionCallback;
+}
+
+export type ActionCallback = (
+  change: number,
+  patchset: number,
+  attempt: number,
+  externalId: string,
+  /** Identical to 'checkName' property of CheckRun. */
+  checkName: string,
+  /** Identical to 'name' property of Action entity. */
+  actionName: string
+) => Promise<ActionResult>;
+
+export interface ActionResult {
+  /** An empty errorMessage means success. */
+  errorMessage?: string;
+}
+
+export enum RunStatus {
+  RUNNABLE,
+  RUNNING,
+  COMPLETED,
+}
+
+export interface CheckResult {
+  /**
+   * An optional opaque identifier not used by Gerrit directly, but might be
+   * used by plugin extensions and callbacks.
+   */
+  externalId?: string;
+
+  /**
+   * INFO:    The user will typically not bother to look into this category,
+   *          only for looking up something that they are searching for. Can be
+   *          used for reporting secondary metrics and analysis, or a wider
+   *          range of artifacts produced by the checks system.
+   * WARNING: A warning is something that should be read before submitting the
+   *          change. The user should not ignore it, but it is also not blocking
+   *          submit. It has a similar level of importance as an unresolved
+   *          comment.
+   * ERROR:   An error indicates that the change must not or cannot be submitted
+   *          without fixing the problem. Errors will be visualized very
+   *          prominently to the user.
+   *
+   * The ‘tags’ field below can be used for further categorization, e.g. for
+   * distinguishing FAILED vs TIMED_OUT.
+   */
+  category: Category;
+
+  /**
+   * Short description of the check result.
+   *
+   * It should be expected that this string might be cut off at ~80 chars in the
+   * UI. The full description will then be shown in a tooltip.
+   * This is a plain string without styling or formatting options.
+   *
+   * Examples:
+   * MessageConverterTest failed with: 'kermit' expected, but got 'ernie'.
+   * Binary size of javascript bundle has increased by 27%.
+   */
+  summary: string;
+
+  /**
+   * Exhaustive optional message describing the check result.
+   * Will be initially collapsed. Might potentially be very long, e.g. a log of
+   * MB size. The UI is not limiting this. Data providing plugins are
+   * responsible for not killing the browser. :-)
+   *
+   * For now this is just a plain unformatted string. The only formatting
+   * applied is the one that Gerrit also applies to human comments. TBD: Both
+   * human comments and check result messages should get richer formatting
+   * options.
+   */
+  message?: string;
+
+  /**
+   * Tags allow a plugins to further categorize a result, e.g. making a list
+   * of results filterable by the end-user.
+   * The name is free-form, but there is a predefined set of TagColors to
+   * choose from with a recommendation of color for common tags, see below.
+   *
+   * Examples:
+   * PASS, FAIL, SCHEDULED, OBSOLETE, SKIPPED, TIMED_OUT, INFRA_ERROR, FLAKY
+   * WIN, MAC, LINUX
+   * BUILD, TEST, LINT
+   * INTEGRATION, E2E, SCREENSHOT
+   */
+  tags: Tag[];
+
+  /**
+   * Links provide an opportunity for the end-user to easily access details and
+   * artifacts. Links are displayed by an icon+tooltip only. They don’t have a
+   * name, making them clearly distinguishable from tags and actions.
+   *
+   * There is a fixed set of LinkIcons to choose from, see below.
+   *
+   * Examples:
+   * Link to test log.
+   * Link to result artifacts such as images and screenshots.
+   * Link to downloadable artifacts such as ZIP or APK files.
+   */
+  links: Link[];
+
+  /**
+   * Callbacks to the plugin. Must be implemented individually by each
+   * plugin. Actions are rendered as buttons. If there are more than two actions
+   * per result, then further actions are put into an overflow menu. Sort order
+   * is defined by the data provider.
+   *
+   * Examples:
+   * Acknowledge/Dismiss, Delete, Report a bug, Report as not useful,
+   * Make blocking, Downgrade severity.
+   */
+  actions: Action[];
+}
+
+export enum Category {
+  INFO,
+  WARNING,
+  ERROR,
+}
+
+export interface Tag {
+  name: string;
+  tooltip?: string;
+  color?: TagColor;
+}
+
+// TBD: Add more ...
+// TBD: Clarify standard colors for common tags.
+export enum TagColor {
+  GRAY,
+  GREEN,
+}
+
+export interface Link {
+  /** Must begin with 'http'. */
+  url: string;
+  tooltip?: string;
+  /**
+   * Primary links will get a more prominent treatment in the UI, e.g. being
+   * always visible in the results table or also showing up in the change page
+   * summary of checks.
+   */
+  primary: boolean;
+  icon: LinkIcon;
+}
+
+// TBD: Add more ...
+export enum LinkIcon {
+  EXTERNAL,
+  DOWNLOAD,
+}
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 bfcff33..50b9222 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
@@ -15,7 +15,60 @@
  * limitations under the License.
  */
 import {PluginApi} from '../gr-plugin-types';
+import {
+  ChecksApiConfig,
+  ChecksProvider,
+  GrChecksApiInterface,
+  ResponseCode,
+} from './gr-checks-api-types';
 
-export class GrChecksApi {
+const DEFAULT_CONFIG: ChecksApiConfig = {
+  fetchPollingIntervalSeconds: 60,
+};
+
+enum State {
+  NOT_REGISTERED,
+  REGISTERED,
+  FETCHING,
+}
+
+/**
+ * Plugin API for checks.
+ *
+ * This object is created/returned to plugins that want to provide check data.
+ * Plugins normally just call register() once at startup and then wait for
+ * fetch() being called on the provider interface.
+ */
+export class GrChecksApi implements GrChecksApiInterface {
+  private provider?: ChecksProvider;
+
+  config?: ChecksApiConfig;
+
+  private state = State.NOT_REGISTERED;
+
   constructor(readonly plugin: PluginApi) {}
+
+  announceUpdate() {
+    // TODO(brohlfs): Implement!
+  }
+
+  register(provider: ChecksProvider, config?: ChecksApiConfig): void {
+    if (this.state !== State.NOT_REGISTERED || this.provider)
+      throw new Error('Only one provider can be registered per plugin.');
+    this.state = State.REGISTERED;
+    this.provider = provider;
+    this.config = config ?? DEFAULT_CONFIG;
+  }
+
+  async fetch(change: number, patchset: number) {
+    if (this.state === State.NOT_REGISTERED || !this.provider)
+      throw new Error('Cannot fetch checks without a registered provider.');
+    if (this.state === State.FETCHING) return;
+    this.state = State.FETCHING;
+    const response = await this.provider.fetch(change, patchset);
+    this.state = State.REGISTERED;
+    if (response.responseCode === ResponseCode.OK) {
+      // TODO(brohlfs): Do something with the response.
+    }
+  }
 }
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 02fcdec..5a4d2ae 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
@@ -72,8 +72,9 @@
     const capture = options?.capture;
     const event = options?.event || 'click';
     const handler = (e: Event) => {
-      if (!e.path) return;
-      if (e.path.indexOf(this.element) !== -1) {
+      const path = e.composedPath();
+      if (!path) return;
+      if (path.indexOf(this.element) !== -1) {
         let mayContinue = true;
         try {
           mayContinue = callback(e);
diff --git a/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view.ts b/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view.ts
index 28cd672..891fdf6 100644
--- a/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view.ts
+++ b/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view.ts
@@ -32,6 +32,7 @@
   GroupInfo,
   ContributorAgreementInfo,
 } from '../../../types/common';
+import {fireAlert, fireTitleChange} from '../../../utils/event-util';
 
 export interface GrClaView {
   $: {
@@ -79,13 +80,7 @@
     super.attached();
     this.loadData();
 
-    this.dispatchEvent(
-      new CustomEvent('title-change', {
-        detail: {title: 'New Contributor Agreement'},
-        composed: true,
-        bubbles: true,
-      })
-    );
+    fireTitleChange(this, 'New Contributor Agreement');
   }
 
   loadData() {
@@ -156,13 +151,7 @@
   }
 
   _createToast(message: string) {
-    this.dispatchEvent(
-      new CustomEvent('show-alert', {
-        detail: {message},
-        bubbles: true,
-        composed: true,
-      })
-    );
+    fireAlert(this, message);
   }
 
   _computeShowAgreementsClass(showAgreements: boolean) {
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.ts b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.ts
index 9f9840b..4293a83 100644
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.ts
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.ts
@@ -68,6 +68,7 @@
 import {GerritView} from '../../core/gr-navigation/gr-navigation';
 import {GrEmailEditor} from '../gr-email-editor/gr-email-editor';
 import {CustomKeyboardEvent} from '../../../types/events';
+import {fireAlert, fireTitleChange} from '../../../utils/event-util';
 
 const PREFS_SECTION_FIELDS: Array<keyof PreferencesInput> = [
   'changes_per_page',
@@ -219,13 +220,7 @@
     // Polymer 2: anchor tag won't work on shadow DOM
     // we need to manually calling scrollIntoView when hash changed
     this.listen(window, 'location-change', '_handleLocationChange');
-    this.dispatchEvent(
-      new CustomEvent('title-change', {
-        detail: {title: 'Settings'},
-        composed: true,
-        bubbles: true,
-      })
-    );
+    fireTitleChange(this, 'Settings');
 
     this._isDark = !!window.localStorage.getItem('dark-theme');
 
@@ -286,13 +281,7 @@
       promises.push(
         this.$.restAPI.confirmEmail(this.params.emailToken).then(message => {
           if (message) {
-            this.dispatchEvent(
-              new CustomEvent('show-alert', {
-                detail: {message},
-                composed: true,
-                bubbles: true,
-              })
-            );
+            fireAlert(this, message);
           }
           this.$.emailEditor.loadData();
         })
@@ -542,15 +531,7 @@
       applyDarkTheme();
     }
     this._isDark = !!window.localStorage.getItem('dark-theme');
-    this.dispatchEvent(
-      new CustomEvent('show-alert', {
-        detail: {
-          message: `Theme changed to ${this._isDark ? 'dark' : 'light'}.`,
-        },
-        bubbles: true,
-        composed: true,
-      })
-    );
+    fireAlert(this, `Theme changed to ${this._isDark ? 'dark' : 'light'}.`);
   }
 
   _showHttpAuth(config?: ServerInfo) {
diff --git a/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list.ts b/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list.ts
index c5e71fc..5cc1240 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list.ts
+++ b/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list.ts
@@ -38,6 +38,7 @@
 import {GrAccountChip} from '../gr-account-chip/gr-account-chip';
 import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
 import {PaperInputElementExt} from '../../../types/types';
+import {fireAlert} from '../../../utils/event-util';
 
 const VALID_EMAIL_ALERT = 'Please input a valid email.';
 
@@ -256,13 +257,7 @@
         // Repopulate the input with what the user tried to enter and have
         // a toast tell them why they can't enter it.
         this.$.entry.setText(item);
-        this.dispatchEvent(
-          new CustomEvent('show-alert', {
-            detail: {message: VALID_EMAIL_ALERT},
-            bubbles: true,
-            composed: true,
-          })
-        );
+        fireAlert(this, VALID_EMAIL_ALERT);
         return false;
       } else {
         const account = {email: item, _pendingAdd: true};
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 668ea1b..45f30f5 100644
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.ts
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.ts
@@ -450,7 +450,7 @@
   }
 
   _handleBodyClick(e: Event) {
-    const eventPath = e.path;
+    const eventPath = e.composedPath();
     if (!eventPath) return;
     for (let i = 0; i < eventPath.length; i++) {
       if (eventPath[i] === this) {
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 878c01c..4bad587 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
@@ -50,6 +50,7 @@
 import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
 import {GrStorage, StorageLocation} from '../gr-storage/gr-storage';
 import {CustomKeyboardEvent} from '../../../types/events';
+import {LineNumber, FILE} from '../../diff/gr-diff/gr-diff-line';
 
 const UNRESOLVED_EXPAND_COUNT = 5;
 const NEWLINE_PATTERN = /\n/g;
@@ -88,7 +89,7 @@
    * diff widget like gr-diff to show the thread in the right location:
    *
    * line-num:
-   *     1-based line number or undefined if it refers to the entire file.
+   *     1-based line number or 'FILE' if it refers to the entire file.
    *
    * comment-side:
    *     "left" or "right". These indicate which of the two diffed versions
@@ -146,8 +147,8 @@
   @property({type: Boolean})
   showFilePath = false;
 
-  @property({type: Number, reflectToAttribute: true})
-  lineNum?: number;
+  @property({type: Object, reflectToAttribute: true})
+  lineNum?: LineNumber;
 
   @property({type: Boolean, notify: true, reflectToAttribute: true})
   unresolved?: boolean;
@@ -200,7 +201,7 @@
     this._setInitialExpandedState();
   }
 
-  addOrEditDraft(lineNum?: number, rangeParam?: CommentRange) {
+  addOrEditDraft(lineNum?: LineNumber, rangeParam?: CommentRange) {
     const lastComment = this.comments[this.comments.length - 1] || {};
     if (isDraft(lastComment)) {
       const commentEl = this._commentElWithDraftID(
@@ -223,7 +224,7 @@
     }
   }
 
-  addDraft(lineNum?: number, range?: CommentRange, unresolved?: boolean) {
+  addDraft(lineNum?: LineNumber, range?: CommentRange, unresolved?: boolean) {
     const draft = this._newDraft(lineNum, range);
     draft.__editing = true;
     draft.unresolved = unresolved === false ? unresolved : true;
@@ -272,7 +273,7 @@
         path,
         patchNum,
         undefined,
-        this.lineNum
+        this.lineNum === FILE ? undefined : this.lineNum
       );
     }
     const id = this.comments[0].id;
@@ -293,14 +294,14 @@
   }
 
   _computeDisplayLine() {
-    if (this.lineNum) return `#${this.lineNum}`;
-    // If range is set, then lineNum equals the end line of the range.
-    if (!this.lineNum && !this.range) {
+    if (this.lineNum === FILE) {
       if (this.path === SpecialFilePath.PATCHSET_LEVEL_COMMENTS) {
         return '';
       }
-      return 'FILE';
+      return FILE;
     }
+    if (this.lineNum) return `#${this.lineNum}`;
+    // If range is set, then lineNum equals the end line of the range.
     if (this.range) return `#${this.range.end_line}`;
     return '';
   }
@@ -490,7 +491,7 @@
     return d;
   }
 
-  _newDraft(lineNum?: number, range?: CommentRange) {
+  _newDraft(lineNum?: LineNumber, range?: CommentRange) {
     const d: UIDraft = {
       __draft: true,
       __draftID: Math.random().toString(36),
@@ -516,7 +517,7 @@
       d.side = this._getSide(this.isOnParent);
       d.__commentSide = this.commentSide;
 
-      if (lineNum) {
+      if (lineNum && lineNum !== FILE) {
         d.line = lineNum;
       }
       if (range) {
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 e4d520f..bba6bf1 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
@@ -60,6 +60,7 @@
   UIRobot,
 } from '../../../utils/comment-util';
 import {OpenFixPreviewEventDetail} from '../../../types/events';
+import {fireAlert} from '../../../utils/event-util';
 
 const STORAGE_DEBOUNCE_INTERVAL = 400;
 const TOAST_DEBOUNCE_INTERVAL = 200;
@@ -850,13 +851,7 @@
         // 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
         // case the event would not bubble.
-        document.body.dispatchEvent(
-          new CustomEvent('show-alert', {
-            detail: {message},
-            bubbles: true,
-            composed: true,
-          })
-        );
+        fireAlert(document.body, message);
       },
       TOAST_DEBOUNCE_INTERVAL
     );
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_html.ts b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_html.ts
index c021461..c3125c1 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_html.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_html.ts
@@ -249,12 +249,17 @@
   <div id="container" class="container">
     <div class="header" id="header" on-click="_handleToggleCollapsed">
       <div class="headerLeft">
-        <gr-account-label
-          account="[[_getAuthor(comment, _selfAccount)]]"
-          class$="[[_computeAccountLabelClass(draft)]]"
-          hide-status=""
-        >
-        </gr-account-label>
+        <template is="dom-if" if="[[comment.robot_id]]">
+          <span class="robotName"> [[comment.robot_id]] </span>
+        </template>
+        <template is="dom-if" if="[[!comment.robot_id]]">
+          <gr-account-label
+            account="[[_getAuthor(comment, _selfAccount)]]"
+            class$="[[_computeAccountLabelClass(draft)]]"
+            hide-status=""
+          >
+          </gr-account-label>
+        </template>
         <gr-tooltip-content
           class="draftTooltip"
           has-tooltip=""
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.js b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.js
index 10925af..e8d74f0 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.js
@@ -695,9 +695,8 @@
         assert.isTrue(runDetailsLink.href.indexOf(element.comment.url) !== -1);
 
         const robotServiceName = element.shadowRoot
-            .querySelector('gr-account-label')
-            .shadowRoot.querySelector('span.name');
-        assert.equal(robotServiceName.textContent.trim(), 'Display name Robot');
+            .querySelector('.robotName');
+        assert.equal(robotServiceName.textContent.trim(), 'happy_robot_id');
 
         const authorName = element.shadowRoot
             .querySelector('.robotId');
diff --git a/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.ts b/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.ts
index 352bc58..2fbbd7c 100644
--- a/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.ts
+++ b/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.ts
@@ -261,14 +261,14 @@
     this._targetHeight = null;
   }
 
-  isAtStart() {
-    return this.index === 0;
+  /** Returns true if there are no stops, or we are on the first stop. */
+  isAtStart(): boolean {
+    return this.stops.length === 0 || this.index === 0;
   }
 
-  isAtEnd() {
-    // Unset cursor should not be considered "at end", even when there are no
-    // cursor stops.
-    return this.index !== -1 && this.index === this.stops.length - 1;
+  /** Returns true if there are no stops, or we are on the last stop. */
+  isAtEnd(): boolean {
+    return this.stops.length === 0 || this.index === this.stops.length - 1;
   }
 
   moveToStart() {
diff --git a/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager_test.js b/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager_test.js
index 33aeafc..6f74d6b 100644
--- a/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager_test.js
@@ -117,6 +117,16 @@
     assert.equal(element.index, -1);
   });
 
+  test('isAtStart() returns true when there are no stops', () => {
+    element.stops = [];
+    assert.isTrue(element.isAtStart());
+  });
+
+  test('isAtEnd() returns true when there are no stops', () => {
+    element.stops = [];
+    assert.isTrue(element.isAtEnd());
+  });
+
   test('next() goes to first element when no cursor is set', () => {
     element.stops = [...list.querySelectorAll('li')];
     const result = element.next();
@@ -137,8 +147,6 @@
     assert.equal(element.index, -1);
     assert.isNotOk(element.target);
     assert.isFalse(list.children[1].classList.contains('targeted'));
-    assert.isFalse(element.isAtStart());
-    assert.isFalse(element.isAtEnd());
   });
 
   test('previous() goes to last element when no cursor is set', () => {
@@ -162,8 +170,6 @@
     assert.equal(element.index, -1);
     assert.isNotOk(element.target);
     assert.isFalse(list.children[1].classList.contains('targeted'));
-    assert.isFalse(element.isAtStart());
-    assert.isFalse(element.isAtEnd());
   });
 
   test('_moveCursor', () => {
diff --git a/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter.ts b/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter.ts
index c64dc2a..05e9a73 100644
--- a/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter.ts
+++ b/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter.ts
@@ -30,6 +30,7 @@
   isWithinHalfYear,
   formatDate,
   utcOffsetString,
+  wasYesterday,
 } from '../../../utils/date-util';
 import {TimeFormat, DateFormat} from '../../../constants/constants';
 import {assertNever} from '../../../utils/common-util';
@@ -104,6 +105,9 @@
   @property({type: Boolean})
   hasTooltip = false;
 
+  @property({type: Boolean})
+  showYesterday = false;
+
   /**
    * The title to be used as the native tooltip or by the tooltip behavior.
    */
@@ -222,7 +226,8 @@
     timeFormat?: string,
     dateFormat?: DateFormatPair,
     relative?: boolean,
-    showDateAndTime?: boolean
+    showDateAndTime?: boolean,
+    showYesterday?: boolean
   ) {
     if (!dateStr || !timeFormat || !dateFormat) {
       return '';
@@ -238,6 +243,8 @@
     let format = dateFormat.full;
     if (isWithinDay(now, date)) {
       format = timeFormat;
+    } else if (showYesterday && wasYesterday(now, date)) {
+      return `Yesterday at ${formatDate(date, timeFormat)}`;
     } else {
       if (isWithinHalfYear(now, date)) {
         format = dateFormat.short;
diff --git a/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter_html.ts b/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter_html.ts
index a5dd6d0..a08a77d 100644
--- a/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter_html.ts
+++ b/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter_html.ts
@@ -25,7 +25,7 @@
   </style>
   <span>
     [[_computeDateStr(dateStr, _timeFormat, _dateFormat, _relative,
-    showDateAndTime)]]
+    showDateAndTime, showYesterday)]]
   </span>
   <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
 `;
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 90aaa9f..9c9363b 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
@@ -24,6 +24,7 @@
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {customElement, property} from '@polymer/decorators';
 import {htmlTemplate} from './gr-editable-content_html';
+import {fireAlert} from '../../../utils/event-util';
 
 const RESTORED_MESSAGE = 'Content restored from a previous edit.';
 const STORAGE_DEBOUNCE_INTERVAL_MS = 400;
@@ -149,13 +150,7 @@
       );
       if (storedContent?.message) {
         content = storedContent.message;
-        this.dispatchEvent(
-          new CustomEvent('show-alert', {
-            detail: {message: RESTORED_MESSAGE},
-            bubbles: true,
-            composed: true,
-          })
-        );
+        fireAlert(this, RESTORED_MESSAGE);
       }
     }
     if (!content) {
diff --git a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account.ts b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account.ts
index da2881e..0b4e577 100644
--- a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account.ts
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account.ts
@@ -46,6 +46,7 @@
 } from '../../../utils/attention-set-util';
 import {ReviewerState} from '../../../constants/constants';
 import {isRemovableReviewer} from '../../../utils/change-util';
+import {CURRENT} from '../../../utils/patch-set-util';
 
 export interface GrHovercardAccount {
   $: {
@@ -186,7 +187,7 @@
     ];
 
     this.$.restAPI
-      .saveChangeReview(this.change._number, 'current', reviewInput)
+      .saveChangeReview(this.change._number, CURRENT, reviewInput)
       .then(response => {
         if (!response || !response.ok) {
           throw new Error(
diff --git a/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.ts b/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.ts
index 7112e3b..1170477 100644
--- a/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.ts
+++ b/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.ts
@@ -112,6 +112,8 @@
       <g id="schedule"><path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm.5-13H11v6l5.25 3.15.75-1.23-4.5-2.67z"></path></g>
       <!-- This SVG is a copy from material.io https://material.io/icons/#bug_report-->
       <g id="bug"><path d="M0 0h24v24H0z" fill="none"/><path d="M20 8h-2.81c-.45-.78-1.07-1.45-1.82-1.96L17 4.41 15.59 3l-2.17 2.17C12.96 5.06 12.49 5 12 5c-.49 0-.96.06-1.41.17L8.41 3 7 4.41l1.62 1.63C7.88 6.55 7.26 7.22 6.81 8H4v2h2.09c-.05.33-.09.66-.09 1v1H4v2h2v1c0 .34.04.67.09 1H4v2h2.81c1.04 1.79 2.97 3 5.19 3s4.15-1.21 5.19-3H20v-2h-2.09c.05-.33.09-.66.09-1v-1h2v-2h-2v-1c0-.34-.04-.67-.09-1H20V8zm-6 8h-4v-2h4v2zm0-4h-4v-2h4v2z"/></g>
+      <!-- This SVG is a copy from material.io https://fonts.gstatic.com/s/i/googlematerialicons/move_item/v1/24px.svg -->
+      <g id="move-item"><path d="M15,19H5V5h10v4h2V5c0-1.1-0.89-2-2-2H5C3.9,3,3,3.9,3,5v14c0,1.1,0.9,2,2,2h10c1.11,0,2-0.9,2-2v-4h-2V19z"/><polygon points="20.01,8.01 18.59,9.41 20.17,11 8,11 8,13 20.17,13 18.59,14.59 20.01,15.99 24,12"/></g>
     </defs>
   </svg>
 </iron-iconset-svg>`;
diff --git a/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.ts b/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.ts
index 957496c..cb25b81 100644
--- a/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.ts
+++ b/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.ts
@@ -22,6 +22,7 @@
 import {IronOverlayMixin} from '../../../mixins/iron-overlay-mixin/iron-overlay-mixin';
 import {customElement, property} from '@polymer/decorators';
 import {IronOverlayBehavior} from '@polymer/iron-overlay-behavior/iron-overlay-behavior';
+import {findActiveElement} from '../../../utils/dom-util';
 
 const AWAIT_MAX_ITERS = 10;
 const AWAIT_STEP = 5;
@@ -59,7 +60,9 @@
 
   private _boundHandleClose: () => void = () => super.close();
 
-  private focusableNodes: Node[] | undefined;
+  private focusableNodes?: Node[];
+
+  private returnFocusTo?: HTMLElement;
 
   get _focusableNodes() {
     if (this.focusableNodes) {
@@ -89,6 +92,7 @@
   }
 
   open() {
+    this.returnFocusTo = findActiveElement(document, true) ?? undefined;
     window.addEventListener('popstate', this._boundHandleClose);
     return new Promise((resolve, reject) => {
       super.open.apply(this);
@@ -121,6 +125,10 @@
       );
       this._fullScreenOpen = false;
     }
+    if (this.returnFocusTo) {
+      this.returnFocusTo.focus();
+      this.returnFocusTo = undefined;
+    }
   }
 
   /**
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 0629457..5ffdfe2 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
@@ -157,6 +157,7 @@
   HttpMethod,
   ReviewerState,
 } from '../../../constants/constants';
+import {firePageError} from '../../../utils/event-util';
 
 const JSON_PREFIX = ")]}'";
 const MAX_PROJECT_RESULTS = 25;
@@ -253,7 +254,6 @@
 
 interface GetDiffParams {
   [paramName: string]: string | undefined | null | number | boolean;
-  context?: number | 'ALL';
   intraline?: boolean | null;
   whitespace?: IgnoreWhitespaceType;
   parent?: number;
@@ -1424,9 +1424,7 @@
       ListChangesOption.LABELS,
       ListChangesOption.DETAILED_ACCOUNTS,
     ];
-    if (config?.change && config.change.enable_attention_set) {
-      options.push(ListChangesOption.DETAILED_LABELS);
-    } else {
+    if (!config?.change?.enable_attention_set) {
       options.push(ListChangesOption.REVIEWED);
     }
 
@@ -2525,7 +2523,6 @@
     errFn?: ErrorCallback
   ) {
     const params: GetDiffParams = {
-      context: 'ALL',
       intraline: null,
       whitespace: whitespace || 'IGNORE_NONE',
     };
@@ -3086,10 +3083,7 @@
     });
   }
 
-  setChangeTopic(
-    changeNum: NumericChangeId,
-    topic: string | null
-  ): Promise<string> {
+  setChangeTopic(changeNum: NumericChangeId, topic?: string): Promise<string> {
     return (this._getChangeURLAndSend({
       changeNum,
       method: HttpMethod.PUT,
@@ -3398,16 +3392,8 @@
       return Promise.resolve(project);
     }
 
-    const onError = (response?: Response | null) => {
-      // Fire a page error so that the visual 404 is displayed.
-      this.dispatchEvent(
-        new CustomEvent('page-error', {
-          detail: {response},
-          composed: true,
-          bubbles: true,
-        })
-      );
-    };
+    const onError = (response?: Response | null) =>
+      firePageError(this, response);
 
     return this.getChange(changeNum, onError).then(change => {
       if (!change || !change.project) {
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface_test.js b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface_test.js
index d75c186..b86fcff 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface_test.js
@@ -22,6 +22,7 @@
 import {ListChangesOption} from '../../../utils/change-util.js';
 import {appContext} from '../../../services/app-context.js';
 import {createChange} from '../../../test/test-data-generators.js';
+import {CURRENT} from '../../../utils/patch-set-util.js';
 
 const basicFixture = fixtureFromElement('gr-rest-api-interface');
 
@@ -1350,7 +1351,7 @@
     sinon.stub(element._restApiHelper, 'fetchJSON').returns(Promise.resolve({
       ok: false}));
 
-    element.getPortedComments(change._number, 'current');
+    element.getPortedComments(change._number, CURRENT);
 
     assert.isFalse(dispatchStub.called);
   });
@@ -1361,7 +1362,7 @@
     const getChangeURLAndFetchStub = sinon.stub(element,
         '_getChangeURLAndFetch');
 
-    element.getPortedDrafts(change._number, 'current');
+    element.getPortedDrafts(change._number, CURRENT);
 
     assert.isFalse(getChangeURLAndFetchStub.called);
   });
diff --git a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts
index b3c0a85..b0b40dd 100644
--- a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts
+++ b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts
@@ -38,32 +38,23 @@
   {value: '😊', match: 'smile :)'},
   {value: '👍', match: 'thumbs up'},
   {value: '😄', match: 'laugh :D'},
-  {value: '🎉', match: 'party'},
-  {value: '😞', match: 'sad :('},
+  {value: '❤️', match: 'heart <3'},
   {value: '😂', match: "tears :')"},
-  {value: '🙏', match: 'pray'},
+  {value: '🎉', match: 'party'},
+  {value: '😎', match: 'cool |;)'},
+  {value: '😞', match: 'sad :('},
   {value: '😐', match: 'neutral :|'},
   {value: '😮', match: 'shock :O'},
-  {value: '👎', match: 'thumbs down'},
-  {value: '😎', match: 'cool |;)'},
+  {value: '🙏', match: 'pray'},
   {value: '😕', match: 'confused'},
   {value: '👌', match: 'ok'},
   {value: '🔥', match: 'fire'},
-  {value: '👊', match: 'fistbump'},
   {value: '💯', match: '100'},
-  {value: '💔', match: 'broken heart'},
-  {value: '🍺', match: 'beer'},
   {value: '✔', match: 'check'},
   {value: '😋', match: 'tongue'},
   {value: '😭', match: "crying :'("},
-  {value: '🐨', match: 'koala'},
   {value: '🤓', match: 'glasses'},
-  {value: '😆', match: 'grin'},
-  {value: '💩', match: 'poop'},
   {value: '😢', match: 'tear'},
-  {value: '😒', match: 'unamused'},
-  {value: '😉', match: 'wink ;)'},
-  {value: '🍷', match: 'wine'},
   {value: '😜', match: 'winking tongue ;)'},
 ];
 
diff --git a/polygerrit-ui/app/mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.ts b/polygerrit-ui/app/mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.ts
index 7aade93..9744bc9 100644
--- a/polygerrit-ui/app/mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.ts
+++ b/polygerrit-ui/app/mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.ts
@@ -129,7 +129,7 @@
 export enum ShortcutSection {
   ACTIONS = 'Actions',
   DIFFS = 'Diffs',
-  EVERYWHERE = 'Everywhere',
+  EVERYWHERE = 'Global Shortcuts',
   FILE_LIST = 'File list',
   NAVIGATION = 'Navigation',
   REPLY_DIALOG = 'Reply dialog',
diff --git a/polygerrit-ui/app/services/flags/flags.ts b/polygerrit-ui/app/services/flags/flags.ts
index d6b2237..ba33954 100644
--- a/polygerrit-ui/app/services/flags/flags.ts
+++ b/polygerrit-ui/app/services/flags/flags.ts
@@ -26,4 +26,6 @@
 export enum KnownExperimentId {
   PATCHSET_COMMENTS = 'UiFeature__patchset_comments',
   NEW_CONTEXT_CONTROLS = 'UiFeature__new_context_controls',
+  CI_REBOOT_CHECKS = 'UiFeature__ci_reboot_checks',
+  NEW_CHANGE_SUMMARY_UI = 'UiFeature__new_change_summary_ui',
 }
diff --git a/polygerrit-ui/app/services/services/gr-rest-api/gr-rest-api.ts b/polygerrit-ui/app/services/services/gr-rest-api/gr-rest-api.ts
index 19a8dc5..3688e44 100644
--- a/polygerrit-ui/app/services/services/gr-rest-api/gr-rest-api.ts
+++ b/polygerrit-ui/app/services/services/gr-rest-api/gr-rest-api.ts
@@ -828,10 +828,7 @@
     hashtag: HashtagsInput
   ): Promise<Hashtag[]>;
 
-  setChangeTopic(
-    changeNum: NumericChangeId,
-    topic: string | null
-  ): Promise<string>;
+  setChangeTopic(changeNum: NumericChangeId, topic?: string): Promise<string>;
 
   getChangeFiles(
     changeNum: NumericChangeId,
diff --git a/polygerrit-ui/app/styles/themes/app-theme.ts b/polygerrit-ui/app/styles/themes/app-theme.ts
index d7b96c8..73dfe5c 100644
--- a/polygerrit-ui/app/styles/themes/app-theme.ts
+++ b/polygerrit-ui/app/styles/themes/app-theme.ts
@@ -174,7 +174,10 @@
     --diff-trailing-whitespace-indicator: #ff9ad2;
     --light-add-highlight-color: #d8fed8;
     --light-rebased-add-highlight-color: #eef;
-    --light-moved-add-highlight-color: #eef;
+    --diff-moved-in-background: #e4f7fb;
+    --diff-moved-out-background: #f3e8fd;
+    --diff-moved-in-label-background: #007b83;
+    --diff-moved-out-label-background: #681da8;
     --light-remove-add-highlight-color: #fff8dc;
     --light-remove-highlight-color: #ffebee;
     --coverage-covered: #e0f2f1;
diff --git a/polygerrit-ui/app/styles/themes/dark-theme.ts b/polygerrit-ui/app/styles/themes/dark-theme.ts
index 4d3e6d8..c7c8eb1 100644
--- a/polygerrit-ui/app/styles/themes/dark-theme.ts
+++ b/polygerrit-ui/app/styles/themes/dark-theme.ts
@@ -125,7 +125,10 @@
       --diff-trailing-whitespace-indicator: #ff9ad2;
       --light-add-highlight-color: #0f401f;
       --light-rebased-add-highlight-color: #487165;
-      --light-moved-add-highlight-color: #487165;
+      --diff-moved-in-background: #006066;
+      --diff-moved-out-background: #681da8;
+      --diff-moved-in-label-background: #cbf0f8;
+      --diff-moved-out-label-background: #e9d2fd;
       --light-remove-add-highlight-color: #2f3f2f;
       --light-remove-highlight-color: #320404;
       --coverage-covered: #112826;
diff --git a/polygerrit-ui/app/types/diff.d.ts b/polygerrit-ui/app/types/diff.d.ts
index b45b461..ed03dcf 100644
--- a/polygerrit-ui/app/types/diff.d.ts
+++ b/polygerrit-ui/app/types/diff.d.ts
@@ -107,25 +107,12 @@
   edit_b?: DiffIntralineInfo[];
   /** Indicates whether this entry was introduced by a rebase. */
   due_to_rebase?: boolean;
-  /** @deprecated Use move_details instead. */
-  due_to_move?: boolean;
 
   /**
    * Provides info about a move operation the chunk.
    * It's presence indicates the current chunk exists due to a move.
    */
-  move_details?: {
-    /** Indicates whether the content of the chunk changes while moving code */
-    changed: boolean;
-    /**
-     * Indicates the range (line numbers) on the other side of the comparison
-     * where the code related to the current chunk came from/went to.
-     */
-    range: {
-      start: number;
-      end: number;
-    };
-  };
+  move_details?: MoveDetails;
   /**
    * Count of lines skipped on both sides when the file is too large to include
    * all common lines.
@@ -142,6 +129,22 @@
 }
 
 /**
+ * Details about move operation related to a specific chunk.
+ */
+export declare interface MoveDetails {
+  /** Indicates whether the content of the chunk changes while moving code */
+  changed: boolean;
+  /**
+   * Indicates the range (line numbers) on the other side of the comparison
+   * where the code related to the current chunk came from/went to.
+   */
+  range: {
+    start: number;
+    end: number;
+  };
+}
+
+/**
  * The DiffWebLinkInfo entity describes a link on a diff screen to an external
  * site.
  * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#diff-web-link-info
diff --git a/polygerrit-ui/app/types/globals.ts b/polygerrit-ui/app/types/globals.ts
index 28cac87..628cee4 100644
--- a/polygerrit-ui/app/types/globals.ts
+++ b/polygerrit-ui/app/types/globals.ts
@@ -36,12 +36,7 @@
       Auth?: unknown;
       _pluginLoader?: unknown;
       _endpoints?: unknown;
-      slotToContent?: unknown;
-      rangesEqual?: unknown;
-      SUGGESTIONS_PROVIDERS_USERS_TYPES?: unknown;
       RevisionInfo?: unknown;
-      CoverageType?: unknown;
-      hiddenscroll?: unknown;
       flushPreinstalls?: () => void;
     };
     // TODO(TS): define polymer type
@@ -69,49 +64,16 @@
     // TODO(TS): should clean up those and removing them may break certain plugin behaviors
     // TODO(TS): as @brohlfs suggested, to avoid importing anything from elements/ to types/
     // use any for them for now
-    GrDisplayNameUtils: unknown;
     GrAnnotation: unknown;
-    GrAttributeHelper: unknown;
     GrDiffLine: unknown;
     GrDiffLineType: unknown;
     GrDiffGroup: unknown;
     GrDiffGroupType: unknown;
-    GrDiffBuilder: unknown;
-    GrDiffBuilderSideBySide: unknown;
-    GrDiffBuilderImage: unknown;
-    GrDiffBuilderUnified: unknown;
-    GrDiffBuilderBinary: unknown;
-    GrChangeActionsInterface: unknown;
-    GrChangeReplyInterface: unknown;
-    GrEditConstants: unknown;
-    GrDomHooksManager: unknown;
-    GrDomHook: unknown;
-    GrEtagDecorator: unknown;
-    GrThemeApi: unknown;
-    SiteBasedCache: unknown;
-    FetchPromisesCache: unknown;
-    GrRestApiHelper: unknown;
-    GrLinkTextParser: unknown;
-    GrPluginEndpoints: unknown;
-    GrReviewerUpdatesParser: unknown;
-    GrPopupInterface: unknown;
-    GrCountStringFormatter: unknown;
-    GrReviewerSuggestionsProvider: unknown;
     util: unknown;
     Auth: unknown;
     EventEmitter: unknown;
-    GrAdminApi: unknown;
-    GrAnnotationActionsContext: unknown;
-    GrAnnotationActionsInterface: unknown;
-    GrChangeMetadataApi: unknown;
-    GrEmailSuggestionsProvider: unknown;
-    GrGroupSuggestionsProvider: unknown;
-    GrEventHelper: unknown;
-    GrPluginRestApi: unknown;
-    GrRepoApi: unknown;
-    GrSettingsApi: unknown;
-    GrStylesApi: unknown;
     PluginLoader: unknown;
+    // Heads up! There is a known plugin dependency on GrPluginActionContext.
     GrPluginActionContext: unknown;
     _apiUtils: {};
   }
@@ -126,13 +88,6 @@
     };
   }
 
-  interface Event {
-    // path is a non-standard property. Actually, this is optional property,
-    // but marking it as optional breaks CustomKeyboardEvent
-    // TODO(TS): replace with composedPath if possible
-    readonly path: EventTarget[];
-  }
-
   interface Error {
     lineNumber?: number; // non-standard property
     columnNumber?: number; // non-standard property
diff --git a/polygerrit-ui/app/utils/change-metadata-util.ts b/polygerrit-ui/app/utils/change-metadata-util.ts
new file mode 100644
index 0000000..6ce1483
--- /dev/null
+++ b/polygerrit-ui/app/utils/change-metadata-util.ts
@@ -0,0 +1,75 @@
+/**
+ * @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 {ParsedChangeInfo} from '../elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser';
+
+export enum Metadata {
+  OWNER = 'Owner',
+  REVIEWERS = 'Reviewers',
+  REPO_BRANCH = 'Repo | Branch',
+  SUBMITTED = 'Submitted',
+  PARENT = 'Parent',
+  STRATEGY = 'Strategy',
+  UPDATED = 'Updated',
+  CC = 'CC',
+  HASHTAGS = 'Hashtags',
+  TOPIC = 'Topic',
+  UPLOADER = 'Uploader',
+  AUTHOR = 'Author',
+  COMMITTER = 'Committer',
+  ASSIGNEE = 'Assignee',
+  CHERRY_PICK_OF = 'Cherry pick of',
+}
+
+export const DisplayRules = {
+  ALWAYS_SHOW: [
+    Metadata.OWNER,
+    Metadata.REVIEWERS,
+    Metadata.REPO_BRANCH,
+    Metadata.SUBMITTED,
+  ],
+  SHOW_IF_SET: [
+    Metadata.CC,
+    Metadata.HASHTAGS,
+    Metadata.TOPIC,
+    Metadata.UPLOADER,
+    Metadata.AUTHOR,
+    Metadata.COMMITTER,
+    Metadata.ASSIGNEE,
+    Metadata.CHERRY_PICK_OF,
+  ],
+  ALWAYS_HIDE: [Metadata.PARENT, Metadata.STRATEGY, Metadata.UPDATED],
+};
+
+export function isSectionSet(section: Metadata, change?: ParsedChangeInfo) {
+  switch (section) {
+    case Metadata.CC:
+      return !!change?.reviewers?.CC?.length;
+    case Metadata.HASHTAGS:
+      return !!change?.hashtags?.length;
+    case Metadata.TOPIC:
+      return !!change?.topic;
+    case Metadata.UPLOADER:
+    case Metadata.AUTHOR:
+    case Metadata.COMMITTER:
+    case Metadata.ASSIGNEE:
+      return false;
+    case Metadata.CHERRY_PICK_OF:
+      return !!change?.cherry_pick_of_change;
+  }
+  return true;
+}
diff --git a/polygerrit-ui/app/utils/comment-util.ts b/polygerrit-ui/app/utils/comment-util.ts
index 5f8aa82..5af9bb7 100644
--- a/polygerrit-ui/app/utils/comment-util.ts
+++ b/polygerrit-ui/app/utils/comment-util.ts
@@ -21,9 +21,12 @@
   RobotCommentInfo,
   Timestamp,
   UrlEncodedCommentId,
+  CommentRange,
 } from '../types/common';
 import {CommentSide, Side} from '../constants/constants';
 import {parseDate} from './date-util';
+import {LineNumber} from '../elements/diff/gr-diff/gr-diff-line';
+import {CommentIdToCommentThreadMap} from '../elements/diff/gr-comment-api/gr-comment-api';
 
 export interface DraftCommentProps {
   __draft?: boolean;
@@ -97,17 +100,57 @@
   });
 }
 
+export function createCommentThreads(comments: UIComment[]) {
+  const sortedComments = sortComments(comments);
+  const threads: CommentThread[] = [];
+  const idThreadMap: CommentIdToCommentThreadMap = {};
+  for (const comment of sortedComments) {
+    if (!comment.id) continue;
+    // If the comment is in reply to another comment, find that comment's
+    // thread and append to it.
+    if (comment.in_reply_to) {
+      const thread = idThreadMap[comment.in_reply_to];
+      if (thread) {
+        thread.comments.push(comment);
+        idThreadMap[comment.id] = thread;
+        continue;
+      }
+    }
+
+    // Otherwise, this comment starts its own thread.
+    if (!comment.__path && !comment.path) {
+      throw new Error('Comment missing required "path".');
+    }
+    const newThread: CommentThread = {
+      comments: [comment],
+      patchNum: comment.patch_set,
+      commentSide: comment.side ?? CommentSide.REVISION,
+      path: comment.__path || comment.path!,
+      line: comment.line,
+      range: comment.range,
+      rootId: comment.id,
+      diffSide: comment.__commentSide,
+    };
+    if (!comment.line && !comment.range) {
+      newThread.line = 'FILE';
+    }
+    threads.push(newThread);
+    idThreadMap[comment.id] = newThread;
+  }
+  return threads;
+}
+
 export interface CommentThread {
   comments: UIComment[];
-  patchNum?: PatchSetNum;
   path: string;
-  // TODO(TS): It would be nice to use LineNumber here, but the comment thread
-  // element actually relies on line to be undefined for file comments. Be
-  // aware of element attribute getters and setters, if you try to refactor
-  // this. :-) Still worthwhile to do ...
-  line?: number;
-  rootId: UrlEncodedCommentId;
-  commentSide?: CommentSide;
+  commentSide: CommentSide;
+  patchNum?: PatchSetNum;
+  line?: LineNumber;
+  /* rootId is optional since we create a empty comment thread element for
+     drafts and then create the draft which becomes the root */
+  rootId?: UrlEncodedCommentId;
+  diffSide?: Side;
+  range?: CommentRange;
 }
 
 export function getLastComment(thread?: CommentThread): UIComment | undefined {
diff --git a/polygerrit-ui/app/utils/date-util.ts b/polygerrit-ui/app/utils/date-util.ts
index 1dd2d2f..5101d11 100644
--- a/polygerrit-ui/app/utils/date-util.ts
+++ b/polygerrit-ui/app/utils/date-util.ts
@@ -69,6 +69,16 @@
   return diff < Duration.DAY && date.getDay() === now.getDay();
 }
 
+export function wasYesterday(now: Date, date: Date) {
+  const diff = now.valueOf() - date.valueOf();
+  // return true if date is withing 24 hours and not on the same day
+  if (diff < Duration.DAY && date.getDay() !== now.getDay()) return true;
+
+  // move now to yesterday
+  now.setDate(now.getDate() - 1);
+  return isWithinDay(now, date);
+}
+
 /**
  * Returns true if date is from one to six months.
  */
diff --git a/polygerrit-ui/app/utils/date-util_test.js b/polygerrit-ui/app/utils/date-util_test.js
index a003c65..76d8516 100644
--- a/polygerrit-ui/app/utils/date-util_test.js
+++ b/polygerrit-ui/app/utils/date-util_test.js
@@ -15,7 +15,7 @@
  * limitations under the License.
  */
 import '../test/common-test-setup-karma.js';
-import {isValidDate, parseDate, fromNow, isWithinDay, isWithinHalfYear, formatDate} from './date-util.js';
+import {isValidDate, parseDate, fromNow, isWithinDay, isWithinHalfYear, formatDate, wasYesterday} from './date-util.js';
 
 suite('date-util tests', () => {
   suite('parseDate', () => {
@@ -64,6 +64,21 @@
     });
   });
 
+  suite('wasYesterday', () => {
+    test('less 24 hours', () => {
+      assert.isFalse(wasYesterday(new Date('May 08 2020 12:00:00'),
+          new Date('May 08 2020 02:00:00')));
+      assert.isTrue(wasYesterday(new Date('May 08 2020 12:00:00'),
+          new Date('May 07 2020 12:00:00')));
+    });
+    test('more 24 hours', () => {
+      assert.isTrue(wasYesterday(new Date('May 08 2020 12:00:00'),
+          new Date('May 07 2020 2:00:00')));
+      assert.isFalse(wasYesterday(new Date('May 08 2020 12:00:00'),
+          new Date('May 06 2020 14:00:00')));
+    });
+  });
+
   suite('isWithinHalfYear', () => {
     test('basics works', () => {
       assert.isTrue(isWithinHalfYear(new Date('May 08 2020 12:00:00'),
diff --git a/polygerrit-ui/app/utils/dom-util.ts b/polygerrit-ui/app/utils/dom-util.ts
index 364112b..ca5de4d 100644
--- a/polygerrit-ui/app/utils/dom-util.ts
+++ b/polygerrit-ui/app/utils/dom-util.ts
@@ -167,7 +167,7 @@
 export function getEventPath<T extends PolymerEvent>(e?: T) {
   if (!e) return '';
 
-  let path = e.path;
+  let path = e.composedPath();
   if (!path || !path.length) {
     path = [];
     let el = e.target;
@@ -234,3 +234,32 @@
   }
   return _sharedApiEl;
 }
+
+// document.activeElement is not enough, because it's not getting activeElement
+// without looking inside of shadow roots. This will find best activeElement.
+export function findActiveElement(
+  root: DocumentOrShadowRoot | null,
+  ignoreDialogs?: boolean
+): HTMLElement | null {
+  if (root === null) {
+    return null;
+  }
+  if (
+    ignoreDialogs &&
+    root.activeElement &&
+    root.activeElement.nodeName.toUpperCase().includes('DIALOG')
+  ) {
+    return null;
+  }
+  if (root.activeElement?.shadowRoot?.activeElement) {
+    return findActiveElement(root.activeElement.shadowRoot);
+  }
+  if (!root.activeElement) {
+    return null;
+  }
+  // We block some elements
+  if ('BODY' === root.activeElement.nodeName.toUpperCase()) {
+    return null;
+  }
+  return root.activeElement as HTMLElement;
+}
diff --git a/polygerrit-ui/app/utils/dom-util_test.js b/polygerrit-ui/app/utils/dom-util_test.js
index e2d61ed..bcb4505 100644
--- a/polygerrit-ui/app/utils/dom-util_test.js
+++ b/polygerrit-ui/app/utils/dom-util_test.js
@@ -55,13 +55,13 @@
       assert.equal(getEventPath(), '');
       assert.equal(getEventPath(null), '');
       assert.equal(getEventPath(undefined), '');
-      assert.equal(getEventPath({}), '');
+      assert.equal(getEventPath({composedPath: () => []}), '');
     });
 
     test('event with fake path', () => {
-      assert.equal(getEventPath({path: []}), '');
+      assert.equal(getEventPath({composedPath: () => []}), '');
       const dd = document.createElement('dd');
-      assert.equal(getEventPath({path: [dd]}), 'dd');
+      assert.equal(getEventPath({composedPath: () => [dd]}), 'dd');
     });
 
     test('event with fake complicated path', () => {
@@ -72,7 +72,7 @@
       divNode.id = 'test2';
       divNode.className = 'a b c';
       assert.equal(getEventPath(
-          {path: [dd, divNode]}),
+          {composedPath: () => [dd, divNode]}),
       'div#test2.a.b.c>dd#test.a.b'
       );
     });
@@ -88,7 +88,7 @@
       const fakeTarget = document.createElement('SPAN');
       fakeTargetParent1.appendChild(fakeTarget);
       assert.equal(
-          getEventPath({target: fakeTarget}),
+          getEventPath({composedPath: () => {}, target: fakeTarget}),
           'div#test2.a.b.c>dd#test.a.b>span'
       );
     });
diff --git a/polygerrit-ui/app/utils/event-util.ts b/polygerrit-ui/app/utils/event-util.ts
new file mode 100644
index 0000000..0af8fe2
--- /dev/null
+++ b/polygerrit-ui/app/utils/event-util.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.
+ */
+
+export enum EventType {
+  SHOW_ALERT = 'show-alert',
+  PAGE_ERROR = 'page-error',
+  TITLE_CHANGE = 'title-change',
+}
+
+export function fireAlert(target: EventTarget, message: string) {
+  target.dispatchEvent(
+    new CustomEvent(EventType.SHOW_ALERT, {
+      detail: {message},
+      composed: true,
+      bubbles: true,
+    })
+  );
+}
+
+export function firePageError(target: EventTarget, response?: Response | null) {
+  target.dispatchEvent(
+    new CustomEvent(EventType.PAGE_ERROR, {
+      detail: {response},
+      composed: true,
+      bubbles: true,
+    })
+  );
+}
+
+export function fireTitleChange(target: EventTarget, title: string) {
+  target.dispatchEvent(
+    new CustomEvent(EventType.TITLE_CHANGE, {
+      detail: {title},
+      composed: true,
+      bubbles: true,
+    })
+  );
+}
diff --git a/polygerrit-ui/app/utils/patch-set-util.ts b/polygerrit-ui/app/utils/patch-set-util.ts
index 8974af8..d063168 100644
--- a/polygerrit-ui/app/utils/patch-set-util.ts
+++ b/polygerrit-ui/app/utils/patch-set-util.ts
@@ -45,6 +45,8 @@
   PARENT: 'PARENT',
 };
 
+export const CURRENT = 'current';
+
 export interface PatchSet {
   num: PatchSetNum;
   desc: string | undefined;
diff --git a/polygerrit-ui/server.go b/polygerrit-ui/server.go
index eaf2017..e6487a7 100644
--- a/polygerrit-ui/server.go
+++ b/polygerrit-ui/server.go
@@ -200,8 +200,8 @@
 		data = moduleImportRegexp.ReplaceAll(data, []byte("${1}tslib/tslib.es6.js';"))
 
 		// 'lit-element' imports and exports have to be resolved to 'lit-element/lit-element.js'.
-		moduleImportRegexp = regexp.MustCompile("(?m)^((import|export).*'/node_modules/)lit-element.js';$")
-		data = moduleImportRegexp.ReplaceAll(data, []byte("${1}lit-element/lit-element.js';"))
+		moduleImportRegexp = regexp.MustCompile("(?m)^((import|export).*'/node_modules/)lit-(element|html).js';$")
+		data = moduleImportRegexp.ReplaceAll(data, []byte("${1}lit-${3}/lit-${3}.js';"))
 
 		if strings.HasSuffix(normalizedContentPath, "/node_modules/page/page.js") {
 			// Can't import page.js directly, because this is undefined.
diff --git a/resources/com/google/gerrit/pgm/init/gerrit.sh b/resources/com/google/gerrit/pgm/init/gerrit.sh
index ce858d5..87a6c05 100755
--- a/resources/com/google/gerrit/pgm/init/gerrit.sh
+++ b/resources/com/google/gerrit/pgm/init/gerrit.sh
@@ -296,6 +296,11 @@
 GERRIT_FDS=`expr $FDS_MULTIPLIER \* $GERRIT_FDS`
 test $GERRIT_FDS -lt 1024 && GERRIT_FDS=1024
 
+CACHE_FDS=`get_config --get cache.openFiles`
+if test -n "$CACHE_FDS"; then
+  GERRIT_FDS=`expr $CACHE_FDS \+ $GERRIT_FDS`
+fi
+
 GERRIT_STARTUP_TIMEOUT=`get_config --get container.startupTimeout`
 test -z "$GERRIT_STARTUP_TIMEOUT" && GERRIT_STARTUP_TIMEOUT=90  # seconds
 
diff --git a/tools/BUILD b/tools/BUILD
index be12735..545a206 100644
--- a/tools/BUILD
+++ b/tools/BUILD
@@ -44,7 +44,6 @@
     header_compiler_direct = ["@bazel_tools//tools/jdk:turbine_direct"],
     ijar = ["@bazel_tools//tools/jdk:ijar"],
     javabuilder = ["@bazel_tools//tools/jdk:javabuilder"],
-    javac = ["@bazel_tools//tools/jdk:javac_jar"],
     javac_supports_workers = True,
     jvm_opts = JDK11_JVM_OPTS,
     misc = [
@@ -60,6 +59,7 @@
     target_version = "11",
     tools = [
         "@bazel_tools//tools/jdk:java_compiler_jar",
+        "@bazel_tools//tools/jdk:javac_jar",
         "@bazel_tools//tools/jdk:jdk_compiler_jar",
     ],
     visibility = ["//visibility:public"],
diff --git a/tools/nongoogle.bzl b/tools/nongoogle.bzl
index a3cc66e..4c65588 100644
--- a/tools/nongoogle.bzl
+++ b/tools/nongoogle.bzl
@@ -1,5 +1,9 @@
 load("//tools/bzl:maven_jar.bzl", "maven_jar")
 
+GUAVA_VERSION = "29.0-jre"
+GUAVA_BIN_SHA1 = "801142b4c3d0f0770dd29abea50906cacfddd447"
+GUAVA_DOC_URL = "https://google.github.io/guava/releases/" + GUAVA_VERSION + "/api/docs/"
+
 def declare_nongoogle_deps():
     """loads dependencies that are not used at Google.
 
@@ -23,8 +27,8 @@
 
     maven_jar(
         name = "dropwizard-core",
-        artifact = "io.dropwizard.metrics:metrics-core:4.1.14",
-        sha1 = "14cf9dd67619a0390812dddb232df339e3383d35",
+        artifact = "io.dropwizard.metrics:metrics-core:4.1.12.1",
+        sha1 = "cb2f351bf4463751201f43bb99865235d5ba07ca",
     )
 
     SSHD_VERS = "2.4.0"
@@ -129,6 +133,12 @@
         sha1 = "b66d3bedb14da604828a8693bb24fd78e36b0e9e",
     )
 
+    maven_jar(
+        name = "guava",
+        artifact = "com.google.guava:guava:" + GUAVA_VERSION,
+        sha1 = GUAVA_BIN_SHA1,
+    )
+
     # Test-only dependencies below.
 
     maven_jar(
@@ -143,18 +153,40 @@
         sha1 = "dc13ae4faca6df981fc7aeb5a522d9db446d5d50",
     )
 
-    TESTCONTAINERS_VERSION = "1.14.3"
+    DOCKER_JAVA_VERS = "3.2.5"
+
+    maven_jar(
+        name = "docker-java-api",
+        artifact = "com.github.docker-java:docker-java-api:" + DOCKER_JAVA_VERS,
+        sha1 = "8fe5c5e39f940ce58620e77cedc0a2a52d76f9d8",
+    )
+
+    maven_jar(
+        name = "docker-java-transport",
+        artifact = "com.github.docker-java:docker-java-transport:" + DOCKER_JAVA_VERS,
+        sha1 = "27af0ee7ebc2f5672e23ea64769497b5d55ce3ac",
+    )
+
+    # https://github.com/docker-java/docker-java/blob/3.2.5/pom.xml#L61
+    # <=> DOCKER_JAVA_VERS
+    maven_jar(
+        name = "jackson-annotations",
+        artifact = "com.fasterxml.jackson.core:jackson-annotations:2.10.3",
+        sha1 = "0f63b3b1da563767d04d2e4d3fc1ae0cdeffebe7",
+    )
+
+    TESTCONTAINERS_VERSION = "1.15.0"
 
     maven_jar(
         name = "testcontainers",
         artifact = "org.testcontainers:testcontainers:" + TESTCONTAINERS_VERSION,
-        sha1 = "071fc82ba663f469447a19434e7db90f3a872753",
+        sha1 = "b627535b444d88e7b14953bb953d80d9b7b3bd76",
     )
 
     maven_jar(
         name = "testcontainers-elasticsearch",
         artifact = "org.testcontainers:elasticsearch:" + TESTCONTAINERS_VERSION,
-        sha1 = "3709e2ebb0b6aa4e2ba2b6ca92ffdd3bf637a86c",
+        sha1 = "2bd79fd915e5c7bcf9b5d86cd8e0b7a0fff4b8ce",
     )
 
     maven_jar(