Merge "Compute known groups lazily when writing groups to the trace"
diff --git a/.bazelrc b/.bazelrc
index bf3aa6c..b9189c1 100644
--- a/.bazelrc
+++ b/.bazelrc
@@ -2,7 +2,7 @@
 build --repository_cache=~/.gerritcodereview/bazel-cache/repository
 build --action_env=PATH
 build --disk_cache=~/.gerritcodereview/bazel-cache/cas
-build --java_toolchain //tools:error_prone_warnings_toolchain
+build --java_toolchain=//tools:error_prone_warnings_toolchain_java11
 
 # Enable strict_action_env flag to. For more information on this feature see
 # https://groups.google.com/forum/#!topic/bazel-discuss/_VmRfMyyHBk.
@@ -13,5 +13,6 @@
 
 test --build_tests_only
 test --test_output=errors
+test --java_toolchain=//tools:error_prone_warnings_toolchain_java11
 
 import %workspace%/tools/remote-bazelrc
diff --git a/.bazelversion b/.bazelversion
index 1545d96..d5c0c99 100644
--- a/.bazelversion
+++ b/.bazelversion
@@ -1 +1 @@
-3.5.0
+3.5.1
diff --git a/Documentation/config-gerrit.txt b/Documentation/config-gerrit.txt
index 879ec99..05370cc 100644
--- a/Documentation/config-gerrit.txt
+++ b/Documentation/config-gerrit.txt
@@ -973,6 +973,11 @@
 be expensive to compute (60 or more seconds for a large history
 like the Linux kernel repository).
 
+cache `"comment_context"`::
++
+Caches the context lines of comments, which are the lines of the source file
+highlighted by the user when the comment was written.
+
 cache `"groups"`::
 +
 Caches the basic group information of internal groups by group ID,
diff --git a/Documentation/dev-bazel.txt b/Documentation/dev-bazel.txt
index ee2f4a1..086e836 100644
--- a/Documentation/dev-bazel.txt
+++ b/Documentation/dev-bazel.txt
@@ -18,7 +18,7 @@
 To build Gerrit from source, you need:
 
 * A Linux or macOS system (Windows is not supported at this time)
-* A JDK for Java 8|9|10|11|...
+* A JDK for Java 8|11|...
 * Python 2 or 3
 * link:https://github.com/nodesource/distributions/blob/master/README.md[Node.js (including npm),role=external,window=_blank]
 * Bower (`npm install -g bower`)
@@ -54,6 +54,27 @@
 
 `java -version`
 
+[[java-8]]
+==== Java 8 support (deprecated)
+
+Java 8 is a legacy Java release and support for Java 8 will be discontinued
+in future gerrit releases. To build Gerrit with Java 8 language level, run:
+
+```
+  $ bazel build --java_toolchain //tools:error_prone_warnings_toolchain_java8
+        :release
+```
+
+[[java-11]]
+==== Java 11 support
+
+Java language level 11 is the default. To build Gerrit with Java 11 language
+level, run:
+
+```
+  $ bazel build :release
+```
+
 [[java-13]]
 ==== Java 13 support
 
@@ -101,22 +122,6 @@
 Now, invoking Bazel with just `bazel build :release` would include
 all those options.
 
-[[java-11]]
-==== Java 11 support
-
-Java 11 is supported through alternative java toolchain
-link:https://docs.bazel.build/versions/master/toolchains.html[Bazel option].
-To build Gerrit with Java 11, specify JDK 11 java toolchain:
-
-```
-  $ bazel build \
-      --host_javabase=@bazel_tools//tools/jdk:remote_jdk11 \
-      --javabase=@bazel_tools//tools/jdk:remote_jdk11 \
-      --host_java_toolchain=@bazel_tools//tools/jdk:toolchain_java11 \
-      --java_toolchain=@bazel_tools//tools/jdk:toolchain_java11 \
-      :release
-```
-
 === Node.js and npm packages
 See link:https://gerrit.googlesource.com/gerrit/+/master/polygerrit-ui/README.md#installing-node_js-and-npm-packages[Installing Node.js and npm packages,role=external,window=_blank].
 
diff --git a/Documentation/dev-eclipse.txt b/Documentation/dev-eclipse.txt
index 742cf42..bbe227a 100644
--- a/Documentation/dev-eclipse.txt
+++ b/Documentation/dev-eclipse.txt
@@ -4,7 +4,8 @@
 This document is about configuring Gerrit Code Review into an
 Eclipse workspace for development.
 
-Java 8 or later SDK is required.
+Java 11 or later SDK is required.
+Otherwise, java 8 can still be used for now as described below.
 
 [[setup]]
 == Project Setup
@@ -30,6 +31,10 @@
 ----
 
 First, generate the Eclipse project by running the `tools/eclipse/project.py` script.
+If running Eclipse on Java 8, add the extra parameter
+`-e='--java_toolchain=//tools:error_prone_warnings_toolchain'`
+for generating a compatible project.
+
 Then, in Eclipse, choose 'Import existing project' and select the `gerrit` project
 from the current working directory.
 
@@ -79,15 +84,16 @@
 link:dev-build-plugins.html#_bundle_custom_plugin_in_release_war[bundling in release.war]
 and run `tools/eclipse/project.py`.
 
-[[Newer Java versions]]
+== Java Versions
 
-Java 9 and later are supported, but some adjustments must be done, because
-Java 8 is still the default:
+Java 11 is supported as a default, but some adjustments must be done for other JDKs:
 
 * Add JRE, e.g.: directory: /usr/lib64/jvm/java-9-openjdk, name: java-9-openjdk-9
 * Change execution environment for gerrit project to: JavaSE-9 (java-9-openjdk-9)
 * Check that compiler compliance level in gerrit project is set to: 9
 
+Moreover, the actual java 11 language features are not supported yet.
+
 [[Formatting]]
 == Code Formatter Settings
 
diff --git a/Documentation/dev-intellij.txt b/Documentation/dev-intellij.txt
index b67d546..149b14a 100644
--- a/Documentation/dev-intellij.txt
+++ b/Documentation/dev-intellij.txt
@@ -9,7 +9,7 @@
 <<dev-bazel#installation,Building with Bazel - Installation>>.
 
 It's strongly recommended to verify you can build your Gerrit tree with Bazel
-for Java 8 from the command line first. Ensure that at least
+for Java 11 from the command line first. Ensure that at least
 `bazel build gerrit` runs successfully before you proceed.
 
 === IntelliJ version and Bazel plugin
@@ -21,12 +21,12 @@
 Also note that the version of the Bazel plugin used in turn may or may not be
 compatible with the Bazel version used.
 
-In addition, Java 8 must be specified on your path or via `JAVA_HOME` so that
+In addition, Java 11 must be specified on your path or via `JAVA_HOME` so that
 building with Bazel via the Bazel plugin is possible.
 
 TIP: If the synchronization of the project with the BUILD files using the Bazel
 plugin fails and IntelliJ reports the error **Could not get Bazel roots**, this
-indicates that the Bazel plugin couldn't find Java 8.
+indicates that the Bazel plugin couldn't find Java 11.
 
 === Installation of IntelliJ IDEA
 
diff --git a/Documentation/licenses.txt b/Documentation/licenses.txt
index 98e99d4..605b493 100644
--- a/Documentation/licenses.txt
+++ b/Documentation/licenses.txt
@@ -44,6 +44,7 @@
 
 * auto:auto-value
 * auto:auto-value-annotations
+* auto:auto-value-gson
 * commons:codec
 * commons:compress
 * commons:dbcp
diff --git a/Documentation/rest-api-accounts.txt b/Documentation/rest-api-accounts.txt
index 32f8656..2a59d0c 100644
--- a/Documentation/rest-api-accounts.txt
+++ b/Documentation/rest-api-accounts.txt
@@ -2807,9 +2807,11 @@
 |`email_strategy`               ||
 The type of email strategy to use. On `ENABLED`, the user will receive emails
 from Gerrit. On `CC_ON_OWN_COMMENTS` the user will also receive emails for
-their own comments. On `DISABLED` the user will not receive any email
-notifications from Gerrit.
-Allowed values are `ENABLED`, `CC_ON_OWN_COMMENTS`, `DISABLED`.
+their own comments. On `ATTENTION_SET_ONLY`, on emails about changes, the user
+will receive emails only if they are in the attention set of that change.
+On `DISABLED` the user will not receive any email notifications from Gerrit.
+Allowed values are `ENABLED`, `CC_ON_OWN_COMMENTS`, `ATTENTION_SET_ONLY`,
+`DISABLED`.
 |`default_base_for_merges`      ||
 The base which should be pre-selected in the 'Diff Against' drop-down
 list when the change screen is opened for a merge commit.
@@ -2870,9 +2872,11 @@
 |`email_strategy`               |optional|
 The type of email strategy to use. On `ENABLED`, the user will receive emails
 from Gerrit. On `CC_ON_OWN_COMMENTS` the user will also receive emails for
-their own comments. On `DISABLED` the user will not receive any email
-notifications from Gerrit.
-Allowed values are `ENABLED`, `CC_ON_OWN_COMMENTS`, `DISABLED`.
+their own comments. On `ATTENTION_SET_ONLY`, on emails about changes, the user
+will receive emails only if they are in the attention set of that change.
+On `DISABLED` the user will not receive any email notifications from Gerrit.
+Allowed values are `ENABLED`, `CC_ON_OWN_COMMENTS`, `ATTENTION_SET_ONLY`,
+`DISABLED`.
 |`default_base_for_merges`      |optional|
 The base which should be pre-selected in the 'Diff Against' drop-down
 list when the change screen is opened for a merge commit.
diff --git a/Documentation/rest-api-changes.txt b/Documentation/rest-api-changes.txt
index 8bd02a8..6b73482 100644
--- a/Documentation/rest-api-changes.txt
+++ b/Documentation/rest-api-changes.txt
@@ -7325,6 +7325,13 @@
 |`merge`              ||
 The detail of the source commit for merge as a link:#merge-input[MergeInput]
 entity.
+|`author`             |optional|
+An link:rest-api-accounts.html#account-input[AccountInput] entity
+that will set the author of the commit to create. The author must be
+specified as name/email combination.
+The caller needs "Forge Author" permission when using this field.
+This field does not affect the owner of the change, which will
+continue to use the identity of the caller.
 |==================================
 
 [[move-input]]
diff --git a/Documentation/rest-api-projects.txt b/Documentation/rest-api-projects.txt
index 92759b6..d34ccb4 100644
--- a/Documentation/rest-api-projects.txt
+++ b/Documentation/rest-api-projects.txt
@@ -3393,6 +3393,8 @@
 |`status`                    ||The HTTP status code for the access.
 200 means success and 403 means denied.
 |`message`                   |optional|A clarifying message if `status` is not 200.
+|`debug_logs`                |optional|
+Debug logs that may help to understand why a permission is denied or allowed.
 |=========================================
 
 [[auto_closeable_changes_check_input]]
diff --git a/WORKSPACE b/WORKSPACE
index 4c2fe35..48fa1d6 100644
--- a/WORKSPACE
+++ b/WORKSPACE
@@ -640,6 +640,38 @@
     sha1 = "eff48ed53995db2dadf0456426cc1f8700136f86",
 )
 
+AUTO_VALUE_GSON_VERSION = "1.3.0"
+
+maven_jar(
+    name = "auto-value-gson-runtime",
+    artifact = "com.ryanharter.auto.value:auto-value-gson-runtime:" + AUTO_VALUE_GSON_VERSION,
+    sha1 = "a69a9db5868bb039bd80f60661a771b643eaba59",
+)
+
+maven_jar(
+    name = "auto-value-gson-extension",
+    artifact = "com.ryanharter.auto.value:auto-value-gson-extension:" + AUTO_VALUE_GSON_VERSION,
+    sha1 = "6a61236d17b58b05e32b4c532bcb348280d2212b",
+)
+
+maven_jar(
+    name = "auto-value-gson-factory",
+    artifact = "com.ryanharter.auto.value:auto-value-gson-factory:" + AUTO_VALUE_GSON_VERSION,
+    sha1 = "b1f01918c0d6cb1f5482500e6b9e62589334dbb0",
+)
+
+maven_jar(
+    name = "javapoet",
+    artifact = "com.squareup:javapoet:1.13.0",
+    sha1 = "d6562d385049f35eb50403fa86bb11cce76b866a",
+)
+
+maven_jar(
+    name = "autotransient",
+    artifact = "io.sweers.autotransient:autotransient:1.0.0",
+    sha1 = "38b1c630b8e76560221622289f37be40105abb3d",
+)
+
 declare_nongoogle_deps()
 
 LUCENE_VERS = "6.6.5"
diff --git a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
index 78a621c..c35c9c2 100644
--- a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
+++ b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
@@ -388,6 +388,15 @@
     initSsh();
   }
 
+  protected void restart() throws Exception {
+    server = GerritServer.restart(server, createModule(), createSshModule());
+    server.getTestInjector().injectMembers(this);
+    if (resetter != null) {
+      server.getTestInjector().injectMembers(resetter);
+    }
+    initSsh();
+  }
+
   protected void reindexAccount(Account.Id accountId) {
     accountIndexer.index(accountId);
   }
@@ -430,15 +439,18 @@
     baseConfig.setInt("receive", null, "changeUpdateThreads", 4);
     Module module = createModule();
     Module auditModule = createAuditModule();
+    Module sshModule = createSshModule();
     if (classDesc.equals(methodDesc) && !classDesc.sandboxed() && !methodDesc.sandboxed()) {
       if (commonServer == null) {
         commonServer =
-            GerritServer.initAndStart(temporaryFolder, classDesc, baseConfig, module, auditModule);
+            GerritServer.initAndStart(
+                temporaryFolder, classDesc, baseConfig, module, auditModule, sshModule);
       }
       server = commonServer;
     } else {
       server =
-          GerritServer.initAndStart(temporaryFolder, methodDesc, baseConfig, module, auditModule);
+          GerritServer.initAndStart(
+              temporaryFolder, methodDesc, baseConfig, module, auditModule, sshModule);
     }
 
     server.getTestInjector().injectMembers(this);
@@ -536,6 +548,11 @@
     return null;
   }
 
+  /** Override to bind an additional Guice module for SSH injector */
+  public Module createSshModule() {
+    return null;
+  }
+
   protected void initSsh() throws Exception {
     if (testRequiresSsh
         && SshMode.useSsh()
diff --git a/java/com/google/gerrit/acceptance/GerritServer.java b/java/com/google/gerrit/acceptance/GerritServer.java
index 5942c0f..0025396 100644
--- a/java/com/google/gerrit/acceptance/GerritServer.java
+++ b/java/com/google/gerrit/acceptance/GerritServer.java
@@ -323,6 +323,7 @@
    * @param desc server description.
    * @param baseConfig default config values; merged with config from {@code desc}.
    * @param testSysModule additional Guice module to use.
+   * @param testSshModule additional Guice module to use.
    * @return started server.
    * @throws Exception
    */
@@ -331,14 +332,15 @@
       Description desc,
       Config baseConfig,
       @Nullable Module testSysModule,
-      @Nullable Module testAuditModule)
+      @Nullable Module testAuditModule,
+      @Nullable Module testSshModule)
       throws Exception {
     Path site = temporaryFolder.newFolder().toPath();
     try {
       if (!desc.memory()) {
         init(desc, baseConfig, site);
       }
-      return start(desc, baseConfig, site, testSysModule, testAuditModule, null);
+      return start(desc, baseConfig, site, testSysModule, testAuditModule, testSshModule, null);
     } catch (Exception e) {
       throw e;
     }
@@ -354,6 +356,7 @@
    *     initialize this directory. Can be retrieved from the returned instance via {@link
    *     #getSitePath()}.
    * @param testSysModule optional additional module to add to the system injector.
+   * @param testSshModule optional additional module to add to the ssh injector.
    * @param inMemoryRepoManager {@link InMemoryRepositoryManager} that should be used if the site is
    *     started in memory
    * @param additionalArgs additional command-line arguments for the daemon program; only allowed if
@@ -367,6 +370,7 @@
       Path site,
       @Nullable Module testSysModule,
       @Nullable Module testAuditModule,
+      @Nullable Module testSshModule,
       @Nullable InMemoryRepositoryManager inMemoryRepoManager,
       String... additionalArgs)
       throws Exception {
@@ -390,6 +394,9 @@
     if (testSysModule != null) {
       daemon.addAdditionalSysModuleForTesting(testSysModule);
     }
+    if (testSshModule != null) {
+      daemon.addAdditionalSshModuleForTesting(testSshModule);
+    }
     daemon.setEnableSshd(desc.useSsh());
 
     if (desc.memory()) {
@@ -614,7 +621,24 @@
 
     server.close();
     server.daemon.stop();
-    return start(server.desc, cfg, site, null, null, inMemoryRepoManager);
+    return start(server.desc, cfg, site, null, null, null, inMemoryRepoManager);
+  }
+
+  public static GerritServer restart(
+      GerritServer server, @Nullable Module testSysModule, @Nullable Module testSshModule)
+      throws Exception {
+    checkState(server.desc.sandboxed(), "restarting as slave requires @Sandboxed");
+    Config cfg = server.testInjector.getInstance(Key.get(Config.class, GerritServerConfig.class));
+    Path site = server.testInjector.getInstance(Key.get(Path.class, SitePath.class));
+
+    InMemoryRepositoryManager inMemoryRepoManager = null;
+    if (hasBinding(server.testInjector, InMemoryRepositoryManager.class)) {
+      inMemoryRepoManager = server.testInjector.getInstance(InMemoryRepositoryManager.class);
+    }
+
+    server.close();
+    server.daemon.stop();
+    return start(server.desc, cfg, site, testSysModule, null, testSshModule, inMemoryRepoManager);
   }
 
   private static boolean hasBinding(Injector injector, Class<?> clazz) {
diff --git a/java/com/google/gerrit/acceptance/SshSession.java b/java/com/google/gerrit/acceptance/SshSession.java
index 6ecf85f..6698657 100644
--- a/java/com/google/gerrit/acceptance/SshSession.java
+++ b/java/com/google/gerrit/acceptance/SshSession.java
@@ -65,6 +65,22 @@
     }
   }
 
+  @SuppressWarnings("resource")
+  public int execAndReturnStatus(String command) throws Exception {
+    ChannelExec channel = (ChannelExec) getSession().openChannel("exec");
+    try {
+      channel.setCommand(command);
+      InputStream err = channel.getErrStream();
+      channel.connect();
+
+      Scanner s = new Scanner(err, UTF_8.name()).useDelimiter("\\A");
+      error = s.hasNext() ? s.next() : null;
+      return channel.getExitStatus();
+    } finally {
+      channel.disconnect();
+    }
+  }
+
   private boolean hasError() {
     return error != null;
   }
diff --git a/java/com/google/gerrit/acceptance/StandaloneSiteTest.java b/java/com/google/gerrit/acceptance/StandaloneSiteTest.java
index 43fe4eb..dcb49a5 100644
--- a/java/com/google/gerrit/acceptance/StandaloneSiteTest.java
+++ b/java/com/google/gerrit/acceptance/StandaloneSiteTest.java
@@ -187,7 +187,14 @@
   private GerritServer startImpl(@Nullable Module testSysModule, String... additionalArgs)
       throws Exception {
     return GerritServer.start(
-        serverDesc, baseConfig, sitePaths.site_path, testSysModule, null, null, additionalArgs);
+        serverDesc,
+        baseConfig,
+        sitePaths.site_path,
+        testSysModule,
+        null,
+        null,
+        null,
+        additionalArgs);
   }
 
   protected static void runGerrit(String... args) throws Exception {
diff --git a/java/com/google/gerrit/acceptance/ssh/GracefulCommand.java b/java/com/google/gerrit/acceptance/ssh/GracefulCommand.java
new file mode 100644
index 0000000..ddaf341
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/ssh/GracefulCommand.java
@@ -0,0 +1,31 @@
+// 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.sshd.CommandMetaData.Mode.MASTER_OR_SLAVE;
+
+import com.google.gerrit.sshd.CommandMetaData;
+
+@CommandMetaData(
+    name = "graceful",
+    description = "Test command for graceful shutdown",
+    runsAt = MASTER_OR_SLAVE)
+public class GracefulCommand extends TestCommand {
+
+  @Override
+  boolean isGraceful() {
+    return true;
+  }
+}
diff --git a/java/com/google/gerrit/acceptance/ssh/NonGracefulCommand.java b/java/com/google/gerrit/acceptance/ssh/NonGracefulCommand.java
new file mode 100644
index 0000000..ed635c8
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/ssh/NonGracefulCommand.java
@@ -0,0 +1,31 @@
+// 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.sshd.CommandMetaData.Mode.MASTER_OR_SLAVE;
+
+import com.google.gerrit.sshd.CommandMetaData;
+
+@CommandMetaData(
+    name = "non-graceful",
+    description = "Test command for immediate shutdown",
+    runsAt = MASTER_OR_SLAVE)
+public class NonGracefulCommand extends TestCommand {
+
+  @Override
+  boolean isGraceful() {
+    return false;
+  }
+}
diff --git a/java/com/google/gerrit/acceptance/ssh/TestCommand.java b/java/com/google/gerrit/acceptance/ssh/TestCommand.java
new file mode 100644
index 0000000..7839578
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/ssh/TestCommand.java
@@ -0,0 +1,49 @@
+// 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 com.google.common.flogger.FluentLogger;
+import com.google.gerrit.sshd.SshCommand;
+import java.util.concurrent.CyclicBarrier;
+import org.kohsuke.args4j.Option;
+
+public abstract class TestCommand extends SshCommand {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+  public static final CyclicBarrier syncPoint = new CyclicBarrier(2);
+
+  @Option(
+      name = "--duration",
+      aliases = {"-d"},
+      required = true,
+      usage = "Duration of the command execution in seconds")
+  private int duration;
+
+  @Override
+  protected void run() throws UnloggedFailure, Failure, Exception {
+    logger.atFine().log("Starting command.");
+    if (isGraceful()) {
+      enableGracefulStop();
+    }
+    try {
+      syncPoint.await();
+      Thread.sleep(duration * 1000);
+      logger.atFine().log("Stopping command.");
+    } catch (Exception e) {
+      throw die("Command ended prematurely.", e);
+    }
+  }
+
+  abstract boolean isGraceful();
+}
diff --git a/java/com/google/gerrit/acceptance/ssh/TestSshCommandModule.java b/java/com/google/gerrit/acceptance/ssh/TestSshCommandModule.java
new file mode 100644
index 0000000..626092b
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/ssh/TestSshCommandModule.java
@@ -0,0 +1,25 @@
+// 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 com.google.gerrit.sshd.CommandModule;
+
+public class TestSshCommandModule extends CommandModule {
+  @Override
+  protected void configure() {
+    command("graceful").to(GracefulCommand.class);
+    command("non-graceful").to(NonGracefulCommand.class);
+  }
+}
diff --git a/java/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImpl.java b/java/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImpl.java
index e4d594b..0138290 100644
--- a/java/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImpl.java
+++ b/java/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImpl.java
@@ -90,6 +90,7 @@
 
     CreateProjectArgs args = new CreateProjectArgs();
     args.setProjectName(name);
+    args.permissionsOnly = projectCreation.permissionOnly().orElse(false);
     args.branch =
         projectCreation.branches().stream().map(RefNames::fullName).collect(toImmutableList());
     args.createEmptyCommit = projectCreation.createEmptyCommit().orElse(true);
diff --git a/java/com/google/gerrit/acceptance/testsuite/project/TestProjectCreation.java b/java/com/google/gerrit/acceptance/testsuite/project/TestProjectCreation.java
index 2649dea..00759a0 100644
--- a/java/com/google/gerrit/acceptance/testsuite/project/TestProjectCreation.java
+++ b/java/com/google/gerrit/acceptance/testsuite/project/TestProjectCreation.java
@@ -35,6 +35,8 @@
 
   public abstract Optional<Boolean> createEmptyCommit();
 
+  public abstract Optional<Boolean> permissionOnly();
+
   public abstract Optional<SubmitType> submitType();
 
   abstract ThrowingFunction<TestProjectCreation, Project.NameKey> projectCreator();
@@ -67,6 +69,8 @@
 
     public abstract TestProjectCreation.Builder createEmptyCommit(boolean value);
 
+    public abstract TestProjectCreation.Builder permissionOnly(boolean value);
+
     /** Skips the empty commit on creation. This means that project's branches will not exist. */
     public TestProjectCreation.Builder noEmptyCommit() {
       return createEmptyCommit(false);
diff --git a/java/com/google/gerrit/entities/BUILD b/java/com/google/gerrit/entities/BUILD
index 66d1869..c0f5de6 100644
--- a/java/com/google/gerrit/entities/BUILD
+++ b/java/com/google/gerrit/entities/BUILD
@@ -10,11 +10,13 @@
     deps = [
         "//java/com/google/gerrit/common:annotations",
         "//java/com/google/gerrit/extensions:api",
+        "//lib:gson",
         "//lib:guava",
         "//lib:jgit",
         "//lib:protobuf",
         "//lib/auto:auto-value",
         "//lib/auto:auto-value-annotations",
+        "//lib/auto:auto-value-gson",
         "//lib/errorprone:annotations",
         "//lib/flogger:api",
         "//proto:cache_java_proto",
diff --git a/java/com/google/gerrit/entities/Change.java b/java/com/google/gerrit/entities/Change.java
index 845a9bb..aab72ea72 100644
--- a/java/com/google/gerrit/entities/Change.java
+++ b/java/com/google/gerrit/entities/Change.java
@@ -21,6 +21,9 @@
 import com.google.common.primitives.Ints;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.client.ChangeStatus;
+import com.google.gson.Gson;
+import com.google.gson.TypeAdapter;
+import com.google.gson.annotations.SerializedName;
 import java.sql.Timestamp;
 import java.util.Arrays;
 import java.util.Optional;
@@ -283,6 +286,7 @@
       return Change.key(KeyUtil.decode(str));
     }
 
+    @SerializedName("id")
     abstract String key();
 
     public String get() {
@@ -307,6 +311,10 @@
     public final String toString() {
       return get();
     }
+
+    public static TypeAdapter<Key> typeAdapter(Gson gson) {
+      return new AutoValue_Change_Key.GsonTypeAdapter(gson);
+    }
   }
 
   /** Minimum database status constant for an open change. */
diff --git a/java/com/google/gerrit/entities/CommentContext.java b/java/com/google/gerrit/entities/CommentContext.java
new file mode 100644
index 0000000..183f6d0
--- /dev/null
+++ b/java/com/google/gerrit/entities/CommentContext.java
@@ -0,0 +1,29 @@
+// 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.entities;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableMap;
+
+/** An entity class representing all context lines of a comment. */
+@AutoValue
+public abstract class CommentContext {
+  public static CommentContext create(ImmutableMap<Integer, String> lines) {
+    return new AutoValue_CommentContext(lines);
+  }
+
+  /** Map of {line number, line text} of the context lines of a comment */
+  public abstract ImmutableMap<Integer, String> lines();
+}
diff --git a/java/com/google/gerrit/entities/EntitiesAdapterFactory.java b/java/com/google/gerrit/entities/EntitiesAdapterFactory.java
new file mode 100644
index 0000000..e6a06fd
--- /dev/null
+++ b/java/com/google/gerrit/entities/EntitiesAdapterFactory.java
@@ -0,0 +1,25 @@
+// 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.entities;
+
+import com.google.gson.TypeAdapterFactory;
+import com.ryanharter.auto.value.gson.GsonTypeAdapterFactory;
+
+@GsonTypeAdapterFactory
+public abstract class EntitiesAdapterFactory implements TypeAdapterFactory {
+  public static TypeAdapterFactory create() {
+    return new AutoValueGson_EntitiesAdapterFactory();
+  }
+}
diff --git a/java/com/google/gerrit/extensions/api/config/AccessCheckInfo.java b/java/com/google/gerrit/extensions/api/config/AccessCheckInfo.java
index fab2ec4..423ac49 100644
--- a/java/com/google/gerrit/extensions/api/config/AccessCheckInfo.java
+++ b/java/com/google/gerrit/extensions/api/config/AccessCheckInfo.java
@@ -14,10 +14,15 @@
 
 package com.google.gerrit.extensions.api.config;
 
+import java.util.List;
+
 public class AccessCheckInfo {
   public String message;
   // HTTP status code
   public int status;
 
+  /** Debug logs that may help to understand why a permission is denied or allowed. */
+  public List<String> debugLogs;
+
   // for future extension, we may add inputs / results for bulk checks.
 }
diff --git a/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java b/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java
index c6555b9..30514a6 100644
--- a/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java
+++ b/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java
@@ -75,6 +75,7 @@
   public enum EmailStrategy {
     ENABLED,
     CC_ON_OWN_COMMENTS,
+    ATTENTION_SET_ONLY,
     DISABLED
   }
 
diff --git a/java/com/google/gerrit/extensions/common/MergePatchSetInput.java b/java/com/google/gerrit/extensions/common/MergePatchSetInput.java
index 53f5e07..734d7e9 100644
--- a/java/com/google/gerrit/extensions/common/MergePatchSetInput.java
+++ b/java/com/google/gerrit/extensions/common/MergePatchSetInput.java
@@ -14,9 +14,12 @@
 
 package com.google.gerrit.extensions.common;
 
+import com.google.gerrit.extensions.api.accounts.AccountInput;
+
 public class MergePatchSetInput {
   public String subject;
   public boolean inheritParent;
   public String baseChange;
   public MergeInput merge;
+  public AccountInput author;
 }
diff --git a/java/com/google/gerrit/httpd/CacheBasedWebSession.java b/java/com/google/gerrit/httpd/CacheBasedWebSession.java
index 5c4830c..a3a67e5 100644
--- a/java/com/google/gerrit/httpd/CacheBasedWebSession.java
+++ b/java/com/google/gerrit/httpd/CacheBasedWebSession.java
@@ -28,6 +28,7 @@
 import com.google.gerrit.server.AnonymousUser;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.PropertyMap;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AuthResult;
 import com.google.gerrit.server.account.externalids.ExternalId;
@@ -161,15 +162,11 @@
   }
 
   @Override
-  public ExternalId.Key getLastLoginExternalId() {
-    return val != null ? val.getExternalId() : null;
-  }
-
-  @Override
   public CurrentUser getUser() {
     if (user == null) {
       if (isSignedIn()) {
-        user = identified.create(val.getAccountId());
+
+        user = identified.create(val.getAccountId(), getUserProperties(val));
       } else {
         user = anonymousProvider.get();
       }
@@ -177,6 +174,15 @@
     return user;
   }
 
+  private static PropertyMap getUserProperties(@Nullable WebSessionManager.Val val) {
+    if (val == null || val.getExternalId() == null) {
+      return PropertyMap.EMPTY;
+    }
+    return PropertyMap.builder()
+        .put(CurrentUser.LAST_LOGIN_EXTERNAL_ID_PROPERTY_KEY, val.getExternalId())
+        .build();
+  }
+
   @Override
   public void login(AuthResult res, boolean rememberMe) {
     Account.Id id = res.getAccountId();
@@ -194,7 +200,7 @@
     key = manager.createKey(id);
     val = manager.createVal(key, id, rememberMe, identity, null, null);
     saveCookie();
-    user = identified.create(val.getAccountId());
+    user = identified.create(val.getAccountId(), getUserProperties(val));
   }
 
   /** Set the user account for this current request only. */
@@ -202,7 +208,7 @@
   public void setUserAccountId(Account.Id id) {
     key = new Key("id:" + id);
     val = new Val(id, 0, false, null, 0, null, null);
-    user = identified.runAs(id, user);
+    user = identified.runAs(id, user, PropertyMap.EMPTY);
   }
 
   @Override
diff --git a/java/com/google/gerrit/httpd/WebSession.java b/java/com/google/gerrit/httpd/WebSession.java
index e8b54fe..daf30ff 100644
--- a/java/com/google/gerrit/httpd/WebSession.java
+++ b/java/com/google/gerrit/httpd/WebSession.java
@@ -19,7 +19,6 @@
 import com.google.gerrit.server.AccessPath;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.account.AuthResult;
-import com.google.gerrit.server.account.externalids.ExternalId;
 
 public interface WebSession {
   boolean isSignedIn();
@@ -29,8 +28,6 @@
 
   boolean isValidXGerritAuth(String keyIn);
 
-  ExternalId.Key getLastLoginExternalId();
-
   CurrentUser getUser();
 
   void login(AuthResult res, boolean rememberMe);
diff --git a/java/com/google/gerrit/httpd/auth/container/HttpAuthFilter.java b/java/com/google/gerrit/httpd/auth/container/HttpAuthFilter.java
index 509a9f1..e20c9b9 100644
--- a/java/com/google/gerrit/httpd/auth/container/HttpAuthFilter.java
+++ b/java/com/google/gerrit/httpd/auth/container/HttpAuthFilter.java
@@ -35,6 +35,7 @@
 import java.io.IOException;
 import java.io.OutputStream;
 import java.util.Locale;
+import java.util.Optional;
 import javax.servlet.Filter;
 import javax.servlet.FilterChain;
 import javax.servlet.FilterConfig;
@@ -124,8 +125,8 @@
   }
 
   private static boolean correctUser(String user, WebSession session) {
-    ExternalId.Key id = session.getLastLoginExternalId();
-    return id != null && id.equals(ExternalId.Key.create(SCHEME_GERRIT, user));
+    Optional<ExternalId.Key> id = session.getUser().getLastLoginExternalIdKey();
+    return id.map(i -> i.equals(ExternalId.Key.create(SCHEME_GERRIT, user))).orElse(false);
   }
 
   String getRemoteUser(HttpServletRequest req) {
diff --git a/java/com/google/gerrit/httpd/restapi/RestApiServlet.java b/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
index f743578..a1197ef 100644
--- a/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
+++ b/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
@@ -108,7 +108,6 @@
 import com.google.gerrit.server.RequestInfo;
 import com.google.gerrit.server.RequestListener;
 import com.google.gerrit.server.audit.ExtendedHttpAuditEvent;
-import com.google.gerrit.server.cache.PerThreadCache;
 import com.google.gerrit.server.change.ChangeFinder;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.group.GroupAuditService;
@@ -328,7 +327,7 @@
     try (TraceContext traceContext = enableTracing(req, res)) {
       List<IdString> path = splitPath(req);
 
-      try (PerThreadCache ignored = PerThreadCache.create()) {
+      try {
         RequestInfo requestInfo = createRequestInfo(traceContext, requestUri(req), path);
         globals.requestListeners.runEach(l -> l.onRequest(requestInfo));
 
@@ -1640,9 +1639,6 @@
           "Invalid authentication method. In order to authenticate, "
               + "prefix the REST endpoint URL with /a/ (e.g. http://example.com/a/projects/).");
     }
-    if (user.isIdentifiedUser()) {
-      user.setLastLoginExternalIdKey(globals.webSession.get().getLastLoginExternalId());
-    }
   }
 
   private List<String> getParameterNames(HttpServletRequest req) {
diff --git a/java/com/google/gerrit/pgm/Daemon.java b/java/com/google/gerrit/pgm/Daemon.java
index ba037d9..2eb19aa 100644
--- a/java/com/google/gerrit/pgm/Daemon.java
+++ b/java/com/google/gerrit/pgm/Daemon.java
@@ -206,6 +206,7 @@
   private AbstractModule luceneModule;
   private Module emailModule;
   private List<Module> testSysModules = new ArrayList<>();
+  private List<Module> testSshModules = new ArrayList<>();
   private Module auditEventModule;
 
   private Runnable serverStarted;
@@ -337,6 +338,11 @@
   }
 
   @VisibleForTesting
+  public void addAdditionalSshModuleForTesting(@Nullable Module... modules) {
+    testSshModules.addAll(Arrays.asList(modules));
+  }
+
+  @VisibleForTesting
   public void start() throws IOException {
     if (dbInjector == null) {
       dbInjector = createDbInjector(true /* enableMetrics */);
@@ -532,6 +538,8 @@
             replica,
             sysInjector.getInstance(DownloadConfig.class),
             sysInjector.getInstance(LfsPluginAuthCommand.Module.class)));
+
+    modules.addAll(testSshModules);
     if (!replica) {
       modules.add(new IndexCommandsModule(sysInjector));
       modules.add(new SequenceCommandsModule());
diff --git a/java/com/google/gerrit/pgm/init/SitePathInitializer.java b/java/com/google/gerrit/pgm/init/SitePathInitializer.java
index 8a71c1c..ddc4f79 100644
--- a/java/com/google/gerrit/pgm/init/SitePathInitializer.java
+++ b/java/com/google/gerrit/pgm/init/SitePathInitializer.java
@@ -125,7 +125,8 @@
     extractMailExample("DeleteVoteHtml.soy");
     extractMailExample("Footer.soy");
     extractMailExample("FooterHtml.soy");
-    extractMailExample("HeaderHtml.soy");
+    extractMailExample("ChangeHeader.soy");
+    extractMailExample("ChangeHeaderHtml.soy");
     extractMailExample("HttpPasswordUpdate.soy");
     extractMailExample("HttpPasswordUpdateHtml.soy");
     extractMailExample("InboundEmailRejection.soy");
diff --git a/java/com/google/gerrit/server/AnonymousUser.java b/java/com/google/gerrit/server/AnonymousUser.java
index 91d2d05..c96d61a 100644
--- a/java/com/google/gerrit/server/AnonymousUser.java
+++ b/java/com/google/gerrit/server/AnonymousUser.java
@@ -27,12 +27,6 @@
   }
 
   @Override
-  public Object getCacheKey() {
-    // Treat all anonymous users as a single user
-    return "anonymous";
-  }
-
-  @Override
   public String toString() {
     return "ANONYMOUS";
   }
diff --git a/java/com/google/gerrit/server/CommentContextLoader.java b/java/com/google/gerrit/server/CommentContextLoader.java
index 7f84693..68a80c3 100644
--- a/java/com/google/gerrit/server/CommentContextLoader.java
+++ b/java/com/google/gerrit/server/CommentContextLoader.java
@@ -17,18 +17,20 @@
 import static java.util.stream.Collectors.groupingBy;
 
 import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Iterables;
+import com.google.common.collect.Streams;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.CommentContext;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.extensions.common.CommentInfo;
 import com.google.gerrit.extensions.common.ContextLineInfo;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.patch.Text;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 import java.io.IOException;
-import java.util.ArrayList;
-import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Optional;
@@ -48,7 +50,6 @@
 
   private final GitRepositoryManager repoManager;
   private final Project.NameKey project;
-  private Map<ContextData, List<ContextLineInfo>> candidates;
 
   public interface Factory {
     CommentContextLoader create(Project.NameKey project);
@@ -58,80 +59,66 @@
   CommentContextLoader(GitRepositoryManager repoManager, @Assisted Project.NameKey project) {
     this.repoManager = repoManager;
     this.project = project;
-    this.candidates = new HashMap<>();
   }
 
   /**
-   * Returns an empty list of {@link ContextLineInfo}. Clients are expected to call this method one
-   * or more times. Each call returns a reference to an empty {@link List<ContextLineInfo>}.
+   * Load the comment context for multiple comments at once. This method will open the repository
+   * and read the source files for all necessary comments' file paths.
    *
-   * <p>A single call to {@link #fill()} will cause all list references returned from this method to
-   * be populated. If a client calls this method again with a comment that was passed before calling
-   * {@link #fill()}, the new populated list will be returned.
-   *
-   * @param comment the comment entity for which we want to load the context
-   * @return a list of {@link ContextLineInfo}
+   * @param comments a list of comments.
+   * @return a Map where all entries consist of the input comments and the values are their
+   *     corresponding {@link CommentContext}.
    */
-  public List<ContextLineInfo> getContext(CommentInfo comment) {
-    ContextData key =
-        ContextData.create(
-            comment.id,
-            ObjectId.fromString(comment.commitId),
-            comment.path,
-            getStartAndEndLines(comment));
-    List<ContextLineInfo> context = candidates.get(key);
-    if (context == null) {
-      context = new ArrayList<>();
-      candidates.put(key, context);
-    }
-    return context;
-  }
+  public Map<Comment, CommentContext> getContext(Iterable<Comment> comments) {
+    ImmutableMap.Builder<Comment, CommentContext> result =
+        ImmutableMap.builderWithExpectedSize(Iterables.size(comments));
 
-  /**
-   * A call to this method loads the context for all comments stored in {@link
-   * CommentContextLoader#candidates}. This is useful so that the repository is opened once for all
-   * comments.
-   */
-  public void fill() {
     // Group comments by commit ID so that each commit is parsed only once
-    Map<ObjectId, List<ContextData>> commentsByCommitId =
-        candidates.keySet().stream().collect(groupingBy(ContextData::commitId));
+    Map<ObjectId, List<Comment>> commentsByCommitId =
+        Streams.stream(comments).collect(groupingBy(Comment::getCommitId));
 
     try (Repository repo = repoManager.openRepository(project);
         RevWalk rw = new RevWalk(repo)) {
       for (ObjectId commitId : commentsByCommitId.keySet()) {
         RevCommit commit = rw.parseCommit(commitId);
-        for (ContextData k : commentsByCommitId.get(commitId)) {
-          if (!k.range().isPresent()) {
+        for (Comment comment : commentsByCommitId.get(commitId)) {
+          Optional<Range> range = getStartAndEndLines(comment);
+          if (!range.isPresent()) {
             continue;
           }
-          try (TreeWalk tw = TreeWalk.forPath(rw.getObjectReader(), k.path(), commit.getTree())) {
+          // TODO(ghareeb): We can further group the comments by file paths to avoid opening
+          // the same file multiple times.
+          try (TreeWalk tw =
+              TreeWalk.forPath(rw.getObjectReader(), comment.key.filename, commit.getTree())) {
             if (tw == null) {
               logger.atWarning().log(
                   "Failed to find path %s in the git tree of ID %s.",
-                  k.path(), commit.getTree().getId());
+                  comment.key.filename, commit.getTree().getId());
               continue;
             }
             ObjectId id = tw.getObjectId(0);
             Text src = new Text(repo.open(id, Constants.OBJ_BLOB));
-            List<ContextLineInfo> contextLines = candidates.get(k);
-            Range r = k.range().get();
-            for (int i = r.start(); i <= r.end(); i++) {
-              contextLines.add(new ContextLineInfo(i, src.getString(i - 1)));
+            Range r = range.get();
+            ImmutableMap.Builder<Integer, String> context =
+                ImmutableMap.builderWithExpectedSize(r.end() - r.start());
+            for (int i = r.start(); i < r.end(); i++) {
+              context.put(i, src.getString(i - 1));
             }
+            result.put(comment, CommentContext.create(context.build()));
           }
         }
       }
+      return result.build();
     } catch (IOException e) {
       throw new StorageException("Failed to load the comment context", e);
     }
   }
 
-  private static Optional<Range> getStartAndEndLines(CommentInfo comment) {
+  private static Optional<Range> getStartAndEndLines(Comment comment) {
     if (comment.range != null) {
-      return Optional.of(Range.create(comment.range.startLine, comment.range.endLine));
-    } else if (comment.line != null) {
-      return Optional.of(Range.create(comment.line, comment.line));
+      return Optional.of(Range.create(comment.range.startLine, comment.range.endLine + 1));
+    } else if (comment.lineNbr > 0) {
+      return Optional.of(Range.create(comment.lineNbr, comment.lineNbr + 1));
     }
     return Optional.empty();
   }
@@ -142,23 +129,10 @@
       return new AutoValue_CommentContextLoader_Range(start, end);
     }
 
+    /** Start line of the comment (inclusive). */
     abstract int start();
 
+    /** End line of the comment (exclusive). */
     abstract int end();
   }
-
-  @AutoValue
-  abstract static class ContextData {
-    static ContextData create(String id, ObjectId commitId, String path, Optional<Range> range) {
-      return new AutoValue_CommentContextLoader_ContextData(id, commitId, path, range);
-    }
-
-    abstract String id();
-
-    abstract ObjectId commitId();
-
-    abstract String path();
-
-    abstract Optional<Range> range();
-  }
 }
diff --git a/java/com/google/gerrit/server/CurrentUser.java b/java/com/google/gerrit/server/CurrentUser.java
index 75afc04..43d3c7b 100644
--- a/java/com/google/gerrit/server/CurrentUser.java
+++ b/java/com/google/gerrit/server/CurrentUser.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.server;
 
-import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.server.account.GroupMembership;
 import com.google.gerrit.server.account.externalids.ExternalId;
@@ -31,17 +30,19 @@
  * @see IdentifiedUser
  */
 public abstract class CurrentUser {
-  /** Unique key for plugin/extension specific data on a CurrentUser. */
-  public static final class PropertyKey<T> {
-    public static <T> PropertyKey<T> create() {
-      return new PropertyKey<>();
-    }
+  public static final PropertyMap.Key<ExternalId.Key> LAST_LOGIN_EXTERNAL_ID_PROPERTY_KEY =
+      PropertyMap.key();
 
-    private PropertyKey() {}
+  private final PropertyMap properties;
+  private AccessPath accessPath = AccessPath.UNKNOWN;
+
+  protected CurrentUser() {
+    this.properties = PropertyMap.EMPTY;
   }
 
-  private AccessPath accessPath = AccessPath.UNKNOWN;
-  private PropertyKey<ExternalId.Key> lastLoginExternalIdPropertyKey = PropertyKey.create();
+  protected CurrentUser(PropertyMap properties) {
+    this.properties = properties;
+  }
 
   /** How this user is accessing the Gerrit Code Review application. */
   public final AccessPath getAccessPath() {
@@ -90,12 +91,6 @@
    */
   public abstract GroupMembership getEffectiveGroups();
 
-  /**
-   * Returns a unique identifier for this user that is intended to be used as a cache key. Returned
-   * object should to implement {@code equals()} and {@code hashCode()} for effective caching.
-   */
-  public abstract Object getCacheKey();
-
   /** Unique name of the user on this server, if one has been assigned. */
   public Optional<String> getUserName() {
     return Optional.empty();
@@ -133,29 +128,18 @@
   }
 
   /**
-   * Lookup a previously stored property.
+   * Lookup a stored property.
    *
-   * @param key unique property key.
-   * @return previously stored value, or {@code Optional#empty()}.
+   * @param key unique property key. This key has to be the same instance that was used to store the
+   *     value when constructing the {@link PropertyMap}
+   * @return stored value, or {@code Optional#empty()}.
    */
-  public <T> Optional<T> get(PropertyKey<T> key) {
-    return Optional.empty();
-  }
-
-  /**
-   * Store a property for later retrieval.
-   *
-   * @param key unique property key.
-   * @param value value to store; or {@code null} to clear the value.
-   */
-  public <T> void put(PropertyKey<T> key, @Nullable T value) {}
-
-  public void setLastLoginExternalIdKey(ExternalId.Key externalIdKey) {
-    put(lastLoginExternalIdPropertyKey, externalIdKey);
+  public <T> Optional<T> get(PropertyMap.Key<T> key) {
+    return properties.get(key);
   }
 
   public Optional<ExternalId.Key> getLastLoginExternalIdKey() {
-    return get(lastLoginExternalIdPropertyKey);
+    return get(LAST_LOGIN_EXTERNAL_ID_PROPERTY_KEY);
   }
 
   /**
diff --git a/java/com/google/gerrit/server/IdentifiedUser.java b/java/com/google/gerrit/server/IdentifiedUser.java
index 7cafdc0..ec2eb81 100644
--- a/java/com/google/gerrit/server/IdentifiedUser.java
+++ b/java/com/google/gerrit/server/IdentifiedUser.java
@@ -17,11 +17,13 @@
 import static com.google.common.base.MoreObjects.firstNonNull;
 import static com.google.common.flogger.LazyArgs.lazy;
 
+import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Sets;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.UsedAt;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AccountState;
@@ -46,8 +48,6 @@
 import java.net.SocketAddress;
 import java.net.URL;
 import java.util.Date;
-import java.util.HashMap;
-import java.util.Map;
 import java.util.Optional;
 import java.util.Set;
 import java.util.TimeZone;
@@ -105,12 +105,26 @@
       return create(null, id);
     }
 
+    @VisibleForTesting
+    @UsedAt(UsedAt.Project.GOOGLE)
+    public IdentifiedUser forTest(Account.Id id, PropertyMap properties) {
+      return runAs(null, id, null, properties);
+    }
+
     public IdentifiedUser create(SocketAddress remotePeer, Account.Id id) {
       return runAs(remotePeer, id, null);
     }
 
     public IdentifiedUser runAs(
         SocketAddress remotePeer, Account.Id id, @Nullable CurrentUser caller) {
+      return runAs(remotePeer, id, caller, PropertyMap.EMPTY);
+    }
+
+    private IdentifiedUser runAs(
+        SocketAddress remotePeer,
+        Account.Id id,
+        @Nullable CurrentUser caller,
+        PropertyMap properties) {
       return new IdentifiedUser(
           authConfig,
           realm,
@@ -121,7 +135,8 @@
           enableReverseDnsLookup,
           Providers.of(remotePeer),
           id,
-          caller);
+          caller,
+          properties);
     }
   }
 
@@ -163,20 +178,10 @@
     }
 
     public IdentifiedUser create(Account.Id id) {
-      return new IdentifiedUser(
-          authConfig,
-          realm,
-          anonymousCowardName,
-          canonicalUrl,
-          accountCache,
-          groupBackend,
-          enableReverseDnsLookup,
-          remotePeerProvider,
-          id,
-          null);
+      return create(id, PropertyMap.EMPTY);
     }
 
-    public IdentifiedUser runAs(Account.Id id, CurrentUser caller) {
+    public <T> IdentifiedUser create(Account.Id id, PropertyMap properties) {
       return new IdentifiedUser(
           authConfig,
           realm,
@@ -187,7 +192,23 @@
           enableReverseDnsLookup,
           remotePeerProvider,
           id,
-          caller);
+          null,
+          properties);
+    }
+
+    public IdentifiedUser runAs(Account.Id id, CurrentUser caller, PropertyMap properties) {
+      return new IdentifiedUser(
+          authConfig,
+          realm,
+          anonymousCowardName,
+          canonicalUrl,
+          accountCache,
+          groupBackend,
+          enableReverseDnsLookup,
+          remotePeerProvider,
+          id,
+          caller,
+          properties);
     }
   }
 
@@ -212,7 +233,6 @@
   private boolean loadedAllEmails;
   private Set<String> invalidEmails;
   private GroupMembership effectiveGroups;
-  private Map<PropertyKey<Object>, Object> properties;
 
   private IdentifiedUser(
       AuthConfig authConfig,
@@ -235,7 +255,8 @@
         enableReverseDnsLookup,
         remotePeerProvider,
         state.account().id(),
-        realUser);
+        realUser,
+        PropertyMap.EMPTY);
     this.state = state;
   }
 
@@ -249,7 +270,9 @@
       Boolean enableReverseDnsLookup,
       @Nullable Provider<SocketAddress> remotePeerProvider,
       Account.Id id,
-      @Nullable CurrentUser realUser) {
+      @Nullable CurrentUser realUser,
+      PropertyMap properties) {
+    super(properties);
     this.canonicalUrl = canonicalUrl;
     this.accountCache = accountCache;
     this.groupBackend = groupBackend;
@@ -390,11 +413,6 @@
     return effectiveGroups;
   }
 
-  @Override
-  public Object getCacheKey() {
-    return getAccountId();
-  }
-
   public PersonIdent newRefLogIdent() {
     return newRefLogIdent(new Date(), TimeZone.getDefault());
   }
@@ -463,40 +481,6 @@
     return true;
   }
 
-  @Override
-  public synchronized <T> Optional<T> get(PropertyKey<T> key) {
-    if (properties != null) {
-      @SuppressWarnings("unchecked")
-      T value = (T) properties.get(key);
-      return Optional.ofNullable(value);
-    }
-    return Optional.empty();
-  }
-
-  /**
-   * Store a property for later retrieval.
-   *
-   * @param key unique property key.
-   * @param value value to store; or {@code null} to clear the value.
-   */
-  @Override
-  public synchronized <T> void put(PropertyKey<T> key, @Nullable T value) {
-    if (properties == null) {
-      if (value == null) {
-        return;
-      }
-      properties = new HashMap<>();
-    }
-
-    @SuppressWarnings("unchecked")
-    PropertyKey<Object> k = (PropertyKey<Object>) key;
-    if (value != null) {
-      properties.put(k, value);
-    } else {
-      properties.remove(k);
-    }
-  }
-
   /**
    * Returns a materialized copy of the user with all dependencies.
    *
diff --git a/java/com/google/gerrit/server/InternalUser.java b/java/com/google/gerrit/server/InternalUser.java
index 381819d..821a0c6 100644
--- a/java/com/google/gerrit/server/InternalUser.java
+++ b/java/com/google/gerrit/server/InternalUser.java
@@ -36,11 +36,6 @@
   }
 
   @Override
-  public String getCacheKey() {
-    return "internal";
-  }
-
-  @Override
   public boolean isInternalUser() {
     return true;
   }
diff --git a/java/com/google/gerrit/server/PeerDaemonUser.java b/java/com/google/gerrit/server/PeerDaemonUser.java
index b27e05c..8a8b67a 100644
--- a/java/com/google/gerrit/server/PeerDaemonUser.java
+++ b/java/com/google/gerrit/server/PeerDaemonUser.java
@@ -40,11 +40,6 @@
     return GroupMembership.EMPTY;
   }
 
-  @Override
-  public Object getCacheKey() {
-    return getRemoteAddress();
-  }
-
   public SocketAddress getRemoteAddress() {
     return peer;
   }
diff --git a/java/com/google/gerrit/server/PropertyMap.java b/java/com/google/gerrit/server/PropertyMap.java
new file mode 100644
index 0000000..da3a2495
--- /dev/null
+++ b/java/com/google/gerrit/server/PropertyMap.java
@@ -0,0 +1,84 @@
+// 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;
+
+import com.google.common.collect.ImmutableMap;
+import java.util.Optional;
+
+/**
+ * Immutable map that holds a collection of random objects allowing for a type-safe retrieval.
+ *
+ * <p>Intended to be used in {@link CurrentUser} when the object is constructed during login and
+ * holds per-request state. This functionality allows plugins/extensions to contribute specific data
+ * to {@link CurrentUser} that is unknown to Gerrit core.
+ */
+public class PropertyMap {
+  /** Empty instance to be referenced once per JVM. */
+  public static final PropertyMap EMPTY = builder().build();
+
+  /**
+   * Typed key for {@link PropertyMap}. This class intentionally does not implement {@link
+   * Object#equals(Object)} and {@link Object#hashCode()} so that the same instance has to be used
+   * to retrieve a stored value.
+   *
+   * <p>We require the exact same key instance because {@link PropertyMap} is implemented in a
+   * type-safe fashion by using Java generics to guarantee the return type. The generic type can't
+   * be recovered at runtime, so there is no way to just use the type's full name as key - we'd have
+   * to pass additional arguments. At the same time, this is in-line with how we'd want callers to
+   * use {@link PropertyMap}: Instantiate a static, per-JVM key that is reused when setting and
+   * getting values.
+   */
+  public static class Key<T> {}
+
+  public static <T> Key<T> key() {
+    return new Key<>();
+  }
+
+  public static class Builder {
+    private ImmutableMap.Builder<Object, Object> mutableMap;
+
+    private Builder() {
+      this.mutableMap = ImmutableMap.builder();
+    }
+
+    /** Adds the provided {@code value} to the {@link PropertyMap} that is being built. */
+    public <T> Builder put(Key<T> key, T value) {
+      mutableMap.put(key, value);
+      return this;
+    }
+
+    /** Builds and returns an immutable {@link PropertyMap}. */
+    public PropertyMap build() {
+      return new PropertyMap(mutableMap.build());
+    }
+  }
+
+  private final ImmutableMap<Object, Object> map;
+
+  private PropertyMap(ImmutableMap<Object, Object> map) {
+    this.map = map;
+  }
+
+  /** Returns a new {@link Builder} instance. */
+  public static Builder builder() {
+    return new Builder();
+  }
+
+  /** Returns the requested value wrapped as {@link Optional}. */
+  @SuppressWarnings("unchecked")
+  public <T> Optional<T> get(Key<T> key) {
+    return Optional.ofNullable((T) map.get(key));
+  }
+}
diff --git a/java/com/google/gerrit/server/cache/PerThreadCache.java b/java/com/google/gerrit/server/cache/PerThreadCache.java
deleted file mode 100644
index b4f79d1..0000000
--- a/java/com/google/gerrit/server/cache/PerThreadCache.java
+++ /dev/null
@@ -1,146 +0,0 @@
-// Copyright (C) 2018 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.cache;
-
-import static com.google.common.base.Preconditions.checkState;
-
-import com.google.common.base.Objects;
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.Maps;
-import com.google.gerrit.common.Nullable;
-import java.util.Map;
-import java.util.function.Supplier;
-
-/**
- * Caches object instances for a request as {@link ThreadLocal} in the serving thread.
- *
- * <p>This class is intended to cache objects that have a high instantiation cost, are specific to
- * the current request and potentially need to be instantiated multiple times while serving a
- * request.
- *
- * <p>This is different from the key-value storage in {@code CurrentUser}: {@code CurrentUser}
- * offers a key-value storage by providing thread-safe {@code get} and {@code put} methods. Once the
- * value is retrieved through {@code get} there is not thread-safety anymore - apart from the
- * retrieved object guarantees. Depending on the implementation of {@code CurrentUser}, it might be
- * shared between the request serving thread as well as sub- or background treads.
- *
- * <p>In comparison to that, this class guarantees thread safety even on non-thread-safe objects as
- * its cache is tied to the serving thread only. While allowing to cache non-thread-safe objects, it
- * has the downside of not sharing any objects with background threads or executors.
- *
- * <p>Lastly, this class offers a cache, that requires callers to also provide a {@code Supplier} in
- * case the object is not present in the cache, while {@code CurrentUser} provides a storage where
- * just retrieving stored values is a valid operation.
- *
- * <p>To prevent OOM errors on requests that would cache a lot of objects, this class enforces an
- * internal limit after which no new elements are cached. All {@code get} calls are served by
- * invoking the {@code Supplier} after that.
- */
-public class PerThreadCache implements AutoCloseable {
-  private static final ThreadLocal<PerThreadCache> CACHE = new ThreadLocal<>();
-  /**
-   * Cache at maximum 25 values per thread. This value was chosen arbitrarily. Some endpoints (like
-   * ListProjects) break the assumption that the data cached in a request is limited. To prevent
-   * this class from accumulating an unbound number of objects, we enforce this limit.
-   */
-  private static final int PER_THREAD_CACHE_SIZE = 25;
-
-  /**
-   * Unique key for key-value mappings stored in PerThreadCache. The key is based on the value's
-   * class and a list of identifiers that in combination uniquely set the object apart form others
-   * of the same class.
-   */
-  public static final class Key<T> {
-    private final Class<T> clazz;
-    private final ImmutableList<Object> identifiers;
-
-    /**
-     * Returns a key based on the value's class and an identifier that uniquely identify the value.
-     * The identifier needs to implement {@code equals()} and {@hashCode()}.
-     */
-    public static <T> Key<T> create(Class<T> clazz, Object identifier) {
-      return new Key<>(clazz, ImmutableList.of(identifier));
-    }
-
-    /**
-     * Returns a key based on the value's class and a set of identifiers that uniquely identify the
-     * value. Identifiers need to implement {@code equals()} and {@hashCode()}.
-     */
-    public static <T> Key<T> create(Class<T> clazz, Object... identifiers) {
-      return new Key<>(clazz, ImmutableList.copyOf(identifiers));
-    }
-
-    private Key(Class<T> clazz, ImmutableList<Object> identifiers) {
-      this.clazz = clazz;
-      this.identifiers = identifiers;
-    }
-
-    @Override
-    public int hashCode() {
-      return Objects.hashCode(clazz, identifiers);
-    }
-
-    @Override
-    public boolean equals(Object o) {
-      if (!(o instanceof Key)) {
-        return false;
-      }
-      Key<?> other = (Key<?>) o;
-      return this.clazz == other.clazz && this.identifiers.equals(other.identifiers);
-    }
-  }
-
-  public static PerThreadCache create() {
-    checkState(CACHE.get() == null, "called create() twice on the same request");
-    PerThreadCache cache = new PerThreadCache();
-    CACHE.set(cache);
-    return cache;
-  }
-
-  @Nullable
-  public static PerThreadCache get() {
-    return CACHE.get();
-  }
-
-  public static <T> T getOrCompute(Key<T> key, Supplier<T> loader) {
-    PerThreadCache cache = get();
-    return cache != null ? cache.get(key, loader) : loader.get();
-  }
-
-  private final Map<Key<?>, Object> cache = Maps.newHashMapWithExpectedSize(PER_THREAD_CACHE_SIZE);
-
-  private PerThreadCache() {}
-
-  /**
-   * Returns an instance of {@code T} that was either loaded from the cache or obtained from the
-   * provided {@link Supplier}.
-   */
-  public <T> T get(Key<T> key, Supplier<T> loader) {
-    @SuppressWarnings("unchecked")
-    T value = (T) cache.get(key);
-    if (value == null) {
-      value = loader.get();
-      if (cache.size() < PER_THREAD_CACHE_SIZE) {
-        cache.put(key, value);
-      }
-    }
-    return value;
-  }
-
-  @Override
-  public void close() {
-    CACHE.remove();
-  }
-}
diff --git a/java/com/google/gerrit/server/change/ChangeKeyAdapter.java b/java/com/google/gerrit/server/change/ChangeKeyAdapter.java
deleted file mode 100644
index 0db4cea..0000000
--- a/java/com/google/gerrit/server/change/ChangeKeyAdapter.java
+++ /dev/null
@@ -1,51 +0,0 @@
-// Copyright (C) 2019 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.change;
-
-import com.google.gerrit.entities.Change;
-import com.google.gson.JsonDeserializationContext;
-import com.google.gson.JsonDeserializer;
-import com.google.gson.JsonElement;
-import com.google.gson.JsonObject;
-import com.google.gson.JsonParseException;
-import com.google.gson.JsonSerializationContext;
-import com.google.gson.JsonSerializer;
-import java.lang.reflect.Type;
-
-/**
- * Adapter that serializes {@link com.google.gerrit.entities.Change.Key}'s {@code key} field as
- * {@code id}, for backwards compatibility in stream-events.
- */
-// TODO(dborowitz): auto-value-gson should support this directly using @SerializedName on the
-// AutoValue method.
-public class ChangeKeyAdapter implements JsonSerializer<Change.Key>, JsonDeserializer<Change.Key> {
-  @Override
-  public JsonElement serialize(Change.Key src, Type typeOfSrc, JsonSerializationContext context) {
-    JsonObject obj = new JsonObject();
-    obj.addProperty("id", src.get());
-    return obj;
-  }
-
-  @Override
-  public Change.Key deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context)
-      throws JsonParseException {
-    JsonElement keyJson = json.getAsJsonObject().get("id");
-    if (keyJson == null || !keyJson.isJsonPrimitive() || !keyJson.getAsJsonPrimitive().isString()) {
-      throw new JsonParseException("Key is not a string: " + keyJson);
-    }
-    String key = keyJson.getAsJsonPrimitive().getAsString();
-    return Change.key(key);
-  }
-}
diff --git a/java/com/google/gerrit/server/comment/CommentContextCache.java b/java/com/google/gerrit/server/comment/CommentContextCache.java
new file mode 100644
index 0000000..8c40763
--- /dev/null
+++ b/java/com/google/gerrit/server/comment/CommentContextCache.java
@@ -0,0 +1,42 @@
+// 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.comment;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.entities.CommentContext;
+
+/**
+ * Caches the context lines of comments (source file content surrounding and including the lines
+ * where the comment was written)
+ */
+public interface CommentContextCache {
+
+  /**
+   * Returns the context lines for a single comment.
+   *
+   * @param key a key representing a subset of fields for a comment that serves as an identifier.
+   * @return a {@link CommentContext} object containing all line numbers and text of the context.
+   */
+  CommentContext get(CommentContextKey key);
+
+  /**
+   * Returns the context lines for multiple comments - identified by their {@code keys}.
+   *
+   * @param keys list of keys, where each key represents a single comment through its project,
+   *     change ID, patchset, path and ID. The keys can belong to different projects and changes.
+   * @return {@code Map} of {@code CommentContext} containing the context for all comments.
+   */
+  ImmutableMap<CommentContextKey, CommentContext> getAll(Iterable<CommentContextKey> keys);
+}
diff --git a/java/com/google/gerrit/server/comment/CommentContextCacheImpl.java b/java/com/google/gerrit/server/comment/CommentContextCacheImpl.java
new file mode 100644
index 0000000..c4e29d8
--- /dev/null
+++ b/java/com/google/gerrit/server/comment/CommentContextCacheImpl.java
@@ -0,0 +1,256 @@
+// 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.comment;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import com.google.common.cache.CacheLoader;
+import com.google.common.cache.LoadingCache;
+import com.google.common.cache.Weigher;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Iterables;
+import com.google.common.collect.Streams;
+import com.google.common.hash.Hashing;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.CommentContext;
+import com.google.gerrit.entities.HumanComment;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.proto.Protos;
+import com.google.gerrit.server.CommentContextLoader;
+import com.google.gerrit.server.CommentsUtil;
+import com.google.gerrit.server.cache.CacheModule;
+import com.google.gerrit.server.cache.proto.Cache.AllCommentContextProto;
+import com.google.gerrit.server.cache.proto.Cache.AllCommentContextProto.CommentContextProto;
+import com.google.gerrit.server.cache.serialize.CacheSerializer;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.inject.Inject;
+import com.google.inject.Module;
+import com.google.inject.name.Named;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ExecutionException;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+
+/** Implementation of {@link CommentContextCache}. */
+public class CommentContextCacheImpl implements CommentContextCache {
+  private static final String CACHE_NAME = "comment_context";
+
+  public static Module module() {
+    return new CacheModule() {
+      @Override
+      protected void configure() {
+        persist(CACHE_NAME, CommentContextKey.class, CommentContext.class)
+            .version(1)
+            .diskLimit(1 << 30) // limit the total cache size to 1 GB
+            .maximumWeight(1 << 23) // Limit the size of the in-memory cache to 8 MB
+            .weigher(CommentContextWeigher.class)
+            .keySerializer(CommentContextKey.Serializer.INSTANCE)
+            .valueSerializer(CommentContextSerializer.INSTANCE)
+            .loader(Loader.class);
+
+        bind(CommentContextCache.class).to(CommentContextCacheImpl.class);
+      }
+    };
+  }
+
+  private final LoadingCache<CommentContextKey, CommentContext> contextCache;
+
+  @Inject
+  CommentContextCacheImpl(
+      @Named(CACHE_NAME) LoadingCache<CommentContextKey, CommentContext> contextCache) {
+    this.contextCache = contextCache;
+  }
+
+  @Override
+  public CommentContext get(CommentContextKey comment) {
+    return getAll(ImmutableList.of(comment)).get(comment);
+  }
+
+  @Override
+  public ImmutableMap<CommentContextKey, CommentContext> getAll(
+      Iterable<CommentContextKey> inputKeys) {
+    ImmutableMap.Builder<CommentContextKey, CommentContext> result = ImmutableMap.builder();
+
+    // Convert the input keys to the same keys but with their file paths hashed
+    Map<CommentContextKey, CommentContextKey> keysToCacheKeys =
+        Streams.stream(inputKeys)
+            .collect(
+                Collectors.toMap(
+                    Function.identity(),
+                    k -> k.toBuilder().path(Loader.hashPath(k.path())).build()));
+
+    try {
+      ImmutableMap<CommentContextKey, CommentContext> allContext =
+          contextCache.getAll(keysToCacheKeys.values());
+
+      for (CommentContextKey inputKey : inputKeys) {
+        CommentContextKey cacheKey = keysToCacheKeys.get(inputKey);
+        result.put(inputKey, allContext.get(cacheKey));
+      }
+      return result.build();
+    } catch (ExecutionException e) {
+      throw new StorageException("Failed to retrieve comments' context", e);
+    }
+  }
+
+  public enum CommentContextSerializer implements CacheSerializer<CommentContext> {
+    INSTANCE;
+
+    @Override
+    public byte[] serialize(CommentContext commentContext) {
+      AllCommentContextProto.Builder allBuilder = AllCommentContextProto.newBuilder();
+
+      commentContext
+          .lines()
+          .entrySet()
+          .forEach(
+              c ->
+                  allBuilder.addContext(
+                      CommentContextProto.newBuilder()
+                          .setLineNumber(c.getKey())
+                          .setContextLine(c.getValue())));
+      return Protos.toByteArray(allBuilder.build());
+    }
+
+    @Override
+    public CommentContext deserialize(byte[] in) {
+      ImmutableMap.Builder<Integer, String> contextLinesMap = ImmutableMap.builder();
+      Protos.parseUnchecked(AllCommentContextProto.parser(), in).getContextList().stream()
+          .forEach(c -> contextLinesMap.put(c.getLineNumber(), c.getContextLine()));
+      return CommentContext.create(contextLinesMap.build());
+    }
+  }
+
+  static class Loader extends CacheLoader<CommentContextKey, CommentContext> {
+    private final ChangeNotes.Factory notesFactory;
+    private final CommentsUtil commentsUtil;
+    private final CommentContextLoader.Factory factory;
+
+    @Inject
+    Loader(
+        CommentsUtil commentsUtil,
+        ChangeNotes.Factory notesFactory,
+        CommentContextLoader.Factory factory) {
+      this.commentsUtil = commentsUtil;
+      this.notesFactory = notesFactory;
+      this.factory = factory;
+    }
+
+    @Override
+    public CommentContext load(CommentContextKey key) {
+      return loadAll(ImmutableList.of(key)).get(key);
+    }
+
+    @Override
+    public Map<CommentContextKey, CommentContext> loadAll(
+        Iterable<? extends CommentContextKey> keys) {
+      ImmutableMap.Builder<CommentContextKey, CommentContext> result =
+          ImmutableMap.builderWithExpectedSize(Iterables.size(keys));
+
+      Map<Project.NameKey, Map<Change.Id, List<CommentContextKey>>> groupedKeys =
+          Streams.stream(keys)
+              .distinct()
+              .map(k -> (CommentContextKey) k)
+              .collect(
+                  Collectors.groupingBy(
+                      CommentContextKey::project,
+                      Collectors.groupingBy(CommentContextKey::changeId)));
+
+      for (Map.Entry<Project.NameKey, Map<Change.Id, List<CommentContextKey>>> perProject :
+          groupedKeys.entrySet()) {
+        Map<Change.Id, List<CommentContextKey>> keysPerProject = perProject.getValue();
+
+        for (Map.Entry<Change.Id, List<CommentContextKey>> perChange : keysPerProject.entrySet()) {
+          Map<CommentContextKey, CommentContext> context =
+              loadForSameChange(perChange.getValue(), perProject.getKey(), perChange.getKey());
+          result.putAll(context);
+        }
+      }
+      return result.build();
+    }
+
+    /**
+     * Load the comment context for comments of the same project and change ID.
+     *
+     * @param keys a list of keys corresponding to some comments
+     * @param project a gerrit project/repository
+     * @param changeId an identifier for a change
+     * @return a map of the input keys to their corresponding {@link CommentContext}
+     */
+    private Map<CommentContextKey, CommentContext> loadForSameChange(
+        List<CommentContextKey> keys, Project.NameKey project, Change.Id changeId) {
+      ChangeNotes notes = notesFactory.createChecked(project, changeId);
+      List<HumanComment> humanComments = commentsUtil.publishedHumanCommentsByChange(notes);
+      CommentContextLoader loader = factory.create(project);
+      Map<Comment, CommentContextKey> commentsToKeys = new HashMap<>();
+      for (CommentContextKey key : keys) {
+        commentsToKeys.put(getCommentForKey(humanComments, key), key);
+      }
+      Map<Comment, CommentContext> allContext = loader.getContext(commentsToKeys.keySet());
+      return allContext.entrySet().stream()
+          .collect(Collectors.toMap(e -> commentsToKeys.get(e.getKey()), Map.Entry::getValue));
+    }
+
+    /**
+     * Return the single comment from the {@code allComments} input list corresponding to the key
+     * parameter.
+     *
+     * @param allComments a list of comments.
+     * @param key a key representing a single comment.
+     * @return the single comment corresponding to the key parameter.
+     */
+    private Comment getCommentForKey(List<HumanComment> allComments, CommentContextKey key) {
+      return allComments.stream()
+          .filter(
+              c ->
+                  key.id().equals(c.key.uuid)
+                      && key.patchset() == c.key.patchSetId
+                      && key.path().equals(hashPath(c.key.filename)))
+          .findFirst()
+          .orElseThrow(() -> new IllegalArgumentException("Unable to find comment for key " + key));
+    }
+
+    /**
+     * Hash an input String using the general {@link Hashing#murmur3_128()} hash.
+     *
+     * @param input the input String
+     * @return a hashed representation of the input String
+     */
+    static String hashPath(String input) {
+      return Hashing.murmur3_128().hashString(input, UTF_8).toString();
+    }
+  }
+
+  private static class CommentContextWeigher implements Weigher<CommentContextKey, CommentContext> {
+    @Override
+    public int weigh(CommentContextKey key, CommentContext commentContext) {
+      int size = 0;
+      size += key.id().length();
+      size += key.path().length();
+      size += key.project().get().length();
+      size += 4;
+      for (String line : commentContext.lines().values()) {
+        size += 4; // line number
+        size += line.length(); // number of characters in the context line
+      }
+      return size;
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/comment/CommentContextKey.java b/java/com/google/gerrit/server/comment/CommentContextKey.java
new file mode 100644
index 0000000..e4a927a
--- /dev/null
+++ b/java/com/google/gerrit/server/comment/CommentContextKey.java
@@ -0,0 +1,82 @@
+package com.google.gerrit.server.comment;
+
+import com.google.auto.value.AutoValue;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.proto.Protos;
+import com.google.gerrit.server.cache.proto.Cache;
+import com.google.gerrit.server.cache.serialize.CacheSerializer;
+import java.util.Collection;
+
+/**
+ * An identifier of a comment that should be used to load the comment context using {@link
+ * CommentContextCache#get(CommentContextKey)}, or {@link CommentContextCache#getAll(Collection)}.
+ *
+ * <p>The {@link CommentContextCacheImpl} implementation uses this class as the cache key, while
+ * replacing the {@link #path()} field with the hashed path.
+ */
+@AutoValue
+public abstract class CommentContextKey {
+  abstract Project.NameKey project();
+
+  abstract Change.Id changeId();
+
+  /** The unique comment ID. */
+  abstract String id();
+
+  /** File path at which the comment was written. */
+  abstract String path();
+
+  abstract Integer patchset();
+
+  abstract Builder toBuilder();
+
+  public static Builder builder() {
+    return new AutoValue_CommentContextKey.Builder();
+  }
+
+  @AutoValue.Builder
+  public abstract static class Builder {
+
+    public abstract Builder project(Project.NameKey nameKey);
+
+    public abstract Builder changeId(Change.Id changeId);
+
+    public abstract Builder id(String id);
+
+    public abstract Builder path(String path);
+
+    public abstract Builder patchset(Integer patchset);
+
+    public abstract CommentContextKey build();
+  }
+
+  public enum Serializer implements CacheSerializer<CommentContextKey> {
+    INSTANCE;
+
+    @Override
+    public byte[] serialize(CommentContextKey key) {
+      return Protos.toByteArray(
+          Cache.CommentContextKeyProto.newBuilder()
+              .setProject(key.project().get())
+              .setChangeId(key.changeId().toString())
+              .setPatchset(key.patchset())
+              .setPathHash(key.path())
+              .setCommentId(key.id())
+              .build());
+    }
+
+    @Override
+    public CommentContextKey deserialize(byte[] in) {
+      Cache.CommentContextKeyProto proto =
+          Protos.parseUnchecked(Cache.CommentContextKeyProto.parser(), in);
+      return CommentContextKey.builder()
+          .project(Project.NameKey.parse(proto.getProject()))
+          .changeId(Change.Id.tryParse(proto.getChangeId()).get())
+          .patchset(proto.getPatchset())
+          .id(proto.getCommentId())
+          .path(proto.getPathHash())
+          .build();
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/config/GerritGlobalModule.java b/java/com/google/gerrit/server/config/GerritGlobalModule.java
index 22d02d2..98a3179 100644
--- a/java/com/google/gerrit/server/config/GerritGlobalModule.java
+++ b/java/com/google/gerrit/server/config/GerritGlobalModule.java
@@ -114,6 +114,7 @@
 import com.google.gerrit.server.change.MergeabilityCacheImpl;
 import com.google.gerrit.server.change.ReviewerSuggestion;
 import com.google.gerrit.server.change.RevisionJson;
+import com.google.gerrit.server.comment.CommentContextCacheImpl;
 import com.google.gerrit.server.events.EventFactory;
 import com.google.gerrit.server.events.EventListener;
 import com.google.gerrit.server.events.EventsMetrics;
@@ -250,6 +251,7 @@
     install(TagCache.module());
     install(OAuthTokenCache.module());
     install(PureRevertCache.module());
+    install(CommentContextCacheImpl.module());
 
     install(new AccessControlModule());
     install(new CmdLineParserModule());
diff --git a/java/com/google/gerrit/server/events/EventGsonProvider.java b/java/com/google/gerrit/server/events/EventGsonProvider.java
index 688507b..72cf7be3 100644
--- a/java/com/google/gerrit/server/events/EventGsonProvider.java
+++ b/java/com/google/gerrit/server/events/EventGsonProvider.java
@@ -15,9 +15,8 @@
 package com.google.gerrit.server.events;
 
 import com.google.common.base.Supplier;
-import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.EntitiesAdapterFactory;
 import com.google.gerrit.entities.Project;
-import com.google.gerrit.server.change.ChangeKeyAdapter;
 import com.google.gson.Gson;
 import com.google.gson.GsonBuilder;
 import com.google.inject.Provider;
@@ -29,8 +28,8 @@
         .registerTypeAdapter(Event.class, new EventDeserializer())
         .registerTypeAdapter(Supplier.class, new SupplierSerializer())
         .registerTypeAdapter(Supplier.class, new SupplierDeserializer())
-        .registerTypeAdapter(Change.Key.class, new ChangeKeyAdapter())
         .registerTypeAdapter(Project.NameKey.class, new ProjectNameKeyAdapter())
+        .registerTypeAdapterFactory(EntitiesAdapterFactory.create())
         .create();
   }
 }
diff --git a/java/com/google/gerrit/server/git/GroupCollector.java b/java/com/google/gerrit/server/git/GroupCollector.java
index 9e0f2ee..5bbe5e2 100644
--- a/java/com/google/gerrit/server/git/GroupCollector.java
+++ b/java/com/google/gerrit/server/git/GroupCollector.java
@@ -29,7 +29,6 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
-import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.change.RevisionResource;
 import com.google.gerrit.server.git.receive.ReceivePackRefCache;
@@ -43,7 +42,6 @@
 import java.util.Set;
 import java.util.TreeSet;
 import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.revwalk.RevCommit;
 
 /**
@@ -231,7 +229,7 @@
 
   private boolean isGroupFromExistingPatchSet(RevCommit commit, String group) throws IOException {
     ObjectId id = parseGroup(commit, group);
-    return id != null && !receivePackRefCache.tipsFromObjectId(id, RefNames.REFS_CHANGES).isEmpty();
+    return id != null && !receivePackRefCache.patchSetIdsFromObjectId(id).isEmpty();
   }
 
   private Set<String> resolveGroups(ObjectId forCommit, Collection<String> candidates)
@@ -273,17 +271,13 @@
   private Iterable<String> resolveGroup(ObjectId forCommit, String group) throws IOException {
     ObjectId id = parseGroup(forCommit, group);
     if (id != null) {
-      Ref ref =
-          Iterables.getFirst(receivePackRefCache.tipsFromObjectId(id, RefNames.REFS_CHANGES), null);
-      if (ref != null) {
-        PatchSet.Id psId = PatchSet.Id.fromRef(ref.getName());
-        if (psId != null) {
-          List<String> groups = groupLookup.lookup(psId);
-          // Group for existing patch set may be missing, e.g. if group has not
-          // been migrated yet.
-          if (groups != null && !groups.isEmpty()) {
-            return groups;
-          }
+      PatchSet.Id psId = Iterables.getFirst(receivePackRefCache.patchSetIdsFromObjectId(id), null);
+      if (psId != null) {
+        List<String> groups = groupLookup.lookup(psId);
+        // Group for existing patch set may be missing, e.g. if group has not
+        // been migrated yet.
+        if (groups != null && !groups.isEmpty()) {
+          return groups;
         }
       }
     }
diff --git a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
index 3c70bfb..21bcc77 100644
--- a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
+++ b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
@@ -344,7 +344,7 @@
   private final RequestScopePropagator requestScopePropagator;
   private final Sequences seq;
   private final SetHashtagsOp.Factory hashtagsFactory;
-  private final SubmissionListener superprojectUpdateSubmissionListener;
+  private final ImmutableList<SubmissionListener> superprojectUpdateSubmissionListeners;
   private final TagCache tagCache;
   private final ProjectConfig.Factory projectConfigFactory;
   private final SetPrivateOp.Factory setPrivateOpFactory;
@@ -426,7 +426,8 @@
       RequestScopePropagator requestScopePropagator,
       Sequences seq,
       SetHashtagsOp.Factory hashtagsFactory,
-      @SuperprojectUpdateOnSubmission SubmissionListener superprojectUpdateSubmissionListener,
+      @SuperprojectUpdateOnSubmission
+          ImmutableList<SubmissionListener> superprojectUpdateSubmissionListeners,
       TagCache tagCache,
       SetPrivateOp.Factory setPrivateOpFactory,
       ReplyAttentionSetUpdates replyAttentionSetUpdates,
@@ -474,7 +475,7 @@
     this.retryHelper = retryHelper;
     this.requestScopePropagator = requestScopePropagator;
     this.seq = seq;
-    this.superprojectUpdateSubmissionListener = superprojectUpdateSubmissionListener;
+    this.superprojectUpdateSubmissionListeners = superprojectUpdateSubmissionListeners;
     this.tagCache = tagCache;
     this.projectConfigFactory = projectConfigFactory;
     this.setPrivateOpFactory = setPrivateOpFactory;
@@ -742,7 +743,7 @@
         logger.atFine().log("Added %d additional ref updates", added);
 
         SubmissionExecutor submissionExecutor =
-            new SubmissionExecutor(false, superprojectUpdateSubmissionListener);
+            new SubmissionExecutor(false, superprojectUpdateSubmissionListeners);
 
         submissionExecutor.execute(ImmutableList.of(bu));
 
@@ -2145,15 +2146,15 @@
           receivePack.getRevWalk().parseBody(c);
           String name = c.name();
           groupCollector.visit(c);
-          Collection<Ref> existingRefs =
-              receivePackRefCache.tipsFromObjectId(c, RefNames.REFS_CHANGES);
+          Collection<PatchSet.Id> existingPatchSets =
+              receivePackRefCache.patchSetIdsFromObjectId(c);
 
           if (rejectImplicitMerges) {
             Collections.addAll(mergedParents, c.getParents());
             mergedParents.remove(c);
           }
 
-          boolean commitAlreadyTracked = !existingRefs.isEmpty();
+          boolean commitAlreadyTracked = !existingPatchSets.isEmpty();
           if (commitAlreadyTracked) {
             alreadyTracked++;
             // Corner cases where an existing commit might need a new group:
@@ -2169,9 +2170,7 @@
             //      A's group.
             // C) Commit is a PatchSet of a pre-existing change uploaded with a
             //    different target branch.
-            existingRefs.stream()
-                .map(r -> PatchSet.Id.fromRef(r.getName()))
-                .filter(Objects::nonNull)
+            existingPatchSets.stream()
                 .forEach(i -> updateGroups.add(new UpdateGroupsRequest(i, c)));
             if (!(newChangeForAllNotInTarget || magicBranch.base != null)) {
               continue;
@@ -2312,8 +2311,7 @@
 
             // In case the change look up from the index failed,
             // double check against the existing refs
-            if (foundInExistingRef(
-                receivePackRefCache.tipsFromObjectId(p.commit, RefNames.REFS_CHANGES))) {
+            if (foundInExistingPatchSets(receivePackRefCache.patchSetIdsFromObjectId(p.commit))) {
               if (pending.size() == 1) {
                 reject(magicBranch.cmd, "commit(s) already exists (as current patchset)");
                 return Collections.emptyList();
@@ -2361,11 +2359,10 @@
     }
   }
 
-  private boolean foundInExistingRef(Collection<Ref> existingRefs) {
-    try (TraceTimer traceTimer = newTimer("foundInExistingRef")) {
-      for (Ref ref : existingRefs) {
-        ChangeNotes notes =
-            notesFactory.create(project.getNameKey(), Change.Id.fromRef(ref.getName()));
+  private boolean foundInExistingPatchSets(Collection<PatchSet.Id> existingPatchSets) {
+    try (TraceTimer traceTimer = newTimer("foundInExistingPatchSet")) {
+      for (PatchSet.Id psId : existingPatchSets) {
+        ChangeNotes notes = notesFactory.create(project.getNameKey(), psId.changeId());
         Change change = notes.getChange();
         if (change.getDest().equals(magicBranch.dest)) {
           logger.atFine().log("Found change %s from existing refs.", change.getKey());
@@ -2839,15 +2836,15 @@
           return false;
         }
 
-        List<Ref> existingChangesWithSameCommit =
-            receivePackRefCache.tipsFromObjectId(newCommit, RefNames.REFS_CHANGES);
-        if (!existingChangesWithSameCommit.isEmpty()) {
+        List<PatchSet.Id> existingPatchSetsWithSameCommit =
+            receivePackRefCache.patchSetIdsFromObjectId(newCommit);
+        if (!existingPatchSetsWithSameCommit.isEmpty()) {
           // TODO(hiesel, hanwen): Remove this check entirely when Gerrit requires change IDs
           //  without the option to turn that off.
           reject(
               inputCommand,
               "commit already exists (in the project): "
-                  + existingChangesWithSameCommit.get(0).getName());
+                  + existingPatchSetsWithSameCommit.get(0).toRefName());
           return false;
         }
 
@@ -3226,7 +3223,7 @@
                     "more than %d commits, and %s not set", limit, PUSH_OPTION_SKIP_VALIDATION));
             return;
           }
-          if (!receivePackRefCache.tipsFromObjectId(c, RefNames.REFS_CHANGES).isEmpty()) {
+          if (!receivePackRefCache.patchSetIdsFromObjectId(c).isEmpty()) {
             continue;
           }
 
@@ -3295,12 +3292,8 @@
 
                       // Check if change refs point to this commit. Usually there are 0-1 change
                       // refs pointing to this commit.
-                      for (Ref ref :
-                          receivePackRefCache.tipsFromObjectId(c.copy(), RefNames.REFS_CHANGES)) {
-                        if (!PatchSet.isChangeRef(ref.getName())) {
-                          continue;
-                        }
-                        PatchSet.Id psId = PatchSet.Id.fromRef(ref.getName());
+                      for (PatchSet.Id psId :
+                          receivePackRefCache.patchSetIdsFromObjectId(c.copy())) {
                         Optional<ChangeNotes> notes = getChangeNotes(psId.changeId());
                         if (notes.isPresent() && notes.get().getChange().getDest().equals(branch)) {
                           if (submissionId == null) {
diff --git a/java/com/google/gerrit/server/git/receive/ReceivePackRefCache.java b/java/com/google/gerrit/server/git/receive/ReceivePackRefCache.java
index 376ab2d..8568810 100644
--- a/java/com/google/gerrit/server/git/receive/ReceivePackRefCache.java
+++ b/java/com/google/gerrit/server/git/receive/ReceivePackRefCache.java
@@ -21,9 +21,11 @@
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.MultimapBuilder;
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.RefNames;
 import java.io.IOException;
 import java.util.Map;
+import java.util.Objects;
 import org.eclipse.jgit.annotations.Nullable;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Ref;
@@ -58,8 +60,8 @@
     return new WithAdvertisedRefs(allRefsSupplier);
   }
 
-  /** Returns a list of refs whose name starts with {@code prefix} that point to {@code id}. */
-  ImmutableList<Ref> tipsFromObjectId(ObjectId id, @Nullable String prefix) throws IOException;
+  /** Returns a list of {@link com.google.gerrit.entities.PatchSet.Id}s that point to {@code id}. */
+  ImmutableList<PatchSet.Id> patchSetIdsFromObjectId(ObjectId id) throws IOException;
 
   /** Returns all refs whose name starts with {@code prefix}. */
   ImmutableList<Ref> byPrefix(String prefix) throws IOException;
@@ -76,10 +78,10 @@
     }
 
     @Override
-    public ImmutableList<Ref> tipsFromObjectId(ObjectId id, @Nullable String prefix)
-        throws IOException {
+    public ImmutableList<PatchSet.Id> patchSetIdsFromObjectId(ObjectId id) throws IOException {
       return delegate.getTipsWithSha1(id).stream()
-          .filter(r -> prefix == null || r.getName().startsWith(prefix))
+          .map(r -> PatchSet.Id.fromRef(r.getName()))
+          .filter(Objects::nonNull)
           .collect(toImmutableList());
     }
 
@@ -113,10 +115,11 @@
     }
 
     @Override
-    public ImmutableList<Ref> tipsFromObjectId(ObjectId id, String prefix) {
+    public ImmutableList<PatchSet.Id> patchSetIdsFromObjectId(ObjectId id) {
       lazilyInitRefMaps();
       return refsByObjectId.get(id).stream()
-          .filter(r -> prefix == null || r.getName().startsWith(prefix))
+          .map(r -> PatchSet.Id.fromRef(r.getName()))
+          .filter(Objects::nonNull)
           .collect(toImmutableList());
     }
 
diff --git a/java/com/google/gerrit/server/index/IndexUtils.java b/java/com/google/gerrit/server/index/IndexUtils.java
index 9e3d91c..ee8dfc8 100644
--- a/java/com/google/gerrit/server/index/IndexUtils.java
+++ b/java/com/google/gerrit/server/index/IndexUtils.java
@@ -30,7 +30,7 @@
 import com.google.gerrit.server.index.account.AccountField;
 import com.google.gerrit.server.index.group.GroupField;
 import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gerrit.server.query.change.SingleGroupUser;
+import com.google.gerrit.server.query.change.GroupBackedUser;
 import java.io.IOException;
 import java.util.Set;
 import org.eclipse.jgit.errors.ConfigInvalidException;
@@ -107,7 +107,7 @@
     if (user.isIdentifiedUser()) {
       return user.getAccountId().toString();
     }
-    if (user instanceof SingleGroupUser) {
+    if (user instanceof GroupBackedUser) {
       return "group:" + user.getEffectiveGroups().getKnownGroups().iterator().next().toString();
     }
     return user.toString();
diff --git a/java/com/google/gerrit/server/logging/LoggingContext.java b/java/com/google/gerrit/server/logging/LoggingContext.java
index 671c224..a1b807d 100644
--- a/java/com/google/gerrit/server/logging/LoggingContext.java
+++ b/java/com/google/gerrit/server/logging/LoggingContext.java
@@ -42,11 +42,12 @@
   private static final ThreadLocal<MutableTags> tags = new ThreadLocal<>();
   private static final ThreadLocal<Boolean> forceLogging = new ThreadLocal<>();
   private static final ThreadLocal<Boolean> performanceLogging = new ThreadLocal<>();
+  private static final ThreadLocal<Boolean> aclLogging = new ThreadLocal<>();
 
   /**
-   * When copying the logging context to a new thread we need to ensure that the performance log
-   * records that are added in the new thread are added to the same {@link
-   * MutablePerformanceLogRecords} instance (see {@link LoggingContextAwareRunnable} and {@link
+   * When copying the logging context to a new thread we need to ensure that the mutable log records
+   * (performance logs and ACL logs) that are added in the new thread are added to the same multable
+   * log records instance (see {@link LoggingContextAwareRunnable} and {@link
    * LoggingContextAwareCallable}). This is important since performance log records are processed
    * only at the end of the request and performance log records that are created in another thread
    * should not get lost.
@@ -54,6 +55,8 @@
   private static final ThreadLocal<MutablePerformanceLogRecords> performanceLogRecords =
       new ThreadLocal<>();
 
+  private static final ThreadLocal<MutableAclLogRecords> aclLogRecords = new ThreadLocal<>();
+
   private LoggingContext() {}
 
   /** This method is expected to be called via reflection (and might otherwise be unused). */
@@ -67,7 +70,9 @@
     }
 
     return new LoggingContextAwareRunnable(
-        runnable, getInstance().getMutablePerformanceLogRecords());
+        runnable,
+        getInstance().getMutablePerformanceLogRecords(),
+        getInstance().getMutableAclRecords());
   }
 
   public static <T> Callable<T> copy(Callable<T> callable) {
@@ -76,14 +81,18 @@
     }
 
     return new LoggingContextAwareCallable<>(
-        callable, getInstance().getMutablePerformanceLogRecords());
+        callable,
+        getInstance().getMutablePerformanceLogRecords(),
+        getInstance().getMutableAclRecords());
   }
 
   public boolean isEmpty() {
     return tags.get() == null
         && forceLogging.get() == null
         && performanceLogging.get() == null
-        && performanceLogRecords.get() == null;
+        && performanceLogRecords.get() == null
+        && aclLogging.get() == null
+        && aclLogRecords.get() == null;
   }
 
   public void clear() {
@@ -91,6 +100,8 @@
     forceLogging.remove();
     performanceLogging.remove();
     performanceLogRecords.remove();
+    aclLogging.remove();
+    aclLogRecords.remove();
   }
 
   @Override
@@ -250,6 +261,101 @@
     return records;
   }
 
+  public boolean isAclLogging() {
+    Boolean isAclLogging = aclLogging.get();
+    return isAclLogging != null ? isAclLogging : false;
+  }
+
+  /**
+   * Enables ACL logging.
+   *
+   * <p>It's important to enable ACL logging only in a context that ensures to consume the captured
+   * ACL log records. Otherwise captured ACL log records might leak into other requests that are
+   * executed by the same thread (if a thread pool is used to process requests).
+   *
+   * @param enable whether ACL logging should be enabled.
+   * @return whether ACL logging was be enabled before invoking this method (old value).
+   */
+  boolean aclLogging(boolean enable) {
+    Boolean oldValue = aclLogging.get();
+    if (enable) {
+      aclLogging.set(true);
+    } else {
+      aclLogging.remove();
+    }
+    return oldValue != null ? oldValue : false;
+  }
+
+  /**
+   * Adds an ACL log record.
+   *
+   * @param aclLogRecord ACL log record
+   */
+  public void addAclLogRecord(String aclLogRecord) {
+    if (!isAclLogging()) {
+      return;
+    }
+
+    getMutableAclRecords().add(aclLogRecord);
+  }
+
+  ImmutableList<String> getAclLogRecords() {
+    MutableAclLogRecords records = aclLogRecords.get();
+    if (records != null) {
+      return records.list();
+    }
+    return ImmutableList.of();
+  }
+
+  void clearAclLogEntries() {
+    aclLogRecords.remove();
+  }
+
+  /**
+   * Set the ACL log records in this logging context. Existing log records are overwritten.
+   *
+   * <p>This method makes a defensive copy of the passed in list.
+   *
+   * @param newAclLogRecords ACL log records that should be set
+   */
+  void setAclLogRecords(List<String> newAclLogRecords) {
+    if (newAclLogRecords.isEmpty()) {
+      aclLogRecords.remove();
+      return;
+    }
+
+    getMutableAclRecords().set(newAclLogRecords);
+  }
+
+  /**
+   * Sets a {@link MutableAclLogRecords} instance for storing ACL log records.
+   *
+   * <p><strong>Attention:</strong> The passed in {@link MutableAclLogRecords} instance is directly
+   * stored in the logging context.
+   *
+   * <p>This method is intended to be only used when the logging context is copied to a new thread
+   * to ensure that the ACL log records that are added in the new thread are added to the same
+   * {@link MutableAclLogRecords} instance (see {@link LoggingContextAwareRunnable} and {@link
+   * LoggingContextAwareCallable}). This is important since ACL log records are processed only at
+   * the end of the request and ACL log records that are created in another thread should not get
+   * lost.
+   *
+   * @param mutableAclLogRecords the {@link MutableAclLogRecords} instance in which ACL log records
+   *     should be stored
+   */
+  void setMutableAclLogRecords(MutableAclLogRecords mutableAclLogRecords) {
+    aclLogRecords.set(requireNonNull(mutableAclLogRecords));
+  }
+
+  private MutableAclLogRecords getMutableAclRecords() {
+    MutableAclLogRecords records = aclLogRecords.get();
+    if (records == null) {
+      records = new MutableAclLogRecords();
+      aclLogRecords.set(records);
+    }
+    return records;
+  }
+
   @Override
   public String toString() {
     return MoreObjects.toStringHelper(this)
@@ -257,6 +363,8 @@
         .add("forceLogging", forceLogging.get())
         .add("performanceLogging", performanceLogging.get())
         .add("performanceLogRecords", performanceLogRecords.get())
+        .add("aclLogging", aclLogging.get())
+        .add("aclLogRecords", aclLogRecords.get())
         .toString();
   }
 }
diff --git a/java/com/google/gerrit/server/logging/LoggingContextAwareCallable.java b/java/com/google/gerrit/server/logging/LoggingContextAwareCallable.java
index 1adee1b..ab5db02 100644
--- a/java/com/google/gerrit/server/logging/LoggingContextAwareCallable.java
+++ b/java/com/google/gerrit/server/logging/LoggingContextAwareCallable.java
@@ -40,6 +40,8 @@
   private final boolean forceLogging;
   private final boolean performanceLogging;
   private final MutablePerformanceLogRecords mutablePerformanceLogRecords;
+  private final boolean aclLogging;
+  private final MutableAclLogRecords mutableAclLogRecords;
 
   /**
    * Creates a LoggingContextAwareCallable that wraps the given {@link Callable}.
@@ -47,15 +49,21 @@
    * @param callable Callable that should be wrapped.
    * @param mutablePerformanceLogRecords instance of {@link MutablePerformanceLogRecords} to which
    *     performance log records that are created from the runnable are added
+   * @param mutableAclLogRecords instance of {@link MutableAclLogRecords} to which ACL log records
+   *     that are created from the runnable are added
    */
   LoggingContextAwareCallable(
-      Callable<T> callable, MutablePerformanceLogRecords mutablePerformanceLogRecords) {
+      Callable<T> callable,
+      MutablePerformanceLogRecords mutablePerformanceLogRecords,
+      MutableAclLogRecords mutableAclLogRecords) {
     this.callable = callable;
     this.callingThread = Thread.currentThread();
     this.tags = LoggingContext.getInstance().getTagsAsMap();
     this.forceLogging = LoggingContext.getInstance().isLoggingForced();
     this.performanceLogging = LoggingContext.getInstance().isPerformanceLogging();
     this.mutablePerformanceLogRecords = mutablePerformanceLogRecords;
+    this.aclLogging = LoggingContext.getInstance().isAclLogging();
+    this.mutableAclLogRecords = mutableAclLogRecords;
   }
 
   @Override
@@ -76,6 +84,8 @@
     loggingCtx.forceLogging(forceLogging);
     loggingCtx.performanceLogging(performanceLogging);
     loggingCtx.setMutablePerformanceLogRecords(mutablePerformanceLogRecords);
+    loggingCtx.aclLogging(aclLogging);
+    loggingCtx.setMutableAclLogRecords(mutableAclLogRecords);
     try {
       return callable.call();
     } finally {
diff --git a/java/com/google/gerrit/server/logging/LoggingContextAwareRunnable.java b/java/com/google/gerrit/server/logging/LoggingContextAwareRunnable.java
index d0559cc..3c4c563 100644
--- a/java/com/google/gerrit/server/logging/LoggingContextAwareRunnable.java
+++ b/java/com/google/gerrit/server/logging/LoggingContextAwareRunnable.java
@@ -58,6 +58,8 @@
   private final boolean forceLogging;
   private final boolean performanceLogging;
   private final MutablePerformanceLogRecords mutablePerformanceLogRecords;
+  private final boolean aclLogging;
+  private final MutableAclLogRecords mutableAclLogRecords;
 
   /**
    * Creates a LoggingContextAwareRunnable that wraps the given {@link Runnable}.
@@ -65,15 +67,21 @@
    * @param runnable Runnable that should be wrapped.
    * @param mutablePerformanceLogRecords instance of {@link MutablePerformanceLogRecords} to which
    *     performance log records that are created from the runnable are added
+   * @param mutableAclLogRecords instance of {@link MutableAclLogRecords} to which ACL log records
+   *     that are created from the runnable are added
    */
   LoggingContextAwareRunnable(
-      Runnable runnable, MutablePerformanceLogRecords mutablePerformanceLogRecords) {
+      Runnable runnable,
+      MutablePerformanceLogRecords mutablePerformanceLogRecords,
+      MutableAclLogRecords mutableAclLogRecords) {
     this.runnable = runnable;
     this.callingThread = Thread.currentThread();
     this.tags = LoggingContext.getInstance().getTagsAsMap();
     this.forceLogging = LoggingContext.getInstance().isLoggingForced();
     this.performanceLogging = LoggingContext.getInstance().isPerformanceLogging();
     this.mutablePerformanceLogRecords = mutablePerformanceLogRecords;
+    this.aclLogging = LoggingContext.getInstance().isAclLogging();
+    this.mutableAclLogRecords = mutableAclLogRecords;
   }
 
   public Runnable unwrap() {
@@ -99,6 +107,8 @@
     loggingCtx.forceLogging(forceLogging);
     loggingCtx.performanceLogging(performanceLogging);
     loggingCtx.setMutablePerformanceLogRecords(mutablePerformanceLogRecords);
+    loggingCtx.aclLogging(aclLogging);
+    loggingCtx.setMutableAclLogRecords(mutableAclLogRecords);
     try {
       runnable.run();
     } finally {
diff --git a/java/com/google/gerrit/server/logging/MutableAclLogRecords.java b/java/com/google/gerrit/server/logging/MutableAclLogRecords.java
new file mode 100644
index 0000000..baa9b1f
--- /dev/null
+++ b/java/com/google/gerrit/server/logging/MutableAclLogRecords.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.server.logging;
+
+import com.google.common.base.MoreObjects;
+import com.google.common.collect.ImmutableList;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Thread-safe store for ACL log records.
+ *
+ * <p>This class is intended to keep track of user ACL records in {@link LoggingContext}. It needs
+ * to be thread-safe because it gets shared between threads when the logging context is copied to
+ * another thread (see {@link LoggingContextAwareRunnable} and {@link LoggingContextAwareCallable}.
+ * In this case the logging contexts of both threads share the same instance of this class. This is
+ * important since ACL log records are processed only at the end of a request and user ACL records
+ * that are created in another thread should not get lost.
+ */
+public class MutableAclLogRecords {
+  private final ArrayList<String> aclLogRecords = new ArrayList<>();
+
+  public synchronized void add(String record) {
+    aclLogRecords.add(record);
+  }
+
+  public synchronized void set(List<String> records) {
+    aclLogRecords.clear();
+    aclLogRecords.addAll(records);
+  }
+
+  public synchronized ImmutableList<String> list() {
+    return ImmutableList.copyOf(aclLogRecords);
+  }
+
+  @Override
+  public String toString() {
+    return MoreObjects.toStringHelper(this).add("aclLogRecords", aclLogRecords).toString();
+  }
+}
diff --git a/java/com/google/gerrit/server/logging/TraceContext.java b/java/com/google/gerrit/server/logging/TraceContext.java
index 21a4ce6..2fc19b5 100644
--- a/java/com/google/gerrit/server/logging/TraceContext.java
+++ b/java/com/google/gerrit/server/logging/TraceContext.java
@@ -19,6 +19,7 @@
 import com.google.common.base.Stopwatch;
 import com.google.common.base.Strings;
 import com.google.common.collect.HashBasedTable;
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Table;
 import com.google.common.flogger.FluentLogger;
@@ -222,9 +223,17 @@
   // Table<TAG_NAME, TAG_VALUE, REMOVE_ON_CLOSE>
   private final Table<String, String, Boolean> tags = HashBasedTable.create();
 
-  private boolean stopForceLoggingOnClose;
+  private final boolean oldAclLogging;
+  private final ImmutableList<String> oldAclLogRecords;
 
-  private TraceContext() {}
+  private boolean stopForceLoggingOnClose;
+  private boolean stopAclLoggingOnClose;
+
+  private TraceContext() {
+    // Just in case remember the old state and reset ACL log entries.
+    this.oldAclLogging = LoggingContext.getInstance().isAclLogging();
+    this.oldAclLogRecords = LoggingContext.getInstance().getAclLogRecords();
+  }
 
   public TraceContext addTag(RequestId.Type requestId, Object tagValue) {
     return addTag(requireNonNull(requestId, "request ID is required").name(), tagValue);
@@ -265,6 +274,23 @@
         .findFirst();
   }
 
+  public TraceContext enableAclLogging() {
+    if (stopAclLoggingOnClose) {
+      return this;
+    }
+
+    stopAclLoggingOnClose = !LoggingContext.getInstance().aclLogging(true);
+    return this;
+  }
+
+  public boolean isAclLoggingEnabled() {
+    return LoggingContext.getInstance().isAclLogging();
+  }
+
+  public ImmutableList<String> getAclLogRecords() {
+    return LoggingContext.getInstance().getAclLogRecords();
+  }
+
   @Override
   public void close() {
     for (Table.Cell<String, String, Boolean> cell : tags.cellSet()) {
@@ -275,5 +301,10 @@
     if (stopForceLoggingOnClose) {
       LoggingContext.getInstance().forceLogging(false);
     }
+
+    if (stopAclLoggingOnClose) {
+      LoggingContext.getInstance().aclLogging(oldAclLogging);
+      LoggingContext.getInstance().setAclLogRecords(oldAclLogRecords);
+    }
   }
 }
diff --git a/java/com/google/gerrit/server/mail/EmailSettings.java b/java/com/google/gerrit/server/mail/EmailSettings.java
index c411af5..1ac6eb6 100644
--- a/java/com/google/gerrit/server/mail/EmailSettings.java
+++ b/java/com/google/gerrit/server/mail/EmailSettings.java
@@ -38,6 +38,7 @@
   public final Encryption encryption;
   public final long fetchInterval; // in milliseconds
   public final boolean sendNewPatchsetEmails;
+  public final boolean isAttentionSetEnabled;
 
   @Inject
   EmailSettings(@GerritServerConfig Config cfg) {
@@ -60,5 +61,6 @@
             TimeUnit.MILLISECONDS.convert(60, TimeUnit.SECONDS),
             TimeUnit.MILLISECONDS);
     sendNewPatchsetEmails = cfg.getBoolean("change", null, "sendNewPatchsetEmails", true);
+    isAttentionSetEnabled = cfg.getBoolean("change", null, "enableAttentionSet", false);
   }
 }
diff --git a/java/com/google/gerrit/server/mail/send/ChangeEmail.java b/java/com/google/gerrit/server/mail/send/ChangeEmail.java
index 7b2bf12..8d76e23 100644
--- a/java/com/google/gerrit/server/mail/send/ChangeEmail.java
+++ b/java/com/google/gerrit/server/mail/send/ChangeEmail.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.mail.send;
 
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
 import static com.google.gerrit.server.util.AttentionSetUtil.additionsOnly;
 
 import com.google.common.base.Splitter;
@@ -33,9 +34,11 @@
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.api.changes.RecipientType;
+import com.google.gerrit.extensions.client.GeneralPreferencesInfo.EmailStrategy;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.mail.MailHeader;
 import com.google.gerrit.server.StarredChangesUtil;
+import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.mail.send.ProjectWatch.Watchers;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
 import com.google.gerrit.server.patch.PatchList;
@@ -56,6 +59,7 @@
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.Map;
+import java.util.Optional;
 import java.util.Set;
 import java.util.TreeSet;
 import java.util.stream.Collectors;
@@ -75,6 +79,7 @@
     return ea.changeDataFactory.create(project, id);
   }
 
+  private final Set<Account.Id> currentAttentionSet;
   protected final Change change;
   protected final ChangeData changeData;
   protected ListMultimap<Account.Id, String> stars;
@@ -92,6 +97,7 @@
     this.changeData = changeData;
     this.change = changeData.change();
     this.emailOnlyAuthors = false;
+    this.currentAttentionSet = getAttentionSet();
   }
 
   @Override
@@ -123,6 +129,10 @@
   /** Format the message body by calling {@link #appendText(String)}. */
   @Override
   protected void format() throws EmailException {
+    if (useHtml()) {
+      appendHtml(soyHtmlTemplate("ChangeHeaderHtml"));
+    }
+    appendText(textTemplate("ChangeHeader"));
     formatChange();
     appendText(textTemplate("ChangeFooter"));
     if (useHtml()) {
@@ -387,9 +397,19 @@
 
   @Override
   protected void add(RecipientType rt, Account.Id to) {
-    if (!emailOnlyAuthors || authors.contains(to)) {
-      super.add(rt, to);
+    Optional<AccountState> accountState = args.accountCache.get(to);
+    if (!accountState.isPresent()) {
+      return;
     }
+    if (accountState.get().generalPreferences().getEmailStrategy()
+            == EmailStrategy.ATTENTION_SET_ONLY
+        && !currentAttentionSet.contains(to)) {
+      return;
+    }
+    if (emailOnlyAuthors && !authors.contains(to)) {
+      return;
+    }
+    super.add(rt, to);
   }
 
   @Override
@@ -487,8 +507,15 @@
     for (String reviewer : getEmailsByState(ReviewerStateInternal.CC)) {
       footers.add(MailHeader.CC.withDelimiter() + reviewer);
     }
-    for (String attentionUser : getAttentionSet()) {
-      footers.add(MailHeader.ATTENTION.withDelimiter() + attentionUser);
+    for (Account.Id attentionUser : currentAttentionSet) {
+      footers.add(MailHeader.ATTENTION.withDelimiter() + getNameEmailFor(attentionUser));
+    }
+    // Since this would be user visible, only show it if attention set is enabled
+    if (args.settings.isAttentionSetEnabled && !currentAttentionSet.isEmpty()) {
+      // We need names rather than account ids / emails to make it user readable.
+      soyContext.put(
+          "attentionSet",
+          currentAttentionSet.stream().map(this::getNameFor).collect(toImmutableSet()));
     }
   }
 
@@ -515,12 +542,12 @@
     return reviewers;
   }
 
-  private Set<String> getAttentionSet() {
-    Set<String> attentionSet = new TreeSet<>();
+  private Set<Account.Id> getAttentionSet() {
+    Set<Account.Id> attentionSet = new TreeSet<>();
     try {
       attentionSet =
           additionsOnly(changeData.attentionSet()).stream()
-              .map(a -> getNameEmailFor(a.account()))
+              .map(a -> a.account())
               .collect(Collectors.toSet());
     } catch (StorageException e) {
       logger.atWarning().withCause(e).log("Cannot get change attention set");
diff --git a/java/com/google/gerrit/server/mail/send/MailSoySauceProvider.java b/java/com/google/gerrit/server/mail/send/MailSoySauceProvider.java
index 623bdc2..1b58057 100644
--- a/java/com/google/gerrit/server/mail/send/MailSoySauceProvider.java
+++ b/java/com/google/gerrit/server/mail/send/MailSoySauceProvider.java
@@ -45,6 +45,8 @@
     "AddToAttentionSetHtml.soy",
     "ChangeFooter.soy",
     "ChangeFooterHtml.soy",
+    "ChangeHeader.soy",
+    "ChangeHeaderHtml.soy",
     "ChangeSubject.soy",
     "Comment.soy",
     "CommentHtml.soy",
@@ -60,7 +62,6 @@
     "InboundEmailRejectionHtml.soy",
     "Footer.soy",
     "FooterHtml.soy",
-    "HeaderHtml.soy",
     "HttpPasswordUpdate.soy",
     "HttpPasswordUpdateHtml.soy",
     "Merged.soy",
diff --git a/java/com/google/gerrit/server/mail/send/OutgoingEmail.java b/java/com/google/gerrit/server/mail/send/OutgoingEmail.java
index 1eb274b..bef5317 100644
--- a/java/com/google/gerrit/server/mail/send/OutgoingEmail.java
+++ b/java/com/google/gerrit/server/mail/send/OutgoingEmail.java
@@ -116,9 +116,6 @@
     if (messageId == null) {
       throw new IllegalStateException("All emails must have a messageId");
     }
-    if (useHtml()) {
-      appendHtml(soyHtmlTemplate("HeaderHtml"));
-    }
     format();
     appendText(textTemplate("Footer"));
     if (useHtml()) {
diff --git a/java/com/google/gerrit/server/mail/send/ProjectWatch.java b/java/com/google/gerrit/server/mail/send/ProjectWatch.java
index 0514337..173b121 100644
--- a/java/com/google/gerrit/server/mail/send/ProjectWatch.java
+++ b/java/com/google/gerrit/server/mail/send/ProjectWatch.java
@@ -33,7 +33,7 @@
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.ChangeQueryBuilder;
-import com.google.gerrit.server.query.change.SingleGroupUser;
+import com.google.gerrit.server.query.change.GroupBackedUser;
 import java.util.ArrayList;
 import java.util.HashSet;
 import java.util.List;
@@ -150,7 +150,7 @@
       throws QueryParseException {
     logger.atFine().log("Checking watchers for notify config %s from project %s", nc, projectName);
     for (GroupReference groupRef : nc.getGroups()) {
-      CurrentUser user = new SingleGroupUser(groupRef.getUUID());
+      CurrentUser user = new GroupBackedUser(ImmutableSet.of(groupRef.getUUID()));
       if (filterMatch(user, nc.getFilter())) {
         deliverToMembers(matching.list(nc.getHeader()), groupRef.getUUID());
         logger.atFine().log("Added watchers for group %s", groupRef);
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotes.java b/java/com/google/gerrit/server/notedb/ChangeNotes.java
index e83d4fd..890a1b1 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotes.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotes.java
@@ -77,6 +77,9 @@
 import org.eclipse.jgit.lib.Repository;
 
 /** View of a single {@link Change} based on the log of its notes branch. */
+// TODO(paiking): This class should be refactored to get rid of potentially duplicate or unneeded
+// variables, such as allAttentionSetUpdates, reviewerUpdates, and others.
+
 public class ChangeNotes extends AbstractChangeNotes<ChangeNotes> {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
@@ -391,6 +394,11 @@
     return state.attentionSet();
   }
 
+  /** Returns all updates for the attention set. */
+  public ImmutableList<AttentionSetUpdate> getAttentionSetUpdates() {
+    return state.allAttentionSetUpdates();
+  }
+
   /**
    * @return an ImmutableSet of Account.Ids of all users that have been assigned to this change. The
    *     order of the set is the order in which they were assigned.
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotesCache.java b/java/com/google/gerrit/server/notedb/ChangeNotesCache.java
index 7fde297..1650421 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotesCache.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotesCache.java
@@ -168,6 +168,10 @@
           + P
           + list(state.assigneeUpdates(), 4 * O + K + K)
           + P
+          + set(state.attentionSet(), 4 * O + K + I + str(15))
+          + P
+          + list(state.allAttentionSetUpdates(), 4 * O + K + I + str(15))
+          + P
           + list(state.submitRecords(), P + list(2, str(4) + P + K) + P)
           + P
           + list(state.changeMessages(), changeMessage())
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotesParser.java b/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
index c92d236..fae29f8 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
@@ -118,6 +118,8 @@
   private final List<ReviewerStatusUpdate> reviewerUpdates;
   /** Holds only the most recent update per user. Older updates are discarded. */
   private final Map<Account.Id, AttentionSetUpdate> latestAttentionStatus;
+  /** Holds all updates to attention set. */
+  private final List<AttentionSetUpdate> allAttentionSetUpdates;
 
   private final List<AssigneeStatusUpdate> assigneeUpdates;
   private final List<SubmitRecord> submitRecords;
@@ -175,6 +177,7 @@
     allPastReviewers = new ArrayList<>();
     reviewerUpdates = new ArrayList<>();
     latestAttentionStatus = new HashMap<>();
+    allAttentionSetUpdates = new ArrayList<>();
     assigneeUpdates = new ArrayList<>();
     submitRecords = Lists.newArrayListWithExpectedSize(1);
     allChangeMessages = new ArrayList<>();
@@ -246,6 +249,7 @@
         allPastReviewers,
         buildReviewerUpdates(),
         ImmutableSet.copyOf(latestAttentionStatus.values()),
+        allAttentionSetUpdates,
         assigneeUpdates,
         submitRecords,
         buildAllMessages(),
@@ -589,6 +593,9 @@
       }
       // Processing is in reverse chronological order. Keep only the latest update.
       latestAttentionStatus.putIfAbsent(attentionStatus.get().account(), attentionStatus.get());
+
+      // Keep all updates as well.
+      allAttentionSetUpdates.add(attentionStatus.get());
     }
   }
 
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotesState.java b/java/com/google/gerrit/server/notedb/ChangeNotesState.java
index 76c4678..fa32686 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotesState.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotesState.java
@@ -84,6 +84,9 @@
  * <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.
  */
+// TODO(paiking): This class should be refactored to get rid of potentially duplicate or unneeded
+// variables, such as allAttentionSetUpdates, reviewerUpdates, and others.
+
 @AutoValue
 public abstract class ChangeNotesState {
 
@@ -120,6 +123,7 @@
       List<Account.Id> allPastReviewers,
       List<ReviewerStatusUpdate> reviewerUpdates,
       Set<AttentionSetUpdate> attentionSetUpdates,
+      List<AttentionSetUpdate> allAttentionSetUpdates,
       List<AssigneeStatusUpdate> assigneeUpdates,
       List<SubmitRecord> submitRecords,
       List<ChangeMessage> changeMessages,
@@ -171,6 +175,7 @@
         .allPastReviewers(allPastReviewers)
         .reviewerUpdates(reviewerUpdates)
         .attentionSet(attentionSetUpdates)
+        .allAttentionSetUpdates(allAttentionSetUpdates)
         .assigneeUpdates(assigneeUpdates)
         .submitRecords(submitRecords)
         .changeMessages(changeMessages)
@@ -305,9 +310,12 @@
 
   abstract ImmutableList<ReviewerStatusUpdate> reviewerUpdates();
 
-  /** Returns the most recent update (i.e. current status status) per user. */
+  /** Returns the most recent update (i.e. current status) per user. */
   abstract ImmutableSet<AttentionSetUpdate> attentionSet();
 
+  /** Returns all attention set updates. */
+  abstract ImmutableList<AttentionSetUpdate> allAttentionSetUpdates();
+
   abstract ImmutableList<AssigneeStatusUpdate> assigneeUpdates();
 
   abstract ImmutableList<SubmitRecord> submitRecords();
@@ -386,6 +394,7 @@
           .allPastReviewers(ImmutableList.of())
           .reviewerUpdates(ImmutableList.of())
           .attentionSet(ImmutableSet.of())
+          .allAttentionSetUpdates(ImmutableList.of())
           .assigneeUpdates(ImmutableList.of())
           .submitRecords(ImmutableList.of())
           .changeMessages(ImmutableList.of())
@@ -421,6 +430,8 @@
 
     abstract Builder attentionSet(Set<AttentionSetUpdate> attentionSetUpdates);
 
+    abstract Builder allAttentionSetUpdates(List<AttentionSetUpdate> attentionSetUpdates);
+
     abstract Builder assigneeUpdates(List<AssigneeStatusUpdate> assigneeUpdates);
 
     abstract Builder submitRecords(List<SubmitRecord> submitRecords);
@@ -489,6 +500,9 @@
       object.allPastReviewers().forEach(a -> b.addPastReviewer(a.get()));
       object.reviewerUpdates().forEach(u -> b.addReviewerUpdate(toReviewerStatusUpdateProto(u)));
       object.attentionSet().forEach(u -> b.addAttentionSetUpdate(toAttentionSetUpdateProto(u)));
+      object
+          .allAttentionSetUpdates()
+          .forEach(u -> b.addAllAttentionSetUpdate(toAttentionSetUpdateProto(u)));
       object.assigneeUpdates().forEach(u -> b.addAssigneeUpdate(toAssigneeStatusUpdateProto(u)));
       object
           .submitRecords()
@@ -623,6 +637,8 @@
                   proto.getPastReviewerList().stream().map(Account::id).collect(toImmutableList()))
               .reviewerUpdates(toReviewerStatusUpdateList(proto.getReviewerUpdateList()))
               .attentionSet(toAttentionSetUpdates(proto.getAttentionSetUpdateList()))
+              .allAttentionSetUpdates(
+                  toAllAttentionSetUpdates(proto.getAllAttentionSetUpdateList()))
               .assigneeUpdates(toAssigneeStatusUpdateList(proto.getAssigneeUpdateList()))
               .submitRecords(
                   proto.getSubmitRecordList().stream()
@@ -735,6 +751,20 @@
       return b.build();
     }
 
+    private static ImmutableList<AttentionSetUpdate> toAllAttentionSetUpdates(
+        List<AttentionSetUpdateProto> protos) {
+      ImmutableList.Builder<AttentionSetUpdate> b = ImmutableList.builder();
+      for (AttentionSetUpdateProto proto : protos) {
+        b.add(
+            AttentionSetUpdate.createFromRead(
+                Instant.ofEpochMilli(proto.getTimestampMillis()),
+                Account.id(proto.getAccount()),
+                AttentionSetUpdate.Operation.valueOf(proto.getOperation()),
+                proto.getReason()));
+      }
+      return b.build();
+    }
+
     private static ImmutableList<AssigneeStatusUpdate> toAssigneeStatusUpdateList(
         List<AssigneeStatusUpdateProto> protos) {
       ImmutableList.Builder<AssigneeStatusUpdate> b = ImmutableList.builder();
diff --git a/java/com/google/gerrit/server/permissions/DefaultPermissionBackend.java b/java/com/google/gerrit/server/permissions/DefaultPermissionBackend.java
index cf6a184..49df653 100644
--- a/java/com/google/gerrit/server/permissions/DefaultPermissionBackend.java
+++ b/java/com/google/gerrit/server/permissions/DefaultPermissionBackend.java
@@ -34,7 +34,6 @@
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.PeerDaemonUser;
 import com.google.gerrit.server.account.CapabilityCollection;
-import com.google.gerrit.server.cache.PerThreadCache;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.inject.Inject;
@@ -42,15 +41,12 @@
 import com.google.inject.Singleton;
 import java.util.Collection;
 import java.util.List;
-import java.util.Optional;
 import java.util.Set;
 
 @Singleton
 public class DefaultPermissionBackend extends PermissionBackend {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  private static final CurrentUser.PropertyKey<Boolean> IS_ADMIN = CurrentUser.PropertyKey.create();
-
   private final Provider<CurrentUser> currentUser;
   private final ProjectCache projectCache;
   private final ProjectControl.Factory projectControlFactory;
@@ -105,11 +101,7 @@
     public ForProject project(Project.NameKey project) {
       try {
         ProjectState state = projectCache.get(project).orElseThrow(illegalState(project));
-        ProjectControl control =
-            PerThreadCache.getOrCompute(
-                PerThreadCache.Key.create(ProjectControl.class, project, user.getCacheKey()),
-                () -> projectControlFactory.create(user, state));
-        return control.asForProject();
+        return projectControlFactory.create(user, state).asForProject();
       } catch (Exception e) {
         Throwable cause = e.getCause() != null ? e.getCause() : e;
         return FailedPermissionBackend.project(
@@ -202,21 +194,13 @@
     }
 
     private Boolean computeAdmin() {
-      Optional<Boolean> r = user.get(IS_ADMIN);
-      if (r.isPresent()) {
-        return r.get();
-      }
-
-      boolean isAdmin;
       if (user.isImpersonating()) {
-        isAdmin = false;
-      } else if (user instanceof PeerDaemonUser) {
-        isAdmin = true;
-      } else {
-        isAdmin = allow(capabilities().administrateServer);
+        return false;
       }
-      user.put(IS_ADMIN, isAdmin);
-      return isAdmin;
+      if (user instanceof PeerDaemonUser) {
+        return true;
+      }
+      return allow(capabilities().administrateServer);
     }
 
     private boolean canEmailReviewers() {
diff --git a/java/com/google/gerrit/server/permissions/RefControl.java b/java/com/google/gerrit/server/permissions/RefControl.java
index bc802cc..e704a99 100644
--- a/java/com/google/gerrit/server/permissions/RefControl.java
+++ b/java/com/google/gerrit/server/permissions/RefControl.java
@@ -29,6 +29,7 @@
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.logging.CallerFinder;
+import com.google.gerrit.server.logging.LoggingContext;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.permissions.PermissionBackend.ForChange;
 import com.google.gerrit.server.permissions.PermissionBackend.ForRef;
@@ -397,40 +398,52 @@
   /** True if the user has this permission. */
   private boolean canPerform(String permissionName, boolean isChangeOwner, boolean withForce) {
     if (isBlocked(permissionName, isChangeOwner, withForce)) {
-      logger.atFine().log(
-          "'%s' cannot perform '%s' with force=%s on project '%s' for ref '%s'"
-              + " because this permission is blocked (caller: %s)",
-          getUser().getLoggableName(),
-          permissionName,
-          withForce,
-          projectControl.getProject().getName(),
-          refName,
-          callerFinder.findCallerLazy());
+      if (logger.atFine().isEnabled() || LoggingContext.getInstance().isAclLogging()) {
+        String logMessage =
+            String.format(
+                "'%s' cannot perform '%s' with force=%s on project '%s' for ref '%s'"
+                    + " because this permission is blocked",
+                getUser().getLoggableName(),
+                permissionName,
+                withForce,
+                projectControl.getProject().getName(),
+                refName);
+        LoggingContext.getInstance().addAclLogRecord(logMessage);
+        logger.atFine().log("%s (caller: %s)", logMessage, callerFinder.findCallerLazy());
+      }
       return false;
     }
 
     for (PermissionRule pr : relevant.getAllowRules(permissionName)) {
       if (isAllow(pr, withForce) && projectControl.match(pr, isChangeOwner)) {
-        logger.atFine().log(
-            "'%s' can perform '%s' with force=%s on project '%s' for ref '%s' (caller: %s)",
-            getUser().getLoggableName(),
-            permissionName,
-            withForce,
-            projectControl.getProject().getName(),
-            refName,
-            callerFinder.findCallerLazy());
+        if (logger.atFine().isEnabled() || LoggingContext.getInstance().isAclLogging()) {
+          String logMessage =
+              String.format(
+                  "'%s' can perform '%s' with force=%s on project '%s' for ref '%s'",
+                  getUser().getLoggableName(),
+                  permissionName,
+                  withForce,
+                  projectControl.getProject().getName(),
+                  refName);
+          LoggingContext.getInstance().addAclLogRecord(logMessage);
+          logger.atFine().log("%s (caller: %s)", logMessage, callerFinder.findCallerLazy());
+        }
         return true;
       }
     }
 
-    logger.atFine().log(
-        "'%s' cannot perform '%s' with force=%s on project '%s' for ref '%s' (caller: %s)",
-        getUser().getLoggableName(),
-        permissionName,
-        withForce,
-        projectControl.getProject().getName(),
-        refName,
-        callerFinder.findCallerLazy());
+    if (logger.atFine().isEnabled() || LoggingContext.getInstance().isAclLogging()) {
+      String logMessage =
+          String.format(
+              "'%s' cannot perform '%s' with force=%s on project '%s' for ref '%s'",
+              getUser().getLoggableName(),
+              permissionName,
+              withForce,
+              projectControl.getProject().getName(),
+              refName);
+      LoggingContext.getInstance().addAclLogRecord(logMessage);
+      logger.atFine().log("%s (caller: %s)", logMessage, callerFinder.findCallerLazy());
+    }
     return false;
   }
 
diff --git a/java/com/google/gerrit/server/query/change/ChangeIsVisibleToPredicate.java b/java/com/google/gerrit/server/query/change/ChangeIsVisibleToPredicate.java
index c6bcd60..a66c43ae 100644
--- a/java/com/google/gerrit/server/query/change/ChangeIsVisibleToPredicate.java
+++ b/java/com/google/gerrit/server/query/change/ChangeIsVisibleToPredicate.java
@@ -88,7 +88,7 @@
             ? permissionBackend.absentUser(user.getAccountId())
             : permissionBackend.user(
                 Optional.of(user)
-                    .filter(u -> u instanceof SingleGroupUser || u instanceof InternalUser)
+                    .filter(u -> u instanceof GroupBackedUser || u instanceof InternalUser)
                     .orElseGet(anonymousUserProvider::get));
     try {
       withUser.change(cd).check(ChangePermission.READ);
diff --git a/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java b/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
index 464ba81..6f4ccb7 100644
--- a/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
+++ b/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
@@ -1026,7 +1026,7 @@
       for (GroupReference ref : suggestions) {
         ids.add(ref.getUUID());
       }
-      return visibleto(new SingleGroupUser(ids));
+      return visibleto(new GroupBackedUser(ids));
     }
 
     throw error("No user or group matches \"" + who + "\".");
diff --git a/java/com/google/gerrit/server/query/change/GroupBackedUser.java b/java/com/google/gerrit/server/query/change/GroupBackedUser.java
new file mode 100644
index 0000000..dac555d
--- /dev/null
+++ b/java/com/google/gerrit/server/query/change/GroupBackedUser.java
@@ -0,0 +1,67 @@
+// Copyright (C) 2009 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.query.change;
+
+import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.account.GroupMembership;
+import com.google.gerrit.server.account.ListGroupMembership;
+import java.util.Set;
+
+/**
+ * Representation of a user that does not have a Gerrit account.
+ *
+ * <p>This user representation is intended to be used for two purposes:
+ *
+ * <ol>
+ *   <li>Checking permissions for groups: There are occasions where we need to check if a resource -
+ *       such as a change - is accessible by a group. Our entire {@link
+ *       com.google.gerrit.server.permissions.PermissionBackend} works solely with {@link
+ *       CurrentUser}. This class can be used to check permissions on a synthetic user with the
+ *       given group memberships. Any real Gerrit user with the same group memberships would receive
+ *       the same permission check results.
+ *   <li>Checking permissions for an external user: In installations with external group systems,
+ *       one might want to check what Gerrit permissions a user has, before or even without creating
+ *       a Gerrit account. Such an external user has external group memberships only as well as
+ *       internal groups that contain the user's external groups as subgroups. This class can be
+ *       used to represent such an external user.
+ * </ol>
+ */
+public final class GroupBackedUser extends CurrentUser {
+  private final GroupMembership groups;
+
+  /**
+   * Creates a new instance
+   *
+   * @param groups this set has to include all parent groups the user is contained in through
+   *     subgroup membership. Given a set of groups that contains the user directly, callers can use
+   *     {@link
+   *     com.google.gerrit.server.account.GroupIncludeCache#parentGroupsOf(AccountGroup.UUID)} to
+   *     resolve parent groups.
+   */
+  public GroupBackedUser(Set<AccountGroup.UUID> groups) {
+    this.groups = new ListGroupMembership(groups);
+  }
+
+  @Override
+  public GroupMembership getEffectiveGroups() {
+    return groups;
+  }
+
+  @Override
+  public String getLoggableName() {
+    return "GroupBackedUser with memberships: " + groups.getKnownGroups();
+  }
+}
diff --git a/java/com/google/gerrit/server/query/change/SingleGroupUser.java b/java/com/google/gerrit/server/query/change/SingleGroupUser.java
deleted file mode 100644
index c451d46..0000000
--- a/java/com/google/gerrit/server/query/change/SingleGroupUser.java
+++ /dev/null
@@ -1,44 +0,0 @@
-// Copyright (C) 2009 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.query.change;
-
-import com.google.common.collect.ImmutableSet;
-import com.google.gerrit.entities.AccountGroup;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.account.GroupMembership;
-import com.google.gerrit.server.account.ListGroupMembership;
-import java.util.Set;
-
-public final class SingleGroupUser extends CurrentUser {
-  private final GroupMembership groups;
-
-  public SingleGroupUser(AccountGroup.UUID groupId) {
-    this(ImmutableSet.of(groupId));
-  }
-
-  public SingleGroupUser(Set<AccountGroup.UUID> groups) {
-    this.groups = new ListGroupMembership(groups);
-  }
-
-  @Override
-  public GroupMembership getEffectiveGroups() {
-    return groups;
-  }
-
-  @Override
-  public Object getCacheKey() {
-    return groups.getKnownGroups();
-  }
-}
diff --git a/java/com/google/gerrit/server/restapi/account/PostWatchedProjects.java b/java/com/google/gerrit/server/restapi/account/PostWatchedProjects.java
index c80bf57..5979b2a 100644
--- a/java/com/google/gerrit/server/restapi/account/PostWatchedProjects.java
+++ b/java/com/google/gerrit/server/restapi/account/PostWatchedProjects.java
@@ -91,7 +91,7 @@
       throws RestApiException, IOException, PermissionBackendException {
     Map<ProjectWatchKey, Set<NotifyType>> m = new HashMap<>();
     for (ProjectWatchInfo info : input) {
-      if (info.project == null) {
+      if (info.project == null || info.project.trim().isEmpty()) {
         throw new BadRequestException("project name must be specified");
       }
 
diff --git a/java/com/google/gerrit/server/restapi/change/CommentJson.java b/java/com/google/gerrit/server/restapi/change/CommentJson.java
index 4de9b63..cc8ad47 100644
--- a/java/com/google/gerrit/server/restapi/change/CommentJson.java
+++ b/java/com/google/gerrit/server/restapi/change/CommentJson.java
@@ -20,9 +20,12 @@
 
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Streams;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.CommentContext;
 import com.google.gerrit.entities.FixReplacement;
 import com.google.gerrit.entities.FixSuggestion;
 import com.google.gerrit.entities.HumanComment;
@@ -31,15 +34,18 @@
 import com.google.gerrit.extensions.client.Comment.Range;
 import com.google.gerrit.extensions.client.Side;
 import com.google.gerrit.extensions.common.CommentInfo;
+import com.google.gerrit.extensions.common.ContextLineInfo;
 import com.google.gerrit.extensions.common.FixReplacementInfo;
 import com.google.gerrit.extensions.common.FixSuggestionInfo;
 import com.google.gerrit.extensions.common.RobotCommentInfo;
 import com.google.gerrit.extensions.restapi.Url;
-import com.google.gerrit.server.CommentContextLoader;
 import com.google.gerrit.server.account.AccountLoader;
+import com.google.gerrit.server.comment.CommentContextCache;
+import com.google.gerrit.server.comment.CommentContextKey;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.inject.Inject;
 import java.util.ArrayList;
+import java.util.Collection;
 import java.util.List;
 import java.util.Map;
 import java.util.TreeMap;
@@ -47,18 +53,19 @@
 public class CommentJson {
 
   private final AccountLoader.Factory accountLoaderFactory;
+  private final CommentContextCache commentContextCache;
+
+  private Project.NameKey project;
+  private Change.Id changeId;
 
   private boolean fillAccounts = true;
   private boolean fillPatchSet;
-  private CommentContextLoader.Factory commentContextLoaderFactory;
-  private CommentContextLoader commentContextLoader;
+  private boolean fillCommentContext;
 
   @Inject
-  CommentJson(
-      AccountLoader.Factory accountLoaderFactory,
-      CommentContextLoader.Factory commentContextLoaderFactory) {
+  CommentJson(AccountLoader.Factory accountLoaderFactory, CommentContextCache commentContextCache) {
     this.accountLoaderFactory = accountLoaderFactory;
-    this.commentContextLoaderFactory = commentContextLoaderFactory;
+    this.commentContextCache = commentContextCache;
   }
 
   CommentJson setFillAccounts(boolean fillAccounts) {
@@ -71,10 +78,18 @@
     return this;
   }
 
-  CommentJson setEnableContext(boolean enableContext, Project.NameKey project) {
-    if (enableContext) {
-      this.commentContextLoader = commentContextLoaderFactory.create(project);
-    }
+  CommentJson setFillCommentContext(boolean fillCommentContext) {
+    this.fillCommentContext = fillCommentContext;
+    return this;
+  }
+
+  CommentJson setProjectKey(Project.NameKey project) {
+    this.project = project;
+    return this;
+  }
+
+  CommentJson setChangeId(Change.Id changeId) {
+    this.changeId = changeId;
     return this;
   }
 
@@ -93,9 +108,6 @@
       if (loader != null) {
         loader.fill();
       }
-      if (commentContextLoader != null) {
-        commentContextLoader.fill();
-      }
       return info;
     }
 
@@ -120,8 +132,10 @@
       if (loader != null) {
         loader.fill();
       }
-      if (commentContextLoader != null) {
-        commentContextLoader.fill();
+
+      if (fillCommentContext) {
+        List<T> allComments = out.values().stream().flatMap(Collection::stream).collect(toList());
+        addCommentContext(allComments);
       }
       return out;
     }
@@ -138,12 +152,41 @@
       if (loader != null) {
         loader.fill();
       }
-      if (commentContextLoader != null) {
-        commentContextLoader.fill();
+
+      if (fillCommentContext) {
+        addCommentContext(out);
       }
+
       return out;
     }
 
+    protected void addCommentContext(List<T> allComments) {
+      List<CommentContextKey> keys =
+          allComments.stream().map(this::createCommentContextKey).collect(toList());
+      ImmutableMap<CommentContextKey, CommentContext> allContext = commentContextCache.getAll(keys);
+      for (T c : allComments) {
+        c.contextLines = toContextLineInfoList(allContext.get(createCommentContextKey(c)));
+      }
+    }
+
+    protected List<ContextLineInfo> toContextLineInfoList(CommentContext commentContext) {
+      List<ContextLineInfo> result = new ArrayList<>();
+      for (Map.Entry<Integer, String> e : commentContext.lines().entrySet()) {
+        result.add(new ContextLineInfo(e.getKey(), e.getValue()));
+      }
+      return result;
+    }
+
+    protected CommentContextKey createCommentContextKey(T r) {
+      return CommentContextKey.builder()
+          .project(project)
+          .changeId(changeId)
+          .id(r.id)
+          .path(r.path)
+          .patchset(r.patchSet)
+          .build();
+    }
+
     protected abstract T toInfo(F comment, AccountLoader loader);
 
     protected void fillCommentInfo(Comment c, CommentInfo r, AccountLoader loader) {
@@ -170,9 +213,6 @@
         r.author = loader.get(c.author.getId());
       }
       r.commitId = c.getCommitId().getName();
-      if (commentContextLoader != null) {
-        r.contextLines = commentContextLoader.getContext(r);
-      }
     }
 
     protected Range toRange(Comment.Range commentRange) {
diff --git a/java/com/google/gerrit/server/restapi/change/CreateMergePatchSet.java b/java/com/google/gerrit/server/restapi/change/CreateMergePatchSet.java
index 8ac2140..af4bf69 100644
--- a/java/com/google/gerrit/server/restapi/change/CreateMergePatchSet.java
+++ b/java/com/google/gerrit/server/restapi/change/CreateMergePatchSet.java
@@ -55,6 +55,7 @@
 import com.google.gerrit.server.permissions.ChangePermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.RefPermission;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.restapi.project.CommitsCollection;
@@ -128,6 +129,13 @@
     psUtil.checkPatchSetNotLocked(rsrc.getNotes());
 
     rsrc.permissions().check(ChangePermission.ADD_PATCH_SET);
+    if (in.author != null) {
+      permissionBackend
+          .currentUser()
+          .project(rsrc.getProject())
+          .ref(rsrc.getChange().getDest().branch())
+          .check(RefPermission.FORGE_AUTHOR);
+    }
 
     ProjectState projectState =
         projectCache.get(rsrc.getProject()).orElseThrow(illegalState(rsrc.getProject()));
@@ -137,6 +145,10 @@
     if (merge == null || Strings.isNullOrEmpty(merge.source)) {
       throw new BadRequestException("merge.source must be non-empty");
     }
+    if (in.author != null
+        && (Strings.isNullOrEmpty(in.author.email) || Strings.isNullOrEmpty(in.author.name))) {
+      throw new BadRequestException("Author must specify name and email");
+    }
     in.baseChange = Strings.nullToEmpty(in.baseChange).trim();
 
     PatchSet ps = psUtil.current(rsrc.getNotes());
@@ -166,7 +178,10 @@
 
       Timestamp now = TimeUtil.nowTs();
       IdentifiedUser me = user.get().asIdentifiedUser();
-      PersonIdent author = me.newCommitterIdent(now, serverTimeZone);
+      PersonIdent author =
+          in.author == null
+              ? me.newCommitterIdent(now, serverTimeZone)
+              : new PersonIdent(in.author.name, in.author.email, now, serverTimeZone);
       CodeReviewCommit newCommit =
           createMergeCommit(
               in,
diff --git a/java/com/google/gerrit/server/restapi/change/ListChangeComments.java b/java/com/google/gerrit/server/restapi/change/ListChangeComments.java
index e3b433c..fa7c1f5 100644
--- a/java/com/google/gerrit/server/restapi/change/ListChangeComments.java
+++ b/java/com/google/gerrit/server/restapi/change/ListChangeComments.java
@@ -19,7 +19,6 @@
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.entities.HumanComment;
-import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.common.CommentInfo;
 import com.google.gerrit.extensions.common.ContextLineInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
@@ -84,8 +83,7 @@
 
   private ImmutableList<CommentInfo> getAsList(Iterable<HumanComment> comments, ChangeResource rsrc)
       throws PermissionBackendException {
-    ImmutableList<CommentInfo> commentInfos =
-        getCommentFormatter(rsrc.getProject()).formatAsList(comments);
+    ImmutableList<CommentInfo> commentInfos = getCommentFormatter(rsrc).formatAsList(comments);
     List<ChangeMessage> changeMessages = changeMessagesUtil.byChange(rsrc.getNotes());
     CommentsUtil.linkCommentsToChangeMessages(commentInfos, changeMessages, true);
     return commentInfos;
@@ -93,8 +91,7 @@
 
   private Map<String, List<CommentInfo>> getAsMap(
       Iterable<HumanComment> comments, ChangeResource rsrc) throws PermissionBackendException {
-    Map<String, List<CommentInfo>> commentInfosMap =
-        getCommentFormatter(rsrc.getProject()).format(comments);
+    Map<String, List<CommentInfo>> commentInfosMap = getCommentFormatter(rsrc).format(comments);
     List<CommentInfo> commentInfos =
         commentInfosMap.values().stream().flatMap(List::stream).collect(toList());
     List<ChangeMessage> changeMessages = changeMessagesUtil.byChange(rsrc.getNotes());
@@ -102,12 +99,14 @@
     return commentInfosMap;
   }
 
-  private CommentJson.HumanCommentFormatter getCommentFormatter(Project.NameKey project) {
+  private CommentJson.HumanCommentFormatter getCommentFormatter(ChangeResource rsrc) {
     return commentJson
         .get()
         .setFillAccounts(true)
         .setFillPatchSet(true)
-        .setEnableContext(includeContext, project)
+        .setFillCommentContext(includeContext)
+        .setProjectKey(rsrc.getProject())
+        .setChangeId(rsrc.getId())
         .newHumanCommentFormatter();
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/project/CheckAccess.java b/java/com/google/gerrit/server/restapi/project/CheckAccess.java
index 037a953..4ef724a 100644
--- a/java/com/google/gerrit/server/restapi/project/CheckAccess.java
+++ b/java/com/google/gerrit/server/restapi/project/CheckAccess.java
@@ -28,6 +28,7 @@
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.server.account.AccountResolver;
 import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.logging.TraceContext;
 import com.google.gerrit.server.permissions.DefaultPermissionMappings;
 import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
@@ -73,60 +74,74 @@
       throw new BadRequestException("input requires 'account'");
     }
 
-    Account.Id match = accountResolver.resolve(input.account).asUnique().account().id();
+    try (TraceContext traceContext = TraceContext.open()) {
+      traceContext.enableAclLogging();
 
-    AccessCheckInfo info = new AccessCheckInfo();
-    try {
-      permissionBackend
-          .absentUser(match)
-          .project(rsrc.getNameKey())
-          .check(ProjectPermission.ACCESS);
-    } catch (AuthException e) {
-      info.message = String.format("user %s cannot see project %s", match, rsrc.getName());
-      info.status = HttpServletResponse.SC_FORBIDDEN;
-      return Response.ok(info);
-    }
+      Account.Id match = accountResolver.resolve(input.account).asUnique().account().id();
 
-    RefPermission refPerm;
-    if (!Strings.isNullOrEmpty(input.permission)) {
-      if (Strings.isNullOrEmpty(input.ref)) {
-        throw new BadRequestException("must set 'ref' when specifying 'permission'");
-      }
-      Optional<RefPermission> rp = DefaultPermissionMappings.refPermission(input.permission);
-      if (!rp.isPresent()) {
-        throw new BadRequestException(
-            String.format("'%s' is not recognized as ref permission", input.permission));
-      }
-
-      refPerm = rp.get();
-    } else {
-      refPerm = RefPermission.READ;
-    }
-
-    if (!Strings.isNullOrEmpty(input.ref)) {
       try {
         permissionBackend
             .absentUser(match)
-            .ref(BranchNameKey.create(rsrc.getNameKey(), input.ref))
-            .check(refPerm);
+            .project(rsrc.getNameKey())
+            .check(ProjectPermission.ACCESS);
       } catch (AuthException e) {
-        info.status = HttpServletResponse.SC_FORBIDDEN;
-        info.message =
-            String.format(
-                "user %s lacks permission %s for %s in project %s",
-                match, input.permission, input.ref, rsrc.getName());
-        return Response.ok(info);
+        return Response.ok(
+            createInfo(
+                traceContext,
+                HttpServletResponse.SC_FORBIDDEN,
+                String.format("user %s cannot see project %s", match, rsrc.getName())));
       }
-    } else {
-      // We say access is okay if there are no refs, but this warrants a warning,
-      // as access denied looks the same as no branches to the user.
-      try (Repository repo = gitRepositoryManager.openRepository(rsrc.getNameKey())) {
-        if (repo.getRefDatabase().getRefsByPrefix(REFS_HEADS).isEmpty()) {
-          info.message = "access is OK, but repository has no branches under refs/heads/";
+
+      RefPermission refPerm;
+      if (!Strings.isNullOrEmpty(input.permission)) {
+        if (Strings.isNullOrEmpty(input.ref)) {
+          throw new BadRequestException("must set 'ref' when specifying 'permission'");
+        }
+        Optional<RefPermission> rp = DefaultPermissionMappings.refPermission(input.permission);
+        if (!rp.isPresent()) {
+          throw new BadRequestException(
+              String.format("'%s' is not recognized as ref permission", input.permission));
+        }
+
+        refPerm = rp.get();
+      } else {
+        refPerm = RefPermission.READ;
+      }
+
+      String message = null;
+      if (!Strings.isNullOrEmpty(input.ref)) {
+        try {
+          permissionBackend
+              .absentUser(match)
+              .ref(BranchNameKey.create(rsrc.getNameKey(), input.ref))
+              .check(refPerm);
+        } catch (AuthException e) {
+          return Response.ok(
+              createInfo(
+                  traceContext,
+                  HttpServletResponse.SC_FORBIDDEN,
+                  String.format(
+                      "user %s lacks permission %s for %s in project %s",
+                      match, input.permission, input.ref, rsrc.getName())));
+        }
+      } else {
+        // We say access is okay if there are no refs, but this warrants a warning,
+        // as access denied looks the same as no branches to the user.
+        try (Repository repo = gitRepositoryManager.openRepository(rsrc.getNameKey())) {
+          if (repo.getRefDatabase().getRefsByPrefix(REFS_HEADS).isEmpty()) {
+            message = "access is OK, but repository has no branches under refs/heads/";
+          }
         }
       }
+      return Response.ok(createInfo(traceContext, HttpServletResponse.SC_OK, message));
     }
-    info.status = HttpServletResponse.SC_OK;
-    return Response.ok(info);
+  }
+
+  private AccessCheckInfo createInfo(TraceContext traceContext, int statusCode, String message) {
+    AccessCheckInfo info = new AccessCheckInfo();
+    info.status = statusCode;
+    info.message = message;
+    info.debugLogs = traceContext.getAclLogRecords();
+    return info;
   }
 }
diff --git a/java/com/google/gerrit/server/submit/MergeOp.java b/java/com/google/gerrit/server/submit/MergeOp.java
index 8a293a4..45e544d2 100644
--- a/java/com/google/gerrit/server/submit/MergeOp.java
+++ b/java/com/google/gerrit/server/submit/MergeOp.java
@@ -232,7 +232,7 @@
   private final SubmitStrategyFactory submitStrategyFactory;
   private final SubscriptionGraph.Factory subscriptionGraphFactory;
   private final SubmoduleCommits.Factory submoduleCommitsFactory;
-  private final SubmissionListener superprojectUpdateSubmissionListener;
+  private final ImmutableList<SubmissionListener> superprojectUpdateSubmissionListeners;
   private final Provider<MergeOpRepoManager> ormProvider;
   private final NotifyResolver notifyResolver;
   private final RetryHelper retryHelper;
@@ -264,7 +264,8 @@
       SubmitStrategyFactory submitStrategyFactory,
       SubmoduleCommits.Factory submoduleCommitsFactory,
       SubscriptionGraph.Factory subscriptionGraphFactory,
-      @SuperprojectUpdateOnSubmission SubmissionListener superprojectUpdateSubmissionListener,
+      @SuperprojectUpdateOnSubmission
+          ImmutableList<SubmissionListener> superprojectUpdateSubmissionListeners,
       Provider<MergeOpRepoManager> ormProvider,
       NotifyResolver notifyResolver,
       TopicMetrics topicMetrics,
@@ -279,7 +280,7 @@
     this.submitStrategyFactory = submitStrategyFactory;
     this.submoduleCommitsFactory = submoduleCommitsFactory;
     this.subscriptionGraphFactory = subscriptionGraphFactory;
-    this.superprojectUpdateSubmissionListener = superprojectUpdateSubmissionListener;
+    this.superprojectUpdateSubmissionListeners = superprojectUpdateSubmissionListeners;
     this.ormProvider = ormProvider;
     this.notifyResolver = notifyResolver;
     this.retryHelper = retryHelper;
@@ -498,7 +499,7 @@
         }
 
         SubmissionExecutor submissionExecutor =
-            new SubmissionExecutor(dryrun, superprojectUpdateSubmissionListener);
+            new SubmissionExecutor(dryrun, superprojectUpdateSubmissionListeners);
         RetryTracker retryTracker = new RetryTracker();
         retryHelper
             .changeUpdate(
diff --git a/java/com/google/gerrit/server/submit/SubmitStrategyOp.java b/java/com/google/gerrit/server/submit/SubmitStrategyOp.java
index 3b77dd9..3430047 100644
--- a/java/com/google/gerrit/server/submit/SubmitStrategyOp.java
+++ b/java/com/google/gerrit/server/submit/SubmitStrategyOp.java
@@ -461,9 +461,12 @@
       // If we naively execute postUpdate even if the change is already merged when updateChange
       // being, then we are subject to a race where postUpdate steps are run twice if two submit
       // processes run at the same time.
-      logger.atFine().log("Skipping post-update steps for change %s", getId());
+      logger.atFine().log(
+          "Skipping post-update steps for change %s; submitter is %s", getId(), submitter);
       return;
     }
+    logger.atFine().log(
+        "Begin post-update steps for change %s; submitter is %s", getId(), submitter);
     postUpdateImpl(ctx);
 
     if (command != null) {
@@ -483,6 +486,9 @@
       }
     }
 
+    logger.atFine().log(
+        "Begin sending emails for submitting change %s; submitter is %s", getId(), submitter);
+
     // Assume the change must have been merged at this point, otherwise we would
     // have failed fast in one of the other steps.
     try {
diff --git a/java/com/google/gerrit/server/update/SubmissionExecutor.java b/java/com/google/gerrit/server/update/SubmissionExecutor.java
index 5a3a789..39eda58 100644
--- a/java/com/google/gerrit/server/update/SubmissionExecutor.java
+++ b/java/com/google/gerrit/server/update/SubmissionExecutor.java
@@ -17,7 +17,6 @@
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.server.submit.MergeOpRepoManager;
-import java.util.Arrays;
 import java.util.Collection;
 import java.util.Optional;
 import java.util.stream.Collectors;
@@ -28,14 +27,9 @@
   private final boolean dryrun;
   private ImmutableList<BatchUpdateListener> additionalListeners = ImmutableList.of();
 
-  public SubmissionExecutor(
-      boolean dryrun, SubmissionListener listener, SubmissionListener... otherListeners) {
+  public SubmissionExecutor(boolean dryrun, ImmutableList<SubmissionListener> submissionListeners) {
     this.dryrun = dryrun;
-    this.submissionListeners =
-        ImmutableList.<SubmissionListener>builder()
-            .add(listener)
-            .addAll(Arrays.asList(otherListeners))
-            .build();
+    this.submissionListeners = submissionListeners;
     if (dryrun) {
       submissionListeners.forEach(SubmissionListener::setDryrun);
     }
diff --git a/java/com/google/gerrit/server/update/SuperprojectUpdateSubmissionListener.java b/java/com/google/gerrit/server/update/SuperprojectUpdateSubmissionListener.java
index dffdff0..4c65c80 100644
--- a/java/com/google/gerrit/server/update/SuperprojectUpdateSubmissionListener.java
+++ b/java/com/google/gerrit/server/update/SuperprojectUpdateSubmissionListener.java
@@ -21,6 +21,7 @@
 import com.google.gerrit.server.submit.MergeOpRepoManager;
 import com.google.gerrit.server.submit.SubmoduleOp;
 import com.google.inject.AbstractModule;
+import com.google.inject.Provides;
 import java.util.Collection;
 import java.util.HashMap;
 import java.util.Map;
@@ -39,11 +40,11 @@
   private boolean dryrun;
 
   public static class Module extends AbstractModule {
-    @Override
-    protected void configure() {
-      bind(SubmissionListener.class)
-          .annotatedWith(SuperprojectUpdateOnSubmission.class)
-          .to(SuperprojectUpdateSubmissionListener.class);
+    @Provides
+    @SuperprojectUpdateOnSubmission
+    ImmutableList<SubmissionListener> provideSubmissionListeners(
+        SuperprojectUpdateSubmissionListener listener) {
+      return ImmutableList.of(listener);
     }
   }
 
diff --git a/java/com/google/gerrit/sshd/AbstractGitCommand.java b/java/com/google/gerrit/sshd/AbstractGitCommand.java
index 8bf6cd5..9efcff2 100644
--- a/java/com/google/gerrit/sshd/AbstractGitCommand.java
+++ b/java/com/google/gerrit/sshd/AbstractGitCommand.java
@@ -52,6 +52,7 @@
 
   @Override
   public void start(ChannelSession channel, Environment env) {
+    enableGracefulStop();
     String gitProtocol = env.getEnv().get(GIT_PROTOCOL);
     if (gitProtocol != null) {
       extraParameters = gitProtocol.split(":");
diff --git a/java/com/google/gerrit/sshd/BaseCommand.java b/java/com/google/gerrit/sshd/BaseCommand.java
index ab1f062..a027dd1 100644
--- a/java/com/google/gerrit/sshd/BaseCommand.java
+++ b/java/com/google/gerrit/sshd/BaseCommand.java
@@ -403,6 +403,10 @@
     }
   }
 
+  protected void enableGracefulStop() {
+    context.getSession().setGracefulStop(true);
+  }
+
   protected String getTaskDescription() {
     String[] ta = getTrimmedArguments();
     if (ta != null) {
diff --git a/java/com/google/gerrit/sshd/SshDaemon.java b/java/com/google/gerrit/sshd/SshDaemon.java
index c43bf91..c14ebd8 100644
--- a/java/com/google/gerrit/sshd/SshDaemon.java
+++ b/java/com/google/gerrit/sshd/SshDaemon.java
@@ -90,6 +90,7 @@
 import org.apache.sshd.common.random.Random;
 import org.apache.sshd.common.random.SingletonRandomFactory;
 import org.apache.sshd.common.session.Session;
+import org.apache.sshd.common.session.helpers.AbstractSession;
 import org.apache.sshd.common.session.helpers.DefaultUnknownChannelReferenceHandler;
 import org.apache.sshd.common.util.buffer.Buffer;
 import org.apache.sshd.common.util.buffer.ByteArrayBuffer;
@@ -369,14 +370,24 @@
     Collection<IoSession> ioSessions = daemonAcceptor.getManagedSessions().values();
     CountDownLatch allSessionsClosed = new CountDownLatch(ioSessions.size());
     for (IoSession io : ioSessions) {
-      logger.atFine().log("Waiting for session %s to stop.", io.getId());
-      io.addCloseFutureListener(
-          new SshFutureListener<CloseFuture>() {
-            @Override
-            public void operationComplete(CloseFuture future) {
-              allSessionsClosed.countDown();
-            }
-          });
+      AbstractSession serverSession = AbstractSession.getSession(io, true);
+      SshSession sshSession =
+          serverSession != null ? serverSession.getAttribute(SshSession.KEY) : null;
+      if (sshSession != null && sshSession.requiresGracefulStop()) {
+        logger.atFine().log("Waiting for session %s to stop.", io.getId());
+        io.addCloseFutureListener(
+            new SshFutureListener<CloseFuture>() {
+              @Override
+              public void operationComplete(CloseFuture future) {
+                logger.atFine().log("Session %s was stopped.", io.getId());
+                allSessionsClosed.countDown();
+              }
+            });
+      } else {
+        logger.atFine().log("Stopping session %s immediately.", io.getId());
+        io.close(true);
+        allSessionsClosed.countDown();
+      }
     }
     try {
       if (!allSessionsClosed.await(gracefulStopTimeout, TimeUnit.SECONDS)) {
diff --git a/java/com/google/gerrit/sshd/SshSession.java b/java/com/google/gerrit/sshd/SshSession.java
index d6ecc73..b39eaed 100644
--- a/java/com/google/gerrit/sshd/SshSession.java
+++ b/java/com/google/gerrit/sshd/SshSession.java
@@ -35,6 +35,8 @@
   private volatile String authError;
   private volatile String peerAgent;
 
+  private volatile boolean gracefulStop = false;
+
   SshSession(int sessionId, SocketAddress peer) {
     this.sessionId = sessionId;
     this.remoteAddress = peer;
@@ -58,6 +60,14 @@
     return sessionId;
   }
 
+  public boolean requiresGracefulStop() {
+    return gracefulStop;
+  }
+
+  public void setGracefulStop(boolean gracefulStop) {
+    this.gracefulStop = gracefulStop;
+  }
+
   /** Identity of the authenticated user account on the socket. */
   public CurrentUser getUser() {
     return identity;
diff --git a/java/com/google/gerrit/sshd/commands/AproposCommand.java b/java/com/google/gerrit/sshd/commands/AproposCommand.java
index d3db70d..e7a88a1 100644
--- a/java/com/google/gerrit/sshd/commands/AproposCommand.java
+++ b/java/com/google/gerrit/sshd/commands/AproposCommand.java
@@ -39,6 +39,7 @@
 
   @Override
   public void run() throws Exception {
+    enableGracefulStop();
     try {
       List<QueryDocumentationExecutor.DocResult> res = searcher.doQuery(q);
       for (DocResult docResult : res) {
diff --git a/java/com/google/gerrit/sshd/commands/BanCommitCommand.java b/java/com/google/gerrit/sshd/commands/BanCommitCommand.java
index ee6f635..134fb03 100644
--- a/java/com/google/gerrit/sshd/commands/BanCommitCommand.java
+++ b/java/com/google/gerrit/sshd/commands/BanCommitCommand.java
@@ -63,6 +63,7 @@
 
   @Override
   protected void run() throws Failure {
+    enableGracefulStop();
     try {
       BanCommitInput input =
           BanCommitInput.fromCommits(Lists.transform(commitsToBan, ObjectId::getName));
diff --git a/java/com/google/gerrit/sshd/commands/BaseTestPrologCommand.java b/java/com/google/gerrit/sshd/commands/BaseTestPrologCommand.java
index d70c153..ad8e20d 100644
--- a/java/com/google/gerrit/sshd/commands/BaseTestPrologCommand.java
+++ b/java/com/google/gerrit/sshd/commands/BaseTestPrologCommand.java
@@ -59,6 +59,7 @@
 
   @Override
   protected final void run() throws UnloggedFailure {
+    enableGracefulStop();
     try {
       RevisionResource revision =
           revisions.parse(
diff --git a/java/com/google/gerrit/sshd/commands/CloseConnection.java b/java/com/google/gerrit/sshd/commands/CloseConnection.java
index 093f647..e0b87f8 100644
--- a/java/com/google/gerrit/sshd/commands/CloseConnection.java
+++ b/java/com/google/gerrit/sshd/commands/CloseConnection.java
@@ -57,6 +57,7 @@
 
   @Override
   protected void run() throws Failure {
+    enableGracefulStop();
     SshUtil.forEachSshSession(
         sshDaemon,
         (k, sshSession, abstractSession, ioSession) -> {
diff --git a/java/com/google/gerrit/sshd/commands/CreateAccountCommand.java b/java/com/google/gerrit/sshd/commands/CreateAccountCommand.java
index 004a0ba..4da55e2 100644
--- a/java/com/google/gerrit/sshd/commands/CreateAccountCommand.java
+++ b/java/com/google/gerrit/sshd/commands/CreateAccountCommand.java
@@ -72,6 +72,7 @@
   @Override
   protected void run()
       throws IOException, ConfigInvalidException, UnloggedFailure, PermissionBackendException {
+    enableGracefulStop();
     AccountInput input = new AccountInput();
     input.username = username;
     input.email = email;
diff --git a/java/com/google/gerrit/sshd/commands/CreateBranchCommand.java b/java/com/google/gerrit/sshd/commands/CreateBranchCommand.java
index aad96a1..a837ecd 100644
--- a/java/com/google/gerrit/sshd/commands/CreateBranchCommand.java
+++ b/java/com/google/gerrit/sshd/commands/CreateBranchCommand.java
@@ -44,6 +44,7 @@
 
   @Override
   protected void run() throws UnloggedFailure {
+    enableGracefulStop();
     try {
       BranchInput in = new BranchInput();
       in.revision = revision;
diff --git a/java/com/google/gerrit/sshd/commands/CreateGroupCommand.java b/java/com/google/gerrit/sshd/commands/CreateGroupCommand.java
index 17f80c0..5fd2297 100644
--- a/java/com/google/gerrit/sshd/commands/CreateGroupCommand.java
+++ b/java/com/google/gerrit/sshd/commands/CreateGroupCommand.java
@@ -102,6 +102,7 @@
   @Override
   protected void run()
       throws Failure, IOException, ConfigInvalidException, PermissionBackendException {
+    enableGracefulStop();
     try {
       GroupResource rsrc = createGroup();
 
diff --git a/java/com/google/gerrit/sshd/commands/CreateProjectCommand.java b/java/com/google/gerrit/sshd/commands/CreateProjectCommand.java
index fca7427..f2ab4e8 100644
--- a/java/com/google/gerrit/sshd/commands/CreateProjectCommand.java
+++ b/java/com/google/gerrit/sshd/commands/CreateProjectCommand.java
@@ -166,6 +166,7 @@
 
   @Override
   protected void run() throws Failure {
+    enableGracefulStop();
     try {
       if (!suggestParent) {
         if (projectName == null) {
diff --git a/java/com/google/gerrit/sshd/commands/FlushCaches.java b/java/com/google/gerrit/sshd/commands/FlushCaches.java
index 2afc009..fe2a897 100644
--- a/java/com/google/gerrit/sshd/commands/FlushCaches.java
+++ b/java/com/google/gerrit/sshd/commands/FlushCaches.java
@@ -55,6 +55,7 @@
 
   @Override
   protected void run() throws Failure {
+    enableGracefulStop();
     try {
       if (list) {
         if (all || !caches.isEmpty()) {
diff --git a/java/com/google/gerrit/sshd/commands/GarbageCollectionCommand.java b/java/com/google/gerrit/sshd/commands/GarbageCollectionCommand.java
index 2073087..28a7804 100644
--- a/java/com/google/gerrit/sshd/commands/GarbageCollectionCommand.java
+++ b/java/com/google/gerrit/sshd/commands/GarbageCollectionCommand.java
@@ -62,6 +62,7 @@
 
   @Override
   public void run() throws Exception {
+    enableGracefulStop();
     verifyCommandLine();
     runGC();
   }
diff --git a/java/com/google/gerrit/sshd/commands/IndexActivateCommand.java b/java/com/google/gerrit/sshd/commands/IndexActivateCommand.java
index 0804d08..30dc5c4 100644
--- a/java/com/google/gerrit/sshd/commands/IndexActivateCommand.java
+++ b/java/com/google/gerrit/sshd/commands/IndexActivateCommand.java
@@ -34,6 +34,7 @@
 
   @Override
   protected void run() throws UnloggedFailure {
+    enableGracefulStop();
     try {
       if (versionManager.isKnownIndex(name)) {
         if (versionManager.activateLatestIndex(name)) {
diff --git a/java/com/google/gerrit/sshd/commands/IndexChangesCommand.java b/java/com/google/gerrit/sshd/commands/IndexChangesCommand.java
index fb62b48..1fb0e13 100644
--- a/java/com/google/gerrit/sshd/commands/IndexChangesCommand.java
+++ b/java/com/google/gerrit/sshd/commands/IndexChangesCommand.java
@@ -52,6 +52,7 @@
 
   @Override
   protected void run() throws UnloggedFailure {
+    enableGracefulStop();
     boolean ok = true;
     for (ChangeResource rsrc : changes.values()) {
       try {
diff --git a/java/com/google/gerrit/sshd/commands/IndexChangesInProjectCommand.java b/java/com/google/gerrit/sshd/commands/IndexChangesInProjectCommand.java
index 56b00a5..168dc19 100644
--- a/java/com/google/gerrit/sshd/commands/IndexChangesInProjectCommand.java
+++ b/java/com/google/gerrit/sshd/commands/IndexChangesInProjectCommand.java
@@ -43,6 +43,7 @@
 
   @Override
   protected void run() throws UnloggedFailure, Failure, Exception {
+    enableGracefulStop();
     if (projects.isEmpty()) {
       throw die("needs at least one project as command arguments");
     }
diff --git a/java/com/google/gerrit/sshd/commands/IndexStartCommand.java b/java/com/google/gerrit/sshd/commands/IndexStartCommand.java
index f3d349c..5433b17 100644
--- a/java/com/google/gerrit/sshd/commands/IndexStartCommand.java
+++ b/java/com/google/gerrit/sshd/commands/IndexStartCommand.java
@@ -38,6 +38,7 @@
 
   @Override
   protected void run() throws UnloggedFailure {
+    enableGracefulStop();
     try {
       if (versionManager.isKnownIndex(name)) {
         if (versionManager.startReindexer(name, force)) {
diff --git a/java/com/google/gerrit/sshd/commands/KillCommand.java b/java/com/google/gerrit/sshd/commands/KillCommand.java
index df74f86..a633a8a 100644
--- a/java/com/google/gerrit/sshd/commands/KillCommand.java
+++ b/java/com/google/gerrit/sshd/commands/KillCommand.java
@@ -47,6 +47,7 @@
 
   @Override
   protected void run() {
+    enableGracefulStop();
     ConfigResource cfgRsrc = new ConfigResource();
     for (String id : taskIds) {
       try {
diff --git a/java/com/google/gerrit/sshd/commands/ListGroupsCommand.java b/java/com/google/gerrit/sshd/commands/ListGroupsCommand.java
index bdf5412..7bf42eb 100644
--- a/java/com/google/gerrit/sshd/commands/ListGroupsCommand.java
+++ b/java/com/google/gerrit/sshd/commands/ListGroupsCommand.java
@@ -52,6 +52,7 @@
 
   @Override
   public void run() throws Exception {
+    enableGracefulStop();
     if (listGroups.getUser() != null && !listGroups.getProjects().isEmpty()) {
       throw die("--user and --project options are not compatible.");
     }
diff --git a/java/com/google/gerrit/sshd/commands/ListLoggingLevelCommand.java b/java/com/google/gerrit/sshd/commands/ListLoggingLevelCommand.java
index c8b8fa1..1a7be32 100644
--- a/java/com/google/gerrit/sshd/commands/ListLoggingLevelCommand.java
+++ b/java/com/google/gerrit/sshd/commands/ListLoggingLevelCommand.java
@@ -40,6 +40,7 @@
   @SuppressWarnings("unchecked")
   @Override
   protected void run() {
+    enableGracefulStop();
     Map<String, String> logs = new TreeMap<>();
     for (Enumeration<Logger> logger = LogManager.getCurrentLoggers(); logger.hasMoreElements(); ) {
       Logger log = logger.nextElement();
diff --git a/java/com/google/gerrit/sshd/commands/ListMembersCommand.java b/java/com/google/gerrit/sshd/commands/ListMembersCommand.java
index dc1bc6e..3269c2b 100644
--- a/java/com/google/gerrit/sshd/commands/ListMembersCommand.java
+++ b/java/com/google/gerrit/sshd/commands/ListMembersCommand.java
@@ -45,6 +45,7 @@
 
   @Override
   public void run() throws Exception {
+    enableGracefulStop();
     impl.display(stdout);
   }
 
diff --git a/java/com/google/gerrit/sshd/commands/ListProjectsCommand.java b/java/com/google/gerrit/sshd/commands/ListProjectsCommand.java
index 9f2ffa9..e711d57 100644
--- a/java/com/google/gerrit/sshd/commands/ListProjectsCommand.java
+++ b/java/com/google/gerrit/sshd/commands/ListProjectsCommand.java
@@ -32,6 +32,7 @@
 
   @Override
   public void run() throws Exception {
+    enableGracefulStop();
     if (!impl.getFormat().isJson()) {
       List<String> showBranch = impl.getShowBranch();
       if (impl.isShowTree() && (showBranch != null) && !showBranch.isEmpty()) {
diff --git a/java/com/google/gerrit/sshd/commands/LsUserRefs.java b/java/com/google/gerrit/sshd/commands/LsUserRefs.java
index 80aee01..6eb045b 100644
--- a/java/com/google/gerrit/sshd/commands/LsUserRefs.java
+++ b/java/com/google/gerrit/sshd/commands/LsUserRefs.java
@@ -74,6 +74,7 @@
 
   @Override
   protected void run() throws Failure {
+    enableGracefulStop();
     Account.Id userAccountId;
     try {
       userAccountId = accountResolver.resolve(userName).asUnique().account().id();
diff --git a/java/com/google/gerrit/sshd/commands/PluginAdminSshCommand.java b/java/com/google/gerrit/sshd/commands/PluginAdminSshCommand.java
index 7e32615..086081c 100644
--- a/java/com/google/gerrit/sshd/commands/PluginAdminSshCommand.java
+++ b/java/com/google/gerrit/sshd/commands/PluginAdminSshCommand.java
@@ -28,6 +28,7 @@
 
   @Override
   protected final void run() throws UnloggedFailure {
+    enableGracefulStop();
     if (!loader.isRemoteAdminEnabled()) {
       throw die("remote plugin administration is disabled");
     }
diff --git a/java/com/google/gerrit/sshd/commands/PluginLsCommand.java b/java/com/google/gerrit/sshd/commands/PluginLsCommand.java
index 3a952f0..504b239 100644
--- a/java/com/google/gerrit/sshd/commands/PluginLsCommand.java
+++ b/java/com/google/gerrit/sshd/commands/PluginLsCommand.java
@@ -41,6 +41,7 @@
 
   @Override
   public void run() throws Exception {
+    enableGracefulStop();
     Map<String, PluginInfo> output = list.apply(TopLevelResource.INSTANCE).value();
 
     if (format.isJson()) {
diff --git a/java/com/google/gerrit/sshd/commands/Query.java b/java/com/google/gerrit/sshd/commands/Query.java
index 78485d3..772eabe 100644
--- a/java/com/google/gerrit/sshd/commands/Query.java
+++ b/java/com/google/gerrit/sshd/commands/Query.java
@@ -106,6 +106,7 @@
 
   @Override
   protected void run() throws Exception {
+    enableGracefulStop();
     processor.query(join(query, " "));
   }
 
diff --git a/java/com/google/gerrit/sshd/commands/ReloadConfig.java b/java/com/google/gerrit/sshd/commands/ReloadConfig.java
index cbe3c57..eeb48bb 100644
--- a/java/com/google/gerrit/sshd/commands/ReloadConfig.java
+++ b/java/com/google/gerrit/sshd/commands/ReloadConfig.java
@@ -38,6 +38,7 @@
 
   @Override
   protected void run() throws Failure {
+    enableGracefulStop();
     Multimap<UpdateResult, ConfigUpdateEntry> updates = gerritServerConfigReloader.reloadConfig();
     if (updates.isEmpty()) {
       stdout.println("No config entries updated!");
diff --git a/java/com/google/gerrit/sshd/commands/RenameGroupCommand.java b/java/com/google/gerrit/sshd/commands/RenameGroupCommand.java
index 166ad68..976e7bd 100644
--- a/java/com/google/gerrit/sshd/commands/RenameGroupCommand.java
+++ b/java/com/google/gerrit/sshd/commands/RenameGroupCommand.java
@@ -46,6 +46,7 @@
 
   @Override
   protected void run() throws Failure {
+    enableGracefulStop();
     try {
       GroupResource rsrc = groups.parse(TopLevelResource.INSTANCE, IdString.fromDecoded(groupName));
       NameInput input = new NameInput();
diff --git a/java/com/google/gerrit/sshd/commands/ReviewCommand.java b/java/com/google/gerrit/sshd/commands/ReviewCommand.java
index 78a7381..b58cc45 100644
--- a/java/com/google/gerrit/sshd/commands/ReviewCommand.java
+++ b/java/com/google/gerrit/sshd/commands/ReviewCommand.java
@@ -167,6 +167,7 @@
 
   @Override
   protected void run() throws UnloggedFailure {
+    enableGracefulStop();
     if (abandonChange) {
       if (restoreChange) {
         throw die("abandon and restore actions are mutually exclusive");
diff --git a/java/com/google/gerrit/sshd/commands/SetAccountCommand.java b/java/com/google/gerrit/sshd/commands/SetAccountCommand.java
index df1e3ed..43a1670 100644
--- a/java/com/google/gerrit/sshd/commands/SetAccountCommand.java
+++ b/java/com/google/gerrit/sshd/commands/SetAccountCommand.java
@@ -154,6 +154,7 @@
 
   @Override
   public void run() throws Exception {
+    enableGracefulStop();
     user = genericUserFactory.create(id);
 
     validate();
diff --git a/java/com/google/gerrit/sshd/commands/SetHeadCommand.java b/java/com/google/gerrit/sshd/commands/SetHeadCommand.java
index fd7ef75..b6d283e 100644
--- a/java/com/google/gerrit/sshd/commands/SetHeadCommand.java
+++ b/java/com/google/gerrit/sshd/commands/SetHeadCommand.java
@@ -43,6 +43,7 @@
 
   @Override
   protected void run() throws Exception {
+    enableGracefulStop();
     HeadInput input = new HeadInput();
     input.ref = newHead;
     try {
diff --git a/java/com/google/gerrit/sshd/commands/SetLoggingLevelCommand.java b/java/com/google/gerrit/sshd/commands/SetLoggingLevelCommand.java
index cfdd735..3faf598 100644
--- a/java/com/google/gerrit/sshd/commands/SetLoggingLevelCommand.java
+++ b/java/com/google/gerrit/sshd/commands/SetLoggingLevelCommand.java
@@ -61,6 +61,7 @@
   @SuppressWarnings("unchecked")
   @Override
   protected void run() throws MalformedURLException {
+    enableGracefulStop();
     if (level == LevelOption.RESET) {
       reset();
     } else {
diff --git a/java/com/google/gerrit/sshd/commands/SetMembersCommand.java b/java/com/google/gerrit/sshd/commands/SetMembersCommand.java
index 2511df4..db8e42a 100644
--- a/java/com/google/gerrit/sshd/commands/SetMembersCommand.java
+++ b/java/com/google/gerrit/sshd/commands/SetMembersCommand.java
@@ -102,6 +102,7 @@
 
   @Override
   protected void run() throws UnloggedFailure, Failure, Exception {
+    enableGracefulStop();
     try {
       for (AccountGroup.UUID groupUuid : groups) {
         GroupResource resource =
diff --git a/java/com/google/gerrit/sshd/commands/SetParentCommand.java b/java/com/google/gerrit/sshd/commands/SetParentCommand.java
index 406949e..d23f7fa 100644
--- a/java/com/google/gerrit/sshd/commands/SetParentCommand.java
+++ b/java/com/google/gerrit/sshd/commands/SetParentCommand.java
@@ -90,6 +90,7 @@
 
   @Override
   protected void run() throws Failure {
+    enableGracefulStop();
     if (oldParent == null && children.isEmpty()) {
       throw die(
           "child projects have to be specified as "
diff --git a/java/com/google/gerrit/sshd/commands/SetProjectCommand.java b/java/com/google/gerrit/sshd/commands/SetProjectCommand.java
index 8c9fc9f..9866c4e 100644
--- a/java/com/google/gerrit/sshd/commands/SetProjectCommand.java
+++ b/java/com/google/gerrit/sshd/commands/SetProjectCommand.java
@@ -132,6 +132,7 @@
 
   @Override
   protected void run() throws Failure {
+    enableGracefulStop();
     ConfigInput configInput = new ConfigInput();
     configInput.requireChangeId = requireChangeID;
     configInput.submitType = submitType;
diff --git a/java/com/google/gerrit/sshd/commands/SetReviewersCommand.java b/java/com/google/gerrit/sshd/commands/SetReviewersCommand.java
index 5bc5537..95627e1 100644
--- a/java/com/google/gerrit/sshd/commands/SetReviewersCommand.java
+++ b/java/com/google/gerrit/sshd/commands/SetReviewersCommand.java
@@ -95,6 +95,7 @@
 
   @Override
   protected void run() throws UnloggedFailure {
+    enableGracefulStop();
     boolean ok = true;
     for (ChangeResource rsrc : changes.values()) {
       try {
diff --git a/java/com/google/gerrit/sshd/commands/ShowCaches.java b/java/com/google/gerrit/sshd/commands/ShowCaches.java
index 1d756de..ba84179 100644
--- a/java/com/google/gerrit/sshd/commands/ShowCaches.java
+++ b/java/com/google/gerrit/sshd/commands/ShowCaches.java
@@ -112,6 +112,7 @@
 
   @Override
   protected void run() throws Failure {
+    enableGracefulStop();
     nw = columns - 50;
     Date now = new Date();
     stdout.format(
diff --git a/java/com/google/gerrit/sshd/commands/ShowConnections.java b/java/com/google/gerrit/sshd/commands/ShowConnections.java
index decf5d5..d271364 100644
--- a/java/com/google/gerrit/sshd/commands/ShowConnections.java
+++ b/java/com/google/gerrit/sshd/commands/ShowConnections.java
@@ -86,6 +86,7 @@
 
   @Override
   protected void run() throws Failure {
+    enableGracefulStop();
     final IoAcceptor acceptor = daemon.getIoAcceptor();
     if (acceptor == null) {
       throw new Failure(1, "fatal: sshd no longer running");
diff --git a/java/com/google/gerrit/sshd/commands/ShowQueue.java b/java/com/google/gerrit/sshd/commands/ShowQueue.java
index 2ec9e2d..779f2df 100644
--- a/java/com/google/gerrit/sshd/commands/ShowQueue.java
+++ b/java/com/google/gerrit/sshd/commands/ShowQueue.java
@@ -85,6 +85,7 @@
 
   @Override
   protected void run() throws Failure {
+    enableGracefulStop();
     maxCommandWidth = wide ? Integer.MAX_VALUE : columns - 8 - 12 - 12 - 4 - 4;
     stdout.print(
         String.format(
diff --git a/java/com/google/gerrit/sshd/commands/VersionCommand.java b/java/com/google/gerrit/sshd/commands/VersionCommand.java
index 8fac979..f8771fb 100644
--- a/java/com/google/gerrit/sshd/commands/VersionCommand.java
+++ b/java/com/google/gerrit/sshd/commands/VersionCommand.java
@@ -25,6 +25,7 @@
 
   @Override
   protected void run() throws Failure {
+    enableGracefulStop();
     String v = Version.getVersion();
     if (v == null) {
       throw new Failure(1, "fatal: version unavailable");
diff --git a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
index d4affb7..410cb41 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
@@ -47,7 +47,6 @@
 import static com.google.gerrit.extensions.client.ReviewerState.CC;
 import static com.google.gerrit.extensions.client.ReviewerState.REMOVED;
 import static com.google.gerrit.extensions.client.ReviewerState.REVIEWER;
-import static com.google.gerrit.git.ObjectIds.abbreviateName;
 import static com.google.gerrit.server.StarredChangesUtil.DEFAULT_LABEL;
 import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
 import static com.google.gerrit.server.group.SystemGroupBackend.CHANGE_OWNER;
@@ -142,14 +141,11 @@
 import com.google.gerrit.extensions.common.CommitInfo;
 import com.google.gerrit.extensions.common.GitPerson;
 import com.google.gerrit.extensions.common.LabelInfo;
-import com.google.gerrit.extensions.common.MergeInput;
-import com.google.gerrit.extensions.common.MergePatchSetInput;
 import com.google.gerrit.extensions.common.RevisionInfo;
 import com.google.gerrit.extensions.common.TrackingIdInfo;
 import com.google.gerrit.extensions.events.WorkInProgressStateChangedListener;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
-import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
@@ -183,7 +179,6 @@
 import com.google.inject.AbstractModule;
 import com.google.inject.Inject;
 import com.google.inject.name.Named;
-import java.io.ByteArrayOutputStream;
 import java.io.IOException;
 import java.sql.Timestamp;
 import java.util.ArrayList;
@@ -3189,407 +3184,6 @@
   }
 
   @Test
-  public void createMergePatchSet() throws Exception {
-    RevCommit initialHead = projectOperations.project(project).getHead("master");
-    createBranch("dev");
-
-    // create a change for master
-    String changeId = createChange().getChangeId();
-
-    testRepo.reset(initialHead);
-    PushOneCommit.Result currentMaster = pushTo("refs/heads/master");
-    currentMaster.assertOkStatus();
-    String parent = currentMaster.getCommit().getName();
-
-    // push a commit into dev branch
-    testRepo.reset(initialHead);
-    PushOneCommit.Result changeA =
-        pushFactory
-            .create(user.newIdent(), testRepo, "change A", "A.txt", "A content")
-            .to("refs/heads/dev");
-    changeA.assertOkStatus();
-    MergeInput mergeInput = new MergeInput();
-    mergeInput.source = "dev";
-    MergePatchSetInput in = new MergePatchSetInput();
-    in.merge = mergeInput;
-    String subject = "update change by merge ps2";
-    in.subject = subject;
-
-    TestWorkInProgressStateChangedListener wipStateChangedListener =
-        new TestWorkInProgressStateChangedListener();
-    try (Registration registration =
-        extensionRegistry.newRegistration().add(wipStateChangedListener)) {
-      ChangeInfo changeInfo = gApi.changes().id(changeId).createMergePatchSet(in);
-      assertThat(changeInfo.subject).isEqualTo(in.subject);
-      assertThat(changeInfo.containsGitConflicts).isNull();
-      assertThat(changeInfo.workInProgress).isNull();
-    }
-    assertThat(wipStateChangedListener.invoked).isFalse();
-
-    // To get the revisions, we must retrieve the change with more change options.
-    ChangeInfo changeInfo =
-        gApi.changes().id(changeId).get(ALL_REVISIONS, CURRENT_COMMIT, CURRENT_REVISION);
-    assertThat(changeInfo.revisions).hasSize(2);
-    assertThat(changeInfo.revisions.get(changeInfo.currentRevision).commit.parents.get(0).commit)
-        .isEqualTo(parent);
-
-    // Verify the message that has been posted on the change.
-    List<ChangeMessageInfo> messages = gApi.changes().id(changeId).messages();
-    assertThat(messages).hasSize(2);
-    assertThat(Iterables.getLast(messages).message).isEqualTo("Uploaded patch set 2.");
-
-    assertThat(changeInfo.revisions.get(changeInfo.currentRevision).commit.message)
-        .contains(subject);
-  }
-
-  @Test
-  public void createMergePatchSet_SubjectCarriesOverByDefault() throws Exception {
-    RevCommit initialHead = projectOperations.project(project).getHead("master");
-    createBranch("dev");
-
-    // create a change for master
-    PushOneCommit.Result result = createChange();
-    String changeId = result.getChangeId();
-    String subject = result.getChange().change().getSubject();
-
-    // push a commit into dev branch
-    testRepo.reset(initialHead);
-    PushOneCommit.Result pushResult =
-        pushFactory.create(user.newIdent(), testRepo).to("refs/heads/dev");
-    pushResult.assertOkStatus();
-    MergeInput mergeInput = new MergeInput();
-    mergeInput.source = "dev";
-    MergePatchSetInput in = new MergePatchSetInput();
-    in.merge = mergeInput;
-    in.subject = null;
-
-    // Ensure subject carries over
-    gApi.changes().id(changeId).createMergePatchSet(in);
-    ChangeInfo changeInfo = gApi.changes().id(changeId).get();
-    assertThat(changeInfo.subject).isEqualTo(subject);
-  }
-
-  @Test
-  public void createMergePatchSet_Conflict() throws Exception {
-    RevCommit initialHead = projectOperations.project(project).getHead("master");
-    createBranch("dev");
-
-    // create a change for master
-    String changeId = createChange().getChangeId();
-
-    String fileName = "shared.txt";
-    testRepo.reset(initialHead);
-    PushOneCommit.Result currentMaster =
-        pushFactory
-            .create(admin.newIdent(), testRepo, "change 1", fileName, "content 1")
-            .to("refs/heads/master");
-    currentMaster.assertOkStatus();
-
-    // push a commit into dev branch
-    testRepo.reset(initialHead);
-    PushOneCommit.Result changeA =
-        pushFactory
-            .create(user.newIdent(), testRepo, "change 2", fileName, "content 2")
-            .to("refs/heads/dev");
-    changeA.assertOkStatus();
-    MergeInput mergeInput = new MergeInput();
-    mergeInput.source = "dev";
-    MergePatchSetInput in = new MergePatchSetInput();
-    in.merge = mergeInput;
-    in.subject = "update change by merge ps2";
-    ResourceConflictException thrown =
-        assertThrows(
-            ResourceConflictException.class,
-            () -> gApi.changes().id(changeId).createMergePatchSet(in));
-    assertThat(thrown).hasMessageThat().isEqualTo("merge conflict(s):\n" + fileName);
-  }
-
-  @Test
-  public void createMergePatchSet_ConflictAllowed() throws Exception {
-    RevCommit initialHead = projectOperations.project(project).getHead("master");
-    createBranch("dev");
-
-    // create a change for master
-    String changeId = createChange().getChangeId();
-
-    String fileName = "shared.txt";
-    String sourceSubject = "source change";
-    String sourceContent = "source content";
-    String targetSubject = "target change";
-    String targetContent = "target content";
-    testRepo.reset(initialHead);
-    PushOneCommit.Result currentMaster =
-        pushFactory
-            .create(admin.newIdent(), testRepo, targetSubject, fileName, targetContent)
-            .to("refs/heads/master");
-    currentMaster.assertOkStatus();
-    String parent = currentMaster.getCommit().getName();
-
-    // push a commit into dev branch
-    testRepo.reset(initialHead);
-    PushOneCommit.Result changeA =
-        pushFactory
-            .create(user.newIdent(), testRepo, sourceSubject, fileName, sourceContent)
-            .to("refs/heads/dev");
-    changeA.assertOkStatus();
-    MergeInput mergeInput = new MergeInput();
-    mergeInput.source = "dev";
-    mergeInput.allowConflicts = true;
-    MergePatchSetInput in = new MergePatchSetInput();
-    in.merge = mergeInput;
-    in.subject = "update change by merge ps2";
-
-    TestWorkInProgressStateChangedListener wipStateChangedListener =
-        new TestWorkInProgressStateChangedListener();
-    try (Registration registration =
-        extensionRegistry.newRegistration().add(wipStateChangedListener)) {
-      ChangeInfo changeInfo = gApi.changes().id(changeId).createMergePatchSet(in);
-      assertThat(changeInfo.subject).isEqualTo(in.subject);
-      assertThat(changeInfo.containsGitConflicts).isTrue();
-      assertThat(changeInfo.workInProgress).isTrue();
-    }
-    assertThat(wipStateChangedListener.invoked).isTrue();
-    assertThat(wipStateChangedListener.wip).isTrue();
-
-    // To get the revisions, we must retrieve the change with more change options.
-    ChangeInfo changeInfo =
-        gApi.changes().id(changeId).get(ALL_REVISIONS, CURRENT_COMMIT, CURRENT_REVISION);
-    assertThat(changeInfo.revisions).hasSize(2);
-    assertThat(changeInfo.revisions.get(changeInfo.currentRevision).commit.parents.get(0).commit)
-        .isEqualTo(parent);
-
-    // Verify that the file content in the created patch set is correct.
-    // We expect that it has conflict markers to indicate the conflict.
-    BinaryResult bin = gApi.changes().id(changeId).current().file(fileName).content();
-    ByteArrayOutputStream os = new ByteArrayOutputStream();
-    bin.writeTo(os);
-    String fileContent = new String(os.toByteArray(), UTF_8);
-    String sourceSha1 = abbreviateName(changeA.getCommit(), 6);
-    String targetSha1 = abbreviateName(currentMaster.getCommit(), 6);
-    assertThat(fileContent)
-        .isEqualTo(
-            "<<<<<<< TARGET BRANCH ("
-                + targetSha1
-                + " "
-                + targetSubject
-                + ")\n"
-                + targetContent
-                + "\n"
-                + "=======\n"
-                + sourceContent
-                + "\n"
-                + ">>>>>>> SOURCE BRANCH ("
-                + sourceSha1
-                + " "
-                + sourceSubject
-                + ")\n");
-
-    // Verify the message that has been posted on the change.
-    List<ChangeMessageInfo> messages = gApi.changes().id(changeId).messages();
-    assertThat(messages).hasSize(2);
-    assertThat(Iterables.getLast(messages).message)
-        .isEqualTo(
-            "Uploaded patch set 2.\n\n"
-                + "The following files contain Git conflicts:\n"
-                + "* "
-                + fileName
-                + "\n");
-  }
-
-  @Test
-  public void createMergePatchSet_ConflictAllowedNotSupportedByMergeStrategy() throws Exception {
-    RevCommit initialHead = projectOperations.project(project).getHead("master");
-    createBranch("dev");
-
-    // create a change for master
-    String changeId = createChange().getChangeId();
-
-    String fileName = "shared.txt";
-    String sourceSubject = "source change";
-    String sourceContent = "source content";
-    String targetSubject = "target change";
-    String targetContent = "target content";
-    testRepo.reset(initialHead);
-    PushOneCommit.Result currentMaster =
-        pushFactory
-            .create(admin.newIdent(), testRepo, targetSubject, fileName, targetContent)
-            .to("refs/heads/master");
-    currentMaster.assertOkStatus();
-
-    // push a commit into dev branch
-    testRepo.reset(initialHead);
-    PushOneCommit.Result changeA =
-        pushFactory
-            .create(user.newIdent(), testRepo, sourceSubject, fileName, sourceContent)
-            .to("refs/heads/dev");
-    changeA.assertOkStatus();
-    MergeInput mergeInput = new MergeInput();
-    mergeInput.source = "dev";
-    mergeInput.allowConflicts = true;
-    mergeInput.strategy = "simple-two-way-in-core";
-    MergePatchSetInput in = new MergePatchSetInput();
-    in.merge = mergeInput;
-    in.subject = "update change by merge ps2";
-
-    BadRequestException ex =
-        assertThrows(
-            BadRequestException.class, () -> gApi.changes().id(changeId).createMergePatchSet(in));
-    assertThat(ex)
-        .hasMessageThat()
-        .isEqualTo(
-            "merge with conflicts is not supported with merge strategy: " + mergeInput.strategy);
-  }
-
-  @Test
-  public void createMergePatchSetInheritParent() throws Exception {
-    RevCommit initialHead = projectOperations.project(project).getHead("master");
-    createBranch("dev");
-
-    // create a change for master
-    PushOneCommit.Result r = createChange();
-    String changeId = r.getChangeId();
-    String parent = r.getCommit().getParent(0).getName();
-
-    // advance master branch
-    testRepo.reset(initialHead);
-    PushOneCommit.Result currentMaster = pushTo("refs/heads/master");
-    currentMaster.assertOkStatus();
-
-    // push a commit into dev branch
-    testRepo.reset(initialHead);
-    PushOneCommit.Result changeA =
-        pushFactory
-            .create(user.newIdent(), testRepo, "change A", "A.txt", "A content")
-            .to("refs/heads/dev");
-    changeA.assertOkStatus();
-    MergeInput mergeInput = new MergeInput();
-    mergeInput.source = "dev";
-    MergePatchSetInput in = new MergePatchSetInput();
-    in.merge = mergeInput;
-    in.subject = "update change by merge ps2 inherit parent of ps1";
-    in.inheritParent = true;
-    gApi.changes().id(changeId).createMergePatchSet(in);
-    ChangeInfo changeInfo =
-        gApi.changes().id(changeId).get(ALL_REVISIONS, CURRENT_COMMIT, CURRENT_REVISION);
-
-    assertThat(changeInfo.revisions).hasSize(2);
-    assertThat(changeInfo.subject).isEqualTo(in.subject);
-    assertThat(changeInfo.revisions.get(changeInfo.currentRevision).commit.parents.get(0).commit)
-        .isEqualTo(parent);
-    assertThat(changeInfo.revisions.get(changeInfo.currentRevision).commit.parents.get(0).commit)
-        .isNotEqualTo(currentMaster.getCommit().getName());
-  }
-
-  @Test
-  public void createMergePatchSetCannotBaseOnInvisibleChange() throws Exception {
-    RevCommit initialHead = projectOperations.project(project).getHead("master");
-    createBranch("foo");
-    createBranch("bar");
-
-    // Create a merged commit on 'foo' branch.
-    merge(createChange("refs/for/foo"));
-
-    // Create the base change on 'bar' branch.
-    testRepo.reset(initialHead);
-    String baseChange = createChange("refs/for/bar").getChangeId();
-    gApi.changes().id(baseChange).setPrivate(true, "set private");
-
-    // Create the destination change on 'master' branch.
-    requestScopeOperations.setApiUser(user.id());
-    testRepo.reset(initialHead);
-    String changeId = createChange().getChangeId();
-
-    UnprocessableEntityException thrown =
-        assertThrows(
-            UnprocessableEntityException.class,
-            () ->
-                gApi.changes()
-                    .id(changeId)
-                    .createMergePatchSet(createMergePatchSetInput(baseChange)));
-    assertThat(thrown).hasMessageThat().contains("Read not permitted for " + baseChange);
-  }
-
-  @Test
-  public void createMergePatchSetBaseOnChange() throws Exception {
-    RevCommit initialHead = projectOperations.project(project).getHead("master");
-    createBranch("foo");
-    createBranch("bar");
-
-    // Create a merged commit on 'foo' branch.
-    merge(createChange("refs/for/foo"));
-
-    // Create the base change on 'bar' branch.
-    testRepo.reset(initialHead);
-    PushOneCommit.Result result = createChange("refs/for/bar");
-    String baseChange = result.getChangeId();
-    String expectedParent = result.getCommit().getName();
-
-    // Create the destination change on 'master' branch.
-    testRepo.reset(initialHead);
-    String changeId = createChange().getChangeId();
-
-    gApi.changes().id(changeId).createMergePatchSet(createMergePatchSetInput(baseChange));
-
-    ChangeInfo changeInfo =
-        gApi.changes().id(changeId).get(ALL_REVISIONS, CURRENT_COMMIT, CURRENT_REVISION);
-    assertThat(changeInfo.revisions).hasSize(2);
-    assertThat(changeInfo.subject).isEqualTo("create ps2");
-    assertThat(changeInfo.revisions.get(changeInfo.currentRevision).commit.parents.get(0).commit)
-        .isEqualTo(expectedParent);
-  }
-
-  @Test
-  public void createMergePatchSetWithUnupportedMergeStrategy() throws Exception {
-    RevCommit initialHead = projectOperations.project(project).getHead("master");
-    createBranch("dev");
-
-    // create a change for master
-    String changeId = createChange().getChangeId();
-
-    String fileName = "shared.txt";
-    String sourceSubject = "source change";
-    String sourceContent = "source content";
-    String targetSubject = "target change";
-    String targetContent = "target content";
-    testRepo.reset(initialHead);
-    PushOneCommit.Result currentMaster =
-        pushFactory
-            .create(admin.newIdent(), testRepo, targetSubject, fileName, targetContent)
-            .to("refs/heads/master");
-    currentMaster.assertOkStatus();
-
-    // push a commit into dev branch
-    testRepo.reset(initialHead);
-    PushOneCommit.Result changeA =
-        pushFactory
-            .create(user.newIdent(), testRepo, sourceSubject, fileName, sourceContent)
-            .to("refs/heads/dev");
-    changeA.assertOkStatus();
-    MergeInput mergeInput = new MergeInput();
-    mergeInput.source = "dev";
-    mergeInput.strategy = "unsupported-strategy";
-    MergePatchSetInput in = new MergePatchSetInput();
-    in.merge = mergeInput;
-    in.subject = "update change by merge ps2";
-
-    BadRequestException ex =
-        assertThrows(
-            BadRequestException.class, () -> gApi.changes().id(changeId).createMergePatchSet(in));
-    assertThat(ex).hasMessageThat().isEqualTo("invalid merge strategy: " + mergeInput.strategy);
-  }
-
-  private MergePatchSetInput createMergePatchSetInput(String baseChange) {
-    MergeInput mergeInput = new MergeInput();
-    mergeInput.source = "foo";
-    MergePatchSetInput in = new MergePatchSetInput();
-    in.merge = mergeInput;
-    in.subject = "create ps2";
-    in.inheritParent = false;
-    in.baseChange = baseChange;
-    return in;
-  }
-
-  @Test
   public void checkLabelsForUnsubmittedChange() throws Exception {
     PushOneCommit.Result r = createChange();
     ChangeInfo change = gApi.changes().id(r.getChangeId()).get();
diff --git a/javatests/com/google/gerrit/acceptance/api/change/CreateMergePatchSetIT.java b/javatests/com/google/gerrit/acceptance/api/change/CreateMergePatchSetIT.java
new file mode 100644
index 0000000..8b77d013
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/change/CreateMergePatchSetIT.java
@@ -0,0 +1,644 @@
+package com.google.gerrit.acceptance.api.change;
+
+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.block;
+import static com.google.gerrit.extensions.client.ListChangesOption.ALL_REVISIONS;
+import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_COMMIT;
+import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_REVISION;
+import static com.google.gerrit.git.ObjectIds.abbreviateName;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import com.google.common.collect.Iterables;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.ExtensionRegistry;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Permission;
+import com.google.gerrit.extensions.api.accounts.AccountInput;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.ChangeMessageInfo;
+import com.google.gerrit.extensions.common.CommitInfo;
+import com.google.gerrit.extensions.common.MergeInput;
+import com.google.gerrit.extensions.common.MergePatchSetInput;
+import com.google.gerrit.extensions.events.WorkInProgressStateChangedListener;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.BinaryResult;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.inject.Inject;
+import java.io.ByteArrayOutputStream;
+import java.util.List;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.junit.Before;
+import org.junit.Test;
+
+public class CreateMergePatchSetIT extends AbstractDaemonTest {
+
+  @Inject private ProjectOperations projectOperations;
+  @Inject private RequestScopeOperations requestScopeOperations;
+  @Inject private ExtensionRegistry extensionRegistry;
+
+  @Before
+  public void setUp() {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.FORGE_AUTHOR).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+  }
+
+  @Test
+  public void createMergePatchSet() throws Exception {
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
+    createBranch(BranchNameKey.create(project, "dev"));
+
+    // create a change for master
+    String changeId = createChange().getChangeId();
+
+    testRepo.reset(initialHead);
+    PushOneCommit.Result currentMaster = pushTo("refs/heads/master");
+    currentMaster.assertOkStatus();
+    String parent = currentMaster.getCommit().getName();
+
+    // push a commit into dev branch
+    testRepo.reset(initialHead);
+    PushOneCommit.Result changeA =
+        pushFactory
+            .create(user.newIdent(), testRepo, "change A", "A.txt", "A content")
+            .to("refs/heads/dev");
+    changeA.assertOkStatus();
+    MergeInput mergeInput = new MergeInput();
+    mergeInput.source = "dev";
+    MergePatchSetInput in = new MergePatchSetInput();
+    in.merge = mergeInput;
+    String subject = "update change by merge ps2";
+    in.subject = subject;
+
+    TestWorkInProgressStateChangedListener wipStateChangedListener =
+        new TestWorkInProgressStateChangedListener();
+    try (ExtensionRegistry.Registration registration =
+        extensionRegistry.newRegistration().add(wipStateChangedListener)) {
+      ChangeInfo changeInfo = gApi.changes().id(changeId).createMergePatchSet(in);
+      assertThat(changeInfo.subject).isEqualTo(in.subject);
+      assertThat(changeInfo.containsGitConflicts).isNull();
+      assertThat(changeInfo.workInProgress).isNull();
+    }
+    assertThat(wipStateChangedListener.invoked).isFalse();
+
+    // To get the revisions, we must retrieve the change with more change options.
+    ChangeInfo changeInfo =
+        gApi.changes().id(changeId).get(ALL_REVISIONS, CURRENT_COMMIT, CURRENT_REVISION);
+    assertThat(changeInfo.revisions).hasSize(2);
+    assertThat(changeInfo.revisions.get(changeInfo.currentRevision).commit.parents.get(0).commit)
+        .isEqualTo(parent);
+
+    // Verify the message that has been posted on the change.
+    List<ChangeMessageInfo> messages = gApi.changes().id(changeId).messages();
+    assertThat(messages).hasSize(2);
+    assertThat(Iterables.getLast(messages).message).isEqualTo("Uploaded patch set 2.");
+
+    assertThat(changeInfo.revisions.get(changeInfo.currentRevision).commit.message)
+        .contains(subject);
+  }
+
+  @Test
+  public void createMergePatchSet_SubjectCarriesOverByDefault() throws Exception {
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
+    createBranch(BranchNameKey.create(project, "dev"));
+
+    // create a change for master
+    PushOneCommit.Result result = createChange();
+    String changeId = result.getChangeId();
+    String subject = result.getChange().change().getSubject();
+
+    // push a commit into dev branch
+    testRepo.reset(initialHead);
+    PushOneCommit.Result pushResult =
+        pushFactory.create(user.newIdent(), testRepo).to("refs/heads/dev");
+    pushResult.assertOkStatus();
+    MergeInput mergeInput = new MergeInput();
+    mergeInput.source = "dev";
+    MergePatchSetInput in = new MergePatchSetInput();
+    in.merge = mergeInput;
+    in.subject = null;
+
+    // Ensure subject carries over
+    gApi.changes().id(changeId).createMergePatchSet(in);
+    ChangeInfo changeInfo = gApi.changes().id(changeId).get();
+    assertThat(changeInfo.subject).isEqualTo(subject);
+  }
+
+  @Test
+  public void createMergePatchSet_Conflict() throws Exception {
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
+    createBranch(BranchNameKey.create(project, "dev"));
+
+    // create a change for master
+    String changeId = createChange().getChangeId();
+
+    String fileName = "shared.txt";
+    testRepo.reset(initialHead);
+    PushOneCommit.Result currentMaster =
+        pushFactory
+            .create(admin.newIdent(), testRepo, "change 1", fileName, "content 1")
+            .to("refs/heads/master");
+    currentMaster.assertOkStatus();
+
+    // push a commit into dev branch
+    testRepo.reset(initialHead);
+    PushOneCommit.Result changeA =
+        pushFactory
+            .create(user.newIdent(), testRepo, "change 2", fileName, "content 2")
+            .to("refs/heads/dev");
+    changeA.assertOkStatus();
+    MergeInput mergeInput = new MergeInput();
+    mergeInput.source = "dev";
+    MergePatchSetInput in = new MergePatchSetInput();
+    in.merge = mergeInput;
+    in.subject = "update change by merge ps2";
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.changes().id(changeId).createMergePatchSet(in));
+    assertThat(thrown).hasMessageThat().isEqualTo("merge conflict(s):\n" + fileName);
+  }
+
+  @Test
+  public void createMergePatchSet_ConflictAllowed() throws Exception {
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
+    createBranch(BranchNameKey.create(project, "dev"));
+
+    // create a change for master
+    String changeId = createChange().getChangeId();
+
+    String fileName = "shared.txt";
+    String sourceSubject = "source change";
+    String sourceContent = "source content";
+    String targetSubject = "target change";
+    String targetContent = "target content";
+    testRepo.reset(initialHead);
+    PushOneCommit.Result currentMaster =
+        pushFactory
+            .create(admin.newIdent(), testRepo, targetSubject, fileName, targetContent)
+            .to("refs/heads/master");
+    currentMaster.assertOkStatus();
+    String parent = currentMaster.getCommit().getName();
+
+    // push a commit into dev branch
+    testRepo.reset(initialHead);
+    PushOneCommit.Result changeA =
+        pushFactory
+            .create(user.newIdent(), testRepo, sourceSubject, fileName, sourceContent)
+            .to("refs/heads/dev");
+    changeA.assertOkStatus();
+    MergeInput mergeInput = new MergeInput();
+    mergeInput.source = "dev";
+    mergeInput.allowConflicts = true;
+    MergePatchSetInput in = new MergePatchSetInput();
+    in.merge = mergeInput;
+    in.subject = "update change by merge ps2";
+
+    TestWorkInProgressStateChangedListener wipStateChangedListener =
+        new TestWorkInProgressStateChangedListener();
+    try (ExtensionRegistry.Registration registration =
+        extensionRegistry.newRegistration().add(wipStateChangedListener)) {
+      ChangeInfo changeInfo = gApi.changes().id(changeId).createMergePatchSet(in);
+      assertThat(changeInfo.subject).isEqualTo(in.subject);
+      assertThat(changeInfo.containsGitConflicts).isTrue();
+      assertThat(changeInfo.workInProgress).isTrue();
+    }
+    assertThat(wipStateChangedListener.invoked).isTrue();
+    assertThat(wipStateChangedListener.wip).isTrue();
+
+    // To get the revisions, we must retrieve the change with more change options.
+    ChangeInfo changeInfo =
+        gApi.changes().id(changeId).get(ALL_REVISIONS, CURRENT_COMMIT, CURRENT_REVISION);
+    assertThat(changeInfo.revisions).hasSize(2);
+    assertThat(changeInfo.revisions.get(changeInfo.currentRevision).commit.parents.get(0).commit)
+        .isEqualTo(parent);
+
+    // Verify that the file content in the created patch set is correct.
+    // We expect that it has conflict markers to indicate the conflict.
+    BinaryResult bin = gApi.changes().id(changeId).current().file(fileName).content();
+    ByteArrayOutputStream os = new ByteArrayOutputStream();
+    bin.writeTo(os);
+    String fileContent = new String(os.toByteArray(), UTF_8);
+    String sourceSha1 = abbreviateName(changeA.getCommit(), 6);
+    String targetSha1 = abbreviateName(currentMaster.getCommit(), 6);
+    assertThat(fileContent)
+        .isEqualTo(
+            "<<<<<<< TARGET BRANCH ("
+                + targetSha1
+                + " "
+                + targetSubject
+                + ")\n"
+                + targetContent
+                + "\n"
+                + "=======\n"
+                + sourceContent
+                + "\n"
+                + ">>>>>>> SOURCE BRANCH ("
+                + sourceSha1
+                + " "
+                + sourceSubject
+                + ")\n");
+
+    // Verify the message that has been posted on the change.
+    List<ChangeMessageInfo> messages = gApi.changes().id(changeId).messages();
+    assertThat(messages).hasSize(2);
+    assertThat(Iterables.getLast(messages).message)
+        .isEqualTo(
+            "Uploaded patch set 2.\n\n"
+                + "The following files contain Git conflicts:\n"
+                + "* "
+                + fileName
+                + "\n");
+  }
+
+  @Test
+  public void createMergePatchSet_ConflictAllowedNotSupportedByMergeStrategy() throws Exception {
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
+    createBranch(BranchNameKey.create(project, "dev"));
+
+    // create a change for master
+    String changeId = createChange().getChangeId();
+
+    String fileName = "shared.txt";
+    String sourceSubject = "source change";
+    String sourceContent = "source content";
+    String targetSubject = "target change";
+    String targetContent = "target content";
+    testRepo.reset(initialHead);
+    PushOneCommit.Result currentMaster =
+        pushFactory
+            .create(admin.newIdent(), testRepo, targetSubject, fileName, targetContent)
+            .to("refs/heads/master");
+    currentMaster.assertOkStatus();
+
+    // push a commit into dev branch
+    testRepo.reset(initialHead);
+    PushOneCommit.Result changeA =
+        pushFactory
+            .create(user.newIdent(), testRepo, sourceSubject, fileName, sourceContent)
+            .to("refs/heads/dev");
+    changeA.assertOkStatus();
+    MergeInput mergeInput = new MergeInput();
+    mergeInput.source = "dev";
+    mergeInput.allowConflicts = true;
+    mergeInput.strategy = "simple-two-way-in-core";
+    MergePatchSetInput in = new MergePatchSetInput();
+    in.merge = mergeInput;
+    in.subject = "update change by merge ps2";
+
+    BadRequestException ex =
+        assertThrows(
+            BadRequestException.class, () -> gApi.changes().id(changeId).createMergePatchSet(in));
+    assertThat(ex)
+        .hasMessageThat()
+        .isEqualTo(
+            "merge with conflicts is not supported with merge strategy: " + mergeInput.strategy);
+  }
+
+  @Test
+  public void createMergePatchSetInheritParent() throws Exception {
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
+    createBranch(BranchNameKey.create(project, "dev"));
+
+    // create a change for master
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    String parent = r.getCommit().getParent(0).getName();
+
+    // advance master branch
+    testRepo.reset(initialHead);
+    PushOneCommit.Result currentMaster = pushTo("refs/heads/master");
+    currentMaster.assertOkStatus();
+
+    // push a commit into dev branch
+    testRepo.reset(initialHead);
+    PushOneCommit.Result changeA =
+        pushFactory
+            .create(user.newIdent(), testRepo, "change A", "A.txt", "A content")
+            .to("refs/heads/dev");
+    changeA.assertOkStatus();
+    MergeInput mergeInput = new MergeInput();
+    mergeInput.source = "dev";
+    MergePatchSetInput in = new MergePatchSetInput();
+    in.merge = mergeInput;
+    in.subject = "update change by merge ps2 inherit parent of ps1";
+    in.inheritParent = true;
+    gApi.changes().id(changeId).createMergePatchSet(in);
+    ChangeInfo changeInfo =
+        gApi.changes().id(changeId).get(ALL_REVISIONS, CURRENT_COMMIT, CURRENT_REVISION);
+
+    assertThat(changeInfo.revisions).hasSize(2);
+    assertThat(changeInfo.subject).isEqualTo(in.subject);
+    assertThat(changeInfo.revisions.get(changeInfo.currentRevision).commit.parents.get(0).commit)
+        .isEqualTo(parent);
+    assertThat(changeInfo.revisions.get(changeInfo.currentRevision).commit.parents.get(0).commit)
+        .isNotEqualTo(currentMaster.getCommit().getName());
+  }
+
+  @Test
+  public void createMergePatchSetCannotBaseOnInvisibleChange() throws Exception {
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
+    createBranch(BranchNameKey.create(project, "foo"));
+    createBranch(BranchNameKey.create(project, "bar"));
+
+    // Create a merged commit on 'foo' branch.
+    merge(createChange("refs/for/foo"));
+
+    // Create the base change on 'bar' branch.
+    testRepo.reset(initialHead);
+    String baseChange = createChange("refs/for/bar").getChangeId();
+    gApi.changes().id(baseChange).setPrivate(true, "set private");
+
+    // Create the destination change on 'master' branch.
+    requestScopeOperations.setApiUser(user.id());
+    testRepo.reset(initialHead);
+    String changeId = createChange().getChangeId();
+
+    UnprocessableEntityException thrown =
+        assertThrows(
+            UnprocessableEntityException.class,
+            () ->
+                gApi.changes()
+                    .id(changeId)
+                    .createMergePatchSet(createMergePatchSetInput(baseChange)));
+    assertThat(thrown).hasMessageThat().contains("Read not permitted for " + baseChange);
+  }
+
+  @Test
+  public void createMergePatchSetBaseOnChange() throws Exception {
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
+    createBranch(BranchNameKey.create(project, "foo"));
+    createBranch(BranchNameKey.create(project, "bar"));
+
+    // Create a merged commit on 'foo' branch.
+    merge(createChange("refs/for/foo"));
+
+    // Create the base change on 'bar' branch.
+    testRepo.reset(initialHead);
+    PushOneCommit.Result result = createChange("refs/for/bar");
+    String baseChange = result.getChangeId();
+    String expectedParent = result.getCommit().getName();
+
+    // Create the destination change on 'master' branch.
+    testRepo.reset(initialHead);
+    String changeId = createChange().getChangeId();
+
+    gApi.changes().id(changeId).createMergePatchSet(createMergePatchSetInput(baseChange));
+
+    ChangeInfo changeInfo =
+        gApi.changes().id(changeId).get(ALL_REVISIONS, CURRENT_COMMIT, CURRENT_REVISION);
+    assertThat(changeInfo.revisions).hasSize(2);
+    assertThat(changeInfo.subject).isEqualTo("create ps2");
+    assertThat(changeInfo.revisions.get(changeInfo.currentRevision).commit.parents.get(0).commit)
+        .isEqualTo(expectedParent);
+  }
+
+  @Test
+  public void createMergePatchSetWithUnupportedMergeStrategy() throws Exception {
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
+    createBranch(BranchNameKey.create(project, "dev"));
+
+    // create a change for master
+    String changeId = createChange().getChangeId();
+
+    String fileName = "shared.txt";
+    String sourceSubject = "source change";
+    String sourceContent = "source content";
+    String targetSubject = "target change";
+    String targetContent = "target content";
+    testRepo.reset(initialHead);
+    PushOneCommit.Result currentMaster =
+        pushFactory
+            .create(admin.newIdent(), testRepo, targetSubject, fileName, targetContent)
+            .to("refs/heads/master");
+    currentMaster.assertOkStatus();
+
+    // push a commit into dev branch
+    testRepo.reset(initialHead);
+    PushOneCommit.Result changeA =
+        pushFactory
+            .create(user.newIdent(), testRepo, sourceSubject, fileName, sourceContent)
+            .to("refs/heads/dev");
+    changeA.assertOkStatus();
+    MergeInput mergeInput = new MergeInput();
+    mergeInput.source = "dev";
+    mergeInput.strategy = "unsupported-strategy";
+    MergePatchSetInput in = new MergePatchSetInput();
+    in.merge = mergeInput;
+    in.subject = "update change by merge ps2";
+
+    BadRequestException ex =
+        assertThrows(
+            BadRequestException.class, () -> gApi.changes().id(changeId).createMergePatchSet(in));
+    assertThat(ex).hasMessageThat().isEqualTo("invalid merge strategy: " + mergeInput.strategy);
+  }
+
+  @Test
+  public void createMergePatchSetWithOtherAuthor() throws Exception {
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
+    createBranch(BranchNameKey.create(project, "dev"));
+
+    // create a change for master
+    String changeId = createChange().getChangeId();
+
+    testRepo.reset(initialHead);
+    PushOneCommit.Result currentMaster = pushTo("refs/heads/master");
+    currentMaster.assertOkStatus();
+    String parent = currentMaster.getCommit().getName();
+
+    // push a commit into dev branch
+    testRepo.reset(initialHead);
+    PushOneCommit.Result changeA =
+        pushFactory
+            .create(user.newIdent(), testRepo, "change A", "A.txt", "A content")
+            .to("refs/heads/dev");
+    changeA.assertOkStatus();
+    MergeInput mergeInput = new MergeInput();
+    mergeInput.source = "dev";
+    MergePatchSetInput in = new MergePatchSetInput();
+    in.merge = mergeInput;
+    String subject = "update change by merge ps2";
+    in.subject = subject;
+    in.author = new AccountInput();
+    in.author.name = "Other Author";
+    in.author.email = "otherauthor@example.com";
+    gApi.changes().id(changeId).createMergePatchSet(in);
+
+    // To get the revisions, we must retrieve the change with more change options.
+    ChangeInfo changeInfo =
+        gApi.changes().id(changeId).get(ALL_REVISIONS, CURRENT_COMMIT, CURRENT_REVISION);
+    assertThat(changeInfo.revisions).hasSize(2);
+    assertThat(changeInfo.revisions.get(changeInfo.currentRevision).commit.parents.get(0).commit)
+        .isEqualTo(parent);
+
+    // Verify the message that has been posted on the change.
+    List<ChangeMessageInfo> messages = gApi.changes().id(changeId).messages();
+    assertThat(messages).hasSize(2);
+    assertThat(Iterables.getLast(messages).message).isEqualTo("Uploaded patch set 2.");
+
+    CommitInfo commitInfo = changeInfo.revisions.get(changeInfo.currentRevision).commit;
+    assertThat(commitInfo.message).contains(subject);
+    assertThat(commitInfo.author.name).isEqualTo("Other Author");
+    assertThat(commitInfo.author.email).isEqualTo("otherauthor@example.com");
+  }
+
+  @Test
+  public void createMergePatchSetWithSpecificAuthorButNoForgeAuthorPermission() throws Exception {
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
+    createBranch(BranchNameKey.create(project, "dev"));
+
+    // create a change for master
+    String changeId = createChange().getChangeId();
+
+    testRepo.reset(initialHead);
+    PushOneCommit.Result currentMaster = pushTo("refs/heads/master");
+    currentMaster.assertOkStatus();
+    String parent = currentMaster.getCommit().getName();
+
+    // push a commit into dev branch
+    testRepo.reset(initialHead);
+    PushOneCommit.Result changeA =
+        pushFactory
+            .create(user.newIdent(), testRepo, "change A", "A.txt", "A content")
+            .to("refs/heads/dev");
+    changeA.assertOkStatus();
+    MergeInput mergeInput = new MergeInput();
+    mergeInput.source = "dev";
+    MergePatchSetInput in = new MergePatchSetInput();
+    in.merge = mergeInput;
+    String subject = "update change by merge ps2";
+    in.subject = subject;
+    in.author = new AccountInput();
+    in.author.name = "Foo";
+    in.author.email = "foo@example.com";
+
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .remove(
+            TestProjectUpdate.permissionKey(Permission.FORGE_AUTHOR)
+                .ref("refs/*")
+                .group(REGISTERED_USERS))
+        .add(block(Permission.FORGE_AUTHOR).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+    AuthException ex =
+        assertThrows(
+            AuthException.class, () -> gApi.changes().id(changeId).createMergePatchSet(in));
+    assertThat(ex).hasMessageThat().isEqualTo("not permitted: forge author on refs/heads/master");
+  }
+
+  @Test
+  public void createMergePatchSetWithMissingNameFailsWithBadRequestException() throws Exception {
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
+    createBranch(BranchNameKey.create(project, "dev"));
+
+    // create a change for master
+    String changeId = createChange().getChangeId();
+
+    testRepo.reset(initialHead);
+    PushOneCommit.Result currentMaster = pushTo("refs/heads/master");
+    currentMaster.assertOkStatus();
+    String parent = currentMaster.getCommit().getName();
+
+    // push a commit into dev branch
+    testRepo.reset(initialHead);
+    PushOneCommit.Result changeA =
+        pushFactory
+            .create(user.newIdent(), testRepo, "change A", "A.txt", "A content")
+            .to("refs/heads/dev");
+    changeA.assertOkStatus();
+    MergeInput mergeInput = new MergeInput();
+    mergeInput.source = "dev";
+    MergePatchSetInput in = new MergePatchSetInput();
+    in.merge = mergeInput;
+    String subject = "update change by merge ps2";
+    in.subject = subject;
+    in.author = new AccountInput();
+    in.author.name = "Foo";
+
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(Permission.FORGE_AUTHOR).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+    BadRequestException ex =
+        assertThrows(
+            BadRequestException.class, () -> gApi.changes().id(changeId).createMergePatchSet(in));
+    assertThat(ex).hasMessageThat().isEqualTo("Author must specify name and email");
+  }
+
+  @Test
+  public void createMergePatchSetWithMissingEmailFailsWithBadRequestException() throws Exception {
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
+    createBranch(BranchNameKey.create(project, "dev"));
+
+    // create a change for master
+    String changeId = createChange().getChangeId();
+
+    testRepo.reset(initialHead);
+    PushOneCommit.Result currentMaster = pushTo("refs/heads/master");
+    currentMaster.assertOkStatus();
+    String parent = currentMaster.getCommit().getName();
+
+    // push a commit into dev branch
+    testRepo.reset(initialHead);
+    PushOneCommit.Result changeA =
+        pushFactory
+            .create(user.newIdent(), testRepo, "change A", "A.txt", "A content")
+            .to("refs/heads/dev");
+    changeA.assertOkStatus();
+    MergeInput mergeInput = new MergeInput();
+    mergeInput.source = "dev";
+    MergePatchSetInput in = new MergePatchSetInput();
+    in.merge = mergeInput;
+    String subject = "update change by merge ps2";
+    in.subject = subject;
+    in.author = new AccountInput();
+    in.author.email = "Foo";
+
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(Permission.FORGE_AUTHOR).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+    BadRequestException ex =
+        assertThrows(
+            BadRequestException.class, () -> gApi.changes().id(changeId).createMergePatchSet(in));
+    assertThat(ex).hasMessageThat().isEqualTo("Author must specify name and email");
+  }
+
+  private MergePatchSetInput createMergePatchSetInput(String baseChange) {
+    MergeInput mergeInput = new MergeInput();
+    mergeInput.source = "foo";
+    MergePatchSetInput in = new MergePatchSetInput();
+    in.merge = mergeInput;
+    in.subject = "create ps2";
+    in.inheritParent = false;
+    in.baseChange = baseChange;
+    return in;
+  }
+
+  private static class TestWorkInProgressStateChangedListener
+      implements WorkInProgressStateChangedListener {
+    boolean invoked;
+    Boolean wip;
+
+    @Override
+    public void onWorkInProgressStateChanged(Event event) {
+      this.invoked = true;
+      this.wip =
+          event.getChange().workInProgress != null ? event.getChange().workInProgress : false;
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/project/CheckAccessIT.java b/javatests/com/google/gerrit/acceptance/api/project/CheckAccessIT.java
index f1d537f..59493be 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/CheckAccessIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/CheckAccessIT.java
@@ -162,28 +162,37 @@
     String project;
     String permission;
     int want;
+    List<String> expectedDebugLogs;
 
-    static TestCase project(String mail, String project, int want) {
+    static TestCase project(String mail, String project, int want, List<String> expectedDebugLogs) {
       TestCase t = new TestCase();
       t.input = new AccessCheckInput();
       t.input.account = mail;
       t.project = project;
       t.want = want;
+      t.expectedDebugLogs = expectedDebugLogs;
       return t;
     }
 
-    static TestCase projectRef(String mail, String project, String ref, int want) {
+    static TestCase projectRef(
+        String mail, String project, String ref, int want, List<String> expectedDebugLogs) {
       TestCase t = new TestCase();
       t.input = new AccessCheckInput();
       t.input.account = mail;
       t.input.ref = ref;
       t.project = project;
       t.want = want;
+      t.expectedDebugLogs = expectedDebugLogs;
       return t;
     }
 
     static TestCase projectRefPerm(
-        String mail, String project, String ref, String permission, int want) {
+        String mail,
+        String project,
+        String ref,
+        String permission,
+        int want,
+        List<String> expectedDebugLogs) {
       TestCase t = new TestCase();
       t.input = new AccessCheckInput();
       t.input.account = mail;
@@ -191,6 +200,7 @@
       t.input.permission = permission;
       t.project = project;
       t.want = want;
+      t.expectedDebugLogs = expectedDebugLogs;
       return t;
     }
   }
@@ -217,27 +227,98 @@
                 normalProject.get(),
                 "refs/heads/master",
                 Permission.VIEW_PRIVATE_CHANGES,
-                403),
-            TestCase.project(user.email(), normalProject.get(), 200),
-            TestCase.project(user.email(), secretProject.get(), 403),
+                403,
+                ImmutableList.of(
+                    "'user' can perform 'read' with force=false on project '"
+                        + normalProject.get()
+                        + "' for ref 'refs/*'",
+                    "'user' cannot perform 'viewPrivateChanges' with force=false on project '"
+                        + normalProject.get()
+                        + "' for ref 'refs/heads/master'")),
+            TestCase.project(
+                user.email(),
+                normalProject.get(),
+                200,
+                ImmutableList.of(
+                    "'user' can perform 'read' with force=false on project '"
+                        + normalProject.get()
+                        + "' for ref 'refs/*'")),
+            TestCase.project(
+                user.email(),
+                secretProject.get(),
+                403,
+                ImmutableList.of(
+                    "'user' cannot perform 'read' with force=false on project '"
+                        + secretProject.get()
+                        + "' for ref 'refs/*' because this permission is blocked")),
             TestCase.projectRef(
-                user.email(), secretRefProject.get(), "refs/heads/secret/master", 403),
+                user.email(),
+                secretRefProject.get(),
+                "refs/heads/secret/master",
+                403,
+                ImmutableList.of(
+                    "'user' can perform 'read' with force=false on project '"
+                        + secretRefProject.get()
+                        + "' for ref 'refs/heads/*'",
+                    "'user' cannot perform 'read' with force=false on project '"
+                        + secretRefProject.get()
+                        + "' for ref 'refs/heads/secret/master' because this permission is blocked")),
             TestCase.projectRef(
-                privilegedUser.email(), secretRefProject.get(), "refs/heads/secret/master", 200),
-            TestCase.projectRef(privilegedUser.email(), normalProject.get(), null, 200),
-            TestCase.projectRef(privilegedUser.email(), secretProject.get(), null, 200),
+                privilegedUser.email(),
+                secretRefProject.get(),
+                "refs/heads/secret/master",
+                200,
+                ImmutableList.of(
+                    "'privilegedUser' can perform 'read' with force=false on project '"
+                        + secretRefProject.get()
+                        + "' for ref 'refs/heads/*'",
+                    "'privilegedUser' can perform 'read' with force=false on project '"
+                        + secretRefProject.get()
+                        + "' for ref 'refs/heads/secret/master'")),
+            TestCase.projectRef(
+                privilegedUser.email(),
+                normalProject.get(),
+                null,
+                200,
+                ImmutableList.of(
+                    "'privilegedUser' can perform 'read' with force=false on project '"
+                        + normalProject.get()
+                        + "' for ref 'refs/*'")),
+            TestCase.projectRef(
+                privilegedUser.email(),
+                secretProject.get(),
+                null,
+                200,
+                ImmutableList.of(
+                    "'privilegedUser' can perform 'read' with force=false on project '"
+                        + secretProject.get()
+                        + "' for ref 'refs/*'")),
             TestCase.projectRefPerm(
                 privilegedUser.email(),
                 normalProject.get(),
                 "refs/heads/master",
                 Permission.VIEW_PRIVATE_CHANGES,
-                200),
+                200,
+                ImmutableList.of(
+                    "'privilegedUser' can perform 'read' with force=false on project '"
+                        + normalProject.get()
+                        + "' for ref 'refs/*'",
+                    "'privilegedUser' can perform 'viewPrivateChanges' with force=false on project '"
+                        + normalProject.get()
+                        + "' for ref 'refs/heads/master'")),
             TestCase.projectRefPerm(
                 privilegedUser.email(),
                 normalProject.get(),
                 "refs/heads/master",
                 Permission.FORGE_SERVER,
-                200));
+                200,
+                ImmutableList.of(
+                    "'privilegedUser' can perform 'read' with force=false on project '"
+                        + normalProject.get()
+                        + "' for ref 'refs/*'",
+                    "'privilegedUser' can perform 'forgeServerAsCommitter' with force=false on project '"
+                        + normalProject.get()
+                        + "' for ref 'refs/heads/master'")));
 
     for (TestCase tc : inputs) {
       String in = newGson().toJson(tc.input);
@@ -273,6 +354,14 @@
         default:
           assertWithMessage(String.format("unknown code %d", want)).fail();
       }
+
+      if (!info.debugLogs.equals(tc.expectedDebugLogs)) {
+        assertWithMessage(
+                String.format(
+                    "check.access(%s, %s) = %s, want %s",
+                    tc.project, in, info.debugLogs, tc.expectedDebugLogs))
+            .fail();
+      }
     }
   }
 
diff --git a/javatests/com/google/gerrit/acceptance/git/PushPermissionsIT.java b/javatests/com/google/gerrit/acceptance/git/PushPermissionsIT.java
index 1a2ae7c..78be4ab 100644
--- a/javatests/com/google/gerrit/acceptance/git/PushPermissionsIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/PushPermissionsIT.java
@@ -364,7 +364,7 @@
         .update();
 
     String project2 = name("project2");
-    gApi.projects().create(project2);
+    projectOperations.newProject().name(project2).create();
 
     ObjectId oldId = forceFetch("refs/meta/config");
 
diff --git a/javatests/com/google/gerrit/acceptance/rest/TraceIT.java b/javatests/com/google/gerrit/acceptance/rest/TraceIT.java
index f5d9e3a..cb34bdb 100644
--- a/javatests/com/google/gerrit/acceptance/rest/TraceIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/TraceIT.java
@@ -54,9 +54,9 @@
 import com.google.inject.Inject;
 import java.util.ArrayList;
 import java.util.List;
+import java.util.Map;
 import java.util.Optional;
-import java.util.SortedMap;
-import java.util.SortedSet;
+import java.util.Set;
 import org.apache.http.message.BasicHeader;
 import org.junit.Rule;
 import org.junit.Test;
@@ -337,7 +337,7 @@
     assertThat(LoggingContext.getInstance().getTags().isEmpty()).isTrue();
     assertForceLogging(false);
     try (TraceContext traceContext = TraceContext.open().forceLogging().addTag("foo", "bar")) {
-      SortedMap<String, SortedSet<Object>> tagMap = LoggingContext.getInstance().getTags().asMap();
+      Map<String, ? extends Set<Object>> tagMap = LoggingContext.getInstance().getTags().asMap();
       assertThat(tagMap.keySet()).containsExactly("foo");
       assertThat(tagMap.get("foo")).containsExactly("bar");
       assertForceLogging(true);
@@ -348,7 +348,7 @@
               () -> {
                 // Verify that the tags and force logging flag have been propagated to the new
                 // thread.
-                SortedMap<String, SortedSet<Object>> threadTagMap =
+                Map<String, ? extends Set<Object>> threadTagMap =
                     LoggingContext.getInstance().getTags().asMap();
                 expect.that(threadTagMap.keySet()).containsExactly("foo");
                 expect.that(threadTagMap.get("foo")).containsExactly("bar");
diff --git a/javatests/com/google/gerrit/acceptance/rest/account/WatchedProjectsIT.java b/javatests/com/google/gerrit/acceptance/rest/account/WatchedProjectsIT.java
index 2c9107c..b70cab8 100644
--- a/javatests/com/google/gerrit/acceptance/rest/account/WatchedProjectsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/account/WatchedProjectsIT.java
@@ -249,4 +249,30 @@
   public void postWithoutBody() throws Exception {
     adminRestSession.post("/accounts/" + admin.username() + "/watched.projects").assertOK();
   }
+
+  @Test
+  public void nullProjectThrowsBadRequestException() {
+    List<ProjectWatchInfo> projectsToWatch = new ArrayList<>();
+    ProjectWatchInfo pwi = new ProjectWatchInfo();
+    pwi.project = null;
+    projectsToWatch.add(pwi);
+    Throwable t =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.accounts().self().setWatchedProjects(projectsToWatch));
+    assertThat(t.getMessage()).isEqualTo("project name must be specified");
+  }
+
+  @Test
+  public void emptyProjectThrowsBadRequestException() {
+    List<ProjectWatchInfo> projectsToWatch = new ArrayList<>();
+    ProjectWatchInfo pwi = new ProjectWatchInfo();
+    pwi.project = "  ";
+    projectsToWatch.add(pwi);
+    Throwable t =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.accounts().self().setWatchedProjects(projectsToWatch));
+    assertThat(t.getMessage()).isEqualTo("project name must be specified");
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java b/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java
index 976be96..61eef63 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java
@@ -28,6 +28,7 @@
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.acceptance.UseClockStep;
+import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
 import com.google.gerrit.acceptance.testsuite.change.ChangeOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
@@ -41,6 +42,8 @@
 import com.google.gerrit.extensions.api.changes.DeleteReviewerInput;
 import com.google.gerrit.extensions.api.changes.HashtagsInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
+import com.google.gerrit.extensions.client.GeneralPreferencesInfo.EmailStrategy;
 import com.google.gerrit.extensions.client.ReviewerState;
 import com.google.gerrit.extensions.client.Side;
 import com.google.gerrit.extensions.restapi.BadRequestException;
@@ -1360,6 +1363,118 @@
     sender.clear();
   }
 
+  @Test
+  @GerritConfig(name = "change.enableAttentionSet", value = "true")
+  public void attentionSetEmailHeader() throws Exception {
+    PushOneCommit.Result r = createChange();
+    TestAccount user2 = accountCreator.user2();
+    // Add user and user2 to the attention set.
+    change(r)
+        .current()
+        .review(
+            ReviewInput.create().reviewer(user.email()).reviewer(accountCreator.user2().email()));
+    assertThat(Iterables.getOnlyElement(sender.getMessages()).body())
+        .contains(
+            "Attention is currently required from: "
+                + user2.fullName()
+                + ", "
+                + user.fullName()
+                + ".");
+    assertThat(Iterables.getOnlyElement(sender.getMessages()).htmlBody())
+        .contains(
+            "Attention is currently required from: "
+                + user2.fullName()
+                + ", "
+                + user.fullName()
+                + ".");
+    sender.clear();
+
+    // Irrelevant reply, User and User2 are still in the attention set.
+    change(r).current().review(ReviewInput.approve());
+    assertThat(Iterables.getOnlyElement(sender.getMessages()).body())
+        .contains(
+            "Attention is currently required from: "
+                + user2.fullName()
+                + ", "
+                + user.fullName()
+                + ".");
+    assertThat(Iterables.getOnlyElement(sender.getMessages()).htmlBody())
+        .contains(
+            "Attention is currently required from: "
+                + user2.fullName()
+                + ", "
+                + user.fullName()
+                + ".");
+    sender.clear();
+
+    // Abandon the change which removes user from attention set; there is an email but without the
+    // attention footer.
+    change(r).abandon();
+    assertThat(Iterables.getOnlyElement(sender.getMessages()).body())
+        .doesNotContain("Attention is currently required");
+    assertThat(Iterables.getOnlyElement(sender.getMessages()).htmlBody())
+        .doesNotContain("Attention is currently required");
+    sender.clear();
+  }
+
+  @Test
+  @GerritConfig(name = "change.enableAttentionSet", value = "false")
+  public void noReferenceToAttentionSetInEmailsWhenDisabled() throws Exception {
+    PushOneCommit.Result r = createChange();
+    // Add user and to the attention set.
+    change(r).addReviewer(user.id().toString());
+
+    // Attention set is not referenced.
+    assertThat(Iterables.getOnlyElement(sender.getMessages()).body())
+        .doesNotContain("Attention is currently required");
+    assertThat(Iterables.getOnlyElement(sender.getMessages()).htmlBody())
+        .doesNotContain("Attention is currently required");
+    sender.clear();
+  }
+
+  @Test
+  public void attentionSetWithEmailFilter() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    // Add preference for the user such that they only receive an email on changes that require
+    // their attention.
+    requestScopeOperations.setApiUser(user.id());
+    GeneralPreferencesInfo prefs = gApi.accounts().self().getPreferences();
+    prefs.emailStrategy = EmailStrategy.ATTENTION_SET_ONLY;
+    gApi.accounts().self().setPreferences(prefs);
+    requestScopeOperations.setApiUser(admin.id());
+
+    // Add user to attention set. They receive an email since they are in the attention set.
+    change(r).addReviewer(user.id().toString());
+    assertThat(sender.getMessages()).isNotEmpty();
+    sender.clear();
+
+    // Irrelevant reply, User is still in the attention set, thus got another email.
+    change(r).current().review(ReviewInput.approve());
+    assertThat(sender.getMessages()).isNotEmpty();
+    sender.clear();
+
+    // Abandon the change which removes user from attention set; the user doesn't receive an email
+    // since they are not in the attention set.
+    change(r).abandon();
+    assertThat(sender.getMessages()).isEmpty();
+  }
+
+  @Test
+  public void attentionSetWithEmailFilterImpactingOnlyChangeEmails() throws Exception {
+    // Add preference for the user such that they only receive an email on changes that require
+    // their attention.
+    requestScopeOperations.setApiUser(user.id());
+    GeneralPreferencesInfo prefs = gApi.accounts().self().getPreferences();
+    prefs.emailStrategy = EmailStrategy.ATTENTION_SET_ONLY;
+    gApi.accounts().self().setPreferences(prefs);
+    requestScopeOperations.setApiUser(admin.id());
+
+    // Ensure emails that don't relate to changes are still sent.
+    gApi.accounts().id(user.id().get()).generateHttpPassword();
+    assertThat(sender.getMessages()).isNotEmpty();
+  }
+
   private List<AttentionSetUpdate> getAttentionSetUpdatesForUser(
       PushOneCommit.Result r, TestAccount account) {
     return getAttentionSetUpdates(r.getChange().getId()).stream()
diff --git a/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java b/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
index b4dd4b3..2c42d0a 100644
--- a/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
@@ -1090,6 +1090,76 @@
             contextLines("2", "line_2", "3", "line_3", "4", "line_4", "5", "line_5"));
   }
 
+  @Test
+  public void commentContextForCommentsOnDifferentPatchsets() throws Exception {
+    PushOneCommit.Result r1 = createChange();
+
+    ImmutableList.Builder<String> content = ImmutableList.builder();
+    for (int i = 1; i <= 10; i++) {
+      content.add("line_" + i);
+    }
+
+    PushOneCommit.Result r2 =
+        pushFactory
+            .create(
+                admin.newIdent(),
+                testRepo,
+                SUBJECT,
+                FILE_NAME,
+                String.join("\n", content.build()),
+                r1.getChangeId())
+            .to("refs/for/master");
+
+    PushOneCommit.Result r3 =
+        pushFactory
+            .create(
+                admin.newIdent(),
+                testRepo,
+                SUBJECT,
+                FILE_NAME,
+                content.build().stream().collect(Collectors.joining("\n")),
+                r1.getChangeId())
+            .to("refs/for/master");
+
+    addCommentOnLine(r2, "r2: please fix", 1);
+    addCommentOnRange(r2, "r2: looks good", commentRangeInLines(2, 3));
+    addCommentOnLine(r3, "r3: please fix", 6);
+    addCommentOnRange(r3, "r3: looks good", commentRangeInLines(7, 8));
+
+    List<CommentInfo> comments =
+        gApi.changes().id(r2.getChangeId()).commentsRequest().withContext(true).getAsList();
+
+    assertThat(comments).hasSize(4);
+
+    assertThat(
+            comments.stream()
+                .filter(c -> c.message.equals("r2: please fix"))
+                .collect(MoreCollectors.onlyElement())
+                .contextLines)
+        .containsExactlyElementsIn(contextLines("1", "line_1"));
+
+    assertThat(
+            comments.stream()
+                .filter(c -> c.message.equals("r2: looks good"))
+                .collect(MoreCollectors.onlyElement())
+                .contextLines)
+        .containsExactlyElementsIn(contextLines("2", "line_2", "3", "line_3"));
+
+    assertThat(
+            comments.stream()
+                .filter(c -> c.message.equals("r3: please fix"))
+                .collect(MoreCollectors.onlyElement())
+                .contextLines)
+        .containsExactlyElementsIn(contextLines("6", "line_6"));
+
+    assertThat(
+            comments.stream()
+                .filter(c -> c.message.equals("r3: looks good"))
+                .collect(MoreCollectors.onlyElement())
+                .contextLines)
+        .containsExactlyElementsIn(contextLines("7", "line_7", "8", "line_8"));
+  }
+
   private List<ContextLineInfo> contextLines(String... args) {
     List<ContextLineInfo> result = new ArrayList<>();
     for (int i = 0; i < args.length; i += 2) {
diff --git a/javatests/com/google/gerrit/acceptance/ssh/SshDaemonIT.java b/javatests/com/google/gerrit/acceptance/ssh/SshDaemonIT.java
new file mode 100644
index 0000000..827c192
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/ssh/SshDaemonIT.java
@@ -0,0 +1,100 @@
+// 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 com.google.common.flogger.FluentLogger;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.Sandboxed;
+import com.google.gerrit.acceptance.UseSsh;
+import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.restapi.config.ListTasks;
+import com.google.gerrit.testing.ConfigSuite;
+import com.google.inject.Inject;
+import com.google.inject.Module;
+import java.time.LocalDateTime;
+import java.util.concurrent.Callable;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import org.eclipse.jgit.lib.Config;
+import org.junit.Assert;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@NoHttpd
+@UseSsh
+@Sandboxed
+@RunWith(ConfigSuite.class)
+@SuppressWarnings("unused")
+public class SshDaemonIT extends AbstractDaemonTest {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  @Inject private ListTasks listTasks;
+  @Inject private SitePaths gerritSitePath;
+
+  @ConfigSuite.Parameter protected Config config;
+
+  @ConfigSuite.Config
+  public static Config gracefulConfig() {
+    Config config = new Config();
+    config.setString("sshd", null, "gracefulStopTimeout", "10s");
+    return config;
+  }
+
+  @Override
+  public Module createSshModule() {
+    return new TestSshCommandModule();
+  }
+
+  public Future<Integer> startCommand(String command) throws Exception {
+    Callable<Integer> gracefulSession =
+        () -> {
+          int returnCode = -1;
+          logger.atFine().log("Before Command");
+          returnCode = userSshSession.execAndReturnStatus(command);
+          logger.atFine().log("After Command");
+          return returnCode;
+        };
+
+    ExecutorService executor = Executors.newFixedThreadPool(1);
+    Future<Integer> future = executor.submit(gracefulSession);
+
+    LocalDateTime timeout = LocalDateTime.now().plusSeconds(10);
+
+    TestCommand.syncPoint.await();
+
+    return future;
+  }
+
+  @Test
+  public void NonGracefulCommandIsStoppedImmediately() throws Exception {
+    Future<Integer> future = startCommand("non-graceful -d 5");
+    restart();
+    Assert.assertTrue(future.get() == -1);
+  }
+
+  @Test
+  public void GracefulCommandIsStoppedGracefully() throws Exception {
+    Future<Integer> future = startCommand("graceful -d 5");
+    restart();
+    if (cfg.getTimeUnit("sshd", null, "gracefulStopTimeout", 0, TimeUnit.SECONDS) == 0) {
+      Assert.assertTrue(future.get() == -1);
+    } else {
+      Assert.assertTrue(future.get() == 0);
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImplTest.java b/javatests/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImplTest.java
index 62dfc63..b888102 100644
--- a/javatests/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImplTest.java
+++ b/javatests/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImplTest.java
@@ -41,6 +41,7 @@
 import com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.TestPermission;
 import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.projects.BranchInfo;
 import com.google.gerrit.extensions.api.projects.ConfigInput;
 import com.google.gerrit.server.project.ProjectConfig;
@@ -114,6 +115,13 @@
   }
 
   @Test
+  public void permissionOnly() throws Exception {
+    Project.NameKey key = projectOperations.newProject().permissionOnly(true).create();
+    String head = gApi.projects().name(key.get()).head();
+    assertThat(head).isEqualTo(RefNames.REFS_CONFIG);
+  }
+
+  @Test
   public void getProjectConfig() throws Exception {
     Project.NameKey key = projectOperations.newProject().create();
     assertThat(projectOperations.project(key).getProjectConfig().getProject().getDescription())
diff --git a/javatests/com/google/gerrit/acceptance/testsuite/request/RequestScopeOperationsImplTest.java b/javatests/com/google/gerrit/acceptance/testsuite/request/RequestScopeOperationsImplTest.java
index f6421a5..48fd38c 100644
--- a/javatests/com/google/gerrit/acceptance/testsuite/request/RequestScopeOperationsImplTest.java
+++ b/javatests/com/google/gerrit/acceptance/testsuite/request/RequestScopeOperationsImplTest.java
@@ -16,7 +16,6 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assertWithMessage;
-import static com.google.common.truth.Truth8.assertThat;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.common.collect.ImmutableSet;
@@ -29,7 +28,6 @@
 import com.google.gerrit.extensions.common.ChangeInput;
 import com.google.gerrit.server.AnonymousUser;
 import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.CurrentUser.PropertyKey;
 import com.google.gerrit.server.notedb.Sequences;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -75,19 +73,6 @@
   }
 
   @Test
-  public void resetCurrentApiUserClearsCachedState() throws Exception {
-    requestScopeOperations.setApiUser(user.id());
-    PropertyKey<String> key = PropertyKey.create();
-    atrScope.get().getUser().put(key, "foo");
-    assertThat(atrScope.get().getUser().get(key)).hasValue("foo");
-
-    AcceptanceTestRequestScope.Context oldCtx = requestScopeOperations.resetCurrentApiUser();
-    checkCurrentUser(user.id());
-    assertThat(atrScope.get().getUser().get(key)).isEmpty();
-    assertThat(oldCtx.getUser().get(key)).hasValue("foo");
-  }
-
-  @Test
   public void setApiUserAnonymousSetsAnonymousUser() throws Exception {
     fastCheckCurrentUser(admin.id());
     requestScopeOperations.setApiUserAnonymous();
diff --git a/javatests/com/google/gerrit/server/cache/PerThreadCacheTest.java b/javatests/com/google/gerrit/server/cache/PerThreadCacheTest.java
deleted file mode 100644
index 5d420d3..0000000
--- a/javatests/com/google/gerrit/server/cache/PerThreadCacheTest.java
+++ /dev/null
@@ -1,103 +0,0 @@
-// Copyright (C) 2018 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.cache;
-
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.gerrit.testing.GerritJUnit.assertThrows;
-
-import java.util.function.Supplier;
-import org.junit.Test;
-
-public class PerThreadCacheTest {
-
-  @SuppressWarnings("TruthIncompatibleType")
-  @Test
-  public void key_respectsClass() {
-    assertThat(PerThreadCache.Key.create(String.class))
-        .isEqualTo(PerThreadCache.Key.create(String.class));
-    assertThat(PerThreadCache.Key.create(String.class))
-        .isNotEqualTo(
-            /* expected: Key<String>, actual: Key<Integer> */ PerThreadCache.Key.create(
-                Integer.class));
-  }
-
-  @Test
-  public void key_respectsIdentifiers() {
-    assertThat(PerThreadCache.Key.create(String.class, "id1"))
-        .isEqualTo(PerThreadCache.Key.create(String.class, "id1"));
-    assertThat(PerThreadCache.Key.create(String.class, "id1"))
-        .isNotEqualTo(PerThreadCache.Key.create(String.class, "id2"));
-  }
-
-  @Test
-  public void endToEndCache() {
-    try (PerThreadCache ignored = PerThreadCache.create()) {
-      PerThreadCache cache = PerThreadCache.get();
-      PerThreadCache.Key<String> key1 = PerThreadCache.Key.create(String.class);
-
-      String value1 = cache.get(key1, () -> "value1");
-      assertThat(value1).isEqualTo("value1");
-
-      Supplier<String> neverCalled =
-          () -> {
-            throw new IllegalStateException("this method must not be called");
-          };
-      assertThat(cache.get(key1, neverCalled)).isEqualTo("value1");
-    }
-  }
-
-  @Test
-  public void cleanUp() {
-    PerThreadCache.Key<String> key = PerThreadCache.Key.create(String.class);
-    try (PerThreadCache ignored = PerThreadCache.create()) {
-      PerThreadCache cache = PerThreadCache.get();
-      String value1 = cache.get(key, () -> "value1");
-      assertThat(value1).isEqualTo("value1");
-    }
-
-    // Create a second cache and assert that it is not connected to the first one.
-    // This ensures that the cleanup is actually working.
-    try (PerThreadCache ignored = PerThreadCache.create()) {
-      PerThreadCache cache = PerThreadCache.get();
-      String value1 = cache.get(key, () -> "value2");
-      assertThat(value1).isEqualTo("value2");
-    }
-  }
-
-  @Test
-  public void doubleInstantiationFails() {
-    try (PerThreadCache ignored = PerThreadCache.create()) {
-      IllegalStateException thrown =
-          assertThrows(IllegalStateException.class, () -> PerThreadCache.create());
-      assertThat(thrown).hasMessageThat().contains("called create() twice on the same request");
-    }
-  }
-
-  @Test
-  public void enforceMaxSize() {
-    try (PerThreadCache cache = PerThreadCache.create()) {
-      // Fill the cache
-      for (int i = 0; i < 50; i++) {
-        PerThreadCache.Key<String> key = PerThreadCache.Key.create(String.class, i);
-        cache.get(key, () -> "cached value");
-      }
-      // Assert that the value was not persisted
-      PerThreadCache.Key<String> key = PerThreadCache.Key.create(String.class, 1000);
-      cache.get(key, () -> "new value");
-      String value = cache.get(key, () -> "directly served");
-      assertThat(value).isEqualTo("directly served");
-    }
-  }
-}
diff --git a/javatests/com/google/gerrit/server/cache/serialize/BUILD b/javatests/com/google/gerrit/server/cache/serialize/BUILD
index fa6a717..6976d19 100644
--- a/javatests/com/google/gerrit/server/cache/serialize/BUILD
+++ b/javatests/com/google/gerrit/server/cache/serialize/BUILD
@@ -5,6 +5,7 @@
     srcs = glob(["*.java"]),
     deps = [
         "//java/com/google/gerrit/entities",
+        "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/server/cache/serialize",
         "//java/com/google/gerrit/server/cache/testing",
diff --git a/javatests/com/google/gerrit/server/cache/serialize/CommentContextSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/CommentContextSerializerTest.java
new file mode 100644
index 0000000..84f290c
--- /dev/null
+++ b/javatests/com/google/gerrit/server/cache/serialize/CommentContextSerializerTest.java
@@ -0,0 +1,41 @@
+package com.google.gerrit.server.cache.serialize;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.server.comment.CommentContextCacheImpl.CommentContextSerializer.INSTANCE;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.CommentContext;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.server.comment.CommentContextKey;
+import org.junit.Test;
+
+public class CommentContextSerializerTest {
+  @Test
+  public void roundTripValue() {
+    CommentContext commentContext =
+        CommentContext.create(ImmutableMap.of(1, "line_1", 2, "line_2"));
+
+    byte[] serialized = INSTANCE.serialize(commentContext);
+    CommentContext deserialized = INSTANCE.deserialize(serialized);
+
+    assertThat(commentContext).isEqualTo(deserialized);
+  }
+
+  @Test
+  public void roundTripKey() {
+    Project.NameKey proj = Project.NameKey.parse("project");
+    Change.Id changeId = Change.Id.tryParse("1234").get();
+
+    CommentContextKey k =
+        CommentContextKey.builder()
+            .project(proj)
+            .changeId(changeId)
+            .id("commentId")
+            .path("pathHash")
+            .patchset(1)
+            .build();
+    byte[] serialized = CommentContextKey.Serializer.INSTANCE.serialize(k);
+    assertThat(k).isEqualTo(CommentContextKey.Serializer.INSTANCE.deserialize(serialized));
+  }
+}
diff --git a/javatests/com/google/gerrit/server/extensions/webui/UiActionsTest.java b/javatests/com/google/gerrit/server/extensions/webui/UiActionsTest.java
index de23ef4..1cdca1b 100644
--- a/javatests/com/google/gerrit/server/extensions/webui/UiActionsTest.java
+++ b/javatests/com/google/gerrit/server/extensions/webui/UiActionsTest.java
@@ -101,11 +101,6 @@
         }
 
         @Override
-        public Object getCacheKey() {
-          return new Object();
-        }
-
-        @Override
         public boolean isIdentifiedUser() {
           return true;
         }
diff --git a/javatests/com/google/gerrit/server/git/receive/ReceivePackRefCacheTest.java b/javatests/com/google/gerrit/server/git/receive/ReceivePackRefCacheTest.java
index 698acd8..7eb6bc7 100644
--- a/javatests/com/google/gerrit/server/git/receive/ReceivePackRefCacheTest.java
+++ b/javatests/com/google/gerrit/server/git/receive/ReceivePackRefCacheTest.java
@@ -24,6 +24,7 @@
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.RefNames;
 import java.util.Map;
 import org.eclipse.jgit.lib.ObjectId;
@@ -60,17 +61,18 @@
   }
 
   @Test
-  public void noCache_tipsFromObjectIdDelegatesToRefDbAndFiltersByPrefix() throws Exception {
+  public void noCache_tipsFromObjectIdDelegatesToRefDb() throws Exception {
     Ref refBla = newRef("refs/bla", "badc0feebadc0feebadc0feebadc0feebadc0fee");
-    Ref refheads = newRef(RefNames.REFS_HEADS, "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef");
+    String patchSetRef = RefNames.REFS_CHANGES + "01/1/1";
+    Ref patchSet = newRef(patchSetRef, "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef");
 
     RefDatabase mockRefDb = mock(RefDatabase.class);
     ReceivePackRefCache cache = ReceivePackRefCache.noCache(mockRefDb);
     when(mockRefDb.getTipsWithSha1(ObjectId.zeroId()))
-        .thenReturn(ImmutableSet.of(refBla, refheads));
+        .thenReturn(ImmutableSet.of(refBla, patchSet));
 
-    assertThat(cache.tipsFromObjectId(ObjectId.zeroId(), RefNames.REFS_HEADS))
-        .containsExactly(refheads);
+    assertThat(cache.patchSetIdsFromObjectId(ObjectId.zeroId()))
+        .containsExactly(PatchSet.Id.fromRef(patchSetRef));
     verify(mockRefDb).getTipsWithSha1(ObjectId.zeroId());
     verifyNoMoreInteractions(mockRefDb);
   }
@@ -107,25 +109,14 @@
   }
 
   @Test
-  public void advertisedRefs_tipsFromObjectIdWithNoPrefix() throws Exception {
+  public void advertisedRefs_patchSetIdsFromObjectId() throws Exception {
     Map<String, Ref> refs = setupTwoChanges();
     ReceivePackRefCache cache = ReceivePackRefCache.withAdvertisedRefs(() -> refs);
 
     assertThat(
-            cache.tipsFromObjectId(
-                ObjectId.fromString("badc0feebadc0feebadc0feebadc0feebadc0fee"), null))
-        .containsExactly(refs.get("refs/changes/01/1/1"));
-  }
-
-  @Test
-  public void advertisedRefs_tipsFromObjectIdWithPrefix() throws Exception {
-    Map<String, Ref> refs = setupTwoChanges();
-    ReceivePackRefCache cache = ReceivePackRefCache.withAdvertisedRefs(() -> refs);
-
-    assertThat(
-            cache.tipsFromObjectId(
-                ObjectId.fromString("badc0feebadc0feebadc0feebadc0feebadc0fee"), "/refs/some"))
-        .isEmpty();
+            cache.patchSetIdsFromObjectId(
+                ObjectId.fromString("badc0feebadc0feebadc0feebadc0feebadc0fee")))
+        .containsExactly(PatchSet.Id.fromRef("refs/changes/01/1/1"));
   }
 
   private static Ref newRef(String name, String sha1) {
diff --git a/javatests/com/google/gerrit/server/logging/LoggingContextAwareExecutorServiceTest.java b/javatests/com/google/gerrit/server/logging/LoggingContextAwareExecutorServiceTest.java
index 733d784..8d019f3 100644
--- a/javatests/com/google/gerrit/server/logging/LoggingContextAwareExecutorServiceTest.java
+++ b/javatests/com/google/gerrit/server/logging/LoggingContextAwareExecutorServiceTest.java
@@ -24,8 +24,8 @@
 import com.google.inject.Guice;
 import com.google.inject.Inject;
 import com.google.inject.Injector;
-import java.util.SortedMap;
-import java.util.SortedSet;
+import java.util.Map;
+import java.util.Set;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Executors;
 import org.eclipse.jgit.lib.Config;
@@ -76,7 +76,7 @@
       // Create a performance log record.
       TraceContext.newTimer("test").close();
 
-      SortedMap<String, SortedSet<Object>> tagMap = LoggingContext.getInstance().getTags().asMap();
+      Map<String, ? extends Set<Object>> tagMap = LoggingContext.getInstance().getTags().asMap();
       assertThat(tagMap.keySet()).containsExactly("foo");
       assertThat(tagMap.get("foo")).containsExactly("bar");
       assertForceLogging(true);
@@ -90,7 +90,7 @@
               () -> {
                 // Verify that the tags and force logging flag have been propagated to the new
                 // thread.
-                SortedMap<String, SortedSet<Object>> threadTagMap =
+                Map<String, ? extends Set<Object>> threadTagMap =
                     LoggingContext.getInstance().getTags().asMap();
                 expect.that(threadTagMap.keySet()).containsExactly("foo");
                 expect.that(threadTagMap.get("foo")).containsExactly("bar");
diff --git a/javatests/com/google/gerrit/server/logging/MutableTagsTest.java b/javatests/com/google/gerrit/server/logging/MutableTagsTest.java
index f6f3b46..200c49d 100644
--- a/javatests/com/google/gerrit/server/logging/MutableTagsTest.java
+++ b/javatests/com/google/gerrit/server/logging/MutableTagsTest.java
@@ -21,8 +21,7 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.ImmutableSetMultimap;
 import java.util.Map;
-import java.util.SortedMap;
-import java.util.SortedSet;
+import java.util.Set;
 import org.junit.Before;
 import org.junit.Test;
 
@@ -157,7 +156,7 @@
   }
 
   private void assertTags(ImmutableMap<String, ImmutableSet<String>> expectedTagMap) {
-    SortedMap<String, SortedSet<Object>> actualTagMap = tags.getTags().asMap();
+    Map<String, ? extends Set<Object>> actualTagMap = tags.getTags().asMap();
     assertThat(actualTagMap.keySet()).containsExactlyElementsIn(expectedTagMap.keySet());
     for (Map.Entry<String, ImmutableSet<String>> expectedEntry : expectedTagMap.entrySet()) {
       assertThat(actualTagMap.get(expectedEntry.getKey()))
diff --git a/javatests/com/google/gerrit/server/logging/TraceContextTest.java b/javatests/com/google/gerrit/server/logging/TraceContextTest.java
index 13f2035..6a3632d 100644
--- a/javatests/com/google/gerrit/server/logging/TraceContextTest.java
+++ b/javatests/com/google/gerrit/server/logging/TraceContextTest.java
@@ -21,8 +21,7 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.server.logging.TraceContext.TraceIdConsumer;
 import java.util.Map;
-import java.util.SortedMap;
-import java.util.SortedSet;
+import java.util.Set;
 import org.junit.After;
 import org.junit.Test;
 
@@ -254,7 +253,7 @@
   }
 
   private void assertTags(ImmutableMap<String, ImmutableSet<String>> expectedTagMap) {
-    SortedMap<String, SortedSet<Object>> actualTagMap =
+    Map<String, ? extends Set<Object>> actualTagMap =
         LoggingContext.getInstance().getTags().asMap();
     assertThat(actualTagMap.keySet()).containsExactlyElementsIn(expectedTagMap.keySet());
     for (Map.Entry<String, ImmutableSet<String>> expectedEntry : expectedTagMap.entrySet()) {
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java b/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java
index dd3238f..321e4da 100644
--- a/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java
+++ b/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java
@@ -634,6 +634,39 @@
   }
 
   @Test
+  public void serializeAllAttentionSetUpdates() throws Exception {
+    assertRoundTrip(
+        newBuilder()
+            .allAttentionSetUpdates(
+                ImmutableList.of(
+                    AttentionSetUpdate.createFromRead(
+                        Instant.EPOCH.plusSeconds(23),
+                        Account.id(1000),
+                        AttentionSetUpdate.Operation.ADD,
+                        "reason 1"),
+                    AttentionSetUpdate.createFromRead(
+                        Instant.EPOCH.plusSeconds(42),
+                        Account.id(2000),
+                        AttentionSetUpdate.Operation.REMOVE,
+                        "reason 2")))
+            .build(),
+        newProtoBuilder()
+            .addAllAttentionSetUpdate(
+                AttentionSetUpdateProto.newBuilder()
+                    .setTimestampMillis(23_000) // epoch millis
+                    .setAccount(1000)
+                    .setOperation("ADD")
+                    .setReason("reason 1"))
+            .addAllAttentionSetUpdate(
+                AttentionSetUpdateProto.newBuilder()
+                    .setTimestampMillis(42_000) // epoch millis
+                    .setAccount(2000)
+                    .setOperation("REMOVE")
+                    .setReason("reason 2"))
+            .build());
+  }
+
+  @Test
   public void serializeAssigneeUpdates() throws Exception {
     assertRoundTrip(
         newBuilder()
@@ -793,6 +826,9 @@
                     "attentionSet",
                     new TypeLiteral<ImmutableSet<AttentionSetUpdate>>() {}.getType())
                 .put(
+                    "allAttentionSetUpdates",
+                    new TypeLiteral<ImmutableList<AttentionSetUpdate>>() {}.getType())
+                .put(
                     "assigneeUpdates",
                     new TypeLiteral<ImmutableList<AssigneeStatusUpdate>>() {}.getType())
                 .put("submitRecords", new TypeLiteral<ImmutableList<SubmitRecord>>() {}.getType())
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java b/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java
index 938fffc..44dd831 100644
--- a/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java
+++ b/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java
@@ -699,6 +699,13 @@
   }
 
   @Test
+  public void defaultAttentionSetUpdatesIsEmpty() throws Exception {
+    Change c = newChange();
+    ChangeNotes notes = newNotes(c);
+    assertThat(notes.getAttentionSetUpdates()).isEmpty();
+  }
+
+  @Test
   public void addAttentionStatus() throws Exception {
     Change c = newChange();
     ChangeUpdate update = newUpdate(c, changeOwner);
@@ -712,6 +719,19 @@
   }
 
   @Test
+  public void addAllAttentionUpdates() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    AttentionSetUpdate attentionSetUpdate =
+        AttentionSetUpdate.createForWrite(changeOwner.getAccountId(), Operation.ADD, "test");
+    update.addToPlannedAttentionSetUpdates(ImmutableSet.of(attentionSetUpdate));
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+    assertThat(notes.getAttentionSetUpdates()).containsExactly(addTimestamp(attentionSetUpdate, c));
+  }
+
+  @Test
   public void filterLatestAttentionStatus() throws Exception {
     Change c = newChange();
     ChangeUpdate update = newUpdate(c, changeOwner);
@@ -730,6 +750,28 @@
   }
 
   @Test
+  public void DoesNotFilterLatestAttentionSetUpdates() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    AttentionSetUpdate firstAttentionSetUpdate =
+        AttentionSetUpdate.createForWrite(changeOwner.getAccountId(), Operation.ADD, "test");
+    update.addToPlannedAttentionSetUpdates(ImmutableSet.of(firstAttentionSetUpdate));
+    update.commit();
+    update = newUpdate(c, changeOwner);
+    firstAttentionSetUpdate = addTimestamp(firstAttentionSetUpdate, c);
+
+    AttentionSetUpdate secondAttentionSetUpdate =
+        AttentionSetUpdate.createForWrite(changeOwner.getAccountId(), Operation.REMOVE, "test");
+    update.addToPlannedAttentionSetUpdates(ImmutableSet.of(secondAttentionSetUpdate));
+    update.commit();
+    secondAttentionSetUpdate = addTimestamp(secondAttentionSetUpdate, c);
+
+    ChangeNotes notes = newNotes(c);
+    assertThat(notes.getAttentionSetUpdates())
+        .containsExactly(secondAttentionSetUpdate, firstAttentionSetUpdate);
+  }
+
+  @Test
   public void addAttentionStatus_rejectTimestamp() throws Exception {
     Change c = newChange();
     ChangeUpdate update = newUpdate(c, changeOwner);
diff --git a/javatests/com/google/gerrit/server/permissions/RefControlTest.java b/javatests/com/google/gerrit/server/permissions/RefControlTest.java
index 81cb732..64f9392 100644
--- a/javatests/com/google/gerrit/server/permissions/RefControlTest.java
+++ b/javatests/com/google/gerrit/server/permissions/RefControlTest.java
@@ -1198,11 +1198,6 @@
     }
 
     @Override
-    public Object getCacheKey() {
-      return new Object();
-    }
-
-    @Override
     public Optional<String> getUserName() {
       return Optional.ofNullable(username);
     }
diff --git a/lib/auto/BUILD b/lib/auto/BUILD
index 1da7f50..18b9b91 100644
--- a/lib/auto/BUILD
+++ b/lib/auto/BUILD
@@ -27,6 +27,21 @@
     ],
 )
 
+java_plugin(
+    name = "auto-value-gson-plugin",
+    processor_class = "com.ryanharter.auto.value.gson.factory.AutoValueGsonAdapterFactoryProcessor",
+    deps = [
+        "@auto-value-annotations//jar",
+        "@auto-value-gson-extension//jar",
+        "@auto-value-gson-factory//jar",
+        "@auto-value-gson-runtime//jar",
+        "@auto-value//jar",
+        "@autotransient//jar",
+        "@gson//jar",
+        "@javapoet//jar",
+    ],
+)
+
 java_library(
     name = "auto-value",
     data = ["//lib:LICENSE-Apache2.0"],
@@ -50,3 +65,17 @@
     visibility = ["//visibility:public"],
     exports = ["@auto-value-annotations//jar"],
 )
+
+java_library(
+    name = "auto-value-gson",
+    data = ["//lib:LICENSE-Apache2.0"],
+    exported_plugins = [
+        ":auto-value-gson-plugin",
+    ],
+    visibility = ["//visibility:public"],
+    exports = [
+        "@auto-value-gson-extension//jar",
+        "@auto-value-gson-factory//jar",
+        "@auto-value-gson-runtime//jar",
+    ],
+)
diff --git a/modules/jgit b/modules/jgit
index 9fe5406..dd16976 160000
--- a/modules/jgit
+++ b/modules/jgit
@@ -1 +1 @@
-Subproject commit 9fe54061197c42faedc9417bdc70797681aa06d6
+Subproject commit dd169769bf42115e1dee749efeecab84544b28c4
diff --git a/plugins/replication b/plugins/replication
index 293ee35..63fb2b4 160000
--- a/plugins/replication
+++ b/plugins/replication
@@ -1 +1 @@
-Subproject commit 293ee3590d9fb57f6882e11126d8c97532cce968
+Subproject commit 63fb2b4ba85380d798acbdc076e8673353507569
diff --git a/polygerrit-ui/app/.eslintrc.js b/polygerrit-ui/app/.eslintrc.js
index 226cc4c..399ae6e 100644
--- a/polygerrit-ui/app/.eslintrc.js
+++ b/polygerrit-ui/app/.eslintrc.js
@@ -277,6 +277,12 @@
         "@typescript-eslint/restrict-plus-operands": "error",
         // 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
+        // errors in some cases (see https://github.com/typescript-eslint/typescript-eslint/issues/491)
+        // At the same time, we are using typescript in a strict mode and
+        // it catches almost all errors related to invalid usage of this.
+        "no-invalid-this": "off",
+
         "jsdoc/no-types": 2,
       },
       "parserOptions": {
diff --git a/polygerrit-ui/app/constants/constants.ts b/polygerrit-ui/app/constants/constants.ts
index 2db076f..8e8eaf3 100644
--- a/polygerrit-ui/app/constants/constants.ts
+++ b/polygerrit-ui/app/constants/constants.ts
@@ -292,10 +292,21 @@
 export enum EmailStrategy {
   ENABLED = 'ENABLED',
   CC_ON_OWN_COMMENTS = 'CC_ON_OWN_COMMENTS',
+  ATTENTION_SET_ONLY = 'ATTENTION_SET_ONLY',
   DISABLED = 'DISABLED',
 }
 
 /**
+ * The type of email format to use.
+ * Doesn't mentioned in doc, but exists in Java class GeneralPreferencesInfo.
+ */
+
+export enum EmailFormat {
+  PLAINTEXT = 'PLAINTEXT',
+  HTML_PLAINTEXT = 'HTML_PLAINTEXT',
+}
+
+/**
  * The base which should be pre-selected in the 'Diff Against' drop-down list when the change screen is opened for a merge commit
  * https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#preferences-input
  */
@@ -330,3 +341,20 @@
   OWNER_REVIEWERS = 'OWNER_REVIEWERS',
   ALL = 'ALL',
 }
+
+/**
+ * The authentication type that is configured on the server.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#auth-info
+ */
+export enum AuthType {
+  OPENID = 'OPENID',
+  OPENID_SSO = 'OPENID_SSO',
+  OAUTH = 'OAUTH',
+  HTTP = 'HTTP',
+  HTTP_LDAP = 'HTTP_LDAP',
+  CLIENT_SSL_CERT_LDAP = 'CLIENT_SSL_CERT_LDAP',
+  LDAP = 'LDAP',
+  LDAP_BIND = 'LDAP_BIND',
+  CUSTOM_EXTENSION = 'CUSTOM_EXTENSION',
+  DEVELOPMENT_BECOME_ANY_ACCOUNT = 'DEVELOPMENT_BECOME_ANY_ACCOUNT',
+}
diff --git a/polygerrit-ui/app/constants/messages.ts b/polygerrit-ui/app/constants/messages.ts
index 15692bd..5b4a534 100644
--- a/polygerrit-ui/app/constants/messages.ts
+++ b/polygerrit-ui/app/constants/messages.ts
@@ -15,10 +15,6 @@
  * limitations under the License.
  */
 
-/** @desc Default message shown when no threads in gr-thread-list */
-export const NO_THREADS_MSG =
-  'There are no inline comment threads on any diff for this change.';
-
 /** @desc Message shown when no threads in gr-thread-list for robot comments */
 export const NO_ROBOT_COMMENTS_THREADS_MSG =
   'There are no findings for this patchset.';
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 b9039ae..9d40e28 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
@@ -31,7 +31,7 @@
 import {customElement, property, observe, computed} from '@polymer/decorators';
 import {AppElementAdminParams} from '../../gr-app-types';
 import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
-import {GroupId, GroupInfo} from '../../../types/common';
+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';
 
@@ -160,7 +160,7 @@
         }
         this._groups = Object.keys(groups).map(key => {
           const group = groups[key];
-          group.name = key;
+          group.name = key as GroupName;
           return group;
         });
         this._loading = false;
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.js b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.js
deleted file mode 100644
index d861681..0000000
--- a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.js
+++ /dev/null
@@ -1,313 +0,0 @@
-/**
- * @license
- * Copyright (C) 2017 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 '../../../styles/gr-menu-page-styles.js';
-import '../../../styles/gr-page-nav-styles.js';
-import '../../../styles/shared-styles.js';
-import '../../shared/gr-dropdown-list/gr-dropdown-list.js';
-import '../../shared/gr-icons/gr-icons.js';
-import '../../shared/gr-js-api-interface/gr-js-api-interface.js';
-import '../../shared/gr-page-nav/gr-page-nav.js';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
-import '../gr-admin-group-list/gr-admin-group-list.js';
-import '../gr-group/gr-group.js';
-import '../gr-group-audit-log/gr-group-audit-log.js';
-import '../gr-group-members/gr-group-members.js';
-import '../gr-plugin-list/gr-plugin-list.js';
-import '../gr-repo/gr-repo.js';
-import '../gr-repo-access/gr-repo-access.js';
-import '../gr-repo-commands/gr-repo-commands.js';
-import '../gr-repo-dashboards/gr-repo-dashboards.js';
-import '../gr-repo-detail-list/gr-repo-detail-list.js';
-import '../gr-repo-list/gr-repo-list.js';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
-import {PolymerElement} from '@polymer/polymer/polymer-element.js';
-import {htmlTemplate} from './gr-admin-view_html.js';
-import {getBaseUrl} from '../../../utils/url-util.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
-import {getAdminLinks} from '../../../utils/admin-nav-util.js';
-
-const INTERNAL_GROUP_REGEX = /^[\da-f]{40}$/;
-
-/**
- * @extends PolymerElement
- */
-class GrAdminView extends GestureEventListeners(
-    LegacyElementMixin(
-        PolymerElement)) {
-  static get template() { return htmlTemplate; }
-
-  static get is() { return 'gr-admin-view'; }
-
-  static get properties() {
-    return {
-    /** @type {?} */
-      params: Object,
-      path: String,
-      adminView: String,
-
-      _breadcrumbParentName: String,
-      _repoName: String,
-      _groupId: {
-        type: Number,
-        observer: '_computeGroupName',
-      },
-      _groupIsInternal: Boolean,
-      _groupName: String,
-      _groupOwner: {
-        type: Boolean,
-        value: false,
-      },
-      _subsectionLinks: Array,
-      _filteredLinks: Array,
-      _showDownload: {
-        type: Boolean,
-        value: false,
-      },
-      _isAdmin: {
-        type: Boolean,
-        value: false,
-      },
-      _showGroup: Boolean,
-      _showGroupAuditLog: Boolean,
-      _showGroupList: Boolean,
-      _showGroupMembers: Boolean,
-      _showRepoAccess: Boolean,
-      _showRepoCommands: Boolean,
-      _showRepoDashboards: Boolean,
-      _showRepoDetailList: Boolean,
-      _showRepoMain: Boolean,
-      _showRepoList: Boolean,
-      _showPluginList: Boolean,
-    };
-  }
-
-  static get observers() {
-    return [
-      '_paramsChanged(params)',
-    ];
-  }
-
-  /** @override */
-  attached() {
-    super.attached();
-    this.reload();
-  }
-
-  reload() {
-    const promises = [
-      this.$.restAPI.getAccount(),
-      getPluginLoader().awaitPluginsLoaded(),
-    ];
-    return Promise.all(promises).then(result => {
-      this._account = result[0];
-      let options;
-      if (this._repoName) {
-        options = {repoName: this._repoName};
-      } else if (this._groupId) {
-        options = {
-          groupId: this._groupId,
-          groupName: this._groupName,
-          groupIsInternal: this._groupIsInternal,
-          isAdmin: this._isAdmin,
-          groupOwner: this._groupOwner,
-        };
-      }
-
-      return getAdminLinks(this._account,
-          params => this.$.restAPI.getAccountCapabilities(params),
-          () => this.$.jsAPI.getAdminMenuLinks(),
-          options)
-          .then(res => {
-            this._filteredLinks = res.links;
-            this._breadcrumbParentName = res.expandedSection ?
-              res.expandedSection.name : '';
-
-            if (!res.expandedSection) {
-              this._subsectionLinks = [];
-              return;
-            }
-            this._subsectionLinks = [res.expandedSection]
-                .concat(res.expandedSection.children).map(section => {
-                  return {
-                    text: !section.detailType ? 'Home' : section.name,
-                    value: section.view + (section.detailType || ''),
-                    view: section.view,
-                    url: section.url,
-                    detailType: section.detailType,
-                    parent: this._groupId || this._repoName || '',
-                  };
-                });
-          });
-    });
-  }
-
-  _computeSelectValue(params) {
-    if (!params || !params.view) { return; }
-    return params.view + (params.detail || '');
-  }
-
-  _selectedIsCurrentPage(selected) {
-    return (selected.parent === (this._repoName || this._groupId) &&
-        selected.view === this.params.view &&
-        selected.detailType === this.params.detail);
-  }
-
-  _handleSubsectionChange(e) {
-    const selected = this._subsectionLinks
-        .find(section => section.value === e.detail.value);
-
-    // This is when it gets set initially.
-    if (this._selectedIsCurrentPage(selected)) {
-      return;
-    }
-    GerritNav.navigateToRelativeUrl(selected.url);
-  }
-
-  _paramsChanged(params) {
-    const isGroupView = params.view === GerritNav.View.GROUP;
-    const isRepoView = params.view === GerritNav.View.REPO;
-    const isAdminView = params.view === GerritNav.View.ADMIN;
-
-    this.set('_showGroup', isGroupView && !params.detail);
-    this.set('_showGroupAuditLog', isGroupView &&
-        params.detail === GerritNav.GroupDetailView.LOG);
-    this.set('_showGroupMembers', isGroupView &&
-        params.detail === GerritNav.GroupDetailView.MEMBERS);
-
-    this.set('_showGroupList', isAdminView &&
-        params.adminView === 'gr-admin-group-list');
-
-    this.set('_showRepoAccess', isRepoView &&
-        params.detail === GerritNav.RepoDetailView.ACCESS);
-    this.set('_showRepoCommands', isRepoView &&
-        params.detail === GerritNav.RepoDetailView.COMMANDS);
-    this.set('_showRepoDetailList', isRepoView &&
-        (params.detail === GerritNav.RepoDetailView.BRANCHES ||
-         params.detail === GerritNav.RepoDetailView.TAGS));
-    this.set('_showRepoDashboards', isRepoView &&
-        params.detail === GerritNav.RepoDetailView.DASHBOARDS);
-    this.set('_showRepoMain', isRepoView && !params.detail);
-
-    this.set('_showRepoList', isAdminView &&
-        params.adminView === 'gr-repo-list');
-
-    this.set('_showPluginList', isAdminView &&
-        params.adminView === 'gr-plugin-list');
-
-    let needsReload = false;
-    if (params.repo !== this._repoName) {
-      this._repoName = params.repo || '';
-      // Reloads the admin menu.
-      needsReload = true;
-    }
-    if (params.groupId !== this._groupId) {
-      this._groupId = params.groupId || '';
-      // Reloads the admin menu.
-      needsReload = true;
-    }
-    if (this._breadcrumbParentName && !params.groupId && !params.repo) {
-      needsReload = true;
-    }
-    if (!needsReload) { return; }
-    this.reload();
-  }
-
-  // TODO (beckysiegel): Update these functions after router abstraction is
-  // updated. They are currently copied from gr-dropdown (and should be
-  // updated there as well once complete).
-  _computeURLHelper(host, path) {
-    return '//' + host + getBaseUrl() + path;
-  }
-
-  _computeRelativeURL(path) {
-    const host = window.location.host;
-    return this._computeURLHelper(host, path);
-  }
-
-  _computeLinkURL(link) {
-    if (!link || typeof link.url === 'undefined') { return ''; }
-    if (link.target || !link.noBaseUrl) {
-      return link.url;
-    }
-    return this._computeRelativeURL(link.url);
-  }
-
-  /**
-   * @param {string} itemView
-   * @param {Object} params
-   * @param {string=} opt_detailType
-   */
-  _computeSelectedClass(itemView, params, opt_detailType) {
-    if (!params) return '';
-    // Group params are structured differently from admin params. Compute
-    // selected differently for groups.
-    // TODO(wyatta): Simplify this when all routes work like group params.
-    if (params.view === GerritNav.View.GROUP &&
-        itemView === GerritNav.View.GROUP) {
-      if (!params.detail && !opt_detailType) { return 'selected'; }
-      if (params.detail === opt_detailType) { return 'selected'; }
-      return '';
-    }
-
-    if (params.view === GerritNav.View.REPO &&
-        itemView === GerritNav.View.REPO) {
-      if (!params.detail && !opt_detailType) { return 'selected'; }
-      if (params.detail === opt_detailType) { return 'selected'; }
-      return '';
-    }
-
-    if (params.detailType && params.detailType !== opt_detailType) {
-      return '';
-    }
-    return itemView === params.adminView ? 'selected' : '';
-  }
-
-  _computeGroupName(groupId) {
-    if (!groupId) { return ''; }
-
-    const promises = [];
-    this.$.restAPI.getGroupConfig(groupId).then(group => {
-      if (!group || !group.name) { return; }
-
-      this._groupName = group.name;
-      this._groupIsInternal = !!group.id.match(INTERNAL_GROUP_REGEX);
-      this.reload();
-
-      promises.push(this.$.restAPI.getIsAdmin().then(isAdmin => {
-        this._isAdmin = isAdmin;
-      }));
-
-      promises.push(this.$.restAPI.getIsGroupOwner(group.name).then(
-          isOwner => {
-            this._groupOwner = isOwner;
-          }));
-
-      return Promise.all(promises).then(() => {
-        this.reload();
-      });
-    });
-  }
-
-  _updateGroupName(e) {
-    this._groupName = e.detail.name;
-    this.reload();
-  }
-}
-
-customElements.define(GrAdminView.is, GrAdminView);
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.ts b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.ts
new file mode 100644
index 0000000..7fb713f
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.ts
@@ -0,0 +1,463 @@
+/**
+ * @license
+ * Copyright (C) 2017 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 '../../../styles/gr-menu-page-styles';
+import '../../../styles/gr-page-nav-styles';
+import '../../../styles/shared-styles';
+import '../../shared/gr-dropdown-list/gr-dropdown-list';
+import '../../shared/gr-icons/gr-icons';
+import '../../shared/gr-js-api-interface/gr-js-api-interface';
+import '../../shared/gr-page-nav/gr-page-nav';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../gr-admin-group-list/gr-admin-group-list';
+import '../gr-group/gr-group';
+import '../gr-group-audit-log/gr-group-audit-log';
+import '../gr-group-members/gr-group-members';
+import '../gr-plugin-list/gr-plugin-list';
+import '../gr-repo/gr-repo';
+import '../gr-repo-access/gr-repo-access';
+import '../gr-repo-commands/gr-repo-commands';
+import '../gr-repo-dashboards/gr-repo-dashboards';
+import '../gr-repo-detail-list/gr-repo-detail-list';
+import '../gr-repo-list/gr-repo-list';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-admin-view_html';
+import {getBaseUrl} from '../../../utils/url-util';
+import {
+  GerritNav,
+  GerritView,
+  GroupDetailView,
+  RepoDetailView,
+} from '../../core/gr-navigation/gr-navigation';
+import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
+import {
+  AdminNavLinksOption,
+  getAdminLinks,
+  NavLink,
+  SubsectionInterface,
+} from '../../../utils/admin-nav-util';
+import {customElement, observe, property} from '@polymer/decorators';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {
+  AppElementAdminParams,
+  AppElementGroupParams,
+  AppElementRepoParams,
+} from '../../gr-app-types';
+import {
+  AccountDetailInfo,
+  GroupId,
+  GroupName,
+  RepoName,
+} from '../../../types/common';
+import {GroupNameChangedDetail} from '../gr-group/gr-group';
+import {ValueChangeDetail} from '../../shared/gr-dropdown-list/gr-dropdown-list';
+import {GrJsApiInterface} from '../../shared/gr-js-api-interface/gr-js-api-interface-element';
+
+const INTERNAL_GROUP_REGEX = /^[\da-f]{40}$/;
+
+export interface GrAdminView {
+  $: {
+    restAPI: RestApiService & Element;
+    jsAPI: GrJsApiInterface;
+  };
+}
+
+interface AdminSubsectionLink {
+  text: string;
+  value: string;
+  view: GerritView;
+  url: string;
+  detailType?: GroupDetailView | RepoDetailView;
+  parent?: GroupId | RepoName;
+}
+
+// The type is matched to the _showAdminView function from the gr-app-element
+type AdminViewParams =
+  | AppElementAdminParams
+  | AppElementGroupParams
+  | AppElementRepoParams;
+
+function getAdminViewParamsDetail(
+  params: AdminViewParams
+): GroupDetailView | RepoDetailView | undefined {
+  if (params.view !== GerritView.ADMIN) {
+    return params.detail;
+  }
+  return undefined;
+}
+
+@customElement('gr-admin-view')
+export class GrAdminView extends GestureEventListeners(
+  LegacyElementMixin(PolymerElement)
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  private _account?: AccountDetailInfo;
+
+  @property({type: Object})
+  params?: AdminViewParams;
+
+  @property({type: String})
+  path?: string;
+
+  @property({type: String})
+  adminView?: string;
+
+  @property({type: String})
+  _breadcrumbParentName?: string;
+
+  @property({type: String})
+  _repoName?: RepoName;
+
+  @property({type: String, observer: '_computeGroupName'})
+  _groupId?: GroupId;
+
+  @property({type: Boolean})
+  _groupIsInternal?: boolean;
+
+  @property({type: String})
+  _groupName?: GroupName;
+
+  @property({type: Boolean})
+  _groupOwner = false;
+
+  @property({type: Array})
+  _subsectionLinks?: AdminSubsectionLink[];
+
+  @property({type: Array})
+  _filteredLinks?: NavLink[];
+
+  @property({type: Boolean})
+  _showDownload = false;
+
+  @property({type: Boolean})
+  _isAdmin = false;
+
+  @property({type: Boolean})
+  _showGroup?: boolean;
+
+  @property({type: Boolean})
+  _showGroupAuditLog?: boolean;
+
+  @property({type: Boolean})
+  _showGroupList?: boolean;
+
+  @property({type: Boolean})
+  _showGroupMembers?: boolean;
+
+  @property({type: Boolean})
+  _showRepoAccess?: boolean;
+
+  @property({type: Boolean})
+  _showRepoCommands?: boolean;
+
+  @property({type: Boolean})
+  _showRepoDashboards?: boolean;
+
+  @property({type: Boolean})
+  _showRepoDetailList?: boolean;
+
+  @property({type: Boolean})
+  _showRepoMain?: boolean;
+
+  @property({type: Boolean})
+  _showRepoList?: boolean;
+
+  @property({type: Boolean})
+  _showPluginList?: boolean;
+
+  /** @override */
+  attached() {
+    super.attached();
+    this.reload();
+  }
+
+  reload() {
+    const promises: [Promise<AccountDetailInfo | undefined>, Promise<void>] = [
+      this.$.restAPI.getAccount(),
+      getPluginLoader().awaitPluginsLoaded(),
+    ];
+    return Promise.all(promises).then(result => {
+      this._account = result[0];
+      let options: AdminNavLinksOption | undefined = undefined;
+      if (this._repoName) {
+        options = {repoName: this._repoName};
+      } else if (this._groupId) {
+        options = {
+          groupId: this._groupId,
+          groupName: this._groupName,
+          groupIsInternal: this._groupIsInternal,
+          isAdmin: this._isAdmin,
+          groupOwner: this._groupOwner,
+        };
+      }
+
+      return getAdminLinks(
+        this._account,
+        () =>
+          this.$.restAPI.getAccountCapabilities().then(capabilities => {
+            if (!capabilities) {
+              throw new Error('getAccountCapabilities returns undefined');
+            }
+            return capabilities;
+          }),
+        () => this.$.jsAPI.getAdminMenuLinks(),
+        options
+      ).then(res => {
+        this._filteredLinks = res.links;
+        this._breadcrumbParentName = res.expandedSection
+          ? res.expandedSection.name
+          : '';
+
+        if (!res.expandedSection) {
+          this._subsectionLinks = [];
+          return;
+        }
+        this._subsectionLinks = [res.expandedSection]
+          .concat(res.expandedSection.children ?? [])
+          .map(section => {
+            return {
+              text: !section.detailType ? 'Home' : section.name,
+              value: section.view + (section.detailType ?? ''),
+              view: section.view,
+              url: section.url,
+              detailType: section.detailType,
+              parent: this._groupId ?? this._repoName,
+            };
+          });
+      });
+    });
+  }
+
+  _computeSelectValue(params: AdminViewParams) {
+    if (!params || !params.view) return;
+    return `${params.view}${getAdminViewParamsDetail(params) ?? ''}`;
+  }
+
+  _selectedIsCurrentPage(selected: AdminSubsectionLink) {
+    if (!this.params) return false;
+
+    return (
+      selected.parent === (this._repoName ?? this._groupId) &&
+      selected.view === this.params.view &&
+      selected.detailType === getAdminViewParamsDetail(this.params)
+    );
+  }
+
+  _handleSubsectionChange(e: CustomEvent<ValueChangeDetail>) {
+    if (!this._subsectionLinks) return;
+
+    // The GrDropdownList items are _subsectionLinks, so find(...) always return
+    // an item _subsectionLinks and never returns undefined
+    const selected = this._subsectionLinks.find(
+      section => section.value === e.detail.value
+    )!;
+
+    // This is when it gets set initially.
+    if (this._selectedIsCurrentPage(selected)) return;
+    GerritNav.navigateToRelativeUrl(selected.url);
+  }
+
+  @observe('params')
+  _paramsChanged(params: AdminViewParams) {
+    this.set('_showGroup', params.view === GerritView.GROUP && !params.detail);
+    this.set(
+      '_showGroupAuditLog',
+      params.view === GerritView.GROUP && params.detail === GroupDetailView.LOG
+    );
+    this.set(
+      '_showGroupMembers',
+      params.view === GerritView.GROUP &&
+        params.detail === GroupDetailView.MEMBERS
+    );
+
+    this.set(
+      '_showGroupList',
+      params.view === GerritView.ADMIN &&
+        params.adminView === 'gr-admin-group-list'
+    );
+
+    this.set(
+      '_showRepoAccess',
+      params.view === GerritView.REPO && params.detail === RepoDetailView.ACCESS
+    );
+    this.set(
+      '_showRepoCommands',
+      params.view === GerritView.REPO &&
+        params.detail === RepoDetailView.COMMANDS
+    );
+    this.set(
+      '_showRepoDetailList',
+      params.view === GerritView.REPO &&
+        (params.detail === RepoDetailView.BRANCHES ||
+          params.detail === RepoDetailView.TAGS)
+    );
+    this.set(
+      '_showRepoDashboards',
+      params.view === GerritView.REPO &&
+        params.detail === RepoDetailView.DASHBOARDS
+    );
+    this.set(
+      '_showRepoMain',
+      params.view === GerritView.REPO && !params.detail
+    );
+
+    this.set(
+      '_showRepoList',
+      params.view === GerritView.ADMIN && params.adminView === 'gr-repo-list'
+    );
+
+    this.set(
+      '_showPluginList',
+      params.view === GerritView.ADMIN && params.adminView === 'gr-plugin-list'
+    );
+
+    let needsReload = false;
+    const newRepoName =
+      params.view === GerritView.REPO ? params.repo : undefined;
+    if (newRepoName !== this._repoName) {
+      this._repoName = newRepoName;
+      // Reloads the admin menu.
+      needsReload = true;
+    }
+    const newGroupId =
+      params.view === GerritView.GROUP ? params.groupId : undefined;
+    if (newGroupId !== this._groupId) {
+      this._groupId = newGroupId;
+      // Reloads the admin menu.
+      needsReload = true;
+    }
+    if (
+      this._breadcrumbParentName &&
+      (params.view !== GerritView.GROUP || !params.groupId) &&
+      (params.view !== GerritView.REPO || !params.repo)
+    ) {
+      needsReload = true;
+    }
+    if (!needsReload) {
+      return;
+    }
+    this.reload();
+  }
+
+  // TODO (beckysiegel): Update these functions after router abstraction is
+  // updated. They are currently copied from gr-dropdown (and should be
+  // updated there as well once complete).
+  _computeURLHelper(host: string, path: string) {
+    return '//' + host + getBaseUrl() + path;
+  }
+
+  _computeRelativeURL(path: string) {
+    const host = window.location.host;
+    return this._computeURLHelper(host, path);
+  }
+
+  _computeLinkURL(link: NavLink | SubsectionInterface) {
+    if (!link || typeof link.url === 'undefined') return '';
+
+    if ((link as NavLink).target || !(link as NavLink).noBaseUrl) {
+      return link.url;
+    }
+    return this._computeRelativeURL(link.url);
+  }
+
+  _computeSelectedClass(
+    itemView?: GerritView,
+    params?: AdminViewParams,
+    detailType?: GroupDetailView | RepoDetailView
+  ) {
+    if (!params) return '';
+    // Group params are structured differently from admin params. Compute
+    // selected differently for groups.
+    // TODO(wyatta): Simplify this when all routes work like group params.
+    if (params.view === GerritView.GROUP && itemView === GerritView.GROUP) {
+      if (!params.detail && !detailType) {
+        return 'selected';
+      }
+      if (params.detail === detailType) {
+        return 'selected';
+      }
+      return '';
+    }
+
+    if (params.view === GerritView.REPO && itemView === GerritView.REPO) {
+      if (!params.detail && !detailType) {
+        return 'selected';
+      }
+      if (params.detail === detailType) {
+        return 'selected';
+      }
+      return '';
+    }
+    // TODO(TS): The following condtion seems always false, because params
+    // never has detailType property. Remove it.
+    if (
+      ((params as unknown) as AdminSubsectionLink).detailType &&
+      ((params as unknown) as AdminSubsectionLink).detailType !== detailType
+    ) {
+      return '';
+    }
+    return params.view === GerritView.ADMIN && itemView === params.adminView
+      ? 'selected'
+      : '';
+  }
+
+  _computeGroupName(groupId?: GroupId) {
+    if (!groupId) return;
+
+    const promises: Array<Promise<void>> = [];
+    this.$.restAPI.getGroupConfig(groupId).then(group => {
+      if (!group || !group.name) {
+        return;
+      }
+
+      this._groupName = group.name;
+      this._groupIsInternal = !!group.id.match(INTERNAL_GROUP_REGEX);
+      this.reload();
+
+      promises.push(
+        this.$.restAPI.getIsAdmin().then(isAdmin => {
+          this._isAdmin = !!isAdmin;
+        })
+      );
+
+      promises.push(
+        this.$.restAPI.getIsGroupOwner(group.name).then(isOwner => {
+          this._groupOwner = isOwner;
+        })
+      );
+
+      return Promise.all(promises).then(() => {
+        this.reload();
+      });
+    });
+  }
+
+  _updateGroupName(e: CustomEvent<GroupNameChangedDetail>) {
+    this._groupName = e.detail.name;
+    this.reload();
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-admin-view': GrAdminView;
+  }
+}
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_test.js b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_test.js
index 5fe479a..44fd4d6 100644
--- a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_test.js
+++ b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_test.js
@@ -18,7 +18,7 @@
 import '../../../test/common-test-setup-karma.js';
 import './gr-admin-view.js';
 import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
+import {GerritNav, GerritView} from '../../core/gr-navigation/gr-navigation.js';
 import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
 import {stubBaseUrl} from '../../../test/test-utils.js';
 
@@ -149,7 +149,7 @@
     return element.reload().then(() => {
       assert.equal(element._filteredLinks.length, 3);
       assert.deepEqual(element._filteredLinks[1], {
-        capability: null,
+        capability: undefined,
         url: '/internal/link/url',
         name: 'internal link text',
         noBaseUrl: true,
@@ -158,7 +158,7 @@
         target: null,
       });
       assert.deepEqual(element._filteredLinks[2], {
-        capability: null,
+        capability: undefined,
         url: 'http://external/link/url',
         name: 'external link text',
         noBaseUrl: false,
@@ -244,10 +244,10 @@
         'getAccount')
         .callsFake(() => Promise.resolve({_id: 1}));
     sinon.stub(element, 'reload');
-    element.params = {repo: 'Test Repo', adminView: 'gr-repo'};
+    element.params = {repo: 'Test Repo', view: GerritView.REPO};
     assert.equal(element.reload.callCount, 1);
     element.params = {repo: 'Test Repo 2',
-      adminView: 'gr-repo'};
+      view: GerritView.REPO};
     assert.equal(element.reload.callCount, 2);
   });
 
@@ -266,7 +266,7 @@
         'getAccount')
         .callsFake(() => Promise.resolve({_id: 1}));
     sinon.stub(element, 'reload');
-    element.params = {groupId: '1', adminView: 'gr-group'};
+    element.params = {groupId: '1', view: GerritView.GROUP};
     assert.equal(element.reload.callCount, 1);
   });
 
diff --git a/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog.ts b/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog.ts
index e8b5d78..1d8b7db 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog.ts
+++ b/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog.ts
@@ -26,7 +26,7 @@
 import {page} from '../../../utils/page-wrapper-utils';
 import {customElement, property, observe} from '@polymer/decorators';
 import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
-import {GroupId} from '../../../types/common';
+import {GroupName} from '../../../types/common';
 
 export interface GrCreateGroupDialog {
   $: {
@@ -46,7 +46,7 @@
   hasNewGroupName = false;
 
   @property({type: String})
-  _name = '';
+  _name: GroupName | '' = '';
 
   @property({type: Boolean})
   _groupCreated = false;
@@ -61,20 +61,17 @@
   }
 
   handleCreateGroup() {
-    return this.$.restAPI
-      .createGroup({name: this._name})
-      .then(groupRegistered => {
-        if (groupRegistered.status !== 201) {
-          return;
-        }
-        this._groupCreated = true;
-        return this.$.restAPI
-          .getGroupConfig(this._name as GroupId)
-          .then(group => {
-            // TODO(TS): should group always defined ?
-            page.show(this._computeGroupUrl(group!.group_id!));
-          });
+    const name = this._name as GroupName;
+    return this.$.restAPI.createGroup({name}).then(groupRegistered => {
+      if (groupRegistered.status !== 201) {
+        return;
+      }
+      this._groupCreated = true;
+      return this.$.restAPI.getGroupConfig(name).then(group => {
+        // TODO(TS): should group always defined ?
+        page.show(this._computeGroupUrl(group!.group_id!));
       });
+    });
   }
 }
 
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 f8f1fee..ae10c03 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
@@ -41,6 +41,7 @@
   AccountId,
   AccountInfo,
   GroupInfo,
+  GroupName,
 } from '../../../types/common';
 import {AutocompleteQuery} from '../../shared/gr-autocomplete/gr-autocomplete';
 import {PolymerDomRepeatEvent} from '../../../types/types';
@@ -85,7 +86,7 @@
   _loading = true;
 
   @property({type: String})
-  _groupName?: GroupId;
+  _groupName?: GroupName;
 
   @property({type: Object})
   _groupMembers?: AccountInfo[];
@@ -155,7 +156,7 @@
         return Promise.resolve();
       }
 
-      this._groupName = config.name as GroupId;
+      this._groupName = config.name;
 
       promises.push(
         this.$.restAPI.getIsAdmin().then(isAdmin => {
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 7bc0312..511bf5c 100644
--- a/polygerrit-ui/app/elements/admin/gr-group/gr-group.ts
+++ b/polygerrit-ui/app/elements/admin/gr-group/gr-group.ts
@@ -32,7 +32,7 @@
   AutocompleteSuggestion,
   AutocompleteQuery,
 } from '../../shared/gr-autocomplete/gr-autocomplete';
-import {GroupId, GroupInfo} from '../../../types/common';
+import {GroupId, GroupInfo, GroupName} from '../../../types/common';
 import {
   ErrorCallback,
   RestApiService,
@@ -59,6 +59,11 @@
   };
 }
 
+export interface GroupNameChangedDetail {
+  name: GroupName;
+  external: boolean;
+}
+
 declare global {
   interface HTMLElementTagNameMap {
     'gr-group': GrGroup;
@@ -164,7 +169,7 @@
       );
 
       promises.push(
-        this.$.restAPI.getIsGroupOwner(config.name as GroupId).then(isOwner => {
+        this.$.restAPI.getIsGroupOwner(config.name).then(isOwner => {
           this._groupOwner = !!isOwner;
         })
       );
@@ -205,17 +210,19 @@
     if (!this.groupId || !groupConfig || !groupConfig.name) {
       return Promise.reject(new Error('invalid groupId or config name'));
     }
+    const groupName = groupConfig.name;
     return this.$.restAPI
-      .saveGroupName(this.groupId, groupConfig.name)
+      .saveGroupName(this.groupId, groupName)
       .then(config => {
         if (config.status === 200) {
-          this._groupName = groupConfig.name;
+          this._groupName = groupName;
+          const detail: GroupNameChangedDetail = {
+            name: groupName,
+            external: !this._groupIsInternal,
+          };
           this.dispatchEvent(
             new CustomEvent('name-changed', {
-              detail: {
-                name: groupConfig.name,
-                external: !this._groupIsInternal,
-              },
+              detail,
               composed: true,
               bubbles: true,
             })
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 20cc207..b5d2d53 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
@@ -46,6 +46,7 @@
   ServerInfo,
   AccountInfo,
   QuickLabelInfo,
+  Timestamp,
 } from '../../../types/common';
 import {hasOwnProperty} from '../../../utils/common-util';
 
@@ -67,6 +68,11 @@
   REJECTED = 'REJECTED',
 }
 
+export interface ChangeListToggleReviewedDetail {
+  change: ChangeInfo;
+  reviewed: boolean;
+}
+
 // How many reviewers should be shown with an account-label?
 const PRIMARY_REVIEWERS_COUNT = 2;
 
@@ -383,14 +389,27 @@
     }
   }
 
+  _computeSince(
+    account?: AccountInfo,
+    change?: ChangeInfo
+  ): Timestamp | undefined {
+    if (!account?._account_id || !change?.attention_set) return undefined;
+    return change?.attention_set[account._account_id]?.last_update;
+  }
+
   toggleReviewed() {
+    if (!this.change) return;
     const newVal = !this.change?.reviewed;
     this.set('change.reviewed', newVal);
+    const detail: ChangeListToggleReviewedDetail = {
+      change: this.change,
+      reviewed: newVal,
+    };
     this.dispatchEvent(
       new CustomEvent('toggle-reviewed', {
         bubbles: true,
         composed: true,
-        detail: {change: this.change, reviewed: newVal},
+        detail,
       })
     );
   }
@@ -408,6 +427,16 @@
       isOwner: selfId === ownerId,
     });
   }
+
+  _computeCommaHidden(index?: number, change?: ChangeInfo) {
+    if (index === undefined) return false;
+    if (change === undefined) return false;
+
+    const additionalCount = this._computeAdditionalReviewersCount(change);
+    const primaryCount = this._computePrimaryReviewers(change).length;
+    const isLast = index === primaryCount - 1;
+    return isLast && additionalCount === 0;
+  }
 }
 
 declare global {
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_html.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_html.ts
index 5316fe5..2ae6c37 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_html.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_html.ts
@@ -32,9 +32,6 @@
       font-weight: var(--font-weight-bold);
       color: var(--primary-text-color);
     }
-    :host([highlight]) {
-      background-color: var(--assignee-highlight-color);
-    }
     .container {
       position: relative;
     }
@@ -113,9 +110,6 @@
     .cell.label iron-icon {
       vertical-align: top;
     }
-    .lastChildHidden:last-of-type {
-      display: none;
-    }
     @media only screen and (max-width: 50em) {
       :host {
         display: flex;
@@ -197,6 +191,7 @@
         is="dom-repeat"
         items="[[_computePrimaryReviewers(change)]]"
         as="reviewer"
+        indexAs="index"
       >
         <gr-account-link
           hide-avatar=""
@@ -206,7 +201,11 @@
           change="[[change]]"
           account="[[reviewer]]"
         ></gr-account-link
-        ><span class="lastChildHidden" aria-hidden="true">, </span>
+        ><span
+          hidden$="[[_computeCommaHidden(index, change)]]"
+          aria-hidden="true"
+          >,
+        </span>
       </template>
       <template is="dom-if" if="[[_computeAdditionalReviewersCount(change)]]">
         <span title="[[_computeAdditionalReviewersTitle(change, config)]]">
@@ -266,6 +265,26 @@
     ></gr-date-formatter>
   </td>
   <td
+    class="cell submitted"
+    hidden$="[[isColumnHidden('Submitted', visibleChangeTableColumns)]]"
+  >
+    <gr-date-formatter
+      has-tooltip=""
+      date-str="[[change.submitted]]"
+    ></gr-date-formatter>
+  </td>
+  <td
+    class="cell since"
+    hidden$="[[isColumnHidden('Since', visibleChangeTableColumns)]]"
+  >
+    <gr-date-formatter
+      has-tooltip=""
+      force-relative=""
+      relative-option-no-ago=""
+      date-str="[[_computeSince(account, change)]]"
+    ></gr-date-formatter>
+  </td>
+  <td
     class="cell size"
     hidden$="[[isColumnHidden('Size', visibleChangeTableColumns)]]"
   >
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.js b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.js
deleted file mode 100644
index b6bc03c..0000000
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.js
+++ /dev/null
@@ -1,306 +0,0 @@
-/**
- * @license
- * Copyright (C) 2015 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 '../../shared/gr-icons/gr-icons.js';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
-import '../gr-change-list/gr-change-list.js';
-import '../gr-repo-header/gr-repo-header.js';
-import '../gr-user-header/gr-user-header.js';
-import '../../../styles/shared-styles.js';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
-import {PolymerElement} from '@polymer/polymer/polymer-element.js';
-import {htmlTemplate} from './gr-change-list-view_html.js';
-import {page} from '../../../utils/page-wrapper-utils.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-
-const LookupQueryPatterns = {
-  CHANGE_ID: /^\s*i?[0-9a-f]{7,40}\s*$/i,
-  CHANGE_NUM: /^\s*[1-9][0-9]*\s*$/g,
-  COMMIT: /[0-9a-f]{40}/,
-};
-
-const USER_QUERY_PATTERN = /^owner:\s?("[^"]+"|[^ ]+)$/;
-
-const REPO_QUERY_PATTERN =
-    /^project:\s?("[^"]+"|[^ ]+)(\sstatus\s?:(open|"open"))?$/;
-
-const LIMIT_OPERATOR_PATTERN = /\blimit:(\d+)/i;
-
-/**
- * @extends PolymerElement
- */
-class GrChangeListView extends GestureEventListeners(
-    LegacyElementMixin(
-        PolymerElement)) {
-  static get template() { return htmlTemplate; }
-
-  static get is() { return 'gr-change-list-view'; }
-  /**
-   * Fired when the title of the page should change.
-   *
-   * @event title-change
-   */
-
-  static get properties() {
-    return {
-    /**
-     * URL params passed from the router.
-     */
-      params: {
-        type: Object,
-        observer: '_paramsChanged',
-      },
-
-      /**
-       * True when user is logged in.
-       */
-      _loggedIn: {
-        type: Boolean,
-        computed: '_computeLoggedIn(account)',
-      },
-
-      account: {
-        type: Object,
-        value: null,
-      },
-
-      /**
-       * State persisted across restamps of the element.
-       *
-       * Need sub-property declaration since it is used in template before
-       * assignment.
-       *
-       * @type {{ selectedChangeIndex: (number|undefined) }}
-       *
-       */
-      viewState: {
-        type: Object,
-        notify: true,
-        value() { return {}; },
-      },
-
-      preferences: Object,
-
-      _changesPerPage: Number,
-
-      /**
-       * Currently active query.
-       */
-      _query: {
-        type: String,
-        value: '',
-      },
-
-      /**
-       * Offset of currently visible query results.
-       */
-      _offset: Number,
-
-      /**
-       * Change objects loaded from the server.
-       */
-      _changes: {
-        type: Array,
-        observer: '_changesChanged',
-      },
-
-      /**
-       * For showing a "loading..." string during ajax requests.
-       */
-      _loading: {
-        type: Boolean,
-        value: true,
-      },
-
-      /** @type {?string} */
-      _userId: {
-        type: String,
-        value: null,
-      },
-
-      /** @type {?string} */
-      _repo: {
-        type: String,
-        value: null,
-      },
-    };
-  }
-
-  /** @override */
-  created() {
-    super.created();
-    this.addEventListener('next-page',
-        () => this._handleNextPage());
-    this.addEventListener('previous-page',
-        () => this._handlePreviousPage());
-  }
-
-  /** @override */
-  attached() {
-    super.attached();
-    this._loadPreferences();
-  }
-
-  _paramsChanged(value) {
-    if (value.view !== GerritNav.View.SEARCH) { return; }
-
-    this._loading = true;
-    this._query = value.query;
-    this._offset = value.offset || 0;
-    if (this.viewState.query != this._query ||
-        this.viewState.offset != this._offset) {
-      this.set('viewState.selectedChangeIndex', 0);
-      this.set('viewState.query', this._query);
-      this.set('viewState.offset', this._offset);
-    }
-
-    // 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._getPreferences()
-        .then(prefs => {
-          this._changesPerPage = prefs.changes_per_page;
-          return this._getChanges();
-        })
-        .then(changes => {
-          changes = changes || [];
-          if (this._query && changes.length === 1) {
-            for (const query in LookupQueryPatterns) {
-              if (LookupQueryPatterns.hasOwnProperty(query) &&
-              this._query.match(LookupQueryPatterns[query])) {
-                // "Back"/"Forward" buttons work correctly only with
-                // opt_redirect options
-                GerritNav.navigateToChange(changes[0], null, null, null, true);
-                return;
-              }
-            }
-          }
-          this._changes = changes;
-          this._loading = false;
-        });
-  }
-
-  _loadPreferences() {
-    return this.$.restAPI.getLoggedIn().then(loggedIn => {
-      if (loggedIn) {
-        this._getPreferences().then(preferences => {
-          this.preferences = preferences;
-        });
-      } else {
-        this.preferences = {};
-      }
-    });
-  }
-
-  _getChanges() {
-    return this.$.restAPI.getChanges(this._changesPerPage, this._query,
-        this._offset);
-  }
-
-  _getPreferences() {
-    return this.$.restAPI.getPreferences();
-  }
-
-  _limitFor(query, defaultLimit) {
-    const match = query.match(LIMIT_OPERATOR_PATTERN);
-    if (!match) {
-      return defaultLimit;
-    }
-    return parseInt(match[1], 10);
-  }
-
-  _computeNavLink(query, offset, direction, changesPerPage) {
-    // Offset could be a string when passed from the router.
-    offset = +(offset || 0);
-    const limit = this._limitFor(query, changesPerPage);
-    const newOffset = Math.max(0, offset + (limit * direction));
-    return GerritNav.getUrlForSearchQuery(query, newOffset);
-  }
-
-  _computePrevArrowClass(offset) {
-    return offset === 0 ? 'hide' : '';
-  }
-
-  _computeNextArrowClass(changes) {
-    const more = changes.length && changes[changes.length - 1]._more_changes;
-    return more ? '' : 'hide';
-  }
-
-  _computeNavClass(loading) {
-    return loading || !this._changes || !this._changes.length ? 'hide' : '';
-  }
-
-  _handleNextPage() {
-    if (this.$.nextArrow.hidden) { return; }
-    page.show(this._computeNavLink(
-        this._query, this._offset, 1, this._changesPerPage));
-  }
-
-  _handlePreviousPage() {
-    if (this.$.prevArrow.hidden) { return; }
-    page.show(this._computeNavLink(
-        this._query, this._offset, -1, this._changesPerPage));
-  }
-
-  _changesChanged(changes) {
-    this._userId = null;
-    this._repo = null;
-    if (!changes || !changes.length) {
-      return;
-    }
-    if (USER_QUERY_PATTERN.test(this._query)) {
-      const owner = changes[0].owner;
-      const userId = owner._account_id ? owner._account_id : owner.email;
-      if (userId) {
-        this._userId = userId;
-        return;
-      }
-    }
-    if (REPO_QUERY_PATTERN.test(this._query)) {
-      this._repo = changes[0].project;
-    }
-  }
-
-  _computeHeaderClass(id) {
-    return id ? '' : 'hide';
-  }
-
-  _computePage(offset, changesPerPage) {
-    return offset / changesPerPage + 1;
-  }
-
-  _computeLoggedIn(account) {
-    return !!(account && Object.keys(account).length > 0);
-  }
-
-  _handleToggleStar(e) {
-    this.$.restAPI.saveChangeStarred(e.detail.change._number,
-        e.detail.starred);
-  }
-
-  _handleToggleReviewed(e) {
-    this.$.restAPI.saveChangeReviewed(e.detail.change._number,
-        e.detail.reviewed);
-  }
-}
-
-customElements.define(GrChangeListView.is, GrChangeListView);
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
new file mode 100644
index 0000000..63bbaee
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.ts
@@ -0,0 +1,322 @@
+/**
+ * @license
+ * Copyright (C) 2015 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 '../../shared/gr-icons/gr-icons';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../gr-change-list/gr-change-list';
+import '../gr-repo-header/gr-repo-header';
+import '../gr-user-header/gr-user-header';
+import '../../../styles/shared-styles';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-change-list-view_html';
+import {page} from '../../../utils/page-wrapper-utils';
+import {GerritNav, GerritView} from '../../core/gr-navigation/gr-navigation';
+import {customElement, property} from '@polymer/decorators';
+import {AppElementParams} from '../../gr-app-types';
+import {
+  AccountDetailInfo,
+  AccountId,
+  ChangeId,
+  ChangeInfo,
+  EmailAddress,
+  PatchRange,
+  PreferencesInput,
+} from '../../../types/common';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {ChangeListToggleReviewedDetail} from '../gr-change-list-item/gr-change-list-item';
+import {ChangeStarToggleStarDetail} from '../../shared/gr-change-star/gr-change-star';
+import {hasOwnProperty} from '../../../utils/common-util';
+import {DiffViewMode} from '../../../constants/constants';
+
+const LookupQueryPatterns = {
+  CHANGE_ID: /^\s*i?[0-9a-f]{7,40}\s*$/i,
+  CHANGE_NUM: /^\s*[1-9][0-9]*\s*$/g,
+  COMMIT: /[0-9a-f]{40}/,
+};
+
+const USER_QUERY_PATTERN = /^owner:\s?("[^"]+"|[^ ]+)$/;
+
+const REPO_QUERY_PATTERN = /^project:\s?("[^"]+"|[^ ]+)(\sstatus\s?:(open|"open"))?$/;
+
+const LIMIT_OPERATOR_PATTERN = /\blimit:(\d+)/i;
+
+export interface ChangeListViewState {
+  changeNum?: ChangeId;
+  patchRange?: PatchRange;
+  selectedFileIndex?: number;
+  showReplyDialog?: boolean;
+  showDownloadDialog?: boolean;
+  diffMode?: DiffViewMode;
+  numFilesShown?: number;
+  scrollTop?: number;
+  query?: string;
+  offset?: number;
+}
+
+export interface GrChangeListView {
+  $: {
+    restAPI: RestApiService & Element;
+    prevArrow: HTMLAnchorElement;
+    nextArrow: HTMLAnchorElement;
+  };
+}
+
+@customElement('gr-change-list-view')
+export class GrChangeListView extends GestureEventListeners(
+  LegacyElementMixin(PolymerElement)
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  /**
+   * Fired when the title of the page should change.
+   *
+   * @event title-change
+   */
+
+  @property({type: Object, observer: '_paramsChanged'})
+  params?: AppElementParams;
+
+  @property({type: Boolean, computed: '_computeLoggedIn(account)'})
+  _loggedIn?: boolean;
+
+  @property({type: Object})
+  account: AccountDetailInfo | null = null;
+
+  @property({type: Object, notify: true})
+  viewState: ChangeListViewState = {};
+
+  @property({type: Object})
+  preferences?: PreferencesInput;
+
+  @property({type: Number})
+  _changesPerPage?: number;
+
+  @property({type: String})
+  _query = '';
+
+  @property({type: Number})
+  _offset?: number;
+
+  @property({type: Array, observer: '_changesChanged'})
+  _changes?: ChangeInfo[];
+
+  @property({type: Boolean})
+  _loading = true;
+
+  @property({type: String})
+  _userId: AccountId | EmailAddress | null = null;
+
+  @property({type: String})
+  _repo: string | null = null;
+
+  /** @override */
+  created() {
+    super.created();
+    this.addEventListener('next-page', () => this._handleNextPage());
+    this.addEventListener('previous-page', () => this._handlePreviousPage());
+  }
+
+  /** @override */
+  attached() {
+    super.attached();
+    this._loadPreferences();
+  }
+
+  _paramsChanged(value: AppElementParams) {
+    if (value.view !== GerritView.SEARCH) return;
+
+    this._loading = true;
+    this._query = value.query;
+    const offset = Number(value.offset);
+    this._offset = isNaN(offset) ? 0 : offset;
+    if (
+      this.viewState.query !== this._query ||
+      this.viewState.offset !== this._offset
+    ) {
+      this.set('viewState.selectedChangeIndex', 0);
+      this.set('viewState.query', this._query);
+      this.set('viewState.offset', this._offset);
+    }
+
+    // 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.$.restAPI
+      .getPreferences()
+      .then(prefs => {
+        if (!prefs) {
+          throw new Error('getPreferences returned undefined');
+        }
+        this._changesPerPage = prefs.changes_per_page;
+        return this._getChanges();
+      })
+      .then(changes => {
+        changes = changes || [];
+        if (this._query && changes.length === 1) {
+          let query: keyof typeof LookupQueryPatterns;
+          for (query in LookupQueryPatterns) {
+            if (
+              hasOwnProperty(LookupQueryPatterns, query) &&
+              this._query.match(LookupQueryPatterns[query])
+            ) {
+              // "Back"/"Forward" buttons work correctly only with
+              // opt_redirect options
+              GerritNav.navigateToChange(
+                changes[0],
+                undefined,
+                undefined,
+                undefined,
+                true
+              );
+              return;
+            }
+          }
+        }
+        this._changes = changes;
+        this._loading = false;
+      });
+  }
+
+  _loadPreferences() {
+    return this.$.restAPI.getLoggedIn().then(loggedIn => {
+      if (loggedIn) {
+        this.$.restAPI.getPreferences().then(preferences => {
+          this.preferences = preferences;
+        });
+      } else {
+        this.preferences = {};
+      }
+    });
+  }
+
+  _getChanges() {
+    return this.$.restAPI.getChanges(
+      this._changesPerPage,
+      this._query,
+      this._offset
+    );
+  }
+
+  _limitFor(query: string, defaultLimit: number) {
+    const match = query.match(LIMIT_OPERATOR_PATTERN);
+    if (!match) {
+      return defaultLimit;
+    }
+    return parseInt(match[1], 10);
+  }
+
+  _computeNavLink(
+    query: string,
+    offset: number | undefined,
+    direction: number,
+    changesPerPage: number
+  ) {
+    offset = offset ?? 0;
+    const limit = this._limitFor(query, changesPerPage);
+    const newOffset = Math.max(0, offset + limit * direction);
+    return GerritNav.getUrlForSearchQuery(query, newOffset);
+  }
+
+  _computePrevArrowClass(offset?: number) {
+    return offset === 0 ? 'hide' : '';
+  }
+
+  _computeNextArrowClass(changes?: ChangeInfo[]) {
+    const more = changes?.length && changes[changes.length - 1]._more_changes;
+    return more ? '' : 'hide';
+  }
+
+  _computeNavClass(loading?: boolean) {
+    return loading || !this._changes || !this._changes.length ? 'hide' : '';
+  }
+
+  _handleNextPage() {
+    if (this.$.nextArrow.hidden || !this._changesPerPage) return;
+    page.show(
+      this._computeNavLink(this._query, this._offset, 1, this._changesPerPage)
+    );
+  }
+
+  _handlePreviousPage() {
+    if (this.$.prevArrow.hidden || !this._changesPerPage) return;
+    page.show(
+      this._computeNavLink(this._query, this._offset, -1, this._changesPerPage)
+    );
+  }
+
+  _changesChanged(changes?: ChangeInfo[]) {
+    this._userId = null;
+    this._repo = null;
+    if (!changes || !changes.length) {
+      return;
+    }
+    if (USER_QUERY_PATTERN.test(this._query)) {
+      const owner = changes[0].owner;
+      const userId = owner._account_id ? owner._account_id : owner.email;
+      if (userId) {
+        this._userId = userId;
+        return;
+      }
+    }
+    if (REPO_QUERY_PATTERN.test(this._query)) {
+      this._repo = changes[0].project;
+    }
+  }
+
+  _computeHeaderClass(id?: string) {
+    return id ? '' : 'hide';
+  }
+
+  _computePage(offset?: number, changesPerPage?: number) {
+    if (offset === undefined || changesPerPage === undefined) return;
+    return offset / changesPerPage + 1;
+  }
+
+  _computeLoggedIn(account?: AccountDetailInfo) {
+    return !!(account && Object.keys(account).length > 0);
+  }
+
+  _handleToggleStar(e: CustomEvent<ChangeStarToggleStarDetail>) {
+    this.$.restAPI.saveChangeStarred(e.detail.change._number, e.detail.starred);
+  }
+
+  _handleToggleReviewed(e: CustomEvent<ChangeListToggleReviewedDetail>) {
+    this.$.restAPI.saveChangeReviewed(
+      e.detail.change._number,
+      e.detail.reviewed
+    );
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-change-list-view': GrChangeListView;
+  }
+}
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_test.js b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_test.js
index 4c52ce9..af3acd8 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_test.js
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_test.js
@@ -109,6 +109,7 @@
 
   test('_handleNextPage', () => {
     const showStub = sinon.stub(page, 'show');
+    element._changesPerPage = 10;
     element.$.nextArrow.hidden = true;
     element._handleNextPage();
     assert.isFalse(showStub.called);
@@ -119,6 +120,7 @@
 
   test('_handlePreviousPage', () => {
     const showStub = sinon.stub(page, 'show');
+    element._changesPerPage = 10;
     element.$.prevArrow.hidden = true;
     element._handlePreviousPage();
     assert.isFalse(showStub.called);
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.ts b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.ts
index a05ccf4..f486435 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.ts
@@ -37,10 +37,12 @@
 import {
   GerritNav,
   DashboardSection,
+  YOUR_TURN,
+  CLOSED,
 } from '../../core/gr-navigation/gr-navigation';
 import {getPluginEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints';
 import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
-import {changeIsOpen} from '../../../utils/change-util';
+import {changeIsOpen, isOwner} from '../../../utils/change-util';
 import {customElement, property, observe} from '@polymer/decorators';
 import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
 import {GrCursorManager} from '../../shared/gr-cursor-manager/gr-cursor-manager';
@@ -50,6 +52,10 @@
   ServerInfo,
   PreferencesInput,
 } from '../../../types/common';
+import {
+  hasAttention,
+  isAttentionSetEnabled,
+} from '../../../utils/attention-set-util';
 
 const NUMBER_FIXED_COLUMNS = 3;
 const CLOSED_STATUS = ['MERGED', 'ABANDONED'];
@@ -57,6 +63,8 @@
 const MAX_SHORTCUT_CHARS = 5;
 
 export interface ChangeListSection {
+  name?: string;
+  query?: string;
   results: ChangeInfo[];
 }
 export interface GrChangeList {
@@ -229,9 +237,35 @@
     }
   }
 
-  _computeColspan(changeTableColumns: string[], labelNames: string[]) {
-    if (!changeTableColumns || !labelNames) return;
-    return changeTableColumns.length + labelNames.length + NUMBER_FIXED_COLUMNS;
+  /**
+   * This methods allows us to customize the columns per section.
+   *
+   * @param visibleColumns are the columns according to configs and user prefs
+   */
+  _computeColumns(
+    section?: ChangeListSection,
+    visibleColumns?: string[]
+  ): string[] {
+    if (!section || !visibleColumns) return [];
+    const cols = [...visibleColumns];
+    const updatedIndex = cols.indexOf('Updated');
+    if (section.name === YOUR_TURN.name && updatedIndex !== -1) {
+      cols[updatedIndex] = 'Since';
+    }
+    if (section.name === CLOSED.name && updatedIndex !== -1) {
+      cols[updatedIndex] = 'Submitted';
+    }
+    return cols;
+  }
+
+  _computeColspan(
+    section?: ChangeListSection,
+    visibleColumns?: string[],
+    labelNames?: string[]
+  ) {
+    const cols = this._computeColumns(section, visibleColumns);
+    if (!cols || !labelNames) return 1;
+    return cols.length + labelNames.length + NUMBER_FIXED_COLUMNS;
   }
 
   _computeLabelNames(sections: ChangeListSection[]) {
@@ -327,10 +361,8 @@
     showReviewedState: boolean,
     config?: ServerInfo
   ) {
-    const isAttentionSetEnabled =
-      !!config && !!config.change && config.change.enable_attention_set;
     return (
-      !isAttentionSetEnabled &&
+      !isAttentionSetEnabled(config) &&
       showReviewedState &&
       !change.reviewed &&
       !change.work_in_progress &&
@@ -339,17 +371,19 @@
     );
   }
 
-  _computeItemHighlight(account?: AccountInfo, change?: ChangeInfo) {
-    // Do not show the assignee highlight if the change is not open.
-    if (
-      !change ||
-      !change.assignee ||
-      !account ||
-      CLOSED_STATUS.indexOf(change.status) !== -1
-    ) {
-      return false;
-    }
-    return account._account_id === change.assignee._account_id;
+  _computeItemHighlight(
+    account?: AccountInfo,
+    change?: ChangeInfo,
+    config?: ServerInfo,
+    sectionName?: string
+  ) {
+    if (!change || !account) return false;
+    if (CLOSED_STATUS.indexOf(change.status) !== -1) return false;
+    return isAttentionSetEnabled(config)
+      ? hasAttention(config, account, change) &&
+          !isOwner(change, account) &&
+          sectionName === YOUR_TURN.name
+      : account._account_id === change.assignee?._account_id;
   }
 
   _nextChange(e: CustomKeyboardEvent) {
@@ -492,7 +526,7 @@
 
   _getSpecialEmptySlot(section: DashboardSection) {
     if (section.isOutgoing) return 'empty-outgoing';
-    if (section.name === 'Your Turn') return 'empty-your-turn';
+    if (section.name === YOUR_TURN.name) return 'empty-your-turn';
     return '';
   }
 
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_html.ts b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_html.ts
index f223fbf..6a66472 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_html.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_html.ts
@@ -61,7 +61,7 @@
             ></td>
             <td
               class="cell"
-              colspan$="[[_computeColspan(changeTableColumns, labelNames)]]"
+              colspan$="[[_computeColspan(changeSection, visibleChangeTableColumns, labelNames)]]"
             >
               <a
                 href$="[[_sectionHref(changeSection.query)]]"
@@ -83,7 +83,7 @@
             <td aria-hidden="true" class="star" hidden></td>
             <td
               class="cell"
-              colspan$="[[_computeColspan(changeTableColumns, labelNames)]]"
+              colspan$="[[_computeColspan(changeSection, visibleChangeTableColumns, labelNames)]]"
             >
               <template
                 is="dom-if"
@@ -110,11 +110,12 @@
               hidden=""
             ></td>
             <td class="number" hidden$="[[!showNumber]]" hidden="">#</td>
-            <template is="dom-repeat" items="[[changeTableColumns]]" as="item">
-              <td
-                class$="[[_lowerCase(item)]]"
-                hidden$="[[isColumnHidden(item, visibleChangeTableColumns)]]"
-              >
+            <template
+              is="dom-repeat"
+              items="[[_computeColumns(changeSection, visibleChangeTableColumns)]]"
+              as="item"
+            >
+              <td class$="[[_lowerCase(item)]]">
                 [[item]]
               </td>
             </template>
@@ -139,12 +140,12 @@
           <gr-change-list-item
             account="[[account]]"
             selected$="[[_computeItemSelected(sectionIndex, index, selectedIndex)]]"
-            highlight$="[[_computeItemHighlight(account, change)]]"
+            highlight$="[[_computeItemHighlight(account, change, _config, changeSection.name)]]"
             needs-review$="[[_computeItemNeedsReview(account, change, showReviewedState, _config)]]"
             change="[[change]]"
             config="[[_config]]"
             section-name="[[changeSection.name]]"
-            visible-change-table-columns="[[visibleChangeTableColumns]]"
+            visible-change-table-columns="[[_computeColumns(changeSection, visibleChangeTableColumns)]]"
             show-number="[[showNumber]]"
             show-star="[[showStar]]"
             tabindex$="[[_computeTabIndex(sectionIndex, index, selectedIndex)]]"
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.js b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.js
index 68ab5e5..494d05a 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.js
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.js
@@ -21,6 +21,7 @@
 import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
 import {TestKeyboardShortcutBinder} from '../../../test/test-utils.js';
 import {Shortcut} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.js';
+import {YOUR_TURN} from '../../core/gr-navigation/gr-navigation.js';
 
 const basicFixture = fixtureFromElement('gr-change-list');
 
@@ -145,7 +146,7 @@
     const changeTableColumns = [];
     const labelNames = [];
     assert.equal(tdItemCount, element._computeColspan(
-        changeTableColumns, labelNames));
+        {}, changeTableColumns, labelNames));
   });
 
   test('keyboard shortcuts', done => {
@@ -303,7 +304,7 @@
     });
 
     test('shown on empty outgoing sections', () => {
-      const section = {results: [], name: 'Your Turn'};
+      const section = {results: [], name: YOUR_TURN.name};
       assert.isTrue(element._isEmpty(section));
       assert.equal(element._getSpecialEmptySlot(section), 'empty-your-turn');
     });
@@ -416,11 +417,9 @@
       for (const column of element.changeTableColumns) {
         const elementClass = '.' + column.toLowerCase();
         if (column === 'Repo') {
-          assert.isTrue(element.shadowRoot
-              .querySelector(elementClass).hidden);
+          assert.isNotOk(element.shadowRoot.querySelector(elementClass));
         } else {
-          assert.isFalse(element.shadowRoot
-              .querySelector(elementClass).hidden);
+          assert.isOk(element.shadowRoot.querySelector(elementClass));
         }
       }
     });
diff --git a/polygerrit-ui/app/elements/change-list/gr-create-destination-dialog/gr-create-destination-dialog.ts b/polygerrit-ui/app/elements/change-list/gr-create-destination-dialog/gr-create-destination-dialog.ts
index 27866ee..e53f68b 100644
--- a/polygerrit-ui/app/elements/change-list/gr-create-destination-dialog/gr-create-destination-dialog.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-create-destination-dialog/gr-create-destination-dialog.ts
@@ -26,6 +26,11 @@
 import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
 import {RepoName, BranchName} from '../../../types/common';
 
+export interface CreateDestinationConfirmDetail {
+  repo?: RepoName;
+  branch?: BranchName;
+}
+
 /**
  * Fired when a destination has been picked. Event details contain the repo
  * name and the branch name.
@@ -70,7 +75,10 @@
 
   _pickerConfirm(e: Event) {
     this.$.createOverlay.close();
-    const detail = {repo: this._repo, branch: this._branch};
+    const detail: CreateDestinationConfirmDetail = {
+      repo: this._repo,
+      branch: this._branch,
+    };
     // e is a 'confirm' event from gr-dialog. We want to fire a more detailed
     // 'confirm' event here, so let's stop propagation of the bare event.
     e.preventDefault();
diff --git a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.js b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.js
deleted file mode 100644
index 0658e16..0000000
--- a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.js
+++ /dev/null
@@ -1,352 +0,0 @@
-/**
- * @license
- * Copyright (C) 2015 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 '../../../styles/shared-styles.js';
-import '../gr-change-list/gr-change-list.js';
-import '../../shared/gr-button/gr-button.js';
-import '../../shared/gr-dialog/gr-dialog.js';
-import '../../shared/gr-overlay/gr-overlay.js';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
-import '../gr-create-commands-dialog/gr-create-commands-dialog.js';
-import '../gr-create-change-help/gr-create-change-help.js';
-import '../gr-create-destination-dialog/gr-create-destination-dialog.js';
-import '../gr-user-header/gr-user-header.js';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
-import {PolymerElement} from '@polymer/polymer/polymer-element.js';
-import {htmlTemplate} from './gr-dashboard-view_html.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-import {appContext} from '../../../services/app-context.js';
-import {changeIsOpen} from '../../../utils/change-util.js';
-
-const PROJECT_PLACEHOLDER_PATTERN = /\$\{project\}/g;
-
-/**
- * @extends PolymerElement
- */
-class GrDashboardView extends GestureEventListeners(
-    LegacyElementMixin(PolymerElement)) {
-  static get template() { return htmlTemplate; }
-
-  static get is() { return 'gr-dashboard-view'; }
-  /**
-   * Fired when the title of the page should change.
-   *
-   * @event title-change
-   */
-
-  static get properties() {
-    return {
-      account: {
-        type: Object,
-        value: null,
-      },
-      preferences: Object,
-      /** @type {{ selectedChangeIndex: number }} */
-      viewState: Object,
-
-      /** @type {{ project: string, user: string }} */
-      params: {
-        type: Object,
-      },
-
-      createChangeTap: {
-        type: Function,
-        value() {
-          return e => this._createChangeTap(e);
-        },
-      },
-
-      _results: Array,
-
-      /**
-       * For showing a "loading..." string during ajax requests.
-       */
-      _loading: {
-        type: Boolean,
-        value: true,
-      },
-
-      _showDraftsBanner: {
-        type: Boolean,
-        value: false,
-      },
-
-      _showNewUserHelp: {
-        type: Boolean,
-        value: false,
-      },
-    };
-  }
-
-  constructor() {
-    super();
-    this.reporting = appContext.reportingService;
-  }
-
-  static get observers() {
-    return [
-      '_paramsChanged(params.*)',
-    ];
-  }
-
-  /** @override */
-  attached() {
-    super.attached();
-    this._loadPreferences();
-    this.addEventListener('reload', e => {
-      e.stopPropagation();
-      this._reload();
-    });
-  }
-
-  _loadPreferences() {
-    return this.$.restAPI.getLoggedIn().then(loggedIn => {
-      if (loggedIn) {
-        this.$.restAPI.getPreferences().then(preferences => {
-          this.preferences = preferences;
-        });
-      } else {
-        this.preferences = {};
-      }
-    });
-  }
-
-  _getProjectDashboard(project, dashboard) {
-    const errFn = response => {
-      this.dispatchEvent(new CustomEvent('page-error', {
-        detail: {response},
-        composed: true, bubbles: true,
-      }));
-    };
-    return this.$.restAPI.getDashboard(
-        project, dashboard, errFn).then(response => {
-      if (!response) {
-        return;
-      }
-      return {
-        title: response.title,
-        sections: response.sections.map(section => {
-          const suffix = response.foreach ? ' ' + response.foreach : '';
-          return {
-            name: section.name,
-            query: (section.query + suffix).replace(
-                PROJECT_PLACEHOLDER_PATTERN, project),
-          };
-        }),
-      };
-    });
-  }
-
-  _computeTitle(user) {
-    if (!user || user === 'self') {
-      return 'My Reviews';
-    }
-    return 'Dashboard for ' + user;
-  }
-
-  _isViewActive(params) {
-    return params.view === GerritNav.View.DASHBOARD;
-  }
-
-  _paramsChanged(paramsChangeRecord) {
-    const params = paramsChangeRecord.base;
-
-    if (!this._isViewActive(params)) {
-      return Promise.resolve();
-    }
-
-    return this._reload();
-  }
-
-  /**
-   * Reloads the element.
-   *
-   * @return {Promise<!Object>}
-   */
-  _reload() {
-    this._loading = true;
-    const {project, dashboard, title, user, sections} = this.params;
-    const dashboardPromise = project ?
-      this._getProjectDashboard(project, dashboard) :
-      this.$.restAPI.getConfig().then(
-          config => Promise.resolve(GerritNav.getUserDashboard(
-              user,
-              sections,
-              title || this._computeTitle(user),
-              config
-          ))
-      );
-
-    const checkForNewUser = !project && user === 'self';
-    return dashboardPromise
-        .then(res => {
-          if (res && res.title) {
-            this.dispatchEvent(new CustomEvent('title-change', {
-              detail: {title: res.title},
-              composed: true, bubbles: true,
-            }));
-          }
-          return this._fetchDashboardChanges(res, checkForNewUser);
-        })
-        .then(() => {
-          this._maybeShowDraftsBanner();
-          this.reporting.dashboardDisplayed();
-        })
-        .catch(err => {
-          this.dispatchEvent(new CustomEvent('title-change', {
-            detail: {
-              title: title || this._computeTitle(user),
-            },
-            composed: true, bubbles: true,
-          }));
-          console.warn(err);
-        })
-        .then(() => { this._loading = false; });
-  }
-
-  /**
-   * Fetches the changes for each dashboard section and sets this._results
-   * with the response.
-   *
-   * @param {!Object} res
-   * @param {boolean} checkForNewUser
-   * @return {Promise}
-   */
-  _fetchDashboardChanges(res, checkForNewUser) {
-    if (!res) { return Promise.resolve(); }
-
-    let queries;
-
-    if (window.PRELOADED_QUERIES
-      && window.PRELOADED_QUERIES.dashboardQuery) {
-      queries = window.PRELOADED_QUERIES.dashboardQuery;
-      // we use preloaded query from index only on first page load
-      window.PRELOADED_QUERIES.dashboardQuery = undefined;
-    } else {
-      queries = res.sections
-          .map(section => (section.suffixForDashboard ?
-            section.query + ' ' + section.suffixForDashboard :
-            section.query));
-
-      if (checkForNewUser) {
-        queries.push('owner:self limit:1');
-      }
-    }
-
-    return this.$.restAPI.getChanges(null, queries)
-        .then(changes => {
-          if (checkForNewUser) {
-            // Last set of results is not meant for dashboard display.
-            const lastResultSet = changes.pop();
-            this._showNewUserHelp = lastResultSet.length == 0;
-          }
-          this._results = changes.map((results, i) => {
-            return {
-              name: res.sections[i].name,
-              countLabel: this._computeSectionCountLabel(results),
-              query: res.sections[i].query,
-              results,
-              isOutgoing: res.sections[i].isOutgoing,
-            };
-          }).filter((section, i) => i < res.sections.length && (
-            !res.sections[i].hideIfEmpty ||
-              section.results.length));
-        });
-  }
-
-  _computeSectionCountLabel(changes) {
-    if (!changes || !changes.length || changes.length == 0) {
-      return '';
-    }
-    const more = changes[changes.length - 1]._more_changes;
-    const numChanges = changes.length;
-    const andMore = more ? ' and more' : '';
-    return `(${numChanges}${andMore})`;
-  }
-
-  _computeUserHeaderClass(params) {
-    if (!params || !!params.project || !params.user ||
-        params.user === 'self') {
-      return 'hide';
-    }
-    return '';
-  }
-
-  _handleToggleStar(e) {
-    this.$.restAPI.saveChangeStarred(e.detail.change._number,
-        e.detail.starred);
-  }
-
-  _handleToggleReviewed(e) {
-    this.$.restAPI.saveChangeReviewed(e.detail.change._number,
-        e.detail.reviewed);
-  }
-
-  /**
-   * Banner is shown if a user is on their own dashboard and they have draft
-   * comments on closed changes.
-   */
-  _maybeShowDraftsBanner() {
-    this._showDraftsBanner = false;
-    if (!(this.params.user === 'self')) { return; }
-
-    const draftSection = this._results
-        .find(section => section.query === 'has:draft');
-    if (!draftSection || !draftSection.results.length) { return; }
-
-    const closedChanges = draftSection.results
-        .filter(change => !changeIsOpen(change));
-    if (!closedChanges.length) { return; }
-
-    this._showDraftsBanner = true;
-  }
-
-  _computeBannerClass(show) {
-    return show ? '' : 'hide';
-  }
-
-  _handleOpenDeleteDialog() {
-    this.$.confirmDeleteOverlay.open();
-  }
-
-  _handleConfirmDelete() {
-    this.$.confirmDeleteDialog.disabled = true;
-    return this.$.restAPI.deleteDraftComments('-is:open').then(() => {
-      this._closeConfirmDeleteOverlay();
-      this._reload();
-    });
-  }
-
-  _closeConfirmDeleteOverlay() {
-    this.$.confirmDeleteOverlay.close();
-  }
-
-  _computeDraftsLink() {
-    return GerritNav.getUrlForSearchQuery('has:draft -is:open');
-  }
-
-  _createChangeTap(e) {
-    this.$.destinationDialog.open();
-  }
-
-  _handleDestinationConfirm(e) {
-    this.$.commandsDialog.branch = e.detail.branch;
-    this.$.commandsDialog.open();
-  }
-}
-
-customElements.define(GrDashboardView.is, GrDashboardView);
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
new file mode 100644
index 0000000..7ca62d9
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.ts
@@ -0,0 +1,453 @@
+/**
+ * @license
+ * Copyright (C) 2015 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 '../../../styles/shared-styles';
+import '../gr-change-list/gr-change-list';
+import '../../shared/gr-button/gr-button';
+import '../../shared/gr-dialog/gr-dialog';
+import '../../shared/gr-overlay/gr-overlay';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../gr-create-commands-dialog/gr-create-commands-dialog';
+import '../gr-create-change-help/gr-create-change-help';
+import '../gr-create-destination-dialog/gr-create-destination-dialog';
+import '../gr-user-header/gr-user-header';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-dashboard-view_html';
+import {
+  GerritNav,
+  GerritView,
+  UserDashboard,
+  YOUR_TURN,
+} from '../../core/gr-navigation/gr-navigation';
+import {appContext} from '../../../services/app-context';
+import {changeIsOpen} from '../../../utils/change-util';
+import {parseDate} from '../../../utils/date-util';
+import {customElement, observe, property} from '@polymer/decorators';
+import {
+  AccountDetailInfo,
+  ChangeInfo,
+  DashboardId,
+  ElementPropertyDeepChange,
+  PreferencesInput,
+  RepoName,
+} from '../../../types/common';
+import {AppElementDashboardParams, AppElementParams} from '../../gr-app-types';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
+import {GrCreateCommandsDialog} from '../gr-create-commands-dialog/gr-create-commands-dialog';
+import {
+  CreateDestinationConfirmDetail,
+  GrCreateDestinationDialog,
+} from '../gr-create-destination-dialog/gr-create-destination-dialog';
+import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
+import {ChangeListToggleReviewedDetail} from '../gr-change-list-item/gr-change-list-item';
+import {ChangeStarToggleStarDetail} from '../../shared/gr-change-star/gr-change-star';
+
+const PROJECT_PLACEHOLDER_PATTERN = /\$\{project\}/g;
+
+export interface DashboardViewState {
+  selectedChangeIndex: number;
+}
+
+export interface GrDashboardView {
+  $: {
+    restAPI: RestApiService & Element;
+    confirmDeleteDialog: GrDialog;
+    commandsDialog: GrCreateCommandsDialog;
+    destinationDialog: GrCreateDestinationDialog;
+    confirmDeleteOverlay: GrOverlay;
+  };
+}
+
+interface DashboardChange {
+  name: string;
+  countLabel: string;
+  query: string;
+  results: ChangeInfo[];
+  isOutgoing?: boolean;
+}
+
+@customElement('gr-dashboard-view')
+export class GrDashboardView extends GestureEventListeners(
+  LegacyElementMixin(PolymerElement)
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  /**
+   * Fired when the title of the page should change.
+   *
+   * @event title-change
+   */
+
+  @property({type: Object})
+  account: AccountDetailInfo | null = null;
+
+  @property({type: Object})
+  preferences?: PreferencesInput;
+
+  @property({type: Object})
+  viewState?: DashboardViewState;
+
+  @property({type: Object})
+  params?: AppElementParams;
+
+  @property({type: Array})
+  _results?: DashboardChange[];
+
+  @property({type: Boolean})
+  _loading = true;
+
+  @property({type: Boolean})
+  _showDraftsBanner = false;
+
+  @property({type: Boolean})
+  _showNewUserHelp = false;
+
+  private reporting = appContext.reportingService;
+
+  constructor() {
+    super();
+  }
+
+  /** @override */
+  attached() {
+    super.attached();
+    this._loadPreferences();
+    this.addEventListener('reload', e => {
+      e.stopPropagation();
+      this._reload();
+    });
+  }
+
+  _loadPreferences() {
+    return this.$.restAPI.getLoggedIn().then(loggedIn => {
+      if (loggedIn) {
+        this.$.restAPI.getPreferences().then(preferences => {
+          this.preferences = preferences;
+        });
+      } else {
+        this.preferences = {};
+      }
+    });
+  }
+
+  _getProjectDashboard(
+    project: RepoName,
+    dashboard: DashboardId
+  ): Promise<UserDashboard | undefined> {
+    const errFn = (response?: Response | null) => {
+      this.dispatchEvent(
+        new CustomEvent('page-error', {
+          detail: {response},
+          composed: true,
+          bubbles: true,
+        })
+      );
+    };
+    return this.$.restAPI
+      .getDashboard(project, dashboard, errFn)
+      .then(response => {
+        if (!response) {
+          return;
+        }
+        return {
+          title: response.title,
+          sections: response.sections.map(section => {
+            const suffix = response.foreach ? ' ' + response.foreach : '';
+            return {
+              name: section.name,
+              query: (section.query + suffix).replace(
+                PROJECT_PLACEHOLDER_PATTERN,
+                project
+              ),
+            };
+          }),
+        };
+      });
+  }
+
+  _computeTitle(user?: string) {
+    if (!user || user === 'self') {
+      return 'My Reviews';
+    }
+    return 'Dashboard for ' + user;
+  }
+
+  _isViewActive(params: AppElementParams): params is AppElementDashboardParams {
+    return params.view === GerritView.DASHBOARD;
+  }
+
+  @observe('params.*')
+  _paramsChanged(
+    paramsChangeRecord: ElementPropertyDeepChange<GrDashboardView, 'params'>
+  ) {
+    const params = paramsChangeRecord.base;
+
+    return this._reload(params);
+  }
+
+  /**
+   * Reloads the element.
+   */
+  _reload(params?: AppElementParams) {
+    if (!params || !this._isViewActive(params)) {
+      return Promise.resolve();
+    }
+    this._loading = true;
+    const {project, dashboard, title, user, sections} = params;
+    const dashboardPromise: Promise<UserDashboard | undefined> = project
+      ? this._getProjectDashboard(project, dashboard)
+      : this.$.restAPI
+          .getConfig()
+          .then(config =>
+            Promise.resolve(
+              GerritNav.getUserDashboard(
+                user,
+                sections,
+                title || this._computeTitle(user),
+                config
+              )
+            )
+          );
+
+    const checkForNewUser = !project && user === 'self';
+    return dashboardPromise
+      .then(res => {
+        if (res && res.title) {
+          this.dispatchEvent(
+            new CustomEvent('title-change', {
+              detail: {title: res.title},
+              composed: true,
+              bubbles: true,
+            })
+          );
+        }
+        return this._fetchDashboardChanges(res, checkForNewUser);
+      })
+      .then(() => {
+        this._maybeShowDraftsBanner(params);
+        this.reporting.dashboardDisplayed();
+      })
+      .catch(err => {
+        this.dispatchEvent(
+          new CustomEvent('title-change', {
+            detail: {
+              title: title || this._computeTitle(user),
+            },
+            composed: true,
+            bubbles: true,
+          })
+        );
+        console.warn(err);
+      })
+      .then(() => {
+        this._loading = false;
+      });
+  }
+
+  /**
+   * Fetches the changes for each dashboard section and sets this._results
+   * with the response.
+   */
+  _fetchDashboardChanges(
+    res: UserDashboard | undefined,
+    checkForNewUser: boolean
+  ): Promise<void> {
+    if (!res) {
+      return Promise.resolve();
+    }
+
+    let queries: string[];
+
+    if (window.PRELOADED_QUERIES && window.PRELOADED_QUERIES.dashboardQuery) {
+      queries = window.PRELOADED_QUERIES.dashboardQuery;
+      // we use preloaded query from index only on first page load
+      window.PRELOADED_QUERIES.dashboardQuery = undefined;
+    } else {
+      queries = res.sections.map(section =>
+        section.suffixForDashboard
+          ? section.query + ' ' + section.suffixForDashboard
+          : section.query
+      );
+
+      if (checkForNewUser) {
+        queries.push('owner:self limit:1');
+      }
+    }
+
+    return this.$.restAPI.getChanges(undefined, queries).then(changes => {
+      if (!changes) {
+        throw new Error('getChanges returns undefined');
+      }
+      if (checkForNewUser) {
+        // Last set of results is not meant for dashboard display.
+        const lastResultSet = changes.pop();
+        this._showNewUserHelp = lastResultSet!.length === 0;
+      }
+      this._results = changes
+        .map((results, i) => {
+          return {
+            name: res.sections[i].name,
+            countLabel: this._computeSectionCountLabel(results),
+            query: res.sections[i].query,
+            results: this._maybeSortResults(res.sections[i].name, results),
+            isOutgoing: res.sections[i].isOutgoing,
+          };
+        })
+        .filter(
+          (section, i) =>
+            i < res.sections.length &&
+            (!res.sections[i].hideIfEmpty || section.results.length)
+        );
+    });
+  }
+
+  /**
+   * Usually we really want to stick to the sorting that the backend provides,
+   * but for the "Your Turn" section it is important to put the changes at the
+   * top where the current user is a reviewer. Owned changes are less important.
+   * And then we want to emphasize the changes where the waiting time is larger.
+   */
+  _maybeSortResults(name: string, results: ChangeInfo[]) {
+    const userId = this.account && this.account._account_id;
+    const sortedResults = [...results];
+    if (name === YOUR_TURN.name && userId) {
+      sortedResults.sort((c1, c2) => {
+        const c1Owner = c1.owner._account_id === userId;
+        const c2Owner = c2.owner._account_id === userId;
+        if (c1Owner !== c2Owner) return c1Owner ? 1 : -1;
+        // Should never happen, because the change is in the 'Your Turn'
+        // section, so the userId should be found in the attention set of both.
+        if (!c1.attention_set || !c1.attention_set[userId]) return 0;
+        if (!c2.attention_set || !c2.attention_set[userId]) return 0;
+        const c1Update = c1.attention_set[userId].last_update;
+        const c2Update = c2.attention_set[userId].last_update;
+        // Should never happen that an attention set entry has no update.
+        if (!c1Update || !c2Update) return c1Update ? 1 : -1;
+        return parseDate(c1Update).valueOf() - parseDate(c2Update).valueOf();
+      });
+    }
+    return sortedResults;
+  }
+
+  _computeSectionCountLabel(changes: ChangeInfo[]) {
+    if (!changes || !changes.length || changes.length === 0) {
+      return '';
+    }
+    const more = changes[changes.length - 1]._more_changes;
+    const numChanges = changes.length;
+    const andMore = more ? ' and more' : '';
+    return `(${numChanges}${andMore})`;
+  }
+
+  _computeUserHeaderClass(params: AppElementParams) {
+    if (
+      !params ||
+      params.view !== GerritView.DASHBOARD ||
+      !!params.project ||
+      !params.user ||
+      params.user === 'self'
+    ) {
+      return 'hide';
+    }
+    return '';
+  }
+
+  _handleToggleStar(e: CustomEvent<ChangeStarToggleStarDetail>) {
+    this.$.restAPI.saveChangeStarred(e.detail.change._number, e.detail.starred);
+  }
+
+  _handleToggleReviewed(e: CustomEvent<ChangeListToggleReviewedDetail>) {
+    this.$.restAPI.saveChangeReviewed(
+      e.detail.change._number,
+      e.detail.reviewed
+    );
+  }
+
+  /**
+   * Banner is shown if a user is on their own dashboard and they have draft
+   * comments on closed changes.
+   */
+  _maybeShowDraftsBanner(params: AppElementDashboardParams) {
+    this._showDraftsBanner = false;
+    if (!(params.user === 'self')) {
+      return;
+    }
+
+    if (!this._results) {
+      throw new Error('this._results must be set. restAPI returned undefined');
+    }
+
+    const draftSection = this._results.find(
+      section => section.query === 'has:draft'
+    );
+    if (!draftSection || !draftSection.results.length) {
+      return;
+    }
+
+    const closedChanges = draftSection.results.filter(
+      change => !changeIsOpen(change)
+    );
+    if (!closedChanges.length) {
+      return;
+    }
+
+    this._showDraftsBanner = true;
+  }
+
+  _computeBannerClass(show: boolean) {
+    return show ? '' : 'hide';
+  }
+
+  _handleOpenDeleteDialog() {
+    this.$.confirmDeleteOverlay.open();
+  }
+
+  _handleConfirmDelete() {
+    this.$.confirmDeleteDialog.disabled = true;
+    return this.$.restAPI.deleteDraftComments('-is:open').then(() => {
+      this._closeConfirmDeleteOverlay();
+      this._reload(this.params);
+    });
+  }
+
+  _closeConfirmDeleteOverlay() {
+    this.$.confirmDeleteOverlay.close();
+  }
+
+  _computeDraftsLink() {
+    return GerritNav.getUrlForSearchQuery('has:draft -is:open');
+  }
+
+  _handleCreateChangeTap() {
+    this.$.destinationDialog.open();
+  }
+
+  _handleDestinationConfirm(e: CustomEvent<CreateDestinationConfirmDetail>) {
+    this.$.commandsDialog.branch = e.detail.branch;
+    this.$.commandsDialog.open();
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-dashboard-view': GrDashboardView;
+  }
+}
diff --git a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_html.ts b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_html.ts
index f8a0167..5739d4d 100644
--- a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_html.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_html.ts
@@ -91,7 +91,7 @@
       <div id="emptyOutgoing" slot="empty-outgoing">
         <template is="dom-if" if="[[_showNewUserHelp]]">
           <gr-create-change-help
-            on-create-tap="createChangeTap"
+            on-create-tap="_handleCreateChangeTap"
           ></gr-create-change-help>
         </template>
         <template is="dom-if" if="[[!_showNewUserHelp]]">
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 063fbb2..44f203d 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
@@ -18,7 +18,7 @@
 import '../../../test/common-test-setup-karma.js';
 import './gr-dashboard-view.js';
 import {isHidden} from '../../../test/test-utils.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
+import {GerritNav, GerritView} from '../../core/gr-navigation/gr-navigation.js';
 import {changeIsOpen} from '../../../utils/change-util.js';
 import {ChangeStatus} from '../../../constants/constants.js';
 
@@ -54,32 +54,40 @@
   suite('drafts banner functionality', () => {
     suite('_maybeShowDraftsBanner', () => {
       test('not dashboard/self', () => {
-        element.params = {user: 'notself'};
-        element._maybeShowDraftsBanner();
+        element._maybeShowDraftsBanner({
+          view: GerritView.DASHBOARD,
+          user: 'notself',
+        });
         assert.isFalse(element._showDraftsBanner);
       });
 
       test('no drafts at all', () => {
-        element.params = {user: 'self'};
         element._results = [];
-        element._maybeShowDraftsBanner();
+        element._maybeShowDraftsBanner({
+          view: GerritView.DASHBOARD,
+          user: 'self',
+        });
         assert.isFalse(element._showDraftsBanner);
       });
 
       test('no drafts on open changes', () => {
-        element.params = {user: 'self'};
         const openChange = {status: ChangeStatus.NEW};
         element._results = [{query: 'has:draft', results: [openChange]}];
-        element._maybeShowDraftsBanner();
+        element._maybeShowDraftsBanner({
+          view: GerritView.DASHBOARD,
+          user: 'self',
+        });
         assert.isFalse(element._showDraftsBanner);
       });
 
       test('no drafts on not open changes', () => {
-        element.params = {user: 'self'};
         const notOpenChange = {status: '_'};
         element._results = [{query: 'has:draft', results: [notOpenChange]}];
         assert.isFalse(changeIsOpen(element._results[0].results[0]));
-        element._maybeShowDraftsBanner();
+        element._maybeShowDraftsBanner({
+          view: GerritView.DASHBOARD,
+          user: 'self',
+        });
         assert.isTrue(element._showDraftsBanner);
       });
     });
@@ -194,7 +202,8 @@
       };
       return paramsChangedPromise.then(() => {
         assert.isTrue(
-            getChangesStub.calledWith(null, ['1', '2', 'owner:self limit:1']));
+            getChangesStub.calledWith(undefined,
+                ['1', '2', 'owner:self limit:1']));
       });
     });
 
@@ -208,7 +217,7 @@
         user: 'user',
       };
       return paramsChangedPromise.then(() => {
-        assert.isTrue(getChangesStub.calledWith(null, ['1']));
+        assert.isTrue(getChangesStub.calledWith(undefined, ['1']));
       });
     });
   });
@@ -224,7 +233,7 @@
     return paramsChangedPromise.then(() => {
       assert.isTrue(getChangesStub.calledOnce);
       assert.deepEqual(
-          getChangesStub.firstCall.args, [null, ['1', '2 suffix']]);
+          getChangesStub.firstCall.args, [undefined, ['1', '2 suffix']]);
     });
   });
 
@@ -329,10 +338,23 @@
     assert.equal(element._computeUserHeaderClass(undefined), 'hide');
     assert.equal(element._computeUserHeaderClass({}), 'hide');
     assert.equal(element._computeUserHeaderClass({user: 'self'}), 'hide');
-    assert.equal(element._computeUserHeaderClass({user: 'user'}), '');
+    assert.equal(element._computeUserHeaderClass({user: 'user'}), 'hide');
+    assert.equal(
+        element._computeUserHeaderClass({
+          view: GerritView.DASHBOARD,
+          user: 'user',
+        }),
+        '');
     assert.equal(
         element._computeUserHeaderClass({project: 'p', user: 'user'}),
         'hide');
+    assert.equal(
+        element._computeUserHeaderClass({
+          view: GerritView.DASHBOARD,
+          project: 'p',
+          user: 'user',
+        }),
+        'hide');
   });
 
   test('404 page', done => {
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.js b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.js
deleted file mode 100644
index fba4b53..0000000
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.js
+++ /dev/null
@@ -1,1724 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 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 '../../admin/gr-create-change-dialog/gr-create-change-dialog.js';
-import '../../shared/gr-button/gr-button.js';
-import '../../shared/gr-dialog/gr-dialog.js';
-import '../../shared/gr-dropdown/gr-dropdown.js';
-import '../../shared/gr-icons/gr-icons.js';
-import '../../shared/gr-js-api-interface/gr-js-api-interface.js';
-import '../../shared/gr-overlay/gr-overlay.js';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
-import '../gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.js';
-import '../gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.js';
-import '../gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog.js';
-import '../gr-confirm-move-dialog/gr-confirm-move-dialog.js';
-import '../gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.js';
-import '../gr-confirm-revert-dialog/gr-confirm-revert-dialog.js';
-import '../gr-confirm-revert-submission-dialog/gr-confirm-revert-submission-dialog.js';
-import '../gr-confirm-submit-dialog/gr-confirm-submit-dialog.js';
-import '../../../styles/shared-styles.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
-import {PolymerElement} from '@polymer/polymer/polymer-element.js';
-import {htmlTemplate} from './gr-change-actions_html.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
-import {appContext} from '../../../services/app-context.js';
-import {
-  fetchChangeUpdates,
-  patchNumEquals,
-} from '../../../utils/patch-set-util.js';
-import {
-  changeIsOpen,
-  ListChangesOption,
-  listChangesOptionsToHex,
-} from '../../../utils/change-util.js';
-import {NotifyType} from '../../../constants/constants.js';
-import {TargetElement, EventType} from '../../plugins/gr-plugin-types.js';
-
-const ERR_BRANCH_EMPTY = 'The destination branch can’t be empty.';
-const ERR_COMMIT_EMPTY = 'The commit message can’t be empty.';
-const ERR_REVISION_ACTIONS = 'Couldn’t load revision actions.';
-/**
- * @enum {string}
- */
-const LabelStatus = {
-  /**
-   * This label provides what is necessary for submission.
-   */
-  OK: 'OK',
-  /**
-   * This label prevents the change from being submitted.
-   */
-  REJECT: 'REJECT',
-  /**
-   * The label may be set, but it's neither necessary for submission
-   * nor does it block submission if set.
-   */
-  MAY: 'MAY',
-  /**
-   * The label is required for submission, but has not been satisfied.
-   */
-  NEED: 'NEED',
-  /**
-   * The label is required for submission, but is impossible to complete.
-   * The likely cause is access has not been granted correctly by the
-   * project owner or site administrator.
-   */
-  IMPOSSIBLE: 'IMPOSSIBLE',
-  OPTIONAL: 'OPTIONAL',
-};
-
-const ChangeActions = {
-  ABANDON: 'abandon',
-  DELETE: '/',
-  DELETE_EDIT: 'deleteEdit',
-  EDIT: 'edit',
-  FOLLOW_UP: 'followup',
-  IGNORE: 'ignore',
-  MOVE: 'move',
-  PRIVATE: 'private',
-  PRIVATE_DELETE: 'private.delete',
-  PUBLISH_EDIT: 'publishEdit',
-  REBASE: 'rebase',
-  REBASE_EDIT: 'rebaseEdit',
-  READY: 'ready',
-  RESTORE: 'restore',
-  REVERT: 'revert',
-  REVERT_SUBMISSION: 'revert_submission',
-  REVIEWED: 'reviewed',
-  STOP_EDIT: 'stopEdit',
-  SUBMIT: 'submit',
-  UNIGNORE: 'unignore',
-  UNREVIEWED: 'unreviewed',
-  WIP: 'wip',
-};
-
-const RevisionActions = {
-  CHERRYPICK: 'cherrypick',
-  REBASE: 'rebase',
-  SUBMIT: 'submit',
-  DOWNLOAD: 'download',
-};
-
-const ActionLoadingLabels = {
-  abandon: 'Abandoning...',
-  cherrypick: 'Cherry-picking...',
-  delete: 'Deleting...',
-  move: 'Moving..',
-  rebase: 'Rebasing...',
-  restore: 'Restoring...',
-  revert: 'Reverting...',
-  revert_submission: 'Reverting Submission...',
-  submit: 'Submitting...',
-};
-
-const ActionType = {
-  CHANGE: 'change',
-  REVISION: 'revision',
-};
-
-const ADDITIONAL_ACTION_KEY_PREFIX = '__additionalAction_';
-
-const QUICK_APPROVE_ACTION = {
-  __key: 'review',
-  __type: 'change',
-  enabled: true,
-  key: 'review',
-  label: 'Quick approve',
-  method: 'POST',
-};
-
-const ActionPriority = {
-  CHANGE: 2,
-  DEFAULT: 0,
-  PRIMARY: 3,
-  REVIEW: -3,
-  REVISION: 1,
-};
-
-const DOWNLOAD_ACTION = {
-  enabled: true,
-  label: 'Download patch',
-  title: 'Open download dialog',
-  __key: 'download',
-  __primary: false,
-  __type: 'revision',
-};
-
-const REBASE_EDIT = {
-  enabled: true,
-  label: 'Rebase edit',
-  title: 'Rebase change edit',
-  __key: 'rebaseEdit',
-  __primary: false,
-  __type: 'change',
-  method: 'POST',
-};
-
-const PUBLISH_EDIT = {
-  enabled: true,
-  label: 'Publish edit',
-  title: 'Publish change edit',
-  __key: 'publishEdit',
-  __primary: false,
-  __type: 'change',
-  method: 'POST',
-};
-
-const DELETE_EDIT = {
-  enabled: true,
-  label: 'Delete edit',
-  title: 'Delete change edit',
-  __key: 'deleteEdit',
-  __primary: false,
-  __type: 'change',
-  method: 'DELETE',
-};
-
-const EDIT = {
-  enabled: true,
-  label: 'Edit',
-  title: 'Edit this change',
-  __key: 'edit',
-  __primary: false,
-  __type: 'change',
-};
-
-const STOP_EDIT = {
-  enabled: true,
-  label: 'Stop editing',
-  title: 'Stop editing this change',
-  __key: 'stopEdit',
-  __primary: false,
-  __type: 'change',
-};
-
-// Set of keys that have icons. As more icons are added to gr-icons.html, this
-// set should be expanded.
-const ACTIONS_WITH_ICONS = new Set([
-  ChangeActions.ABANDON,
-  ChangeActions.DELETE_EDIT,
-  ChangeActions.EDIT,
-  ChangeActions.PUBLISH_EDIT,
-  ChangeActions.READY,
-  ChangeActions.REBASE_EDIT,
-  ChangeActions.RESTORE,
-  ChangeActions.REVERT,
-  ChangeActions.REVERT_SUBMISSION,
-  ChangeActions.STOP_EDIT,
-  QUICK_APPROVE_ACTION.key,
-  RevisionActions.REBASE,
-  RevisionActions.SUBMIT,
-]);
-
-const AWAIT_CHANGE_ATTEMPTS = 5;
-const AWAIT_CHANGE_TIMEOUT_MS = 1000;
-
-const REVERT_TYPES = {
-  REVERT_SINGLE_CHANGE: 1,
-  REVERT_SUBMISSION: 2,
-};
-
-/* Revert submission is skipped as the normal revert dialog will now show
-the user a choice between reverting single change or an entire submission.
-Hence, a second button is not needed.
-*/
-const SKIP_ACTION_KEYS = [ChangeActions.REVERT_SUBMISSION];
-
-const SKIP_ACTION_KEYS_ATTENTION_SET = [
-  ChangeActions.REVIEWED,
-  ChangeActions.UNREVIEWED,
-];
-
-/**
- * @extends PolymerElement
- */
-class GrChangeActions extends GestureEventListeners(
-    LegacyElementMixin(PolymerElement)) {
-  static get template() { return htmlTemplate; }
-
-  static get is() { return 'gr-change-actions'; }
-  /**
-   * Fired when the change should be reloaded.
-   *
-   * @event reload
-   */
-
-  /**
-   * Fired when an action is tapped.
-   *
-   * @event custom-tap - naming pattern: <action key>-tap
-   */
-
-  /**
-   * Fires to show an alert when a send is attempted on the non-latest patch.
-   *
-   * @event show-alert
-   */
-
-  /**
-   * Fires when a change action fails.
-   *
-   * @event show-error
-   */
-
-  constructor() {
-    super();
-    this.ActionType = ActionType;
-    this.ChangeActions = ChangeActions;
-    this.RevisionActions = RevisionActions;
-    this.reporting = appContext.reportingService;
-  }
-
-  static get properties() {
-    return {
-    /**
-     * @type {{
-     *    _number: number,
-     *    branch: string,
-     *    id: string,
-     *    project: string,
-     *    subject: string,
-     *  }}
-     */
-      change: Object,
-      actions: {
-        type: Object,
-        value() { return {}; },
-      },
-      primaryActionKeys: {
-        type: Array,
-        value() {
-          return [
-            ChangeActions.READY,
-            RevisionActions.SUBMIT,
-          ];
-        },
-      },
-      disableEdit: {
-        type: Boolean,
-        value: false,
-      },
-      _hasKnownChainState: {
-        type: Boolean,
-        value: false,
-      },
-      _hideQuickApproveAction: {
-        type: Boolean,
-        value: false,
-      },
-      changeNum: String,
-      changeStatus: String,
-      commitNum: String,
-      hasParent: {
-        type: Boolean,
-        observer: '_computeChainState',
-      },
-      latestPatchNum: String,
-      commitMessage: {
-        type: String,
-        value: '',
-      },
-      /** @type {?} */
-      revisionActions: {
-        type: Object,
-        notify: true,
-        value() { return {}; },
-      },
-      // If property binds directly to [[revisionActions.submit]] it is not
-      // updated when revisionActions doesn't contain submit action.
-      /** @type {?} */
-      _revisionSubmitAction: {
-        type: Object,
-        computed: '_getSubmitAction(revisionActions)',
-      },
-      // If property binds directly to [[revisionActions.rebase]] it is not
-      // updated when revisionActions doesn't contain rebase action.
-      /** @type {?} */
-      _revisionRebaseAction: {
-        type: Object,
-        computed: '_getRebaseAction(revisionActions)',
-      },
-      privateByDefault: String,
-
-      _loading: {
-        type: Boolean,
-        value: true,
-      },
-      _actionLoadingMessage: {
-        type: String,
-        value: '',
-      },
-      _allActionValues: {
-        type: Array,
-        computed: '_computeAllActions(actions.*, revisionActions.*,' +
-          'primaryActionKeys.*, _additionalActions.*, change, ' +
-          '_config, _actionPriorityOverrides.*)',
-      },
-      _topLevelActions: {
-        type: Array,
-        computed: '_computeTopLevelActions(_allActionValues.*, ' +
-          '_hiddenActions.*, _overflowActions.*)',
-        observer: '_filterPrimaryActions',
-      },
-      _topLevelPrimaryActions: Array,
-      _topLevelSecondaryActions: Array,
-      _menuActions: {
-        type: Array,
-        computed: '_computeMenuActions(_allActionValues.*, ' +
-          '_hiddenActions.*, _overflowActions.*)',
-      },
-      _overflowActions: {
-        type: Array,
-        value() {
-          const value = [
-            {
-              type: ActionType.CHANGE,
-              key: ChangeActions.WIP,
-            },
-            {
-              type: ActionType.CHANGE,
-              key: ChangeActions.DELETE,
-            },
-            {
-              type: ActionType.REVISION,
-              key: RevisionActions.CHERRYPICK,
-            },
-            {
-              type: ActionType.CHANGE,
-              key: ChangeActions.MOVE,
-            },
-            {
-              type: ActionType.REVISION,
-              key: RevisionActions.DOWNLOAD,
-            },
-            {
-              type: ActionType.CHANGE,
-              key: ChangeActions.IGNORE,
-            },
-            {
-              type: ActionType.CHANGE,
-              key: ChangeActions.UNIGNORE,
-            },
-            {
-              type: ActionType.CHANGE,
-              key: ChangeActions.REVIEWED,
-            },
-            {
-              type: ActionType.CHANGE,
-              key: ChangeActions.UNREVIEWED,
-            },
-            {
-              type: ActionType.CHANGE,
-              key: ChangeActions.PRIVATE,
-            },
-            {
-              type: ActionType.CHANGE,
-              key: ChangeActions.PRIVATE_DELETE,
-            },
-            {
-              type: ActionType.CHANGE,
-              key: ChangeActions.FOLLOW_UP,
-            },
-          ];
-          return value;
-        },
-      },
-      _actionPriorityOverrides: {
-        type: Array,
-        value() { return []; },
-      },
-      _additionalActions: {
-        type: Array,
-        value() { return []; },
-      },
-      _hiddenActions: {
-        type: Array,
-        value() { return []; },
-      },
-      _disabledMenuActions: {
-        type: Array,
-        value() { return []; },
-      },
-      // editPatchsetLoaded == "does the current selected patch range have
-      // 'edit' as one of either basePatchNum or patchNum".
-      editPatchsetLoaded: {
-        type: Boolean,
-        value: false,
-      },
-      // editMode == "is edit mode enabled in the file list".
-      editMode: {
-        type: Boolean,
-        value: false,
-      },
-      editBasedOnCurrentPatchSet: {
-        type: Boolean,
-        value: true,
-      },
-      _config: Object,
-    };
-  }
-
-  static get observers() {
-    return [
-      '_actionsChanged(actions.*, revisionActions.*, _additionalActions.*)',
-      '_changeChanged(change)',
-      '_editStatusChanged(editMode, editPatchsetLoaded, ' +
-        'editBasedOnCurrentPatchSet, disableEdit, actions.*, change.*)',
-    ];
-  }
-
-  /** @override */
-  created() {
-    super.created();
-    this.addEventListener('fullscreen-overlay-opened',
-        () => this._handleHideBackgroundContent());
-    this.addEventListener('fullscreen-overlay-closed',
-        () => this._handleShowBackgroundContent());
-  }
-
-  /** @override */
-  ready() {
-    super.ready();
-    this.$.jsAPI.addElement(TargetElement.CHANGE_ACTIONS, this);
-    this.$.restAPI.getConfig().then(config => {
-      this._config = config;
-    });
-    this._handleLoadingComplete();
-  }
-
-  _getSubmitAction(revisionActions) {
-    return this._getRevisionAction(revisionActions, 'submit', null);
-  }
-
-  _getRebaseAction(revisionActions) {
-    return this._getRevisionAction(revisionActions, 'rebase', null);
-  }
-
-  _getRevisionAction(revisionActions, actionName, emptyActionValue) {
-    if (!revisionActions) {
-      return undefined;
-    }
-    if (revisionActions[actionName] === undefined) {
-      // Return null to fire an event when reveisionActions was loaded
-      // but doesn't contain actionName. undefined doesn't fire an event
-      return emptyActionValue;
-    }
-    return revisionActions[actionName];
-  }
-
-  reload() {
-    if (!this.changeNum || !this.latestPatchNum) {
-      return Promise.resolve();
-    }
-
-    this._loading = true;
-    return this._getRevisionActions()
-        .then(revisionActions => {
-          if (!revisionActions) { return; }
-
-          this.revisionActions = revisionActions;
-          this._sendShowRevisionActions({
-            change: this.change,
-            revisionActions,
-          });
-          this._handleLoadingComplete();
-        })
-        .catch(err => {
-          this.dispatchEvent(new CustomEvent('show-alert', {
-            detail: {message: ERR_REVISION_ACTIONS},
-            composed: true, bubbles: true,
-          }));
-          this._loading = false;
-          throw err;
-        });
-  }
-
-  _handleLoadingComplete() {
-    getPluginLoader().awaitPluginsLoaded()
-        .then(() => this._loading = false);
-  }
-
-  _sendShowRevisionActions(detail) {
-    this.$.jsAPI.handleEvent(
-        EventType.SHOW_REVISION_ACTIONS,
-        detail
-    );
-  }
-
-  _changeChanged() {
-    this.reload();
-  }
-
-  addActionButton(type, label) {
-    if (type !== ActionType.CHANGE && type !== ActionType.REVISION) {
-      throw Error(`Invalid action type: ${type}`);
-    }
-    const action = {
-      enabled: true,
-      label,
-      __type: type,
-      __key: ADDITIONAL_ACTION_KEY_PREFIX +
-          Math.random().toString(36)
-              .substr(2),
-    };
-    this.push('_additionalActions', action);
-    return action.__key;
-  }
-
-  removeActionButton(key) {
-    const idx = this._indexOfActionButtonWithKey(key);
-    if (idx === -1) {
-      return;
-    }
-    this.splice('_additionalActions', idx, 1);
-  }
-
-  setActionButtonProp(key, prop, value) {
-    this.set([
-      '_additionalActions',
-      this._indexOfActionButtonWithKey(key),
-      prop,
-    ], value);
-  }
-
-  setActionOverflow(type, key, overflow) {
-    if (type !== ActionType.CHANGE && type !== ActionType.REVISION) {
-      throw Error(`Invalid action type given: ${type}`);
-    }
-    const index = this._getActionOverflowIndex(type, key);
-    const action = {
-      type,
-      key,
-      overflow,
-    };
-    if (!overflow && index !== -1) {
-      this.splice('_overflowActions', index, 1);
-    } else if (overflow) {
-      this.push('_overflowActions', action);
-    }
-  }
-
-  setActionPriority(type, key, priority) {
-    if (type !== ActionType.CHANGE && type !== ActionType.REVISION) {
-      throw Error(`Invalid action type given: ${type}`);
-    }
-    const index = this._actionPriorityOverrides
-        .findIndex(action => action.type === type && action.key === key);
-    const action = {
-      type,
-      key,
-      priority,
-    };
-    if (index !== -1) {
-      this.set('_actionPriorityOverrides', index, action);
-    } else {
-      this.push('_actionPriorityOverrides', action);
-    }
-  }
-
-  setActionHidden(type, key, hidden) {
-    if (type !== ActionType.CHANGE && type !== ActionType.REVISION) {
-      throw Error(`Invalid action type given: ${type}`);
-    }
-
-    const idx = this._hiddenActions.indexOf(key);
-    if (hidden && idx === -1) {
-      this.push('_hiddenActions', key);
-    } else if (!hidden && idx !== -1) {
-      this.splice('_hiddenActions', idx, 1);
-    }
-  }
-
-  getActionDetails(action) {
-    if (this.revisionActions[action]) {
-      return this.revisionActions[action];
-    } else if (this.actions[action]) {
-      return this.actions[action];
-    }
-  }
-
-  _indexOfActionButtonWithKey(key) {
-    for (let i = 0; i < this._additionalActions.length; i++) {
-      if (this._additionalActions[i].__key === key) {
-        return i;
-      }
-    }
-    return -1;
-  }
-
-  _getRevisionActions() {
-    return this.$.restAPI.getChangeRevisionActions(this.changeNum,
-        this.latestPatchNum);
-  }
-
-  _shouldHideActions(actions, loading) {
-    return loading || !actions || !actions.base || !actions.base.length;
-  }
-
-  _keyCount(changeRecord) {
-    return Object.keys((changeRecord && changeRecord.base) || {}).length;
-  }
-
-  _actionsChanged(actionsChangeRecord, revisionActionsChangeRecord,
-      additionalActionsChangeRecord) {
-    // Polymer 2: check for undefined
-    if ([
-      actionsChangeRecord,
-      revisionActionsChangeRecord,
-      additionalActionsChangeRecord,
-    ].includes(undefined)) {
-      return;
-    }
-
-    const additionalActions = (additionalActionsChangeRecord &&
-        additionalActionsChangeRecord.base) || [];
-    this.hidden = this._keyCount(actionsChangeRecord) === 0 &&
-        this._keyCount(revisionActionsChangeRecord) === 0 &&
-            additionalActions.length === 0;
-    this._actionLoadingMessage = '';
-    this._disabledMenuActions = [];
-
-    const revisionActions = revisionActionsChangeRecord.base || {};
-    if (Object.keys(revisionActions).length !== 0) {
-      if (!revisionActions.download) {
-        this.set('revisionActions.download', DOWNLOAD_ACTION);
-      }
-    }
-  }
-
-  /**
-   * @param {string=} actionName
-   */
-  _deleteAndNotify(actionName) {
-    if (this.actions && this.actions[actionName]) {
-      delete this.actions[actionName];
-      // We assign a fake value of 'false' to support Polymer 2
-      // see https://github.com/Polymer/polymer/issues/2631
-      this.notifyPath('actions.' + actionName, false);
-    }
-  }
-
-  _editStatusChanged(editMode, editPatchsetLoaded,
-      editBasedOnCurrentPatchSet, disableEdit) {
-    // Polymer 2: check for undefined
-    if ([
-      editMode,
-      editBasedOnCurrentPatchSet,
-      disableEdit,
-    ].includes(undefined)) {
-      return;
-    }
-
-    if (disableEdit) {
-      this._deleteAndNotify('publishEdit');
-      this._deleteAndNotify('rebaseEdit');
-      this._deleteAndNotify('deleteEdit');
-      this._deleteAndNotify('stopEdit');
-      this._deleteAndNotify('edit');
-      return;
-    }
-    if (this.actions && editPatchsetLoaded) {
-      // Only show actions that mutate an edit if an actual edit patch set
-      // is loaded.
-      if (changeIsOpen(this.change)) {
-        if (editBasedOnCurrentPatchSet) {
-          if (!this.actions.publishEdit) {
-            this.set('actions.publishEdit', PUBLISH_EDIT);
-          }
-          this._deleteAndNotify('rebaseEdit');
-        } else {
-          if (!this.actions.rebaseEdit) {
-            this.set('actions.rebaseEdit', REBASE_EDIT);
-          }
-          this._deleteAndNotify('publishEdit');
-        }
-      }
-      if (!this.actions.deleteEdit) {
-        this.set('actions.deleteEdit', DELETE_EDIT);
-      }
-    } else {
-      this._deleteAndNotify('publishEdit');
-      this._deleteAndNotify('rebaseEdit');
-      this._deleteAndNotify('deleteEdit');
-    }
-
-    if (this.actions && changeIsOpen(this.change)) {
-      // Only show edit button if there is no edit patchset loaded and the
-      // file list is not in edit mode.
-      if (editPatchsetLoaded || editMode) {
-        this._deleteAndNotify('edit');
-      } else {
-        if (!this.actions.edit) { this.set('actions.edit', EDIT); }
-      }
-      // Only show STOP_EDIT if edit mode is enabled, but no edit patch set
-      // is loaded.
-      if (editMode && !editPatchsetLoaded) {
-        if (!this.actions.stopEdit) {
-          this.set('actions.stopEdit', STOP_EDIT);
-        }
-      } else {
-        this._deleteAndNotify('stopEdit');
-      }
-    } else {
-      // Remove edit button.
-      this._deleteAndNotify('edit');
-    }
-  }
-
-  _getValuesFor(obj) {
-    return Object.keys(obj).map(key => obj[key]);
-  }
-
-  _getLabelStatus(label) {
-    if (label.approved) {
-      return LabelStatus.OK;
-    } else if (label.rejected) {
-      return LabelStatus.REJECT;
-    } else if (label.optional) {
-      return LabelStatus.OPTIONAL;
-    } else {
-      return LabelStatus.NEED;
-    }
-  }
-
-  /**
-   * Get highest score for last missing permitted label for current change.
-   * Returns null if no labels permitted or more than one label missing.
-   *
-   * @return {{label: string, score: string}|null}
-   */
-  _getTopMissingApproval() {
-    if (!this.change ||
-        !this.change.labels ||
-        !this.change.permitted_labels) {
-      return null;
-    }
-    let result;
-    for (const label in this.change.labels) {
-      if (!(label in this.change.permitted_labels)) {
-        continue;
-      }
-      if (this.change.permitted_labels[label].length === 0) {
-        continue;
-      }
-      const status = this._getLabelStatus(this.change.labels[label]);
-      if (status === LabelStatus.NEED) {
-        if (result) {
-          // More than one label is missing, so it's unclear which to quick
-          // approve, return null;
-          return null;
-        }
-        result = label;
-      } else if (status === LabelStatus.REJECT ||
-          status === LabelStatus.IMPOSSIBLE) {
-        return null;
-      }
-    }
-    if (result) {
-      const score = this.change.permitted_labels[result].slice(-1)[0];
-      const maxScore =
-          Object.keys(this.change.labels[result].values).slice(-1)[0];
-      if (score === maxScore) {
-        // Allow quick approve only for maximal score.
-        return {
-          label: result,
-          score,
-        };
-      }
-    }
-    return null;
-  }
-
-  hideQuickApproveAction() {
-    this._topLevelSecondaryActions =
-      this._topLevelSecondaryActions
-          .filter(sa => sa.key !== QUICK_APPROVE_ACTION.key);
-    this._hideQuickApproveAction = true;
-  }
-
-  _getQuickApproveAction() {
-    if (this._hideQuickApproveAction) {
-      return null;
-    }
-    const approval = this._getTopMissingApproval();
-    if (!approval) {
-      return null;
-    }
-    const action = {...QUICK_APPROVE_ACTION};
-    action.label = approval.label + approval.score;
-    const review = {
-      drafts: 'PUBLISH_ALL_REVISIONS',
-      labels: {},
-    };
-    review.labels[approval.label] = approval.score;
-    action.payload = review;
-    return action;
-  }
-
-  _getActionValues(actionsChangeRecord, primariesChangeRecord,
-      additionalActionsChangeRecord, type) {
-    if (!actionsChangeRecord || !primariesChangeRecord) { return []; }
-
-    const actions = actionsChangeRecord.base || {};
-    const primaryActionKeys = primariesChangeRecord.base || [];
-    const result = [];
-    const values = this._getValuesFor(
-        type === ActionType.CHANGE ? ChangeActions : RevisionActions);
-    const pluginActions = [];
-    Object.keys(actions).forEach(a => {
-      actions[a].__key = a;
-      actions[a].__type = type;
-      actions[a].__primary = primaryActionKeys.includes(a);
-      // Plugin actions always contain ~ in the key.
-      if (a.indexOf('~') !== -1) {
-        this._populateActionUrl(actions[a]);
-        pluginActions.push(actions[a]);
-        // Add server-side provided plugin actions to overflow menu.
-        this._overflowActions.push({
-          type,
-          key: a,
-        });
-        return;
-      } else if (!values.includes(a)) {
-        return;
-      }
-      actions[a].label = this._getActionLabel(actions[a]);
-
-      // Triggers a re-render by ensuring object inequality.
-      result.push({...actions[a]});
-    });
-
-    let additionalActions = (additionalActionsChangeRecord &&
-    additionalActionsChangeRecord.base) || [];
-    additionalActions = additionalActions
-        .filter(a => a.__type === type)
-        .map(a => {
-          a.__primary = primaryActionKeys.includes(a.__key);
-          // Triggers a re-render by ensuring object inequality.
-          return {...a};
-        });
-    return result.concat(additionalActions).concat(pluginActions);
-  }
-
-  _populateActionUrl(action) {
-    const patchNum =
-          action.__type === ActionType.REVISION ? this.latestPatchNum : null;
-    this.$.restAPI.getChangeActionURL(
-        this.changeNum, patchNum, '/' + action.__key)
-        .then(url => action.__url = url);
-  }
-
-  /**
-   * Given a change action, return a display label that uses the appropriate
-   * casing or includes explanatory details.
-   */
-  _getActionLabel(action) {
-    if (action.label === 'Delete') {
-      // This label is common within change and revision actions. Make it more
-      // explicit to the user.
-      return 'Delete change';
-    } else if (action.label === 'WIP') {
-      return 'Mark as work in progress';
-    }
-    // Otherwise, just map the name to sentence case.
-    return this._toSentenceCase(action.label);
-  }
-
-  /**
-   * Capitalize the first letter and lowecase all others.
-   *
-   * @param {string} s
-   * @return {string}
-   */
-  _toSentenceCase(s) {
-    if (!s.length) { return ''; }
-    return s[0].toUpperCase() + s.slice(1).toLowerCase();
-  }
-
-  _computeLoadingLabel(action) {
-    return ActionLoadingLabels[action] || 'Working...';
-  }
-
-  _canSubmitChange() {
-    return this.$.jsAPI.canSubmitChange(this.change,
-        this._getRevision(this.change, this.latestPatchNum));
-  }
-
-  _getRevision(change, patchNum) {
-    for (const rev of Object.values(change.revisions)) {
-      if (patchNumEquals(rev._number, patchNum)) {
-        return rev;
-      }
-    }
-    return null;
-  }
-
-  showRevertDialog() {
-    // The search is still broken if there is a " in the topic.
-    const query = `submissionid: "${this.change.submission_id}"`;
-    /* A chromium plugin expects that the modifyRevertMsg hook will only
-    be called after the revert button is pressed, hence we populate the
-    revert dialog after revert button is pressed. */
-    this.$.restAPI.getChanges('', query)
-        .then(changes => {
-          this.$.confirmRevertDialog.populate(this.change,
-              this.commitMessage, changes);
-          this._showActionDialog(this.$.confirmRevertDialog);
-        });
-  }
-
-  showRevertSubmissionDialog() {
-    const query = 'submissionid:' + this.change.submission_id;
-    this.$.restAPI.getChanges('', query)
-        .then(changes => {
-          this.$.confirmRevertSubmissionDialog.
-              _populateRevertSubmissionMessage(this.change, changes);
-          this._showActionDialog(this.$.confirmRevertSubmissionDialog);
-        });
-  }
-
-  _handleActionTap(e) {
-    e.preventDefault();
-    let el = dom(e).localTarget;
-    while (el.tagName.toLowerCase() !== 'gr-button') {
-      if (!el.parentElement) { return; }
-      el = el.parentElement;
-    }
-
-    const key = el.getAttribute('data-action-key');
-    if (key.startsWith(ADDITIONAL_ACTION_KEY_PREFIX) ||
-        key.indexOf('~') !== -1) {
-      this.dispatchEvent(new CustomEvent(`${key}-tap`, {
-        detail: {node: el},
-        composed: true, bubbles: true,
-      }));
-      return;
-    }
-    const type = el.getAttribute('data-action-type');
-    this._handleAction(type, key);
-  }
-
-  _handleOverflowItemTap(e) {
-    e.preventDefault();
-    const el = dom(e).localTarget;
-    const key = e.detail.action.__key;
-    if (key.startsWith(ADDITIONAL_ACTION_KEY_PREFIX) ||
-        key.indexOf('~') !== -1) {
-      this.dispatchEvent(new CustomEvent(`${key}-tap`, {
-        detail: {node: el},
-        composed: true, bubbles: true,
-      }));
-      return;
-    }
-    this._handleAction(e.detail.action.__type, e.detail.action.__key);
-  }
-
-  _handleAction(type, key) {
-    this.reporting.reportInteraction(`${type}-${key}`);
-    switch (type) {
-      case ActionType.REVISION:
-        this._handleRevisionAction(key);
-        break;
-      case ActionType.CHANGE:
-        this._handleChangeAction(key);
-        break;
-      default:
-        this._fireAction(this._prependSlash(key), this.actions[key], false);
-    }
-  }
-
-  _handleChangeAction(key) {
-    let action;
-    switch (key) {
-      case ChangeActions.REVERT:
-        this.showRevertDialog();
-        break;
-      case ChangeActions.REVERT_SUBMISSION:
-        this.showRevertSubmissionDialog();
-        break;
-      case ChangeActions.ABANDON:
-        this._showActionDialog(this.$.confirmAbandonDialog);
-        break;
-      case QUICK_APPROVE_ACTION.key:
-        action = this._allActionValues.find(o => o.key === key);
-        this._fireAction(
-            this._prependSlash(key), action, true, action.payload);
-        break;
-      case ChangeActions.EDIT:
-        this._handleEditTap();
-        break;
-      case ChangeActions.STOP_EDIT:
-        this._handleStopEditTap();
-        break;
-      case ChangeActions.DELETE:
-        this._handleDeleteTap();
-        break;
-      case ChangeActions.DELETE_EDIT:
-        this._handleDeleteEditTap();
-        break;
-      case ChangeActions.FOLLOW_UP:
-        this._handleFollowUpTap();
-        break;
-      case ChangeActions.WIP:
-        this._handleWipTap();
-        break;
-      case ChangeActions.MOVE:
-        this._handleMoveTap();
-        break;
-      case ChangeActions.PUBLISH_EDIT:
-        this._handlePublishEditTap();
-        break;
-      case ChangeActions.REBASE_EDIT:
-        this._handleRebaseEditTap();
-        break;
-      default:
-        this._fireAction(this._prependSlash(key), this.actions[key], false);
-    }
-  }
-
-  _handleRevisionAction(key) {
-    switch (key) {
-      case RevisionActions.REBASE:
-        this._showActionDialog(this.$.confirmRebase);
-        this.$.confirmRebase.fetchRecentChanges();
-        break;
-      case RevisionActions.CHERRYPICK:
-        this._handleCherrypickTap();
-        break;
-      case RevisionActions.DOWNLOAD:
-        this._handleDownloadTap();
-        break;
-      case RevisionActions.SUBMIT:
-        if (!this._canSubmitChange()) { return; }
-        this._showActionDialog(this.$.confirmSubmitDialog);
-        break;
-      default:
-        this._fireAction(this._prependSlash(key),
-            this.revisionActions[key], true);
-    }
-  }
-
-  _prependSlash(key) {
-    return key === '/' ? key : `/${key}`;
-  }
-
-  /**
-   * _hasKnownChainState set to true true if hasParent is defined (can be
-   * either true or false). set to false otherwise.
-   */
-  _computeChainState(hasParent) {
-    this._hasKnownChainState = true;
-  }
-
-  _calculateDisabled(action, hasKnownChainState) {
-    if (action.__key === 'rebase') {
-      // Rebase button is only disabled when change has no parent(s).
-      return hasKnownChainState === false;
-    }
-    return !action.enabled;
-  }
-
-  _handleConfirmDialogCancel() {
-    this._hideAllDialogs();
-  }
-
-  _hideAllDialogs() {
-    const dialogEls =
-        this.root.querySelectorAll('.confirmDialog');
-    for (const dialogEl of dialogEls) { dialogEl.hidden = true; }
-    this.$.overlay.close();
-  }
-
-  _handleRebaseConfirm(e) {
-    const el = this.$.confirmRebase;
-    const payload = {base: e.detail.base};
-    this.$.overlay.close();
-    el.hidden = true;
-    this._fireAction('/rebase', this.revisionActions.rebase, true, payload);
-  }
-
-  _handleCherrypickConfirm() {
-    this._handleCherryPickRestApi(false);
-  }
-
-  _handleCherrypickConflictConfirm() {
-    this._handleCherryPickRestApi(true);
-  }
-
-  _handleCherryPickRestApi(conflicts) {
-    const el = this.$.confirmCherrypick;
-    if (!el.branch) {
-      this.dispatchEvent(new CustomEvent('show-alert', {
-        detail: {message: ERR_BRANCH_EMPTY},
-        composed: true, bubbles: true,
-      }));
-      return;
-    }
-    if (!el.message) {
-      this.dispatchEvent(new CustomEvent('show-alert', {
-        detail: {message: ERR_COMMIT_EMPTY},
-        composed: true, bubbles: true,
-      }));
-      return;
-    }
-    this.$.overlay.close();
-    el.hidden = true;
-    this._fireAction(
-        '/cherrypick',
-        this.revisionActions.cherrypick,
-        true,
-        {
-          destination: el.branch,
-          base: el.baseCommit ? el.baseCommit : null,
-          message: el.message,
-          allow_conflicts: conflicts,
-        }
-    );
-  }
-
-  _handleMoveConfirm() {
-    const el = this.$.confirmMove;
-    if (!el.branch) {
-      this.dispatchEvent(new CustomEvent('show-alert', {
-        detail: {message: ERR_BRANCH_EMPTY},
-        composed: true, bubbles: true,
-      }));
-      return;
-    }
-    this.$.overlay.close();
-    el.hidden = true;
-    this._fireAction(
-        '/move',
-        this.actions.move,
-        false,
-        {
-          destination_branch: el.branch,
-          message: el.message,
-        }
-    );
-  }
-
-  _handleRevertDialogConfirm(e) {
-    const revertType = e.detail.revertType;
-    const message = e.detail.message;
-    const el = this.$.confirmRevertDialog;
-    this.$.overlay.close();
-    el.hidden = true;
-    switch (revertType) {
-      case REVERT_TYPES.REVERT_SINGLE_CHANGE:
-        this._fireAction('/revert', this.actions.revert, false,
-            {message});
-        break;
-      case REVERT_TYPES.REVERT_SUBMISSION:
-        this._fireAction('/revert_submission', this.actions.revert_submission,
-            false, {message});
-        break;
-      default:
-        console.error('invalid revert type');
-    }
-  }
-
-  _handleRevertSubmissionDialogConfirm() {
-    const el = this.$.confirmRevertSubmissionDialog;
-    this.$.overlay.close();
-    el.hidden = true;
-    this._fireAction('/revert_submission', this.actions.revert_submission,
-        false, {message: el.message});
-  }
-
-  _handleAbandonDialogConfirm() {
-    const el = this.$.confirmAbandonDialog;
-    this.$.overlay.close();
-    el.hidden = true;
-    this._fireAction('/abandon', this.actions.abandon, false,
-        {message: el.message});
-  }
-
-  _handleCreateFollowUpChange() {
-    this.$.createFollowUpChange.handleCreateChange();
-    this._handleCloseCreateFollowUpChange();
-  }
-
-  _handleCloseCreateFollowUpChange() {
-    this.$.overlay.close();
-  }
-
-  _handleDeleteConfirm() {
-    this._fireAction('/', this.actions[ChangeActions.DELETE], false);
-  }
-
-  _handleDeleteEditConfirm() {
-    this._hideAllDialogs();
-
-    this._fireAction('/edit', this.actions.deleteEdit, false);
-  }
-
-  _handleSubmitConfirm() {
-    if (!this._canSubmitChange()) { return; }
-    this._hideAllDialogs();
-    this._fireAction('/submit', this.revisionActions.submit, true);
-  }
-
-  _getActionOverflowIndex(type, key) {
-    return this._overflowActions
-        .findIndex(action => action.type === type && action.key === key);
-  }
-
-  _setLoadingOnButtonWithKey(type, key) {
-    this._actionLoadingMessage = this._computeLoadingLabel(key);
-    let buttonKey = key;
-    // TODO(dhruvsri): clean this up later
-    // If key is revert-submission, then button key should be 'revert'
-    if (buttonKey === ChangeActions.REVERT_SUBMISSION) {
-      // Revert submission button no longer exists
-      buttonKey = ChangeActions.REVERT;
-    }
-
-    // If the action appears in the overflow menu.
-    if (this._getActionOverflowIndex(type, buttonKey) !== -1) {
-      this.push('_disabledMenuActions', buttonKey === '/' ? 'delete' :
-        buttonKey);
-      return () => {
-        this._actionLoadingMessage = '';
-        this._disabledMenuActions = [];
-      };
-    }
-
-    // Otherwise it's a top-level action.
-    const buttonEl = this.shadowRoot
-        .querySelector(`[data-action-key="${buttonKey}"]`);
-    buttonEl.setAttribute('loading', true);
-    buttonEl.disabled = true;
-    return () => {
-      this._actionLoadingMessage = '';
-      buttonEl.removeAttribute('loading');
-      buttonEl.disabled = false;
-    };
-  }
-
-  /**
-   * @param {string} endpoint
-   * @param {!Object|undefined} action
-   * @param {boolean} revAction
-   * @param {!Object|string=} opt_payload
-   */
-  _fireAction(endpoint, action, revAction, opt_payload) {
-    const cleanupFn =
-        this._setLoadingOnButtonWithKey(action.__type, action.__key);
-
-    this._send(action.method, opt_payload, endpoint, revAction, cleanupFn,
-        action).then(res => this._handleResponse(action, res));
-  }
-
-  _showActionDialog(dialog) {
-    this._hideAllDialogs();
-
-    dialog.hidden = false;
-    this.$.overlay.open().then(() => {
-      if (dialog.resetFocus) {
-        dialog.resetFocus();
-      }
-    });
-  }
-
-  // TODO(rmistry): Redo this after
-  // https://bugs.chromium.org/p/gerrit/issues/detail?id=4671 is resolved.
-  _setLabelValuesOnRevert(newChangeId) {
-    const labels = this.$.jsAPI.getLabelValuesPostRevert(this.change);
-    if (!labels) { return Promise.resolve(); }
-    return this.$.restAPI.saveChangeReview(newChangeId, 'current', {labels});
-  }
-
-  _handleResponse(action, response) {
-    if (!response) { return; }
-    return this.$.restAPI.getResponseObject(response).then(obj => {
-      switch (action.__key) {
-        case ChangeActions.REVERT:
-          this._waitForChangeReachable(obj._number)
-              .then(() => this._setLabelValuesOnRevert(obj._number))
-              .then(() => {
-                GerritNav.navigateToChange(obj);
-              });
-          break;
-        case RevisionActions.CHERRYPICK:
-          this._waitForChangeReachable(obj._number).then(() => {
-            GerritNav.navigateToChange(obj);
-          });
-          break;
-        case ChangeActions.DELETE:
-          if (action.__type === ActionType.CHANGE) {
-            GerritNav.navigateToRelativeUrl(GerritNav.getUrlForRoot());
-          }
-          break;
-        case ChangeActions.WIP:
-        case ChangeActions.DELETE_EDIT:
-        case ChangeActions.PUBLISH_EDIT:
-        case ChangeActions.REBASE_EDIT:
-        case ChangeActions.REBASE:
-        case ChangeActions.SUBMIT:
-          this.dispatchEvent(new CustomEvent('reload',
-              {
-                detail: {clearPatchset: true},
-                bubbles: false,
-                composed: true,
-              }));
-          break;
-        case ChangeActions.REVERT_SUBMISSION:
-          if (!obj.revert_changes || !obj.revert_changes.length) return;
-          /* If there is only 1 change then gerrit will automatically
-             redirect to that change */
-          GerritNav.navigateToSearchQuery('topic: ' +
-              obj.revert_changes[0].topic);
-          break;
-        default:
-          this.dispatchEvent(new CustomEvent('reload',
-              {
-                detail: {action: action.__key, clearPatchset: true},
-                bubbles: false,
-                composed: true,
-              }));
-          break;
-      }
-    });
-  }
-
-  _handleShowRevertSubmissionChangesConfirm() {
-    this._hideAllDialogs();
-  }
-
-  _handleResponseError(action, response, body) {
-    if (action && action.__key === RevisionActions.CHERRYPICK) {
-      if (response && response.status === 409 &&
-          body && !body.allow_conflicts) {
-        return this._showActionDialog(
-            this.$.confirmCherrypickConflict);
-      }
-    }
-    return response.text().then(errText => {
-      this.dispatchEvent(new CustomEvent('show-error', {
-        detail: {message: `Could not perform action: ${errText}`},
-        composed: true, bubbles: true,
-      }));
-      if (!errText.startsWith('Change is already up to date')) {
-        throw Error(errText);
-      }
-    });
-  }
-
-  /**
-   * @param {string} method
-   * @param {string|!Object|undefined} payload
-   * @param {string} actionEndpoint
-   * @param {boolean} revisionAction
-   * @param {?Function} cleanupFn
-   * @param {!Object|undefined} action
-   */
-  _send(method, payload, actionEndpoint, revisionAction, cleanupFn, action) {
-    const handleError = response => {
-      cleanupFn.call(this);
-      this._handleResponseError(action, response, payload);
-    };
-    return fetchChangeUpdates(this.change, this.$.restAPI)
-        .then(result => {
-          if (!result.isLatest) {
-            this.dispatchEvent(new CustomEvent('show-alert', {
-              detail: {
-                message: 'Cannot set label: a newer patch has been ' +
-                  'uploaded to this change.',
-                action: 'Reload',
-                callback: () => {
-                  this.dispatchEvent(new CustomEvent('reload',
-                      {
-                        detail: {clearPatchset: true},
-                        bubbles: false,
-                        composed: true,
-                      }));
-                },
-              },
-              composed: true, bubbles: true,
-            }));
-
-            // Because this is not a network error, call the cleanup function
-            // but not the error handler.
-            cleanupFn();
-
-            return Promise.resolve();
-          }
-          const patchNum = revisionAction ? this.latestPatchNum : null;
-          return this.$.restAPI.executeChangeAction(this.changeNum, method,
-              actionEndpoint, patchNum, payload, handleError)
-              .then(response => {
-                cleanupFn.call(this);
-                return response;
-              });
-        });
-  }
-
-  _handleAbandonTap() {
-    this._showActionDialog(this.$.confirmAbandonDialog);
-  }
-
-  _handleCherrypickTap() {
-    this.$.confirmCherrypick.branch = '';
-    const query = `topic: "${this.change.topic}"`;
-    const options =
-      listChangesOptionsToHex(ListChangesOption.MESSAGES,
-          ListChangesOption.ALL_REVISIONS);
-    this.$.restAPI.getChanges('', query, undefined, options)
-        .then(changes => {
-          this.$.confirmCherrypick.updateChanges(changes);
-          this._showActionDialog(this.$.confirmCherrypick);
-        });
-  }
-
-  _handleMoveTap() {
-    this.$.confirmMove.branch = '';
-    this.$.confirmMove.message = '';
-    this._showActionDialog(this.$.confirmMove);
-  }
-
-  _handleDownloadTap() {
-    this.dispatchEvent(new CustomEvent('download-tap', {
-      composed: true, bubbles: false,
-    }));
-  }
-
-  _handleDeleteTap() {
-    this._showActionDialog(this.$.confirmDeleteDialog);
-  }
-
-  _handleDeleteEditTap() {
-    this._showActionDialog(this.$.confirmDeleteEditDialog);
-  }
-
-  _handleFollowUpTap() {
-    this._showActionDialog(this.$.createFollowUpDialog);
-  }
-
-  _handleWipTap() {
-    this._fireAction('/wip', this.actions.wip, false);
-  }
-
-  _handlePublishEditTap() {
-    // Type of payload is PublishChangeEditInput.
-    const payload = {notify: NotifyType.NONE};
-    this._fireAction('/edit:publish', this.actions.publishEdit, false, payload);
-  }
-
-  _handleRebaseEditTap() {
-    this._fireAction('/edit:rebase', this.actions.rebaseEdit, false);
-  }
-
-  _handleHideBackgroundContent() {
-    this.$.mainContent.classList.add('overlayOpen');
-  }
-
-  _handleShowBackgroundContent() {
-    this.$.mainContent.classList.remove('overlayOpen');
-  }
-
-  /**
-   * Merge sources of change actions into a single ordered array of action
-   * values.
-   *
-   * @param {!Array} changeActionsRecord
-   * @param {!Array} revisionActionsRecord
-   * @param {!Array} primariesRecord
-   * @param {!Array} additionalActionsRecord
-   * @param {!Object} change The change object.
-   * @param {!Object} config server configuration info
-   * @return {!Array}
-   */
-  _computeAllActions(changeActionsRecord, revisionActionsRecord,
-      primariesRecord, additionalActionsRecord, change, config) {
-    // Polymer 2: check for undefined
-    if ([
-      changeActionsRecord,
-      revisionActionsRecord,
-      primariesRecord,
-      additionalActionsRecord,
-      change,
-    ].includes(undefined)) {
-      return [];
-    }
-
-    const revisionActionValues = this._getActionValues(revisionActionsRecord,
-        primariesRecord, additionalActionsRecord, ActionType.REVISION);
-    const changeActionValues = this._getActionValues(changeActionsRecord,
-        primariesRecord, additionalActionsRecord, ActionType.CHANGE);
-    const quickApprove = this._getQuickApproveAction();
-    if (quickApprove) {
-      changeActionValues.unshift(quickApprove);
-    }
-
-    return revisionActionValues
-        .concat(changeActionValues)
-        .sort((a, b) => this._actionComparator(a, b))
-        .map(action => {
-          if (ACTIONS_WITH_ICONS.has(action.__key)) {
-            action.icon = action.__key;
-          }
-          // TODO(brohlfs): Temporary hack until change 269573 is live in all
-          // backends.
-          if (action.__key === ChangeActions.READY) {
-            action.label = 'Mark as Active';
-          }
-          // End of hack
-          return action;
-        })
-        .filter(action => !this._shouldSkipAction(action, config));
-  }
-
-  _getActionPriority(action) {
-    if (action.__type && action.__key) {
-      const overrideAction = this._actionPriorityOverrides
-          .find(i => i.type === action.__type && i.key === action.__key);
-
-      if (overrideAction !== undefined) {
-        return overrideAction.priority;
-      }
-    }
-    if (action.__key === 'review') {
-      return ActionPriority.REVIEW;
-    } else if (action.__primary) {
-      return ActionPriority.PRIMARY;
-    } else if (action.__type === ActionType.CHANGE) {
-      return ActionPriority.CHANGE;
-    } else if (action.__type === ActionType.REVISION) {
-      return ActionPriority.REVISION;
-    }
-    return ActionPriority.DEFAULT;
-  }
-
-  /**
-   * Sort comparator to define the order of change actions.
-   */
-  _actionComparator(actionA, actionB) {
-    const priorityDelta = this._getActionPriority(actionA) -
-        this._getActionPriority(actionB);
-    // Sort by the button label if same priority.
-    if (priorityDelta === 0) {
-      return actionA.label > actionB.label ? 1 : -1;
-    } else {
-      return priorityDelta;
-    }
-  }
-
-  _shouldSkipAction(action, config) {
-    const skipActionKeys = [...SKIP_ACTION_KEYS];
-    const isAttentionSetEnabled = !!config && !!config.change
-        && config.change.enable_attention_set;
-    if (isAttentionSetEnabled) {
-      skipActionKeys.push(...SKIP_ACTION_KEYS_ATTENTION_SET);
-    }
-    return skipActionKeys.includes(action.__key);
-  }
-
-  _computeTopLevelActions(actionRecord, hiddenActionsRecord) {
-    const hiddenActions = hiddenActionsRecord.base || [];
-    return actionRecord.base.filter(a => {
-      const overflow = this._getActionOverflowIndex(a.__type, a.__key) !== -1;
-      return !(overflow || hiddenActions.includes(a.__key));
-    });
-  }
-
-  _filterPrimaryActions(_topLevelActions) {
-    this._topLevelPrimaryActions = _topLevelActions.filter(action =>
-      action.__primary);
-    this._topLevelSecondaryActions = _topLevelActions.filter(action =>
-      !action.__primary);
-  }
-
-  _computeMenuActions(actionRecord, hiddenActionsRecord) {
-    const hiddenActions = hiddenActionsRecord.base || [];
-    return actionRecord.base.filter(a => {
-      const overflow = this._getActionOverflowIndex(a.__type, a.__key) !== -1;
-      return overflow && !hiddenActions.includes(a.__key);
-    }).map(action => {
-      let key = action.__key;
-      if (key === '/') { key = 'delete'; }
-      return {
-        name: action.label,
-        id: `${key}-${action.__type}`,
-        action,
-        tooltip: action.title,
-      };
-    });
-  }
-
-  _computeRebaseOnCurrent(revisionRebaseAction) {
-    if (revisionRebaseAction) {
-      return !!revisionRebaseAction.enabled;
-    }
-    return null;
-  }
-
-  /**
-   * Occasionally, a change created by a change action is not yet knwon to the
-   * API for a brief time. Wait for the given change number to be recognized.
-   *
-   * Returns a promise that resolves with true if a request is recognized, or
-   * false if the change was never recognized after all attempts.
-   *
-   * @param  {number} changeNum
-   * @return {Promise<boolean>}
-   */
-  _waitForChangeReachable(changeNum) {
-    let attempsRemaining = AWAIT_CHANGE_ATTEMPTS;
-    return new Promise(resolve => {
-      const check = () => {
-        attempsRemaining--;
-        // Pass a no-op error handler to avoid the "not found" error toast.
-        this.$.restAPI.getChange(changeNum, () => {}).then(response => {
-          // If the response is 404, the response will be undefined.
-          if (response) {
-            resolve(true);
-            return;
-          }
-
-          if (attempsRemaining) {
-            this.async(check, AWAIT_CHANGE_TIMEOUT_MS);
-          } else {
-            resolve(false);
-          }
-        });
-      };
-      check();
-    });
-  }
-
-  _handleEditTap() {
-    this.dispatchEvent(new CustomEvent('edit-tap', {bubbles: false}));
-  }
-
-  _handleStopEditTap() {
-    this.dispatchEvent(new CustomEvent('stop-edit-tap', {bubbles: false}));
-  }
-
-  _computeHasTooltip(title) {
-    return !!title;
-  }
-
-  _computeHasIcon(action) {
-    return action.icon ? '' : 'hidden';
-  }
-}
-
-customElements.define(GrChangeActions.is, GrChangeActions);
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
new file mode 100644
index 0000000..a9024ef
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts
@@ -0,0 +1,2094 @@
+/**
+ * @license
+ * Copyright (C) 2016 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 '../../admin/gr-create-change-dialog/gr-create-change-dialog';
+import '../../shared/gr-button/gr-button';
+import '../../shared/gr-dialog/gr-dialog';
+import '../../shared/gr-dropdown/gr-dropdown';
+import '../../shared/gr-icons/gr-icons';
+import '../../shared/gr-js-api-interface/gr-js-api-interface';
+import '../../shared/gr-overlay/gr-overlay';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../gr-confirm-abandon-dialog/gr-confirm-abandon-dialog';
+import '../gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog';
+import '../gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog';
+import '../gr-confirm-move-dialog/gr-confirm-move-dialog';
+import '../gr-confirm-rebase-dialog/gr-confirm-rebase-dialog';
+import '../gr-confirm-revert-dialog/gr-confirm-revert-dialog';
+import '../gr-confirm-revert-submission-dialog/gr-confirm-revert-submission-dialog';
+import '../gr-confirm-submit-dialog/gr-confirm-submit-dialog';
+import '../../../styles/shared-styles';
+import {dom, EventApi} 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';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-change-actions_html';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
+import {appContext} from '../../../services/app-context';
+import {
+  fetchChangeUpdates,
+  patchNumEquals,
+} from '../../../utils/patch-set-util';
+import {
+  changeIsOpen,
+  ListChangesOption,
+  listChangesOptionsToHex,
+} from '../../../utils/change-util';
+import {
+  ChangeStatus,
+  DraftsAction,
+  HttpMethod,
+  NotifyType,
+} from '../../../constants/constants';
+import {EventType, 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 {
+  ActionPriority,
+  ActionType,
+  ErrorCallback,
+  RestApiService,
+} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {
+  ActionInfo,
+  ActionNameToActionInfoMap,
+  BranchName,
+  ChangeInfo,
+  ChangeViewChangeInfo,
+  CherryPickInput,
+  CommitId,
+  InheritedBooleanInfo,
+  isDetailedLabelInfo,
+  isQuickLabelInfo,
+  LabelInfo,
+  NumericChangeId,
+  PatchSetNum,
+  PropertyType,
+  RequestPayload,
+  RevertSubmissionInfo,
+  ReviewInput,
+  ServerInfo,
+} from '../../../types/common';
+import {GrConfirmAbandonDialog} from '../gr-confirm-abandon-dialog/gr-confirm-abandon-dialog';
+import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
+import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
+import {GrCreateChangeDialog} from '../../admin/gr-create-change-dialog/gr-create-change-dialog';
+import {GrConfirmSubmitDialog} from '../gr-confirm-submit-dialog/gr-confirm-submit-dialog';
+import {GrConfirmRevertSubmissionDialog} from '../gr-confirm-revert-submission-dialog/gr-confirm-revert-submission-dialog';
+import {
+  ConfirmRevertEventDetail,
+  GrConfirmRevertDialog,
+  RevertType,
+} from '../gr-confirm-revert-dialog/gr-confirm-revert-dialog';
+import {GrConfirmMoveDialog} from '../gr-confirm-move-dialog/gr-confirm-move-dialog';
+import {GrConfirmCherrypickDialog} from '../gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog';
+import {GrConfirmCherrypickConflictDialog} from '../gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog';
+import {
+  ConfirmRebaseEventDetail,
+  GrConfirmRebaseDialog,
+} from '../gr-confirm-rebase-dialog/gr-confirm-rebase-dialog';
+import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
+import {GrButton} from '../../shared/gr-button/gr-button';
+import {
+  ChangeActions,
+  GrChangeActionsElement,
+  PrimaryActionKey,
+  RevisionActions,
+  UIActionInfo,
+} from '../../shared/gr-js-api-interface/gr-change-actions-js-api';
+
+const ERR_BRANCH_EMPTY = 'The destination branch can’t be empty.';
+const ERR_COMMIT_EMPTY = 'The commit message can’t be empty.';
+const ERR_REVISION_ACTIONS = 'Couldn’t load revision actions.';
+
+enum LabelStatus {
+  /**
+   * This label provides what is necessary for submission.
+   */
+  OK = 'OK',
+  /**
+   * This label prevents the change from being submitted.
+   */
+  REJECT = 'REJECT',
+  /**
+   * The label may be set, but it's neither necessary for submission
+   * nor does it block submission if set.
+   */
+  MAY = 'MAY',
+  /**
+   * The label is required for submission, but has not been satisfied.
+   */
+  NEED = 'NEED',
+  /**
+   * The label is required for submission, but is impossible to complete.
+   * The likely cause is access has not been granted correctly by the
+   * project owner or site administrator.
+   */
+  IMPOSSIBLE = 'IMPOSSIBLE',
+  OPTIONAL = 'OPTIONAL',
+}
+
+const ActionLoadingLabels: {[actionKey: string]: string} = {
+  abandon: 'Abandoning...',
+  cherrypick: 'Cherry-picking...',
+  delete: 'Deleting...',
+  move: 'Moving..',
+  rebase: 'Rebasing...',
+  restore: 'Restoring...',
+  revert: 'Reverting...',
+  revert_submission: 'Reverting Submission...',
+  submit: 'Submitting...',
+};
+
+const ADDITIONAL_ACTION_KEY_PREFIX = '__additionalAction_';
+
+interface QuickApproveUIActionInfo extends UIActionInfo {
+  key: string;
+  payload?: RequestPayload;
+}
+
+const QUICK_APPROVE_ACTION: QuickApproveUIActionInfo = {
+  __key: 'review',
+  __type: ActionType.CHANGE,
+  enabled: true,
+  key: 'review',
+  label: 'Quick approve',
+  method: HttpMethod.POST,
+};
+
+function isQuckApproveAction(
+  action: UIActionInfo
+): action is QuickApproveUIActionInfo {
+  return (action as QuickApproveUIActionInfo).key === QUICK_APPROVE_ACTION.key;
+}
+
+const DOWNLOAD_ACTION: UIActionInfo = {
+  enabled: true,
+  label: 'Download patch',
+  title: 'Open download dialog',
+  __key: 'download',
+  __primary: false,
+  __type: ActionType.REVISION,
+};
+
+const REBASE_EDIT: UIActionInfo = {
+  enabled: true,
+  label: 'Rebase edit',
+  title: 'Rebase change edit',
+  __key: 'rebaseEdit',
+  __primary: false,
+  __type: ActionType.CHANGE,
+  method: HttpMethod.POST,
+};
+
+const PUBLISH_EDIT: UIActionInfo = {
+  enabled: true,
+  label: 'Publish edit',
+  title: 'Publish change edit',
+  __key: 'publishEdit',
+  __primary: false,
+  __type: ActionType.CHANGE,
+  method: HttpMethod.POST,
+};
+
+const DELETE_EDIT: UIActionInfo = {
+  enabled: true,
+  label: 'Delete edit',
+  title: 'Delete change edit',
+  __key: 'deleteEdit',
+  __primary: false,
+  __type: ActionType.CHANGE,
+  method: HttpMethod.DELETE,
+};
+
+const EDIT: UIActionInfo = {
+  enabled: true,
+  label: 'Edit',
+  title: 'Edit this change',
+  __key: 'edit',
+  __primary: false,
+  __type: ActionType.CHANGE,
+};
+
+const STOP_EDIT: UIActionInfo = {
+  enabled: true,
+  label: 'Stop editing',
+  title: 'Stop editing this change',
+  __key: 'stopEdit',
+  __primary: false,
+  __type: ActionType.CHANGE,
+};
+
+// Set of keys that have icons. As more icons are added to gr-icons.html, this
+// set should be expanded.
+const ACTIONS_WITH_ICONS = new Set([
+  ChangeActions.ABANDON,
+  ChangeActions.DELETE_EDIT,
+  ChangeActions.EDIT,
+  ChangeActions.PUBLISH_EDIT,
+  ChangeActions.READY,
+  ChangeActions.REBASE_EDIT,
+  ChangeActions.RESTORE,
+  ChangeActions.REVERT,
+  ChangeActions.REVERT_SUBMISSION,
+  ChangeActions.STOP_EDIT,
+  QUICK_APPROVE_ACTION.key,
+  RevisionActions.REBASE,
+  RevisionActions.SUBMIT,
+]);
+
+const AWAIT_CHANGE_ATTEMPTS = 5;
+const AWAIT_CHANGE_TIMEOUT_MS = 1000;
+
+/* Revert submission is skipped as the normal revert dialog will now show
+the user a choice between reverting single change or an entire submission.
+Hence, a second button is not needed.
+*/
+const SKIP_ACTION_KEYS = [ChangeActions.REVERT_SUBMISSION];
+
+const SKIP_ACTION_KEYS_ATTENTION_SET = [
+  ChangeActions.REVIEWED,
+  ChangeActions.UNREVIEWED,
+];
+
+function assertUIActionInfo(action?: ActionInfo): UIActionInfo {
+  // TODO(TS): Remove this function. The gr-change-actions adds properties
+  // to existing ActionInfo objects instead of creating a new objects. This
+  // function checks, that 'action' has all property required by UIActionInfo.
+  // In the future, we should avoid updates of an existing ActionInfos and
+  // instead create a new object to make code cleaner. However, at the current
+  // state this is unsafe, because other code can expect these properties to be
+  // set in ActionInfo.
+  if (!action) {
+    throw new Error('action is undefined');
+  }
+  const result = action as UIActionInfo;
+  if (result.__key === undefined || result.__type === undefined) {
+    throw new Error('action is not an UIActionInfo');
+  }
+  return result;
+}
+
+interface MenuAction {
+  name: string;
+  id: string;
+  action: UIActionInfo;
+  tooltip?: string;
+}
+
+interface OverflowAction {
+  type: ActionType;
+  key: string;
+  overflow?: boolean;
+}
+
+interface ActionPriorityOverride {
+  type: ActionType.CHANGE | ActionType.REVISION;
+  key: string;
+  priority: ActionPriority;
+}
+
+interface ChangeActionDialog extends HTMLElement {
+  resetFocus?(): void;
+}
+
+export interface GrChangeActions {
+  $: {
+    jsAPI: GrJsApiInterface;
+    restAPI: RestApiService & Element;
+    mainContent: Element;
+    overlay: GrOverlay;
+    confirmRebase: GrConfirmRebaseDialog;
+    confirmCherrypick: GrConfirmCherrypickDialog;
+    confirmCherrypickConflict: GrConfirmCherrypickConflictDialog;
+    confirmMove: GrConfirmMoveDialog;
+    confirmRevertDialog: GrConfirmRevertDialog;
+    confirmRevertSubmissionDialog: GrConfirmRevertSubmissionDialog;
+    confirmAbandonDialog: GrConfirmAbandonDialog;
+    confirmSubmitDialog: GrConfirmSubmitDialog;
+    createFollowUpDialog: GrDialog;
+    createFollowUpChange: GrCreateChangeDialog;
+    confirmDeleteDialog: GrDialog;
+    confirmDeleteEditDialog: GrDialog;
+  };
+}
+
+@customElement('gr-change-actions')
+export class GrChangeActions
+  extends GestureEventListeners(LegacyElementMixin(PolymerElement))
+  implements GrChangeActionsElement {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  /**
+   * Fired when the change should be reloaded.
+   *
+   * @event reload
+   */
+
+  /**
+   * Fired when an action is tapped.
+   *
+   * @event custom-tap - naming pattern: <action key>-tap
+   */
+
+  /**
+   * Fires to show an alert when a send is attempted on the non-latest patch.
+   *
+   * @event show-alert
+   */
+
+  /**
+   * Fires when a change action fails.
+   *
+   * @event show-error
+   */
+
+  // TODO(TS): Ensure that ActionType, ChangeActions and RevisionActions
+  // properties are replaced with enums everywhere and remove them from
+  // the GrChangeActions class
+  ActionType = ActionType;
+
+  ChangeActions = ChangeActions;
+
+  RevisionActions = RevisionActions;
+
+  reporting = appContext.reportingService;
+
+  @property({type: Object})
+  change?: ChangeViewChangeInfo;
+
+  @property({type: Object})
+  actions: ActionNameToActionInfoMap = {};
+
+  @property({type: Array})
+  primaryActionKeys: PrimaryActionKey[] = [
+    ChangeActions.READY,
+    RevisionActions.SUBMIT,
+  ];
+
+  @property({type: Boolean})
+  disableEdit = false;
+
+  @property({type: Boolean})
+  _hasKnownChainState = false;
+
+  @property({type: Boolean})
+  _hideQuickApproveAction = false;
+
+  @property({type: String})
+  changeNum?: NumericChangeId;
+
+  @property({type: String})
+  changeStatus?: ChangeStatus;
+
+  @property({type: String})
+  commitNum?: CommitId;
+
+  @property({type: Boolean, observer: '_computeChainState'})
+  hasParent?: boolean;
+
+  @property({type: String})
+  latestPatchNum?: PatchSetNum;
+
+  @property({type: String})
+  commitMessage = '';
+
+  @property({type: Object, notify: true})
+  revisionActions: ActionNameToActionInfoMap = {};
+
+  @property({type: Object, computed: '_getSubmitAction(revisionActions)'})
+  _revisionSubmitAction?: ActionInfo | null;
+
+  @property({type: Object, computed: '_getRebaseAction(revisionActions)'})
+  _revisionRebaseAction?: ActionInfo | null;
+
+  @property({type: String})
+  privateByDefault?: InheritedBooleanInfo;
+
+  @property({type: Boolean})
+  _loading = true;
+
+  @property({type: String})
+  _actionLoadingMessage = '';
+
+  @property({
+    type: Array,
+    computed:
+      '_computeAllActions(actions.*, revisionActions.*,' +
+      'primaryActionKeys.*, _additionalActions.*, change, ' +
+      '_config, _actionPriorityOverrides.*)',
+  })
+  _allActionValues: UIActionInfo[] = []; // _computeAllActions always returns an array
+
+  @property({
+    type: Array,
+    computed:
+      '_computeTopLevelActions(_allActionValues.*, ' +
+      '_hiddenActions.*, _overflowActions.*)',
+    observer: '_filterPrimaryActions',
+  })
+  _topLevelActions?: UIActionInfo[];
+
+  @property({type: Array})
+  _topLevelPrimaryActions?: UIActionInfo[];
+
+  @property({type: Array})
+  _topLevelSecondaryActions?: UIActionInfo[];
+
+  @property({
+    type: Array,
+    computed:
+      '_computeMenuActions(_allActionValues.*, ' +
+      '_hiddenActions.*, _overflowActions.*)',
+  })
+  _menuActions?: MenuAction[];
+
+  @property({type: Array})
+  _overflowActions: OverflowAction[] = [
+    {
+      type: ActionType.CHANGE,
+      key: ChangeActions.WIP,
+    },
+    {
+      type: ActionType.CHANGE,
+      key: ChangeActions.DELETE,
+    },
+    {
+      type: ActionType.REVISION,
+      key: RevisionActions.CHERRYPICK,
+    },
+    {
+      type: ActionType.CHANGE,
+      key: ChangeActions.MOVE,
+    },
+    {
+      type: ActionType.REVISION,
+      key: RevisionActions.DOWNLOAD,
+    },
+    {
+      type: ActionType.CHANGE,
+      key: ChangeActions.IGNORE,
+    },
+    {
+      type: ActionType.CHANGE,
+      key: ChangeActions.UNIGNORE,
+    },
+    {
+      type: ActionType.CHANGE,
+      key: ChangeActions.REVIEWED,
+    },
+    {
+      type: ActionType.CHANGE,
+      key: ChangeActions.UNREVIEWED,
+    },
+    {
+      type: ActionType.CHANGE,
+      key: ChangeActions.PRIVATE,
+    },
+    {
+      type: ActionType.CHANGE,
+      key: ChangeActions.PRIVATE_DELETE,
+    },
+    {
+      type: ActionType.CHANGE,
+      key: ChangeActions.FOLLOW_UP,
+    },
+  ];
+
+  @property({type: Array})
+  _actionPriorityOverrides: ActionPriorityOverride[] = [];
+
+  @property({type: Array})
+  _additionalActions: UIActionInfo[] = [];
+
+  @property({type: Array})
+  _hiddenActions: string[] = [];
+
+  @property({type: Array})
+  _disabledMenuActions: string[] = [];
+
+  @property({type: Boolean})
+  editPatchsetLoaded = false;
+
+  @property({type: Boolean})
+  editMode = false;
+
+  @property({type: Boolean})
+  editBasedOnCurrentPatchSet = true;
+
+  @property({type: Object})
+  _config?: ServerInfo;
+
+  /** @override */
+  created() {
+    super.created();
+    this.addEventListener('fullscreen-overlay-opened', () =>
+      this._handleHideBackgroundContent()
+    );
+    this.addEventListener('fullscreen-overlay-closed', () =>
+      this._handleShowBackgroundContent()
+    );
+  }
+
+  /** @override */
+  ready() {
+    super.ready();
+    this.$.jsAPI.addElement(TargetElement.CHANGE_ACTIONS, this);
+    this.$.restAPI.getConfig().then(config => {
+      this._config = config;
+    });
+    this._handleLoadingComplete();
+  }
+
+  _getSubmitAction(revisionActions: ActionNameToActionInfoMap) {
+    return this._getRevisionAction(revisionActions, 'submit');
+  }
+
+  _getRebaseAction(revisionActions: ActionNameToActionInfoMap) {
+    return this._getRevisionAction(revisionActions, 'rebase');
+  }
+
+  _getRevisionAction(
+    revisionActions: ActionNameToActionInfoMap,
+    actionName: string
+  ) {
+    if (!revisionActions) {
+      return undefined;
+    }
+    if (revisionActions[actionName] === undefined) {
+      // Return null to fire an event when reveisionActions was loaded
+      // but doesn't contain actionName. undefined doesn't fire an event
+      return null;
+    }
+    return revisionActions[actionName];
+  }
+
+  reload() {
+    if (!this.changeNum || !this.latestPatchNum || !this.change) {
+      return Promise.resolve();
+    }
+    const change = this.change;
+
+    this._loading = true;
+    return this.$.restAPI
+      .getChangeRevisionActions(this.changeNum, this.latestPatchNum)
+      .then(revisionActions => {
+        if (!revisionActions) {
+          return;
+        }
+
+        this.revisionActions = revisionActions;
+        this._sendShowRevisionActions({
+          change,
+          revisionActions,
+        });
+        this._handleLoadingComplete();
+      })
+      .catch(err => {
+        this.dispatchEvent(
+          new CustomEvent('show-alert', {
+            detail: {message: ERR_REVISION_ACTIONS},
+            composed: true,
+            bubbles: true,
+          })
+        );
+        this._loading = false;
+        throw err;
+      });
+  }
+
+  _handleLoadingComplete() {
+    getPluginLoader()
+      .awaitPluginsLoaded()
+      .then(() => (this._loading = false));
+  }
+
+  _sendShowRevisionActions(detail: {
+    change: ChangeInfo;
+    revisionActions: ActionNameToActionInfoMap;
+  }) {
+    this.$.jsAPI.handleEvent(EventType.SHOW_REVISION_ACTIONS, detail);
+  }
+
+  @observe('change')
+  _changeChanged() {
+    this.reload();
+  }
+
+  addActionButton(type: ActionType, label: string) {
+    if (type !== ActionType.CHANGE && type !== ActionType.REVISION) {
+      throw Error(`Invalid action type: ${type}`);
+    }
+    const action: UIActionInfo = {
+      enabled: true,
+      label,
+      __type: type,
+      __key:
+        ADDITIONAL_ACTION_KEY_PREFIX + Math.random().toString(36).substr(2),
+    };
+    this.push('_additionalActions', action);
+    return action.__key;
+  }
+
+  removeActionButton(key: string) {
+    const idx = this._indexOfActionButtonWithKey(key);
+    if (idx === -1) {
+      return;
+    }
+    this.splice('_additionalActions', idx, 1);
+  }
+
+  setActionButtonProp<T extends keyof UIActionInfo>(
+    key: string,
+    prop: T,
+    value: UIActionInfo[T]
+  ) {
+    this.set(
+      ['_additionalActions', this._indexOfActionButtonWithKey(key), prop],
+      value
+    );
+  }
+
+  setActionOverflow(type: ActionType, key: string, overflow: boolean) {
+    if (type !== ActionType.CHANGE && type !== ActionType.REVISION) {
+      throw Error(`Invalid action type given: ${type}`);
+    }
+    const index = this._getActionOverflowIndex(type, key);
+    const action: OverflowAction = {
+      type,
+      key,
+      overflow,
+    };
+    if (!overflow && index !== -1) {
+      this.splice('_overflowActions', index, 1);
+    } else if (overflow) {
+      this.push('_overflowActions', action);
+    }
+  }
+
+  setActionPriority(
+    type: ActionType.CHANGE | ActionType.REVISION,
+    key: string,
+    priority: ActionPriority
+  ) {
+    if (type !== ActionType.CHANGE && type !== ActionType.REVISION) {
+      throw Error(`Invalid action type given: ${type}`);
+    }
+    const index = this._actionPriorityOverrides.findIndex(
+      action => action.type === type && action.key === key
+    );
+    const action: ActionPriorityOverride = {
+      type,
+      key,
+      priority,
+    };
+    if (index !== -1) {
+      this.set('_actionPriorityOverrides', index, action);
+    } else {
+      this.push('_actionPriorityOverrides', action);
+    }
+  }
+
+  setActionHidden(
+    type: ActionType.CHANGE | ActionType.REVISION,
+    key: string,
+    hidden: boolean
+  ) {
+    if (type !== ActionType.CHANGE && type !== ActionType.REVISION) {
+      throw Error(`Invalid action type given: ${type}`);
+    }
+
+    const idx = this._hiddenActions.indexOf(key);
+    if (hidden && idx === -1) {
+      this.push('_hiddenActions', key);
+    } else if (!hidden && idx !== -1) {
+      this.splice('_hiddenActions', idx, 1);
+    }
+  }
+
+  getActionDetails(actionName: string) {
+    if (this.revisionActions[actionName]) {
+      return this.revisionActions[actionName];
+    } else if (this.actions[actionName]) {
+      return this.actions[actionName];
+    } else {
+      return undefined;
+    }
+  }
+
+  _indexOfActionButtonWithKey(key: string) {
+    for (let i = 0; i < this._additionalActions.length; i++) {
+      if (this._additionalActions[i].__key === key) {
+        return i;
+      }
+    }
+    return -1;
+  }
+
+  _shouldHideActions(
+    actions?: PolymerDeepPropertyChange<UIActionInfo[], UIActionInfo[]>,
+    loading?: boolean
+  ) {
+    return loading || !actions || !actions.base || !actions.base.length;
+  }
+
+  _keyCount(
+    changeRecord?: PolymerDeepPropertyChange<
+      ActionNameToActionInfoMap,
+      ActionNameToActionInfoMap
+    >
+  ) {
+    return Object.keys(changeRecord?.base || {}).length;
+  }
+
+  @observe('actions.*', 'revisionActions.*', '_additionalActions.*')
+  _actionsChanged(
+    actionsChangeRecord?: PolymerDeepPropertyChange<
+      ActionNameToActionInfoMap,
+      ActionNameToActionInfoMap
+    >,
+    revisionActionsChangeRecord?: PolymerDeepPropertyChange<
+      ActionNameToActionInfoMap,
+      ActionNameToActionInfoMap
+    >,
+    additionalActionsChangeRecord?: PolymerDeepPropertyChange<
+      UIActionInfo[],
+      UIActionInfo[]
+    >
+  ) {
+    // Polymer 2: check for undefined
+    if (
+      actionsChangeRecord === undefined ||
+      revisionActionsChangeRecord === undefined ||
+      additionalActionsChangeRecord === undefined
+    ) {
+      return;
+    }
+
+    const additionalActions =
+      (additionalActionsChangeRecord && additionalActionsChangeRecord.base) ||
+      [];
+    this.hidden =
+      this._keyCount(actionsChangeRecord) === 0 &&
+      this._keyCount(revisionActionsChangeRecord) === 0 &&
+      additionalActions.length === 0;
+    this._actionLoadingMessage = '';
+    this._actionLoadingMessage = '';
+    this._disabledMenuActions = [];
+
+    const revisionActions = revisionActionsChangeRecord.base || {};
+    if (Object.keys(revisionActions).length !== 0) {
+      if (!revisionActions.download) {
+        this.set('revisionActions.download', DOWNLOAD_ACTION);
+      }
+    }
+  }
+
+  _deleteAndNotify(actionName: string) {
+    if (this.actions && this.actions[actionName]) {
+      delete this.actions[actionName];
+      // We assign a fake value of 'false' to support Polymer 2
+      // see https://github.com/Polymer/polymer/issues/2631
+      this.notifyPath('actions.' + actionName, false);
+    }
+  }
+
+  @observe(
+    'editMode',
+    'editPatchsetLoaded',
+    'editBasedOnCurrentPatchSet',
+    'disableEdit',
+    'actions.*',
+    'change.*'
+  )
+  _editStatusChanged(
+    editMode: boolean,
+    editPatchsetLoaded: boolean,
+    editBasedOnCurrentPatchSet: boolean,
+    disableEdit: boolean,
+    actionsChangeRecord?: PolymerDeepPropertyChange<
+      ActionNameToActionInfoMap,
+      ActionNameToActionInfoMap
+    >,
+    changeChangeRecord?: PolymerDeepPropertyChange<ChangeInfo, ChangeInfo>
+  ) {
+    if (actionsChangeRecord === undefined || changeChangeRecord === undefined) {
+      return;
+    }
+    if (disableEdit) {
+      this._deleteAndNotify('publishEdit');
+      this._deleteAndNotify('rebaseEdit');
+      this._deleteAndNotify('deleteEdit');
+      this._deleteAndNotify('stopEdit');
+      this._deleteAndNotify('edit');
+      return;
+    }
+    const actions = actionsChangeRecord.base;
+    const change = changeChangeRecord.base;
+    if (actions && editPatchsetLoaded) {
+      // Only show actions that mutate an edit if an actual edit patch set
+      // is loaded.
+      if (changeIsOpen(change)) {
+        if (editBasedOnCurrentPatchSet) {
+          if (!actions.publishEdit) {
+            this.set('actions.publishEdit', PUBLISH_EDIT);
+          }
+          this._deleteAndNotify('rebaseEdit');
+        } else {
+          if (!actions.rebaseEdit) {
+            this.set('actions.rebaseEdit', REBASE_EDIT);
+          }
+          this._deleteAndNotify('publishEdit');
+        }
+      }
+      if (!actions.deleteEdit) {
+        this.set('actions.deleteEdit', DELETE_EDIT);
+      }
+    } else {
+      this._deleteAndNotify('publishEdit');
+      this._deleteAndNotify('rebaseEdit');
+      this._deleteAndNotify('deleteEdit');
+    }
+
+    if (actions && changeIsOpen(change)) {
+      // Only show edit button if there is no edit patchset loaded and the
+      // file list is not in edit mode.
+      if (editPatchsetLoaded || editMode) {
+        this._deleteAndNotify('edit');
+      } else {
+        if (!actions.edit) {
+          this.set('actions.edit', EDIT);
+        }
+      }
+      // Only show STOP_EDIT if edit mode is enabled, but no edit patch set
+      // is loaded.
+      if (editMode && !editPatchsetLoaded) {
+        if (!actions.stopEdit) {
+          this.set('actions.stopEdit', STOP_EDIT);
+        }
+      } else {
+        this._deleteAndNotify('stopEdit');
+      }
+    } else {
+      // Remove edit button.
+      this._deleteAndNotify('edit');
+    }
+  }
+
+  _getValuesFor<T>(obj: {[key: string]: T}): T[] {
+    return Object.keys(obj).map(key => obj[key]);
+  }
+
+  _getLabelStatus(label: LabelInfo): LabelStatus {
+    if (isQuickLabelInfo(label)) {
+      if (label.approved) {
+        return LabelStatus.OK;
+      } else if (label.rejected) {
+        return LabelStatus.REJECT;
+      }
+    }
+    if (label.optional) {
+      return LabelStatus.OPTIONAL;
+    } else {
+      return LabelStatus.NEED;
+    }
+  }
+
+  /**
+   * Get highest score for last missing permitted label for current change.
+   * Returns null if no labels permitted or more than one label missing.
+   */
+  _getTopMissingApproval() {
+    if (!this.change || !this.change.labels || !this.change.permitted_labels) {
+      return null;
+    }
+    let result;
+    for (const label in this.change.labels) {
+      if (!(label in this.change.permitted_labels)) {
+        continue;
+      }
+      if (this.change.permitted_labels[label].length === 0) {
+        continue;
+      }
+      const status = this._getLabelStatus(this.change.labels[label]);
+      if (status === LabelStatus.NEED) {
+        if (result) {
+          // More than one label is missing, so it's unclear which to quick
+          // approve, return null;
+          return null;
+        }
+        result = label;
+      } else if (
+        status === LabelStatus.REJECT ||
+        status === LabelStatus.IMPOSSIBLE
+      ) {
+        return null;
+      }
+    }
+    if (result) {
+      const score = this.change.permitted_labels[result].slice(-1)[0];
+      const labelInfo = this.change.labels[result];
+      if (!isDetailedLabelInfo(labelInfo)) {
+        return null;
+      }
+      const maxScore = Object.keys(labelInfo.values).slice(-1)[0];
+      if (score === maxScore) {
+        // Allow quick approve only for maximal score.
+        return {
+          label: result,
+          score,
+        };
+      }
+    }
+    return null;
+  }
+
+  hideQuickApproveAction() {
+    if (!this._topLevelSecondaryActions) {
+      throw new Error('_topLevelSecondaryActions must be set');
+    }
+    this._topLevelSecondaryActions = this._topLevelSecondaryActions.filter(
+      sa => !isQuckApproveAction(sa)
+    );
+    this._hideQuickApproveAction = true;
+  }
+
+  _getQuickApproveAction(): QuickApproveUIActionInfo | null {
+    if (this._hideQuickApproveAction) {
+      return null;
+    }
+    const approval = this._getTopMissingApproval();
+    if (!approval) {
+      return null;
+    }
+    const action = {...QUICK_APPROVE_ACTION};
+    action.label = approval.label + approval.score;
+
+    const score = Number(approval.score);
+    if (isNaN(score)) {
+      return null;
+    }
+
+    const review: ReviewInput = {
+      drafts: DraftsAction.PUBLISH_ALL_REVISIONS,
+      labels: {
+        [approval.label]: score,
+      },
+    };
+    action.payload = review;
+    return action;
+  }
+
+  _getActionValues(
+    actionsChangeRecord: PolymerDeepPropertyChange<
+      ActionNameToActionInfoMap,
+      ActionNameToActionInfoMap
+    >,
+    primariesChangeRecord: PolymerDeepPropertyChange<
+      PrimaryActionKey[],
+      PrimaryActionKey[]
+    >,
+    additionalActionsChangeRecord: PolymerDeepPropertyChange<
+      UIActionInfo[],
+      UIActionInfo[]
+    >,
+    type: ActionType
+  ): UIActionInfo[] {
+    if (!actionsChangeRecord || !primariesChangeRecord) {
+      return [];
+    }
+
+    const actions = actionsChangeRecord.base || {};
+    const primaryActionKeys = primariesChangeRecord.base || [];
+    const result: UIActionInfo[] = [];
+    const values: Array<ChangeActions | RevisionActions> =
+      type === ActionType.CHANGE
+        ? this._getValuesFor(ChangeActions)
+        : this._getValuesFor(RevisionActions);
+
+    const pluginActions: UIActionInfo[] = [];
+    Object.keys(actions).forEach(a => {
+      const action: UIActionInfo = actions[a] as UIActionInfo;
+      action.__key = a;
+      action.__type = type;
+      action.__primary = primaryActionKeys.includes(a as PrimaryActionKey);
+      // Plugin actions always contain ~ in the key.
+      if (a.indexOf('~') !== -1) {
+        this._populateActionUrl(action);
+        pluginActions.push(action);
+        // Add server-side provided plugin actions to overflow menu.
+        this._overflowActions.push({
+          type,
+          key: a,
+        });
+        return;
+      } else if (!values.includes(a as PrimaryActionKey)) {
+        return;
+      }
+      action.label = this._getActionLabel(action);
+
+      // Triggers a re-render by ensuring object inequality.
+      result.push({...action});
+    });
+
+    let additionalActions =
+      (additionalActionsChangeRecord && additionalActionsChangeRecord.base) ||
+      [];
+    additionalActions = additionalActions
+      .filter(a => a.__type === type)
+      .map(a => {
+        a.__primary = primaryActionKeys.includes(a.__key as PrimaryActionKey);
+        // Triggers a re-render by ensuring object inequality.
+        return {...a};
+      });
+    return result.concat(additionalActions).concat(pluginActions);
+  }
+
+  _populateActionUrl(action: UIActionInfo) {
+    const patchNum =
+      action.__type === ActionType.REVISION ? this.latestPatchNum : undefined;
+    if (!this.changeNum) {
+      return;
+    }
+    this.$.restAPI
+      .getChangeActionURL(this.changeNum, patchNum, '/' + action.__key)
+      .then(url => (action.__url = url));
+  }
+
+  /**
+   * Given a change action, return a display label that uses the appropriate
+   * casing or includes explanatory details.
+   */
+  _getActionLabel(action: UIActionInfo) {
+    if (action.label === 'Delete') {
+      // This label is common within change and revision actions. Make it more
+      // explicit to the user.
+      return 'Delete change';
+    } else if (action.label === 'WIP') {
+      return 'Mark as work in progress';
+    }
+    // Otherwise, just map the name to sentence case.
+    return this._toSentenceCase(action.label);
+  }
+
+  /**
+   * Capitalize the first letter and lowecase all others.
+   */
+  _toSentenceCase(s: string) {
+    if (!s.length) {
+      return '';
+    }
+    return s[0].toUpperCase() + s.slice(1).toLowerCase();
+  }
+
+  _computeLoadingLabel(action: string) {
+    return ActionLoadingLabels[action] || 'Working...';
+  }
+
+  _canSubmitChange() {
+    if (!this.change) {
+      return false;
+    }
+    return this.$.jsAPI.canSubmitChange(
+      this.change,
+      this._getRevision(this.change, this.latestPatchNum)
+    );
+  }
+
+  _getRevision(change: ChangeViewChangeInfo, patchNum?: PatchSetNum) {
+    for (const rev of Object.values(change.revisions)) {
+      if (patchNumEquals(rev._number, patchNum)) {
+        return rev;
+      }
+    }
+    return null;
+  }
+
+  showRevertDialog() {
+    const change = this.change;
+    if (!change) return;
+    // The search is still broken if there is a " in the topic.
+    const query = `submissionid: "${change.submission_id}"`;
+    /* A chromium plugin expects that the modifyRevertMsg hook will only
+    be called after the revert button is pressed, hence we populate the
+    revert dialog after revert button is pressed. */
+    this.$.restAPI.getChanges(0, query).then(changes => {
+      if (!changes) {
+        console.error('changes is undefined');
+        return;
+      }
+      this.$.confirmRevertDialog.populate(change, this.commitMessage, changes);
+      this._showActionDialog(this.$.confirmRevertDialog);
+    });
+  }
+
+  showRevertSubmissionDialog() {
+    const change = this.change;
+    if (!change) return;
+    const query = `submissionid:${change.submission_id}`;
+    this.$.restAPI.getChanges(0, query).then(changes => {
+      if (!changes) {
+        console.error('changes is undefined');
+        return;
+      }
+      this.$.confirmRevertSubmissionDialog._populateRevertSubmissionMessage(
+        change,
+        changes
+      );
+      this._showActionDialog(this.$.confirmRevertSubmissionDialog);
+    });
+  }
+
+  _handleActionTap(e: MouseEvent) {
+    e.preventDefault();
+    let el = (dom(e) as EventApi).localTarget as Element;
+    while (el.tagName.toLowerCase() !== 'gr-button') {
+      if (!el.parentElement) {
+        return;
+      }
+      el = el.parentElement;
+    }
+
+    const key = el.getAttribute('data-action-key');
+    if (!key) {
+      throw new Error("Button doesn't have data-action-key attribute");
+    }
+    if (
+      key.startsWith(ADDITIONAL_ACTION_KEY_PREFIX) ||
+      key.indexOf('~') !== -1
+    ) {
+      this.dispatchEvent(
+        new CustomEvent(`${key}-tap`, {
+          detail: {node: el},
+          composed: true,
+          bubbles: true,
+        })
+      );
+      return;
+    }
+    const type = el.getAttribute('data-action-type') as ActionType;
+    this._handleAction(type, key);
+  }
+
+  _handleOverflowItemTap(e: CustomEvent<MenuAction>) {
+    e.preventDefault();
+    const el = (dom(e) as EventApi).localTarget as Element;
+    const key = e.detail.action.__key;
+    if (
+      key.startsWith(ADDITIONAL_ACTION_KEY_PREFIX) ||
+      key.indexOf('~') !== -1
+    ) {
+      this.dispatchEvent(
+        new CustomEvent(`${key}-tap`, {
+          detail: {node: el},
+          composed: true,
+          bubbles: true,
+        })
+      );
+      return;
+    }
+    this._handleAction(e.detail.action.__type, e.detail.action.__key);
+  }
+
+  _handleAction(type: ActionType, key: string) {
+    this.reporting.reportInteraction(`${type}-${key}`);
+    switch (type) {
+      case ActionType.REVISION:
+        this._handleRevisionAction(key);
+        break;
+      case ActionType.CHANGE:
+        this._handleChangeAction(key);
+        break;
+      default:
+        this._fireAction(
+          this._prependSlash(key),
+          assertUIActionInfo(this.actions[key]),
+          false
+        );
+    }
+  }
+
+  _handleChangeAction(key: string) {
+    switch (key) {
+      case ChangeActions.REVERT:
+        this.showRevertDialog();
+        break;
+      case ChangeActions.REVERT_SUBMISSION:
+        this.showRevertSubmissionDialog();
+        break;
+      case ChangeActions.ABANDON:
+        this._showActionDialog(this.$.confirmAbandonDialog);
+        break;
+      case QUICK_APPROVE_ACTION.key: {
+        const action = this._allActionValues.find(isQuckApproveAction);
+        if (!action) {
+          return;
+        }
+        this._fireAction(this._prependSlash(key), action, true, action.payload);
+        break;
+      }
+      case ChangeActions.EDIT:
+        this._handleEditTap();
+        break;
+      case ChangeActions.STOP_EDIT:
+        this._handleStopEditTap();
+        break;
+      case ChangeActions.DELETE:
+        this._handleDeleteTap();
+        break;
+      case ChangeActions.DELETE_EDIT:
+        this._handleDeleteEditTap();
+        break;
+      case ChangeActions.FOLLOW_UP:
+        this._handleFollowUpTap();
+        break;
+      case ChangeActions.WIP:
+        this._handleWipTap();
+        break;
+      case ChangeActions.MOVE:
+        this._handleMoveTap();
+        break;
+      case ChangeActions.PUBLISH_EDIT:
+        this._handlePublishEditTap();
+        break;
+      case ChangeActions.REBASE_EDIT:
+        this._handleRebaseEditTap();
+        break;
+      default:
+        this._fireAction(
+          this._prependSlash(key),
+          assertUIActionInfo(this.actions[key]),
+          false
+        );
+    }
+  }
+
+  _handleRevisionAction(key: string) {
+    switch (key) {
+      case RevisionActions.REBASE:
+        this._showActionDialog(this.$.confirmRebase);
+        this.$.confirmRebase.fetchRecentChanges();
+        break;
+      case RevisionActions.CHERRYPICK:
+        this._handleCherrypickTap();
+        break;
+      case RevisionActions.DOWNLOAD:
+        this._handleDownloadTap();
+        break;
+      case RevisionActions.SUBMIT:
+        if (!this._canSubmitChange()) {
+          return;
+        }
+        this._showActionDialog(this.$.confirmSubmitDialog);
+        break;
+      default:
+        this._fireAction(
+          this._prependSlash(key),
+          assertUIActionInfo(this.revisionActions[key]),
+          true
+        );
+    }
+  }
+
+  _prependSlash(key: string) {
+    return key === '/' ? key : `/${key}`;
+  }
+
+  /**
+   * _hasKnownChainState set to true true if hasParent is defined (can be
+   * either true or false). set to false otherwise.
+   */
+  _computeChainState() {
+    this._hasKnownChainState = true;
+  }
+
+  _calculateDisabled(action: UIActionInfo, hasKnownChainState: boolean) {
+    if (action.__key === 'rebase') {
+      // Rebase button is only disabled when change has no parent(s).
+      return hasKnownChainState === false;
+    }
+    return !action.enabled;
+  }
+
+  _handleConfirmDialogCancel() {
+    this._hideAllDialogs();
+  }
+
+  _hideAllDialogs() {
+    const dialogEls = this.root!.querySelectorAll('.confirmDialog');
+    for (const dialogEl of dialogEls) {
+      (dialogEl as HTMLElement).hidden = true;
+    }
+    this.$.overlay.close();
+  }
+
+  _handleRebaseConfirm(e: CustomEvent<ConfirmRebaseEventDetail>) {
+    const el = this.$.confirmRebase;
+    const payload = {base: e.detail.base};
+    this.$.overlay.close();
+    el.hidden = true;
+    this._fireAction(
+      '/rebase',
+      assertUIActionInfo(this.revisionActions.rebase),
+      true,
+      payload
+    );
+  }
+
+  _handleCherrypickConfirm() {
+    this._handleCherryPickRestApi(false);
+  }
+
+  _handleCherrypickConflictConfirm() {
+    this._handleCherryPickRestApi(true);
+  }
+
+  _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,
+        })
+      );
+      return;
+    }
+    if (!el.message) {
+      this.dispatchEvent(
+        new CustomEvent('show-alert', {
+          detail: {message: ERR_COMMIT_EMPTY},
+          composed: true,
+          bubbles: true,
+        })
+      );
+      return;
+    }
+    this.$.overlay.close();
+    el.hidden = true;
+    this._fireAction(
+      '/cherrypick',
+      assertUIActionInfo(this.revisionActions.cherrypick),
+      true,
+      {
+        destination: el.branch,
+        base: el.baseCommit ? el.baseCommit : null,
+        message: el.message,
+        allow_conflicts: conflicts,
+      }
+    );
+  }
+
+  _handleMoveConfirm() {
+    const el = this.$.confirmMove;
+    if (!el.branch) {
+      this.dispatchEvent(
+        new CustomEvent('show-alert', {
+          detail: {message: ERR_BRANCH_EMPTY},
+          composed: true,
+          bubbles: true,
+        })
+      );
+      return;
+    }
+    this.$.overlay.close();
+    el.hidden = true;
+    this._fireAction('/move', assertUIActionInfo(this.actions.move), false, {
+      destination_branch: el.branch,
+      message: el.message,
+    });
+  }
+
+  _handleRevertDialogConfirm(e: CustomEvent<ConfirmRevertEventDetail>) {
+    const revertType = e.detail.revertType;
+    const message = e.detail.message;
+    const el = this.$.confirmRevertDialog;
+    this.$.overlay.close();
+    el.hidden = true;
+    switch (revertType) {
+      case RevertType.REVERT_SINGLE_CHANGE:
+        this._fireAction(
+          '/revert',
+          assertUIActionInfo(this.actions.revert),
+          false,
+          {message}
+        );
+        break;
+      case RevertType.REVERT_SUBMISSION:
+        this._fireAction(
+          '/revert_submission',
+          assertUIActionInfo(this.actions.revert_submission),
+          false,
+          {message}
+        );
+        break;
+      default:
+        console.error('invalid revert type');
+    }
+  }
+
+  _handleRevertSubmissionDialogConfirm() {
+    const el = this.$.confirmRevertSubmissionDialog;
+    this.$.overlay.close();
+    el.hidden = true;
+    this._fireAction(
+      '/revert_submission',
+      assertUIActionInfo(this.actions.revert_submission),
+      false,
+      {message: el.message}
+    );
+  }
+
+  _handleAbandonDialogConfirm() {
+    const el = this.$.confirmAbandonDialog;
+    this.$.overlay.close();
+    el.hidden = true;
+    this._fireAction(
+      '/abandon',
+      assertUIActionInfo(this.actions.abandon),
+      false,
+      {
+        message: el.message,
+      }
+    );
+  }
+
+  _handleCreateFollowUpChange() {
+    this.$.createFollowUpChange.handleCreateChange();
+    this._handleCloseCreateFollowUpChange();
+  }
+
+  _handleCloseCreateFollowUpChange() {
+    this.$.overlay.close();
+  }
+
+  _handleDeleteConfirm() {
+    this._fireAction(
+      '/',
+      assertUIActionInfo(this.actions[ChangeActions.DELETE]),
+      false
+    );
+  }
+
+  _handleDeleteEditConfirm() {
+    this._hideAllDialogs();
+
+    this._fireAction(
+      '/edit',
+      assertUIActionInfo(this.actions.deleteEdit),
+      false
+    );
+  }
+
+  _handleSubmitConfirm() {
+    if (!this._canSubmitChange()) {
+      return;
+    }
+    this._hideAllDialogs();
+    this._fireAction(
+      '/submit',
+      assertUIActionInfo(this.revisionActions.submit),
+      true
+    );
+  }
+
+  _getActionOverflowIndex(type: string, key: string) {
+    return this._overflowActions.findIndex(
+      action => action.type === type && action.key === key
+    );
+  }
+
+  _setLoadingOnButtonWithKey(type: string, key: string) {
+    this._actionLoadingMessage = this._computeLoadingLabel(key);
+    let buttonKey = key;
+    // TODO(dhruvsri): clean this up later
+    // If key is revert-submission, then button key should be 'revert'
+    if (buttonKey === ChangeActions.REVERT_SUBMISSION) {
+      // Revert submission button no longer exists
+      buttonKey = ChangeActions.REVERT;
+    }
+
+    // If the action appears in the overflow menu.
+    if (this._getActionOverflowIndex(type, buttonKey) !== -1) {
+      this.push(
+        '_disabledMenuActions',
+        buttonKey === '/' ? 'delete' : buttonKey
+      );
+      return () => {
+        this._actionLoadingMessage = '';
+        this._disabledMenuActions = [];
+      };
+    }
+
+    // Otherwise it's a top-level action.
+    const buttonEl = this.shadowRoot!.querySelector(
+      `[data-action-key="${buttonKey}"]`
+    ) as GrButton;
+    if (!buttonEl) {
+      throw new Error(`Can't find button by data-action-key '${buttonKey}'`);
+    }
+    buttonEl.setAttribute('loading', 'true');
+    buttonEl.disabled = true;
+    return () => {
+      this._actionLoadingMessage = '';
+      buttonEl.removeAttribute('loading');
+      buttonEl.disabled = false;
+    };
+  }
+
+  _fireAction(
+    endpoint: string,
+    action: UIActionInfo,
+    revAction: boolean,
+    payload?: RequestPayload
+  ) {
+    const cleanupFn = this._setLoadingOnButtonWithKey(
+      action.__type,
+      action.__key
+    );
+
+    this._send(
+      action.method,
+      payload,
+      endpoint,
+      revAction,
+      cleanupFn,
+      action
+    ).then(res => this._handleResponse(action, res));
+  }
+
+  _showActionDialog(dialog: ChangeActionDialog) {
+    this._hideAllDialogs();
+
+    dialog.hidden = false;
+    this.$.overlay.open().then(() => {
+      if (dialog.resetFocus) {
+        dialog.resetFocus();
+      }
+    });
+  }
+
+  // TODO(rmistry): Redo this after
+  // https://bugs.chromium.org/p/gerrit/issues/detail?id=4671 is resolved.
+  _setLabelValuesOnRevert(newChangeId: NumericChangeId) {
+    const labels = this.$.jsAPI.getLabelValuesPostRevert(this.change);
+    if (!labels) {
+      return Promise.resolve(undefined);
+    }
+    return this.$.restAPI.saveChangeReview(newChangeId, 'current', {labels});
+  }
+
+  _handleResponse(action: UIActionInfo, response?: Response) {
+    if (!response) {
+      return;
+    }
+    return this.$.restAPI.getResponseObject(response).then(obj => {
+      switch (action.__key) {
+        case ChangeActions.REVERT: {
+          const revertChangeInfo: ChangeInfo = (obj as unknown) as ChangeInfo;
+          this._waitForChangeReachable(revertChangeInfo._number)
+            .then(() => this._setLabelValuesOnRevert(revertChangeInfo._number))
+            .then(() => {
+              GerritNav.navigateToChange(revertChangeInfo);
+            });
+          break;
+        }
+        case RevisionActions.CHERRYPICK: {
+          const cherrypickChangeInfo: ChangeInfo = (obj as unknown) as ChangeInfo;
+          this._waitForChangeReachable(cherrypickChangeInfo._number).then(
+            () => {
+              GerritNav.navigateToChange(cherrypickChangeInfo);
+            }
+          );
+          break;
+        }
+        case ChangeActions.DELETE:
+          if (action.__type === ActionType.CHANGE) {
+            GerritNav.navigateToRelativeUrl(GerritNav.getUrlForRoot());
+          }
+          break;
+        case ChangeActions.WIP:
+        case ChangeActions.DELETE_EDIT:
+        case ChangeActions.PUBLISH_EDIT:
+        case ChangeActions.REBASE_EDIT:
+        case ChangeActions.REBASE:
+        case ChangeActions.SUBMIT:
+          this.dispatchEvent(
+            new CustomEvent('reload', {
+              detail: {clearPatchset: true},
+              bubbles: false,
+              composed: true,
+            })
+          );
+          break;
+        case ChangeActions.REVERT_SUBMISSION: {
+          const revertSubmistionInfo = (obj as unknown) as RevertSubmissionInfo;
+          if (
+            !revertSubmistionInfo.revert_changes ||
+            !revertSubmistionInfo.revert_changes.length
+          )
+            return;
+          /* If there is only 1 change then gerrit will automatically
+             redirect to that change */
+          GerritNav.navigateToSearchQuery(
+            `topic: ${revertSubmistionInfo.revert_changes[0].topic}`
+          );
+          break;
+        }
+        default:
+          this.dispatchEvent(
+            new CustomEvent('reload', {
+              detail: {action: action.__key, clearPatchset: true},
+              bubbles: false,
+              composed: true,
+            })
+          );
+          break;
+      }
+    });
+  }
+
+  _handleShowRevertSubmissionChangesConfirm() {
+    this._hideAllDialogs();
+  }
+
+  _handleResponseError(
+    action: UIActionInfo,
+    response: Response | undefined | null,
+    body?: RequestPayload
+  ) {
+    if (!response) {
+      return Promise.resolve(() => {
+        this.dispatchEvent(
+          new CustomEvent('show-error', {
+            detail: {message: `Could not perform action '${action.__key}'`},
+            composed: true,
+            bubbles: true,
+          })
+        );
+      });
+    }
+    if (action && action.__key === RevisionActions.CHERRYPICK) {
+      if (
+        response.status === 409 &&
+        body &&
+        !(body as CherryPickInput).allow_conflicts
+      ) {
+        return this._showActionDialog(this.$.confirmCherrypickConflict);
+      }
+    }
+    return response.text().then(errText => {
+      this.dispatchEvent(
+        new CustomEvent('show-error', {
+          detail: {message: `Could not perform action: ${errText}`},
+          composed: true,
+          bubbles: true,
+        })
+      );
+      if (!errText.startsWith('Change is already up to date')) {
+        throw Error(errText);
+      }
+    });
+  }
+
+  _send(
+    method: HttpMethod | undefined,
+    payload: RequestPayload | undefined,
+    actionEndpoint: string,
+    revisionAction: boolean,
+    cleanupFn: () => void,
+    action: UIActionInfo
+  ): Promise<Response | undefined> {
+    const handleError: ErrorCallback = response => {
+      cleanupFn.call(this);
+      this._handleResponseError(action, response, payload);
+    };
+    const change = this.change;
+    const changeNum = this.changeNum;
+    if (!change || !changeNum) {
+      return Promise.reject(
+        new Error('Properties change and changeNum must be set.')
+      );
+    }
+    return fetchChangeUpdates(change, this.$.restAPI).then(result => {
+      if (!result.isLatest) {
+        this.dispatchEvent(
+          new CustomEvent('show-alert', {
+            detail: {
+              message:
+                'Cannot set label: a newer patch has been ' +
+                'uploaded to this change.',
+              action: 'Reload',
+              callback: () => {
+                this.dispatchEvent(
+                  new CustomEvent('reload', {
+                    detail: {clearPatchset: true},
+                    bubbles: false,
+                    composed: true,
+                  })
+                );
+              },
+            },
+            composed: true,
+            bubbles: true,
+          })
+        );
+
+        // Because this is not a network error, call the cleanup function
+        // but not the error handler.
+        cleanupFn();
+
+        return Promise.resolve(undefined);
+      }
+      const patchNum = revisionAction ? this.latestPatchNum : undefined;
+      return this.$.restAPI
+        .executeChangeAction(
+          changeNum,
+          method,
+          actionEndpoint,
+          patchNum,
+          payload,
+          handleError
+        )
+        .then(response => {
+          cleanupFn.call(this);
+          return response;
+        });
+    });
+  }
+
+  _handleAbandonTap() {
+    this._showActionDialog(this.$.confirmAbandonDialog);
+  }
+
+  _handleCherrypickTap() {
+    if (!this.change) {
+      throw new Error('The change property must be set');
+    }
+    this.$.confirmCherrypick.branch = '' as BranchName;
+    const query = `topic: "${this.change.topic}"`;
+    const options = listChangesOptionsToHex(
+      ListChangesOption.MESSAGES,
+      ListChangesOption.ALL_REVISIONS
+    );
+    this.$.restAPI.getChanges(0, query, undefined, options).then(changes => {
+      if (!changes) {
+        console.error('getChanges returns undefined');
+        return;
+      }
+      this.$.confirmCherrypick.updateChanges(changes);
+      this._showActionDialog(this.$.confirmCherrypick);
+    });
+  }
+
+  _handleMoveTap() {
+    this.$.confirmMove.branch = '' as BranchName;
+    this.$.confirmMove.message = '';
+    this._showActionDialog(this.$.confirmMove);
+  }
+
+  _handleDownloadTap() {
+    this.dispatchEvent(
+      new CustomEvent('download-tap', {
+        composed: true,
+        bubbles: false,
+      })
+    );
+  }
+
+  _handleDeleteTap() {
+    this._showActionDialog(this.$.confirmDeleteDialog);
+  }
+
+  _handleDeleteEditTap() {
+    this._showActionDialog(this.$.confirmDeleteEditDialog);
+  }
+
+  _handleFollowUpTap() {
+    this._showActionDialog(this.$.createFollowUpDialog);
+  }
+
+  _handleWipTap() {
+    if (!this.actions.wip) {
+      return;
+    }
+    this._fireAction('/wip', assertUIActionInfo(this.actions.wip), false);
+  }
+
+  _handlePublishEditTap() {
+    // Type of payload is PublishChangeEditInput.
+    const payload = {notify: NotifyType.NONE};
+    if (!this.actions.publishEdit) {
+      return;
+    }
+    this._fireAction(
+      '/edit:publish',
+      assertUIActionInfo(this.actions.publishEdit),
+      false,
+      payload
+    );
+  }
+
+  _handleRebaseEditTap() {
+    if (!this.actions.rebaseEdit) {
+      return;
+    }
+    this._fireAction(
+      '/edit:rebase',
+      assertUIActionInfo(this.actions.rebaseEdit),
+      false
+    );
+  }
+
+  _handleHideBackgroundContent() {
+    this.$.mainContent.classList.add('overlayOpen');
+  }
+
+  _handleShowBackgroundContent() {
+    this.$.mainContent.classList.remove('overlayOpen');
+  }
+
+  /**
+   * Merge sources of change actions into a single ordered array of action
+   * values.
+   */
+  _computeAllActions(
+    changeActionsRecord: PolymerDeepPropertyChange<
+      ActionNameToActionInfoMap,
+      ActionNameToActionInfoMap
+    >,
+    revisionActionsRecord: PolymerDeepPropertyChange<
+      ActionNameToActionInfoMap,
+      ActionNameToActionInfoMap
+    >,
+    primariesRecord: PolymerDeepPropertyChange<
+      PrimaryActionKey[],
+      PrimaryActionKey[]
+    >,
+    additionalActionsRecord: PolymerDeepPropertyChange<
+      UIActionInfo[],
+      UIActionInfo[]
+    >,
+    change?: ChangeInfo,
+    config?: ServerInfo
+  ): UIActionInfo[] {
+    // Polymer 2: check for undefined
+    if (
+      [
+        changeActionsRecord,
+        revisionActionsRecord,
+        primariesRecord,
+        additionalActionsRecord,
+        change,
+      ].includes(undefined)
+    ) {
+      return [];
+    }
+
+    const revisionActionValues = this._getActionValues(
+      revisionActionsRecord,
+      primariesRecord,
+      additionalActionsRecord,
+      ActionType.REVISION
+    );
+    const changeActionValues = this._getActionValues(
+      changeActionsRecord,
+      primariesRecord,
+      additionalActionsRecord,
+      ActionType.CHANGE
+    );
+    const quickApprove = this._getQuickApproveAction();
+    if (quickApprove) {
+      changeActionValues.unshift(quickApprove);
+    }
+
+    return revisionActionValues
+      .concat(changeActionValues)
+      .sort((a, b) => this._actionComparator(a, b))
+      .map(action => {
+        if (ACTIONS_WITH_ICONS.has(action.__key)) {
+          action.icon = action.__key;
+        }
+        // TODO(brohlfs): Temporary hack until change 269573 is live in all
+        // backends.
+        if (action.__key === ChangeActions.READY) {
+          action.label = 'Mark as Active';
+        }
+        // End of hack
+        return action;
+      })
+      .filter(action => !this._shouldSkipAction(action, config));
+  }
+
+  _getActionPriority(action: UIActionInfo) {
+    if (action.__type && action.__key) {
+      const overrideAction = this._actionPriorityOverrides.find(
+        i => i.type === action.__type && i.key === action.__key
+      );
+
+      if (overrideAction !== undefined) {
+        return overrideAction.priority;
+      }
+    }
+    if (action.__key === 'review') {
+      return ActionPriority.REVIEW;
+    } else if (action.__primary) {
+      return ActionPriority.PRIMARY;
+    } else if (action.__type === ActionType.CHANGE) {
+      return ActionPriority.CHANGE;
+    } else if (action.__type === ActionType.REVISION) {
+      return ActionPriority.REVISION;
+    }
+    return ActionPriority.DEFAULT;
+  }
+
+  /**
+   * Sort comparator to define the order of change actions.
+   */
+  _actionComparator(actionA: UIActionInfo, actionB: UIActionInfo) {
+    const priorityDelta =
+      this._getActionPriority(actionA) - this._getActionPriority(actionB);
+    // Sort by the button label if same priority.
+    if (priorityDelta === 0) {
+      return actionA.label > actionB.label ? 1 : -1;
+    } else {
+      return priorityDelta;
+    }
+  }
+
+  _shouldSkipAction(action: UIActionInfo, config?: ServerInfo) {
+    const skipActionKeys: string[] = [...SKIP_ACTION_KEYS];
+    const isAttentionSetEnabled =
+      !!config && !!config.change && config.change.enable_attention_set;
+    if (isAttentionSetEnabled) {
+      skipActionKeys.push(...SKIP_ACTION_KEYS_ATTENTION_SET);
+    }
+    return skipActionKeys.includes(action.__key);
+  }
+
+  _computeTopLevelActions(
+    actionRecord: PolymerDeepPropertyChange<UIActionInfo[], UIActionInfo[]>,
+    hiddenActionsRecord: PolymerDeepPropertyChange<string[], string[]>
+  ): UIActionInfo[] {
+    const hiddenActions = hiddenActionsRecord.base || [];
+    return actionRecord.base.filter(a => {
+      const overflow = this._getActionOverflowIndex(a.__type, a.__key) !== -1;
+      return !(overflow || hiddenActions.includes(a.__key));
+    });
+  }
+
+  _filterPrimaryActions(_topLevelActions: UIActionInfo[]) {
+    this._topLevelPrimaryActions = _topLevelActions.filter(
+      action => action.__primary
+    );
+    this._topLevelSecondaryActions = _topLevelActions.filter(
+      action => !action.__primary
+    );
+  }
+
+  _computeMenuActions(
+    actionRecord: PolymerDeepPropertyChange<UIActionInfo[], UIActionInfo[]>,
+    hiddenActionsRecord: PolymerDeepPropertyChange<string[], string[]>
+  ): MenuAction[] {
+    const hiddenActions = hiddenActionsRecord.base || [];
+    return actionRecord.base
+      .filter(a => {
+        const overflow = this._getActionOverflowIndex(a.__type, a.__key) !== -1;
+        return overflow && !hiddenActions.includes(a.__key);
+      })
+      .map(action => {
+        let key = action.__key;
+        if (key === '/') {
+          key = 'delete';
+        }
+        return {
+          name: action.label,
+          id: `${key}-${action.__type}`,
+          action,
+          tooltip: action.title,
+        };
+      });
+  }
+
+  _computeRebaseOnCurrent(
+    revisionRebaseAction: PropertyType<GrChangeActions, '_revisionRebaseAction'>
+  ) {
+    if (revisionRebaseAction) {
+      return !!revisionRebaseAction.enabled;
+    }
+    return null;
+  }
+
+  /**
+   * Occasionally, a change created by a change action is not yet known to the
+   * API for a brief time. Wait for the given change number to be recognized.
+   *
+   * Returns a promise that resolves with true if a request is recognized, or
+   * false if the change was never recognized after all attempts.
+   *
+   */
+  _waitForChangeReachable(changeNum: NumericChangeId) {
+    let attempsRemaining = AWAIT_CHANGE_ATTEMPTS;
+    return new Promise(resolve => {
+      const check = () => {
+        attempsRemaining--;
+        // Pass a no-op error handler to avoid the "not found" error toast.
+        this.$.restAPI
+          .getChange(changeNum, () => {})
+          .then(response => {
+            // If the response is 404, the response will be undefined.
+            if (response) {
+              resolve(true);
+              return;
+            }
+
+            if (attempsRemaining) {
+              this.async(check, AWAIT_CHANGE_TIMEOUT_MS);
+            } else {
+              resolve(false);
+            }
+          });
+      };
+      check();
+    });
+  }
+
+  _handleEditTap() {
+    this.dispatchEvent(new CustomEvent('edit-tap', {bubbles: false}));
+  }
+
+  _handleStopEditTap() {
+    this.dispatchEvent(new CustomEvent('stop-edit-tap', {bubbles: false}));
+  }
+
+  _computeHasTooltip(title?: string) {
+    return !!title;
+  }
+
+  _computeHasIcon(action: UIActionInfo) {
+    return action.icon ? '' : 'hidden';
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-change-actions': GrChangeActions;
+  }
+}
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 091e5a2..301f176 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
@@ -153,19 +153,17 @@
       });
     });
 
-    test('plugin change actions', done => {
+    test('plugin change actions', async () => {
       sinon.stub(element.$.restAPI, 'getChangeActionURL').returns(
           Promise.resolve('the-url'));
       element.actions = {
         'plugin~action': {},
       };
       assert.isOk(element.actions['plugin~action']);
-      flush(() => {
-        assert.isTrue(element.$.restAPI.getChangeActionURL.calledWith(
-            element.changeNum, null, '/plugin~action'));
-        assert.equal(element.actions['plugin~action'].__url, 'the-url');
-        done();
-      });
+      await flush();
+      assert.isTrue(element.$.restAPI.getChangeActionURL.calledWith(
+          element.changeNum, undefined, '/plugin~action'));
+      assert.equal(element.actions['plugin~action'].__url, 'the-url');
     });
 
     test('not supported actions are filtered out', () => {
@@ -813,6 +811,14 @@
       setup(() => {
         fireActionStub = sinon.stub(element, '_fireAction');
         sinon.stub(window, 'alert');
+        element.actions = {
+          move: {
+            method: 'POST',
+            label: 'Move',
+            title: 'Move the change',
+            enabled: true,
+          },
+        };
       });
 
       test('works', () => {
@@ -1614,7 +1620,7 @@
         assert.isTrue(fireActionStub.called);
         assert.isTrue(fireActionStub.calledWith('/review'));
         const payload = fireActionStub.lastCall.args[3];
-        assert.deepEqual(payload.labels, {foo: '+1'});
+        assert.deepEqual(payload.labels, {foo: 1});
       });
 
       test('not added when multiple labels are required', () => {
@@ -1834,16 +1840,12 @@
               'navigateToChange').returns(Promise.resolve(true));
         });
 
-        test('change action', done => {
-          element
-              ._send('DELETE', payload, '/endpoint', false, cleanup)
-              .then(() => {
-                assert.isFalse(onShowError.called);
-                assert.isTrue(cleanup.calledOnce);
-                assert.isTrue(sendStub.calledWith(42, 'DELETE', '/endpoint',
-                    null, payload));
-                done();
-              });
+        test('change action', async () => {
+          await element._send('DELETE', payload, '/endpoint', false, cleanup);
+          assert.isFalse(onShowError.called);
+          assert.isTrue(cleanup.calledOnce);
+          assert.isTrue(sendStub.calledWith(42, 'DELETE', '/endpoint',
+              undefined, payload));
         });
 
         suite('show revert submission dialog', () => {
@@ -1990,6 +1992,13 @@
 
     test('_handleAction reports', () => {
       sinon.stub(element, '_fireAction');
+      element.actions = {
+        key: {
+          __key: 'key',
+          __type: 'type',
+        },
+      };
+
       const reportStub = sinon.stub(element.reporting, 'reportInteraction');
       element._handleAction('type', 'key');
       assert.isTrue(reportStub.called);
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.js b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.js
deleted file mode 100644
index b038bf6..0000000
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.js
+++ /dev/null
@@ -1,541 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 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 '../../../styles/shared-styles.js';
-import '../../../styles/gr-change-metadata-shared-styles.js';
-import '../../../styles/gr-change-view-integration-shared-styles.js';
-import '../../../styles/gr-voting-styles.js';
-import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator.js';
-import '../../plugins/gr-endpoint-param/gr-endpoint-param.js';
-import '../../plugins/gr-external-style/gr-external-style.js';
-import '../../shared/gr-account-chip/gr-account-chip.js';
-import '../../shared/gr-date-formatter/gr-date-formatter.js';
-import '../../shared/gr-editable-label/gr-editable-label.js';
-import '../../shared/gr-icons/gr-icons.js';
-import '../../shared/gr-limited-text/gr-limited-text.js';
-import '../../shared/gr-linked-chip/gr-linked-chip.js';
-import '../../shared/gr-tooltip-content/gr-tooltip-content.js';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
-import '../gr-change-requirements/gr-change-requirements.js';
-import '../gr-commit-info/gr-commit-info.js';
-import '../gr-reviewer-list/gr-reviewer-list.js';
-import '../../shared/gr-account-list/gr-account-list.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
-import {PolymerElement} from '@polymer/polymer/polymer-element.js';
-import {htmlTemplate} from './gr-change-metadata_html.js';
-import {GrReviewerSuggestionsProvider, SUGGESTIONS_PROVIDERS_USERS_TYPES} from '../../../scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-import {ChangeStatus} from '../../../constants/constants.js';
-import {changeIsOpen} from '../../../utils/change-util.js';
-
-const HASHTAG_ADD_MESSAGE = 'Add Hashtag';
-
-const SubmitTypeLabel = {
-  FAST_FORWARD_ONLY: 'Fast Forward Only',
-  MERGE_IF_NECESSARY: 'Merge if Necessary',
-  REBASE_IF_NECESSARY: 'Rebase if Necessary',
-  MERGE_ALWAYS: 'Always Merge',
-  REBASE_ALWAYS: 'Rebase Always',
-  CHERRY_PICK: 'Cherry Pick',
-};
-
-const NOT_CURRENT_MESSAGE = 'Not current - rebase possible';
-
-/**
- * @enum {string}
- */
-const CertificateStatus = {
-  /**
-   * This certificate status is bad.
-   */
-  BAD: 'BAD',
-  /**
-   * This certificate status is OK.
-   */
-  OK: 'OK',
-  /**
-   * This certificate status is TRUSTED.
-   */
-  TRUSTED: 'TRUSTED',
-};
-
-/**
- * @extends PolymerElement
- */
-class GrChangeMetadata extends GestureEventListeners(
-    LegacyElementMixin(PolymerElement)) {
-  static get template() { return htmlTemplate; }
-
-  static get is() { return 'gr-change-metadata'; }
-  /**
-   * Fired when the change topic is changed.
-   *
-   * @event topic-changed
-   */
-
-  static get properties() {
-    return {
-    /** @type {?} */
-      change: Object,
-      labels: {
-        type: Object,
-        notify: true,
-      },
-      account: Object,
-      /** @type {?} */
-      revision: Object,
-      commitInfo: Object,
-      _mutable: {
-        type: Boolean,
-        computed: '_computeIsMutable(account)',
-      },
-      /** @type {?} */
-      serverConfig: Object,
-      parentIsCurrent: Boolean,
-      _notCurrentMessage: {
-        type: String,
-        value: NOT_CURRENT_MESSAGE,
-        readOnly: true,
-      },
-      _topicReadOnly: {
-        type: Boolean,
-        computed: '_computeTopicReadOnly(_mutable, change)',
-      },
-      _hashtagReadOnly: {
-        type: Boolean,
-        computed: '_computeHashtagReadOnly(_mutable, change)',
-      },
-      /**
-       * @type {Gerrit.PushCertificateValidation}
-       */
-      _pushCertificateValidation: {
-        type: Object,
-        computed: '_computePushCertificateValidation(serverConfig, change)',
-      },
-      _showRequirements: {
-        type: Boolean,
-        computed: '_computeShowRequirements(change)',
-      },
-
-      _assignee: Array,
-      _isWip: {
-        type: Boolean,
-        computed: '_computeIsWip(change)',
-      },
-      _newHashtag: String,
-
-      _settingTopic: {
-        type: Boolean,
-        value: false,
-      },
-
-      _currentParents: {
-        type: Array,
-        computed: '_computeParents(change, revision)',
-      },
-
-      /** @type {?} */
-      _CHANGE_ROLE: {
-        type: Object,
-        readOnly: true,
-        value: {
-          OWNER: 'owner',
-          UPLOADER: 'uploader',
-          AUTHOR: 'author',
-          COMMITTER: 'committer',
-        },
-      },
-    };
-  }
-
-  static get observers() {
-    return [
-      '_changeChanged(change)',
-      '_labelsChanged(change.labels)',
-      '_assigneeChanged(_assignee.*)',
-    ];
-  }
-
-  _labelsChanged(labels) {
-    this.labels = ({...labels}) || null;
-  }
-
-  _changeChanged(change) {
-    this._assignee = change.assignee ? [change.assignee] : [];
-    this._settingTopic = false;
-  }
-
-  _assigneeChanged(assigneeRecord) {
-    if (!this.change || !this._isAssigneeEnabled(this.serverConfig)) {
-      return;
-    }
-    const assignee = assigneeRecord.base;
-    if (assignee.length) {
-      const acct = assignee[0];
-      if (this.change.assignee &&
-          acct._account_id === this.change.assignee._account_id) { return; }
-      this.set(['change', 'assignee'], acct);
-      this.$.restAPI.setAssignee(this.change._number, acct._account_id);
-    } else {
-      if (!this.change.assignee) { return; }
-      this.set(['change', 'assignee'], undefined);
-      this.$.restAPI.deleteAssignee(this.change._number);
-    }
-  }
-
-  _computeHideStrategy(change) {
-    return !changeIsOpen(change);
-  }
-
-  /**
-   * @param {Object} commitInfo
-   * @return {?Array} If array is empty, returns null instead so
-   * an existential check can be used to hide or show the webLinks
-   * section.
-   */
-  _computeWebLinks(commitInfo, serverConfig) {
-    if (!commitInfo) { return null; }
-    const weblinks = GerritNav.getChangeWeblinks(
-        this.change ? this.change.repo : '',
-        commitInfo.commit,
-        {
-          weblinks: commitInfo.web_links,
-          config: serverConfig,
-        });
-    return weblinks.length ? weblinks : null;
-  }
-
-  _isAssigneeEnabled(serverConfig) {
-    return serverConfig && serverConfig.change
-        && !!serverConfig.change.enable_assignee;
-  }
-
-  _computeStrategy(change) {
-    return SubmitTypeLabel[change.submit_type];
-  }
-
-  _computeLabelNames(labels) {
-    return Object.keys(labels).sort();
-  }
-
-  _handleTopicChanged(e, topic) {
-    const lastTopic = this.change.topic;
-    if (!topic.length) { topic = null; }
-    this._settingTopic = true;
-    const topicChangedForChangeNumber = this.change._number;
-    this.$.restAPI.setChangeTopic(topicChangedForChangeNumber, topic)
-        .then(newTopic => {
-          if (this.change._number !== topicChangedForChangeNumber) {
-            return;
-          }
-          this._settingTopic = false;
-          this.set(['change', 'topic'], newTopic);
-          if (newTopic !== lastTopic) {
-            this.dispatchEvent(new CustomEvent(
-                'topic-changed', {bubbles: true, composed: true}));
-          }
-        });
-  }
-
-  _showAddTopic(changeRecord, settingTopic) {
-    const hasTopic = !!changeRecord &&
-        !!changeRecord.base && !!changeRecord.base.topic;
-    return !hasTopic && !settingTopic;
-  }
-
-  _showTopicChip(changeRecord, settingTopic) {
-    const hasTopic = !!changeRecord &&
-        !!changeRecord.base && !!changeRecord.base.topic;
-    return hasTopic && !settingTopic;
-  }
-
-  _showCherryPickOf(changeRecord) {
-    const hasCherryPickOf = !!changeRecord &&
-        !!changeRecord.base && !!changeRecord.base.cherry_pick_of_change &&
-        !!changeRecord.base.cherry_pick_of_patch_set;
-    return hasCherryPickOf;
-  }
-
-  _handleHashtagChanged(e) {
-    const lastHashtag = this.change.hashtag;
-    if (!this._newHashtag.length) { return; }
-    const newHashtag = this._newHashtag;
-    this._newHashtag = '';
-    this.$.restAPI.setChangeHashtag(
-        this.change._number, {add: [newHashtag]}).then(newHashtag => {
-      this.set(['change', 'hashtags'], newHashtag);
-      if (newHashtag !== lastHashtag) {
-        this.dispatchEvent(
-            new CustomEvent('hashtag-changed', {
-              bubbles: true, composed: true}));
-      }
-    });
-  }
-
-  _computeTopicReadOnly(mutable, change) {
-    return !mutable ||
-        !change ||
-        !change.actions ||
-        !change.actions.topic ||
-        !change.actions.topic.enabled;
-  }
-
-  _computeHashtagReadOnly(mutable, change) {
-    return !mutable ||
-        !change ||
-        !change.actions ||
-        !change.actions.hashtags ||
-        !change.actions.hashtags.enabled;
-  }
-
-  _computeAssigneeReadOnly(mutable, change) {
-    return !mutable ||
-        !change ||
-        !change.actions ||
-        !change.actions.assignee ||
-        !change.actions.assignee.enabled;
-  }
-
-  _computeTopicPlaceholder(_topicReadOnly) {
-    // Action items in Material Design are uppercase -- placeholder label text
-    // is sentence case.
-    return _topicReadOnly ? 'No topic' : 'ADD TOPIC';
-  }
-
-  _computeHashtagPlaceholder(_hashtagReadOnly) {
-    return _hashtagReadOnly ? '' : HASHTAG_ADD_MESSAGE;
-  }
-
-  _computeShowRequirements(change) {
-    if (change.status !== ChangeStatus.NEW) {
-      // TODO(maximeg) change this to display the stored
-      // requirements, once it is implemented server-side.
-      return false;
-    }
-    const hasRequirements = !!change.requirements &&
-        Object.keys(change.requirements).length > 0;
-    const hasLabels = !!change.labels &&
-        Object.keys(change.labels).length > 0;
-    return hasRequirements || hasLabels || !!change.work_in_progress;
-  }
-
-  /**
-   * @return {?Gerrit.PushCertificateValidation} object representing data for
-   *     the push validation.
-   */
-  _computePushCertificateValidation(serverConfig, change) {
-    if (!change || !serverConfig || !serverConfig.receive ||
-        !serverConfig.receive.enable_signed_push) {
-      return null;
-    }
-    const rev = change.revisions[change.current_revision];
-    if (!rev.push_certificate || !rev.push_certificate.key) {
-      return {
-        class: 'help',
-        icon: 'gr-icons:help',
-        message: 'This patch set was created without a push certificate',
-      };
-    }
-
-    const key = rev.push_certificate.key;
-    switch (key.status) {
-      case CertificateStatus.BAD:
-        return {
-          class: 'invalid',
-          icon: 'gr-icons:close',
-          message: this._problems('Push certificate is invalid', key),
-        };
-      case CertificateStatus.OK:
-        return {
-          class: 'notTrusted',
-          icon: 'gr-icons:info',
-          message: this._problems(
-              'Push certificate is valid, but key is not trusted', key),
-        };
-      case CertificateStatus.TRUSTED:
-        return {
-          class: 'trusted',
-          icon: 'gr-icons:check',
-          message: this._problems(
-              'Push certificate is valid and key is trusted', key),
-        };
-      default:
-        throw new Error(`unknown certificate status: ${key.status}`);
-    }
-  }
-
-  _problems(msg, key) {
-    if (!key || !key.problems || key.problems.length === 0) {
-      return msg;
-    }
-
-    return [msg + ':'].concat(key.problems).join('\n');
-  }
-
-  _computeShowRepoBranchTogether(repo, branch) {
-    return !!repo && !!branch && repo.length + branch.length < 40;
-  }
-
-  _computeProjectUrl(project) {
-    return GerritNav.getUrlForProjectChanges(project);
-  }
-
-  _computeBranchUrl(project, branch) {
-    if (!this.change || !this.change.status) return '';
-    return GerritNav.getUrlForBranch(branch, project,
-        this.change.status == ChangeStatus.NEW ? 'open' :
-          this.change.status.toLowerCase());
-  }
-
-  _computeCherryPickOfUrl(change, patchset, project) {
-    return GerritNav.getUrlForChangeById(change, project, patchset);
-  }
-
-  _computeTopicUrl(topic) {
-    return GerritNav.getUrlForTopic(topic);
-  }
-
-  _computeHashtagUrl(hashtag) {
-    return GerritNav.getUrlForHashtag(hashtag);
-  }
-
-  _handleTopicRemoved(e) {
-    const target = dom(e).rootTarget;
-    target.disabled = true;
-    this.$.restAPI.setChangeTopic(this.change._number, null)
-        .then(() => {
-          target.disabled = false;
-          this.set(['change', 'topic'], '');
-          this.dispatchEvent(
-              new CustomEvent('topic-changed',
-                  {bubbles: true, composed: true}));
-        })
-        .catch(err => {
-          target.disabled = false;
-          return;
-        });
-  }
-
-  _handleHashtagRemoved(e) {
-    e.preventDefault();
-    const target = dom(e).rootTarget;
-    target.disabled = true;
-    this.$.restAPI.setChangeHashtag(this.change._number,
-        {remove: [target.text]})
-        .then(newHashtag => {
-          target.disabled = false;
-          this.set(['change', 'hashtags'], newHashtag);
-        })
-        .catch(err => {
-          target.disabled = false;
-          return;
-        });
-  }
-
-  _computeIsWip(change) {
-    return !!change.work_in_progress;
-  }
-
-  _computeShowRoleClass(change, role) {
-    return this._getNonOwnerRole(change, role) ? '' : 'hideDisplay';
-  }
-
-  /**
-   * Get the user with the specified role on the change. Returns null if the
-   * user with that role is the same as the owner.
-   *
-   * @param {!Object} change
-   * @param {string} role One of the values from _CHANGE_ROLE
-   * @return {Object|null} either an account or null.
-   */
-  _getNonOwnerRole(change, role) {
-    if (!change || !change.current_revision ||
-        !change.revisions[change.current_revision]) {
-      return null;
-    }
-
-    const rev = change.revisions[change.current_revision];
-    if (!rev) { return null; }
-
-    if (role === this._CHANGE_ROLE.UPLOADER &&
-        rev.uploader &&
-        change.owner._account_id !== rev.uploader._account_id) {
-      return rev.uploader;
-    }
-
-    if (role === this._CHANGE_ROLE.AUTHOR &&
-        rev.commit && rev.commit.author &&
-        change.owner.email !== rev.commit.author.email) {
-      return rev.commit.author;
-    }
-
-    if (role === this._CHANGE_ROLE.COMMITTER &&
-        rev.commit && rev.commit.committer &&
-        change.owner.email !== rev.commit.committer.email) {
-      return rev.commit.committer;
-    }
-
-    return null;
-  }
-
-  _computeParents(change, revision) {
-    if (!revision || !revision.commit) {
-      if (!change || !change.current_revision) { return []; }
-      revision = change.revisions[change.current_revision];
-      if (!revision || !revision.commit) { return []; }
-    }
-    return revision.commit.parents;
-  }
-
-  _computeParentsLabel(parents) {
-    return parents && parents.length > 1 ? 'Parents' : 'Parent';
-  }
-
-  _computeParentListClass(parents, parentIsCurrent) {
-    // Undefined check for polymer 2
-    if (parents === undefined || parentIsCurrent === undefined) {
-      return '';
-    }
-
-    return [
-      'parentList',
-      parents && parents.length > 1 ? 'merge' : 'nonMerge',
-      parentIsCurrent ? 'current' : 'notCurrent',
-    ].join(' ');
-  }
-
-  _computeIsMutable(account) {
-    return !!Object.keys(account).length;
-  }
-
-  editTopic() {
-    if (this._topicReadOnly || this.change.topic) { return; }
-    // Cannot use `this.$.ID` syntax because the element exists inside of a
-    // dom-if.
-    this.shadowRoot.querySelector('.topicEditableLabel').open();
-  }
-
-  _getReviewerSuggestionsProvider(change) {
-    const provider = GrReviewerSuggestionsProvider.create(this.$.restAPI,
-        change._number, SUGGESTIONS_PROVIDERS_USERS_TYPES.ANY);
-    provider.init();
-    return provider;
-  }
-}
-
-customElements.define(GrChangeMetadata.is, GrChangeMetadata);
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
new file mode 100644
index 0000000..539697e
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.ts
@@ -0,0 +1,682 @@
+/**
+ * @license
+ * Copyright (C) 2016 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 '../../../styles/shared-styles';
+import '../../../styles/gr-change-metadata-shared-styles';
+import '../../../styles/gr-change-view-integration-shared-styles';
+import '../../../styles/gr-voting-styles';
+import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
+import '../../plugins/gr-endpoint-param/gr-endpoint-param';
+import '../../plugins/gr-external-style/gr-external-style';
+import '../../shared/gr-account-chip/gr-account-chip';
+import '../../shared/gr-date-formatter/gr-date-formatter';
+import '../../shared/gr-editable-label/gr-editable-label';
+import '../../shared/gr-icons/gr-icons';
+import '../../shared/gr-limited-text/gr-limited-text';
+import '../../shared/gr-linked-chip/gr-linked-chip';
+import '../../shared/gr-tooltip-content/gr-tooltip-content';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../gr-change-requirements/gr-change-requirements';
+import '../gr-commit-info/gr-commit-info';
+import '../gr-reviewer-list/gr-reviewer-list';
+import '../../shared/gr-account-list/gr-account-list';
+import {dom, EventApi} 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';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-change-metadata_html';
+import {
+  GrReviewerSuggestionsProvider,
+  SUGGESTIONS_PROVIDERS_USERS_TYPES,
+} from '../../../scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {
+  ChangeStatus,
+  GpgKeyInfoStatus,
+  SubmitType,
+} from '../../../constants/constants';
+import {changeIsOpen} from '../../../utils/change-util';
+import {customElement, property, observe} from '@polymer/decorators';
+import {ParsedChangeInfo} from '../../shared/gr-rest-api-interface/gr-reviewer-updates-parser';
+import {
+  AccountDetailInfo,
+  AccountInfo,
+  BranchName,
+  CommitId,
+  CommitInfo,
+  ElementPropertyDeepChange,
+  GpgKeyInfo,
+  Hashtag,
+  LabelNameToInfoMap,
+  NumericChangeId,
+  ParentCommitInfo,
+  PatchSetNum,
+  RepoName,
+  RevisionInfo,
+  ServerInfo,
+  TopicName,
+} from '../../../types/common';
+import {assertNever} from '../../../utils/common-util';
+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';
+
+const HASHTAG_ADD_MESSAGE = 'Add Hashtag';
+
+enum ChangeRole {
+  OWNER = 'owner',
+  UPLOADER = 'uploader',
+  AUTHOR = 'author',
+  COMMITTER = 'committer',
+}
+
+export interface CommitInfoWithRequiredCommit extends CommitInfo {
+  // gr-change-view always assigns commit to CommitInfo
+  commit: CommitId;
+}
+
+const SubmitTypeLabel = new Map<SubmitType, string>([
+  [SubmitType.FAST_FORWARD_ONLY, 'Fast Forward Only'],
+  [SubmitType.MERGE_IF_NECESSARY, 'Merge if Necessary'],
+  [SubmitType.REBASE_IF_NECESSARY, 'Rebase if Necessary'],
+  [SubmitType.MERGE_ALWAYS, 'Always Merge'],
+  [SubmitType.REBASE_ALWAYS, 'Rebase Always'],
+  [SubmitType.CHERRY_PICK, 'Cherry Pick'],
+]);
+
+const NOT_CURRENT_MESSAGE = 'Not current - rebase possible';
+
+interface PushCertifacteValidationInfo {
+  class: string;
+  icon: string;
+  message: string;
+}
+
+export interface GrChangeMetadata {
+  $: {
+    restAPI: RestApiService & Element;
+  };
+}
+
+@customElement('gr-change-metadata')
+export class GrChangeMetadata extends GestureEventListeners(
+  LegacyElementMixin(PolymerElement)
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  /**
+   * Fired when the change topic is changed.
+   *
+   * @event topic-changed
+   */
+
+  @property({type: Object})
+  change?: ParsedChangeInfo;
+
+  @property({type: Object, notify: true})
+  labels?: LabelNameToInfoMap;
+
+  @property({type: Object})
+  account?: AccountDetailInfo;
+
+  @property({type: Object})
+  revision?: RevisionInfo;
+
+  @property({type: Object})
+  commitInfo?: CommitInfoWithRequiredCommit;
+
+  @property({type: Boolean, computed: '_computeIsMutable(account)'})
+  _mutable = false;
+
+  @property({type: Object})
+  serverConfig?: ServerInfo;
+
+  @property({type: Boolean})
+  parentIsCurrent?: boolean;
+
+  @property({type: String, readOnly: true})
+  _notCurrentMessage = NOT_CURRENT_MESSAGE;
+
+  @property({
+    type: Boolean,
+    computed: '_computeTopicReadOnly(_mutable, change)',
+  })
+  _topicReadOnly = true;
+
+  @property({
+    type: Boolean,
+    computed: '_computeHashtagReadOnly(_mutable, change)',
+  })
+  _hashtagReadOnly = true;
+
+  @property({
+    type: Object,
+    computed: '_computePushCertificateValidation(serverConfig, change)',
+  })
+  _pushCertificateValidation: PushCertifacteValidationInfo | null = null;
+
+  @property({type: Boolean, computed: '_computeShowRequirements(change)'})
+  _showRequirements = false;
+
+  @property({type: Array})
+  _assignee?: AccountInfo[];
+
+  @property({type: Boolean, computed: '_computeIsWip(change)'})
+  _isWip = false;
+
+  @property({type: String})
+  _newHashtag?: Hashtag;
+
+  @property({type: Boolean})
+  _settingTopic = false;
+
+  @property({type: Array, computed: '_computeParents(change, revision)'})
+  _currentParents: ParentCommitInfo[] = [];
+
+  @property({type: Object})
+  _CHANGE_ROLE = ChangeRole;
+
+  @observe('change.labels')
+  _labelsChanged(labels?: LabelNameToInfoMap) {
+    this.labels = {...labels} || null;
+  }
+
+  @observe('change')
+  _changeChanged(change?: ParsedChangeInfo) {
+    this._assignee = change?.assignee ? [change.assignee] : [];
+    this._settingTopic = false;
+  }
+
+  @observe('_assignee.*')
+  _assigneeChanged(
+    assigneeRecord: ElementPropertyDeepChange<GrChangeMetadata, '_assignee'>
+  ) {
+    if (!this.change || !this._isAssigneeEnabled(this.serverConfig)) {
+      return;
+    }
+    const assignee = assigneeRecord.base;
+    if (assignee?.length) {
+      const acct = assignee[0];
+      if (
+        !acct._account_id ||
+        (this.change.assignee &&
+          acct._account_id === this.change.assignee._account_id)
+      ) {
+        return;
+      }
+      this.set(['change', 'assignee'], acct);
+      this.$.restAPI.setAssignee(this.change._number, acct._account_id);
+    } else {
+      if (!this.change.assignee) {
+        return;
+      }
+      this.set(['change', 'assignee'], undefined);
+      this.$.restAPI.deleteAssignee(this.change._number);
+    }
+  }
+
+  _computeHideStrategy(change?: ParsedChangeInfo) {
+    return !changeIsOpen(change);
+  }
+
+  /**
+   * @return If array is empty, returns null instead so
+   * an existential check can be used to hide or show the webLinks
+   * section.
+   */
+  _computeWebLinks(
+    commitInfo?: CommitInfoWithRequiredCommit,
+    serverConfig?: ServerInfo
+  ) {
+    if (!commitInfo) {
+      return null;
+    }
+    const weblinks = GerritNav.getChangeWeblinks(
+      this.change ? this.change.project : ('' as RepoName),
+      commitInfo.commit,
+      {
+        weblinks: commitInfo.web_links,
+        config: serverConfig,
+      }
+    );
+    return weblinks.length ? weblinks : null;
+  }
+
+  _isAssigneeEnabled(serverConfig?: ServerInfo) {
+    return (
+      serverConfig &&
+      serverConfig.change &&
+      !!serverConfig.change.enable_assignee
+    );
+  }
+
+  _computeStrategy(change?: ParsedChangeInfo) {
+    if (!change?.submit_type) {
+      return '';
+    }
+
+    return SubmitTypeLabel.get(change.submit_type);
+  }
+
+  _computeLabelNames(labels?: LabelNameToInfoMap) {
+    return labels ? Object.keys(labels).sort() : [];
+  }
+
+  _handleTopicChanged(e: CustomEvent<string>) {
+    if (!this.change) {
+      throw new Error('change must be set');
+    }
+    const lastTopic = this.change.topic;
+    const topic = e.detail.length ? e.detail : null;
+    this._settingTopic = true;
+    const topicChangedForChangeNumber = this.change._number;
+    this.$.restAPI
+      .setChangeTopic(topicChangedForChangeNumber, topic)
+      .then(newTopic => {
+        if (
+          !this.change ||
+          this.change._number !== topicChangedForChangeNumber
+        ) {
+          return;
+        }
+        this._settingTopic = false;
+        this.set(['change', 'topic'], newTopic);
+        if (newTopic !== lastTopic) {
+          this.dispatchEvent(
+            new CustomEvent('topic-changed', {bubbles: true, composed: true})
+          );
+        }
+      });
+  }
+
+  _showAddTopic(
+    changeRecord: ElementPropertyDeepChange<GrChangeMetadata, 'change'>,
+    settingTopic?: boolean
+  ) {
+    const hasTopic =
+      !!changeRecord && !!changeRecord.base && !!changeRecord.base.topic;
+    return !hasTopic && !settingTopic;
+  }
+
+  _showTopicChip(
+    changeRecord: ElementPropertyDeepChange<GrChangeMetadata, 'change'>,
+    settingTopic?: boolean
+  ) {
+    const hasTopic =
+      !!changeRecord && !!changeRecord.base && !!changeRecord.base.topic;
+    return hasTopic && !settingTopic;
+  }
+
+  _showCherryPickOf(
+    changeRecord: ElementPropertyDeepChange<GrChangeMetadata, 'change'>
+  ) {
+    const hasCherryPickOf =
+      !!changeRecord &&
+      !!changeRecord.base &&
+      !!changeRecord.base.cherry_pick_of_change &&
+      !!changeRecord.base.cherry_pick_of_patch_set;
+    return hasCherryPickOf;
+  }
+
+  _handleHashtagChanged() {
+    if (!this.change) {
+      throw new Error('change must be set');
+    }
+    if (!this._newHashtag?.length) {
+      return;
+    }
+    const newHashtag = this._newHashtag;
+    this._newHashtag = '' as Hashtag;
+    this.$.restAPI
+      .setChangeHashtag(this.change._number, {add: [newHashtag]})
+      .then(newHashtag => {
+        this.set(['change', 'hashtags'], newHashtag);
+        this.dispatchEvent(
+          new CustomEvent('hashtag-changed', {
+            bubbles: true,
+            composed: true,
+          })
+        );
+      });
+  }
+
+  _computeTopicReadOnly(mutable?: boolean, change?: ParsedChangeInfo) {
+    return (
+      !mutable ||
+      !change ||
+      !change.actions ||
+      !change.actions.topic ||
+      !change.actions.topic.enabled
+    );
+  }
+
+  _computeHashtagReadOnly(mutable?: boolean, change?: ParsedChangeInfo) {
+    return (
+      !mutable ||
+      !change ||
+      !change.actions ||
+      !change.actions.hashtags ||
+      !change.actions.hashtags.enabled
+    );
+  }
+
+  _computeAssigneeReadOnly(mutable?: boolean, change?: ParsedChangeInfo) {
+    return (
+      !mutable ||
+      !change ||
+      !change.actions ||
+      !change.actions.assignee ||
+      !change.actions.assignee.enabled
+    );
+  }
+
+  _computeTopicPlaceholder(_topicReadOnly?: boolean) {
+    // Action items in Material Design are uppercase -- placeholder label text
+    // is sentence case.
+    return _topicReadOnly ? 'No topic' : 'ADD TOPIC';
+  }
+
+  _computeHashtagPlaceholder(_hashtagReadOnly?: boolean) {
+    return _hashtagReadOnly ? '' : HASHTAG_ADD_MESSAGE;
+  }
+
+  _computeShowRequirements(change?: ParsedChangeInfo) {
+    if (!change) {
+      return false;
+    }
+    if (change.status !== ChangeStatus.NEW) {
+      // TODO(maximeg) change this to display the stored
+      // requirements, once it is implemented server-side.
+      return false;
+    }
+    const hasRequirements =
+      !!change.requirements && Object.keys(change.requirements).length > 0;
+    const hasLabels = !!change.labels && Object.keys(change.labels).length > 0;
+    return hasRequirements || hasLabels || !!change.work_in_progress;
+  }
+
+  /**
+   * @return object representing data for the push validation.
+   */
+  _computePushCertificateValidation(
+    serverConfig?: ServerInfo,
+    change?: ParsedChangeInfo
+  ): PushCertifacteValidationInfo | null {
+    if (
+      !change ||
+      !serverConfig ||
+      !serverConfig.receive ||
+      !serverConfig.receive.enable_signed_push
+    ) {
+      return null;
+    }
+    const rev = change.revisions[change.current_revision];
+    if (!rev.push_certificate || !rev.push_certificate.key) {
+      return {
+        class: 'help',
+        icon: 'gr-icons:help',
+        message: 'This patch set was created without a push certificate',
+      };
+    }
+
+    const key = rev.push_certificate.key;
+    switch (key.status) {
+      case GpgKeyInfoStatus.BAD:
+        return {
+          class: 'invalid',
+          icon: 'gr-icons:close',
+          message: this._problems('Push certificate is invalid', key),
+        };
+      case GpgKeyInfoStatus.OK:
+        return {
+          class: 'notTrusted',
+          icon: 'gr-icons:info',
+          message: this._problems(
+            'Push certificate is valid, but key is not trusted',
+            key
+          ),
+        };
+      case GpgKeyInfoStatus.TRUSTED:
+        return {
+          class: 'trusted',
+          icon: 'gr-icons:check',
+          message: this._problems(
+            'Push certificate is valid and key is trusted',
+            key
+          ),
+        };
+      case undefined:
+        // TODO(TS): Process it correctly
+        throw new Error('deleted certificate');
+      default:
+        assertNever(key.status, `unknown certificate status: ${key.status}`);
+    }
+  }
+
+  _problems(msg: string, key: GpgKeyInfo) {
+    if (!key || !key.problems || key.problems.length === 0) {
+      return msg;
+    }
+
+    return [msg + ':'].concat(key.problems).join('\n');
+  }
+
+  _computeShowRepoBranchTogether(repo?: RepoName, branch?: BranchName) {
+    return !!repo && !!branch && repo.length + branch.length < 40;
+  }
+
+  _computeProjectUrl(project?: RepoName) {
+    if (!project) return '';
+    return GerritNav.getUrlForProjectChanges(project);
+  }
+
+  _computeBranchUrl(project?: RepoName, branch?: BranchName) {
+    if (!project || !branch || !this.change || !this.change.status) return '';
+    return GerritNav.getUrlForBranch(
+      branch,
+      project,
+      this.change.status === ChangeStatus.NEW
+        ? 'open'
+        : this.change.status.toLowerCase()
+    );
+  }
+
+  _computeCherryPickOfUrl(
+    change?: NumericChangeId,
+    patchset?: PatchSetNum,
+    project?: RepoName
+  ) {
+    if (!change || !project) {
+      return '';
+    }
+    return GerritNav.getUrlForChangeById(change, project, patchset);
+  }
+
+  _computeTopicUrl(topic: TopicName) {
+    return GerritNav.getUrlForTopic(topic);
+  }
+
+  _computeHashtagUrl(hashtag: Hashtag) {
+    return GerritNav.getUrlForHashtag(hashtag);
+  }
+
+  _handleTopicRemoved(e: CustomEvent) {
+    if (!this.change) {
+      throw new Error('change must be set');
+    }
+    const target = (dom(e) as EventApi).rootTarget as GrLinkedChip;
+    target.disabled = true;
+    this.$.restAPI
+      .setChangeTopic(this.change._number, null)
+      .then(() => {
+        target.disabled = false;
+        this.set(['change', 'topic'], '');
+        this.dispatchEvent(
+          new CustomEvent('topic-changed', {bubbles: true, composed: true})
+        );
+      })
+      .catch(() => {
+        target.disabled = false;
+        return;
+      });
+  }
+
+  _handleHashtagRemoved(e: CustomEvent) {
+    e.preventDefault();
+    if (!this.change) {
+      throw new Error('change must be set');
+    }
+    const target = (dom(e) as EventApi).rootTarget as GrLinkedChip;
+    target.disabled = true;
+    this.$.restAPI
+      .setChangeHashtag(this.change._number, {remove: [target.text as Hashtag]})
+      .then(newHashtags => {
+        target.disabled = false;
+        this.set(['change', 'hashtags'], newHashtags);
+      })
+      .catch(() => {
+        target.disabled = false;
+        return;
+      });
+  }
+
+  _computeIsWip(change?: ParsedChangeInfo) {
+    return change && !!change.work_in_progress;
+  }
+
+  _computeShowRoleClass(change?: ParsedChangeInfo, role?: ChangeRole) {
+    return this._getNonOwnerRole(change, role) ? '' : 'hideDisplay';
+  }
+
+  /**
+   * Get the user with the specified role on the change. Returns null 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;
+    }
+
+    const rev = change.revisions[change.current_revision];
+    if (!rev) {
+      return null;
+    }
+
+    if (
+      role === ChangeRole.UPLOADER &&
+      rev.uploader &&
+      change.owner._account_id !== rev.uploader._account_id
+    ) {
+      return rev.uploader;
+    }
+
+    if (
+      role === ChangeRole.AUTHOR &&
+      rev.commit &&
+      rev.commit.author &&
+      change.owner.email !== rev.commit.author.email
+    ) {
+      return rev.commit.author;
+    }
+
+    if (
+      role === ChangeRole.COMMITTER &&
+      rev.commit &&
+      rev.commit.committer &&
+      change.owner.email !== rev.commit.committer.email
+    ) {
+      return rev.commit.committer;
+    }
+
+    return null;
+  }
+
+  _computeParents(
+    change?: ParsedChangeInfo,
+    revision?: RevisionInfo
+  ): ParentCommitInfo[] {
+    if (!revision || !revision.commit) {
+      if (!change || !change.current_revision) {
+        return [];
+      }
+      revision = change.revisions[change.current_revision];
+      if (!revision || !revision.commit) {
+        return [];
+      }
+    }
+    return revision.commit.parents;
+  }
+
+  _computeParentsLabel(parents?: ParentCommitInfo[]) {
+    return parents && parents.length > 1 ? 'Parents' : 'Parent';
+  }
+
+  _computeParentListClass(
+    parents?: ParentCommitInfo[],
+    parentIsCurrent?: boolean
+  ) {
+    // Undefined check for polymer 2
+    if (parents === undefined || parentIsCurrent === undefined) {
+      return '';
+    }
+
+    return [
+      'parentList',
+      parents && parents.length > 1 ? 'merge' : 'nonMerge',
+      parentIsCurrent ? 'current' : 'notCurrent',
+    ].join(' ');
+  }
+
+  _computeIsMutable(account?: AccountDetailInfo) {
+    return account && !!Object.keys(account).length;
+  }
+
+  editTopic() {
+    if (this._topicReadOnly || !this.change || this.change.topic) {
+      return;
+    }
+    // Cannot use `this.$.ID` syntax because the element exists inside of a
+    // dom-if.
+    (this.shadowRoot!.querySelector(
+      '.topicEditableLabel'
+    ) as GrEditableLabel).open();
+  }
+
+  _getReviewerSuggestionsProvider(change?: ParsedChangeInfo) {
+    if (!change) {
+      return undefined;
+    }
+    const provider = GrReviewerSuggestionsProvider.create(
+      this.$.restAPI,
+      change._number,
+      SUGGESTIONS_PROVIDERS_USERS_TYPES.ANY
+    );
+    provider.init();
+    return provider;
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-change-metadata': GrChangeMetadata;
+  }
+}
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 bdcd480..5f53641 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
@@ -686,7 +686,7 @@
       const newTopic = 'the new topic';
       sinon.stub(element.$.restAPI, 'setChangeTopic').returns(
           Promise.resolve(newTopic));
-      element._handleTopicChanged({}, newTopic);
+      element._handleTopicChanged({detail: newTopic});
       const topicChangedSpy = sinon.spy();
       element.addEventListener('topic-changed', topicChangedSpy);
       assert.isTrue(element.$.restAPI.setChangeTopic.calledWith(
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.js b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.js
index e317cf9..20fe1a6 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.js
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.js
@@ -37,7 +37,6 @@
 import '../gr-commit-info/gr-commit-info.js';
 import '../gr-download-dialog/gr-download-dialog.js';
 import '../gr-file-list-header/gr-file-list-header.js';
-import '../gr-file-list/gr-file-list.js';
 import '../gr-included-in-dialog/gr-included-in-dialog.js';
 import '../gr-messages-list/gr-messages-list.js';
 import '../gr-related-changes-list/gr-related-changes-list.js';
@@ -73,6 +72,7 @@
 } from '../../../utils/patch-set-util.js';
 import {changeStatuses, changeStatusString} from '../../../utils/change-util.js';
 import {EventType} from '../../plugins/gr-plugin-types.js';
+import {DEFAULT_NUM_FILES_SHOWN} from '../gr-file-list/gr-file-list.js';
 
 const CHANGE_ID_ERROR = {
   MISMATCH: 'mismatch',
@@ -82,7 +82,6 @@
   /^(Change-Id\:\s|Link:.*\/id\/)(I[0-9a-f]{8,40})/gm;
 
 const MIN_LINES_FOR_COMMIT_COLLAPSE = 30;
-const DEFAULT_NUM_FILES_SHOWN = 200;
 
 const REVIEWERS_REGEX = /^(R|CC)=/gm;
 const MIN_CHECK_INTERVAL_SECS = 0;
@@ -1058,6 +1057,13 @@
       patchNum: value.patchNum,
       basePatchNum: value.basePatchNum || 'PARENT',
     };
+    // TODO(TS): remove once proper type for patchRange is defined
+    if (!isNaN(Number(patchRange.patchNum))) {
+      patchRange.patchNum = Number(patchRange.patchNum);
+    }
+    if (!isNaN(Number(patchRange.basePatchNum))) {
+      patchRange.basePatchNum = Number(patchRange.basePatchNum);
+    }
 
     this.$.fileList.collapseAllDiffs();
     this._patchRange = patchRange;
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 f0b8372..6af8960 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
@@ -637,6 +637,7 @@
           logged-in="[[_loggedIn]]"
           only-show-robot-comments-with-human-reply=""
           on-thread-list-modified="_handleReloadDiffComments"
+          unresolved-only
         ></gr-thread-list>
       </template>
       <template
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.ts b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.ts
index ae9254a..e05bac0 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.ts
@@ -31,6 +31,7 @@
   BranchInfo,
   RepoName,
   BranchName,
+  CommitId,
 } from '../../../types/common';
 import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
 import {customElement, property, observe} from '@polymer/decorators';
@@ -105,7 +106,7 @@
   commitMessage?: string;
 
   @property({type: String})
-  commitNum?: string;
+  commitNum?: CommitId;
 
   @property({type: String})
   message?: string;
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.ts b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.ts
index 00b9906..db0e1ff 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.ts
@@ -37,6 +37,10 @@
   value: NumericChangeId;
 }
 
+export interface ConfirmRebaseEventDetail {
+  base: string | null;
+}
+
 export interface GrConfirmRebaseDialog {
   $: {
     restAPI: RestApiService & Element;
@@ -187,9 +191,10 @@
   _handleConfirmTap(e: Event) {
     e.preventDefault();
     e.stopPropagation();
-    this.dispatchEvent(
-      new CustomEvent('confirm', {detail: {base: this._getSelectedBase()}})
-    );
+    const detail: ConfirmRebaseEventDetail = {
+      base: this._getSelectedBase(),
+    };
+    this.dispatchEvent(new CustomEvent('confirm', {detail}));
     this._text = '';
   }
 
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 beaf0f8..5c0b19f 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
@@ -30,10 +30,15 @@
 const CHANGE_SUBJECT_LIMIT = 50;
 
 // TODO(dhruvsri): clean up repeated definitions after moving to js modules
-const REVERT_TYPES = {
-  REVERT_SINGLE_CHANGE: 1,
-  REVERT_SUBMISSION: 2,
-};
+export enum RevertType {
+  REVERT_SINGLE_CHANGE = 1,
+  REVERT_SUBMISSION = 2,
+}
+
+export interface ConfirmRevertEventDetail {
+  revertType: RevertType;
+  message?: string;
+}
 
 export interface GrConfirmRevertDialog {
   $: {
@@ -66,7 +71,7 @@
   _message?: string;
 
   @property({type: Number})
-  _revertType = REVERT_TYPES.REVERT_SINGLE_CHANGE;
+  _revertType = RevertType.REVERT_SINGLE_CHANGE;
 
   @property({type: Boolean})
   _showRevertSubmission = false;
@@ -88,11 +93,11 @@
   _revertMessages: string[] = [];
 
   _computeIfSingleRevert(revertType: number) {
-    return revertType === REVERT_TYPES.REVERT_SINGLE_CHANGE;
+    return revertType === RevertType.REVERT_SINGLE_CHANGE;
   }
 
   _computeIfRevertSubmission(revertType: number) {
-    return revertType === REVERT_TYPES.REVERT_SUBMISSION;
+    return revertType === RevertType.REVERT_SUBMISSION;
   }
 
   _modifyRevertMsg(change: ChangeInfo, commitMessage: string, message: string) {
@@ -135,7 +140,7 @@
       'Reason for revert: <INSERT REASONING HERE>\n';
     // This is to give plugins a chance to update message
     this._message = this._modifyRevertMsg(change, commitMessage, message);
-    this._revertType = REVERT_TYPES.REVERT_SINGLE_CHANGE;
+    this._revertType = RevertType.REVERT_SINGLE_CHANGE;
     this._showRevertSubmission = false;
     this._revertMessages[this._revertType] = this._message;
     this._originalRevertMessages[this._revertType] = this._message;
@@ -190,7 +195,7 @@
       message,
       commitMessage
     );
-    this._revertType = REVERT_TYPES.REVERT_SUBMISSION;
+    this._revertType = RevertType.REVERT_SUBMISSION;
     this._revertMessages[this._revertType] = this._message;
     this._originalRevertMessages[this._revertType] = this._message;
     this._showRevertSubmission = true;
@@ -199,17 +204,17 @@
   _handleRevertSingleChangeClicked() {
     this._showErrorMessage = false;
     if (this._message)
-      this._revertMessages[REVERT_TYPES.REVERT_SUBMISSION] = this._message;
-    this._message = this._revertMessages[REVERT_TYPES.REVERT_SINGLE_CHANGE];
-    this._revertType = REVERT_TYPES.REVERT_SINGLE_CHANGE;
+      this._revertMessages[RevertType.REVERT_SUBMISSION] = this._message;
+    this._message = this._revertMessages[RevertType.REVERT_SINGLE_CHANGE];
+    this._revertType = RevertType.REVERT_SINGLE_CHANGE;
   }
 
   _handleRevertSubmissionClicked() {
     this._showErrorMessage = false;
-    this._revertType = REVERT_TYPES.REVERT_SUBMISSION;
+    this._revertType = RevertType.REVERT_SUBMISSION;
     if (this._message)
-      this._revertMessages[REVERT_TYPES.REVERT_SINGLE_CHANGE] = this._message;
-    this._message = this._revertMessages[REVERT_TYPES.REVERT_SUBMISSION];
+      this._revertMessages[RevertType.REVERT_SINGLE_CHANGE] = this._message;
+    this._message = this._revertMessages[RevertType.REVERT_SUBMISSION];
   }
 
   _handleConfirmTap(e: MouseEvent) {
@@ -219,9 +224,13 @@
       this._showErrorMessage = true;
       return;
     }
+    const detail: ConfirmRevertEventDetail = {
+      revertType: this._revertType,
+      message: this._message,
+    };
     this.dispatchEvent(
       new CustomEvent('confirm', {
-        detail: {revertType: this._revertType, message: this._message},
+        detail,
         composed: true,
         bubbles: false,
       })
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.js b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.js
deleted file mode 100644
index bf45eb6..0000000
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.js
+++ /dev/null
@@ -1,1614 +0,0 @@
-/**
- * @license
- * Copyright (C) 2015 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 '../../../styles/shared-styles.js';
-import '../../diff/gr-diff-cursor/gr-diff-cursor.js';
-import '../../diff/gr-diff-host/gr-diff-host.js';
-import '../../diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog.js';
-import '../../edit/gr-edit-file-controls/gr-edit-file-controls.js';
-import '../../shared/gr-button/gr-button.js';
-import '../../shared/gr-cursor-manager/gr-cursor-manager.js';
-import '../../shared/gr-icons/gr-icons.js';
-import '../../shared/gr-linked-text/gr-linked-text.js';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
-import '../../shared/gr-select/gr-select.js';
-import '../../shared/gr-tooltip-content/gr-tooltip-content.js';
-import '../../shared/gr-copy-clipboard/gr-copy-clipboard.js';
-import {flush} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
-import {PolymerElement} from '@polymer/polymer/polymer-element.js';
-import {htmlTemplate} from './gr-file-list_html.js';
-import {asyncForeach} from '../../../utils/async-util.js';
-import {KeyboardShortcutMixin, Shortcut} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.js';
-import {FilesExpandedState} from '../gr-file-list-constants.js';
-import {GrCountStringFormatter} from '../../shared/gr-count-string-formatter/gr-count-string-formatter.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-import {getPluginEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints.js';
-import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
-import {appContext} from '../../../services/app-context.js';
-import {SpecialFilePath} from '../../../constants/constants.js';
-import {descendedFromClass} from '../../../utils/dom-util.js';
-import {
-  addUnmodifiedFiles,
-  computeDisplayPath,
-  computeTruncatedPath,
-  isMagicPath,
-  specialFilePathCompare,
-} from '../../../utils/path-list-util.js';
-
-const WARN_SHOW_ALL_THRESHOLD = 1000;
-const LOADING_DEBOUNCE_INTERVAL = 100;
-
-const SIZE_BAR_MAX_WIDTH = 61;
-const SIZE_BAR_GAP_WIDTH = 1;
-const SIZE_BAR_MIN_WIDTH = 1.5;
-
-const RENDER_TIMING_LABEL = 'FileListRenderTime';
-const RENDER_AVG_TIMING_LABEL = 'FileListRenderTimePerFile';
-const EXPAND_ALL_TIMING_LABEL = 'ExpandAllDiffs';
-const EXPAND_ALL_AVG_TIMING_LABEL = 'ExpandAllPerDiff';
-
-const FileStatus = {
-  A: 'Added',
-  C: 'Copied',
-  D: 'Deleted',
-  M: 'Modified',
-  R: 'Renamed',
-  W: 'Rewritten',
-  U: 'Unchanged',
-};
-
-const FILE_ROW_CLASS = 'file-row';
-
-/**
- * Type for FileInfo
- *
- * This should match with the type returned from `files` API plus
- * additional info like `__path`.
- *
- * @typedef {Object} FileInfo
- * @property {string} __path
- * @property {?string} old_path
- * @property {number} size
- * @property {number} size_delta - fallback to 0 if not present in api
- * @property {number} lines_deleted - fallback to 0 if not present in api
- * @property {number} lines_inserted - fallback to 0 if not present in api
- */
-
-/**
- * @extends PolymerElement
- */
-class GrFileList extends KeyboardShortcutMixin(
-    GestureEventListeners(
-        LegacyElementMixin(PolymerElement))) {
-  static get template() { return htmlTemplate; }
-
-  static get is() { return 'gr-file-list'; }
-  /**
-   * Fired when a draft refresh should get triggered
-   *
-   * @event reload-drafts
-   */
-
-  static get properties() {
-    return {
-    /** @type {?} */
-      patchRange: Object,
-      patchNum: String,
-      changeNum: String,
-      /** @type {?} */
-      changeComments: Object,
-      drafts: Object,
-      revisions: Array,
-      projectConfig: Object,
-      selectedIndex: {
-        type: Number,
-        notify: true,
-      },
-      keyEventTarget: {
-        type: Object,
-        value() { return document.body; },
-      },
-      /** @type {?} */
-      change: Object,
-      diffViewMode: {
-        type: String,
-        notify: true,
-        observer: '_updateDiffPreferences',
-      },
-      editMode: {
-        type: Boolean,
-        observer: '_editModeChanged',
-      },
-      filesExpanded: {
-        type: String,
-        value: FilesExpandedState.NONE,
-        notify: true,
-      },
-      _filesByPath: Object,
-
-      /** @type {!Array<FileInfo>} */
-      _files: {
-        type: Array,
-        observer: '_filesChanged',
-        value() { return []; },
-      },
-      _loggedIn: {
-        type: Boolean,
-        value: false,
-      },
-      _reviewed: {
-        type: Array,
-        value() { return []; },
-      },
-      diffPrefs: {
-        type: Object,
-        notify: true,
-        observer: '_updateDiffPreferences',
-      },
-      /** @type {?} */
-      _userPrefs: Object,
-      _showInlineDiffs: Boolean,
-      numFilesShown: {
-        type: Number,
-        notify: true,
-      },
-      /** @type {?} */
-      _patchChange: {
-        type: Object,
-        computed: '_calculatePatchChange(_files)',
-      },
-      fileListIncrement: Number,
-      _hideChangeTotals: {
-        type: Boolean,
-        computed: '_shouldHideChangeTotals(_patchChange)',
-      },
-      _hideBinaryChangeTotals: {
-        type: Boolean,
-        computed: '_shouldHideBinaryChangeTotals(_patchChange)',
-      },
-
-      _shownFiles: {
-        type: Array,
-        computed: '_computeFilesShown(numFilesShown, _files)',
-      },
-
-      /**
-       * The amount of files added to the shown files list the last time it was
-       * updated. This is used for reporting the average render time.
-       */
-      _reportinShownFilesIncrement: Number,
-
-      /** @type {!Array<Gerrit.FileRange>} */
-      _expandedFiles: {
-        type: Array,
-        value() { return []; },
-      },
-      _displayLine: Boolean,
-      _loading: {
-        type: Boolean,
-        observer: '_loadingChanged',
-      },
-      /** @type {Gerrit.LayoutStats|undefined} */
-      _sizeBarLayout: {
-        type: Object,
-        computed: '_computeSizeBarLayout(_shownFiles.*)',
-      },
-
-      _showSizeBars: {
-        type: Boolean,
-        value: true,
-        computed: '_computeShowSizeBars(_userPrefs)',
-      },
-
-      /** @type {Function} */
-      _cancelForEachDiff: Function,
-
-      _showDynamicColumns: {
-        type: Boolean,
-        computed: '_computeShowDynamicColumns(_dynamicHeaderEndpoints, ' +
-                '_dynamicContentEndpoints, _dynamicSummaryEndpoints)',
-      },
-      _showPrependedDynamicColumns: {
-        type: Boolean,
-        computed: '_computeShowPrependedDynamicColumns(' +
-        '_dynamicPrependedHeaderEndpoints, _dynamicPrependedContentEndpoints)',
-      },
-      /** @type {Array<string>} */
-      _dynamicHeaderEndpoints: {
-        type: Array,
-      },
-      /** @type {Array<string>} */
-      _dynamicContentEndpoints: {
-        type: Array,
-      },
-      /** @type {Array<string>} */
-      _dynamicSummaryEndpoints: {
-        type: Array,
-      },
-      /** @type {Array<string>} */
-      _dynamicPrependedHeaderEndpoints: {
-        type: Array,
-      },
-      /** @type {Array<string>} */
-      _dynamicPrependedContentEndpoints: {
-        type: Array,
-      },
-    };
-  }
-
-  static get observers() {
-    return [
-      '_expandedFilesChanged(_expandedFiles.splices)',
-      '_computeFiles(_filesByPath, changeComments, patchRange, _reviewed, ' +
-        '_loading)',
-    ];
-  }
-
-  get keyBindings() {
-    return {
-      esc: '_handleEscKey',
-    };
-  }
-
-  keyboardShortcuts() {
-    return {
-      [Shortcut.LEFT_PANE]: '_handleLeftPane',
-      [Shortcut.RIGHT_PANE]: '_handleRightPane',
-      [Shortcut.TOGGLE_INLINE_DIFF]: '_handleToggleInlineDiff',
-      [Shortcut.TOGGLE_ALL_INLINE_DIFFS]: '_handleToggleAllInlineDiffs',
-      [Shortcut.TOGGLE_HIDE_ALL_COMMENT_THREADS]:
-        '_handleToggleHideAllCommentThreads',
-      [Shortcut.CURSOR_NEXT_FILE]: '_handleCursorNext',
-      [Shortcut.CURSOR_PREV_FILE]: '_handleCursorPrev',
-      [Shortcut.NEXT_LINE]: '_handleCursorNext',
-      [Shortcut.PREV_LINE]: '_handleCursorPrev',
-      [Shortcut.NEW_COMMENT]: '_handleNewComment',
-      [Shortcut.OPEN_LAST_FILE]: '_handleOpenLastFile',
-      [Shortcut.OPEN_FIRST_FILE]: '_handleOpenFirstFile',
-      [Shortcut.OPEN_FILE]: '_handleOpenFile',
-      [Shortcut.NEXT_CHUNK]: '_handleNextChunk',
-      [Shortcut.PREV_CHUNK]: '_handlePrevChunk',
-      [Shortcut.TOGGLE_FILE_REVIEWED]: '_handleToggleFileReviewed',
-      [Shortcut.TOGGLE_LEFT_PANE]: '_handleToggleLeftPane',
-
-      // Final two are actually handled by gr-comment-thread.
-      [Shortcut.EXPAND_ALL_COMMENT_THREADS]: null,
-      [Shortcut.COLLAPSE_ALL_COMMENT_THREADS]: null,
-    };
-  }
-
-  constructor() {
-    super();
-    this.reporting = appContext.reportingService;
-  }
-
-  /** @override */
-  created() {
-    super.created();
-    this.addEventListener('keydown',
-        e => this._scopedKeydownHandler(e));
-  }
-
-  /** @override */
-  attached() {
-    super.attached();
-    getPluginLoader().awaitPluginsLoaded()
-        .then(() => {
-          this._dynamicHeaderEndpoints = getPluginEndpoints()
-              .getDynamicEndpoints('change-view-file-list-header');
-          this._dynamicContentEndpoints = getPluginEndpoints()
-              .getDynamicEndpoints('change-view-file-list-content');
-          this._dynamicPrependedHeaderEndpoints = getPluginEndpoints()
-              .getDynamicEndpoints('change-view-file-list-header-prepend');
-          this._dynamicPrependedContentEndpoints = getPluginEndpoints()
-              .getDynamicEndpoints('change-view-file-list-content-prepend');
-          this._dynamicSummaryEndpoints = getPluginEndpoints()
-              .getDynamicEndpoints('change-view-file-list-summary');
-
-          if (this._dynamicHeaderEndpoints.length !==
-          this._dynamicContentEndpoints.length) {
-            console.warn(
-                'Different number of dynamic file-list header and content.');
-          }
-          if (this._dynamicPrependedHeaderEndpoints.length !==
-        this._dynamicPrependedContentEndpoints.length) {
-            console.warn(
-                'Different number of dynamic file-list header and content.');
-          }
-          if (this._dynamicHeaderEndpoints.length !==
-          this._dynamicSummaryEndpoints.length) {
-            console.warn(
-                'Different number of dynamic file-list headers and summary.');
-          }
-        });
-  }
-
-  /** @override */
-  detached() {
-    super.detached();
-    this._cancelDiffs();
-  }
-
-  /**
-   * Iron-a11y-keys-behavior catches keyboard events globally. Some keyboard
-   * events must be scoped to a component level (e.g. `enter`) in order to not
-   * override native browser functionality.
-   *
-   * Context: Issue 7277
-   */
-  _scopedKeydownHandler(e) {
-    if (e.keyCode === 13) {
-      // Enter.
-      this._handleOpenFile(e);
-    }
-  }
-
-  reload() {
-    if (!this.changeNum || !this.patchRange.patchNum) {
-      return Promise.resolve();
-    }
-
-    this._loading = true;
-
-    this.collapseAllDiffs();
-    const promises = [];
-
-    promises.push(this._getFiles().then(filesByPath => {
-      this._filesByPath = filesByPath;
-    }));
-    promises.push(this._getLoggedIn()
-        .then(loggedIn => this._loggedIn = loggedIn)
-        .then(loggedIn => {
-          if (!loggedIn) { return; }
-
-          return this._getReviewedFiles().then(reviewed => {
-            this._reviewed = reviewed;
-          });
-        }));
-
-    promises.push(this._getDiffPreferences().then(prefs => {
-      this.diffPrefs = prefs;
-    }));
-
-    promises.push(this._getPreferences().then(prefs => {
-      this._userPrefs = prefs;
-    }));
-
-    return Promise.all(promises).then(() => {
-      this._loading = false;
-      this._detectChromiteButler();
-      this.reporting.fileListDisplayed();
-    });
-  }
-
-  _detectChromiteButler() {
-    const hasButler = !!document.getElementById('butler-suggested-owners');
-    if (hasButler) {
-      this.reporting.reportExtension('butler');
-    }
-  }
-
-  get diffs() {
-    const diffs = this.root.querySelectorAll('gr-diff-host');
-    // It is possible that a bogus diff element is hanging around invisibly
-    // from earlier with a different patch set choice and associated with a
-    // different entry in the files array. So filter on visible items only.
-    return Array.from(diffs).filter(
-        el => !!el && !!el.style && el.style.display !== 'none');
-  }
-
-  openDiffPrefs() {
-    this.$.diffPreferencesDialog.open();
-  }
-
-  _calculatePatchChange(files) {
-    const magicFilesExcluded = files.filter(files =>
-      !isMagicPath(files.__path)
-    );
-
-    return magicFilesExcluded.reduce((acc, obj) => {
-      const inserted = obj.lines_inserted ? obj.lines_inserted : 0;
-      const deleted = obj.lines_deleted ? obj.lines_deleted : 0;
-      const total_size = (obj.size && obj.binary) ? obj.size : 0;
-      const size_delta_inserted =
-          obj.binary && obj.size_delta > 0 ? obj.size_delta : 0;
-      const size_delta_deleted =
-          obj.binary && obj.size_delta < 0 ? obj.size_delta : 0;
-
-      return {
-        inserted: acc.inserted + inserted,
-        deleted: acc.deleted + deleted,
-        size_delta_inserted: acc.size_delta_inserted + size_delta_inserted,
-        size_delta_deleted: acc.size_delta_deleted + size_delta_deleted,
-        total_size: acc.total_size + total_size,
-      };
-    }, {inserted: 0, deleted: 0, size_delta_inserted: 0,
-      size_delta_deleted: 0, total_size: 0});
-  }
-
-  _getDiffPreferences() {
-    return this.$.restAPI.getDiffPreferences();
-  }
-
-  _getPreferences() {
-    return this.$.restAPI.getPreferences();
-  }
-
-  _toggleFileExpanded(file) {
-    // Is the path in the list of expanded diffs? IF so remove it, otherwise
-    // add it to the list.
-    const pathIndex = this._expandedFiles.findIndex(f => f.path === file.path);
-    if (pathIndex === -1) {
-      this.push('_expandedFiles', file);
-    } else {
-      this.splice('_expandedFiles', pathIndex, 1);
-    }
-  }
-
-  _toggleFileExpandedByIndex(index) {
-    this._toggleFileExpanded(this._computeFileRange(this._files[index]));
-  }
-
-  _updateDiffPreferences() {
-    if (!this.diffs.length) { return; }
-    // Re-render all expanded diffs sequentially.
-    this.reporting.time(EXPAND_ALL_TIMING_LABEL);
-    this._renderInOrder(this._expandedFiles, this.diffs,
-        this._expandedFiles.length);
-  }
-
-  _forEachDiff(fn) {
-    const diffs = this.diffs;
-    for (let i = 0; i < diffs.length; i++) {
-      fn(diffs[i]);
-    }
-  }
-
-  expandAllDiffs() {
-    this._showInlineDiffs = true;
-
-    // Find the list of paths that are in the file list, but not in the
-    // expanded list.
-    const newFiles = [];
-    let path;
-    for (let i = 0; i < this._shownFiles.length; i++) {
-      path = this._shownFiles[i].__path;
-      if (!this._expandedFiles.some(f => f.path === path)) {
-        newFiles.push(this._computeFileRange(this._shownFiles[i]));
-      }
-    }
-
-    this.splice(...['_expandedFiles', 0, 0].concat(newFiles));
-  }
-
-  collapseAllDiffs() {
-    this._showInlineDiffs = false;
-    this._expandedFiles = [];
-    this.filesExpanded = this._computeExpandedFiles(
-        this._expandedFiles.length, this._files.length);
-    this.$.diffCursor.handleDiffUpdate();
-  }
-
-  /**
-   * Computes a string with the number of comments and unresolved comments.
-   *
-   * @param {!Object} changeComments
-   * @param {!Object} patchRange
-   * @param {string} path
-   * @return {string}
-   */
-  _computeCommentsString(changeComments, patchRange, path) {
-    if ([changeComments, patchRange, path].includes(undefined)) {
-      return '';
-    }
-    const unresolvedCount =
-        changeComments.computeUnresolvedNum({
-          patchNum: patchRange.basePatchNum,
-          path,
-        }) +
-        changeComments.computeUnresolvedNum({
-          patchNum: patchRange.patchNum,
-          path,
-        });
-    const commentCount =
-        changeComments.computeCommentCount({
-          patchNum: patchRange.basePatchNum,
-          path,
-        }) +
-        changeComments.computeCommentCount({
-          patchNum: patchRange.patchNum,
-          path,
-        });
-    const commentString = GrCountStringFormatter.computePluralString(
-        commentCount, 'comment');
-    const unresolvedString = GrCountStringFormatter.computeString(
-        unresolvedCount, 'unresolved');
-
-    return commentString +
-        // Add a space if both comments and unresolved
-        (commentString && unresolvedString ? ' ' : '') +
-        // Add parentheses around unresolved if it exists.
-        (unresolvedString ? `(${unresolvedString})` : '');
-  }
-
-  /**
-   * Computes a string with the number of drafts.
-   *
-   * @param {!Object} changeComments
-   * @param {!Object} patchRange
-   * @param {string} path
-   * @return {string}
-   */
-  _computeDraftsString(changeComments, patchRange, path) {
-    if ([changeComments, patchRange, path].includes(undefined)) {
-      return '';
-    }
-    const draftCount =
-        changeComments.computeDraftCount({
-          patchNum: patchRange.basePatchNum,
-          path,
-        }) +
-        changeComments.computeDraftCount({
-          patchNum: patchRange.patchNum,
-          path,
-        });
-    return GrCountStringFormatter.computePluralString(draftCount, 'draft');
-  }
-
-  /**
-   * Computes a shortened string with the number of drafts.
-   *
-   * @param {!Object} changeComments
-   * @param {!Object} patchRange
-   * @param {string} path
-   * @return {string}
-   */
-  _computeDraftsStringMobile(changeComments, patchRange, path) {
-    if ([changeComments, patchRange, path].includes(undefined)) {
-      return '';
-    }
-    const draftCount =
-        changeComments.computeDraftCount({
-          patchNum: patchRange.basePatchNum,
-          path,
-        }) +
-        changeComments.computeDraftCount({
-          patchNum: patchRange.patchNum,
-          path,
-        });
-    return GrCountStringFormatter.computeShortString(draftCount, 'd');
-  }
-
-  /**
-   * Computes a shortened string with the number of comments.
-   *
-   * @param {!Object} changeComments
-   * @param {!Object} patchRange
-   * @param {string} path
-   * @return {string}
-   */
-  _computeCommentsStringMobile(changeComments, patchRange, path) {
-    if ([changeComments, patchRange, path].includes(undefined)) {
-      return '';
-    }
-    const commentCount =
-        changeComments.computeCommentCount({
-          patchNum: patchRange.basePatchNum,
-          path,
-        }) +
-        changeComments.computeCommentCount({
-          patchNum: patchRange.patchNum,
-          path,
-        });
-    return GrCountStringFormatter.computeShortString(commentCount, 'c');
-  }
-
-  /**
-   * @param {string} path
-   * @param {boolean=} opt_reviewed
-   */
-  _reviewFile(path, opt_reviewed) {
-    if (this.editMode) { return; }
-    const index = this._files.findIndex(file => file.__path === path);
-    const reviewed = opt_reviewed || !this._files[index].isReviewed;
-
-    this.set(['_files', index, 'isReviewed'], reviewed);
-    if (index < this._shownFiles.length) {
-      this.notifyPath(`_shownFiles.${index}.isReviewed`);
-    }
-
-    this._saveReviewedState(path, reviewed);
-  }
-
-  _saveReviewedState(path, reviewed) {
-    return this.$.restAPI.saveFileReviewed(this.changeNum,
-        this.patchRange.patchNum, path, reviewed);
-  }
-
-  _getLoggedIn() {
-    return this.$.restAPI.getLoggedIn();
-  }
-
-  _getReviewedFiles() {
-    if (this.editMode) { return Promise.resolve([]); }
-    return this.$.restAPI.getReviewedFiles(this.changeNum,
-        this.patchRange.patchNum);
-  }
-
-  _getFiles() {
-    return this.$.restAPI.getChangeOrEditFiles(
-        this.changeNum, this.patchRange);
-  }
-
-  /**
-   *
-   * @returns {!Array<FileInfo>}
-   */
-  _normalizeChangeFilesResponse(response) {
-    if (!response) { return []; }
-    const paths = Object.keys(response).sort(specialFilePathCompare);
-    const files = [];
-    for (let i = 0; i < paths.length; i++) {
-      const info = response[paths[i]];
-      info.__path = paths[i];
-      info.lines_inserted = info.lines_inserted || 0;
-      info.lines_deleted = info.lines_deleted || 0;
-      info.size_delta = info.size_delta || 0;
-      files.push(info);
-    }
-    return files;
-  }
-
-  /**
-   * Returns true if the event e is a click on an element.
-   *
-   * The click is: mouse click or pressing Enter or Space key
-   * P.S> Screen readers sends click event as well
-   */
-  _isClickEvent(e) {
-    if (e.type === 'click') {
-      return true;
-    }
-    const isSpaceOrEnter = (e.key === 'Enter' || e.key === ' ');
-    return e.type === 'keydown' && isSpaceOrEnter;
-  }
-
-  _fileActionClick(e, fileAction) {
-    if (this._isClickEvent(e)) {
-      const fileRow = this._getFileRowFromEvent(e);
-      if (!fileRow) {
-        return;
-      }
-      // Prevent default actions (e.g. scrolling for space key)
-      e.preventDefault();
-      // Prevent _handleFileListClick handler call
-      e.stopPropagation();
-      this.$.fileCursor.setCursor(fileRow.element);
-      fileAction(fileRow.file);
-    }
-  }
-
-  _reviewedClick(e) {
-    this._fileActionClick(e,
-        file => this._reviewFile(file.path));
-  }
-
-  _expandedClick(e) {
-    this._fileActionClick(e,
-        file => this._toggleFileExpanded(file));
-  }
-
-  /**
-   * Handle all events from the file list dom-repeat so event handleers don't
-   * have to get registered for potentially very long lists.
-   */
-  _handleFileListClick(e) {
-    const fileRow = this._getFileRowFromEvent(e);
-    if (!fileRow) {
-      return;
-    }
-    const file = fileRow.file;
-    const path = file.path;
-    // If a path cannot be interpreted from the click target (meaning it's not
-    // somewhere in the row, e.g. diff content) or if the user clicked the
-    // link, defer to the native behavior.
-    if (!path || descendedFromClass(e.target, 'pathLink')) { return; }
-
-    // Disregard the event if the click target is in the edit controls.
-    if (descendedFromClass(e.target, 'editFileControls')) { return; }
-
-    e.preventDefault();
-    this.$.fileCursor.setCursor(fileRow.element);
-    this._toggleFileExpanded(file);
-  }
-
-  _getFileRowFromEvent(e) {
-    // Traverse upwards to find the row element if the target is not the row.
-    let row = e.target;
-    while (!row.classList.contains(FILE_ROW_CLASS) && row.parentElement) {
-      row = row.parentElement;
-    }
-
-    // No action needed for item without a valid file
-    if (!row.dataset['file']) {
-      return null;
-    }
-
-    return {
-      file: JSON.parse(row.dataset['file']),
-      element: row,
-    };
-  }
-
-  /**
-   * Generates file range from file info object.
-   *
-   * @param {FileInfo} file
-   * @returns {Gerrit.FileRange}
-   */
-  _computeFileRange(file) {
-    const fileData = {
-      path: file.__path,
-    };
-    if (file.old_path) {
-      fileData.basePath = file.old_path;
-    }
-    return fileData;
-  }
-
-  _handleLeftPane(e) {
-    if (this.shouldSuppressKeyboardShortcut(e) || this._noDiffsExpanded()) {
-      return;
-    }
-
-    e.preventDefault();
-    this.$.diffCursor.moveLeft();
-  }
-
-  _handleRightPane(e) {
-    if (this.shouldSuppressKeyboardShortcut(e) || this._noDiffsExpanded()) {
-      return;
-    }
-
-    e.preventDefault();
-    this.$.diffCursor.moveRight();
-  }
-
-  _handleToggleInlineDiff(e) {
-    if (this.shouldSuppressKeyboardShortcut(e) ||
-        this.modifierPressed(e) ||
-        this.$.fileCursor.index === -1) { return; }
-
-    e.preventDefault();
-    this._toggleFileExpandedByIndex(this.$.fileCursor.index);
-  }
-
-  _handleToggleAllInlineDiffs(e) {
-    if (this.shouldSuppressKeyboardShortcut(e)) { return; }
-
-    e.preventDefault();
-    this._toggleInlineDiffs();
-  }
-
-  _handleToggleHideAllCommentThreads(e) {
-    if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
-      return;
-    }
-
-    e.preventDefault();
-    this.toggleClass('hideComments');
-  }
-
-  _handleCursorNext(e) {
-    if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
-      return;
-    }
-
-    if (this._showInlineDiffs) {
-      e.preventDefault();
-      this.$.diffCursor.moveDown();
-      this._displayLine = true;
-    } else {
-      // Down key
-      if (this.getKeyboardEvent(e).keyCode === 40) { return; }
-      e.preventDefault();
-      this.$.fileCursor.next();
-      this.selectedIndex = this.$.fileCursor.index;
-    }
-  }
-
-  _handleCursorPrev(e) {
-    if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
-      return;
-    }
-
-    if (this._showInlineDiffs) {
-      e.preventDefault();
-      this.$.diffCursor.moveUp();
-      this._displayLine = true;
-    } else {
-      // Up key
-      if (this.getKeyboardEvent(e).keyCode === 38) { return; }
-      e.preventDefault();
-      this.$.fileCursor.previous();
-      this.selectedIndex = this.$.fileCursor.index;
-    }
-  }
-
-  _handleNewComment(e) {
-    if (this.shouldSuppressKeyboardShortcut(e) ||
-        this.modifierPressed(e)) { return; }
-    e.preventDefault();
-    this.$.diffCursor.createCommentInPlace();
-  }
-
-  _handleOpenLastFile(e) {
-    // Check for meta key to avoid overriding native chrome shortcut.
-    if (this.shouldSuppressKeyboardShortcut(e) ||
-        this.getKeyboardEvent(e).metaKey) { return; }
-
-    e.preventDefault();
-    this._openSelectedFile(this._files.length - 1);
-  }
-
-  _handleOpenFirstFile(e) {
-    // Check for meta key to avoid overriding native chrome shortcut.
-    if (this.shouldSuppressKeyboardShortcut(e) ||
-        this.getKeyboardEvent(e).metaKey) { return; }
-
-    e.preventDefault();
-    this._openSelectedFile(0);
-  }
-
-  _handleOpenFile(e) {
-    if (this.shouldSuppressKeyboardShortcut(e) ||
-        this.modifierPressed(e)) { return; }
-    e.preventDefault();
-
-    if (this._showInlineDiffs) {
-      this._openCursorFile();
-      return;
-    }
-
-    this._openSelectedFile();
-  }
-
-  _handleNextChunk(e) {
-    if (this.shouldSuppressKeyboardShortcut(e) ||
-        (this.modifierPressed(e) && !this.isModifierPressed(e, 'shiftKey')) ||
-        this._noDiffsExpanded()) {
-      return;
-    }
-
-    e.preventDefault();
-    if (this.isModifierPressed(e, 'shiftKey')) {
-      this.$.diffCursor.moveToNextCommentThread();
-    } else {
-      this.$.diffCursor.moveToNextChunk();
-    }
-  }
-
-  _handlePrevChunk(e) {
-    if (this.shouldSuppressKeyboardShortcut(e) ||
-        (this.modifierPressed(e) && !this.isModifierPressed(e, 'shiftKey')) ||
-        this._noDiffsExpanded()) {
-      return;
-    }
-
-    e.preventDefault();
-    if (this.isModifierPressed(e, 'shiftKey')) {
-      this.$.diffCursor.moveToPreviousCommentThread();
-    } else {
-      this.$.diffCursor.moveToPreviousChunk();
-    }
-  }
-
-  _handleToggleFileReviewed(e) {
-    if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
-      return;
-    }
-
-    e.preventDefault();
-    if (!this._files[this.$.fileCursor.index]) { return; }
-    this._reviewFile(this._files[this.$.fileCursor.index].__path);
-  }
-
-  _handleToggleLeftPane(e) {
-    if (this.shouldSuppressKeyboardShortcut(e)) { return; }
-
-    e.preventDefault();
-    this._forEachDiff(diff => {
-      diff.toggleLeftDiff();
-    });
-  }
-
-  _toggleInlineDiffs() {
-    if (this._showInlineDiffs) {
-      this.collapseAllDiffs();
-    } else {
-      this.expandAllDiffs();
-    }
-  }
-
-  _openCursorFile() {
-    const diff = this.$.diffCursor.getTargetDiffElement();
-    GerritNav.navigateToDiff(this.change, diff.path,
-        diff.patchRange.patchNum, this.patchRange.basePatchNum);
-  }
-
-  /**
-   * @param {number=} opt_index
-   */
-  _openSelectedFile(opt_index) {
-    if (opt_index != null) {
-      this.$.fileCursor.setCursorAtIndex(opt_index);
-    }
-    if (!this._files[this.$.fileCursor.index]) { return; }
-    GerritNav.navigateToDiff(this.change,
-        this._files[this.$.fileCursor.index].__path, this.patchRange.patchNum,
-        this.patchRange.basePatchNum);
-  }
-
-  _addDraftAtTarget() {
-    const diff = this.$.diffCursor.getTargetDiffElement();
-    const target = this.$.diffCursor.getTargetLineElement();
-    if (diff && target) {
-      diff.addDraftAtLine(target);
-    }
-  }
-
-  _shouldHideChangeTotals(_patchChange) {
-    return _patchChange.inserted === 0 && _patchChange.deleted === 0;
-  }
-
-  _shouldHideBinaryChangeTotals(_patchChange) {
-    return _patchChange.size_delta_inserted === 0 &&
-        _patchChange.size_delta_deleted === 0;
-  }
-
-  _computeFileStatus(status) {
-    return status || 'M';
-  }
-
-  _computeDiffURL(change, patchRange, path, editMode) {
-    // Polymer 2: check for undefined
-    if ([change, patchRange, path, editMode]
-        .some(arg => arg === undefined)) {
-      return;
-    }
-    if (editMode && path !== SpecialFilePath.MERGE_LIST) {
-      return GerritNav.getEditUrlForDiff(change, path, patchRange.patchNum,
-          patchRange.basePatchNum);
-    }
-    return GerritNav.getUrlForDiff(change, path, patchRange.patchNum,
-        patchRange.basePatchNum);
-  }
-
-  _formatBytes(bytes) {
-    if (bytes == 0) return '+/-0 B';
-    const bits = 1024;
-    const decimals = 1;
-    const sizes =
-        ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB'];
-    const exponent = Math.floor(Math.log(Math.abs(bytes)) / Math.log(bits));
-    const prepend = bytes > 0 ? '+' : '';
-    return prepend + parseFloat((bytes / Math.pow(bits, exponent))
-        .toFixed(decimals)) + ' ' + sizes[exponent];
-  }
-
-  _formatPercentage(size, delta) {
-    const oldSize = size - delta;
-
-    if (oldSize === 0) { return ''; }
-
-    const percentage = Math.round(Math.abs(delta * 100 / oldSize));
-    return '(' + (delta > 0 ? '+' : '-') + percentage + '%)';
-  }
-
-  _computeBinaryClass(delta) {
-    if (delta === 0) { return; }
-    return delta >= 0 ? 'added' : 'removed';
-  }
-
-  /**
-   * @param {string} baseClass
-   * @param {string} path
-   */
-  _computeClass(baseClass, path) {
-    const classes = [];
-    if (baseClass) {
-      classes.push(baseClass);
-    }
-    if (path === SpecialFilePath.COMMIT_MESSAGE ||
-      path === SpecialFilePath.MERGE_LIST) {
-      classes.push('invisible');
-    }
-    return classes.join(' ');
-  }
-
-  _computeStatusClass(file) {
-    const classStr = this._computeClass('status', file.__path);
-    return `${classStr} ${this._computeFileStatus(file.status)}`;
-  }
-
-  _computePathClass(path, expandedFilesRecord) {
-    return this._isFileExpanded(path, expandedFilesRecord) ? 'expanded' : '';
-  }
-
-  _computeShowHideIcon(path, expandedFilesRecord) {
-    return this._isFileExpanded(path, expandedFilesRecord) ?
-      'gr-icons:expand-less' : 'gr-icons:expand-more';
-  }
-
-  _computeFiles(filesByPath, changeComments, patchRange, reviewed, loading) {
-    // Polymer 2: check for undefined
-    if ([
-      filesByPath,
-      changeComments,
-      patchRange,
-      reviewed,
-      loading,
-    ].includes(undefined)) {
-      return;
-    }
-
-    // Await all promises resolving from reload. @See Issue 9057
-    if (loading || !changeComments) { return; }
-
-    const commentedPaths = changeComments.getPaths(patchRange);
-    const files = {...filesByPath};
-    addUnmodifiedFiles(files, commentedPaths);
-    const reviewedSet = new Set(reviewed || []);
-    for (const filePath in files) {
-      if (!files.hasOwnProperty(filePath)) { continue; }
-      files[filePath].isReviewed = reviewedSet.has(filePath);
-    }
-
-    this._files = this._normalizeChangeFilesResponse(files);
-  }
-
-  _computeFilesShown(numFilesShown, files) {
-    // Polymer 2: check for undefined
-    if ([numFilesShown, files].includes(undefined)) {
-      return undefined;
-    }
-
-    const previousNumFilesShown = this._shownFiles ?
-      this._shownFiles.length : 0;
-
-    const filesShown = files.slice(0, numFilesShown);
-    this.dispatchEvent(new CustomEvent('files-shown-changed', {
-      detail: {length: filesShown.length},
-      composed: true, bubbles: true,
-    }));
-
-    // Start the timer for the rendering work hwere because this is where the
-    // _shownFiles property is being set, and _shownFiles is used in the
-    // dom-repeat binding.
-    this.reporting.time(RENDER_TIMING_LABEL);
-
-    // How many more files are being shown (if it's an increase).
-    this._reportinShownFilesIncrement =
-        Math.max(0, filesShown.length - previousNumFilesShown);
-
-    return filesShown;
-  }
-
-  _updateDiffCursor() {
-    // Overwrite the cursor's list of diffs:
-    this.$.diffCursor.splice(
-        ...['diffs', 0, this.$.diffCursor.diffs.length].concat(this.diffs));
-  }
-
-  _filesChanged() {
-    if (this._files && this._files.length > 0) {
-      flush();
-      this.$.fileCursor.stops = Array.from(
-          this.root.querySelectorAll(`.${FILE_ROW_CLASS}`));
-      this.$.fileCursor.setCursorAtIndex(this.selectedIndex, true);
-    }
-  }
-
-  _incrementNumFilesShown() {
-    this.numFilesShown += this.fileListIncrement;
-  }
-
-  _computeFileListControlClass(numFilesShown, files) {
-    return numFilesShown >= files.length ? 'invisible' : '';
-  }
-
-  _computeIncrementText(numFilesShown, files) {
-    if (!files) { return ''; }
-    const text =
-        Math.min(this.fileListIncrement, files.length - numFilesShown);
-    return 'Show ' + text + ' more';
-  }
-
-  _computeShowAllText(files) {
-    if (!files) { return ''; }
-    return 'Show all ' + files.length + ' files';
-  }
-
-  _computeWarnShowAll(files) {
-    return files.length > WARN_SHOW_ALL_THRESHOLD;
-  }
-
-  _computeShowAllWarning(files) {
-    if (!this._computeWarnShowAll(files)) { return ''; }
-    return 'Warning: showing all ' + files.length +
-        ' files may take several seconds.';
-  }
-
-  _showAllFiles() {
-    this.numFilesShown = this._files.length;
-  }
-
-  /**
-   * Get a descriptive label for use in the status indicator's tooltip and
-   * ARIA label.
-   *
-   * @param {string} status
-   * @return {string}
-   */
-  _computeFileStatusLabel(status) {
-    const statusCode = this._computeFileStatus(status);
-    return FileStatus.hasOwnProperty(statusCode) ?
-      FileStatus[statusCode] : 'Status Unknown';
-  }
-
-  /**
-   * Converts any boolean-like variable to the string 'true' or 'false'
-   *
-   * This method is useful when you bind aria-checked attribute to a boolean
-   * value. The aria-checked attribute is string attribute. Binding directly
-   * to boolean variable causes problem on gerrit-CI.
-   *
-   * @param {object} val
-   * @return {string} 'true' if val is true-like, otherwise false
-   */
-  _booleanToString(val) {
-    return val ? 'true' : 'false';
-  }
-
-  _isFileExpanded(path, expandedFilesRecord) {
-    return expandedFilesRecord.base.some(f => f.path === path);
-  }
-
-  _isFileExpandedStr(path, expandedFilesRecord) {
-    return this._booleanToString(
-        this._isFileExpanded(path, expandedFilesRecord));
-  }
-
-  _computeExpandedFiles(expandedCount, totalCount) {
-    if (expandedCount === 0) {
-      return FilesExpandedState.NONE;
-    } else if (expandedCount === totalCount) {
-      return FilesExpandedState.ALL;
-    }
-    return FilesExpandedState.SOME;
-  }
-
-  /**
-   * Handle splices to the list of expanded file paths. If there are any new
-   * entries in the expanded list, then render each diff corresponding in
-   * order by waiting for the previous diff to finish before starting the next
-   * one.
-   *
-   * @param {!Array} record The splice record in the expanded paths list.
-   */
-  _expandedFilesChanged(record) {
-    // Clear content for any diffs that are not open so if they get re-opened
-    // the stale content does not flash before it is cleared and reloaded.
-    const collapsedDiffs = this.diffs.filter(diff =>
-      this._expandedFiles.findIndex(f => f.path === diff.path) === -1);
-    this._clearCollapsedDiffs(collapsedDiffs);
-
-    if (!record) { return; } // Happens after "Collapse all" clicked.
-
-    this.filesExpanded = this._computeExpandedFiles(
-        this._expandedFiles.length, this._files.length);
-
-    // Find the paths introduced by the new index splices:
-    const newFiles = record.indexSplices
-        .map(splice => splice.object.slice(
-            splice.index, splice.index + splice.addedCount))
-        .reduce((acc, paths) => acc.concat(paths), []);
-
-    // Required so that the newly created diff view is included in this.diffs.
-    flush();
-
-    this.reporting.time(EXPAND_ALL_TIMING_LABEL);
-
-    if (newFiles.length) {
-      this._renderInOrder(newFiles, this.diffs, newFiles.length);
-    }
-
-    this._updateDiffCursor();
-    this.$.diffCursor.reInitAndUpdateStops();
-  }
-
-  _clearCollapsedDiffs(collapsedDiffs) {
-    for (const diff of collapsedDiffs) {
-      diff.cancel();
-      diff.clearDiffContent();
-    }
-  }
-
-  /**
-   * Given an array of paths and a NodeList of diff elements, render the diff
-   * for each path in order, awaiting the previous render to complete before
-   * continuing.
-   *
-   * @param  {!Array<Gerrit.FileRange>} files
-   * @param  {!NodeList<!Object>} diffElements (GrDiffHostElement)
-   * @param  {number} initialCount The total number of paths in the pass. This
-   *   is used to generate log messages.
-   * @return {!Promise}
-   */
-  _renderInOrder(files, diffElements, initialCount) {
-    let iter = 0;
-
-    for (const file of files) {
-      const path = file.path;
-      const diffElem = this._findDiffByPath(path, diffElements);
-      if (diffElem) {
-        diffElem.prefetchDiff();
-      }
-    }
-
-    return (new Promise(resolve => {
-      this.dispatchEvent(new CustomEvent('reload-drafts', {
-        detail: {resolve},
-        composed: true, bubbles: true,
-      }));
-    })).then(() => asyncForeach(files, (file, cancel) => {
-      const path = file.path;
-      this._cancelForEachDiff = cancel;
-
-      iter++;
-      console.info('Expanding diff', iter, 'of', initialCount, ':',
-          path);
-      const diffElem = this._findDiffByPath(path, diffElements);
-      if (!diffElem) {
-        console.warn(`Did not find <gr-diff-host> element for ${path}`);
-        return Promise.resolve();
-      }
-      diffElem.comments = this.changeComments.getCommentsBySideForFile(
-          file, this.patchRange, this.projectConfig);
-      const promises = [diffElem.reload()];
-      if (this._loggedIn && !this.diffPrefs.manual_review) {
-        promises.push(this._reviewFile(path, true));
-      }
-      return Promise.all(promises);
-    }).then(() => {
-      this._cancelForEachDiff = null;
-      this._nextRenderParams = null;
-      console.info('Finished expanding', initialCount, 'diff(s)');
-      this.reporting.timeEndWithAverage(EXPAND_ALL_TIMING_LABEL,
-          EXPAND_ALL_AVG_TIMING_LABEL, initialCount);
-      /* Block diff cursor from auto scrolling after files are done rendering.
-       * This prevents the bug where the screen jumps to the first diff chunk
-       * after files are done being rendered after the user has already begun
-       * scrolling.
-       * This also however results in the fact that the cursor does not auto
-       * focus on the first diff chunk on a small screen. This is however, a use
-       * case we are willing to not support for now.
-
-       * Using handleDiffUpdate resulted in diffCursor.row being set which
-       * prevented the issue of scrolling to top when we expand the second
-       * file individually.
-       */
-      this.$.diffCursor.reInitAndUpdateStops();
-    }));
-  }
-
-  /** Cancel the rendering work of every diff in the list */
-  _cancelDiffs() {
-    if (this._cancelForEachDiff) { this._cancelForEachDiff(); }
-    this._forEachDiff(d => d.cancel());
-  }
-
-  /**
-   * In the given NodeList of diff elements, find the diff for the given path.
-   *
-   * @param  {string} path
-   * @param  {!NodeList<!Object>} diffElements (GrDiffElement)
-   * @return {!Object|undefined} (GrDiffElement)
-   */
-  _findDiffByPath(path, diffElements) {
-    for (let i = 0; i < diffElements.length; i++) {
-      if (diffElements[i].path === path) {
-        return diffElements[i];
-      }
-    }
-  }
-
-  /**
-   * Reset the comments of a modified thread
-   *
-   * @param  {string} rootId
-   * @param  {string} path
-   */
-  reloadCommentsForThreadWithRootId(rootId, path) {
-    // Don't bother continuing if we already know that the path that contains
-    // the updated comment thread is not expanded.
-    if (!this._expandedFiles.some(f => f.path === path)) { return; }
-    const diff = this.diffs.find(d => d.path === path);
-
-    const threadEl = diff.getThreadEls().find(t => t.rootId === rootId);
-    if (!threadEl) { return; }
-
-    const newComments = this.changeComments.getCommentsForThread(rootId);
-
-    // If newComments is null, it means that a single draft was
-    // removed from a thread in the thread view, and the thread should
-    // no longer exist. Remove the existing thread element in the diff
-    // view.
-    if (!newComments) {
-      threadEl.fireRemoveSelf();
-      return;
-    }
-
-    // Comments are not returned with the commentSide attribute from
-    // the api, but it's necessary to be stored on the diff's
-    // comments due to use in the _handleCommentUpdate function.
-    // The comment thread already has a side associated with it, so
-    // set the comment's side to match.
-    threadEl.comments = newComments.map(c => Object.assign(
-        c, {__commentSide: threadEl.commentSide}
-    ));
-    flush();
-  }
-
-  _handleEscKey(e) {
-    if (this.shouldSuppressKeyboardShortcut(e) ||
-        this.modifierPressed(e)) { return; }
-    e.preventDefault();
-    this._displayLine = false;
-  }
-
-  /**
-   * Update the loading class for the file list rows. The update is inside a
-   * debouncer so that the file list doesn't flash gray when the API requests
-   * are reasonably fast.
-   *
-   * @param {boolean} loading
-   */
-  _loadingChanged(loading) {
-    this.debounce('loading-change', () => {
-      // Only show set the loading if there have been files loaded to show. In
-      // this way, the gray loading style is not shown on initial loads.
-      this.classList.toggle('loading', loading && !!this._files.length);
-    }, LOADING_DEBOUNCE_INTERVAL);
-  }
-
-  _editModeChanged(editMode) {
-    this.classList.toggle('editMode', editMode);
-  }
-
-  _computeReviewedClass(isReviewed) {
-    return isReviewed ? 'isReviewed' : '';
-  }
-
-  _computeReviewedText(isReviewed) {
-    return isReviewed ? 'MARK UNREVIEWED' : 'MARK REVIEWED';
-  }
-
-  /**
-   * Given a file path, return whether that path should have visible size bars
-   * and be included in the size bars calculation.
-   *
-   * @param {string} path
-   * @return {boolean}
-   */
-  _showBarsForPath(path) {
-    return path !== SpecialFilePath.COMMIT_MESSAGE &&
-      path !== SpecialFilePath.MERGE_LIST;
-  }
-
-  /**
-   * Compute size bar layout values from the file list.
-   *
-   * @return {Gerrit.LayoutStats|undefined}
-   *
-   */
-  _computeSizeBarLayout(shownFilesRecord) {
-    if (!shownFilesRecord || !shownFilesRecord.base) { return undefined; }
-    const stats = {
-      maxInserted: 0,
-      maxDeleted: 0,
-      maxAdditionWidth: 0,
-      maxDeletionWidth: 0,
-      deletionOffset: 0,
-    };
-    shownFilesRecord.base
-        .filter(f => this._showBarsForPath(f.__path))
-        .forEach(f => {
-          if (f.lines_inserted) {
-            stats.maxInserted = Math.max(stats.maxInserted, f.lines_inserted);
-          }
-          if (f.lines_deleted) {
-            stats.maxDeleted = Math.max(stats.maxDeleted, f.lines_deleted);
-          }
-        });
-    const ratio = stats.maxInserted / (stats.maxInserted + stats.maxDeleted);
-    if (!isNaN(ratio)) {
-      stats.maxAdditionWidth =
-          (SIZE_BAR_MAX_WIDTH - SIZE_BAR_GAP_WIDTH) * ratio;
-      stats.maxDeletionWidth =
-          SIZE_BAR_MAX_WIDTH - SIZE_BAR_GAP_WIDTH - stats.maxAdditionWidth;
-      stats.deletionOffset = stats.maxAdditionWidth + SIZE_BAR_GAP_WIDTH;
-    }
-    return stats;
-  }
-
-  /**
-   * Get the width of the addition bar for a file.
-   *
-   * @param {Object} file
-   * @param {Gerrit.LayoutStats} stats
-   * @return {number}
-   */
-  _computeBarAdditionWidth(file, stats) {
-    if (stats.maxInserted === 0 ||
-        !file.lines_inserted ||
-        !this._showBarsForPath(file.__path)) {
-      return 0;
-    }
-    const width =
-        stats.maxAdditionWidth * file.lines_inserted / stats.maxInserted;
-    return width === 0 ? 0 : Math.max(SIZE_BAR_MIN_WIDTH, width);
-  }
-
-  /**
-   * Get the x-offset of the addition bar for a file.
-   *
-   * @param {Object} file
-   * @param {Gerrit.LayoutStats} stats
-   * @return {number}
-   */
-  _computeBarAdditionX(file, stats) {
-    return stats.maxAdditionWidth -
-        this._computeBarAdditionWidth(file, stats);
-  }
-
-  /**
-   * Get the width of the deletion bar for a file.
-   *
-   * @param {Object} file
-   * @param {Gerrit.LayoutStats} stats
-   * @return {number}
-   */
-  _computeBarDeletionWidth(file, stats) {
-    if (stats.maxDeleted === 0 ||
-        !file.lines_deleted ||
-        !this._showBarsForPath(file.__path)) {
-      return 0;
-    }
-    const width =
-        stats.maxDeletionWidth * file.lines_deleted / stats.maxDeleted;
-    return width === 0 ? 0 : Math.max(SIZE_BAR_MIN_WIDTH, width);
-  }
-
-  /**
-   * Get the x-offset of the deletion bar for a file.
-   *
-   * @param {Gerrit.LayoutStats} stats
-   *
-   * @return {number}
-   */
-  _computeBarDeletionX(stats) {
-    return stats.deletionOffset;
-  }
-
-  _computeShowSizeBars(userPrefs) {
-    return !!userPrefs.size_bar_in_change_table;
-  }
-
-  _computeSizeBarsClass(showSizeBars, path) {
-    let hideClass = '';
-    if (!showSizeBars) {
-      hideClass = 'hide';
-    } else if (!this._showBarsForPath(path)) {
-      hideClass = 'invisible';
-    }
-    return `sizeBars desktop ${hideClass}`;
-  }
-
-  /**
-   * Shows registered dynamic columns iff the 'header', 'content' and
-   * 'summary' endpoints are registered the exact same number of times.
-   * Ideally, there should be a better way to enforce the expectation of the
-   * dependencies between dynamic endpoints.
-   */
-  _computeShowDynamicColumns(
-      headerEndpoints, contentEndpoints, summaryEndpoints) {
-    return headerEndpoints && contentEndpoints && summaryEndpoints &&
-           headerEndpoints.length &&
-           headerEndpoints.length === contentEndpoints.length &&
-           headerEndpoints.length === summaryEndpoints.length;
-  }
-
-  /**
-   * Shows registered dynamic prepended columns iff the 'header', 'content'
-   * endpoints are registered the exact same number of times.
-   */
-  _computeShowPrependedDynamicColumns(
-      headerEndpoints, contentEndpoints) {
-    return headerEndpoints && contentEndpoints &&
-           headerEndpoints.length &&
-           headerEndpoints.length === contentEndpoints.length;
-  }
-
-  /**
-   * Returns true if none of the inline diffs have been expanded.
-   *
-   * @return {boolean}
-   */
-  _noDiffsExpanded() {
-    return this.filesExpanded === FilesExpandedState.NONE;
-  }
-
-  /**
-   * Method to call via binding when each file list row is rendered. This
-   * allows approximate detection of when the dom-repeat has completed
-   * rendering.
-   *
-   * @param {number} index The index of the row being rendered.
-   * @return {string} an empty string.
-   */
-  _reportRenderedRow(index) {
-    if (index === this._shownFiles.length - 1) {
-      this.async(() => {
-        this.reporting.timeEndWithAverage(RENDER_TIMING_LABEL,
-            RENDER_AVG_TIMING_LABEL, this._reportinShownFilesIncrement);
-      }, 1);
-    }
-    return '';
-  }
-
-  _reviewedTitle(reviewed) {
-    if (reviewed) {
-      return 'Mark as not reviewed (shortcut: r)';
-    }
-
-    return 'Mark as reviewed (shortcut: r)';
-  }
-
-  _handleReloadingDiffPreference() {
-    this._getDiffPreferences().then(prefs => {
-      this.diffPrefs = prefs;
-    });
-  }
-
-  /**
-   * Wrapper for using in the element template and computed properties
-   */
-  _computeDisplayPath(path) {
-    return computeDisplayPath(path);
-  }
-
-  /**
-   * Wrapper for using in the element template and computed properties
-   */
-  _computeTruncatedPath(path) {
-    return computeTruncatedPath(path);
-  }
-}
-
-customElements.define(GrFileList.is, GrFileList);
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
new file mode 100644
index 0000000..3a2c858
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts
@@ -0,0 +1,1901 @@
+/**
+ * @license
+ * Copyright (C) 2015 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 '../../../styles/shared-styles';
+import '../../diff/gr-diff-cursor/gr-diff-cursor';
+import '../../diff/gr-diff-host/gr-diff-host';
+import '../../diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog';
+import '../../edit/gr-edit-file-controls/gr-edit-file-controls';
+import '../../shared/gr-button/gr-button';
+import '../../shared/gr-cursor-manager/gr-cursor-manager';
+import '../../shared/gr-icons/gr-icons';
+import '../../shared/gr-linked-text/gr-linked-text';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../../shared/gr-select/gr-select';
+import '../../shared/gr-tooltip-content/gr-tooltip-content';
+import '../../shared/gr-copy-clipboard/gr-copy-clipboard';
+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';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-file-list_html';
+import {asyncForeach} from '../../../utils/async-util';
+import {
+  CustomKeyboardEvent,
+  KeyboardShortcutMixin,
+  Modifier,
+  Shortcut,
+} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
+import {FilesExpandedState} from '../gr-file-list-constants';
+import {GrCountStringFormatter} from '../../shared/gr-count-string-formatter/gr-count-string-formatter';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {getPluginEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints';
+import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
+import {appContext} from '../../../services/app-context';
+import {DiffViewMode, SpecialFilePath} from '../../../constants/constants';
+import {descendedFromClass} from '../../../utils/dom-util';
+import {
+  addUnmodifiedFiles,
+  computeDisplayPath,
+  computeTruncatedPath,
+  isMagicPath,
+  specialFilePathCompare,
+} from '../../../utils/path-list-util';
+import {customElement, observe, property} from '@polymer/decorators';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {
+  ConfigInfo,
+  DiffPreferencesInfo,
+  ElementPropertyDeepChange,
+  FileInfo,
+  FileNameToFileInfoMap,
+  NumericChangeId,
+  PatchRange,
+  PreferencesInfo,
+  RevisionInfo,
+  UrlEncodedCommentId,
+} from '../../../types/common';
+import {GrDiffHost} from '../../diff/gr-diff-host/gr-diff-host';
+import {GrDiffPreferencesDialog} from '../../diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog';
+import {hasOwnProperty} from '../../../utils/common-util';
+import {GrDiffCursor} from '../../diff/gr-diff-cursor/gr-diff-cursor';
+import {GrCursorManager} from '../../shared/gr-cursor-manager/gr-cursor-manager';
+import {PolymerSpliceChange} from '@polymer/polymer/interfaces';
+import {ChangeComments} from '../../diff/gr-comment-api/gr-comment-api';
+import {PatchSetFile, UIDraft} from '../../../utils/comment-util';
+import {ParsedChangeInfo} from '../../shared/gr-rest-api-interface/gr-reviewer-updates-parser';
+
+export const DEFAULT_NUM_FILES_SHOWN = 200;
+
+const WARN_SHOW_ALL_THRESHOLD = 1000;
+const LOADING_DEBOUNCE_INTERVAL = 100;
+
+const SIZE_BAR_MAX_WIDTH = 61;
+const SIZE_BAR_GAP_WIDTH = 1;
+const SIZE_BAR_MIN_WIDTH = 1.5;
+
+const RENDER_TIMING_LABEL = 'FileListRenderTime';
+const RENDER_AVG_TIMING_LABEL = 'FileListRenderTimePerFile';
+const EXPAND_ALL_TIMING_LABEL = 'ExpandAllDiffs';
+const EXPAND_ALL_AVG_TIMING_LABEL = 'ExpandAllPerDiff';
+
+const FileStatus = {
+  A: 'Added',
+  C: 'Copied',
+  D: 'Deleted',
+  M: 'Modified',
+  R: 'Renamed',
+  W: 'Rewritten',
+  U: 'Unchanged',
+};
+
+const FILE_ROW_CLASS = 'file-row';
+
+export interface GrFileList {
+  $: {
+    restAPI: RestApiService & Element;
+    diffPreferencesDialog: GrDiffPreferencesDialog;
+    diffCursor: GrDiffCursor;
+    fileCursor: GrCursorManager;
+  };
+}
+
+interface ReviewedFileInfo extends FileInfo {
+  isReviewed?: boolean;
+}
+interface NormalizedFileInfo extends ReviewedFileInfo {
+  __path: string;
+}
+
+interface PatchChange {
+  inserted: number;
+  deleted: number;
+  size_delta_inserted: number;
+  size_delta_deleted: number;
+  total_size: number;
+}
+
+function createDefaultPatchChange(): PatchChange {
+  // Use function instead of const to prevent unexpected changes in the default
+  // values.
+  return {
+    inserted: 0,
+    deleted: 0,
+    size_delta_inserted: 0,
+    size_delta_deleted: 0,
+    total_size: 0,
+  };
+}
+
+interface SizeBarLayout {
+  maxInserted: number;
+  maxDeleted: number;
+  maxAdditionWidth: number;
+  maxDeletionWidth: number;
+  deletionOffset: number;
+}
+
+function createDefaultSizeBarLayout(): SizeBarLayout {
+  // Use function instead of const to prevent unexpected changes in the default
+  // values.
+  return {
+    maxInserted: 0,
+    maxDeleted: 0,
+    maxAdditionWidth: 0,
+    maxDeletionWidth: 0,
+    deletionOffset: 0,
+  };
+}
+
+interface FileRow {
+  file: PatchSetFile;
+  element: HTMLElement;
+}
+
+export type FileNameToReviewedFileInfoMap = {[name: string]: ReviewedFileInfo};
+
+/**
+ * Type for FileInfo
+ *
+ * This should match with the type returned from `files` API plus
+ * additional info like `__path`.
+ *
+ * @typedef {Object} FileInfo
+ * @property {string} __path
+ * @property {?string} old_path
+ * @property {number} size
+ * @property {number} size_delta - fallback to 0 if not present in api
+ * @property {number} lines_deleted - fallback to 0 if not present in api
+ * @property {number} lines_inserted - fallback to 0 if not present in api
+ */
+
+@customElement('gr-file-list')
+export class GrFileList extends KeyboardShortcutMixin(
+  GestureEventListeners(LegacyElementMixin(PolymerElement))
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  /**
+   * Fired when a draft refresh should get triggered
+   *
+   * @event reload-drafts
+   */
+
+  @property({type: Object})
+  patchRange?: PatchRange;
+
+  @property({type: String})
+  patchNum?: string;
+
+  @property({type: Number})
+  changeNum?: NumericChangeId;
+
+  @property({type: Object})
+  changeComments?: ChangeComments;
+
+  @property({type: Object})
+  drafts?: {[path: string]: UIDraft[]};
+
+  @property({type: Array})
+  revisions?: {[revisionId: string]: RevisionInfo};
+
+  @property({type: Object})
+  projectConfig?: ConfigInfo;
+
+  @property({type: Number, notify: true})
+  selectedIndex = -1;
+
+  @property({type: Object})
+  keyEventTarget = document.body;
+
+  @property({type: Object})
+  change?: ParsedChangeInfo;
+
+  @property({type: String, notify: true, observer: '_updateDiffPreferences'})
+  diffViewMode?: DiffViewMode;
+
+  @property({type: Boolean, observer: '_editModeChanged'})
+  editMode?: boolean;
+
+  @property({type: String, notify: true})
+  filesExpanded = FilesExpandedState.NONE;
+
+  @property({type: Object})
+  _filesByPath?: FileNameToFileInfoMap;
+
+  @property({type: Array, observer: '_filesChanged'})
+  _files: NormalizedFileInfo[] = [];
+
+  @property({type: Boolean})
+  _loggedIn = false;
+
+  @property({type: Array})
+  _reviewed?: string[] = [];
+
+  @property({type: Object, notify: true, observer: '_updateDiffPreferences'})
+  diffPrefs?: DiffPreferencesInfo;
+
+  @property({type: Object})
+  _userPrefs?: PreferencesInfo;
+
+  @property({type: Boolean})
+  _showInlineDiffs?: boolean;
+
+  @property({type: Number, notify: true})
+  numFilesShown: number = DEFAULT_NUM_FILES_SHOWN;
+
+  @property({type: Object, computed: '_calculatePatchChange(_files)'})
+  _patchChange: PatchChange = createDefaultPatchChange();
+
+  @property({type: Number})
+  fileListIncrement: number = DEFAULT_NUM_FILES_SHOWN;
+
+  @property({type: Boolean, computed: '_shouldHideChangeTotals(_patchChange)'})
+  _hideChangeTotals = true;
+
+  @property({
+    type: Boolean,
+    computed: '_shouldHideBinaryChangeTotals(_patchChange)',
+  })
+  _hideBinaryChangeTotals = true;
+
+  @property({
+    type: Array,
+    computed: '_computeFilesShown(numFilesShown, _files)',
+  })
+  _shownFiles: NormalizedFileInfo[] = [];
+
+  @property({type: Number})
+  _reportinShownFilesIncrement = 0;
+
+  @property({type: Array})
+  _expandedFiles: PatchSetFile[] = [];
+
+  @property({type: Boolean})
+  _displayLine?: boolean;
+
+  @property({type: Boolean, observer: '_loadingChanged'})
+  _loading?: boolean;
+
+  @property({type: Object, computed: '_computeSizeBarLayout(_shownFiles.*)'})
+  _sizeBarLayout: SizeBarLayout = createDefaultSizeBarLayout();
+
+  @property({type: Boolean, computed: '_computeShowSizeBars(_userPrefs)'})
+  _showSizeBars = true;
+
+  private _cancelForEachDiff?: () => void;
+
+  @property({
+    type: Boolean,
+    computed:
+      '_computeShowDynamicColumns(_dynamicHeaderEndpoints, ' +
+      '_dynamicContentEndpoints, _dynamicSummaryEndpoints)',
+  })
+  _showDynamicColumns = false;
+
+  @property({
+    type: Boolean,
+    computed:
+      '_computeShowPrependedDynamicColumns(' +
+      '_dynamicPrependedHeaderEndpoints, _dynamicPrependedContentEndpoints)',
+  })
+  _showPrependedDynamicColumns = false;
+
+  @property({type: Array})
+  _dynamicHeaderEndpoints?: string[];
+
+  @property({type: Array})
+  _dynamicContentEndpoints?: string[];
+
+  @property({type: Array})
+  _dynamicSummaryEndpoints?: string[];
+
+  @property({type: Array})
+  _dynamicPrependedHeaderEndpoints?: string[];
+
+  @property({type: Array})
+  _dynamicPrependedContentEndpoints?: string[];
+
+  private readonly reporting = appContext.reportingService;
+
+  get keyBindings() {
+    return {
+      esc: '_handleEscKey',
+    };
+  }
+
+  keyboardShortcuts() {
+    return {
+      [Shortcut.LEFT_PANE]: '_handleLeftPane',
+      [Shortcut.RIGHT_PANE]: '_handleRightPane',
+      [Shortcut.TOGGLE_INLINE_DIFF]: '_handleToggleInlineDiff',
+      [Shortcut.TOGGLE_ALL_INLINE_DIFFS]: '_handleToggleAllInlineDiffs',
+      [Shortcut.TOGGLE_HIDE_ALL_COMMENT_THREADS]:
+        '_handleToggleHideAllCommentThreads',
+      [Shortcut.CURSOR_NEXT_FILE]: '_handleCursorNext',
+      [Shortcut.CURSOR_PREV_FILE]: '_handleCursorPrev',
+      [Shortcut.NEXT_LINE]: '_handleCursorNext',
+      [Shortcut.PREV_LINE]: '_handleCursorPrev',
+      [Shortcut.NEW_COMMENT]: '_handleNewComment',
+      [Shortcut.OPEN_LAST_FILE]: '_handleOpenLastFile',
+      [Shortcut.OPEN_FIRST_FILE]: '_handleOpenFirstFile',
+      [Shortcut.OPEN_FILE]: '_handleOpenFile',
+      [Shortcut.NEXT_CHUNK]: '_handleNextChunk',
+      [Shortcut.PREV_CHUNK]: '_handlePrevChunk',
+      [Shortcut.TOGGLE_FILE_REVIEWED]: '_handleToggleFileReviewed',
+      [Shortcut.TOGGLE_LEFT_PANE]: '_handleToggleLeftPane',
+
+      // Final two are actually handled by gr-comment-thread.
+      [Shortcut.EXPAND_ALL_COMMENT_THREADS]: null,
+      [Shortcut.COLLAPSE_ALL_COMMENT_THREADS]: null,
+    };
+  }
+
+  /** @override */
+  created() {
+    super.created();
+    this.addEventListener('keydown', e => this._scopedKeydownHandler(e));
+  }
+
+  /** @override */
+  attached() {
+    super.attached();
+    getPluginLoader()
+      .awaitPluginsLoaded()
+      .then(() => {
+        this._dynamicHeaderEndpoints = getPluginEndpoints().getDynamicEndpoints(
+          'change-view-file-list-header'
+        );
+        this._dynamicContentEndpoints = getPluginEndpoints().getDynamicEndpoints(
+          'change-view-file-list-content'
+        );
+        this._dynamicPrependedHeaderEndpoints = getPluginEndpoints().getDynamicEndpoints(
+          'change-view-file-list-header-prepend'
+        );
+        this._dynamicPrependedContentEndpoints = getPluginEndpoints().getDynamicEndpoints(
+          'change-view-file-list-content-prepend'
+        );
+        this._dynamicSummaryEndpoints = getPluginEndpoints().getDynamicEndpoints(
+          'change-view-file-list-summary'
+        );
+
+        if (
+          this._dynamicHeaderEndpoints.length !==
+          this._dynamicContentEndpoints.length
+        ) {
+          console.warn(
+            'Different number of dynamic file-list header and content.'
+          );
+        }
+        if (
+          this._dynamicPrependedHeaderEndpoints.length !==
+          this._dynamicPrependedContentEndpoints.length
+        ) {
+          console.warn(
+            'Different number of dynamic file-list header and content.'
+          );
+        }
+        if (
+          this._dynamicHeaderEndpoints.length !==
+          this._dynamicSummaryEndpoints.length
+        ) {
+          console.warn(
+            'Different number of dynamic file-list headers and summary.'
+          );
+        }
+      });
+  }
+
+  /** @override */
+  detached() {
+    super.detached();
+    this._cancelDiffs();
+  }
+
+  /**
+   * Iron-a11y-keys-behavior catches keyboard events globally. Some keyboard
+   * events must be scoped to a component level (e.g. `enter`) in order to not
+   * override native browser functionality.
+   *
+   * Context: Issue 7277
+   */
+  _scopedKeydownHandler(e: KeyboardEvent) {
+    if (e.keyCode === 13) {
+      // TODO(TS): e is not an instance of CustomKeyboardEvent.
+      // However, to fix it we should fix keyboard-shortcut-mixin first
+      // The keyboard-shortcut-mixin will be updated in a separate change
+      this._handleOpenFile((e as unknown) as CustomKeyboardEvent);
+    }
+  }
+
+  reload() {
+    if (!this.changeNum || !this.patchRange?.patchNum) {
+      return Promise.resolve();
+    }
+    const changeNum = this.changeNum;
+    const patchRange = this.patchRange;
+
+    this._loading = true;
+
+    this.collapseAllDiffs();
+    const promises = [];
+
+    promises.push(
+      this.$.restAPI
+        .getChangeOrEditFiles(changeNum, patchRange)
+        .then(filesByPath => {
+          this._filesByPath = filesByPath;
+        })
+    );
+    promises.push(
+      this._getLoggedIn()
+        .then(loggedIn => (this._loggedIn = loggedIn))
+        .then(loggedIn => {
+          if (!loggedIn) {
+            return;
+          }
+
+          return this._getReviewedFiles(changeNum, patchRange).then(
+            reviewed => {
+              this._reviewed = reviewed;
+            }
+          );
+        })
+    );
+
+    promises.push(
+      this._getDiffPreferences().then(prefs => {
+        this.diffPrefs = prefs;
+      })
+    );
+
+    promises.push(
+      this._getPreferences().then(prefs => {
+        this._userPrefs = prefs;
+      })
+    );
+
+    return Promise.all(promises).then(() => {
+      this._loading = false;
+      this._detectChromiteButler();
+      this.reporting.fileListDisplayed();
+    });
+  }
+
+  _detectChromiteButler() {
+    const hasButler = !!document.getElementById('butler-suggested-owners');
+    if (hasButler) {
+      this.reporting.reportExtension('butler');
+    }
+  }
+
+  get diffs(): GrDiffHost[] {
+    const diffs = this.root!.querySelectorAll('gr-diff-host');
+    // It is possible that a bogus diff element is hanging around invisibly
+    // from earlier with a different patch set choice and associated with a
+    // different entry in the files array. So filter on visible items only.
+    return Array.from(diffs).filter(
+      el => !!el && !!el.style && el.style.display !== 'none'
+    );
+  }
+
+  openDiffPrefs() {
+    this.$.diffPreferencesDialog.open();
+  }
+
+  _calculatePatchChange(files: NormalizedFileInfo[]): PatchChange {
+    const magicFilesExcluded = files.filter(
+      files => !isMagicPath(files.__path)
+    );
+
+    return magicFilesExcluded.reduce((acc, obj) => {
+      const inserted = obj.lines_inserted ? obj.lines_inserted : 0;
+      const deleted = obj.lines_deleted ? obj.lines_deleted : 0;
+      const total_size = obj.size && obj.binary ? obj.size : 0;
+      const size_delta_inserted =
+        obj.binary && obj.size_delta > 0 ? obj.size_delta : 0;
+      const size_delta_deleted =
+        obj.binary && obj.size_delta < 0 ? obj.size_delta : 0;
+
+      return {
+        inserted: acc.inserted + inserted,
+        deleted: acc.deleted + deleted,
+        size_delta_inserted: acc.size_delta_inserted + size_delta_inserted,
+        size_delta_deleted: acc.size_delta_deleted + size_delta_deleted,
+        total_size: acc.total_size + total_size,
+      };
+    }, createDefaultPatchChange());
+  }
+
+  _getDiffPreferences() {
+    return this.$.restAPI.getDiffPreferences();
+  }
+
+  _getPreferences() {
+    return this.$.restAPI.getPreferences();
+  }
+
+  private _toggleFileExpanded(file: PatchSetFile) {
+    // Is the path in the list of expanded diffs? IF so remove it, otherwise
+    // add it to the list.
+    const pathIndex = this._expandedFiles.findIndex(f => f.path === file.path);
+    if (pathIndex === -1) {
+      this.push('_expandedFiles', file);
+    } else {
+      this.splice('_expandedFiles', pathIndex, 1);
+    }
+  }
+
+  _toggleFileExpandedByIndex(index: number) {
+    this._toggleFileExpanded(this._computePatchSetFile(this._files[index]));
+  }
+
+  _updateDiffPreferences() {
+    if (!this.diffs.length) {
+      return;
+    }
+    // Re-render all expanded diffs sequentially.
+    this.reporting.time(EXPAND_ALL_TIMING_LABEL);
+    this._renderInOrder(
+      this._expandedFiles,
+      this.diffs,
+      this._expandedFiles.length
+    );
+  }
+
+  _forEachDiff(fn: (host: GrDiffHost) => void) {
+    const diffs = this.diffs;
+    for (let i = 0; i < diffs.length; i++) {
+      fn(diffs[i]);
+    }
+  }
+
+  expandAllDiffs() {
+    this._showInlineDiffs = true;
+
+    // Find the list of paths that are in the file list, but not in the
+    // expanded list.
+    const newFiles: PatchSetFile[] = [];
+    let path: string;
+    for (let i = 0; i < this._shownFiles.length; i++) {
+      path = this._shownFiles[i].__path;
+      if (!this._expandedFiles.some(f => f.path === path)) {
+        newFiles.push(this._computePatchSetFile(this._shownFiles[i]));
+      }
+    }
+
+    this.splice('_expandedFiles', 0, 0, ...newFiles);
+  }
+
+  collapseAllDiffs() {
+    this._showInlineDiffs = false;
+    this._expandedFiles = [];
+    this.filesExpanded = this._computeExpandedFiles(
+      this._expandedFiles.length,
+      this._files.length
+    );
+    this.$.diffCursor.handleDiffUpdate();
+  }
+
+  /**
+   * Computes a string with the number of comments and unresolved comments.
+   */
+  _computeCommentsString(
+    changeComments?: ChangeComments,
+    patchRange?: PatchRange,
+    path?: string
+  ) {
+    if (
+      changeComments === undefined ||
+      patchRange === undefined ||
+      path === undefined
+    ) {
+      return '';
+    }
+    const unresolvedCount =
+      changeComments.computeUnresolvedNum({
+        patchNum: patchRange.basePatchNum,
+        path,
+      }) +
+      changeComments.computeUnresolvedNum({
+        patchNum: patchRange.patchNum,
+        path,
+      });
+    const commentCount =
+      changeComments.computeCommentCount({
+        patchNum: patchRange.basePatchNum,
+        path,
+      }) +
+      changeComments.computeCommentCount({
+        patchNum: patchRange.patchNum,
+        path,
+      });
+    const commentString = GrCountStringFormatter.computePluralString(
+      commentCount,
+      'comment'
+    );
+    const unresolvedString = GrCountStringFormatter.computeString(
+      unresolvedCount,
+      'unresolved'
+    );
+
+    return (
+      commentString +
+      // Add a space if both comments and unresolved
+      (commentString && unresolvedString ? ' ' : '') +
+      // Add parentheses around unresolved if it exists.
+      (unresolvedString ? `(${unresolvedString})` : '')
+    );
+  }
+
+  /**
+   * Computes a string with the number of drafts.
+   */
+  _computeDraftsString(
+    changeComments?: ChangeComments,
+    patchRange?: PatchRange,
+    path?: string
+  ) {
+    if (
+      changeComments === undefined ||
+      patchRange === undefined ||
+      path === undefined
+    ) {
+      return '';
+    }
+    const draftCount =
+      changeComments.computeDraftCount({
+        patchNum: patchRange.basePatchNum,
+        path,
+      }) +
+      changeComments.computeDraftCount({
+        patchNum: patchRange.patchNum,
+        path,
+      });
+    return GrCountStringFormatter.computePluralString(draftCount, 'draft');
+  }
+
+  /**
+   * Computes a shortened string with the number of drafts.
+   */
+  _computeDraftsStringMobile(
+    changeComments?: ChangeComments,
+    patchRange?: PatchRange,
+    path?: string
+  ) {
+    if (
+      changeComments === undefined ||
+      patchRange === undefined ||
+      path === undefined
+    ) {
+      return '';
+    }
+    const draftCount =
+      changeComments.computeDraftCount({
+        patchNum: patchRange.basePatchNum,
+        path,
+      }) +
+      changeComments.computeDraftCount({
+        patchNum: patchRange.patchNum,
+        path,
+      });
+    return GrCountStringFormatter.computeShortString(draftCount, 'd');
+  }
+
+  /**
+   * Computes a shortened string with the number of comments.
+   */
+  _computeCommentsStringMobile(
+    changeComments?: ChangeComments,
+    patchRange?: PatchRange,
+    path?: string
+  ) {
+    if (
+      changeComments === undefined ||
+      patchRange === undefined ||
+      path === undefined
+    ) {
+      return '';
+    }
+    const commentCount =
+      changeComments.computeCommentCount({
+        patchNum: patchRange.basePatchNum,
+        path,
+      }) +
+      changeComments.computeCommentCount({
+        patchNum: patchRange.patchNum,
+        path,
+      });
+    return GrCountStringFormatter.computeShortString(commentCount, 'c');
+  }
+
+  private _reviewFile(path: string, reviewed?: boolean) {
+    if (this.editMode) {
+      return Promise.resolve();
+    }
+    const index = this._files.findIndex(file => file.__path === path);
+    reviewed = reviewed || !this._files[index].isReviewed;
+
+    this.set(['_files', index, 'isReviewed'], reviewed);
+    if (index < this._shownFiles.length) {
+      this.notifyPath(`_shownFiles.${index}.isReviewed`);
+    }
+
+    return this._saveReviewedState(path, reviewed);
+  }
+
+  _saveReviewedState(path: string, reviewed: boolean) {
+    if (!this.changeNum || !this.patchRange) {
+      throw new Error('changeNum and patchRange must be set');
+    }
+
+    return this.$.restAPI.saveFileReviewed(
+      this.changeNum,
+      this.patchRange.patchNum,
+      path,
+      reviewed
+    );
+  }
+
+  _getLoggedIn() {
+    return this.$.restAPI.getLoggedIn();
+  }
+
+  _getReviewedFiles(changeNum: NumericChangeId, patchRange: PatchRange) {
+    if (this.editMode) {
+      return Promise.resolve([]);
+    }
+    return this.$.restAPI.getReviewedFiles(changeNum, patchRange.patchNum);
+  }
+
+  _normalizeChangeFilesResponse(
+    response: FileNameToReviewedFileInfoMap
+  ): NormalizedFileInfo[] {
+    const paths = Object.keys(response).sort(specialFilePathCompare);
+    const files: NormalizedFileInfo[] = [];
+    for (let i = 0; i < paths.length; i++) {
+      // TODO(TS): make copy instead of as NormalizedFileInfo
+      const info = response[paths[i]] as NormalizedFileInfo;
+      info.__path = paths[i];
+      info.lines_inserted = info.lines_inserted || 0;
+      info.lines_deleted = info.lines_deleted || 0;
+      info.size_delta = info.size_delta || 0;
+      files.push(info);
+    }
+    return files;
+  }
+
+  /**
+   * Returns true if the event e is a click on an element.
+   *
+   * The click is: mouse click or pressing Enter or Space key
+   * P.S> Screen readers sends click event as well
+   */
+  _isClickEvent(e: MouseEvent | KeyboardEvent) {
+    if (e.type === 'click') {
+      return true;
+    }
+    const ke = e as KeyboardEvent;
+    const isSpaceOrEnter = ke.key === 'Enter' || ke.key === ' ';
+    return ke.type === 'keydown' && isSpaceOrEnter;
+  }
+
+  _fileActionClick(
+    e: MouseEvent | KeyboardEvent,
+    fileAction: (file: PatchSetFile) => void
+  ) {
+    if (this._isClickEvent(e)) {
+      const fileRow = this._getFileRowFromEvent(e);
+      if (!fileRow) {
+        return;
+      }
+      // Prevent default actions (e.g. scrolling for space key)
+      e.preventDefault();
+      // Prevent _handleFileListClick handler call
+      e.stopPropagation();
+      this.$.fileCursor.setCursor(fileRow.element);
+      fileAction(fileRow.file);
+    }
+  }
+
+  _reviewedClick(e: MouseEvent | KeyboardEvent) {
+    this._fileActionClick(e, file => this._reviewFile(file.path));
+  }
+
+  _expandedClick(e: MouseEvent | KeyboardEvent) {
+    this._fileActionClick(e, file => this._toggleFileExpanded(file));
+  }
+
+  /**
+   * Handle all events from the file list dom-repeat so event handleers don't
+   * have to get registered for potentially very long lists.
+   */
+  _handleFileListClick(e: MouseEvent) {
+    if (!e.target) {
+      return;
+    }
+    const fileRow = this._getFileRowFromEvent(e);
+    if (!fileRow) {
+      return;
+    }
+    const file = fileRow.file;
+    const path = file.path;
+    // If a path cannot be interpreted from the click target (meaning it's not
+    // somewhere in the row, e.g. diff content) or if the user clicked the
+    // link, defer to the native behavior.
+    if (!path || descendedFromClass(e.target as Element, 'pathLink')) {
+      return;
+    }
+
+    // Disregard the event if the click target is in the edit controls.
+    if (descendedFromClass(e.target as Element, 'editFileControls')) {
+      return;
+    }
+
+    e.preventDefault();
+    this.$.fileCursor.setCursor(fileRow.element);
+    this._toggleFileExpanded(file);
+  }
+
+  _getFileRowFromEvent(e: Event): FileRow | null {
+    // Traverse upwards to find the row element if the target is not the row.
+    let row = e.target as HTMLElement;
+    while (!row.classList.contains(FILE_ROW_CLASS) && row.parentElement) {
+      row = row.parentElement;
+    }
+
+    // No action needed for item without a valid file
+    if (!row.dataset['file']) {
+      return null;
+    }
+
+    return {
+      file: JSON.parse(row.dataset['file']) as PatchSetFile,
+      element: row,
+    };
+  }
+
+  /**
+   * Generates file range from file info object.
+   */
+  _computePatchSetFile(file: NormalizedFileInfo): PatchSetFile {
+    const fileData: PatchSetFile = {
+      path: file.__path,
+    };
+    if (file.old_path) {
+      fileData.basePath = file.old_path;
+    }
+    return fileData;
+  }
+
+  _handleLeftPane(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e) || this._noDiffsExpanded()) {
+      return;
+    }
+
+    e.preventDefault();
+    this.$.diffCursor.moveLeft();
+  }
+
+  _handleRightPane(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e) || this._noDiffsExpanded()) {
+      return;
+    }
+
+    e.preventDefault();
+    this.$.diffCursor.moveRight();
+  }
+
+  _handleToggleInlineDiff(e: CustomKeyboardEvent) {
+    if (
+      this.shouldSuppressKeyboardShortcut(e) ||
+      this.modifierPressed(e) ||
+      this.$.fileCursor.index === -1
+    ) {
+      return;
+    }
+
+    e.preventDefault();
+    this._toggleFileExpandedByIndex(this.$.fileCursor.index);
+  }
+
+  _handleToggleAllInlineDiffs(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e)) {
+      return;
+    }
+
+    e.preventDefault();
+    this._toggleInlineDiffs();
+  }
+
+  _handleToggleHideAllCommentThreads(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
+      return;
+    }
+
+    e.preventDefault();
+    this.toggleClass('hideComments');
+  }
+
+  _handleCursorNext(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
+      return;
+    }
+
+    if (this._showInlineDiffs) {
+      e.preventDefault();
+      this.$.diffCursor.moveDown();
+      this._displayLine = true;
+    } else {
+      // Down key
+      if (this.getKeyboardEvent(e).keyCode === 40) {
+        return;
+      }
+      e.preventDefault();
+      this.$.fileCursor.next();
+      this.selectedIndex = this.$.fileCursor.index;
+    }
+  }
+
+  _handleCursorPrev(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
+      return;
+    }
+
+    if (this._showInlineDiffs) {
+      e.preventDefault();
+      this.$.diffCursor.moveUp();
+      this._displayLine = true;
+    } else {
+      // Up key
+      if (this.getKeyboardEvent(e).keyCode === 38) {
+        return;
+      }
+      e.preventDefault();
+      this.$.fileCursor.previous();
+      this.selectedIndex = this.$.fileCursor.index;
+    }
+  }
+
+  _handleNewComment(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
+      return;
+    }
+    e.preventDefault();
+    this.$.diffCursor.createCommentInPlace();
+  }
+
+  _handleOpenLastFile(e: CustomKeyboardEvent) {
+    // Check for meta key to avoid overriding native chrome shortcut.
+    if (
+      this.shouldSuppressKeyboardShortcut(e) ||
+      this.getKeyboardEvent(e).metaKey
+    ) {
+      return;
+    }
+
+    e.preventDefault();
+    this._openSelectedFile(this._files.length - 1);
+  }
+
+  _handleOpenFirstFile(e: CustomKeyboardEvent) {
+    // Check for meta key to avoid overriding native chrome shortcut.
+    if (
+      this.shouldSuppressKeyboardShortcut(e) ||
+      this.getKeyboardEvent(e).metaKey
+    ) {
+      return;
+    }
+
+    e.preventDefault();
+    this._openSelectedFile(0);
+  }
+
+  _handleOpenFile(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
+      return;
+    }
+    e.preventDefault();
+
+    if (this._showInlineDiffs) {
+      this._openCursorFile();
+      return;
+    }
+
+    this._openSelectedFile();
+  }
+
+  _handleNextChunk(e: CustomKeyboardEvent) {
+    if (
+      this.shouldSuppressKeyboardShortcut(e) ||
+      (this.modifierPressed(e) &&
+        !this.isModifierPressed(e, Modifier.SHIFT_KEY)) ||
+      this._noDiffsExpanded()
+    ) {
+      return;
+    }
+
+    e.preventDefault();
+    if (this.isModifierPressed(e, Modifier.SHIFT_KEY)) {
+      this.$.diffCursor.moveToNextCommentThread();
+    } else {
+      this.$.diffCursor.moveToNextChunk();
+    }
+  }
+
+  _handlePrevChunk(e: CustomKeyboardEvent) {
+    if (
+      this.shouldSuppressKeyboardShortcut(e) ||
+      (this.modifierPressed(e) &&
+        !this.isModifierPressed(e, Modifier.SHIFT_KEY)) ||
+      this._noDiffsExpanded()
+    ) {
+      return;
+    }
+
+    e.preventDefault();
+    if (this.isModifierPressed(e, Modifier.SHIFT_KEY)) {
+      this.$.diffCursor.moveToPreviousCommentThread();
+    } else {
+      this.$.diffCursor.moveToPreviousChunk();
+    }
+  }
+
+  _handleToggleFileReviewed(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
+      return;
+    }
+
+    e.preventDefault();
+    if (!this._files[this.$.fileCursor.index]) {
+      return;
+    }
+    this._reviewFile(this._files[this.$.fileCursor.index].__path);
+  }
+
+  _handleToggleLeftPane(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e)) {
+      return;
+    }
+
+    e.preventDefault();
+    this._forEachDiff(diff => {
+      diff.toggleLeftDiff();
+    });
+  }
+
+  _toggleInlineDiffs() {
+    if (this._showInlineDiffs) {
+      this.collapseAllDiffs();
+    } else {
+      this.expandAllDiffs();
+    }
+  }
+
+  _openCursorFile() {
+    const diff = this.$.diffCursor.getTargetDiffElement();
+    if (
+      !this.change ||
+      !diff ||
+      !this.patchRange ||
+      !diff.path ||
+      !diff.patchRange
+    ) {
+      throw new Error('change, diff and patchRange must be all set and valid');
+    }
+    GerritNav.navigateToDiff(
+      this.change,
+      diff.path,
+      diff.patchRange.patchNum,
+      this.patchRange.basePatchNum
+    );
+  }
+
+  _openSelectedFile(index?: number) {
+    if (index !== undefined) {
+      this.$.fileCursor.setCursorAtIndex(index);
+    }
+    if (!this._files[this.$.fileCursor.index]) {
+      return;
+    }
+    if (!this.change || !this.patchRange) {
+      throw new Error('change and patchRange must be set');
+    }
+    GerritNav.navigateToDiff(
+      this.change,
+      this._files[this.$.fileCursor.index].__path,
+      this.patchRange.patchNum,
+      this.patchRange.basePatchNum
+    );
+  }
+
+  _addDraftAtTarget() {
+    const diff = this.$.diffCursor.getTargetDiffElement();
+    const target = this.$.diffCursor.getTargetLineElement();
+    if (diff && target) {
+      diff.addDraftAtLine(target);
+    }
+  }
+
+  _shouldHideChangeTotals(_patchChange: PatchChange): boolean {
+    return _patchChange.inserted === 0 && _patchChange.deleted === 0;
+  }
+
+  _shouldHideBinaryChangeTotals(_patchChange: PatchChange) {
+    return (
+      _patchChange.size_delta_inserted === 0 &&
+      _patchChange.size_delta_deleted === 0
+    );
+  }
+
+  _computeFileStatus(
+    status?: keyof typeof FileStatus
+  ): keyof typeof FileStatus {
+    return status || 'M';
+  }
+
+  _computeDiffURL(
+    change?: ParsedChangeInfo,
+    patchRange?: PatchRange,
+    path?: string,
+    editMode?: boolean
+  ) {
+    // Polymer 2: check for undefined
+    if (
+      change === undefined ||
+      patchRange === undefined ||
+      path === undefined ||
+      editMode === undefined
+    ) {
+      return;
+    }
+    if (editMode && path !== SpecialFilePath.MERGE_LIST) {
+      return GerritNav.getEditUrlForDiff(change, path, patchRange.patchNum);
+    }
+    return GerritNav.getUrlForDiff(
+      change,
+      path,
+      patchRange.patchNum,
+      patchRange.basePatchNum
+    );
+  }
+
+  _formatBytes(bytes?: number) {
+    if (!bytes) return '+/-0 B';
+    const bits = 1024;
+    const decimals = 1;
+    const sizes = ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB'];
+    const exponent = Math.floor(Math.log(Math.abs(bytes)) / Math.log(bits));
+    const prepend = bytes > 0 ? '+' : '';
+    const value = parseFloat(
+      (bytes / Math.pow(bits, exponent)).toFixed(decimals)
+    );
+    return `${prepend}${value} ${sizes[exponent]}`;
+  }
+
+  _formatPercentage(size?: number, delta?: number) {
+    if (size === undefined || delta === undefined) {
+      return '';
+    }
+    const oldSize = size - delta;
+
+    if (oldSize === 0) {
+      return '';
+    }
+
+    const percentage = Math.round(Math.abs((delta * 100) / oldSize));
+    return `(${delta > 0 ? '+' : '-'}${percentage}%)`;
+  }
+
+  _computeBinaryClass(delta?: number) {
+    if (!delta) {
+      return;
+    }
+    return delta > 0 ? 'added' : 'removed';
+  }
+
+  _computeClass(baseClass?: string, path?: string) {
+    const classes = [];
+    if (baseClass) {
+      classes.push(baseClass);
+    }
+    if (
+      path === SpecialFilePath.COMMIT_MESSAGE ||
+      path === SpecialFilePath.MERGE_LIST
+    ) {
+      classes.push('invisible');
+    }
+    return classes.join(' ');
+  }
+
+  _computeStatusClass(file?: NormalizedFileInfo) {
+    if (!file) return '';
+    const classStr = this._computeClass('status', file.__path);
+    return `${classStr} ${this._computeFileStatus(file.status)}`;
+  }
+
+  _computePathClass(
+    path: string | undefined,
+    expandedFilesRecord: ElementPropertyDeepChange<GrFileList, '_expandedFiles'>
+  ) {
+    return this._isFileExpanded(path, expandedFilesRecord) ? 'expanded' : '';
+  }
+
+  _computeShowHideIcon(
+    path: string | undefined,
+    expandedFilesRecord: ElementPropertyDeepChange<GrFileList, '_expandedFiles'>
+  ) {
+    return this._isFileExpanded(path, expandedFilesRecord)
+      ? 'gr-icons:expand-less'
+      : 'gr-icons:expand-more';
+  }
+
+  @observe(
+    '_filesByPath',
+    'changeComments',
+    'patchRange',
+    '_reviewed',
+    '_loading'
+  )
+  _computeFiles(
+    filesByPath?: FileNameToFileInfoMap,
+    changeComments?: ChangeComments,
+    patchRange?: PatchRange,
+    reviewed?: string[],
+    loading?: boolean
+  ) {
+    // Polymer 2: check for undefined
+    if (
+      filesByPath === undefined ||
+      changeComments === undefined ||
+      patchRange === undefined ||
+      reviewed === undefined ||
+      loading === undefined
+    ) {
+      return;
+    }
+
+    // Await all promises resolving from reload. @See Issue 9057
+    if (loading || !changeComments) {
+      return;
+    }
+
+    const commentedPaths = changeComments.getPaths(patchRange);
+    const files: FileNameToReviewedFileInfoMap = {...filesByPath};
+    addUnmodifiedFiles(files, commentedPaths);
+    const reviewedSet = new Set(reviewed || []);
+    for (const filePath in files) {
+      if (!hasOwnProperty(files, filePath)) {
+        continue;
+      }
+      files[filePath].isReviewed = reviewedSet.has(filePath);
+    }
+
+    this._files = this._normalizeChangeFilesResponse(files);
+  }
+
+  _computeFilesShown(
+    numFilesShown: number,
+    files: NormalizedFileInfo[]
+  ): NormalizedFileInfo[] | undefined {
+    // Polymer 2: check for undefined
+    if (numFilesShown === undefined || files === undefined) return undefined;
+
+    const previousNumFilesShown = this._shownFiles
+      ? this._shownFiles.length
+      : 0;
+
+    const filesShown = files.slice(0, numFilesShown);
+    this.dispatchEvent(
+      new CustomEvent('files-shown-changed', {
+        detail: {length: filesShown.length},
+        composed: true,
+        bubbles: true,
+      })
+    );
+
+    // Start the timer for the rendering work hwere because this is where the
+    // _shownFiles property is being set, and _shownFiles is used in the
+    // dom-repeat binding.
+    this.reporting.time(RENDER_TIMING_LABEL);
+
+    // How many more files are being shown (if it's an increase).
+    this._reportinShownFilesIncrement = Math.max(
+      0,
+      filesShown.length - previousNumFilesShown
+    );
+
+    return filesShown;
+  }
+
+  _updateDiffCursor() {
+    // Overwrite the cursor's list of diffs:
+    this.$.diffCursor.splice(
+      'diffs',
+      0,
+      this.$.diffCursor.diffs.length,
+      ...this.diffs
+    );
+  }
+
+  _filesChanged() {
+    if (this._files && this._files.length > 0) {
+      flush();
+      this.$.fileCursor.stops = Array.from(
+        this.root!.querySelectorAll(`.${FILE_ROW_CLASS}`)
+      );
+      this.$.fileCursor.setCursorAtIndex(this.selectedIndex, true);
+    }
+  }
+
+  _incrementNumFilesShown() {
+    this.numFilesShown += this.fileListIncrement;
+  }
+
+  _computeFileListControlClass(
+    numFilesShown?: number,
+    files?: NormalizedFileInfo[]
+  ) {
+    if (numFilesShown === undefined || files === undefined) return 'invisible';
+    return numFilesShown >= files.length ? 'invisible' : '';
+  }
+
+  _computeIncrementText(numFilesShown?: number, files?: NormalizedFileInfo[]) {
+    if (numFilesShown === undefined || files === undefined) return '';
+    const text = Math.min(this.fileListIncrement, files.length - numFilesShown);
+    return `Show ${text} more`;
+  }
+
+  _computeShowAllText(files: NormalizedFileInfo[]) {
+    if (!files) {
+      return '';
+    }
+    return `Show all ${files.length} files`;
+  }
+
+  _computeWarnShowAll(files: NormalizedFileInfo[]) {
+    return files.length > WARN_SHOW_ALL_THRESHOLD;
+  }
+
+  _computeShowAllWarning(files: NormalizedFileInfo[]) {
+    if (!this._computeWarnShowAll(files)) {
+      return '';
+    }
+    return `Warning: showing all ${files.length} files may take several seconds.`;
+  }
+
+  _showAllFiles() {
+    this.numFilesShown = this._files.length;
+  }
+
+  /**
+   * Get a descriptive label for use in the status indicator's tooltip and
+   * ARIA label.
+   */
+  _computeFileStatusLabel(status?: keyof typeof FileStatus) {
+    const statusCode = this._computeFileStatus(status);
+    return hasOwnProperty(FileStatus, statusCode)
+      ? FileStatus[statusCode]
+      : 'Status Unknown';
+  }
+
+  /**
+   * Converts any boolean-like variable to the string 'true' or 'false'
+   *
+   * This method is useful when you bind aria-checked attribute to a boolean
+   * value. The aria-checked attribute is string attribute. Binding directly
+   * to boolean variable causes problem on gerrit-CI.
+   *
+   * @return 'true' if val is true-like, otherwise false
+   */
+  _booleanToString(val?: unknown) {
+    return val ? 'true' : 'false';
+  }
+
+  _isFileExpanded(
+    path: string | undefined,
+    expandedFilesRecord: ElementPropertyDeepChange<GrFileList, '_expandedFiles'>
+  ) {
+    return expandedFilesRecord.base.some(f => f.path === path);
+  }
+
+  _isFileExpandedStr(
+    path: string | undefined,
+    expandedFilesRecord: ElementPropertyDeepChange<GrFileList, '_expandedFiles'>
+  ) {
+    return this._booleanToString(
+      this._isFileExpanded(path, expandedFilesRecord)
+    );
+  }
+
+  private _computeExpandedFiles(
+    expandedCount: number,
+    totalCount: number
+  ): FilesExpandedState {
+    if (expandedCount === 0) {
+      return FilesExpandedState.NONE;
+    } else if (expandedCount === totalCount) {
+      return FilesExpandedState.ALL;
+    }
+    return FilesExpandedState.SOME;
+  }
+
+  /**
+   * Handle splices to the list of expanded file paths. If there are any new
+   * entries in the expanded list, then render each diff corresponding in
+   * order by waiting for the previous diff to finish before starting the next
+   * one.
+   *
+   * @param record The splice record in the expanded paths list.
+   */
+  @observe('_expandedFiles.splices')
+  _expandedFilesChanged(record?: PolymerSpliceChange<PatchSetFile[]>) {
+    // Clear content for any diffs that are not open so if they get re-opened
+    // the stale content does not flash before it is cleared and reloaded.
+    const collapsedDiffs = this.diffs.filter(
+      diff => this._expandedFiles.findIndex(f => f.path === diff.path) === -1
+    );
+    this._clearCollapsedDiffs(collapsedDiffs);
+
+    if (!record) {
+      return;
+    } // Happens after "Collapse all" clicked.
+
+    this.filesExpanded = this._computeExpandedFiles(
+      this._expandedFiles.length,
+      this._files.length
+    );
+
+    // Find the paths introduced by the new index splices:
+    const newFiles = record.indexSplices
+      .map(splice =>
+        splice.object.slice(splice.index, splice.index + splice.addedCount)
+      )
+      .reduce((acc, paths) => acc.concat(paths), []);
+
+    // Required so that the newly created diff view is included in this.diffs.
+    flush();
+
+    this.reporting.time(EXPAND_ALL_TIMING_LABEL);
+
+    if (newFiles.length) {
+      this._renderInOrder(newFiles, this.diffs, newFiles.length);
+    }
+
+    this._updateDiffCursor();
+    this.$.diffCursor.reInitAndUpdateStops();
+  }
+
+  private _clearCollapsedDiffs(collapsedDiffs: GrDiffHost[]) {
+    for (const diff of collapsedDiffs) {
+      diff.cancel();
+      diff.clearDiffContent();
+    }
+  }
+
+  /**
+   * Given an array of paths and a NodeList of diff elements, render the diff
+   * for each path in order, awaiting the previous render to complete before
+   * continuing.
+   *
+   * @param initialCount The total number of paths in the pass. This
+   * is used to generate log messages.
+   */
+  private _renderInOrder(
+    files: PatchSetFile[],
+    diffElements: GrDiffHost[],
+    initialCount: number
+  ) {
+    let iter = 0;
+
+    for (const file of files) {
+      const path = file.path;
+      const diffElem = this._findDiffByPath(path, diffElements);
+      if (diffElem) {
+        diffElem.prefetchDiff();
+      }
+    }
+
+    return new Promise(resolve => {
+      this.dispatchEvent(
+        new CustomEvent('reload-drafts', {
+          detail: {resolve},
+          composed: true,
+          bubbles: true,
+        })
+      );
+    }).then(() =>
+      asyncForeach(files, (file, cancel) => {
+        const path = file.path;
+        this._cancelForEachDiff = cancel;
+
+        iter++;
+        console.info('Expanding diff', iter, 'of', initialCount, ':', path);
+        const diffElem = this._findDiffByPath(path, diffElements);
+        if (!diffElem) {
+          console.warn(`Did not find <gr-diff-host> element for ${path}`);
+          return Promise.resolve();
+        }
+        if (!this.changeComments || !this.patchRange || !this.diffPrefs) {
+          throw new Error(
+            'changeComments, patchRange and diffPrefs must be set'
+          );
+        }
+        diffElem.comments = this.changeComments.getCommentsBySideForFile(
+          file,
+          this.patchRange,
+          this.projectConfig
+        );
+        const promises: Array<Promise<unknown>> = [diffElem.reload()];
+        if (this._loggedIn && !this.diffPrefs.manual_review) {
+          promises.push(this._reviewFile(path, true));
+        }
+        return Promise.all(promises);
+      }).then(() => {
+        this._cancelForEachDiff = undefined;
+        console.info('Finished expanding', initialCount, 'diff(s)');
+        this.reporting.timeEndWithAverage(
+          EXPAND_ALL_TIMING_LABEL,
+          EXPAND_ALL_AVG_TIMING_LABEL,
+          initialCount
+        );
+        /* Block diff cursor from auto scrolling after files are done rendering.
+       * This prevents the bug where the screen jumps to the first diff chunk
+       * after files are done being rendered after the user has already begun
+       * scrolling.
+       * This also however results in the fact that the cursor does not auto
+       * focus on the first diff chunk on a small screen. This is however, a use
+       * case we are willing to not support for now.
+
+       * Using handleDiffUpdate resulted in diffCursor.row being set which
+       * prevented the issue of scrolling to top when we expand the second
+       * file individually.
+       */
+        this.$.diffCursor.reInitAndUpdateStops();
+      })
+    );
+  }
+
+  /** Cancel the rendering work of every diff in the list */
+  _cancelDiffs() {
+    if (this._cancelForEachDiff) {
+      this._cancelForEachDiff();
+    }
+    this._forEachDiff(d => d.cancel());
+  }
+
+  /**
+   * In the given NodeList of diff elements, find the diff for the given path.
+   */
+  private _findDiffByPath(path: string, diffElements: GrDiffHost[]) {
+    for (let i = 0; i < diffElements.length; i++) {
+      if (diffElements[i].path === path) {
+        return diffElements[i];
+      }
+    }
+    return undefined;
+  }
+
+  /**
+   * Reset the comments of a modified thread
+   */
+  reloadCommentsForThreadWithRootId(rootId: UrlEncodedCommentId, path: string) {
+    // Don't bother continuing if we already know that the path that contains
+    // the updated comment thread is not expanded.
+    if (!this._expandedFiles.some(f => f.path === path)) {
+      return;
+    }
+    const diff = this.diffs.find(d => d.path === path);
+
+    if (!diff) {
+      throw new Error("Can't find diff by path");
+    }
+
+    const threadEl = diff.getThreadEls().find(t => t.rootId === rootId);
+    if (!threadEl) {
+      return;
+    }
+
+    if (!this.changeComments) {
+      throw new Error('changeComments must be set');
+    }
+
+    const newComments = this.changeComments.getCommentsForThread(rootId);
+
+    // If newComments is null, it means that a single draft was
+    // removed from a thread in the thread view, and the thread should
+    // no longer exist. Remove the existing thread element in the diff
+    // view.
+    if (!newComments) {
+      threadEl.fireRemoveSelf();
+      return;
+    }
+
+    // Comments are not returned with the commentSide attribute from
+    // the api, but it's necessary to be stored on the diff's
+    // comments due to use in the _handleCommentUpdate function.
+    // The comment thread already has a side associated with it, so
+    // set the comment's side to match.
+    threadEl.comments = newComments.map(c =>
+      Object.assign(c, {__commentSide: threadEl.commentSide})
+    );
+    flush();
+  }
+
+  _handleEscKey(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
+      return;
+    }
+    e.preventDefault();
+    this._displayLine = false;
+  }
+
+  /**
+   * Update the loading class for the file list rows. The update is inside a
+   * debouncer so that the file list doesn't flash gray when the API requests
+   * are reasonably fast.
+   */
+  _loadingChanged(loading?: boolean) {
+    this.debounce(
+      'loading-change',
+      () => {
+        // Only show set the loading if there have been files loaded to show. In
+        // this way, the gray loading style is not shown on initial loads.
+        this.classList.toggle('loading', loading && !!this._files.length);
+      },
+      LOADING_DEBOUNCE_INTERVAL
+    );
+  }
+
+  _editModeChanged(editMode?: boolean) {
+    this.classList.toggle('editMode', editMode);
+  }
+
+  _computeReviewedClass(isReviewed?: boolean) {
+    return isReviewed ? 'isReviewed' : '';
+  }
+
+  _computeReviewedText(isReviewed?: boolean) {
+    return isReviewed ? 'MARK UNREVIEWED' : 'MARK REVIEWED';
+  }
+
+  /**
+   * Given a file path, return whether that path should have visible size bars
+   * and be included in the size bars calculation.
+   */
+  _showBarsForPath(path?: string) {
+    return (
+      path !== SpecialFilePath.COMMIT_MESSAGE &&
+      path !== SpecialFilePath.MERGE_LIST
+    );
+  }
+
+  /**
+   * Compute size bar layout values from the file list.
+   */
+  _computeSizeBarLayout(
+    shownFilesRecord?: ElementPropertyDeepChange<GrFileList, '_shownFiles'>
+  ) {
+    const stats: SizeBarLayout = createDefaultSizeBarLayout();
+    if (!shownFilesRecord || !shownFilesRecord.base) {
+      return stats;
+    }
+    shownFilesRecord.base
+      .filter(f => this._showBarsForPath(f.__path))
+      .forEach(f => {
+        if (f.lines_inserted) {
+          stats.maxInserted = Math.max(stats.maxInserted, f.lines_inserted);
+        }
+        if (f.lines_deleted) {
+          stats.maxDeleted = Math.max(stats.maxDeleted, f.lines_deleted);
+        }
+      });
+    const ratio = stats.maxInserted / (stats.maxInserted + stats.maxDeleted);
+    if (!isNaN(ratio)) {
+      stats.maxAdditionWidth =
+        (SIZE_BAR_MAX_WIDTH - SIZE_BAR_GAP_WIDTH) * ratio;
+      stats.maxDeletionWidth =
+        SIZE_BAR_MAX_WIDTH - SIZE_BAR_GAP_WIDTH - stats.maxAdditionWidth;
+      stats.deletionOffset = stats.maxAdditionWidth + SIZE_BAR_GAP_WIDTH;
+    }
+    return stats;
+  }
+
+  /**
+   * Get the width of the addition bar for a file.
+   */
+  _computeBarAdditionWidth(file?: NormalizedFileInfo, stats?: SizeBarLayout) {
+    if (
+      !file ||
+      !stats ||
+      stats.maxInserted === 0 ||
+      !file.lines_inserted ||
+      !this._showBarsForPath(file.__path)
+    ) {
+      return 0;
+    }
+    const width =
+      (stats.maxAdditionWidth * file.lines_inserted) / stats.maxInserted;
+    return width === 0 ? 0 : Math.max(SIZE_BAR_MIN_WIDTH, width);
+  }
+
+  /**
+   * Get the x-offset of the addition bar for a file.
+   */
+  _computeBarAdditionX(file?: NormalizedFileInfo, stats?: SizeBarLayout) {
+    if (!file || !stats) return;
+    return stats.maxAdditionWidth - this._computeBarAdditionWidth(file, stats);
+  }
+
+  /**
+   * Get the width of the deletion bar for a file.
+   */
+  _computeBarDeletionWidth(file?: NormalizedFileInfo, stats?: SizeBarLayout) {
+    if (
+      !file ||
+      !stats ||
+      stats.maxDeleted === 0 ||
+      !file.lines_deleted ||
+      !this._showBarsForPath(file.__path)
+    ) {
+      return 0;
+    }
+    const width =
+      (stats.maxDeletionWidth * file.lines_deleted) / stats.maxDeleted;
+    return width === 0 ? 0 : Math.max(SIZE_BAR_MIN_WIDTH, width);
+  }
+
+  /**
+   * Get the x-offset of the deletion bar for a file.
+   */
+  _computeBarDeletionX(stats: SizeBarLayout) {
+    return stats.deletionOffset;
+  }
+
+  _computeShowSizeBars(userPrefs?: PreferencesInfo) {
+    return !!userPrefs?.size_bar_in_change_table;
+  }
+
+  _computeSizeBarsClass(showSizeBars?: boolean, path?: string) {
+    let hideClass = '';
+    if (!showSizeBars) {
+      hideClass = 'hide';
+    } else if (!this._showBarsForPath(path)) {
+      hideClass = 'invisible';
+    }
+    return `sizeBars desktop ${hideClass}`;
+  }
+
+  /**
+   * Shows registered dynamic columns iff the 'header', 'content' and
+   * 'summary' endpoints are registered the exact same number of times.
+   * Ideally, there should be a better way to enforce the expectation of the
+   * dependencies between dynamic endpoints.
+   */
+  _computeShowDynamicColumns(
+    headerEndpoints?: string,
+    contentEndpoints?: string,
+    summaryEndpoints?: string
+  ) {
+    return (
+      headerEndpoints &&
+      contentEndpoints &&
+      summaryEndpoints &&
+      headerEndpoints.length &&
+      headerEndpoints.length === contentEndpoints.length &&
+      headerEndpoints.length === summaryEndpoints.length
+    );
+  }
+
+  /**
+   * Shows registered dynamic prepended columns iff the 'header', 'content'
+   * endpoints are registered the exact same number of times.
+   */
+  _computeShowPrependedDynamicColumns(
+    headerEndpoints?: string,
+    contentEndpoints?: string
+  ) {
+    return (
+      headerEndpoints &&
+      contentEndpoints &&
+      headerEndpoints.length &&
+      headerEndpoints.length === contentEndpoints.length
+    );
+  }
+
+  /**
+   * Returns true if none of the inline diffs have been expanded.
+   */
+  _noDiffsExpanded() {
+    return this.filesExpanded === FilesExpandedState.NONE;
+  }
+
+  /**
+   * Method to call via binding when each file list row is rendered. This
+   * allows approximate detection of when the dom-repeat has completed
+   * rendering.
+   *
+   * @param index The index of the row being rendered.
+   */
+  _reportRenderedRow(index: number) {
+    if (index === this._shownFiles.length - 1) {
+      this.async(() => {
+        this.reporting.timeEndWithAverage(
+          RENDER_TIMING_LABEL,
+          RENDER_AVG_TIMING_LABEL,
+          this._reportinShownFilesIncrement
+        );
+      }, 1);
+    }
+    return '';
+  }
+
+  _reviewedTitle(reviewed?: boolean) {
+    if (reviewed) {
+      return 'Mark as not reviewed (shortcut: r)';
+    }
+
+    return 'Mark as reviewed (shortcut: r)';
+  }
+
+  _handleReloadingDiffPreference() {
+    this._getDiffPreferences().then(prefs => {
+      this.diffPrefs = prefs;
+    });
+  }
+
+  /**
+   * Wrapper for using in the element template and computed properties
+   */
+  _computeDisplayPath(path: string) {
+    return computeDisplayPath(path);
+  }
+
+  /**
+   * Wrapper for using in the element template and computed properties
+   */
+  _computeTruncatedPath(path: string) {
+    return computeTruncatedPath(path);
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-file-list': GrFileList;
+  }
+}
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_html.ts b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_html.ts
index a577eb6..1c30a65 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_html.ts
@@ -375,7 +375,7 @@
       <div class="stickyArea">
         <div
           class$="file-row row [[_computePathClass(file.__path, _expandedFiles.*)]]"
-          data-file$="[[_computeFileRange(file)]]"
+          data-file$="[[_computePatchSetFile(file)]]"
           tabindex="-1"
           role="row"
         >
@@ -657,7 +657,7 @@
             hidden="[[!_isFileExpanded(file.__path, _expandedFiles.*)]]"
             change-num="[[changeNum]]"
             patch-range="[[patchRange]]"
-            file="[[_computeFileRange(file)]]"
+            file="[[_computePatchSetFile(file)]]"
             path="[[file.__path]]"
             prefs="[[diffPrefs]]"
             project-name="[[change.project]]"
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 8aebf9b..129b65e 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
@@ -312,7 +312,7 @@
 
       for (const bytes in table) {
         if (table.hasOwnProperty(bytes)) {
-          assert.equal(element._formatBytes(bytes), table[bytes]);
+          assert.equal(element._formatBytes(Number(bytes)), table[bytes]);
         }
       }
     });
@@ -1329,15 +1329,23 @@
 
   suite('size bars', () => {
     test('_computeSizeBarLayout', () => {
-      assert.isUndefined(element._computeSizeBarLayout(null));
-      assert.isUndefined(element._computeSizeBarLayout({}));
-      assert.deepEqual(element._computeSizeBarLayout({base: []}), {
+      const defaultSizeBarLayout = {
         maxInserted: 0,
         maxDeleted: 0,
         maxAdditionWidth: 0,
         maxDeletionWidth: 0,
         deletionOffset: 0,
-      });
+      };
+
+      assert.deepEqual(
+          element._computeSizeBarLayout(null),
+          defaultSizeBarLayout);
+      assert.deepEqual(
+          element._computeSizeBarLayout({}),
+          defaultSizeBarLayout);
+      assert.deepEqual(
+          element._computeSizeBarLayout({base: []}),
+          defaultSizeBarLayout);
 
       const files = [
         {__path: '/COMMIT_MSG', lines_inserted: 10000},
diff --git a/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.ts b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.ts
index 245c65f..2e9189e 100644
--- a/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.ts
+++ b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.ts
@@ -61,7 +61,7 @@
   @property({type: Object})
   _labelValues?: LabelValuesMap;
 
-  getLabelValues(): LabelNameToValuesMap {
+  getLabelValues(includeDefaults = true): LabelNameToValuesMap {
     const labels: LabelNameToValuesMap = {};
     if (this.shadowRoot === null || !this.change) {
       return labels;
@@ -106,8 +106,12 @@
         prevValNum = prevVal;
       }
 
+      const defValNum = this._getDefaultValue(this.change.labels, label);
+
       if (selectedVal !== prevValNum) {
-        labels[label] = selectedVal;
+        if (includeDefaults || !!prevValNum || selectedVal !== defValNum) {
+          labels[label] = selectedVal;
+        }
       }
     }
     return labels;
@@ -126,6 +130,12 @@
     return numberValue;
   }
 
+  _getDefaultValue(labels?: LabelNameToInfoMap, labelName?: string) {
+    if (!labelName || !labels?.[labelName]) return undefined;
+    const labelInfo = labels[labelName] as DetailedLabelInfo;
+    return labelInfo.default_value;
+  }
+
   _getVoteForAccount(
     labels: LabelNameToInfoMap | undefined,
     labelName: string,
diff --git a/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores_test.js b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores_test.js
index ffc17cd..ae639e1 100644
--- a/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores_test.js
+++ b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores_test.js
@@ -99,6 +99,22 @@
     });
   });
 
+  test('getLabelValues includeDefaults', async () => {
+    element.change = {
+      _number: '123',
+      labels: {
+        'Code-Review': {
+          values: {'0': 'meh', '+1': 'good', '-1': 'bad'},
+          default_value: 0,
+        },
+      },
+    };
+    await flush();
+
+    assert.deepEqual(element.getLabelValues(true), {'Code-Review': 0});
+    assert.deepEqual(element.getLabelValues(false), {});
+  });
+
   test('_getVoteForAccount', () => {
     const labelName = 'Code-Review';
     assert.strictEqual(element._getVoteForAccount(
diff --git a/polygerrit-ui/app/elements/change/gr-message/gr-message.ts b/polygerrit-ui/app/elements/change/gr-message/gr-message.ts
index 87d443b..8b0cf4b 100644
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message.ts
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message.ts
@@ -39,9 +39,10 @@
   ReviewInputTag,
   VotingRangeInfo,
   NumericChangeId,
+  ChangeMessageId,
 } from '../../../types/common';
 import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
-import {CommentThread} from '../../diff/gr-comment-api/gr-comment-api';
+import {CommentThread} from '../../../utils/comment-util';
 import {hasOwnProperty} from '../../../utils/common-util';
 
 const PATCH_SET_PREFIX_PATTERN = /^(?:Uploaded\s*)?(?:P|p)atch (?:S|s)et \d+:\s*(.*)/;
@@ -53,6 +54,10 @@
   }
 }
 
+export interface MessageAnchorTapDetail {
+  id: ChangeMessageId;
+}
+
 export interface GrMessage {
   $: {
     restAPI: RestApiService & Element;
@@ -438,11 +443,16 @@
 
   _handleAnchorClick(e: Event) {
     e.preventDefault();
+    // The element which triggers _handleAnchorClick is rendered only if
+    // message.id defined: the elemenet is wrapped in dom-if if="[[message.id]]"
+    const detail: MessageAnchorTapDetail = {
+      id: this.message!.id,
+    };
     this.dispatchEvent(
       new CustomEvent('message-anchor-tap', {
         bubbles: true,
         composed: true,
-        detail: {id: this.message?.id},
+        detail,
       })
     );
   }
diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.js b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.js
deleted file mode 100644
index bed78df..0000000
--- a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.js
+++ /dev/null
@@ -1,433 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import '@polymer/paper-toggle-button/paper-toggle-button.js';
-import '../../shared/gr-button/gr-button.js';
-import '../../shared/gr-icons/gr-icons.js';
-import '../gr-message/gr-message.js';
-import '../../../styles/shared-styles.js';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
-import {PolymerElement} from '@polymer/polymer/polymer-element.js';
-import {htmlTemplate} from './gr-messages-list_html.js';
-import {
-  KeyboardShortcutMixin,
-  Shortcut, ShortcutSection,
-} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.js';
-import {parseDate} from '../../../utils/date-util.js';
-import {MessageTag} from '../../../constants/constants.js';
-import {appContext} from '../../../services/app-context.js';
-
-/**
- * The content of the enum is also used in the UI for the button text.
- *
- * @enum {string}
- */
-const ExpandAllState = {
-  EXPAND_ALL: 'Expand All',
-  COLLAPSE_ALL: 'Collapse All',
-};
-
-/**
- * Computes message author's comments for this change message. The backend
- * sets comment.change_message_id for matching, so this computation is fairly
- * straightforward.
- */
-function computeThreads(message, allMessages, changeComments) {
-  if ([message, allMessages, changeComments].includes(undefined)) {
-    return [];
-  }
-  if (message._index === undefined) {
-    return [];
-  }
-
-  return changeComments.getAllThreadsForChange().filter(
-      thread => thread.comments.map(comment => {
-        // collapse all by default
-        comment.collapsed = true;
-        return comment;
-      }).some(comment => {
-        const condition = comment.change_message_id === message.id;
-        // Since getAllThreadsForChange() always returns a new copy of
-        // all comments we can modify them here without worrying about
-        // polluting other threads.
-        comment.collapsed = !condition;
-        return condition;
-      })
-  );
-}
-
-/**
- * If messages have the same tag, then that influences grouping and whether
- * a message is initally hidden or not, see isImportant(). So we are applying
- * some "magic" rules here in order to hide exactly the right messages.
- *
- * 1. If a message does not have a tag, but is associated with robot comments,
- * then it gets a tag.
- *
- * 2. Use the same tag for some of Gerrit's standard events, if they should be
- * considered one group, e.g. normal and wip patchset uploads.
- *
- * 3. Everything beyond the ~ character is cut off from the tag. That gives
- * tools control over which messages will be hidden.
- */
-function computeTag(message) {
-  if (!message.tag) {
-    const threads = message.commentThreads || [];
-    const comments = threads.map(
-        t => t.comments.find(c => c.change_message_id === message.id));
-    const isRobot = comments.some(c => c && !!c.robot_id);
-    return isRobot ? 'autogenerated:has-robot-comments' : undefined;
-  }
-
-  if (message.tag === MessageTag.TAG_NEW_WIP_PATCHSET) {
-    return MessageTag.TAG_NEW_PATCHSET;
-  }
-  if (message.tag === MessageTag.TAG_UNSET_ASSIGNEE) {
-    return MessageTag.TAG_SET_ASSIGNEE;
-  }
-  if (message.tag === MessageTag.TAG_UNSET_PRIVATE) {
-    return MessageTag.TAG_SET_PRIVATE;
-  }
-  if (message.tag === MessageTag.TAG_SET_WIP) {
-    return MessageTag.TAG_SET_READY;
-  }
-
-  return message.tag.replace(/~.*/, '');
-}
-
-/**
- * Try to set a revision number that makes sense, if none is set. Just copy
- * over the revision number of the next older message. This is mostly relevant
- * for reviewer updates. Other messages should typically have the revision
- * number already set.
- */
-function computeRevision(message, allMessages) {
-  if (message._revision_number > 0) return message._revision_number;
-  let revision = 0;
-  for (const m of allMessages) {
-    if (m.date > message.date) break;
-    if (m._revision_number > revision) revision = m._revision_number;
-  }
-  return revision > 0 ? revision : undefined;
-}
-
-/**
- * Unimportant messages are initially hidden.
- *
- * Human messages are always important. They have an undefined tag.
- *
- * Autogenerated messages are unimportant, if there is a message with the same
- * tag and a higher revision number.
- */
-function computeIsImportant(message, allMessages) {
-  if (!message.tag) return true;
-
-  const hasSameTag = m => m.tag === message.tag;
-  const revNumber = message._revision_number || 0;
-  const hasHigherRevisionNumber = m => m._revision_number > revNumber;
-  return !allMessages.filter(hasSameTag).some(hasHigherRevisionNumber);
-}
-
-export const TEST_ONLY = {
-  computeThreads,
-  computeTag,
-  computeRevision,
-  computeIsImportant,
-};
-
-/**
- * @extends PolymerElement
- */
-class GrMessagesList extends KeyboardShortcutMixin(
-    GestureEventListeners(
-        LegacyElementMixin(
-            PolymerElement))) {
-  static get template() { return htmlTemplate; }
-
-  static get is() { return 'gr-messages-list'; }
-
-  static get properties() {
-    return {
-      /** @type {?} */
-      change: Object,
-      changeNum: Number,
-      /**
-       * These are just the change messages. They are combined with reviewer
-       * updates below. So _combinedMessages is the more important property.
-       */
-      messages: {
-        type: Array,
-        value() { return []; },
-      },
-      /**
-       * These are just the reviewer updates. They are combined with change
-       * messages above. So _combinedMessages is the more important property.
-       */
-      reviewerUpdates: {
-        type: Array,
-        value() { return []; },
-      },
-      changeComments: Object,
-      projectName: String,
-      showReplyButtons: {
-        type: Boolean,
-        value: false,
-      },
-      labels: Object,
-
-      /**
-       * Keeps track of the state of the "Expand All" toggle button. Note that
-       * you can individually expand/collapse some messages without affecting
-       * the toggle button's state.
-       *
-       * @type {ExpandAllState}
-       */
-      _expandAllState: {
-        type: String,
-        value: ExpandAllState.EXPAND_ALL,
-      },
-      _expandAllTitle: {
-        type: String,
-        computed: '_computeExpandAllTitle(_expandAllState)',
-      },
-
-      _showAllActivity: {
-        type: Boolean,
-        value: false,
-        observer: '_observeShowAllActivity',
-      },
-      /**
-       * The merged array of change messages and reviewer updates.
-       */
-      _combinedMessages: {
-        type: Array,
-        computed: '_computeCombinedMessages(messages, reviewerUpdates, '
-            + 'changeComments)',
-        observer: '_combinedMessagesChanged',
-      },
-
-      _labelExtremes: {
-        type: Object,
-        computed: '_computeLabelExtremes(labels.*)',
-      },
-    };
-  }
-
-  constructor() {
-    super();
-    this.reporting = appContext.reportingService;
-  }
-
-  scrollToMessage(messageID) {
-    const selector = `[data-message-id="${messageID}"]`;
-    const el = this.shadowRoot.querySelector(selector);
-
-    if (!el && this._showAllActivity) {
-      console.warn(`Failed to scroll to message: ${messageID}`);
-      return;
-    }
-    if (!el) {
-      this._showAllActivity = true;
-      setTimeout(() => this.scrollToMessage(messageID));
-      return;
-    }
-
-    el.set('message.expanded', true);
-    let top = el.offsetTop;
-    for (let offsetParent = el.offsetParent;
-      offsetParent;
-      offsetParent = offsetParent.offsetParent) {
-      top += offsetParent.offsetTop;
-    }
-    window.scrollTo(0, top);
-    this._highlightEl(el);
-  }
-
-  _observeShowAllActivity(showAllActivity) {
-    // We have to call render() such that the dom-repeat filter picks up the
-    // change.
-    this.$.messageRepeat.render();
-  }
-
-  /**
-   * Filter for the dom-repeat of combinedMessages.
-   */
-  _isMessageVisible(message) {
-    return this._showAllActivity || message.isImportant;
-  }
-
-  /**
-   * Merges change messages and reviewer updates into one array. Also processes
-   * all messages and updates, aligns or massages some of the properties.
-   */
-  _computeCombinedMessages(messages, reviewerUpdates, changeComments) {
-    const params = [messages, reviewerUpdates, changeComments];
-    if (params.some(o => o === undefined)) return [];
-
-    let mi = 0;
-    let ri = 0;
-    let combinedMessages = [];
-    let mDate;
-    let rDate;
-    for (let i = 0; i < messages.length; i++) {
-      messages[i]._index = i;
-    }
-
-    while (mi < messages.length || ri < reviewerUpdates.length) {
-      if (mi >= messages.length) {
-        combinedMessages = combinedMessages.concat(reviewerUpdates.slice(ri));
-        break;
-      }
-      if (ri >= reviewerUpdates.length) {
-        combinedMessages = combinedMessages.concat(messages.slice(mi));
-        break;
-      }
-      mDate = mDate || parseDate(messages[mi].date);
-      rDate = rDate || parseDate(reviewerUpdates[ri].date);
-      if (rDate < mDate) {
-        combinedMessages.push(reviewerUpdates[ri++]);
-        rDate = null;
-      } else {
-        combinedMessages.push(messages[mi++]);
-        mDate = null;
-      }
-    }
-    combinedMessages.forEach(m => {
-      if (m.expanded === undefined) {
-        m.expanded = false;
-      }
-      m.commentThreads = computeThreads(m, combinedMessages, changeComments);
-      m._revision_number = computeRevision(m, combinedMessages);
-      m.tag = computeTag(m);
-    });
-    // computeIsImportant() depends on tags and revision numbers already being
-    // updated for all messages, so we have to compute this in its own forEach
-    // loop.
-    combinedMessages.forEach(m => {
-      m.isImportant = computeIsImportant(m, combinedMessages);
-    });
-    return combinedMessages;
-  }
-
-  _updateExpandedStateOfAllMessages(exp) {
-    if (this._combinedMessages) {
-      for (let i = 0; i < this._combinedMessages.length; i++) {
-        this._combinedMessages[i].expanded = exp;
-        this.notifyPath(`_combinedMessages.${i}.expanded`);
-      }
-    }
-  }
-
-  _computeExpandAllTitle(_expandAllState) {
-    if (_expandAllState === ExpandAllState.COLLAPSE_ALL) {
-      return this.createTitle(
-          Shortcut.COLLAPSE_ALL_MESSAGES, ShortcutSection.ACTIONS);
-    }
-    if (_expandAllState === ExpandAllState.EXPAND_ALL) {
-      return this.createTitle(
-          Shortcut.EXPAND_ALL_MESSAGES, ShortcutSection.ACTIONS);
-    }
-    return '';
-  }
-
-  _highlightEl(el) {
-    const highlightedEls =
-        this.root.querySelectorAll('.highlighted');
-    for (const highlightedEl of highlightedEls) {
-      highlightedEl.classList.remove('highlighted');
-    }
-    function handleAnimationEnd() {
-      el.removeEventListener('animationend', handleAnimationEnd);
-      el.classList.remove('highlighted');
-    }
-    el.addEventListener('animationend', handleAnimationEnd);
-    el.classList.add('highlighted');
-  }
-
-  /**
-   * @param {boolean} expand
-   */
-  handleExpandCollapse(expand) {
-    this._expandAllState = expand ? ExpandAllState.COLLAPSE_ALL
-      : ExpandAllState.EXPAND_ALL;
-    this._updateExpandedStateOfAllMessages(expand);
-  }
-
-  _handleExpandCollapseTap(e) {
-    e.preventDefault();
-    this.handleExpandCollapse(
-        this._expandAllState === ExpandAllState.EXPAND_ALL);
-  }
-
-  _handleAnchorClick(e) {
-    this.scrollToMessage(e.detail.id);
-  }
-
-  _isVisibleShowAllActivityToggle(messages = []) {
-    return messages.some(m => !m.isImportant);
-  }
-
-  _computeHiddenEntriesCount(messages = []) {
-    return messages.filter(m => !m.isImportant).length;
-  }
-
-  /**
-   * This method is for reporting stats only.
-   */
-  _combinedMessagesChanged(combinedMessages) {
-    if (combinedMessages) {
-      if (combinedMessages.length === 0) return;
-      const tags = combinedMessages.map(
-          message => message.tag || message.type ||
-              (message.comments ? 'comments' : 'none'));
-      const tagsCounted = tags.reduce((acc, val) => {
-        acc[val] = (acc[val] || 0) + 1;
-        return acc;
-      }, {all: combinedMessages.length});
-      this.reporting.reportInteraction('messages-count', tagsCounted);
-    }
-  }
-
-  /**
-   * Compute a mapping from label name to objects representing the minimum and
-   * maximum possible values for that label.
-   */
-  _computeLabelExtremes(labelRecord) {
-    const extremes = {};
-    const labels = labelRecord.base;
-    if (!labels) { return extremes; }
-    for (const key of Object.keys(labels)) {
-      if (!labels[key] || !labels[key].values) { continue; }
-      const values = Object.keys(labels[key].values)
-          .map(v => parseInt(v, 10));
-      values.sort((a, b) => a - b);
-      if (!values.length) { continue; }
-      extremes[key] = {min: values[0], max: values[values.length - 1]};
-    }
-    return extremes;
-  }
-
-  /**
-   * Work around a issue on iOS when clicking turns into double tap
-   */
-  _onTapShowAllActivityToggle(e) {
-    e.preventDefault();
-  }
-}
-
-customElements.define(GrMessagesList.is,
-    GrMessagesList);
diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.ts b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.ts
new file mode 100644
index 0000000..8557c10
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.ts
@@ -0,0 +1,495 @@
+/**
+ * @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 '@polymer/paper-toggle-button/paper-toggle-button';
+import '../../shared/gr-button/gr-button';
+import '../../shared/gr-icons/gr-icons';
+import '../gr-message/gr-message';
+import '../../../styles/shared-styles';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-messages-list_html';
+import {
+  KeyboardShortcutMixin,
+  Shortcut,
+  ShortcutSection,
+} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
+import {parseDate} from '../../../utils/date-util';
+import {MessageTag} from '../../../constants/constants';
+import {appContext} from '../../../services/app-context';
+import {customElement, property} from '@polymer/decorators';
+import {
+  ChangeId,
+  ChangeMessageId,
+  ChangeMessageInfo,
+  ChangeViewChangeInfo,
+  LabelNameToInfoMap,
+  NumericChangeId,
+  PatchSetNum,
+  RepoName,
+  ReviewerUpdateInfo,
+  VotingRangeInfo,
+} from '../../../types/common';
+import {ChangeComments} from '../../diff/gr-comment-api/gr-comment-api';
+import {CommentThread, isRobot} from '../../../utils/comment-util';
+import {GrMessage, MessageAnchorTapDetail} from '../gr-message/gr-message';
+import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
+import {FormattedReviewerUpdateInfo} from '../../shared/gr-rest-api-interface/gr-reviewer-updates-parser';
+import {DomRepeat} from '@polymer/polymer/lib/elements/dom-repeat';
+import {getVotingRange} from '../../../utils/label-util';
+
+/**
+ * The content of the enum is also used in the UI for the button text.
+ */
+enum ExpandAllState {
+  EXPAND_ALL = 'Expand All',
+  COLLAPSE_ALL = 'Collapse All',
+}
+
+interface TagsCountReportInfo {
+  [tag: string]: number;
+  all: number;
+}
+
+type CombinedMessage = Omit<
+  FormattedReviewerUpdateInfo | ChangeMessageInfo,
+  'tag'
+> & {
+  _revision_number?: PatchSetNum;
+  _index?: number;
+  expanded?: boolean;
+  isImportant?: boolean;
+  commentThreads?: CommentThread[];
+  tag?: string;
+};
+
+function isChangeMessageInfo(x: CombinedMessage): x is ChangeMessageInfo {
+  return (x as ChangeMessageInfo).id !== undefined;
+}
+
+function getMessageId(x: CombinedMessage): ChangeMessageId | undefined {
+  return isChangeMessageInfo(x) ? x.id : undefined;
+}
+
+/**
+ * Computes message author's comments for this change message. The backend
+ * sets comment.change_message_id for matching, so this computation is fairly
+ * straightforward.
+ */
+function computeThreads(
+  message: CombinedMessage,
+  changeComments: ChangeComments
+): CommentThread[] {
+  if (message._index === undefined) {
+    return [];
+  }
+  const messageId = getMessageId(message);
+  return changeComments.getAllThreadsForChange().filter(thread =>
+    thread.comments
+      .map(comment => {
+        // collapse all by default
+        comment.collapsed = true;
+        return comment;
+      })
+      .some(comment => {
+        const condition = comment.change_message_id === messageId;
+        // Since getAllThreadsForChange() always returns a new copy of
+        // all comments we can modify them here without worrying about
+        // polluting other threads.
+        comment.collapsed = !condition;
+        return condition;
+      })
+  );
+}
+
+/**
+ * If messages have the same tag, then that influences grouping and whether
+ * a message is initally hidden or not, see isImportant(). So we are applying
+ * some "magic" rules here in order to hide exactly the right messages.
+ *
+ * 1. If a message does not have a tag, but is associated with robot comments,
+ * then it gets a tag.
+ *
+ * 2. Use the same tag for some of Gerrit's standard events, if they should be
+ * considered one group, e.g. normal and wip patchset uploads.
+ *
+ * 3. Everything beyond the ~ character is cut off from the tag. That gives
+ * tools control over which messages will be hidden.
+ */
+function computeTag(message: CombinedMessage) {
+  if (!message.tag) {
+    const threads = message.commentThreads || [];
+    const messageId = getMessageId(message);
+    const comments = threads.map(t =>
+      t.comments.find(c => c.change_message_id === messageId)
+    );
+    const hasRobotComments = comments.some(isRobot);
+    return hasRobotComments ? 'autogenerated:has-robot-comments' : undefined;
+  }
+
+  if (message.tag === MessageTag.TAG_NEW_WIP_PATCHSET) {
+    return MessageTag.TAG_NEW_PATCHSET;
+  }
+  if (message.tag === MessageTag.TAG_UNSET_ASSIGNEE) {
+    return MessageTag.TAG_SET_ASSIGNEE;
+  }
+  if (message.tag === MessageTag.TAG_UNSET_PRIVATE) {
+    return MessageTag.TAG_SET_PRIVATE;
+  }
+  if (message.tag === MessageTag.TAG_SET_WIP) {
+    return MessageTag.TAG_SET_READY;
+  }
+
+  return message.tag.replace(/~.*/, '');
+}
+
+/**
+ * Try to set a revision number that makes sense, if none is set. Just copy
+ * over the revision number of the next older message. This is mostly relevant
+ * for reviewer updates. Other messages should typically have the revision
+ * number already set.
+ */
+function computeRevision(
+  message: CombinedMessage,
+  allMessages: CombinedMessage[]
+): PatchSetNum | undefined {
+  if (message._revision_number && message._revision_number > 0)
+    return message._revision_number;
+  let revision: PatchSetNum = 0 as PatchSetNum;
+  for (const m of allMessages) {
+    if (m.date > message.date) break;
+    if (m._revision_number && m._revision_number > revision)
+      revision = m._revision_number;
+  }
+  return revision > 0 ? revision : undefined;
+}
+
+/**
+ * Unimportant messages are initially hidden.
+ *
+ * Human messages are always important. They have an undefined tag.
+ *
+ * Autogenerated messages are unimportant, if there is a message with the same
+ * tag and a higher revision number.
+ */
+function computeIsImportant(
+  message: CombinedMessage,
+  allMessages: CombinedMessage[]
+) {
+  if (!message.tag) return true;
+
+  const hasSameTag = (m: CombinedMessage) => m.tag === message.tag;
+  const revNumber = message._revision_number || 0;
+  const hasHigherRevisionNumber = (m: CombinedMessage) =>
+    (m._revision_number || 0) > revNumber;
+  return !allMessages.filter(hasSameTag).some(hasHigherRevisionNumber);
+}
+
+export const TEST_ONLY = {
+  computeThreads,
+  computeTag,
+  computeRevision,
+  computeIsImportant,
+};
+
+export interface GrMessagesList {
+  $: {
+    messageRepeat: DomRepeat;
+  };
+}
+
+@customElement('gr-messages-list')
+export class GrMessagesList extends KeyboardShortcutMixin(
+  GestureEventListeners(LegacyElementMixin(PolymerElement))
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  @property({type: Object})
+  change?: ChangeViewChangeInfo;
+
+  @property({type: String})
+  changeNum?: ChangeId | NumericChangeId;
+
+  @property({type: Array})
+  messages: ChangeMessageInfo[] = [];
+
+  @property({type: Array})
+  reviewerUpdates: ReviewerUpdateInfo[] = [];
+
+  @property({type: Object})
+  changeComments?: ChangeComments;
+
+  @property({type: String})
+  projectName?: RepoName;
+
+  @property({type: Boolean})
+  showReplyButtons = false;
+
+  @property({type: Object})
+  labels?: LabelNameToInfoMap;
+
+  @property({type: String})
+  _expandAllState = ExpandAllState.EXPAND_ALL;
+
+  @property({type: String, computed: '_computeExpandAllTitle(_expandAllState)'})
+  _expandAllTitle = '';
+
+  @property({type: Boolean, observer: '_observeShowAllActivity'})
+  _showAllActivity = false;
+
+  @property({
+    type: Array,
+    computed:
+      '_computeCombinedMessages(messages, reviewerUpdates, ' +
+      'changeComments)',
+    observer: '_combinedMessagesChanged',
+  })
+  _combinedMessages: CombinedMessage[] = [];
+
+  @property({type: Object, computed: '_computeLabelExtremes(labels.*)'})
+  _labelExtremes: {[lableName: string]: VotingRangeInfo} = {};
+
+  private readonly reporting = appContext.reportingService;
+
+  scrollToMessage(messageID: string) {
+    const selector = `[data-message-id="${messageID}"]`;
+    const el = this.shadowRoot!.querySelector(selector) as
+      | GrMessage
+      | undefined;
+
+    if (!el && this._showAllActivity) {
+      console.warn(`Failed to scroll to message: ${messageID}`);
+      return;
+    }
+    if (!el) {
+      this._showAllActivity = true;
+      setTimeout(() => this.scrollToMessage(messageID));
+      return;
+    }
+
+    el.set('message.expanded', true);
+    let top = el.offsetTop;
+    for (
+      let offsetParent = el.offsetParent as HTMLElement | null;
+      offsetParent;
+      offsetParent = offsetParent.offsetParent as HTMLElement | null
+    ) {
+      top += offsetParent.offsetTop;
+    }
+    window.scrollTo(0, top);
+    this._highlightEl(el);
+  }
+
+  _observeShowAllActivity() {
+    // We have to call render() such that the dom-repeat filter picks up the
+    // change.
+    this.$.messageRepeat.render();
+  }
+
+  /**
+   * Filter for the dom-repeat of combinedMessages.
+   */
+  _isMessageVisible(message: CombinedMessage) {
+    return this._showAllActivity || message.isImportant;
+  }
+
+  /**
+   * Merges change messages and reviewer updates into one array. Also processes
+   * all messages and updates, aligns or massages some of the properties.
+   */
+  _computeCombinedMessages(
+    messages?: ChangeMessageInfo[],
+    reviewerUpdates?: FormattedReviewerUpdateInfo[],
+    changeComments?: ChangeComments
+  ) {
+    if (
+      messages === undefined ||
+      reviewerUpdates === undefined ||
+      changeComments === undefined
+    )
+      return [];
+
+    let mi = 0;
+    let ri = 0;
+    let combinedMessages: CombinedMessage[] = [];
+    let mDate;
+    let rDate;
+    for (let i = 0; i < messages.length; i++) {
+      // TODO(TS): clone message instead and avoid API object mutation
+      (messages[i] as CombinedMessage)._index = i;
+    }
+
+    while (mi < messages.length || ri < reviewerUpdates.length) {
+      if (mi >= messages.length) {
+        combinedMessages = combinedMessages.concat(reviewerUpdates.slice(ri));
+        break;
+      }
+      if (ri >= reviewerUpdates.length) {
+        combinedMessages = combinedMessages.concat(messages.slice(mi));
+        break;
+      }
+      mDate = mDate || parseDate(messages[mi].date);
+      rDate = rDate || parseDate(reviewerUpdates[ri].date);
+      if (rDate < mDate) {
+        combinedMessages.push(reviewerUpdates[ri++]);
+        rDate = null;
+      } else {
+        combinedMessages.push(messages[mi++]);
+        mDate = null;
+      }
+    }
+    combinedMessages.forEach(m => {
+      if (m.expanded === undefined) {
+        m.expanded = false;
+      }
+      m.commentThreads = computeThreads(m, changeComments);
+      m._revision_number = computeRevision(m, combinedMessages);
+      m.tag = computeTag(m);
+    });
+    // computeIsImportant() depends on tags and revision numbers already being
+    // updated for all messages, so we have to compute this in its own forEach
+    // loop.
+    combinedMessages.forEach(m => {
+      m.isImportant = computeIsImportant(m, combinedMessages);
+    });
+    return combinedMessages;
+  }
+
+  _updateExpandedStateOfAllMessages(exp: boolean) {
+    if (this._combinedMessages) {
+      for (let i = 0; i < this._combinedMessages.length; i++) {
+        this._combinedMessages[i].expanded = exp;
+        this.notifyPath(`_combinedMessages.${i}.expanded`);
+      }
+    }
+  }
+
+  _computeExpandAllTitle(_expandAllState?: string) {
+    if (_expandAllState === ExpandAllState.COLLAPSE_ALL) {
+      return this.createTitle(
+        Shortcut.COLLAPSE_ALL_MESSAGES,
+        ShortcutSection.ACTIONS
+      );
+    }
+    if (_expandAllState === ExpandAllState.EXPAND_ALL) {
+      return this.createTitle(
+        Shortcut.EXPAND_ALL_MESSAGES,
+        ShortcutSection.ACTIONS
+      );
+    }
+    return '';
+  }
+
+  _highlightEl(el: HTMLElement) {
+    const highlightedEls = this.root!.querySelectorAll('.highlighted');
+    for (const highlightedEl of highlightedEls) {
+      highlightedEl.classList.remove('highlighted');
+    }
+    function handleAnimationEnd() {
+      el.removeEventListener('animationend', handleAnimationEnd);
+      el.classList.remove('highlighted');
+    }
+    el.addEventListener('animationend', handleAnimationEnd);
+    el.classList.add('highlighted');
+  }
+
+  handleExpandCollapse(expand: boolean) {
+    this._expandAllState = expand
+      ? ExpandAllState.COLLAPSE_ALL
+      : ExpandAllState.EXPAND_ALL;
+    this._updateExpandedStateOfAllMessages(expand);
+  }
+
+  _handleExpandCollapseTap(e: Event) {
+    e.preventDefault();
+    this.handleExpandCollapse(
+      this._expandAllState === ExpandAllState.EXPAND_ALL
+    );
+  }
+
+  _handleAnchorClick(e: CustomEvent<MessageAnchorTapDetail>) {
+    this.scrollToMessage(e.detail.id);
+  }
+
+  _isVisibleShowAllActivityToggle(messages: CombinedMessage[] = []) {
+    return messages.some(m => !m.isImportant);
+  }
+
+  _computeHiddenEntriesCount(messages: CombinedMessage[] = []) {
+    return messages.filter(m => !m.isImportant).length;
+  }
+
+  /**
+   * This method is for reporting stats only.
+   */
+  _combinedMessagesChanged(combinedMessages?: CombinedMessage[]) {
+    if (combinedMessages) {
+      if (combinedMessages.length === 0) return;
+      const tags = combinedMessages.map(
+        message =>
+          message.tag || (message as FormattedReviewerUpdateInfo).type || 'none'
+      );
+      const tagsCounted = tags.reduce(
+        (acc, val) => {
+          acc[val] = (acc[val] || 0) + 1;
+          return acc;
+        },
+        {all: combinedMessages.length} as TagsCountReportInfo
+      );
+      this.reporting.reportInteraction('messages-count', tagsCounted);
+    }
+  }
+
+  /**
+   * Compute a mapping from label name to objects representing the minimum and
+   * maximum possible values for that label.
+   */
+  _computeLabelExtremes(
+    labelRecord: PolymerDeepPropertyChange<
+      LabelNameToInfoMap,
+      LabelNameToInfoMap
+    >
+  ) {
+    const extremes: {[lableName: string]: VotingRangeInfo} = {};
+    const labels = labelRecord.base;
+    if (!labels) {
+      return extremes;
+    }
+    for (const key of Object.keys(labels)) {
+      const range = getVotingRange(labels[key]);
+      if (range) {
+        extremes[key] = range;
+      }
+    }
+    return extremes;
+  }
+
+  /**
+   * Work around a issue on iOS when clicking turns into double tap
+   */
+  _onTapShowAllActivityToggle(e: Event) {
+    e.preventDefault();
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-messages-list': GrMessagesList;
+  }
+}
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 de56e9d..b3478b1 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
@@ -97,12 +97,19 @@
   PolymerSplice,
   PolymerSpliceChange,
 } from '@polymer/polymer/interfaces';
-import {assertNever} from '../../../utils/common-util';
-import {CommentThread, isDraft} from '../../diff/gr-comment-api/gr-comment-api';
+import {
+  areSetsEqual,
+  assertNever,
+  containsAll,
+} from '../../../utils/common-util';
+import {CommentThread} from '../../../utils/comment-util';
 import {GrTextarea} from '../../shared/gr-textarea/gr-textarea';
 import {GrAccountChip} from '../../shared/gr-account-chip/gr-account-chip';
 import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
 import {GrStorage, StorageLocation} from '../../shared/gr-storage/gr-storage';
+import {isAttentionSetEnabled} from '../../../utils/attention-set-util';
+import {CODE_REVIEW, getMaxAccounts} from '../../../utils/label-util';
+import {isUnresolved} from '../../../utils/comment-util';
 
 const STORAGE_DEBOUNCE_INTERVAL_MS = 400;
 
@@ -240,10 +247,10 @@
   quote = '';
 
   @property({type: Object})
-  filterReviewerSuggestion: () => (input: Suggestion) => boolean;
+  filterReviewerSuggestion: (input: Suggestion) => boolean;
 
   @property({type: Object})
-  filterCCSuggestion: () => (input: Suggestion) => boolean;
+  filterCCSuggestion: (input: Suggestion) => boolean;
 
   @property({type: Object})
   permittedLabels?: LabelNameToValueMap;
@@ -266,6 +273,9 @@
   @property({type: Array})
   _ccs: (AccountInfo | GroupInfo)[] = [];
 
+  @property({type: Number})
+  _attentionCcsCount = 0;
+
   @property({type: Object, observer: '_reviewerPendingConfirmationUpdated'})
   _ccPendingConfirmation: GroupObjectInput | null = null;
 
@@ -311,6 +321,11 @@
   @property({type: Boolean})
   _reviewersMutated = false;
 
+  /**
+   * Signifies that the user has changed their vote on a label or (if they have
+   * not yet voted on a label) if a selected vote is different from the default
+   * vote.
+   */
   @property({type: Boolean})
   _labelsChanged = false;
 
@@ -324,7 +339,7 @@
   _commentEditing = false;
 
   @property({type: Boolean})
-  _attentionModified = false;
+  _attentionExpanded = false;
 
   @property({type: Object})
   _currentAttentionSet: Set<AccountId> = new Set();
@@ -337,7 +352,8 @@
     computed:
       '_computeSendButtonDisabled(canBeStarted, ' +
       'draftCommentThreads, draft, _reviewersMutated, _labelsChanged, ' +
-      '_includeComments, disabled, _commentEditing, _attentionModified)',
+      '_includeComments, disabled, _commentEditing, _attentionExpanded, ' +
+      '_currentAttentionSet, _newAttentionSet)',
     observer: '_sendDisabledChanged',
   })
   _sendDisabled?: boolean;
@@ -362,10 +378,10 @@
 
   constructor() {
     super();
-    this.filterReviewerSuggestion = () =>
-      this._filterReviewerSuggestionGenerator(false);
-    this.filterCCSuggestion = () =>
-      this._filterReviewerSuggestionGenerator(true);
+    this.filterReviewerSuggestion = this._filterReviewerSuggestionGenerator(
+      false
+    );
+    this.filterCCSuggestion = this._filterReviewerSuggestionGenerator(true);
   }
 
   /** @override */
@@ -628,7 +644,7 @@
       reviewInput.ready = true;
     }
 
-    if (this._isAttentionSetEnabled(this.serverConfig)) {
+    if (isAttentionSetEnabled(this.serverConfig)) {
       const selfName = getDisplayName(this.serverConfig, this._account);
       const reason = `${selfName} replied on the change`;
 
@@ -646,7 +662,7 @@
         }
       }
       this.reportAttentionSetChanges(
-        this._attentionModified,
+        this._attentionExpanded,
         reviewInput.add_to_attention_set,
         reviewInput.remove_from_attention_set
       );
@@ -872,7 +888,7 @@
   }
 
   _handleAttentionModify() {
-    this._attentionModified = true;
+    this._attentionExpanded = true;
     // If the attention-detail section is expanded without dispatching this
     // event, then the dialog may expand beyond the screen's bottom border.
     this.dispatchEvent(
@@ -880,16 +896,19 @@
     );
   }
 
-  _showAttentionSummary(config?: ServerInfo, attentionModified?: boolean) {
-    return this._isAttentionSetEnabled(config) && !attentionModified;
+  _showAttentionSummary(config?: ServerInfo, attentionExpanded?: boolean) {
+    return isAttentionSetEnabled(config) && !attentionExpanded;
   }
 
-  _showAttentionDetails(config?: ServerInfo, attentionModified?: boolean) {
-    return this._isAttentionSetEnabled(config) && attentionModified;
+  _showAttentionDetails(config?: ServerInfo, attentionExpanded?: boolean) {
+    return isAttentionSetEnabled(config) && attentionExpanded;
   }
 
-  _isAttentionSetEnabled(config?: ServerInfo) {
-    return !!config && !!config.change && config.change.enable_attention_set;
+  _computeAttentionButtonTitle(sendDisabled?: boolean) {
+    return sendDisabled
+      ? 'Modify the attention set by adding a comment or use the account ' +
+          'hovercard in the change page.'
+      : 'Edit attention set changes';
   }
 
   _handleAttentionClick(e: Event) {
@@ -935,7 +954,10 @@
     '_reviewers.*',
     '_ccs.*',
     'change',
-    'draftCommentThreads'
+    'draftCommentThreads',
+    '_includeComments',
+    '_labelsChanged',
+    'draft'
   )
   _computeNewAttention(
     currentUser?: AccountInfo,
@@ -943,20 +965,32 @@
       AccountInfoInput[],
       AccountInfoInput[]
     >,
-    _?: PolymerDeepPropertyChange<AccountInfoInput[], AccountInfoInput[]>,
+    ccs?: PolymerDeepPropertyChange<AccountInfoInput[], AccountInfoInput[]>,
     change?: ChangeInfo,
-    draftCommentThreads?: CommentThread[]
+    draftCommentThreads?: CommentThread[],
+    includeComments?: boolean,
+    _labelsChanged?: boolean,
+    draft?: boolean
   ) {
     if (
       currentUser === undefined ||
       currentUser._account_id === undefined ||
       reviewers === undefined ||
+      ccs === undefined ||
       change === undefined ||
-      draftCommentThreads === undefined
+      draftCommentThreads === undefined ||
+      includeComments === undefined
     ) {
       return;
     }
-    this._attentionModified = false;
+    // The draft comments are only relevant for the attention set as long as the
+    // user actually plans to publish their drafts.
+    draftCommentThreads = includeComments ? draftCommentThreads : [];
+    const hasDraft = draftCommentThreads.length > 0 || !!draft;
+    const hasVote = !!_labelsChanged;
+    const isOwner = this._isOwner(currentUser, change);
+    const isUploader = this._uploader?._account_id === currentUser._account_id;
+    this._attentionCcsCount = removeServiceUsers(ccs.base).length;
     this._currentAttentionSet = new Set(
       Object.keys(change.attention_set || {}).map(
         id => parseInt(id) as AccountId
@@ -970,30 +1004,28 @@
       );
       // Remove the current user.
       newAttention.delete(currentUser._account_id);
-      // Add all new reviewers.
+      // Add all new reviewers, but not the current reviewer, if they are also
+      // sending a draft or a label vote.
+      const notIsReviewerAndHasDraftOrLabel = (r: AccountInfo) =>
+        !(r._account_id === currentUser._account_id && (hasDraft || hasVote));
       reviewers.base
         .filter(r => r._pendingAdd && r._account_id)
+        .filter(notIsReviewerAndHasDraftOrLabel)
         .forEach(r => newAttention.add(r._account_id!));
-      // Add the uploader, if someone else replies.
-      if (
-        this._uploader &&
-        this._uploader._account_id !== currentUser._account_id
-      ) {
-        // An uploader must have an _account_id.
-        newAttention.add(this._uploader._account_id!);
-      }
-      // Add the owner, if someone else replies. Also add the owner, if the
-      // attention set would otherwise be empty.
-      if (change.owner) {
-        if (!this._isOwner(currentUser, change) || newAttention.size === 0) {
-          // A change owner must have an _account_id.
-          newAttention.add(change.owner._account_id!);
+      // Add owner and uploader, if someone else replies.
+      if (hasDraft || hasVote) {
+        if (this._uploader?._account_id && !isUploader) {
+          newAttention.add(this._uploader._account_id);
+        }
+        if (change.owner?._account_id && !isOwner) {
+          newAttention.add(change.owner._account_id);
         }
       }
     } else {
       // The only reason for adding someone to the attention set for merged or
-      // abandoned changes is that someone adds a new comment thread.
-      if (change.owner && this._containsNewCommentThread(draftCommentThreads)) {
+      // abandoned changes is that someone makes a comment thread unresolved.
+      const hasUnresolvedDraft = draftCommentThreads.some(isUnresolved);
+      if (change.owner && hasUnresolvedDraft) {
         // A change owner must have an _account_id.
         newAttention.add(change.owner._account_id!);
       }
@@ -1008,34 +1040,55 @@
     this._newAttentionSet = new Set(
       [...newAttention].filter(id => allAccountIds.includes(id))
     );
+    this._attentionExpanded = this._computeShowAttentionTip(
+      currentUser,
+      change.owner,
+      this._currentAttentionSet,
+      this._newAttentionSet
+    );
+  }
+
+  _computeShowAttentionTip(
+    currentUser?: AccountInfo,
+    owner?: AccountInfo,
+    currentAttentionSet?: Set<AccountId>,
+    newAttentionSet?: Set<AccountId>
+  ) {
+    if (!currentUser || !owner || !currentAttentionSet || !newAttentionSet)
+      return false;
+    const isOwner = currentUser._account_id === owner._account_id;
+    const addedIds = [...newAttentionSet].filter(
+      id => !currentAttentionSet.has(id)
+    );
+    return isOwner && addedIds.length > 2;
   }
 
   _computeCommentAccounts(threads: CommentThread[]) {
+    const crLabel = this.change?.labels?.[CODE_REVIEW];
+    const maxCrVoteAccountIds = getMaxAccounts(crLabel).map(a => a._account_id);
     const accountIds = new Set<AccountId>();
     threads.forEach(thread => {
+      const unresolved = isUnresolved(thread);
       thread.comments.forEach(comment => {
         if (comment.author) {
           // A comment author must have an _account_id.
-          accountIds.add(comment.author._account_id!);
+          const authorId = comment.author._account_id!;
+          const hasGivenMaxReviewVote = maxCrVoteAccountIds.includes(authorId);
+          if (unresolved || !hasGivenMaxReviewVote) accountIds.add(authorId);
         }
       });
     });
     return accountIds;
   }
 
-  _containsNewCommentThread(threads: CommentThread[]) {
-    return threads.some(
-      thread =>
-        !!thread.comments && !!thread.comments[0] && isDraft(thread.comments[0])
-    );
-  }
-
-  _isNewAttentionEmpty(
+  _computeShowNoAttentionUpdate(
     config?: ServerInfo,
     currentAttentionSet?: Set<AccountId>,
-    newAttentionSet?: Set<AccountId>
+    newAttentionSet?: Set<AccountId>,
+    sendDisabled?: boolean
   ) {
     return (
+      sendDisabled ||
       this._computeNewAttentionAccounts(
         config,
         currentAttentionSet,
@@ -1044,6 +1097,24 @@
     );
   }
 
+  _computeDoNotUpdateMessage(
+    currentAttentionSet?: Set<AccountId>,
+    newAttentionSet?: Set<AccountId>,
+    sendDisabled?: boolean
+  ) {
+    if (!currentAttentionSet || !newAttentionSet) return '';
+    if (sendDisabled || areSetsEqual(currentAttentionSet, newAttentionSet)) {
+      return 'No changes to the attention set.';
+    }
+    if (containsAll(currentAttentionSet, newAttentionSet)) {
+      return 'No additions to the attention set.';
+    }
+    console.error(
+      '_computeDoNotUpdateMessage() should not be called when users were added to the attention set.'
+    );
+    return '';
+  }
+
   _computeNewAttentionAccounts(
     _?: ServerInfo,
     currentAttentionSet?: Set<AccountId>,
@@ -1078,10 +1149,6 @@
     return removeServiceUsers(accounts);
   }
 
-  _computeShowAttentionCcs(ccs: AccountInfo[]) {
-    return removeServiceUsers(ccs).length > 0;
-  }
-
   _computeUploader(change: ChangeInfo) {
     if (
       !change ||
@@ -1311,7 +1378,7 @@
 
   _handleLabelsChanged() {
     this._labelsChanged =
-      Object.keys(this.$.labelScores.getLabelValues()).length !== 0;
+      Object.keys(this.$.labelScores.getLabelValues(false)).length !== 0;
   }
 
   _isState(knownLatestState?: LatestPatchState, value?: LatestPatchState) {
@@ -1351,8 +1418,7 @@
     labelsChanged?: boolean,
     includeComments?: boolean,
     disabled?: boolean,
-    commentEditing?: boolean,
-    attentionModified?: boolean
+    commentEditing?: boolean
   ) {
     if (
       canBeStarted === undefined ||
@@ -1362,8 +1428,7 @@
       labelsChanged === undefined ||
       includeComments === undefined ||
       disabled === undefined ||
-      commentEditing === undefined ||
-      attentionModified === undefined
+      commentEditing === undefined
     ) {
       return undefined;
     }
@@ -1374,13 +1439,7 @@
       return false;
     }
     const hasDrafts = includeComments && draftCommentThreads.length;
-    return (
-      !hasDrafts &&
-      !text.length &&
-      !reviewersMutated &&
-      !labelsChanged &&
-      !attentionModified
-    );
+    return !hasDrafts && !text.length && !reviewersMutated && !labelsChanged;
   }
 
   _computePatchSetWarning(patchNum?: PatchSetNum, labelsChanged?: boolean) {
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_html.ts b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_html.ts
index 1be2f75..c56a5c9 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_html.ts
@@ -47,6 +47,7 @@
     .stickyBottom {
       background-color: var(--dialog-background-color);
       box-shadow: 0px 0px 8px 0px rgba(60, 64, 67, 0.15);
+      margin-top: var(--spacing-s);
       bottom: 0;
       position: sticky;
       /* @see Issue 8602 */
@@ -167,33 +168,39 @@
       display: flex;
       justify-content: space-between;
     }
-    .attentionSummary gr-account-chip {
-      display: inline-block;
-      vertical-align: top;
-      /* The account chip is misbehaving currently: It consumes 22px height, so
-         it does not fit nicely into the 20px line-height of the standard
-         inline layout flow. :-( */
-      position: relative;
-      top: -1px;
-    }
-    .attentionSummary,
-    .attention-detail {
-      --account-max-length: 150px;
+    .attentionSummary {
+      /* The account label for selection is misbehaving currently: It consumes
+         26px height instead of 20px, which is the default line-height and thus
+         the max that can be nicely fit into an inline layout flow. We
+         acknowledge that using a fixed 26px value here is a hack and not a
+         great solution. */
+      line-height: 26px;
     }
     .attention-detail .peopleList .accountList {
       display: flex;
       flex-wrap: wrap;
     }
+    .attentionSummary gr-account-label,
     .attention-detail gr-account-label {
+      --account-max-length: 150px;
       display: inline-block;
       padding: var(--spacing-xs) var(--spacing-m);
-      vertical-align: baseline;
       user-select: none;
       --label-border-radius: 8px;
     }
+    .attentionSummary gr-account-label {
+      margin: 0 var(--spacing-xs);
+      line-height: var(--line-height-normal);
+      vertical-align: top;
+    }
+    .attention-detail gr-account-label {
+      vertical-align: baseline;
+    }
+    .attentionSummary gr-account-label:focus,
     .attention-detail gr-account-label:focus {
       outline: none;
     }
+    .attentionSummary gr-account-label:hover,
     .attention-detail gr-account-label:hover {
       box-shadow: var(--elevation-level-1);
       cursor: pointer;
@@ -206,6 +213,16 @@
       color: var(--deemphasized-text-color);
       margin-bottom: var(--spacing-m);
     }
+    .attentionTip {
+      padding: var(--spacing-m);
+      border: 1px solid var(--border-color);
+      border-radius: var(--border-radius);
+      margin-top: var(--spacing-m);
+      background-color: var(--assignee-highlight-color);
+    }
+    .attentionTip div iron-icon {
+      margin-right: var(--spacing-s);
+    }
   </style>
   <div class="container" tabindex="-1">
     <section class="peopleContainer">
@@ -352,20 +369,23 @@
     </section>
     <div class="stickyBottom">
       <section
-        hidden$="[[!_showAttentionSummary(serverConfig, _attentionModified)]]"
+        hidden$="[[!_showAttentionSummary(serverConfig, _attentionExpanded)]]"
         class="attention"
       >
         <div class="attentionSummary">
           <div>
             <template
               is="dom-if"
-              if="[[_isNewAttentionEmpty(serverConfig, _currentAttentionSet, _newAttentionSet)]]"
+              if="[[_computeShowNoAttentionUpdate(serverConfig, _currentAttentionSet, _newAttentionSet, _sendDisabled)]]"
             >
-              <span>Do not update the attention set.</span>
+              <span
+                >[[_computeDoNotUpdateMessage(_currentAttentionSet,
+                _newAttentionSet, _sendDisabled)]]</span
+              >
             </template>
             <template
               is="dom-if"
-              if="[[!_isNewAttentionEmpty(serverConfig, _currentAttentionSet, _newAttentionSet)]]"
+              if="[[!_computeShowNoAttentionUpdate(serverConfig, _currentAttentionSet, _newAttentionSet, _sendDisabled)]]"
             >
               <span>Bring to attention of</span>
               <template
@@ -373,19 +393,27 @@
                 items="[[_computeNewAttentionAccounts(serverConfig, _currentAttentionSet, _newAttentionSet)]]"
                 as="account"
               >
-                <gr-account-chip account="[[account]]" force-attention="">
-                </gr-account-chip>
+                <gr-account-label
+                  account="[[account]]"
+                  force-attention="[[_computeHasNewAttention(account, _newAttentionSet)]]"
+                  selected$="[[_computeHasNewAttention(account, _newAttentionSet)]]"
+                  deselected$="[[!_computeHasNewAttention(account, _newAttentionSet)]]"
+                  hide-hovercard=""
+                  on-click="_handleAttentionClick"
+                ></gr-account-label>
               </template>
             </template>
             <gr-button
               class="edit-attention-button"
               on-click="_handleAttentionModify"
+              disabled="[[_sendDisabled]]"
               link=""
               position-below=""
               data-label="Edit"
               data-action-type="change"
               data-action-key="edit"
-              title="Edit attention set changes"
+              has-tooltip=""
+              title="[[_computeAttentionButtonTitle(_sendDisabled)]]"
               role="button"
               tabindex="0"
             >
@@ -416,7 +444,7 @@
         </div>
       </section>
       <section
-        hidden$="[[!_showAttentionDetails(serverConfig, _attentionModified)]]"
+        hidden$="[[!_showAttentionDetails(serverConfig, _attentionExpanded)]]"
         class="attention-detail"
       >
         <div class="attentionDetailsTitle">
@@ -501,7 +529,7 @@
             </template>
           </div>
         </div>
-        <template is="dom-if" if="[[_computeShowAttentionCcs(_ccs)]]">
+        <template is="dom-if" if="[[_attentionCcsCount]]">
           <div class="peopleList">
             <div class="peopleListLabel">CC</div>
             <div>
@@ -523,6 +551,18 @@
             </div>
           </div>
         </template>
+        <template
+          is="dom-if"
+          if="[[_computeShowAttentionTip(_account, _owner, _currentAttentionSet, _newAttentionSet)]]"
+        >
+          <div class="attentionTip">
+            <iron-icon
+              class="pointer"
+              icon="gr-icons:lightbulb-outline"
+            ></iron-icon>
+            Be mindful of requiring attention from too many users.
+          </div>
+        </template>
       </section>
       <section class="actions">
         <div class="left">
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.js b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.js
index abbb1d4..7a89f3d 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.js
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.js
@@ -203,7 +203,8 @@
   });
 
   function checkComputeAttention(status, userId, reviewerIds, ownerId,
-      attSetIds, replyToIds, expectedIds, uploaderId, hasDraft) {
+      attSetIds, replyToIds, expectedIds, uploaderId, hasDraft,
+      includeComments = true) {
     const user = {_account_id: userId};
     const reviewers = {base: reviewerIds.map(id => {
       return {_account_id: id};
@@ -212,7 +213,7 @@
       {comments: []},
     ];
     if (hasDraft) {
-      draftThreads[0].comments.push({__draft: true});
+      draftThreads[0].comments.push({__draft: true, unresolved: true});
     }
     replyToIds.forEach(id => draftThreads[0].comments.push({
       author: {_account_id: id},
@@ -230,7 +231,8 @@
     element.change = change;
     element._reviewers = reviewers.base;
     flush();
-    element._computeNewAttention(user, reviewers, [], change, draftThreads);
+    element._computeNewAttention(
+        user, reviewers, [], change, draftThreads, includeComments);
     assert.sameMembers([...element._newAttentionSet], expectedIds);
   }
 
@@ -242,9 +244,11 @@
     checkComputeAttention('NEW', 1, [22], 999, [22], [], [22, 999]);
     checkComputeAttention('NEW', 1, [22], 999, [], [22], [22, 999]);
     checkComputeAttention('NEW', 1, [22, 33], 999, [33], [22], [22, 33, 999]);
-    checkComputeAttention('NEW', 1, [], 1, [], [], [1]);
-    checkComputeAttention('NEW', 1, [], 1, [1], [], [1]);
-    checkComputeAttention('NEW', 1, [22], 1, [], [], [1]);
+    // If the owner replies, then do not add them.
+    checkComputeAttention('NEW', 1, [], 1, [], [], []);
+    checkComputeAttention('NEW', 1, [], 1, [1], [], []);
+    checkComputeAttention('NEW', 1, [22], 1, [], [], []);
+
     checkComputeAttention('NEW', 1, [22], 1, [], [22], [22]);
     checkComputeAttention('NEW', 1, [22, 33], 1, [33], [22], [22, 33]);
     checkComputeAttention('NEW', 1, [22, 33], 1, [], [22], [22]);
@@ -260,6 +264,8 @@
     checkComputeAttention('MERGED', null, [], 999, [], [], []);
     checkComputeAttention('MERGED', 1, [], 999, [], [], []);
     checkComputeAttention('MERGED', 1, [], 999, [], [], [999], undefined, true);
+    checkComputeAttention(
+        'MERGED', 1, [], 999, [], [], [], undefined, true, false);
     checkComputeAttention('MERGED', 1, [], 999, [1], [], []);
     checkComputeAttention('MERGED', 1, [22], 999, [], [], []);
     checkComputeAttention('MERGED', 1, [22], 999, [22], [], [22]);
@@ -277,6 +283,33 @@
     checkComputeAttention('MERGED', 1, [22, 33], 1, [22, 33], [], [22, 33]);
   });
 
+  test('computeNewAttention when adding reviewers', () => {
+    const user = {_account_id: 1};
+    const reviewers = {base: [
+      {_account_id: 1, _pendingAdd: true},
+      {_account_id: 2, _pendingAdd: true},
+    ]};
+    const change = {
+      owner: {_account_id: 5},
+      status: 'NEW',
+      attention_set: {},
+    };
+    element.change = change;
+    element._reviewers = reviewers.base;
+    flush();
+
+    element._computeNewAttention(user, reviewers, [], change, [], true);
+    assert.sameMembers([...element._newAttentionSet], [1, 2]);
+
+    // If the user votes on the change, then they should not be added to the
+    // attention set, even if they have just added themselves as reviewer.
+    // But voting should also add the owner (5).
+    const labelsChanged = true;
+    element._computeNewAttention(
+        user, reviewers, [], change, [], true, labelsChanged);
+    assert.sameMembers([...element._newAttentionSet], [2, 5]);
+  });
+
   test('computeNewAttentionAccounts', () => {
     element._reviewers = [
       {_account_id: 123, display_name: 'Ernie'},
@@ -298,6 +331,45 @@
     assert.sameMembers(compute([999], [7, 123, 999]), [7, 123]);
   });
 
+  test('_computeCommentAccounts', () => {
+    element.change = {
+      labels: {
+        'Code-Review': {
+          all: [
+            {_account_id: 1, value: 0},
+            {_account_id: 2, value: 1},
+            {_account_id: 3, value: 2},
+          ],
+          values: {
+            '-2': 'Do not submit',
+            '-1': 'I would prefer that you didnt submit this',
+            ' 0': 'No score',
+            '+1': 'Looks good to me, but someone else must approve',
+            '+2': 'Looks good to me, approved',
+          },
+        },
+      },
+    };
+    const threads = [
+      {
+        comments: [
+          {author: {_account_id: 1}, unresolved: false},
+          {author: {_account_id: 2}, unresolved: true},
+        ],
+      },
+      {
+        comments: [
+          {author: {_account_id: 3}, unresolved: false},
+          {author: {_account_id: 4}, unresolved: false},
+        ],
+      },
+    ];
+    const actualAccounts = [...element._computeCommentAccounts(threads)];
+    // Account 3 is not included, because the comment is resolved *and* they
+    // have given the highest possible vote on the Code-Review label.
+    assert.sameMembers(actualAccounts, [1, 2, 4]);
+  });
+
   test('toggle resolved checkbox', done => {
     // Async tick is needed because iron-selector content is distributed and
     // distributed content requires an observer to be set up.
@@ -954,11 +1026,18 @@
     element._reviewers = [makeAccount(), makeAccount()];
     element._ccs = [makeAccount(), makeAccount()];
     element.draftCommentThreads = [];
-    MockInteractions.tap(
-        element.shadowRoot.querySelector('.edit-attention-button'));
+    const modifyButton =
+        element.shadowRoot.querySelector('.edit-attention-button');
+    MockInteractions.tap(modifyButton);
     flush();
 
-    assert.isTrue(element._attentionModified);
+    // "Modify" button disabled, because "Send" button is disabled.
+    assert.isFalse(element._attentionExpanded);
+    element.draft = 'a test comment';
+    MockInteractions.tap(modifyButton);
+    flush();
+    assert.isTrue(element._attentionExpanded);
+
     let accountLabels = Array.from(element.shadowRoot.querySelectorAll(
         '.attention-detail gr-account-label'));
     assert.equal(accountLabels.length, 5);
@@ -969,13 +1048,13 @@
 
     // The 'attention modified' section collapses and resets when reviewers or
     // ccs change.
-    assert.isFalse(element._attentionModified);
+    assert.isFalse(element._attentionExpanded);
 
     MockInteractions.tap(
         element.shadowRoot.querySelector('.edit-attention-button'));
     flush();
 
-    assert.isTrue(element._attentionModified);
+    assert.isTrue(element._attentionExpanded);
     accountLabels = Array.from(element.shadowRoot.querySelectorAll(
         '.attention-detail gr-account-label'));
     assert.equal(accountLabels.length, 7);
@@ -1323,8 +1402,7 @@
         /* labelsChanged= */ false,
         /* includeComments= */ false,
         /* disabled= */ false,
-        /* commentEditing= */ false,
-        /* attentionModified= */ false
+        /* commentEditing= */ false
     ));
   });
 
@@ -1339,24 +1417,7 @@
         /* labelsChanged= */ false,
         /* includeComments= */ false,
         /* disabled= */ false,
-        /* commentEditing= */ false,
-        /* attentionModified= */ false
-    ));
-  });
-
-  test('_computeSendButtonDisabled_attentionModified true', () => {
-    const fn = element._computeSendButtonDisabled.bind(element);
-    // Mock everything false
-    assert.isFalse(fn(
-        /* canBeStarted= */ false,
-        /* draftCommentThreads= */ [],
-        /* text= */ '',
-        /* reviewersMutated= */ false,
-        /* labelsChanged= */ false,
-        /* includeComments= */ false,
-        /* disabled= */ false,
-        /* commentEditing= */ false,
-        /* attentionModified= */ true
+        /* commentEditing= */ false
     ));
   });
 
@@ -1371,8 +1432,7 @@
         /* labelsChanged= */ false,
         /* includeComments= */ true,
         /* disabled= */ false,
-        /* commentEditing= */ false,
-        /* attentionModified= */ false
+        /* commentEditing= */ false
     ));
   });
 
@@ -1387,8 +1447,7 @@
         /* labelsChanged= */ false,
         /* includeComments= */ false,
         /* disabled= */ false,
-        /* commentEditing= */ false,
-        /* attentionModified= */ false
+        /* commentEditing= */ false
     ));
   });
 
@@ -1403,8 +1462,7 @@
         /* labelsChanged= */ false,
         /* includeComments= */ false,
         /* disabled= */ false,
-        /* commentEditing= */ false,
-        /* attentionModified= */ false
+        /* commentEditing= */ false
     ));
   });
 
@@ -1419,8 +1477,7 @@
         /* labelsChanged= */ false,
         /* includeComments= */ false,
         /* disabled= */ false,
-        /* commentEditing= */ false,
-        /* attentionModified= */ false
+        /* commentEditing= */ false
     ));
   });
 
@@ -1435,8 +1492,7 @@
         /* labelsChanged= */ true,
         /* includeComments= */ false,
         /* disabled= */ false,
-        /* commentEditing= */ false,
-        /* attentionModified= */ false
+        /* commentEditing= */ false
     ));
   });
 
@@ -1451,8 +1507,7 @@
         /* labelsChanged= */ true,
         /* includeComments= */ false,
         /* disabled= */ true,
-        /* commentEditing= */ false,
-        /* attentionModified= */ false
+        /* commentEditing= */ false
     ));
     assert.isTrue(fn(
         /* buttonLabel= */ 'Send',
@@ -1462,8 +1517,7 @@
         /* labelsChanged= */ true,
         /* includeComments= */ false,
         /* disabled= */ false,
-        /* commentEditing= */ true,
-        /* attentionModified= */ false
+        /* commentEditing= */ true
     ));
   });
 
diff --git a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.ts b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.ts
index 70e7ba7..d7a1f17 100644
--- a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.ts
@@ -23,7 +23,8 @@
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-reviewer-list_html';
-import {hasAttention, isServiceUser} from '../../../utils/account-util';
+import {isServiceUser} from '../../../utils/account-util';
+import {hasAttention} from '../../../utils/attention-set-util';
 import {customElement, property, computed, observe} from '@polymer/decorators';
 import {
   ChangeInfo,
@@ -39,6 +40,7 @@
 import {GrAccountChip} from '../../shared/gr-account-chip/gr-account-chip';
 import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
 import {hasOwnProperty} from '../../../utils/common-util';
+import {isRemovableReviewer} from '../../../utils/change-util';
 
 export interface GrReviewerList {
   $: {
@@ -251,25 +253,7 @@
   }
 
   _computeCanRemoveReviewer(reviewer: AccountInfo, mutable: boolean) {
-    if (
-      !mutable ||
-      this.change === undefined ||
-      this.change.removable_reviewers === undefined
-    ) {
-      return false;
-    }
-
-    let current;
-    for (let i = 0; i < this.change.removable_reviewers.length; i++) {
-      current = this.change.removable_reviewers[i];
-      if (
-        current._account_id === reviewer._account_id ||
-        (!reviewer._account_id && current.email === reviewer.email)
-      ) {
-        return true;
-      }
-    }
-    return false;
+    return mutable && isRemovableReviewer(this.change, reviewer);
   }
 
   _handleRemove(e: Event) {
diff --git a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.ts b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.ts
index 4b02075..6a32834 100644
--- a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.ts
@@ -24,16 +24,14 @@
 import {htmlTemplate} from './gr-thread-list_html';
 import {parseDate} from '../../../utils/date-util';
 
-import {NO_THREADS_MSG} from '../../../constants/messages';
 import {CommentSide, SpecialFilePath} from '../../../constants/constants';
 import {customElement, observe, property} from '@polymer/decorators';
 import {
-  CommentThread,
-  isDraft,
-  UIRobot,
-} from '../../diff/gr-comment-api/gr-comment-api';
-import {PolymerSpliceChange} from '@polymer/polymer/interfaces';
+  PolymerSpliceChange,
+  PolymerDeepPropertyChange,
+} from '@polymer/polymer/interfaces';
 import {ChangeInfo} from '../../../types/common';
+import {CommentThread, isDraft, UIRobot} from '../../../utils/comment-util';
 
 interface CommentThreadWithInfo {
   thread: CommentThread;
@@ -68,8 +66,19 @@
   @property({type: Array})
   _sortedThreads: CommentThread[] = [];
 
+  @property({
+    computed:
+      '_computeDisplayedThreads(_sortedThreads.*, unresolvedOnly, ' +
+      '_draftsOnly, onlyShowRobotCommentsWithHumanReply)',
+    type: Array,
+  })
+  _displayedThreads: CommentThread[] = [];
+
+  // thread-list is used in multiple places like the change log, hence
+  // keeping the default to be false. When used in comments tab, it's
+  // set as true.
   @property({type: Boolean})
-  _unresolvedOnly = false;
+  unresolvedOnly = false;
 
   @property({type: Boolean})
   _draftsOnly = false;
@@ -80,13 +89,53 @@
   @property({type: Boolean})
   hideToggleButtons = false;
 
-  @property({type: String})
-  emptyThreadMsg = NO_THREADS_MSG;
-
   _computeShowDraftToggle(loggedIn?: boolean) {
     return loggedIn ? 'show' : '';
   }
 
+  _showEmptyThreadsMessage(
+    threads: CommentThread[],
+    displayedThreads: CommentThread[],
+    unresolvedOnly: boolean
+  ) {
+    if (!threads || !displayedThreads) return false;
+    return !threads.length || (unresolvedOnly && !displayedThreads.length);
+  }
+
+  _computeEmptyThreadsMessage(threads: CommentThread[]) {
+    return !threads.length ? 'No comments.' : 'No unresolved comments';
+  }
+
+  _showPartyPopper(threads: CommentThread[]) {
+    return !!threads.length;
+  }
+
+  _computeResolvedCommentsMessage(
+    threads: CommentThread[],
+    displayedThreads: CommentThread[],
+    unresolvedOnly: boolean
+  ) {
+    if (unresolvedOnly && threads.length && !displayedThreads.length) {
+      return (
+        `Show ${threads.length} resolved comment` +
+        (threads.length > 1 ? 's' : '')
+      );
+    }
+    return '';
+  }
+
+  _showResolvedCommentsButton(
+    threads: CommentThread[],
+    displayedThreads: CommentThread[],
+    unresolvedOnly: boolean
+  ) {
+    return unresolvedOnly && threads.length && !displayedThreads.length;
+  }
+
+  _handleResolvedCommentsMessageClick() {
+    this.unresolvedOnly = !this.unresolvedOnly;
+  }
+
   _compareThreads(c1: CommentThreadWithInfo, c2: CommentThreadWithInfo) {
     if (c1.thread.path !== c2.thread.path) {
       // '/PATCHSET' will not come before '/COMMIT' when sorting
@@ -170,6 +219,7 @@
   ) {
     if (!threads || threads.length === 0) {
       this._sortedThreads = [];
+      this._displayedThreads = [];
       return;
     }
     // We only want to sort on thread additions / removals to avoid
@@ -200,14 +250,34 @@
       .map(threadInfo => threadInfo.thread);
   }
 
+  _computeDisplayedThreads(
+    sortedThreadsRecord?: PolymerDeepPropertyChange<
+      CommentThread[],
+      CommentThread[]
+    >,
+    unresolvedOnly?: boolean,
+    draftsOnly?: boolean,
+    onlyShowRobotCommentsWithHumanReply?: boolean
+  ) {
+    if (!sortedThreadsRecord || !sortedThreadsRecord.base) return [];
+    return sortedThreadsRecord.base.filter(t =>
+      this._shouldShowThread(
+        t,
+        unresolvedOnly,
+        draftsOnly,
+        onlyShowRobotCommentsWithHumanReply
+      )
+    );
+  }
+
   _isFirstThreadWithFileName(
-    sortedThreads: CommentThread[],
+    displayedThreads: CommentThread[],
     thread: CommentThread,
     unresolvedOnly?: boolean,
     draftsOnly?: boolean,
     onlyShowRobotCommentsWithHumanReply?: boolean
   ) {
-    const threads = sortedThreads.filter(t =>
+    const threads = displayedThreads.filter(t =>
       this._shouldShowThread(
         t,
         unresolvedOnly,
@@ -223,13 +293,13 @@
   }
 
   _shouldRenderSeparator(
-    sortedThreads: CommentThread[],
+    displayedThreads: CommentThread[],
     thread: CommentThread,
     unresolvedOnly?: boolean,
     draftsOnly?: boolean,
     onlyShowRobotCommentsWithHumanReply?: boolean
   ) {
-    const threads = sortedThreads.filter(t =>
+    const threads = displayedThreads.filter(t =>
       this._shouldShowThread(
         t,
         unresolvedOnly,
@@ -244,7 +314,7 @@
     return (
       index > 0 &&
       this._isFirstThreadWithFileName(
-        sortedThreads,
+        displayedThreads,
         thread,
         unresolvedOnly,
         draftsOnly,
diff --git a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_html.ts b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_html.ts
index d74c985..e55f98a 100644
--- a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_html.ts
@@ -57,15 +57,23 @@
       border-top: 1px solid var(--border-color);
       margin-top: var(--spacing-xl);
     }
+    .resolved-comments-message {
+      color: var(--link-color);
+      cursor: pointer;
+    }
+    .show-resolved-comments {
+      box-shadow: none;
+      padding-left: var(--spacing-m);
+    }
   </style>
   <template is="dom-if" if="[[!hideToggleButtons]]">
     <div class="header">
       <div class="toggleItem">
         <paper-toggle-button
           id="unresolvedToggle"
-          checked="{{_unresolvedOnly}}"
+          checked="{{!unresolvedOnly}}"
           on-tap="_onTapUnresolvedToggle"
-          >Only unresolved threads</paper-toggle-button
+          >All comments</paper-toggle-button
         >
       </div>
       <div
@@ -75,48 +83,63 @@
           id="draftToggle"
           checked="{{_draftsOnly}}"
           on-tap="_onTapUnresolvedToggle"
-          >Only threads with drafts</paper-toggle-button
+          >Comments with drafts</paper-toggle-button
         >
       </div>
     </div>
   </template>
   <div id="threads">
-    <template is="dom-if" if="[[!threads.length]]">
-      [[emptyThreadMsg]]
+    <template
+      is="dom-if"
+      if="[[_showEmptyThreadsMessage(threads, _displayedThreads, unresolvedOnly)]]"
+    >
+      <div>
+        <span>
+          <template is="dom-if" if="[[_showPartyPopper(threads)]]">
+            <span> \&#x1F389 </span>
+          </template>
+          [[_computeEmptyThreadsMessage(threads, _displayedThreads,
+          unresolvedOnly)]]
+          <template is="dom-if" if="[[_showResolvedCommentsButton(threads, _displayedThreads, unresolvedOnly)]]">
+            <gr-button
+              class="show-resolved-comments"
+              link
+              on-click="_handleResolvedCommentsMessageClick">
+                [[_computeResolvedCommentsMessage(threads, _displayedThreads,
+                unresolvedOnly)]]
+            </gr-button>
+          </template>
+        </span>
+      </div>
     </template>
     <template
       is="dom-repeat"
-      items="[[_sortedThreads]]"
+      items="[[_displayedThreads]]"
       as="thread"
       initial-count="10"
       target-framerate="60"
     >
       <template
         is="dom-if"
-        if="[[_shouldShowThread(thread, _unresolvedOnly, _draftsOnly, onlyShowRobotCommentsWithHumanReply)]]"
+        if="[[_shouldRenderSeparator(_displayedThreads, thread, unresolvedOnly, _draftsOnly, onlyShowRobotCommentsWithHumanReply)]]"
       >
-        <template
-          is="dom-if"
-          if="[[_shouldRenderSeparator(_sortedThreads, thread, _unresolvedOnly, _draftsOnly, onlyShowRobotCommentsWithHumanReply)]]"
-        >
-          <div class="thread-separator"></div>
-        </template>
-        <gr-comment-thread
-          show-file-path=""
-          change-num="[[changeNum]]"
-          comments="[[thread.comments]]"
-          comment-side="[[thread.commentSide]]"
-          show-file-name="[[_isFirstThreadWithFileName(_sortedThreads, thread, _unresolvedOnly, _draftsOnly, onlyShowRobotCommentsWithHumanReply)]]"
-          project-name="[[change.project]]"
-          is-on-parent="[[_isOnParent(thread.commentSide)]]"
-          line-num="[[thread.line]]"
-          patch-num="[[thread.patchNum]]"
-          path="[[thread.path]]"
-          root-id="{{thread.rootId}}"
-          on-thread-changed="_handleCommentsChanged"
-          on-thread-discard="_handleThreadDiscard"
-        ></gr-comment-thread>
+        <div class="thread-separator"></div>
       </template>
+      <gr-comment-thread
+        show-file-path=""
+        change-num="[[changeNum]]"
+        comments="[[thread.comments]]"
+        comment-side="[[thread.commentSide]]"
+        show-file-name="[[_isFirstThreadWithFileName(_displayedThreads, thread, unresolvedOnly, _draftsOnly, onlyShowRobotCommentsWithHumanReply)]]"
+        project-name="[[change.project]]"
+        is-on-parent="[[_isOnParent(thread.commentSide)]]"
+        line-num="[[thread.line]]"
+        patch-num="[[thread.patchNum]]"
+        path="[[thread.path]]"
+        root-id="{{thread.rootId}}"
+        on-thread-changed="_handleCommentsChanged"
+        on-thread-discard="_handleThreadDiscard"
+      ></gr-comment-thread>
     </template>
   </div>
 `;
diff --git a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_test.js b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_test.js
index bad3a99..efc072f 100644
--- a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_test.js
+++ b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_test.js
@@ -18,7 +18,6 @@
 import '../../../test/common-test-setup-karma.js';
 import './gr-thread-list.js';
 import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-import {NO_THREADS_MSG} from '../../../constants/messages.js';
 import {SpecialFilePath} from '../../../constants/constants.js';
 
 const basicFixture = fixtureFromElement('gr-thread-list');
@@ -287,13 +286,23 @@
     assert.equal(getVisibleThreads().length, element.threads.length);
   });
 
+  test('show unresolved threads if unresolvedOnly is set', done => {
+    element.unresolvedOnly = true;
+    flush();
+    const unresolvedThreads = element.threads.filter(t => t.comments.some(
+        c => c.unresolved
+    ));
+    assert.equal(getVisibleThreads().length, unresolvedThreads.length);
+    done();
+  });
+
   test('showing file name takes visible threads into account', () => {
     assert.equal(element._isFirstThreadWithFileName(element._sortedThreads,
-        element._sortedThreads[2], element._unresolvedOnly, element._draftsOnly,
+        element._sortedThreads[2], element.unresolvedOnly, element._draftsOnly,
         element.onlyShowRobotCommentsWithHumanReply), true);
-    element._unresolvedOnly = true;
+    element.unresolvedOnly = true;
     assert.equal(element._isFirstThreadWithFileName(element._sortedThreads,
-        element._sortedThreads[2], element._unresolvedOnly, element._draftsOnly,
+        element._sortedThreads[2], element.unresolvedOnly, element._draftsOnly,
         element.onlyShowRobotCommentsWithHumanReply), false);
   });
 
@@ -539,7 +548,7 @@
     });
   });
 
-  test('toggle unresolved only shows unresolved comments', () => {
+  test('toggle unresolved shows all comments', () => {
     MockInteractions.tap(element.shadowRoot.querySelector(
         '#unresolvedToggle'));
     flush();
@@ -617,18 +626,9 @@
     });
 
     test('default empty message should show', () => {
-      assert.equal(
-          element.shadowRoot.querySelector('#threads').textContent.trim(),
-          NO_THREADS_MSG
-      );
-    });
-
-    test('can override empty message', () => {
-      element.emptyThreadMsg = 'test';
-      assert.equal(
-          element.shadowRoot.querySelector('#threads').textContent.trim(),
-          'test'
-      );
+      assert.isTrue(
+          element.shadowRoot.querySelector('#threads').textContent.trim()
+              .includes('No comments.'));
     });
   });
 });
diff --git a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.js b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.js
deleted file mode 100644
index 88c4cef..0000000
--- a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.js
+++ /dev/null
@@ -1,363 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 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 '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator.js';
-import '../../shared/gr-dropdown/gr-dropdown.js';
-import '../../shared/gr-icons/gr-icons.js';
-import '../../shared/gr-js-api-interface/gr-js-api-interface.js';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
-import '../gr-account-dropdown/gr-account-dropdown.js';
-import '../gr-smart-search/gr-smart-search.js';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
-import {PolymerElement} from '@polymer/polymer/polymer-element.js';
-import {htmlTemplate} from './gr-main-header_html.js';
-import {getBaseUrl, getDocsBaseUrl} from '../../../utils/url-util.js';
-import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
-import {getAdminLinks} from '../../../utils/admin-nav-util.js';
-
-const DEFAULT_LINKS = [{
-  title: 'Changes',
-  links: [
-    {
-      url: '/q/status:open+-is:wip',
-      name: 'Open',
-    },
-    {
-      url: '/q/status:merged',
-      name: 'Merged',
-    },
-    {
-      url: '/q/status:abandoned',
-      name: 'Abandoned',
-    },
-  ],
-}];
-
-const DOCUMENTATION_LINKS = [
-  {
-    url: '/index.html',
-    name: 'Table of Contents',
-  },
-  {
-    url: '/user-search.html',
-    name: 'Searching',
-  },
-  {
-    url: '/user-upload.html',
-    name: 'Uploading',
-  },
-  {
-    url: '/access-control.html',
-    name: 'Access Control',
-  },
-  {
-    url: '/rest-api.html',
-    name: 'REST API',
-  },
-  {
-    url: '/intro-project-owner.html',
-    name: 'Project Owner Guide',
-  },
-];
-
-// Set of authentication methods that can provide custom registration page.
-const AUTH_TYPES_WITH_REGISTER_URL = new Set([
-  'LDAP',
-  'LDAP_BIND',
-  'CUSTOM_EXTENSION',
-]);
-
-/**
- * @extends PolymerElement
- */
-class GrMainHeader extends GestureEventListeners(
-    LegacyElementMixin(
-        PolymerElement)) {
-  static get template() { return htmlTemplate; }
-
-  static get is() { return 'gr-main-header'; }
-
-  static get properties() {
-    return {
-      searchQuery: {
-        type: String,
-        notify: true,
-      },
-      loggedIn: {
-        type: Boolean,
-        reflectToAttribute: true,
-      },
-      loading: {
-        type: Boolean,
-        reflectToAttribute: true,
-      },
-
-      /** @type {?Object} */
-      _account: Object,
-      _adminLinks: {
-        type: Array,
-        value() { return []; },
-      },
-      _defaultLinks: {
-        type: Array,
-        value() {
-          return DEFAULT_LINKS;
-        },
-      },
-      _docBaseUrl: {
-        type: String,
-        value: null,
-      },
-      _links: {
-        type: Array,
-        computed: '_computeLinks(_defaultLinks, _userLinks, _adminLinks, ' +
-          '_topMenus, _docBaseUrl)',
-      },
-      loginUrl: {
-        type: String,
-        value: '/login',
-      },
-      _userLinks: {
-        type: Array,
-        value() { return []; },
-      },
-      _topMenus: {
-        type: Array,
-        value() { return []; },
-      },
-      _registerText: {
-        type: String,
-        value: 'Sign up',
-      },
-      _registerURL: {
-        type: String,
-        value: null,
-      },
-      mobileSearchHidden: {
-        type: Boolean,
-        value: false,
-      },
-    };
-  }
-
-  static get observers() {
-    return [
-      '_accountLoaded(_account)',
-    ];
-  }
-
-  /** @override */
-  ready() {
-    super.ready();
-    this._ensureAttribute('role', 'banner');
-  }
-
-  /** @override */
-  attached() {
-    super.attached();
-    this._loadAccount();
-    this._loadConfig();
-  }
-
-  /** @override */
-  detached() {
-    super.detached();
-  }
-
-  reload() {
-    this._loadAccount();
-  }
-
-  _computeRelativeURL(path) {
-    return '//' + window.location.host + getBaseUrl() + path;
-  }
-
-  _computeLinks(defaultLinks, userLinks, adminLinks, topMenus, docBaseUrl) {
-    // Polymer 2: check for undefined
-    if ([
-      defaultLinks,
-      userLinks,
-      adminLinks,
-      topMenus,
-      docBaseUrl,
-    ].includes(undefined)) {
-      return undefined;
-    }
-
-    const links = defaultLinks.map(menu => {
-      return {
-        title: menu.title,
-        links: menu.links.slice(),
-      };
-    });
-    if (userLinks && userLinks.length > 0) {
-      links.push({
-        title: 'Your',
-        links: userLinks.slice(),
-      });
-    }
-    const docLinks = this._getDocLinks(docBaseUrl, DOCUMENTATION_LINKS);
-    if (docLinks.length) {
-      links.push({
-        title: 'Documentation',
-        links: docLinks,
-        class: 'hideOnMobile',
-      });
-    }
-    links.push({
-      title: 'Browse',
-      links: adminLinks.slice(),
-    });
-    const topMenuLinks = [];
-    links.forEach(link => { topMenuLinks[link.title] = link.links; });
-    for (const m of topMenus) {
-      const items = m.items.map(this._fixCustomMenuItem).filter(link =>
-        // Ignore GWT project links
-        !link.url.includes('${projectName}')
-      );
-      if (m.name in topMenuLinks) {
-        items.forEach(link => { topMenuLinks[m.name].push(link); });
-      } else {
-        links.push({
-          title: m.name,
-          links: topMenuLinks[m.name] = items,
-        });
-      }
-    }
-    return links;
-  }
-
-  _getDocLinks(docBaseUrl, docLinks) {
-    if (!docBaseUrl || !docLinks) {
-      return [];
-    }
-    return docLinks.map(link => {
-      let url = docBaseUrl;
-      if (url && url[url.length - 1] === '/') {
-        url = url.substring(0, url.length - 1);
-      }
-      return {
-        url: url + link.url,
-        name: link.name,
-        target: '_blank',
-      };
-    });
-  }
-
-  _loadAccount() {
-    this.loading = true;
-    const promises = [
-      this.$.restAPI.getAccount(),
-      this.$.restAPI.getTopMenus(),
-      getPluginLoader().awaitPluginsLoaded(),
-    ];
-
-    return Promise.all(promises).then(result => {
-      const account = result[0];
-      this._account = account;
-      this.loggedIn = !!account;
-      this.loading = false;
-      this._topMenus = result[1];
-
-      return getAdminLinks(account,
-          params => this.$.restAPI.getAccountCapabilities(params),
-          () => this.$.jsAPI.getAdminMenuLinks())
-          .then(res => {
-            this._adminLinks = res.links;
-          });
-    });
-  }
-
-  _loadConfig() {
-    this.$.restAPI.getConfig()
-        .then(config => {
-          this._retrieveRegisterURL(config);
-          return getDocsBaseUrl(config, this.$.restAPI);
-        })
-        .then(docBaseUrl => { this._docBaseUrl = docBaseUrl; });
-  }
-
-  _accountLoaded(account) {
-    if (!account) { return; }
-
-    this.$.restAPI.getPreferences().then(prefs => {
-      this._userLinks = prefs && prefs.my ?
-        prefs.my.map(this._fixCustomMenuItem) : [];
-    });
-  }
-
-  _retrieveRegisterURL(config) {
-    if (AUTH_TYPES_WITH_REGISTER_URL.has(config.auth.auth_type)) {
-      this._registerURL = config.auth.register_url;
-      if (config.auth.register_text) {
-        this._registerText = config.auth.register_text;
-      }
-    }
-  }
-
-  _computeIsInvisible(registerURL) {
-    return registerURL ? '' : 'invisible';
-  }
-
-  _fixCustomMenuItem(linkObj) {
-    // Normalize all urls to PolyGerrit style.
-    if (linkObj.url.startsWith('#')) {
-      linkObj.url = linkObj.url.slice(1);
-    }
-
-    // Delete target property due to complications of
-    // https://bugs.chromium.org/p/gerrit/issues/detail?id=5888
-    //
-    // The server tries to guess whether URL is a view within the UI.
-    // 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.
-    delete linkObj.target;
-
-    return linkObj;
-  }
-
-  _generateSettingsLink() {
-    return getBaseUrl() + '/settings/';
-  }
-
-  _onMobileSearchTap(e) {
-    e.preventDefault();
-    e.stopPropagation();
-    this.dispatchEvent(new CustomEvent('mobile-search', {
-      composed: true, bubbles: false,
-    }));
-  }
-
-  _computeLinkGroupClass(linkGroup) {
-    if (linkGroup && linkGroup.class) {
-      return linkGroup.class;
-    }
-
-    return '';
-  }
-
-  _computeShowHideAriaLabel(mobileSearchHidden) {
-    if (mobileSearchHidden) {
-      return 'Show Searchbar';
-    } else {
-      return 'Hide Searchbar';
-    }
-  }
-}
-
-customElements.define(GrMainHeader.is, GrMainHeader);
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
new file mode 100644
index 0000000..5ad0d92
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.ts
@@ -0,0 +1,396 @@
+/**
+ * @license
+ * Copyright (C) 2016 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 '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
+import '../../shared/gr-dropdown/gr-dropdown';
+import '../../shared/gr-icons/gr-icons';
+import '../../shared/gr-js-api-interface/gr-js-api-interface';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../gr-account-dropdown/gr-account-dropdown';
+import '../gr-smart-search/gr-smart-search';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-main-header_html';
+import {getBaseUrl, getDocsBaseUrl} from '../../../utils/url-util';
+import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
+import {getAdminLinks, NavLink} from '../../../utils/admin-nav-util';
+import {customElement, property, observe} from '@polymer/decorators';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {
+  AccountDetailInfo,
+  ServerInfo,
+  TopMenuEntryInfo,
+  TopMenuItemInfo,
+} from '../../../types/common';
+import {JsApiService} from '../../shared/gr-js-api-interface/gr-js-api-types';
+import {AuthType} from '../../../constants/constants';
+
+interface FixedTopMenuItemInfo extends Omit<TopMenuItemInfo, 'target'> {
+  target?: never;
+}
+interface MainHeaderLink {
+  url: string;
+  name: string;
+}
+interface MainHeaderLinkGroup {
+  title: string;
+  links: MainHeaderLink[];
+  class?: string;
+}
+
+const DEFAULT_LINKS: MainHeaderLinkGroup[] = [
+  {
+    title: 'Changes',
+    links: [
+      {
+        url: '/q/status:open+-is:wip',
+        name: 'Open',
+      },
+      {
+        url: '/q/status:merged',
+        name: 'Merged',
+      },
+      {
+        url: '/q/status:abandoned',
+        name: 'Abandoned',
+      },
+    ],
+  },
+];
+
+const DOCUMENTATION_LINKS: MainHeaderLink[] = [
+  {
+    url: '/index.html',
+    name: 'Table of Contents',
+  },
+  {
+    url: '/user-search.html',
+    name: 'Searching',
+  },
+  {
+    url: '/user-upload.html',
+    name: 'Uploading',
+  },
+  {
+    url: '/access-control.html',
+    name: 'Access Control',
+  },
+  {
+    url: '/rest-api.html',
+    name: 'REST API',
+  },
+  {
+    url: '/intro-project-owner.html',
+    name: 'Project Owner Guide',
+  },
+];
+
+// Set of authentication methods that can provide custom registration page.
+const AUTH_TYPES_WITH_REGISTER_URL: Set<AuthType> = new Set([
+  AuthType.LDAP,
+  AuthType.LDAP_BIND,
+  AuthType.CUSTOM_EXTENSION,
+]);
+
+export interface GrMainHeader {
+  $: {
+    restAPI: RestApiService & Element;
+    jsAPI: JsApiService & Element;
+  };
+}
+
+@customElement('gr-main-header')
+export class GrMainHeader extends GestureEventListeners(
+  LegacyElementMixin(PolymerElement)
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  @property({type: String, notify: true})
+  searchQuery?: string;
+
+  @property({type: Boolean, reflectToAttribute: true})
+  loggedIn?: boolean;
+
+  @property({type: Boolean, reflectToAttribute: true})
+  loading?: boolean;
+
+  @property({type: Object})
+  _account?: AccountDetailInfo;
+
+  @property({type: Array})
+  _adminLinks: NavLink[] = [];
+
+  @property({type: String})
+  _docBaseUrl: string | null = null;
+
+  @property({
+    type: Array,
+    computed: '_computeLinks(_userLinks, _adminLinks, _topMenus, _docBaseUrl)',
+  })
+  _links?: MainHeaderLinkGroup[];
+
+  @property({type: String})
+  loginUrl = '/login';
+
+  @property({type: Array})
+  _userLinks: FixedTopMenuItemInfo[] = [];
+
+  @property({type: Array})
+  _topMenus?: TopMenuEntryInfo[] = [];
+
+  @property({type: String})
+  _registerText = 'Sign up';
+
+  @property({type: String})
+  _registerURL?: string;
+
+  @property({type: Boolean})
+  mobileSearchHidden = false;
+
+  /** @override */
+  ready() {
+    super.ready();
+    this._ensureAttribute('role', 'banner');
+  }
+
+  /** @override */
+  attached() {
+    super.attached();
+    this._loadAccount();
+    this._loadConfig();
+  }
+
+  /** @override */
+  detached() {
+    super.detached();
+  }
+
+  reload() {
+    this._loadAccount();
+  }
+
+  _computeRelativeURL(path: string) {
+    return '//' + window.location.host + getBaseUrl() + path;
+  }
+
+  _computeLinks(
+    userLinks?: FixedTopMenuItemInfo[],
+    adminLinks?: NavLink[],
+    topMenus?: TopMenuEntryInfo[],
+    docBaseUrl?: string | null,
+    // defaultLinks parameter is used in tests only
+    defaultLinks = DEFAULT_LINKS
+  ) {
+    // Polymer 2: check for undefined
+    if (
+      userLinks === undefined ||
+      adminLinks === undefined ||
+      topMenus === undefined ||
+      docBaseUrl === undefined
+    ) {
+      return undefined;
+    }
+
+    const links: MainHeaderLinkGroup[] = defaultLinks.map(menu => {
+      return {
+        title: menu.title,
+        links: menu.links.slice(),
+      };
+    });
+    if (userLinks && userLinks.length > 0) {
+      links.push({
+        title: 'Your',
+        links: userLinks.slice(),
+      });
+    }
+    const docLinks = this._getDocLinks(docBaseUrl, DOCUMENTATION_LINKS);
+    if (docLinks.length) {
+      links.push({
+        title: 'Documentation',
+        links: docLinks,
+        class: 'hideOnMobile',
+      });
+    }
+    links.push({
+      title: 'Browse',
+      links: adminLinks.slice(),
+    });
+    const topMenuLinks: {[name: string]: MainHeaderLink[]} = {};
+    links.forEach(link => {
+      topMenuLinks[link.title] = link.links;
+    });
+    for (const m of topMenus) {
+      const items = m.items.map(this._fixCustomMenuItem).filter(
+        link =>
+          // Ignore GWT project links
+          !link.url.includes('${projectName}')
+      );
+      if (m.name in topMenuLinks) {
+        items.forEach(link => {
+          topMenuLinks[m.name].push(link);
+        });
+      } else {
+        links.push({
+          title: m.name,
+          links: topMenuLinks[m.name] = items,
+        });
+      }
+    }
+    return links;
+  }
+
+  _getDocLinks(docBaseUrl: string | null, docLinks: MainHeaderLink[]) {
+    if (!docBaseUrl) {
+      return [];
+    }
+    return docLinks.map(link => {
+      let url = docBaseUrl;
+      if (url && url[url.length - 1] === '/') {
+        url = url.substring(0, url.length - 1);
+      }
+      return {
+        url: url + link.url,
+        name: link.name,
+        target: '_blank',
+      };
+    });
+  }
+
+  _loadAccount() {
+    this.loading = true;
+
+    return Promise.all([
+      this.$.restAPI.getAccount(),
+      this.$.restAPI.getTopMenus(),
+      getPluginLoader().awaitPluginsLoaded(),
+    ]).then(result => {
+      const account = result[0];
+      this._account = account;
+      this.loggedIn = !!account;
+      this.loading = false;
+      this._topMenus = result[1];
+
+      return getAdminLinks(
+        account,
+        () =>
+          this.$.restAPI.getAccountCapabilities().then(capabilities => {
+            if (!capabilities) {
+              throw new Error('getAccountCapabilities returns undefined');
+            }
+            return capabilities;
+          }),
+        () => this.$.jsAPI.getAdminMenuLinks()
+      ).then(res => {
+        this._adminLinks = res.links;
+      });
+    });
+  }
+
+  _loadConfig() {
+    this.$.restAPI
+      .getConfig()
+      .then(config => {
+        if (!config) {
+          throw new Error('getConfig returned undefined');
+        }
+        this._retrieveRegisterURL(config);
+        return getDocsBaseUrl(config, this.$.restAPI);
+      })
+      .then(docBaseUrl => {
+        this._docBaseUrl = docBaseUrl;
+      });
+  }
+
+  @observe('_account')
+  _accountLoaded(account?: AccountDetailInfo) {
+    if (!account) {
+      return;
+    }
+
+    this.$.restAPI.getPreferences().then(prefs => {
+      this._userLinks =
+        prefs && prefs.my ? prefs.my.map(this._fixCustomMenuItem) : [];
+    });
+  }
+
+  _retrieveRegisterURL(config: ServerInfo) {
+    if (AUTH_TYPES_WITH_REGISTER_URL.has(config.auth.auth_type)) {
+      this._registerURL = config.auth.register_url;
+      if (config.auth.register_text) {
+        this._registerText = config.auth.register_text;
+      }
+    }
+  }
+
+  _computeIsInvisible(registerURL?: string) {
+    return registerURL ? '' : 'invisible';
+  }
+
+  _fixCustomMenuItem(linkObj: TopMenuItemInfo): FixedTopMenuItemInfo {
+    // TODO(TS): make a copy of linkObj instead of modifying the existing one
+    // Normalize all urls to PolyGerrit style.
+    if (linkObj.url.startsWith('#')) {
+      linkObj.url = linkObj.url.slice(1);
+    }
+
+    // Delete target property due to complications of
+    // https://bugs.chromium.org/p/gerrit/issues/detail?id=5888
+    //
+    // The server tries to guess whether URL is a view within the UI.
+    // 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.
+    delete linkObj.target;
+
+    return (linkObj as unknown) as FixedTopMenuItemInfo;
+  }
+
+  _generateSettingsLink() {
+    return getBaseUrl() + '/settings/';
+  }
+
+  _onMobileSearchTap(e: Event) {
+    e.preventDefault();
+    e.stopPropagation();
+    this.dispatchEvent(
+      new CustomEvent('mobile-search', {
+        composed: true,
+        bubbles: false,
+      })
+    );
+  }
+
+  _computeLinkGroupClass(linkGroup: MainHeaderLinkGroup) {
+    return linkGroup.class ?? '';
+  }
+
+  _computeShowHideAriaLabel(mobileSearchHidden: boolean) {
+    if (mobileSearchHidden) {
+      return 'Show Searchbar';
+    } else {
+      return 'Hide Searchbar';
+    }
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-main-header': GrMainHeader;
+  }
+}
diff --git a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_test.js b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_test.js
index 48194a6..e4db65f 100644
--- a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_test.js
+++ b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_test.js
@@ -103,22 +103,22 @@
 
     // When no admin links are passed, it should use the default.
     assert.deepEqual(element._computeLinks(
-        defaultLinks,
         /* userLinks= */[],
         adminLinks,
         /* topMenus= */[],
-        /* docBaseUrl= */ ''
+        /* docBaseUrl= */ '',
+        defaultLinks
     ),
     defaultLinks.concat({
       title: 'Browse',
       links: adminLinks,
     }));
     assert.deepEqual(element._computeLinks(
-        defaultLinks,
         userLinks,
         adminLinks,
         /* topMenus= */[],
-        /* docBaseUrl= */ ''
+        /* docBaseUrl= */ '',
+        defaultLinks
     ),
     defaultLinks.concat([
       {
@@ -142,7 +142,6 @@
 
     assert.deepEqual(element._getDocLinks(null, docLinks), []);
     assert.deepEqual(element._getDocLinks('', docLinks), []);
-    assert.deepEqual(element._getDocLinks('base', null), []);
     assert.deepEqual(element._getDocLinks('base', []), []);
 
     assert.deepEqual(element._getDocLinks('base', docLinks), [{
@@ -172,11 +171,11 @@
       }],
     }];
     assert.deepEqual(element._computeLinks(
-        /* defaultLinks= */ [],
         /* userLinks= */ [],
         adminLinks,
         topMenus,
-        /* baseDocUrl= */ ''
+        /* baseDocUrl= */ '',
+        /* defaultLinks= */ []
     ), [{
       title: 'Browse',
       links: adminLinks,
@@ -208,11 +207,11 @@
       }],
     }];
     assert.deepEqual(element._computeLinks(
-        /* defaultLinks= */ [],
         /* userLinks= */ [],
         adminLinks,
         topMenus,
-        /* baseDocUrl= */ ''
+        /* baseDocUrl= */ '',
+        /* defaultLinks= */ []
     ), [{
       title: 'Browse',
       links: adminLinks,
@@ -247,11 +246,11 @@
       }],
     }];
     assert.deepEqual(element._computeLinks(
-        /* defaultLinks= */ [],
         /* userLinks= */ [],
         adminLinks,
         topMenus,
-        /* baseDocUrl= */ ''
+        /* baseDocUrl= */ '',
+        /* defaultLinks= */ []
     ), [{
       title: 'Browse',
       links: adminLinks,
@@ -284,11 +283,11 @@
       }],
     }];
     assert.deepEqual(element._computeLinks(
-        defaultLinks,
         /* userLinks= */ [],
         /* adminLinks= */ [],
         topMenus,
-        /* baseDocUrl= */ ''
+        /* baseDocUrl= */ '',
+        defaultLinks
     ), [{
       title: 'Faves',
       links: defaultLinks[0].links.concat([{
@@ -315,11 +314,11 @@
       }],
     }];
     assert.deepEqual(element._computeLinks(
-        /* defaultLinks= */ [],
         userLinks,
         /* adminLinks= */ [],
         topMenus,
-        /* baseDocUrl= */ ''
+        /* baseDocUrl= */ '',
+        /* defaultLinks= */ []
     ), [{
       title: 'Your',
       links: userLinks.concat([{
@@ -346,11 +345,11 @@
       }],
     }];
     assert.deepEqual(element._computeLinks(
-        /* defaultLinks= */ [],
         /* userLinks= */ [],
         adminLinks,
         topMenus,
-        /* baseDocUrl= */ ''
+        /* baseDocUrl= */ '',
+        /* defaultLinks= */ []
     ), [{
       title: 'Browse',
       links: adminLinks.concat([{
diff --git a/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.ts b/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.ts
index db0bd64..8470611 100644
--- a/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.ts
+++ b/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.ts
@@ -32,6 +32,7 @@
   ParentPatchSetNum,
   ServerInfo,
 } from '../../../types/common';
+import {ParsedChangeInfo} from '../../shared/gr-rest-api-interface/gr-reviewer-updates-parser';
 
 // Navigation parameters object format:
 //
@@ -126,7 +127,7 @@
 export interface DashboardSection {
   name: string;
   query: string;
-  suffixForDashboard: string;
+  suffixForDashboard?: string;
   attentionSetOnly?: boolean;
   selfOnly?: boolean;
   hideIfEmpty?: boolean;
@@ -140,87 +141,95 @@
 }
 
 export interface UserDashboard {
-  title: string;
+  title?: string;
   sections: DashboardSection[];
 }
 
 // NOTE: These queries are tested in Java. Any changes made to definitions
 // here require corresponding changes to:
 // java/com/google/gerrit/httpd/raw/IndexPreloadingUtil.java
+const HAS_DRAFTS: DashboardSection = {
+  // Changes with unpublished draft comments. This section is omitted when
+  // viewing other users, so we don't need to filter anything out.
+  name: 'Has draft comments',
+  query: 'has:draft',
+  selfOnly: true,
+  hideIfEmpty: true,
+  suffixForDashboard: 'limit:10',
+};
+export const YOUR_TURN: DashboardSection = {
+  // Changes where the user is in the attention set.
+  name: 'Your Turn',
+  query: 'attention:${user}',
+  hideIfEmpty: false,
+  suffixForDashboard: 'limit:25',
+  attentionSetOnly: true,
+};
+const ASSIGNED: DashboardSection = {
+  // Changes that are assigned to the viewed user.
+  name: 'Assigned reviews',
+  query:
+    'assignee:${user} (-is:wip OR owner:self OR assignee:self) ' +
+    'is:open -is:ignored',
+  hideIfEmpty: true,
+  suffixForDashboard: 'limit:25',
+  assigneeOnly: true,
+};
+const WIP: DashboardSection = {
+  // WIP open changes owned by viewing user. This section is omitted when
+  // viewing other users, so we don't need to filter anything out.
+  name: 'Work in progress',
+  query: 'is:open owner:${user} is:wip',
+  selfOnly: true,
+  hideIfEmpty: true,
+  suffixForDashboard: 'limit:25',
+};
+const OUTGOING: DashboardSection = {
+  // Non-WIP open changes owned by viewed user. Filter out changes ignored
+  // by the viewing user.
+  name: 'Outgoing reviews',
+  query: 'is:open owner:${user} -is:wip -is:ignored',
+  isOutgoing: true,
+  suffixForDashboard: 'limit:25',
+};
+const INCOMING: DashboardSection = {
+  // Non-WIP open changes not owned by the viewed user, that the viewed user
+  // is associated with (as either a reviewer or the assignee). Changes
+  // ignored by the viewing user are filtered out.
+  name: 'Incoming reviews',
+  query:
+    'is:open -owner:${user} -is:wip -is:ignored ' +
+    '(reviewer:${user} OR assignee:${user})',
+  suffixForDashboard: 'limit:25',
+};
+const CCED: DashboardSection = {
+  // Open changes the viewed user is CCed on. Changes ignored by the viewing
+  // user are filtered out.
+  name: 'CCed on',
+  query: 'is:open -is:ignored cc:${user}',
+  suffixForDashboard: 'limit:10',
+};
+export const CLOSED: DashboardSection = {
+  name: 'Recently closed',
+  // Closed changes where viewed user is owner, reviewer, or assignee.
+  // Changes ignored by the viewing user are filtered out, and so are WIP
+  // changes not owned by the viewing user (the one instance of
+  // 'owner:self' is intentional and implements this logic).
+  query:
+    'is:closed -is:ignored (-is:wip OR owner:self) ' +
+    '(owner:${user} OR reviewer:${user} OR assignee:${user} ' +
+    'OR cc:${user})',
+  suffixForDashboard: '-age:4w limit:10',
+};
 const DEFAULT_SECTIONS: DashboardSection[] = [
-  {
-    // Changes with unpublished draft comments. This section is omitted when
-    // viewing other users, so we don't need to filter anything out.
-    name: 'Has draft comments',
-    query: 'has:draft',
-    selfOnly: true,
-    hideIfEmpty: true,
-    suffixForDashboard: 'limit:10',
-  },
-  {
-    // Changes where the user is in the attention set.
-    name: 'Your Turn',
-    query: 'attention:${user}',
-    hideIfEmpty: false,
-    suffixForDashboard: 'limit:25',
-    attentionSetOnly: true,
-  },
-  {
-    // Changes that are assigned to the viewed user.
-    name: 'Assigned reviews',
-    query:
-      'assignee:${user} (-is:wip OR owner:self OR assignee:self) ' +
-      'is:open -is:ignored',
-    hideIfEmpty: true,
-    suffixForDashboard: 'limit:25',
-    assigneeOnly: true,
-  },
-  {
-    // WIP open changes owned by viewing user. This section is omitted when
-    // viewing other users, so we don't need to filter anything out.
-    name: 'Work in progress',
-    query: 'is:open owner:${user} is:wip',
-    selfOnly: true,
-    hideIfEmpty: true,
-    suffixForDashboard: 'limit:25',
-  },
-  {
-    // Non-WIP open changes owned by viewed user. Filter out changes ignored
-    // by the viewing user.
-    name: 'Outgoing reviews',
-    query: 'is:open owner:${user} -is:wip -is:ignored',
-    isOutgoing: true,
-    suffixForDashboard: 'limit:25',
-  },
-  {
-    // Non-WIP open changes not owned by the viewed user, that the viewed user
-    // is associated with (as either a reviewer or the assignee). Changes
-    // ignored by the viewing user are filtered out.
-    name: 'Incoming reviews',
-    query:
-      'is:open -owner:${user} -is:wip -is:ignored ' +
-      '(reviewer:${user} OR assignee:${user})',
-    suffixForDashboard: 'limit:25',
-  },
-  {
-    // Open changes the viewed user is CCed on. Changes ignored by the viewing
-    // user are filtered out.
-    name: 'CCed on',
-    query: 'is:open -is:ignored cc:${user}',
-    suffixForDashboard: 'limit:10',
-  },
-  {
-    name: 'Recently closed',
-    // Closed changes where viewed user is owner, reviewer, or assignee.
-    // Changes ignored by the viewing user are filtered out, and so are WIP
-    // changes not owned by the viewing user (the one instance of
-    // 'owner:self' is intentional and implements this logic).
-    query:
-      'is:closed -is:ignored (-is:wip OR owner:self) ' +
-      '(owner:${user} OR reviewer:${user} OR assignee:${user} ' +
-      'OR cc:${user})',
-    suffixForDashboard: '-age:4w limit:10',
-  },
+  HAS_DRAFTS,
+  YOUR_TURN,
+  ASSIGNED,
+  WIP,
+  OUTGOING,
+  INCOMING,
+  CCED,
+  CLOSED,
 ];
 
 export interface GenerateUrlSearchViewParameters {
@@ -656,7 +665,7 @@
    * @param basePatchNum The string 'PARENT' can be used for none.
    */
   getUrlForDiff(
-    change: ChangeInfo,
+    change: ChangeInfo | ParsedChangeInfo,
     filePath: string,
     patchNum?: PatchSetNum,
     basePatchNum?: PatchSetNum,
@@ -715,7 +724,7 @@
   },
 
   getEditUrlForDiff(
-    change: ChangeInfo,
+    change: ChangeInfo | ParsedChangeInfo,
     filePath: string,
     patchNum?: PatchSetNum,
     lineNum?: number
@@ -755,7 +764,7 @@
    * @param basePatchNum The string 'PARENT' can be used for none.
    */
   navigateToDiff(
-    change: ChangeInfo,
+    change: ChangeInfo | ParsedChangeInfo,
     filePath: string,
     patchNum?: PatchSetNum,
     basePatchNum?: PatchSetNum,
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-router.ts b/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
index aa2e4ce..94f0302 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
@@ -52,6 +52,7 @@
 import {customElement, property} from '@polymer/decorators';
 import {assertNever} from '../../../utils/common-util';
 import {
+  DashboardId,
   GroupId,
   NumericChangeId,
   PatchSetNum,
@@ -1256,7 +1257,7 @@
     this._setParams({
       view: GerritView.DASHBOARD,
       project,
-      dashboard: decodeURIComponent(data.params[1]),
+      dashboard: decodeURIComponent(data.params[1]) as DashboardId,
     });
     this.reporting.setRepoName(project);
   }
diff --git a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.ts b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.ts
index acc2cbd..180e4a7 100644
--- a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.ts
+++ b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.ts
@@ -124,11 +124,15 @@
 
 const TOKENIZE_REGEX = /(?:[^\s"]+|"[^"]*")+\s*/g;
 
-type SuggestionProvider = (
+export type SuggestionProvider = (
   predicate: string,
   expression: string
 ) => Promise<AutocompleteSuggestion[]>;
 
+export interface SearchBarHandleSearchDetail {
+  inputVal: string;
+}
+
 export interface GrSearchBar {
   $: {
     restAPI: RestApiService & Element;
@@ -254,7 +258,8 @@
     } else {
       target.blur();
     }
-    const trimmedInput = this._inputVal && this._inputVal.trim();
+    if (!this._inputVal) return;
+    const trimmedInput = this._inputVal.trim();
     if (trimmedInput) {
       const predefinedOpOnlyQuery = [
         ...SEARCH_OPERATORS_WITH_NEGATIONS_SET,
@@ -262,9 +267,12 @@
       if (predefinedOpOnlyQuery) {
         return;
       }
+      const detail: SearchBarHandleSearchDetail = {
+        inputVal: this._inputVal,
+      };
       this.dispatchEvent(
         new CustomEvent('handle-search', {
-          detail: {inputVal: this._inputVal},
+          detail,
         })
       );
     }
diff --git a/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search.js b/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search.js
deleted file mode 100644
index 813298c..0000000
--- a/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search.js
+++ /dev/null
@@ -1,180 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 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 '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
-import '../gr-search-bar/gr-search-bar.js';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
-import {PolymerElement} from '@polymer/polymer/polymer-element.js';
-import {htmlTemplate} from './gr-smart-search_html.js';
-import {GerritNav} from '../gr-navigation/gr-navigation.js';
-import {getUserName} from '../../../utils/display-name-util.js';
-
-const MAX_AUTOCOMPLETE_RESULTS = 10;
-const SELF_EXPRESSION = 'self';
-const ME_EXPRESSION = 'me';
-
-/**
- * @extends PolymerElement
- */
-class GrSmartSearch extends GestureEventListeners(
-    LegacyElementMixin(
-        PolymerElement)) {
-  static get template() { return htmlTemplate; }
-
-  static get is() { return 'gr-smart-search'; }
-
-  static get properties() {
-    return {
-      searchQuery: String,
-      _config: Object,
-      _projectSuggestions: {
-        type: Function,
-        value() {
-          return (predicate, expression) =>
-            this._fetchProjects(predicate, expression);
-        },
-      },
-      _groupSuggestions: {
-        type: Function,
-        value() {
-          return (predicate, expression) =>
-            this._fetchGroups(predicate, expression);
-        },
-      },
-      _accountSuggestions: {
-        type: Function,
-        value() {
-          return (predicate, expression) =>
-            this._fetchAccounts(predicate, expression);
-        },
-      },
-      /**
-       * Invisible label for input element. This label is exposed to
-       * screen readers by nested element
-       */
-      label: {
-        type: String,
-        value: '',
-      },
-    };
-  }
-
-  /** @override */
-  attached() {
-    super.attached();
-    this.$.restAPI.getConfig().then(cfg => {
-      this._config = cfg;
-    });
-  }
-
-  _handleSearch(e) {
-    const input = e.detail.inputVal;
-    if (input) {
-      GerritNav.navigateToSearchQuery(input);
-    }
-  }
-
-  /**
-   * Fetch from the API the predicted projects.
-   *
-   * @param {string} predicate - The first part of the search term, e.g.
-   *     'project'
-   * @param {string} expression - The second part of the search term, e.g.
-   *     'gerr'
-   * @return {!Promise} This returns a promise that resolves to an array of
-   *     strings.
-   */
-  _fetchProjects(predicate, expression) {
-    return this.$.restAPI.getSuggestedProjects(
-        expression,
-        MAX_AUTOCOMPLETE_RESULTS)
-        .then(projects => {
-          if (!projects) { return []; }
-          const keys = Object.keys(projects);
-          return keys.map(key => { return {text: predicate + ':' + key}; });
-        });
-  }
-
-  /**
-   * Fetch from the API the predicted groups.
-   *
-   * @param {string} predicate - The first part of the search term, e.g.
-   *     'ownerin'
-   * @param {string} expression - The second part of the search term, e.g.
-   *     'polyger'
-   * @return {!Promise} This returns a promise that resolves to an array of
-   *     strings.
-   */
-  _fetchGroups(predicate, expression) {
-    if (expression.length === 0) { return Promise.resolve([]); }
-    return this.$.restAPI.getSuggestedGroups(
-        expression,
-        MAX_AUTOCOMPLETE_RESULTS)
-        .then(groups => {
-          if (!groups) { return []; }
-          const keys = Object.keys(groups);
-          return keys.map(key => { return {text: predicate + ':' + key}; });
-        });
-  }
-
-  /**
-   * Fetch from the API the predicted accounts.
-   *
-   * @param {string} predicate - The first part of the search term, e.g.
-   *     'owner'
-   * @param {string} expression - The second part of the search term, e.g.
-   *     'kasp'
-   * @return {!Promise} This returns a promise that resolves to an array of
-   *     strings.
-   */
-  _fetchAccounts(predicate, expression) {
-    if (expression.length === 0) { return Promise.resolve([]); }
-    return this.$.restAPI.getSuggestedAccounts(
-        expression,
-        MAX_AUTOCOMPLETE_RESULTS)
-        .then(accounts => {
-          if (!accounts) { return []; }
-          return this._mapAccountsHelper(accounts, predicate);
-        })
-        .then(accounts => {
-          // When the expression supplied is a beginning substring of 'self',
-          // add it as an autocomplete option.
-          if (SELF_EXPRESSION.startsWith(expression)) {
-            return accounts.concat(
-                [{text: predicate + ':' + SELF_EXPRESSION}]);
-          } else if (ME_EXPRESSION.startsWith(expression)) {
-            return accounts.concat([{text: predicate + ':' + ME_EXPRESSION}]);
-          } else {
-            return accounts;
-          }
-        });
-  }
-
-  _mapAccountsHelper(accounts, predicate) {
-    return accounts.map(account => {
-      const userName = getUserName(this._serverConfig, account);
-      return {
-        label: account.name || '',
-        text: account.email ?
-          `${predicate}:${account.email}` :
-          `${predicate}:"${userName}"`,
-      };
-    });
-  }
-}
-
-customElements.define(GrSmartSearch.is, GrSmartSearch);
diff --git a/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search.ts b/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search.ts
new file mode 100644
index 0000000..a818c59
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search.ts
@@ -0,0 +1,197 @@
+/**
+ * @license
+ * Copyright (C) 2016 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 '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../gr-search-bar/gr-search-bar';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-smart-search_html';
+import {GerritNav} from '../gr-navigation/gr-navigation';
+import {getUserName} from '../../../utils/display-name-util';
+import {customElement, property} from '@polymer/decorators';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {AccountInfo, ServerInfo} from '../../../types/common';
+import {
+  SearchBarHandleSearchDetail,
+  SuggestionProvider,
+} from '../gr-search-bar/gr-search-bar';
+import {AutocompleteSuggestion} from '../../shared/gr-autocomplete/gr-autocomplete';
+
+const MAX_AUTOCOMPLETE_RESULTS = 10;
+const SELF_EXPRESSION = 'self';
+const ME_EXPRESSION = 'me';
+
+export interface GrSmartSearch {
+  $: {
+    restAPI: RestApiService & Element;
+  };
+}
+
+@customElement('gr-smart-search')
+export class GrSmartSearch extends GestureEventListeners(
+  LegacyElementMixin(PolymerElement)
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  @property({type: String})
+  searchQuery?: string;
+
+  @property({type: Object})
+  _config?: ServerInfo;
+
+  @property({type: Object})
+  _projectSuggestions: SuggestionProvider = (predicate, expression) =>
+    this._fetchProjects(predicate, expression);
+
+  @property({type: Object})
+  _groupSuggestions: SuggestionProvider = (predicate, expression) =>
+    this._fetchGroups(predicate, expression);
+
+  @property({type: Object})
+  _accountSuggestions: SuggestionProvider = (predicate, expression) =>
+    this._fetchAccounts(predicate, expression);
+
+  @property({type: String})
+  label = '';
+
+  /** @override */
+  attached() {
+    super.attached();
+    this.$.restAPI.getConfig().then(cfg => {
+      this._config = cfg;
+    });
+  }
+
+  _handleSearch(e: CustomEvent<SearchBarHandleSearchDetail>) {
+    const input = e.detail.inputVal;
+    if (input) {
+      GerritNav.navigateToSearchQuery(input);
+    }
+  }
+
+  /**
+   * Fetch from the API the predicted projects.
+   *
+   * @param predicate - The first part of the search term, e.g.
+   * 'project'
+   * @param expression - The second part of the search term, e.g.
+   * 'gerr'
+   */
+  _fetchProjects(
+    predicate: string,
+    expression: string
+  ): Promise<AutocompleteSuggestion[]> {
+    return this.$.restAPI
+      .getSuggestedProjects(expression, MAX_AUTOCOMPLETE_RESULTS)
+      .then(projects => {
+        if (!projects) {
+          return [];
+        }
+        const keys = Object.keys(projects);
+        return keys.map(key => {
+          return {text: predicate + ':' + key};
+        });
+      });
+  }
+
+  /**
+   * Fetch from the API the predicted groups.
+   *
+   * @param predicate - The first part of the search term, e.g.
+   * 'ownerin'
+   * @param expression - The second part of the search term, e.g.
+   * 'polyger'
+   */
+  _fetchGroups(
+    predicate: string,
+    expression: string
+  ): Promise<AutocompleteSuggestion[]> {
+    if (expression.length === 0) {
+      return Promise.resolve([]);
+    }
+    return this.$.restAPI
+      .getSuggestedGroups(expression, MAX_AUTOCOMPLETE_RESULTS)
+      .then(groups => {
+        if (!groups) {
+          return [];
+        }
+        const keys = Object.keys(groups);
+        return keys.map(key => {
+          return {text: predicate + ':' + key};
+        });
+      });
+  }
+
+  /**
+   * Fetch from the API the predicted accounts.
+   *
+   * @param predicate - The first part of the search term, e.g.
+   * 'owner'
+   * @param expression - The second part of the search term, e.g.
+   * 'kasp'
+   */
+  _fetchAccounts(
+    predicate: string,
+    expression: string
+  ): Promise<AutocompleteSuggestion[]> {
+    if (expression.length === 0) {
+      return Promise.resolve([]);
+    }
+    return this.$.restAPI
+      .getSuggestedAccounts(expression, MAX_AUTOCOMPLETE_RESULTS)
+      .then(accounts => {
+        if (!accounts) {
+          return [];
+        }
+        return this._mapAccountsHelper(accounts, predicate);
+      })
+      .then(accounts => {
+        // When the expression supplied is a beginning substring of 'self',
+        // add it as an autocomplete option.
+        if (SELF_EXPRESSION.startsWith(expression)) {
+          return accounts.concat([{text: predicate + ':' + SELF_EXPRESSION}]);
+        } else if (ME_EXPRESSION.startsWith(expression)) {
+          return accounts.concat([{text: predicate + ':' + ME_EXPRESSION}]);
+        } else {
+          return accounts;
+        }
+      });
+  }
+
+  _mapAccountsHelper(
+    accounts: AccountInfo[],
+    predicate: string
+  ): AutocompleteSuggestion[] {
+    return accounts.map(account => {
+      const userName = getUserName(this._config, account);
+      return {
+        label: account.name || '',
+        text: account.email
+          ? `${predicate}:${account.email}`
+          : `${predicate}:"${userName}"`,
+      };
+    });
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-smart-search': GrSmartSearch;
+  }
+}
diff --git a/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.ts b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.ts
index 675a048..2236db2 100644
--- a/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.ts
+++ b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.ts
@@ -40,7 +40,7 @@
 import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
 import {CommentEventDetail} from '../../shared/gr-comment/gr-comment';
 import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
-import {isRobot} from '../gr-comment-api/gr-comment-api';
+import {isRobot} from '../../../utils/comment-util';
 
 export interface GrApplyFixDialog {
   $: {
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 deecdbf..33c1346 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
@@ -19,7 +19,6 @@
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-comment-api_html';
-import {parseDate} from '../../../utils/date-util';
 import {
   getParentIndex,
   isMergeParent,
@@ -28,121 +27,33 @@
 import {customElement, property} from '@polymer/decorators';
 import {
   CommentBasics,
-  CommentInfo,
   ConfigInfo,
   ParentPatchSetNum,
   PatchRange,
   PatchSetNum,
   PathToRobotCommentsInfoMap,
   RobotCommentInfo,
-  Timestamp,
   UrlEncodedCommentId,
   NumericChangeId,
 } from '../../../types/common';
 import {hasOwnProperty} from '../../../utils/common-util';
-import {CommentSide, Side} from '../../../constants/constants';
+import {CommentSide} from '../../../constants/constants';
 import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
-
-export interface DraftCommentProps {
-  __draft?: boolean;
-  __draftID?: string;
-  __date?: Date;
-}
-
-export type DraftInfo = CommentBasics & DraftCommentProps;
-
-/**
- * Each of the type implements or extends CommentBasics.
- */
-export type Comment = DraftInfo | CommentInfo | RobotCommentInfo;
-
-export interface UIStateCommentProps {
-  // The `side` of the comment is PARENT or REVISION, but this is LEFT or RIGHT.
-  // TODO(TS): Remove the naming confusion of commentSide being of type of Side,
-  // but side being of type CommentSide. :-)
-  __commentSide?: Side;
-  // TODO(TS): Remove this. Seems to be exactly the same as `path`??
-  __path?: string;
-  collapsed?: boolean;
-  // TODO(TS): Consider allowing this only for drafts.
-  __editing?: boolean;
-  __otherEditing?: boolean;
-}
-
-export type UIDraft = DraftInfo & UIStateCommentProps;
-
-export type UIHuman = CommentInfo & UIStateCommentProps;
-
-export type UIRobot = RobotCommentInfo & UIStateCommentProps;
-
-export type UIComment = UIHuman | UIRobot | UIDraft;
-
-export type CommentMap = {[path: string]: boolean};
-
-export function isRobot<T extends CommentInfo>(
-  x: T | DraftInfo | RobotCommentInfo | undefined
-): x is RobotCommentInfo {
-  return !!x && !!(x as RobotCommentInfo).robot_id;
-}
-
-export function isDraft<T extends CommentInfo>(
-  x: T | UIDraft | undefined
-): x is UIDraft {
-  return !!x && !!(x as UIDraft).__draft;
-}
-
-export interface PatchSetFile {
-  path: string;
-  basePath?: string;
-  patchNum?: PatchSetNum;
-}
-
-export interface PatchNumOnly {
-  patchNum: PatchSetNum;
-}
-
-export function isPatchSetFile(
-  x: PatchSetFile | PatchNumOnly
-): x is PatchSetFile {
-  return !!(x as PatchSetFile).path;
-}
-
-interface SortableComment {
-  __draft?: boolean;
-  __date?: Date;
-  updated?: Timestamp;
-  id?: UrlEncodedCommentId;
-}
-
-export function sortComments<T extends SortableComment>(comments: T[]): T[] {
-  return comments.slice(0).sort((c1, c2) => {
-    const d1 = !!c1.__draft;
-    const d2 = !!c2.__draft;
-    if (d1 !== d2) return d1 ? 1 : -1;
-
-    const date1 = (c1.updated && parseDate(c1.updated)) || c1.__date;
-    const date2 = (c2.updated && parseDate(c2.updated)) || c2.__date;
-    const dateDiff = date1!.valueOf() - date2!.valueOf();
-    if (dateDiff !== 0) return dateDiff;
-
-    const id1 = c1.id ?? '';
-    const id2 = c2.id ?? '';
-    return id1.localeCompare(id2);
-  });
-}
-
-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;
-}
+import {
+  Comment,
+  CommentMap,
+  CommentThread,
+  DraftInfo,
+  isPatchSetFile,
+  isUnresolved,
+  PatchNumOnly,
+  PatchSetFile,
+  sortComments,
+  UIComment,
+  UIDraft,
+  UIHuman,
+  UIRobot,
+} from '../../../utils/comment-util';
 
 export type CommentIdToCommentThreadMap = {
   [urlEncodedCommentId: string]: CommentThread;
@@ -560,15 +471,8 @@
     }
 
     comments = comments.concat(drafts);
-
     const threads = this.getCommentThreads(sortComments(comments));
-
-    const unresolvedThreads = threads.filter(
-      thread =>
-        thread.comments.length &&
-        thread.comments[thread.comments.length - 1].unresolved
-    );
-
+    const unresolvedThreads = threads.filter(isUnresolved);
     return unresolvedThreads.length;
   }
 
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 5256564..bb7b518 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
@@ -120,9 +120,6 @@
   @property({type: String})
   path?: string;
 
-  @property({type: String})
-  projectName?: string;
-
   @property({type: Object})
   _builder?: GrDiffBuilder;
 
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 59a0370..2486c03 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
@@ -32,6 +32,7 @@
 import {PolymerSpliceChange} from '@polymer/polymer/interfaces';
 import {PolymerDomWrapper} from '../../../types/types';
 import {GrDiffGroupType} from '../gr-diff/gr-diff-group';
+import {GrDiff} from '../gr-diff/gr-diff';
 
 const DiffViewMode = {
   SIDE_BY_SIDE: 'SIDE_BY_SIDE',
@@ -46,15 +47,6 @@
 // Time in which pressing n key again after the toast navigates to next file
 const NAVIGATE_TO_NEXT_FILE_TIMEOUT_MS = 5000;
 
-// TODO(TS): Use proper GrDiff type once that file is converted to TS.
-interface GrDiff extends HTMLElement {
-  path: string;
-  addDraftAtLine: (element: HTMLElement) => void;
-  createRangeComment: () => void;
-  getCursorStops: () => HTMLElement[];
-  isRangeSelected: () => boolean;
-}
-
 export interface GrDiffCursor {
   $: {
     cursorManager: GrCursorManager;
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 49826bb..e7c9a4a 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
@@ -36,9 +36,9 @@
   isDraft,
   PatchSetFile,
   sortComments,
-  TwoSidesComments,
   UIComment,
-} from '../gr-comment-api/gr-comment-api';
+} from '../../../utils/comment-util';
+import {TwoSidesComments} from '../gr-comment-api/gr-comment-api';
 import {customElement, observe, property} from '@polymer/decorators';
 import {
   CommitRange,
@@ -231,9 +231,6 @@
   @property({type: Boolean})
   _loggedIn = false;
 
-  @property({type: Boolean})
-  _loading = false;
-
   @property({type: String})
   _errorMessage: string | null = null;
 
@@ -327,23 +324,15 @@
    * signal to report metrics event that started on location change.
    * @return
    */
-  reload(shouldReportMetric?: boolean) {
+  async reload(shouldReportMetric?: boolean) {
     this.clear();
     if (!this.path) throw new Error('Missing required "path" property.');
     if (!this.changeNum) throw new Error('Missing required "changeNum" prop.');
-    this._loading = true;
+    this.diff = undefined;
     this._errorMessage = null;
     const whitespaceLevel = this._getIgnoreWhitespace();
 
-    const layers: DiffLayer[] = [this.$.syntaxLayer];
-    // Get layers from plugins (if any).
-    for (const pluginLayer of this.$.jsAPI.getDiffLayers(
-      this.path,
-      this.changeNum
-    )) {
-      layers.push(pluginLayer);
-    }
-    this._layers = layers;
+    this._layers = this._getLayers(this.path, this.changeNum);
 
     if (shouldReportMetric) {
       // We listen on render viewport only on DiffPage (on paramsChanged)
@@ -352,67 +341,58 @@
 
     this._coverageRanges = [];
     this._getCoverageData();
-    const diffRequest = this._getDiff()
-      .then(diff => {
-        this._loadedWhitespaceLevel = whitespaceLevel;
-        this._reportDiff(diff);
-        return diff;
-      })
-      .catch(e => {
-        this._handleGetDiffError(e);
-        return null;
-      });
 
-    const assetRequest = diffRequest.then(diff => {
-      // If the diff is null, then it's failed to load.
-      if (!diff) {
-        return null;
+    try {
+      const diff = await this._getDiff();
+      this._loadedWhitespaceLevel = whitespaceLevel;
+      this._reportDiff(diff);
+
+      await this._loadDiffAssets(diff);
+
+      // Not waiting for coverage ranges intentionally as
+      // plugin loading should not block the content rendering
+
+      this.filesWeblinks = this._getFilesWeblinks(diff);
+      this.diff = diff;
+      const event = await this._onRenderOnce();
+      if (shouldReportMetric) {
+        // We report diffViewContentDisplayed only on reload caused
+        // by params changed - expected only on Diff Page.
+        this.reporting.diffViewContentDisplayed();
       }
-
-      return this._loadDiffAssets(diff);
-    });
-
-    // Not waiting for coverage ranges intentionally as
-    // plugin loading should not block the content rendering
-    return Promise.all([diffRequest, assetRequest])
-      .then(results => {
-        const diff = results[0];
-        if (!diff) {
-          return Promise.resolve();
+      const needsSyntaxHighlighting = !!event.detail?.contentRendered;
+      if (needsSyntaxHighlighting) {
+        this.reporting.time(TimingLabel.SYNTAX);
+        try {
+          await this.$.syntaxLayer.process();
+        } finally {
+          this.reporting.timeEnd(TimingLabel.SYNTAX);
         }
-        this.filesWeblinks = this._getFilesWeblinks(diff);
-        return new Promise(resolve => {
-          const callback = (event: CustomEvent) => {
-            const needsSyntaxHighlighting =
-              event.detail && event.detail.contentRendered;
-            if (needsSyntaxHighlighting) {
-              this.reporting.time(TimingLabel.SYNTAX);
-              this.$.syntaxLayer.process().finally(() => {
-                this.reporting.timeEnd(TimingLabel.SYNTAX);
-                this.reporting.timeEnd(TimingLabel.TOTAL);
-                resolve();
-              });
-            } else {
-              this.reporting.timeEnd(TimingLabel.TOTAL);
-              resolve();
-            }
-            this.removeEventListener('render', callback);
-            if (shouldReportMetric) {
-              // We report diffViewContentDisplayed only on reload caused
-              // by params changed - expected only on Diff Page.
-              this.reporting.diffViewContentDisplayed();
-            }
-          };
-          this.addEventListener('render', callback);
-          this.diff = diff;
-        });
-      })
-      .catch(err => {
-        console.warn('Error encountered loading diff:', err);
-      })
-      .then(() => {
-        this._loading = false;
-      });
+      }
+    } catch (e) {
+      if (e instanceof Response) {
+        this._handleGetDiffError(e);
+      } else {
+        console.warn('Error encountered loading diff:', e);
+      }
+    } finally {
+      this.reporting.timeEnd(TimingLabel.TOTAL);
+    }
+  }
+
+  private _getLayers(path: string, changeNum: NumericChangeId): DiffLayer[] {
+    // Get layers from plugins (if any).
+    return [this.$.syntaxLayer, ...this.$.jsAPI.getDiffLayers(path, changeNum)];
+  }
+
+  private _onRenderOnce(): Promise<CustomEvent> {
+    return new Promise<CustomEvent>(resolve => {
+      const callback = (event: CustomEvent) => {
+        this.removeEventListener('render', callback);
+        resolve(event);
+      };
+      this.addEventListener('render', callback);
+    });
   }
 
   clear() {
@@ -427,12 +407,12 @@
     const changeNum = this.changeNum;
     const path = this.path;
     // Coverage providers do not provide data for EDIT and PARENT patch sets.
-    const basePatchNum = isNumber(this.patchRange.basePatchNum)
-      ? this.patchRange.basePatchNum
-      : undefined;
-    const patchNum = isNumber(this.patchRange.patchNum)
-      ? this.patchRange.patchNum
-      : undefined;
+
+    const toNumberOnly = (patchNum: PatchSetNum) =>
+      isNumber(patchNum) ? patchNum : undefined;
+
+    const basePatchNum = toNumberOnly(this.patchRange.basePatchNum);
+    const patchNum = toNumberOnly(this.patchRange.patchNum);
     this.$.jsAPI
       .getCoverageAnnotationApi()
       .then(coverageAnnotationApi => {
@@ -446,8 +426,8 @@
               !coverageRanges ||
               changeNum !== this.changeNum ||
               path !== this.path ||
-              basePatchNum !== this.patchRange.basePatchNum ||
-              patchNum !== this.patchRange.patchNum
+              basePatchNum !== toNumberOnly(this.patchRange.basePatchNum) ||
+              patchNum !== toNumberOnly(this.patchRange.patchNum)
             ) {
               return;
             }
@@ -887,7 +867,9 @@
     }
     function matchesRange(threadEl: GrCommentThread) {
       const rangeAtt = threadEl.getAttribute('range');
-      const threadRange = rangeAtt ? JSON.parse(rangeAtt) : undefined;
+      const threadRange = rangeAtt
+        ? (JSON.parse(rangeAtt) as CommentRange)
+        : undefined;
       return rangesEqual(threadRange, range);
     }
 
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_html.ts b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_html.ts
index aae7808..d1564b0 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_html.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_html.ts
@@ -24,7 +24,6 @@
     patch-range="[[patchRange]]"
     path="[[path]]"
     prefs="[[prefs]]"
-    project-name="[[projectName]]"
     display-line="[[displayLine]]"
     is-image-diff="[[isImageDiff]]"
     hidden$="[[hidden]]"
@@ -33,7 +32,6 @@
     view-mode="[[viewMode]]"
     line-of-interest="[[lineOfInterest]]"
     logged-in="[[_loggedIn]]"
-    loading="[[_loading]]"
     error-message="[[_errorMessage]]"
     base-image="[[_baseImage]]"
     revision-image="[[_revisionImage]]"
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 ac96ba2..cd2c860 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,7 +20,7 @@
 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 '../gr-comment-api/gr-comment-api.js';
+import {sortComments} from '../../../utils/comment-util.js';
 import {Side} from '../../../constants/constants.js';
 
 const basicFixture = fixtureFromElement('gr-diff-host');
@@ -311,7 +311,7 @@
           'Diff Content Render'));
     });
 
-    test('ends total and syntax timer after syntax layer processing', done => {
+    test('ends total and syntax timer after syntax layer', async () => {
       sinon.stub(element.reporting, 'diffViewContentDisplayed');
       let notifySyntaxProcessed;
       sinon.stub(element.$.syntaxLayer, 'process').returns(new Promise(
@@ -326,39 +326,34 @@
         return element.reload(true);
       });
       // Multiple cascading microtasks are scheduled.
-      setTimeout(() => {
-        notifySyntaxProcessed();
-        // Assert after the notification task is processed.
-        Promise.resolve().then(() => {
-          assert.isTrue(element.reporting.timeEnd.calledWithExactly(
-              'Diff Total Render'));
-          assert.isTrue(element.reporting.timeEnd.calledWithExactly(
-              'Diff Syntax Render'));
-          assert.isTrue(element.reporting.diffViewContentDisplayed.called);
-          done();
-        });
-      });
+      await flush();
+      notifySyntaxProcessed();
+      // Multiple cascading microtasks are scheduled.
+      await flush();
+      assert.isTrue(element.reporting.timeEnd.calledWithExactly(
+          'Diff Total Render'));
+      assert.isTrue(element.reporting.timeEnd.calledWithExactly(
+          'Diff Syntax Render'));
+      assert.isTrue(element.reporting.diffViewContentDisplayed.called);
     });
 
-    test('ends total timer w/ no syntax layer processing', done => {
+    test('ends total timer w/ no syntax layer processing', async () => {
       sinon.stub(element.$.restAPI, 'getDiff').returns(
           Promise.resolve({content: []}));
       element.patchRange = {};
       element.reload();
       // Multiple cascading microtasks are scheduled.
-      setTimeout(() => {
-        // Reporting can be called with other parameters (ex. PluginsLoaded),
-        // but only 'Diff Total Render' is important in this test.
-        assert.equal(
-            element.reporting.timeEnd.getCalls()
-                .filter(call => call.calledWithExactly('Diff Total Render'))
-                .length,
-            1);
-        done();
-      });
+      await flush();
+      // Reporting can be called with other parameters (ex. PluginsLoaded),
+      // but only 'Diff Total Render' is important in this test.
+      assert.equal(
+          element.reporting.timeEnd.getCalls()
+              .filter(call => call.calledWithExactly('Diff Total Render'))
+              .length,
+          1);
     });
 
-    test('completes reload promise after syntax layer processing', done => {
+    test('completes reload promise after syntax layer processing', async () => {
       let notifySyntaxProcessed;
       sinon.stub(element.$.syntaxLayer, 'process').returns(new Promise(
           resolve => {
@@ -377,15 +372,12 @@
             reloadComplete = true;
           });
       // Multiple cascading microtasks are scheduled.
-      setTimeout(() => {
-        assert.isFalse(reloadComplete);
-        notifySyntaxProcessed();
-        // Assert after the notification task is processed.
-        setTimeout(() => {
-          assert.isTrue(reloadComplete);
-          done();
-        });
-      });
+      await flush();
+      assert.isFalse(reloadComplete);
+      notifySyntaxProcessed();
+      // Assert after the notification task is processed.
+      await flush();
+      assert.isTrue(reloadComplete);
     });
   });
 
@@ -396,6 +388,13 @@
     sinon.stub(element, '_getDiff').callsFake(() => new Promise(() => {}));
     element.patchRange = {};
 
+    // Needs to be set to something first for it to cancel.
+    element.diff = {
+      content: [{
+        a: ['foo'],
+      }],
+    };
+
     element.reload();
     assert.isTrue(cancelStub.called);
   });
@@ -468,9 +467,9 @@
 
     test('reload resolves on error', () => {
       const onErrStub = sinon.stub(element, '_handleGetDiffError');
-      const error = {ok: false, status: 500};
+      const error = new Response(null, {ok: false, status: 500});
       sinon.stub(element.$.restAPI, 'getDiff').callsFake(
-          (changeNum, basePatchNum, patchNum, path, onErr) => {
+          (changeNum, basePatchNum, patchNum, path, whitespace, onErr) => {
             onErr(error);
           });
       element.patchRange = {};
@@ -832,7 +831,7 @@
   test('delegates cancel()', () => {
     const stub = sinon.stub(element.$.diff, 'cancel');
     element.patchRange = {};
-    element.reload();
+    element.cancel();
     assert.isTrue(stub.calledOnce);
     assert.equal(stub.lastCall.args.length, 0);
   });
@@ -981,12 +980,6 @@
     assert.equal(element.$.diff.changeNum, 12345);
   });
 
-  test('passes in projectName', () => {
-    const value = 'Gerrit';
-    element.projectName = value;
-    assert.equal(element.$.diff.projectName, value);
-  });
-
   test('passes in displayLine', () => {
     const value = true;
     element.displayLine = value;
@@ -1473,18 +1466,16 @@
       assert.isFalse(element.$.syntaxLayer.enabled);
     });
 
-    test('starts syntax layer processing on render event', done => {
+    test('starts syntax layer processing on render event', async () => {
       sinon.stub(element.$.syntaxLayer, 'process')
           .returns(Promise.resolve());
       sinon.stub(element.$.restAPI, 'getDiff').returns(
           Promise.resolve({content: []}));
       element.reload();
-      setTimeout(() => {
-        element.dispatchEvent(
-            new CustomEvent('render', {bubbles: true, composed: true}));
-        assert.isTrue(element.$.syntaxLayer.process.called);
-        done();
-      });
+      await flush();
+      element.dispatchEvent(
+          new CustomEvent('render', {bubbles: true, composed: true}));
+      assert.isTrue(element.$.syntaxLayer.process.called);
     });
   });
 
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.js b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.js
index 57a9e97..4bfa6a9 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.js
@@ -52,6 +52,7 @@
   isMagicPath, specialFilePathCompare,
 } from '../../../utils/path-list-util.js';
 import {changeBaseURL, changeIsOpen} from '../../../utils/change-util.js';
+import {Side} from '../../../constants/constants.js';
 
 const ERR_REVIEW_STATUS = 'Couldn’t change file review status.';
 const MSG_LOADING_BLAME = 'Loading blame...';
@@ -1218,8 +1219,8 @@
     if (!this._change) { 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 === 'LEFT' || detail.side === 'PARENT';
+    // for on-line-selected side can be left/right
+    const leftSide = detail.side === Side.LEFT || detail.side === 'PARENT';
     const url = GerritNav.getUrlForDiffById(this._changeNum,
         this._change.project, this._path, this._patchRange.patchNum,
         this._patchRange.basePatchNum, number, leftSide);
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 5429ea2..b63af54 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
@@ -1282,6 +1282,29 @@
 
       assert.isTrue(replaceStateStub.called);
       assert.isTrue(getUrlStub.called);
+      assert.isFalse(getUrlStub.lastCall.args[6]);
+    });
+
+    test('line selected on left side', () => {
+      const getUrlStub = sinon.stub(GerritNav, 'getUrlForDiffById');
+      const replaceStateStub = sinon.stub(history, 'replaceState');
+      sinon.stub(element.$.cursor, 'getAddress')
+          .returns({number: 123, isLeftSide: true});
+
+      element._changeNum = 321;
+      element._change = {_number: 321, project: 'foo/bar'};
+      element._patchRange = {
+        basePatchNum: '3',
+        patchNum: '5',
+      };
+      const e = {};
+      const detail = {number: 123, side: 'left'};
+
+      element._onLineSelected(e, detail);
+
+      assert.isTrue(replaceStateStub.called);
+      assert.isTrue(getUrlStub.called);
+      assert.isTrue(getUrlStub.lastCall.args[6]);
     });
 
     test('_getDiffViewMode', () => {
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 159c056..7c317bb 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.ts
@@ -164,9 +164,6 @@
   @property({type: Object, observer: '_prefsObserver'})
   prefs?: DiffPreferencesInfo;
 
-  @property({type: String})
-  projectName?: string;
-
   @property({type: Boolean})
   displayLine = false;
 
@@ -194,8 +191,9 @@
   @property({type: Object})
   lineOfInterest?: LineOfInterest;
 
-  @property({type: Boolean, observer: '_loadingChanged'})
-  loading = false;
+  /** True when diff is changed, until the content is done rendering. */
+  @property({type: Boolean})
+  _loading = false;
 
   @property({type: Boolean})
   loggedIn = false;
@@ -458,12 +456,14 @@
     this.cancelDebouncer(RENDER_DIFF_TABLE_DEBOUNCE_NAME);
   }
 
-  getCursorStops() {
+  getCursorStops(): HTMLElement[] {
     if (this.hidden && this.noAutoRender) return [];
     if (!this.root) return [];
 
     return Array.from(
-      this.root.querySelectorAll(':not(.contextControl) > .diff-row')
+      this.root.querySelectorAll<HTMLElement>(
+        ':not(.contextControl) > .diff-row'
+      )
     ).filter(tr => tr.querySelector('button'));
   }
 
@@ -788,12 +788,6 @@
     this.clearDiffContent();
   }
 
-  _loadingChanged(newValue?: boolean) {
-    if (newValue) {
-      this._cleanup();
-    }
-  }
-
   _lineWrappingObserver() {
     this._prefsChanged(this.prefs);
   }
@@ -832,8 +826,9 @@
   }
 
   _diffChanged(newValue?: DiffInfo) {
+    this._loading = true;
+    this._cleanup();
     if (newValue) {
-      this._cleanup();
       this._diffLength = this.getDiffLength(newValue);
       this._debounceRenderDiffTable();
     }
@@ -891,6 +886,7 @@
   }
 
   _handleRenderContent() {
+    this._loading = false;
     this._unobserveIncrementalNodes();
     this._incrementalNodeObserver = (dom(
       this
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 08f5cd3..e9de9e7 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
@@ -428,7 +428,6 @@
           id="diffBuilder"
           comment-ranges="[[_commentRanges]]"
           coverage-ranges="[[coverageRanges]]"
-          project-name="[[projectName]]"
           diff="[[diff]]"
           path="[[path]]"
           change-num="[[changeNum]]"
@@ -447,7 +446,7 @@
 
           <template
             is="dom-if"
-            if="[[showNoChangeMessage(loading, prefs, _diffLength, diff)]]"
+            if="[[showNoChangeMessage(_loading, prefs, _diffLength, diff)]]"
           >
             <div class="whitespace-change-only-message">
               This file only contains whitespace changes. Modify the whitespace
@@ -458,7 +457,7 @@
       </gr-diff-highlight>
     </gr-diff-selection>
   </div>
-  <div class$="[[_computeNewlineWarningClass(_newlineWarning, loading)]]">
+  <div class$="[[_computeNewlineWarningClass(_newlineWarning, _loading)]]">
     [[_newlineWarning]]
   </div>
   <div id="loadingError" class$="[[_computeErrorClass(errorMessage)]]">
diff --git a/polygerrit-ui/app/elements/gr-app-element.js b/polygerrit-ui/app/elements/gr-app-element.js
index e5d424c..b67bea0 100644
--- a/polygerrit-ui/app/elements/gr-app-element.js
+++ b/polygerrit-ui/app/elements/gr-app-element.js
@@ -419,6 +419,7 @@
     this.set('_showChangeView', view === GerritNav.View.CHANGE);
     this.set('_showDiffView', view === GerritNav.View.DIFF);
     this.set('_showSettingsView', view === GerritNav.View.SETTINGS);
+    // _showAdminView must be in sync with the gr-admin-view AdminViewParams type
     this.set('_showAdminView', view === GerritNav.View.ADMIN ||
         view === GerritNav.View.GROUP || view === GerritNav.View.REPO);
     this.set('_showCLAView', view === GerritNav.View.AGREEMENTS);
diff --git a/polygerrit-ui/app/elements/gr-app-types.ts b/polygerrit-ui/app/elements/gr-app-types.ts
index ef382ad..aee5bcb 100644
--- a/polygerrit-ui/app/elements/gr-app-types.ts
+++ b/polygerrit-ui/app/elements/gr-app-types.ts
@@ -20,7 +20,7 @@
   GroupDetailView,
   RepoDetailView,
 } from './core/gr-navigation/gr-navigation';
-import {GroupId, RepoName} from '../types/common';
+import {DashboardId, GroupId, RepoName} from '../types/common';
 
 export interface AppElement extends HTMLElement {
   params: AppElementParams | GenerateUrlParameters;
@@ -32,9 +32,9 @@
 export interface AppElementDashboardParams {
   view: GerritView.DASHBOARD;
   project?: RepoName;
-  dashboard?: string;
+  dashboard: DashboardId;
   user?: string;
-  sections?: Array<{name: string; query: string}>;
+  sections: Array<{name: string; query: string}>;
   title?: string;
 }
 
@@ -87,6 +87,12 @@
 }
 
 export interface AppElementJustRegisteredParams {
+  // We use params.view === ... as a type guard.
+  // The view?: never tells to the compiler that
+  // AppElementJustRegisteredParams can't have view property.
+  // Otherwise, the compiler reports an error when the code tries to use
+  // the property 'view' of AppElementParams.
+  view?: never;
   justRegistered: true;
 }
 
diff --git a/polygerrit-ui/app/elements/plugins/gr-change-metadata-api/gr-change-metadata-api.ts b/polygerrit-ui/app/elements/plugins/gr-change-metadata-api/gr-change-metadata-api.ts
index d3452dc..322d32e 100644
--- a/polygerrit-ui/app/elements/plugins/gr-change-metadata-api/gr-change-metadata-api.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-change-metadata-api/gr-change-metadata-api.ts
@@ -30,8 +30,7 @@
     this._hook = this.plugin.hook('change-metadata-item');
   }
 
-  // eslint-disable-next-line @typescript-eslint/no-explicit-any
-  onLabelsChanged(callback: (value: any) => void) {
+  onLabelsChanged(callback: (value: unknown) => void) {
     if (!this._hook) {
       this._createHook();
     }
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.js b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.js
deleted file mode 100644
index 06d9183..0000000
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.js
+++ /dev/null
@@ -1,506 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 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 '@polymer/iron-input/iron-input.js';
-import '@polymer/paper-toggle-button/paper-toggle-button.js';
-import '../../../styles/gr-form-styles.js';
-import '../../../styles/gr-menu-page-styles.js';
-import '../../../styles/gr-page-nav-styles.js';
-import '../../../styles/shared-styles.js';
-import {applyTheme as applyDarkTheme, removeTheme as removeDarkTheme} from '../../../styles/themes/dark-theme.js';
-import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator.js';
-import '../gr-change-table-editor/gr-change-table-editor.js';
-import '../../shared/gr-button/gr-button.js';
-import '../../shared/gr-date-formatter/gr-date-formatter.js';
-import '../../shared/gr-diff-preferences/gr-diff-preferences.js';
-import '../../shared/gr-page-nav/gr-page-nav.js';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
-import '../../shared/gr-select/gr-select.js';
-import '../gr-account-info/gr-account-info.js';
-import '../gr-agreements-list/gr-agreements-list.js';
-import '../gr-edit-preferences/gr-edit-preferences.js';
-import '../gr-email-editor/gr-email-editor.js';
-import '../gr-gpg-editor/gr-gpg-editor.js';
-import '../gr-group-list/gr-group-list.js';
-import '../gr-http-password/gr-http-password.js';
-import '../gr-identities/gr-identities.js';
-import '../gr-menu-editor/gr-menu-editor.js';
-import '../gr-ssh-editor/gr-ssh-editor.js';
-import '../gr-watched-projects-editor/gr-watched-projects-editor.js';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
-import {PolymerElement} from '@polymer/polymer/polymer-element.js';
-import {htmlTemplate} from './gr-settings-view_html.js';
-import {getDocsBaseUrl} from '../../../utils/url-util.js';
-import {ChangeTableMixin} from '../../../mixins/gr-change-table-mixin/gr-change-table-mixin.js';
-
-const PREFS_SECTION_FIELDS = [
-  'changes_per_page',
-  'date_format',
-  'time_format',
-  'email_strategy',
-  'diff_view',
-  'publish_comments_on_push',
-  'work_in_progress_by_default',
-  'default_base_for_merges',
-  'signed_off_by',
-  'email_format',
-  'size_bar_in_change_table',
-  'relative_date_in_change_table',
-];
-
-const GERRIT_DOCS_BASE_URL = 'https://gerrit-review.googlesource.com/' +
-    'Documentation';
-const GERRIT_DOCS_FILTER_PATH = '/user-notify.html';
-const ABSOLUTE_URL_PATTERN = /^https?:/;
-const TRAILING_SLASH_PATTERN = /\/$/;
-
-const HTTP_AUTH = [
-  'HTTP',
-  'HTTP_LDAP',
-];
-
-/**
- * @extends PolymerElement
- */
-class GrSettingsView extends ChangeTableMixin(GestureEventListeners(
-    LegacyElementMixin(
-        PolymerElement))) {
-  static get template() { return htmlTemplate; }
-
-  static get is() { return 'gr-settings-view'; }
-  /**
-   * Fired when the title of the page should change.
-   *
-   * @event title-change
-   */
-
-  /**
-   * Fired with email confirmation text, or when the page reloads.
-   *
-   * @event show-alert
-   */
-
-  static get properties() {
-    return {
-      prefs: {
-        type: Object,
-        value() { return {}; },
-      },
-      params: {
-        type: Object,
-        value() { return {}; },
-      },
-      _accountInfoChanged: Boolean,
-      _changeTableColumnsNotDisplayed: Array,
-      /** @type {?} */
-      _localPrefs: {
-        type: Object,
-        value() { return {}; },
-      },
-      _localChangeTableColumns: {
-        type: Array,
-        value() { return []; },
-      },
-      _localMenu: {
-        type: Array,
-        value() { return []; },
-      },
-      _loading: {
-        type: Boolean,
-        value: true,
-      },
-      _changeTableChanged: {
-        type: Boolean,
-        value: false,
-      },
-      _prefsChanged: {
-        type: Boolean,
-        value: false,
-      },
-      /** @type {?} */
-      _diffPrefsChanged: Boolean,
-      /** @type {?} */
-      _editPrefsChanged: Boolean,
-      _menuChanged: {
-        type: Boolean,
-        value: false,
-      },
-      _watchedProjectsChanged: {
-        type: Boolean,
-        value: false,
-      },
-      _keysChanged: {
-        type: Boolean,
-        value: false,
-      },
-      _gpgKeysChanged: {
-        type: Boolean,
-        value: false,
-      },
-      _newEmail: String,
-      _addingEmail: {
-        type: Boolean,
-        value: false,
-      },
-      _lastSentVerificationEmail: {
-        type: String,
-        value: null,
-      },
-      /** @type {?} */
-      _serverConfig: Object,
-      /** @type {?string} */
-      _docsBaseUrl: String,
-      _emailsChanged: Boolean,
-
-      /**
-       * For testing purposes.
-       */
-      _loadingPromise: Object,
-
-      _showNumber: Boolean,
-
-      _isDark: {
-        type: Boolean,
-        value: false,
-      },
-    };
-  }
-
-  static get observers() {
-    return [
-      '_handlePrefsChanged(_localPrefs.*)',
-      '_handleMenuChanged(_localMenu.splices)',
-      '_handleChangeTableChanged(_localChangeTableColumns, _showNumber)',
-    ];
-  }
-
-  /** @override */
-  attached() {
-    super.attached();
-    // 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,
-    }));
-
-    this._isDark = !!window.localStorage.getItem('dark-theme');
-
-    const promises = [
-      this.$.accountInfo.loadData(),
-      this.$.watchedProjectsEditor.loadData(),
-      this.$.groupList.loadData(),
-      this.$.identities.loadData(),
-      this.$.editPrefs.loadData(),
-      this.$.diffPrefs.loadData(),
-    ];
-
-    promises.push(this.$.restAPI.getPreferences().then(prefs => {
-      this.prefs = prefs;
-      this._showNumber = !!prefs.legacycid_in_change_table;
-      this._copyPrefs('_localPrefs', 'prefs');
-      this._cloneMenu(prefs.my);
-      this._cloneChangeTableColumns();
-    }));
-
-    promises.push(this.$.restAPI.getConfig().then(config => {
-      this._serverConfig = config;
-      const configPromises = [];
-
-      if (this._serverConfig && this._serverConfig.sshd) {
-        configPromises.push(this.$.sshEditor.loadData());
-      }
-
-      if (this._serverConfig &&
-          this._serverConfig.receive &&
-          this._serverConfig.receive.enable_signed_push) {
-        configPromises.push(this.$.gpgEditor.loadData());
-      }
-
-      configPromises.push(
-          getDocsBaseUrl(config, this.$.restAPI)
-              .then(baseUrl => { this._docsBaseUrl = baseUrl; }));
-
-      return Promise.all(configPromises);
-    }));
-
-    if (this.params.emailToken) {
-      promises.push(this.$.restAPI.confirmEmail(this.params.emailToken).then(
-          message => {
-            if (message) {
-              this.dispatchEvent(new CustomEvent('show-alert', {
-                detail: {message},
-                composed: true, bubbles: true,
-              }));
-            }
-            this.$.emailEditor.loadData();
-          }));
-    } else {
-      promises.push(this.$.emailEditor.loadData());
-    }
-
-    this._loadingPromise = Promise.all(promises).then(() => {
-      this._loading = false;
-
-      // Handle anchor tag for initial load
-      this._handleLocationChange();
-    });
-  }
-
-  /** @override */
-  detached() {
-    super.detached();
-    this.unlisten(window, 'location-change', '_handleLocationChange');
-  }
-
-  _handleLocationChange() {
-    // Handle anchor tag after dom attached
-    const urlHash = window.location.hash;
-    if (urlHash) {
-      // Use shadowRoot for Polymer 2
-      const elem = (this.shadowRoot || document).querySelector(urlHash);
-      if (elem) {
-        elem.scrollIntoView();
-      }
-    }
-  }
-
-  reloadAccountDetail() {
-    Promise.all([
-      this.$.accountInfo.loadData(),
-      this.$.emailEditor.loadData(),
-    ]);
-  }
-
-  _isLoading() {
-    return this._loading || this._loading === undefined;
-  }
-
-  _copyPrefs(to, from) {
-    for (let i = 0; i < PREFS_SECTION_FIELDS.length; i++) {
-      this.set([to, PREFS_SECTION_FIELDS[i]],
-          this[from][PREFS_SECTION_FIELDS[i]]);
-    }
-  }
-
-  _cloneMenu(prefs) {
-    const menu = [];
-    for (const item of prefs) {
-      menu.push({
-        name: item.name,
-        url: item.url,
-        target: item.target,
-      });
-    }
-    this._localMenu = menu;
-  }
-
-  _cloneChangeTableColumns() {
-    let columns = this.getVisibleColumns(this.prefs.change_table);
-
-    if (columns.length === 0) {
-      columns = this.columnNames;
-      this._changeTableColumnsNotDisplayed = [];
-    } else {
-      this._changeTableColumnsNotDisplayed = this.getComplementColumns(
-          this.prefs.change_table);
-    }
-    this._localChangeTableColumns = columns;
-  }
-
-  _formatChangeTableColumns(changeTableArray) {
-    return changeTableArray.map(item => {
-      return {column: item};
-    });
-  }
-
-  _handleChangeTableChanged() {
-    if (this._isLoading()) { return; }
-    this._changeTableChanged = true;
-  }
-
-  _handlePrefsChanged(prefs) {
-    if (this._isLoading()) { return; }
-    this._prefsChanged = true;
-  }
-
-  _handleRelativeDateInChangeTable() {
-    this.set('_localPrefs.relative_date_in_change_table',
-        this.$.relativeDateInChangeTable.checked);
-  }
-
-  _handleShowSizeBarsInFileListChanged() {
-    this.set('_localPrefs.size_bar_in_change_table',
-        this.$.showSizeBarsInFileList.checked);
-  }
-
-  _handlePublishCommentsOnPushChanged() {
-    this.set('_localPrefs.publish_comments_on_push',
-        this.$.publishCommentsOnPush.checked);
-  }
-
-  _handleWorkInProgressByDefault() {
-    this.set('_localPrefs.work_in_progress_by_default',
-        this.$.workInProgressByDefault.checked);
-  }
-
-  _handleInsertSignedOff() {
-    this.set('_localPrefs.signed_off_by', this.$.insertSignedOff.checked);
-  }
-
-  _handleMenuChanged() {
-    if (this._isLoading()) { return; }
-    this._menuChanged = true;
-  }
-
-  _handleSaveAccountInfo() {
-    this.$.accountInfo.save();
-  }
-
-  _handleSavePreferences() {
-    this._copyPrefs('prefs', '_localPrefs');
-
-    return this.$.restAPI.savePreferences(this.prefs).then(() => {
-      this._prefsChanged = false;
-    });
-  }
-
-  _handleSaveChangeTable() {
-    this.set('prefs.change_table', this._localChangeTableColumns);
-    this.set('prefs.legacycid_in_change_table', this._showNumber);
-    this._cloneChangeTableColumns();
-    return this.$.restAPI.savePreferences(this.prefs).then(() => {
-      this._changeTableChanged = false;
-    });
-  }
-
-  _handleSaveDiffPreferences() {
-    this.$.diffPrefs.save();
-  }
-
-  _handleSaveEditPreferences() {
-    this.$.editPrefs.save();
-  }
-
-  _handleSaveMenu() {
-    this.set('prefs.my', this._localMenu);
-    this._cloneMenu(this.prefs.my);
-    return this.$.restAPI.savePreferences(this.prefs).then(() => {
-      this._menuChanged = false;
-    });
-  }
-
-  _handleResetMenuButton() {
-    return this.$.restAPI.getDefaultPreferences().then(data => {
-      if (data && data.my) {
-        this._cloneMenu(data.my);
-      }
-    });
-  }
-
-  _handleSaveWatchedProjects() {
-    this.$.watchedProjectsEditor.save();
-  }
-
-  _computeHeaderClass(changed) {
-    return changed ? 'edited' : '';
-  }
-
-  _handleSaveEmails() {
-    this.$.emailEditor.save();
-  }
-
-  _handleNewEmailKeydown(e) {
-    if (e.keyCode === 13) { // Enter
-      e.stopPropagation();
-      this._handleAddEmailButton();
-    }
-  }
-
-  _isNewEmailValid(newEmail) {
-    return newEmail && newEmail.includes('@');
-  }
-
-  _computeAddEmailButtonEnabled(newEmail, addingEmail) {
-    return this._isNewEmailValid(newEmail) && !addingEmail;
-  }
-
-  _handleAddEmailButton() {
-    if (!this._isNewEmailValid(this._newEmail)) { return; }
-
-    this._addingEmail = true;
-    this.$.restAPI.addAccountEmail(this._newEmail).then(response => {
-      this._addingEmail = false;
-
-      // If it was unsuccessful.
-      if (response.status < 200 || response.status >= 300) { return; }
-
-      this._lastSentVerificationEmail = this._newEmail;
-      this._newEmail = '';
-    });
-  }
-
-  _getFilterDocsLink(docsBaseUrl) {
-    let base = docsBaseUrl;
-    if (!base || !ABSOLUTE_URL_PATTERN.test(base)) {
-      base = GERRIT_DOCS_BASE_URL;
-    }
-
-    // Remove any trailing slash, since it is in the GERRIT_DOCS_FILTER_PATH.
-    base = base.replace(TRAILING_SLASH_PATTERN, '');
-
-    return base + GERRIT_DOCS_FILTER_PATH;
-  }
-
-  _handleToggleDark() {
-    if (this._isDark) {
-      window.localStorage.removeItem('dark-theme');
-      removeDarkTheme();
-    } else {
-      window.localStorage.setItem('dark-theme', 'true');
-      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,
-    }));
-  }
-
-  _showHttpAuth(config) {
-    if (config && config.auth &&
-        config.auth.git_basic_auth_policy) {
-      return HTTP_AUTH.includes(
-          config.auth.git_basic_auth_policy.toUpperCase());
-    }
-
-    return false;
-  }
-
-  /**
-   * Work around a issue on iOS when clicking turns into double tap
-   */
-  _onTapDarkToggle(e) {
-    e.preventDefault();
-  }
-}
-
-customElements.define(GrSettingsView.is, GrSettingsView);
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
new file mode 100644
index 0000000..cd0d7b8
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.ts
@@ -0,0 +1,578 @@
+/**
+ * @license
+ * Copyright (C) 2016 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 '@polymer/iron-input/iron-input';
+import '@polymer/paper-toggle-button/paper-toggle-button';
+import '../../../styles/gr-form-styles';
+import '../../../styles/gr-menu-page-styles';
+import '../../../styles/gr-page-nav-styles';
+import '../../../styles/shared-styles';
+import {
+  applyTheme as applyDarkTheme,
+  removeTheme as removeDarkTheme,
+} from '../../../styles/themes/dark-theme';
+import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
+import '../gr-change-table-editor/gr-change-table-editor';
+import '../../shared/gr-button/gr-button';
+import '../../shared/gr-date-formatter/gr-date-formatter';
+import '../../shared/gr-diff-preferences/gr-diff-preferences';
+import '../../shared/gr-page-nav/gr-page-nav';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../../shared/gr-select/gr-select';
+import '../gr-account-info/gr-account-info';
+import '../gr-agreements-list/gr-agreements-list';
+import '../gr-edit-preferences/gr-edit-preferences';
+import '../gr-email-editor/gr-email-editor';
+import '../gr-gpg-editor/gr-gpg-editor';
+import '../gr-group-list/gr-group-list';
+import '../gr-http-password/gr-http-password';
+import '../gr-identities/gr-identities';
+import '../gr-menu-editor/gr-menu-editor';
+import '../gr-ssh-editor/gr-ssh-editor';
+import '../gr-watched-projects-editor/gr-watched-projects-editor';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-settings-view_html';
+import {getDocsBaseUrl} from '../../../utils/url-util';
+import {ChangeTableMixin} from '../../../mixins/gr-change-table-mixin/gr-change-table-mixin';
+import {customElement, property, observe} from '@polymer/decorators';
+import {AppElementParams} from '../../gr-app-types';
+import {GrAccountInfo} from '../gr-account-info/gr-account-info';
+import {GrWatchedProjectsEditor} from '../gr-watched-projects-editor/gr-watched-projects-editor';
+import {GrGroupList} from '../gr-group-list/gr-group-list';
+import {GrIdentities} from '../gr-identities/gr-identities';
+import {GrEditPreferences} from '../gr-edit-preferences/gr-edit-preferences';
+import {GrDiffPreferences} from '../../shared/gr-diff-preferences/gr-diff-preferences';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {
+  PreferencesInput,
+  ServerInfo,
+  TopMenuItemInfo,
+} from '../../../types/common';
+import {GrSshEditor} from '../gr-ssh-editor/gr-ssh-editor';
+import {GrGpgEditor} from '../gr-gpg-editor/gr-gpg-editor';
+import {GerritView} from '../../core/gr-navigation/gr-navigation';
+import {GrEmailEditor} from '../gr-email-editor/gr-email-editor';
+import {CustomKeyboardEvent} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
+
+const PREFS_SECTION_FIELDS: Array<keyof PreferencesInput> = [
+  'changes_per_page',
+  'date_format',
+  'time_format',
+  'email_strategy',
+  'diff_view',
+  'publish_comments_on_push',
+  'work_in_progress_by_default',
+  'default_base_for_merges',
+  'signed_off_by',
+  'email_format',
+  'size_bar_in_change_table',
+  'relative_date_in_change_table',
+];
+
+const GERRIT_DOCS_BASE_URL =
+  'https://gerrit-review.googlesource.com/' + 'Documentation';
+const GERRIT_DOCS_FILTER_PATH = '/user-notify.html';
+const ABSOLUTE_URL_PATTERN = /^https?:/;
+const TRAILING_SLASH_PATTERN = /\/$/;
+
+const HTTP_AUTH = ['HTTP', 'HTTP_LDAP'];
+
+enum CopyPrefsDirection {
+  PrefsToLocalPrefs,
+  LocalPrefsToPrefs,
+}
+
+type LocalMenuItemInfo = Omit<TopMenuItemInfo, 'id'>;
+
+export interface GrSettingsView {
+  $: {
+    restAPI: RestApiService & Element;
+    accountInfo: GrAccountInfo;
+    watchedProjectsEditor: GrWatchedProjectsEditor;
+    groupList: GrGroupList;
+    identities: GrIdentities;
+    editPrefs: GrEditPreferences;
+    diffPrefs: GrDiffPreferences;
+    sshEditor: GrSshEditor;
+    gpgEditor: GrGpgEditor;
+    emailEditor: GrEmailEditor;
+    insertSignedOff: HTMLInputElement;
+    workInProgressByDefault: HTMLInputElement;
+    showSizeBarsInFileList: HTMLInputElement;
+    publishCommentsOnPush: HTMLInputElement;
+    relativeDateInChangeTable: HTMLInputElement;
+  };
+}
+
+@customElement('gr-settings-view')
+export class GrSettingsView extends ChangeTableMixin(
+  GestureEventListeners(LegacyElementMixin(PolymerElement))
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  /**
+   * Fired when the title of the page should change.
+   *
+   * @event title-change
+   */
+
+  /**
+   * Fired with email confirmation text, or when the page reloads.
+   *
+   * @event show-alert
+   */
+
+  @property({type: Object})
+  prefs: PreferencesInput = {};
+
+  @property({type: Object})
+  params?: AppElementParams;
+
+  @property({type: Boolean})
+  _accountInfoChanged?: boolean;
+
+  @property({type: Array})
+  _changeTableColumnsNotDisplayed?: string[];
+
+  @property({type: Object})
+  _localPrefs: PreferencesInput = {};
+
+  @property({type: Array})
+  _localChangeTableColumns: string[] = [];
+
+  @property({type: Array})
+  _localMenu: LocalMenuItemInfo[] = [];
+
+  @property({type: Boolean})
+  _loading = true;
+
+  @property({type: Boolean})
+  _changeTableChanged = false;
+
+  @property({type: Boolean})
+  _prefsChanged = false;
+
+  @property({type: Boolean})
+  _diffPrefsChanged?: boolean;
+
+  @property({type: Boolean})
+  _editPrefsChanged?: boolean;
+
+  @property({type: Boolean})
+  _menuChanged = false;
+
+  @property({type: Boolean})
+  _watchedProjectsChanged = false;
+
+  @property({type: Boolean})
+  _keysChanged = false;
+
+  @property({type: Boolean})
+  _gpgKeysChanged = false;
+
+  @property({type: String})
+  _newEmail?: string;
+
+  @property({type: Boolean})
+  _addingEmail = false;
+
+  @property({type: String})
+  _lastSentVerificationEmail?: string | null = null;
+
+  @property({type: Object})
+  _serverConfig?: ServerInfo;
+
+  @property({type: String})
+  _docsBaseUrl?: string | null;
+
+  @property({type: Boolean})
+  _emailsChanged?: boolean;
+
+  @property({type: Boolean})
+  _showNumber?: boolean;
+
+  @property({type: Boolean})
+  _isDark = false;
+
+  public _testOnly_loadingPromise?: Promise<void>;
+
+  /** @override */
+  attached() {
+    super.attached();
+    // 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,
+      })
+    );
+
+    this._isDark = !!window.localStorage.getItem('dark-theme');
+
+    const promises: Array<Promise<unknown>> = [
+      this.$.accountInfo.loadData(),
+      this.$.watchedProjectsEditor.loadData(),
+      this.$.groupList.loadData(),
+      this.$.identities.loadData(),
+      this.$.editPrefs.loadData(),
+      this.$.diffPrefs.loadData(),
+    ];
+
+    promises.push(
+      this.$.restAPI.getPreferences().then(prefs => {
+        if (!prefs) {
+          throw new Error('getPreferences returned undefined');
+        }
+        this.prefs = prefs;
+        this._showNumber = !!prefs.legacycid_in_change_table;
+        this._copyPrefs(CopyPrefsDirection.PrefsToLocalPrefs);
+        this._cloneMenu(prefs.my);
+        this._cloneChangeTableColumns(prefs.change_table);
+      })
+    );
+
+    promises.push(
+      this.$.restAPI.getConfig().then(config => {
+        this._serverConfig = config;
+        const configPromises: Array<Promise<void>> = [];
+
+        if (this._serverConfig && this._serverConfig.sshd) {
+          configPromises.push(this.$.sshEditor.loadData());
+        }
+
+        if (
+          this._serverConfig &&
+          this._serverConfig.receive &&
+          this._serverConfig.receive.enable_signed_push
+        ) {
+          configPromises.push(this.$.gpgEditor.loadData());
+        }
+
+        configPromises.push(
+          getDocsBaseUrl(config, this.$.restAPI).then(baseUrl => {
+            this._docsBaseUrl = baseUrl;
+          })
+        );
+
+        return Promise.all(configPromises);
+      })
+    );
+
+    if (
+      this.params &&
+      this.params.view === GerritView.SETTINGS &&
+      this.params.emailToken
+    ) {
+      promises.push(
+        this.$.restAPI.confirmEmail(this.params.emailToken).then(message => {
+          if (message) {
+            this.dispatchEvent(
+              new CustomEvent('show-alert', {
+                detail: {message},
+                composed: true,
+                bubbles: true,
+              })
+            );
+          }
+          this.$.emailEditor.loadData();
+        })
+      );
+    } else {
+      promises.push(this.$.emailEditor.loadData());
+    }
+
+    this._testOnly_loadingPromise = Promise.all(promises).then(() => {
+      this._loading = false;
+
+      // Handle anchor tag for initial load
+      this._handleLocationChange();
+    });
+  }
+
+  detached() {
+    super.detached();
+    this.unlisten(window, 'location-change', '_handleLocationChange');
+  }
+
+  _handleLocationChange() {
+    // Handle anchor tag after dom attached
+    const urlHash = window.location.hash;
+    if (urlHash) {
+      // Use shadowRoot for Polymer 2
+      const elem = (this.shadowRoot || document).querySelector(urlHash);
+      if (elem) {
+        elem.scrollIntoView();
+      }
+    }
+  }
+
+  reloadAccountDetail() {
+    Promise.all([this.$.accountInfo.loadData(), this.$.emailEditor.loadData()]);
+  }
+
+  _isLoading() {
+    return this._loading || this._loading === undefined;
+  }
+
+  _copyPrefs(direction: CopyPrefsDirection) {
+    let to;
+    let from;
+    if (direction === CopyPrefsDirection.LocalPrefsToPrefs) {
+      from = this._localPrefs;
+      to = 'prefs';
+    } else {
+      from = this.prefs;
+      to = '_localPrefs';
+    }
+    for (let i = 0; i < PREFS_SECTION_FIELDS.length; i++) {
+      this.set([to, PREFS_SECTION_FIELDS[i]], from[PREFS_SECTION_FIELDS[i]]);
+    }
+  }
+
+  _cloneMenu(prefs: TopMenuItemInfo[]) {
+    const menu = [];
+    for (const item of prefs) {
+      menu.push({
+        name: item.name,
+        url: item.url,
+        target: item.target,
+      });
+    }
+    this._localMenu = menu;
+  }
+
+  _cloneChangeTableColumns(changeTable: string[]) {
+    let columns = this.getVisibleColumns(changeTable);
+
+    if (columns.length === 0) {
+      columns = this.columnNames;
+      this._changeTableColumnsNotDisplayed = [];
+    } else {
+      this._changeTableColumnsNotDisplayed = this.getComplementColumns(
+        changeTable
+      );
+    }
+    this._localChangeTableColumns = columns;
+  }
+
+  @observe('_localChangeTableColumns', '_showNumber')
+  _handleChangeTableChanged() {
+    if (this._isLoading()) {
+      return;
+    }
+    this._changeTableChanged = true;
+  }
+
+  @observe('_localPrefs.*')
+  _handlePrefsChanged() {
+    if (this._isLoading()) {
+      return;
+    }
+    this._prefsChanged = true;
+  }
+
+  _handleRelativeDateInChangeTable() {
+    this.set(
+      '_localPrefs.relative_date_in_change_table',
+      this.$.relativeDateInChangeTable.checked
+    );
+  }
+
+  _handleShowSizeBarsInFileListChanged() {
+    this.set(
+      '_localPrefs.size_bar_in_change_table',
+      this.$.showSizeBarsInFileList.checked
+    );
+  }
+
+  _handlePublishCommentsOnPushChanged() {
+    this.set(
+      '_localPrefs.publish_comments_on_push',
+      this.$.publishCommentsOnPush.checked
+    );
+  }
+
+  _handleWorkInProgressByDefault() {
+    this.set(
+      '_localPrefs.work_in_progress_by_default',
+      this.$.workInProgressByDefault.checked
+    );
+  }
+
+  _handleInsertSignedOff() {
+    this.set('_localPrefs.signed_off_by', this.$.insertSignedOff.checked);
+  }
+
+  @observe('_localMenu.splices')
+  _handleMenuChanged() {
+    if (this._isLoading()) {
+      return;
+    }
+    this._menuChanged = true;
+  }
+
+  _handleSaveAccountInfo() {
+    this.$.accountInfo.save();
+  }
+
+  _handleSavePreferences() {
+    this._copyPrefs(CopyPrefsDirection.LocalPrefsToPrefs);
+
+    return this.$.restAPI.savePreferences(this.prefs).then(() => {
+      this._prefsChanged = false;
+    });
+  }
+
+  _handleSaveChangeTable() {
+    this.set('prefs.change_table', this._localChangeTableColumns);
+    this.set('prefs.legacycid_in_change_table', this._showNumber);
+    this._cloneChangeTableColumns(this._localChangeTableColumns);
+    return this.$.restAPI.savePreferences(this.prefs).then(() => {
+      this._changeTableChanged = false;
+    });
+  }
+
+  _handleSaveDiffPreferences() {
+    this.$.diffPrefs.save();
+  }
+
+  _handleSaveEditPreferences() {
+    this.$.editPrefs.save();
+  }
+
+  _handleSaveMenu() {
+    this.set('prefs.my', this._localMenu);
+    this._cloneMenu(this._localMenu);
+    return this.$.restAPI.savePreferences(this.prefs).then(() => {
+      this._menuChanged = false;
+    });
+  }
+
+  _handleResetMenuButton() {
+    return this.$.restAPI.getDefaultPreferences().then(data => {
+      if (data?.my) {
+        this._cloneMenu(data.my);
+      }
+    });
+  }
+
+  _handleSaveWatchedProjects() {
+    this.$.watchedProjectsEditor.save();
+  }
+
+  _computeHeaderClass(changed?: boolean) {
+    return changed ? 'edited' : '';
+  }
+
+  _handleSaveEmails() {
+    this.$.emailEditor.save();
+  }
+
+  _handleNewEmailKeydown(e: CustomKeyboardEvent) {
+    if (e.keyCode === 13) {
+      // Enter
+      e.stopPropagation();
+      this._handleAddEmailButton();
+    }
+  }
+
+  _isNewEmailValid(newEmail?: string): newEmail is string {
+    return !!newEmail && newEmail.includes('@');
+  }
+
+  _computeAddEmailButtonEnabled(newEmail?: string, addingEmail?: boolean) {
+    return this._isNewEmailValid(newEmail) && !addingEmail;
+  }
+
+  _handleAddEmailButton() {
+    if (!this._isNewEmailValid(this._newEmail)) return;
+
+    this._addingEmail = true;
+    this.$.restAPI.addAccountEmail(this._newEmail).then(response => {
+      this._addingEmail = false;
+
+      // If it was unsuccessful.
+      if (response.status < 200 || response.status >= 300) {
+        return;
+      }
+
+      this._lastSentVerificationEmail = this._newEmail;
+      this._newEmail = '';
+    });
+  }
+
+  _getFilterDocsLink(docsBaseUrl?: string) {
+    let base = docsBaseUrl;
+    if (!base || !ABSOLUTE_URL_PATTERN.test(base)) {
+      base = GERRIT_DOCS_BASE_URL;
+    }
+
+    // Remove any trailing slash, since it is in the GERRIT_DOCS_FILTER_PATH.
+    base = base.replace(TRAILING_SLASH_PATTERN, '');
+
+    return base + GERRIT_DOCS_FILTER_PATH;
+  }
+
+  _handleToggleDark() {
+    if (this._isDark) {
+      window.localStorage.removeItem('dark-theme');
+      removeDarkTheme();
+    } else {
+      window.localStorage.setItem('dark-theme', 'true');
+      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,
+      })
+    );
+  }
+
+  _showHttpAuth(config?: ServerInfo) {
+    if (config && config.auth && config.auth.git_basic_auth_policy) {
+      return HTTP_AUTH.includes(
+        config.auth.git_basic_auth_policy.toUpperCase()
+      );
+    }
+
+    return false;
+  }
+
+  /**
+   * Work around a issue on iOS when clicking turns into double tap
+   */
+  _onTapDarkToggle(e: Event) {
+    e.preventDefault();
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-settings-view': GrSettingsView;
+  }
+}
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_html.ts b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_html.ts
index ea26a51..78f84b1 100644
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_html.ts
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_html.ts
@@ -175,6 +175,9 @@
               <select>
                 <option value="CC_ON_OWN_COMMENTS">Every comment</option>
                 <option value="ENABLED">Only comments left by others</option>
+                <option value="ATTENTION_SET_ONLY"
+                  >Only when I am in the attention set</option
+                >
                 <option value="DISABLED">None</option>
               </select>
             </gr-select>
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.js b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.js
index 1929f4e..0535e15 100644
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.js
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.js
@@ -19,6 +19,7 @@
 import {getComputedStyleValue} from '../../../utils/dom-util.js';
 import './gr-settings-view.js';
 import {flush} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {GerritView} from '../../core/gr-navigation/gr-navigation.js';
 
 const basicFixture = fixtureFromElement('gr-settings-view');
 const blankFixture = fixtureFromElement('div');
@@ -95,7 +96,7 @@
     element = basicFixture.instantiate();
 
     // Allow the element to render.
-    element._loadingPromise.then(done);
+    element._testOnly_loadingPromise.then(done);
   });
 
   test('theme changing', () => {
@@ -485,7 +486,7 @@
           .callsFake(
               () => new Promise(
                   resolve => { resolveConfirm = resolve; }));
-      element.params = {emailToken: 'foo'};
+      element.params = {view: GerritView.SETTINGS, emailToken: 'foo'};
       element.attached();
     });
 
@@ -499,7 +500,7 @@
     });
 
     test('user emails are loaded after email confirmed', done => {
-      element._loadingPromise.then(() => {
+      element._testOnly_loadingPromise.then(() => {
         assert.isTrue(element.$.emailEditor.loadData.calledOnce);
         done();
       });
@@ -508,7 +509,7 @@
 
     test('show-alert is fired when email is confirmed', done => {
       sinon.spy(element, 'dispatchEvent');
-      element._loadingPromise.then(() => {
+      element._testOnly_loadingPromise.then(() => {
         assert.equal(
             element.dispatchEvent.lastCall.args[0].type, 'show-alert');
         assert.deepEqual(
diff --git a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.ts b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.ts
index c1fee2dc..be23cb3 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.ts
+++ b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.ts
@@ -183,6 +183,18 @@
     );
   }
 
+  _computeHasAttentionClass(
+    config: ServerInfo | undefined,
+    highlight: boolean,
+    account: AccountInfo,
+    change: ChangeInfo,
+    force: boolean
+  ) {
+    return this._hasAttention(config, highlight, account, change, force)
+      ? 'hasAttention'
+      : '';
+  }
+
   _computeName(
     account?: AccountInfo,
     config?: ServerInfo,
diff --git a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_html.ts b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_html.ts
index d2b58a6..1d8b13e 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_html.ts
+++ b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_html.ts
@@ -85,6 +85,9 @@
       position: relative;
       top: 2px;
     }
+    .hasAttention .name {
+      font-weight: var(--font-weight-bold);
+    }
   </style>
   <span>
     <template is="dom-if" if="[[!hideHovercard]]">
@@ -113,7 +116,10 @@
       </gr-button>
     </template>
   </span>
-  <span id="hovercardTarget">
+  <span
+    id="hovercardTarget"
+    class$="[[_computeHasAttentionClass(_config, highlightAttention, account, change, forceAttention)]]"
+  >
     <template is="dom-if" if="[[!hideAvatar]]">
       <gr-avatar account="[[account]]" image-size="32"></gr-avatar>
     </template>
diff --git a/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star.ts b/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star.ts
index 0aa077e..1ecaf7f 100644
--- a/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star.ts
+++ b/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star.ts
@@ -30,6 +30,11 @@
   }
 }
 
+export interface ChangeStarToggleStarDetail {
+  change: ChangeInfo;
+  starred: boolean;
+}
+
 @customElement('gr-change-star')
 export class GrChangeStar extends KeyboardShortcutMixin(
   GestureEventListeners(LegacyElementMixin(PolymerElement))
@@ -69,11 +74,15 @@
     }
     const newVal = !this.change.starred;
     this.set('change.starred', newVal);
+    const detail: ChangeStarToggleStarDetail = {
+      change: this.change,
+      starred: newVal,
+    };
     this.dispatchEvent(
       new CustomEvent('toggle-star', {
         bubbles: true,
         composed: true,
-        detail: {change: this.change, starred: newVal},
+        detail,
       })
     );
   }
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 fe6a0fd..a9df3f9 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
@@ -34,7 +34,7 @@
   UIComment,
   UIDraft,
   UIRobot,
-} from '../../diff/gr-comment-api/gr-comment-api';
+} from '../../../utils/comment-util';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 import {appContext} from '../../../services/app-context';
 import {CommentSide, Side, SpecialFilePath} from '../../../constants/constants';
@@ -143,7 +143,7 @@
     notify: true,
     computed: '_computeRootId(comments.*)',
   })
-  rootId?: string;
+  rootId?: UrlEncodedCommentId;
 
   @property({type: Boolean})
   showFilePath = false;
diff --git a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.js b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.js
index 52264ec..1833b73 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.js
@@ -19,7 +19,7 @@
 import './gr-comment-thread.js';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
 import {SpecialFilePath} from '../../../constants/constants.js';
-import {sortComments} from '../../diff/gr-comment-api/gr-comment-api.js';
+import {sortComments} from '../../../utils/comment-util.js';
 
 const basicFixture = fixtureFromElement('gr-comment-thread');
 
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 d68da94..9a1d789 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
@@ -58,7 +58,7 @@
   UIComment,
   UIDraft,
   UIRobot,
-} from '../../diff/gr-comment-api/gr-comment-api';
+} from '../../../utils/comment-util';
 
 const STORAGE_DEBOUNCE_INTERVAL = 400;
 const TOAST_DEBOUNCE_INTERVAL = 200;
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 798f58b..c64dc2a 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
@@ -122,7 +122,13 @@
   _timeFormat?: string;
 
   @property({type: Boolean})
-  _relative?: boolean;
+  _relative = false;
+
+  @property({type: Boolean})
+  forceRelative = false;
+
+  @property({type: Boolean})
+  relativeOptionNoAgo = false;
 
   constructor() {
     super();
@@ -143,7 +149,7 @@
       if (!loggedIn) {
         this._timeFormat = TimeFormats.TIME_24;
         this._dateFormat = DateFormats.STD;
-        this._relative = false;
+        this._relative = this.forceRelative;
         return;
       }
       return Promise.all([this._loadTimeFormat(), this._loadRelative()]);
@@ -198,7 +204,8 @@
   _loadRelative() {
     return this._getPreferences().then(prefs => {
       // prefs.relative_date_in_change_table is not set when false.
-      this._relative = !!(prefs && prefs.relative_date_in_change_table);
+      this._relative =
+        this.forceRelative || !!(prefs && prefs.relative_date_in_change_table);
     });
   }
 
@@ -225,7 +232,7 @@
       return '';
     }
     if (relative) {
-      return fromNow(date);
+      return fromNow(date, this.relativeOptionNoAgo);
     }
     const now = new Date();
     let format = dateFormat.full;
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.ts b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.ts
index 1fadce5..47bad2c 100644
--- a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.ts
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.ts
@@ -47,7 +47,7 @@
   };
 }
 
-interface DropdownLink {
+export interface DropdownLink {
   url?: string;
   name?: string;
   external?: boolean;
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 b3a53c5..da2881e 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
@@ -26,13 +26,26 @@
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-hovercard-account_html';
 import {appContext} from '../../../services/app-context';
-import {isServiceUser} from '../../../utils/account-util';
+import {accountKey} from '../../../utils/account-util';
 import {getDisplayName} from '../../../utils/display-name-util';
 import {customElement, property} from '@polymer/decorators';
 import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
-import {AccountInfo, ChangeInfo, ServerInfo} from '../../../types/common';
+import {
+  AccountInfo,
+  ChangeInfo,
+  ServerInfo,
+  ReviewInput,
+} from '../../../types/common';
 import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
-import {hasOwnProperty} from '../../../utils/common-util';
+import {
+  canHaveAttention,
+  getLastUpdate,
+  getReason,
+  hasAttention,
+  isAttentionSetEnabled,
+} from '../../../utils/attention-set-util';
+import {ReviewerState} from '../../../constants/constants';
+import {isRemovableReviewer} from '../../../utils/change-util';
 
 export interface GrHovercardAccount {
   $: {
@@ -101,70 +114,132 @@
     return account._account_id === selfAccount._account_id ? 'Your' : 'Their';
   }
 
-  get isAttentionSetEnabled() {
+  get isAttentionEnabled() {
     return (
-      !!this._config &&
-      !!this._config.change &&
-      !!this._config.change.enable_attention_set &&
+      isAttentionSetEnabled(this._config) &&
       !!this.highlightAttention &&
       !!this.change &&
-      !!this.account &&
-      !isServiceUser(this.account)
+      canHaveAttention(this.account)
     );
   }
 
-  get hasAttention() {
-    if (
-      !this.isAttentionSetEnabled ||
-      !this.change?.attention_set ||
-      !this.account._account_id
-    )
-      return false;
-    return hasOwnProperty(this.change.attention_set, this.account._account_id);
+  get hasUserAttention() {
+    return hasAttention(this._config, this.account, this.change);
   }
 
   _computeReason(change?: ChangeInfo) {
-    if (!change || !change.attention_set || !this.account._account_id) {
-      return '';
-    }
-    const entry = change.attention_set[this.account._account_id];
-    if (!entry || !entry.reason) return '';
-    return entry.reason;
+    return getReason(this.account, change);
   }
 
   _computeLastUpdate(change?: ChangeInfo) {
-    if (!change || !change.attention_set || !this.account._account_id) {
-      return '';
+    return getLastUpdate(this.account, change);
+  }
+
+  _showReviewerOrCCActions(account?: AccountInfo, change?: ChangeInfo) {
+    return !!this._selfAccount && isRemovableReviewer(change, account);
+  }
+
+  _getReviewerState(account: AccountInfo, change: ChangeInfo) {
+    if (
+      change.reviewers[ReviewerState.REVIEWER]?.some(
+        (reviewer: AccountInfo) => {
+          return reviewer._account_id === account._account_id;
+        }
+      )
+    ) {
+      return ReviewerState.REVIEWER;
     }
-    const entry = change.attention_set[this.account._account_id];
-    if (!entry || !entry.last_update) return '';
-    return entry.last_update;
+    return ReviewerState.CC;
+  }
+
+  _computeReviewerOrCCText(account?: AccountInfo, change?: ChangeInfo) {
+    if (!change || !account) return '';
+    return this._getReviewerState(account, change) === ReviewerState.REVIEWER
+      ? 'Reviewer'
+      : 'CC';
+  }
+
+  _computeChangeReviewerOrCCText(account?: AccountInfo, change?: ChangeInfo) {
+    if (!change || !account) return '';
+    return this._getReviewerState(account, change) === ReviewerState.REVIEWER
+      ? 'Move Reviewer to CC'
+      : 'Move CC to Reviewer';
+  }
+
+  _handleChangeReviewerOrCCStatus() {
+    if (!this.change) throw new Error('expected change object to be present');
+    // accountKey() throws an error if _account_id & email is not found, which
+    // we want to check before showing reloading toast
+    const _accountKey = accountKey(this.account);
+    this.dispatchEventThroughTarget('show-alert', {
+      message: 'Reloading page...',
+    });
+    const reviewInput: Partial<ReviewInput> = {};
+    reviewInput.reviewers = [
+      {
+        reviewer: _accountKey,
+        state:
+          this._getReviewerState(this.account, this.change) === ReviewerState.CC
+            ? ReviewerState.REVIEWER
+            : ReviewerState.CC,
+      },
+    ];
+
+    this.$.restAPI
+      .saveChangeReview(this.change._number, 'current', reviewInput)
+      .then(response => {
+        if (!response || !response.ok) {
+          throw new Error(
+            'something went wrong when toggling' +
+              this._getReviewerState(this.account, this.change!)
+          );
+        }
+        this.dispatchEventThroughTarget('reload', {clearPatchset: true});
+      });
+  }
+
+  _handleRemoveReviewerOrCC() {
+    if (!this.change || !(this.account?._account_id || this.account?.email))
+      throw new Error('Missing change or account.');
+    this.dispatchEventThroughTarget('show-alert', {
+      message: 'Reloading page...',
+    });
+    this.$.restAPI
+      .removeChangeReviewer(
+        this.change._number,
+        (this.account?._account_id || this.account?.email)!
+      )
+      .then((response: Response | undefined) => {
+        if (!response || !response.ok) {
+          throw new Error('something went wrong when removing user');
+        }
+        this.dispatchEventThroughTarget('reload', {clearPatchset: true});
+        return response;
+      });
   }
 
   _computeShowLabelNeedsAttention() {
-    return this.isAttentionSetEnabled && this.hasAttention;
+    return this.isAttentionEnabled && this.hasUserAttention;
   }
 
   _computeShowActionAddToAttentionSet() {
-    return this.isAttentionSetEnabled && !this.hasAttention;
+    return (
+      this._selfAccount && this.isAttentionEnabled && !this.hasUserAttention
+    );
   }
 
   _computeShowActionRemoveFromAttentionSet() {
-    return this.isAttentionSetEnabled && this.hasAttention;
+    return (
+      this._selfAccount && this.isAttentionEnabled && this.hasUserAttention
+    );
   }
 
   _handleClickAddToAttentionSet() {
     if (!this.change || !this.account._account_id) return;
-    this.dispatchEvent(
-      new CustomEvent('show-alert', {
-        detail: {
-          message: 'Saving attention set update ...',
-          dismissOnNavigation: true,
-        },
-        composed: true,
-        bubbles: true,
-      })
-    );
+    this.dispatchEventThroughTarget('show-alert', {
+      message: 'Saving attention set update ...',
+      dismissOnNavigation: true,
+    });
 
     // We are deliberately updating the UI before making the API call. It is a
     // risk that we are taking to achieve a better UX for 99.9% of the cases.
@@ -191,16 +266,10 @@
 
   _handleClickRemoveFromAttentionSet() {
     if (!this.change || !this.account._account_id) return;
-    this.dispatchEvent(
-      new CustomEvent('show-alert', {
-        detail: {
-          message: 'Saving attention set update ...',
-          dismissOnNavigation: true,
-        },
-        composed: true,
-        bubbles: true,
-      })
-    );
+    this.dispatchEventThroughTarget('show-alert', {
+      message: 'Saving attention set update ...',
+      dismissOnNavigation: true,
+    });
 
     // We are deliberately updating the UI before making the API call. It is a
     // risk that we are taking to achieve a better UX for 99.9% of the cases.
diff --git a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_html.ts b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_html.ts
index 6556a94..1d437fb 100644
--- a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_html.ts
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_html.ts
@@ -172,6 +172,28 @@
           </gr-button>
         </div>
       </template>
+      <template is="dom-if" if="[[_showReviewerOrCCActions(account, change)]]">
+        <div class="action">
+          <gr-button
+            class="removeReviewerOrCC"
+            link=""
+            no-uppercase=""
+            on-click="_handleRemoveReviewerOrCC"
+          >
+            Remove [[_computeReviewerOrCCText(account, change)]]
+          </gr-button>
+        </div>
+        <div class="action">
+          <gr-button
+            class="changeReviewerOrCC"
+            link=""
+            no-uppercase=""
+            on-click="_handleChangeReviewerOrCCStatus"
+          >
+            [[_computeChangeReviewerOrCCText(account, change)]]
+          </gr-button>
+        </div>
+      </template>
     </template>
   </div>
   <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
diff --git a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_test.js b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_test.js
index f272507..b09f0ce 100644
--- a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_test.js
@@ -18,6 +18,7 @@
 import '../../../test/common-test-setup-karma.js';
 import './gr-hovercard-account.js';
 import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+import {ReviewerState} from '../../../constants/constants.js';
 
 const basicFixture = fixtureFromTemplate(html`
 <gr-hovercard-account class="hovered"></gr-hovercard-account>
@@ -39,6 +40,7 @@
         new Promise(resolve => { '2'; })
     );
 
+    element._selfAccount = {...ACCOUNT};
     element.account = {...ACCOUNT};
     element._config = {
       change: {enable_attention_set: true},
@@ -59,17 +61,6 @@
         'Kermit The Frog');
   });
 
-  test('_computeReason', () => {
-    const change = {
-      attention_set: {
-        31415926535: {
-          reason: 'a good reason',
-        },
-      },
-    };
-    assert.equal(element._computeReason(change), 'a good reason');
-  });
-
   test('_computeLastUpdate', () => {
     const last_update = '2019-07-17 19:39:02.000000000';
     const change = {
@@ -112,6 +103,104 @@
         element.voteableText);
   });
 
+  test('remove reviewer', async () => {
+    element.change = {
+      removable_reviewers: [ACCOUNT],
+      reviewers: {
+        [ReviewerState.REVIEWER]: [ACCOUNT],
+      },
+    };
+    sinon.stub(element.$.restAPI, 'removeChangeReviewer').returns(
+        Promise.resolve({ok: true}));
+    const reloadListener = sinon.spy();
+    element._target.addEventListener('reload', reloadListener);
+    flush();
+    const button = element.shadowRoot.querySelector('.removeReviewerOrCC');
+    assert.isOk(button);
+    assert.equal(button.innerText, 'Remove Reviewer');
+    MockInteractions.tap(button);
+    await flush();
+    assert.isTrue(reloadListener.called);
+  });
+
+  test('move reviewer to cc', async () => {
+    element.change = {
+      removable_reviewers: [ACCOUNT],
+      reviewers: {
+        [ReviewerState.REVIEWER]: [ACCOUNT],
+      },
+    };
+    const saveReviewStub = sinon.stub(element.$.restAPI,
+        'saveChangeReview').returns(
+        Promise.resolve({ok: true}));
+    sinon.stub(element.$.restAPI, 'removeChangeReviewer').returns(
+        Promise.resolve({ok: true}));
+    const reloadListener = sinon.spy();
+    element._target.addEventListener('reload', reloadListener);
+
+    flush();
+    const button = element.shadowRoot.querySelector('.changeReviewerOrCC');
+
+    assert.isOk(button);
+    assert.equal(button.innerText, 'Move Reviewer to CC');
+    MockInteractions.tap(button);
+    await flush();
+
+    assert.isTrue(saveReviewStub.called);
+    assert.isTrue(reloadListener.called);
+  });
+
+  test('move reviewer to cc', async () => {
+    element.change = {
+      removable_reviewers: [ACCOUNT],
+      reviewers: {
+        [ReviewerState.REVIEWER]: [],
+      },
+    };
+    const saveReviewStub = sinon.stub(element.$.restAPI,
+        'saveChangeReview').returns(
+        Promise.resolve({ok: true}));
+    sinon.stub(element.$.restAPI, 'removeChangeReviewer').returns(
+        Promise.resolve({ok: true}));
+    const reloadListener = sinon.spy();
+    element._target.addEventListener('reload', reloadListener);
+    flush();
+
+    const button = element.shadowRoot.querySelector('.changeReviewerOrCC');
+    assert.isOk(button);
+    assert.equal(button.innerText, 'Move CC to Reviewer');
+
+    MockInteractions.tap(button);
+    await flush();
+
+    assert.isTrue(saveReviewStub.called);
+    assert.isTrue(reloadListener.called);
+  });
+
+  test('remove cc', async () => {
+    element.change = {
+      removable_reviewers: [ACCOUNT],
+      reviewers: {
+        [ReviewerState.REVIEWER]: [],
+      },
+    };
+    sinon.stub(element.$.restAPI, 'removeChangeReviewer').returns(
+        Promise.resolve({ok: true}));
+    const reloadListener = sinon.spy();
+    element._target.addEventListener('reload', reloadListener);
+
+    flush();
+    const button = element.shadowRoot.querySelector('.removeReviewerOrCC');
+
+    assert.equal(button.innerText, 'Remove CC');
+    assert.isOk(button);
+    MockInteractions.tap(button);
+
+    await flush();
+
+    assert.isTrue(reloadListener.called);
+  });
+
   test('add to attention set', async () => {
     let apiResolve;
     const apiPromise = new Promise(r => {
@@ -125,7 +214,7 @@
     const showAlertListener = sinon.spy();
     const hideAlertListener = sinon.spy();
     const updatedListener = sinon.spy();
-    element.addEventListener('show-alert', showAlertListener);
+    element._target.addEventListener('show-alert', showAlertListener);
     element._target.addEventListener('hide-alert', hideAlertListener);
     element._target.addEventListener('attention-set-updated', updatedListener);
 
@@ -159,7 +248,7 @@
     const showAlertListener = sinon.spy();
     const hideAlertListener = sinon.spy();
     const updatedListener = sinon.spy();
-    element.addEventListener('show-alert', showAlertListener);
+    element._target.addEventListener('show-alert', showAlertListener);
     element._target.addEventListener('hide-alert', hideAlertListener);
     element._target.addEventListener('attention-set-updated', updatedListener);
 
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api.ts
index 3a86700..a493a2e 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api.ts
@@ -15,50 +15,113 @@
  * limitations under the License.
  */
 import {
-  GrChangeActions,
   ActionType,
   ActionPriority,
 } from '../../../services/services/gr-rest-api/gr-rest-api';
 import {JsApiService} from './gr-js-api-types';
 import {TargetElement} from '../../plugins/gr-plugin-types';
+import {ActionInfo, RequireProperties} from '../../../types/common';
 
 interface Plugin {
   getPluginName(): string;
 }
 
-export class GrChangeActionsInterface {
-  private _el?: GrChangeActions;
-  // TODO(TS): define correct types when gr-change-actions is converted to ts
+export enum ChangeActions {
+  ABANDON = 'abandon',
+  DELETE = '/',
+  DELETE_EDIT = 'deleteEdit',
+  EDIT = 'edit',
+  FOLLOW_UP = 'followup',
+  IGNORE = 'ignore',
+  MOVE = 'move',
+  PRIVATE = 'private',
+  PRIVATE_DELETE = 'private.delete',
+  PUBLISH_EDIT = 'publishEdit',
+  REBASE = 'rebase',
+  REBASE_EDIT = 'rebaseEdit',
+  READY = 'ready',
+  RESTORE = 'restore',
+  REVERT = 'revert',
+  REVERT_SUBMISSION = 'revert_submission',
+  REVIEWED = 'reviewed',
+  STOP_EDIT = 'stopEdit',
+  SUBMIT = 'submit',
+  UNIGNORE = 'unignore',
+  UNREVIEWED = 'unreviewed',
+  WIP = 'wip',
+}
 
+export enum RevisionActions {
+  CHERRYPICK = 'cherrypick',
+  REBASE = 'rebase',
+  SUBMIT = 'submit',
+  DOWNLOAD = 'download',
+}
+
+export type PrimaryActionKey = ChangeActions | RevisionActions;
+
+export interface UIActionInfo extends RequireProperties<ActionInfo, 'label'> {
+  __key: string;
+  __url?: string;
+  __primary?: boolean;
+  __type: ActionType;
+  icon?: string;
+}
+
+// This interface is required to avoid circular dependencies between files;
+export interface GrChangeActionsElement extends Element {
   RevisionActions?: Record<string, string>;
+  ChangeActions: Record<string, string>;
+  ActionType: Record<string, string>;
+  primaryActionKeys: string[];
+  push(propName: 'primaryActionKeys', value: string): void;
+  hideQuickApproveAction(): void;
+  setActionOverflow(type: ActionType, key: string, overflow: boolean): void;
+  setActionPriority(
+    type: ActionType,
+    key: string,
+    overflow: ActionPriority
+  ): void;
+  setActionHidden(type: ActionType, key: string, hidden: boolean): void;
+  addActionButton(type: ActionType, label: string): string;
+  removeActionButton(key: string): void;
+  setActionButtonProp<T extends keyof UIActionInfo>(
+    key: string,
+    prop: T,
+    value: UIActionInfo[T]
+  ): void;
+  getActionDetails(actionName: string): ActionInfo | undefined;
+}
 
-  ChangeActions?: Record<string, string>;
+export class GrChangeActionsInterface {
+  private _el?: GrChangeActionsElement;
 
-  ActionType?: Record<string, string>;
+  RevisionActions = RevisionActions;
 
-  constructor(public plugin: Plugin, el?: GrChangeActions) {
+  ChangeActions = ChangeActions;
+
+  ActionType = ActionType;
+
+  constructor(public plugin: Plugin, el?: GrChangeActionsElement) {
     this.setEl(el);
   }
 
   /**
    * Set gr-change-actions element to a GrChangeActionsInterface instance.
    */
-  private setEl(el?: GrChangeActions) {
+  private setEl(el?: GrChangeActionsElement) {
     if (!el) {
       console.warn('changeActions() is not ready');
       return;
     }
     this._el = el;
-    this.RevisionActions = el.RevisionActions;
-    this.ChangeActions = el.ChangeActions;
-    this.ActionType = el.ActionType;
   }
 
   /**
    * Ensure GrChangeActionsInterface instance has access to gr-change-actions
    * element and retrieve if the interface was created before element.
    */
-  private ensureEl(): GrChangeActions {
+  private ensureEl(): GrChangeActionsElement {
     if (!this._el) {
       const sharedApiElement = (document.createElement(
         'gr-js-api-interface'
@@ -66,13 +129,13 @@
       this.setEl(
         (sharedApiElement.getElement(
           TargetElement.CHANGE_ACTIONS
-        ) as unknown) as GrChangeActions
+        ) as unknown) as GrChangeActionsElement
       );
     }
     return this._el!;
   }
 
-  addPrimaryActionKey(key: string) {
+  addPrimaryActionKey(key: PrimaryActionKey) {
     const el = this.ensureEl();
     if (el.primaryActionKeys.includes(key)) {
       return;
@@ -130,7 +193,7 @@
     this.ensureEl().setActionButtonProp(key, 'title', text);
   }
 
-  setEnabled(key: string, enabled: string) {
+  setEnabled(key: string, enabled: boolean) {
     this.ensureEl().setActionButtonProp(key, 'enabled', enabled);
   }
 
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface-element.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface-element.ts
index 4fc6f9f..8c26d4a 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface-element.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface-element.ts
@@ -20,9 +20,13 @@
 import {getPluginLoader} from './gr-plugin-loader';
 import {patchNumEquals} from '../../../utils/patch-set-util';
 import {customElement} from '@polymer/decorators';
-import {ChangeInfo, RevisionInfo} from '../../../types/common';
+import {
+  ChangeInfo,
+  LabelNameToValuesMap,
+  RevisionInfo,
+} from '../../../types/common';
 import {GrAnnotationActionsInterface} from './gr-annotation-actions-js-api';
-import {GrAdminApi} from '../../plugins/gr-admin-api/gr-admin-api';
+import {GrAdminApi, MenuLink} from '../../plugins/gr-admin-api/gr-admin-api';
 import {
   JsApiService,
   EventCallback,
@@ -87,7 +91,7 @@
     eventCallbacks[eventName].push(callback);
   }
 
-  canSubmitChange(change: ChangeInfo, revision: RevisionInfo) {
+  canSubmitChange(change: ChangeInfo, revision?: RevisionInfo | null) {
     const submitCallbacks = this._getEventCallbacks(EventType.SUBMIT_CHANGE);
     const cancelSubmit = submitCallbacks.some(callback => {
       try {
@@ -290,8 +294,8 @@
       );
   }
 
-  getAdminMenuLinks() {
-    const links = [];
+  getAdminMenuLinks(): MenuLink[] {
+    const links: MenuLink[] = [];
     for (const cb of this._getEventCallbacks(EventType.ADMIN_MENU_LINKS)) {
       const adminApi = (cb as unknown) as GrAdminApi;
       links.push(...adminApi.getMenuLinks());
@@ -299,8 +303,8 @@
     return links;
   }
 
-  getLabelValuesPostRevert(change: ChangeInfo) {
-    let labels = {};
+  getLabelValuesPostRevert(change?: ChangeInfo): LabelNameToValuesMap {
+    let labels: LabelNameToValuesMap = {};
     for (const cb of this._getEventCallbacks(EventType.POST_REVERT)) {
       try {
         labels = cb(change);
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-types.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-types.ts
index 505e62e..261298b 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-types.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-types.ts
@@ -18,6 +18,7 @@
 import {EventType, TargetElement} from '../../plugins/gr-plugin-types';
 import {DiffLayer} from '../../../types/types';
 import {GrAnnotationActionsInterface} from './gr-annotation-actions-js-api';
+import {MenuLink} from '../../plugins/gr-admin-api/gr-admin-api';
 
 export interface ShowChangeDetail {
   change: ChangeInfo;
@@ -50,5 +51,6 @@
   getDiffLayers(path: string, changeNum: number): DiffLayer[];
   disposeDiffLayers(path: string): void;
   getCoverageAnnotationApi(): Promise<GrAnnotationActionsInterface | undefined>;
+  getAdminMenuLinks(): MenuLink[];
   // TODO(TS): Add more methods when needed for the TS conversion.
 }
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context.ts
index b3f4987..ffdf710 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context.ts
@@ -15,13 +15,9 @@
  * limitations under the License.
  */
 
-import {
-  RevisionInfo,
-  ChangeInfo,
-  RequestPayload,
-  ActionInfo,
-} from '../../../types/common';
+import {RevisionInfo, ChangeInfo, RequestPayload} from '../../../types/common';
 import {PluginApi} from '../../plugins/gr-plugin-types';
+import {UIActionInfo} from './gr-change-actions-js-api';
 
 interface GrPopupInterface {
   close(): void;
@@ -36,7 +32,7 @@
 
   constructor(
     public readonly plugin: PluginApi,
-    public readonly action: ActionInfo,
+    public readonly action: UIActionInfo,
     public readonly change: ChangeInfo,
     public readonly revision: RevisionInfo
   ) {}
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.ts
index 043293f..0625f67 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.ts
@@ -45,7 +45,7 @@
 import {RequestPayload} from '../../../types/common';
 import {HttpMethod} from '../../../constants/constants';
 import {JsApiService} from './gr-js-api-types';
-import {GrChangeActions} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {GrChangeActions} from '../../change/gr-change-actions/gr-change-actions';
 
 /**
  * Plugin-provided custom components can affect content in extension
diff --git a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.ts b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.ts
index b6e664e..c9e448e6 100644
--- a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.ts
+++ b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.ts
@@ -33,13 +33,14 @@
   ChangeInfo,
   AccountInfo,
   LabelInfo,
-  DetailedLabelInfo,
-  QuickLabelInfo,
   ApprovalInfo,
   AccountId,
+  isQuickLabelInfo,
+  isDetailedLabelInfo,
 } from '../../../types/common';
 import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
 import {GrButton} from '../gr-button/gr-button';
+import {getVotingRangeOrDefault} from '../../../utils/label-util';
 
 export interface GrLabelInfo {
   $: {
@@ -66,11 +67,6 @@
   value: string;
 }
 
-// type guard to check if label is QuickLabelInfo
-function isQuickLabelInfo(label: LabelInfo): label is QuickLabelInfo {
-  return !(label as DetailedLabelInfo).values;
-}
-
 @customElement('gr-label-info')
 export class GrLabelInfo extends GestureEventListeners(
   LegacyElementMixin(PolymerElement)
@@ -106,8 +102,11 @@
     if (!labelInfo || !account) {
       return result;
     }
-    if (isQuickLabelInfo(labelInfo)) {
-      if (labelInfo.rejected || labelInfo.approved) {
+    if (!isDetailedLabelInfo(labelInfo)) {
+      if (
+        isQuickLabelInfo(labelInfo) &&
+        (labelInfo.rejected || labelInfo.approved)
+      ) {
         const ok = labelInfo.approved || !labelInfo.rejected;
         return [
           {
@@ -125,23 +124,24 @@
     const votes = (labelInfo.all || []).sort(
       (a, b) => (a.value || 0) - (b.value || 0)
     );
-    const values = Object.keys(labelInfo.values || {});
+    const votingRange = getVotingRangeOrDefault(labelInfo);
     for (const label of votes) {
-      if (label.value && label.value !== labelInfo.default_value) {
+      if (
+        label.value &&
+        (!isQuickLabelInfo(labelInfo) ||
+          label.value !== labelInfo.default_value)
+      ) {
         let labelClassName;
         let labelValPrefix = '';
         if (label.value > 0) {
           labelValPrefix = '+';
-          if (
-            parseInt(`${label.value}`, 10) ===
-            parseInt(values[values.length - 1], 10)
-          ) {
+          if (label.value === votingRange.max) {
             labelClassName = LabelClassName.MAX;
           } else {
             labelClassName = LabelClassName.POSITIVE;
           }
         } else if (label.value < 0) {
-          if (parseInt(`${label.value}`, 10) === parseInt(values[0], 10)) {
+          if (label.value === votingRange.min) {
             labelClassName = LabelClassName.MIN;
           } else {
             labelClassName = LabelClassName.NEGATIVE;
@@ -228,8 +228,8 @@
   _computeValueTooltip(labelInfo: LabelInfo, score: string) {
     if (
       !labelInfo ||
-      isQuickLabelInfo(labelInfo) ||
-      !labelInfo.values?.[score]
+      !isDetailedLabelInfo(labelInfo) ||
+      !labelInfo.values[score]
     ) {
       return '';
     }
@@ -240,19 +240,25 @@
    * This method also listens change.labels.* in
    * order to trigger computation when a label is removed from the change.
    */
-  _computeShowPlaceholder(labelInfo: LabelInfo) {
+  _computeShowPlaceholder(labelInfo?: LabelInfo) {
+    if (!labelInfo) {
+      return '';
+    }
     if (
-      labelInfo &&
+      !isDetailedLabelInfo(labelInfo) &&
       isQuickLabelInfo(labelInfo) &&
       (labelInfo.rejected || labelInfo.approved)
     ) {
       return 'hidden';
     }
 
-    // TODO(TS): might replace with hasOwnProperty instead
-    if (labelInfo && (labelInfo as DetailedLabelInfo).all) {
-      for (const label of (labelInfo as DetailedLabelInfo).all || []) {
-        if (label.value && label.value !== labelInfo.default_value) {
+    if (isDetailedLabelInfo(labelInfo) && labelInfo.all) {
+      for (const label of labelInfo.all) {
+        if (
+          label.value &&
+          (!isQuickLabelInfo(labelInfo) ||
+            label.value !== labelInfo.default_value)
+        ) {
           return 'hidden';
         }
       }
diff --git a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_test.js b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_test.js
index be7878b..3a2cc39 100644
--- a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_test.js
@@ -204,25 +204,32 @@
   });
 
   test('placeholder', () => {
+    const values = {
+      '0': 'No score',
+      '+1': 'good',
+      '+2': 'excellent',
+      '-1': 'bad',
+      '-2': 'terrible',
+    };
     element.labelInfo = {};
     assert.isFalse(isHidden(element.shadowRoot
         .querySelector('.placeholder')));
-    element.labelInfo = {all: []};
+    element.labelInfo = {all: [], values};
     assert.isFalse(isHidden(element.shadowRoot
         .querySelector('.placeholder')));
-    element.labelInfo = {all: [{value: 1}]};
+    element.labelInfo = {all: [{value: 1}], values};
     assert.isTrue(isHidden(element.shadowRoot
         .querySelector('.placeholder')));
     element.labelInfo = {rejected: []};
     assert.isTrue(isHidden(element.shadowRoot
         .querySelector('.placeholder')));
-    element.labelInfo = {values: [], rejected: [], all: [{value: 1}]};
+    element.labelInfo = {values: [], rejected: [], all: [{value: 1}, values]};
     assert.isTrue(isHidden(element.shadowRoot
         .querySelector('.placeholder')));
     element.labelInfo = {approved: []};
     assert.isTrue(isHidden(element.shadowRoot
         .querySelector('.placeholder')));
-    element.labelInfo = {values: [], approved: [], all: [{value: 1}]};
+    element.labelInfo = {values: [], approved: [], all: [{value: 1}, values]};
     assert.isTrue(isHidden(element.shadowRoot
         .querySelector('.placeholder')));
   });
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 5b9f360..15cdac4 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
@@ -134,6 +134,11 @@
   FilePathToDiffInfoMap,
   ChangeViewChangeInfo,
   BlameInfo,
+  ActionNameToActionInfoMap,
+  RevisionId,
+  GroupName,
+  Hashtag,
+  TopMenuEntryInfo,
 } from '../../../types/common';
 import {
   CancelConditionCallback,
@@ -194,7 +199,7 @@
   endpoint: string;
   anonymizedEndpoint?: string;
   changeNum: NumericChangeId;
-  method: HttpMethod;
+  method: HttpMethod | undefined;
   errFn?: ErrorCallback;
   headers?: Record<string, string>;
   contentType?: string;
@@ -515,7 +520,7 @@
   }
 
   getGroupConfig(
-    group: GroupId,
+    group: GroupId | GroupName,
     errFn?: ErrorCallback
   ): Promise<GroupInfo | undefined> {
     return this._restApiHelper.fetchJSON({
@@ -649,7 +654,7 @@
     });
   }
 
-  getIsGroupOwner(groupName: GroupId): Promise<boolean> {
+  getIsGroupOwner(groupName: GroupName): Promise<boolean> {
     const encodeName = encodeURIComponent(groupName);
     const req = {
       url: `/groups/?owned&g=${encodeName}`,
@@ -661,7 +666,7 @@
   }
 
   getGroupMembers(
-    groupName: GroupId,
+    groupName: GroupId | GroupName,
     errFn?: ErrorCallback
   ): Promise<AccountInfo[] | undefined> {
     const encodeName = encodeURIComponent(groupName);
@@ -672,14 +677,16 @@
     }) as Promise<AccountInfo[] | undefined>;
   }
 
-  getIncludedGroup(groupName: GroupId): Promise<GroupInfo[] | undefined> {
+  getIncludedGroup(
+    groupName: GroupId | GroupName
+  ): Promise<GroupInfo[] | undefined> {
     return this._restApiHelper.fetchJSON({
       url: `/groups/${encodeURIComponent(groupName)}/groups/`,
       anonymizedUrl: '/groups/*/groups',
     }) as Promise<GroupInfo[] | undefined>;
   }
 
-  saveGroupName(groupId: GroupId, name: string): Promise<Response> {
+  saveGroupName(groupId: GroupId | GroupName, name: string): Promise<Response> {
     const encodeId = encodeURIComponent(groupId);
     return this._restApiHelper.send({
       method: HttpMethod.PUT,
@@ -689,7 +696,10 @@
     });
   }
 
-  saveGroupOwner(groupId: GroupId, ownerId: string): Promise<Response> {
+  saveGroupOwner(
+    groupId: GroupId | GroupName,
+    ownerId: string
+  ): Promise<Response> {
     const encodeId = encodeURIComponent(groupId);
     return this._restApiHelper.send({
       method: HttpMethod.PUT,
@@ -700,7 +710,7 @@
   }
 
   saveGroupDescription(
-    groupId: GroupId,
+    groupId: GroupId | GroupName,
     description: string
   ): Promise<Response> {
     const encodeId = encodeURIComponent(groupId);
@@ -713,7 +723,7 @@
   }
 
   saveGroupOptions(
-    groupId: GroupId,
+    groupId: GroupId | GroupName,
     options: GroupOptionsInput
   ): Promise<Response> {
     const encodeId = encodeURIComponent(groupId);
@@ -737,7 +747,7 @@
   }
 
   saveGroupMember(
-    groupName: GroupId,
+    groupName: GroupId | GroupName,
     groupMember: AccountId
   ): Promise<AccountInfo> {
     const encodeName = encodeURIComponent(groupName);
@@ -751,7 +761,7 @@
   }
 
   saveIncludedGroup(
-    groupName: GroupId,
+    groupName: GroupId | GroupName,
     includedGroup: GroupId,
     errFn?: ErrorCallback
   ): Promise<GroupInfo | undefined> {
@@ -774,7 +784,7 @@
   }
 
   deleteGroupMember(
-    groupName: GroupId,
+    groupName: GroupId | GroupName,
     groupMember: AccountId
   ): Promise<Response> {
     const encodeName = encodeURIComponent(groupName);
@@ -788,7 +798,7 @@
 
   deleteIncludedGroup(
     groupName: GroupId,
-    includedGroup: GroupId
+    includedGroup: GroupId | GroupName
   ): Promise<Response> {
     const encodeName = encodeURIComponent(groupName);
     const encodeIncludedGroup = encodeURIComponent(includedGroup);
@@ -1192,11 +1202,11 @@
       );
   }
 
-  getDefaultPreferences() {
+  getDefaultPreferences(): Promise<PreferencesInfo | undefined> {
     return this._fetchSharedCacheURL({
       url: '/config/server/preferences',
       reportUrlAsIs: true,
-    });
+    }) as Promise<PreferencesInfo | undefined>;
   }
 
   getPreferences(): Promise<PreferencesInfo | undefined> {
@@ -1374,10 +1384,12 @@
 
   getChangeActionURL(
     changeNum: NumericChangeId,
-    patchNum: PatchSetNum | undefined,
+    revisionId: RevisionId | undefined,
     endpoint: string
   ): Promise<string> {
-    return this._changeBaseURL(changeNum, patchNum).then(url => url + endpoint);
+    return this._changeBaseURL(changeNum, revisionId).then(
+      url => url + endpoint
+    );
   }
 
   getChangeDetail(
@@ -1592,7 +1604,10 @@
     }) as Promise<string[] | undefined>;
   }
 
-  getChangeOrEditFiles(changeNum: NumericChangeId, patchRange: PatchRange) {
+  getChangeOrEditFiles(
+    changeNum: NumericChangeId,
+    patchRange: PatchRange
+  ): Promise<FileNameToFileInfoMap | undefined> {
     if (patchNumEquals(patchRange.patchNum, EditPatchSetNum)) {
       return this.getChangeEditFiles(changeNum, patchRange).then(
         res => res && res.files
@@ -1601,14 +1616,19 @@
     return this.getChangeFiles(changeNum, patchRange);
   }
 
-  getChangeRevisionActions(changeNum: NumericChangeId, patchNum: PatchSetNum) {
+  getChangeRevisionActions(
+    changeNum: NumericChangeId,
+    patchNum: PatchSetNum
+  ): Promise<ActionNameToActionInfoMap | undefined> {
     const req: FetchChangeJSON = {
       changeNum,
       endpoint: '/actions',
       patchNum,
       reportEndpointAsIs: true,
     };
-    return this._getChangeURLAndFetch(req);
+    return this._getChangeURLAndFetch(req) as Promise<
+      ActionNameToActionInfoMap | undefined
+    >;
   }
 
   getChangeSuggestedReviewers(
@@ -2070,13 +2090,16 @@
     }) as Promise<ChangeInfo[] | undefined>;
   }
 
-  getReviewedFiles(changeNum: NumericChangeId, patchNum: PatchSetNum) {
+  getReviewedFiles(
+    changeNum: NumericChangeId,
+    patchNum: PatchSetNum
+  ): Promise<string[] | undefined> {
     return this._getChangeURLAndFetch({
       changeNum,
       endpoint: '/files?reviewed',
       patchNum,
       reportEndpointAsIs: true,
-    });
+    }) as Promise<string[] | undefined>;
   }
 
   saveFileReviewed(
@@ -2409,7 +2432,10 @@
     });
   }
 
-  saveChangeStarred(changeNum: NumericChangeId, starred: boolean) {
+  saveChangeStarred(
+    changeNum: NumericChangeId,
+    starred: boolean
+  ): Promise<Response> {
     // Some servers may require the project name to be provided
     // alongside the change number, so resolve the project name
     // first.
@@ -2765,7 +2791,7 @@
   _getDiffCommentsFetchURL(
     changeNum: NumericChangeId,
     endpoint: string,
-    patchNum?: PatchSetNum
+    patchNum?: RevisionId
   ) {
     return this._changeBaseURL(changeNum, patchNum).then(url => url + endpoint);
   }
@@ -2901,7 +2927,7 @@
 
   getB64FileContents(
     changeId: NumericChangeId,
-    patchNum: PatchSetNum,
+    patchNum: RevisionId,
     path: string,
     parentIndex?: number
   ) {
@@ -2974,7 +3000,7 @@
 
   _changeBaseURL(
     changeNum: NumericChangeId,
-    patchNum?: PatchSetNum,
+    revisionId?: RevisionId,
     project?: RepoName
   ): Promise<string> {
     // TODO(kaspern): For full slicer migration, app should warn with a call
@@ -2987,8 +3013,8 @@
       let url = `/changes/${encodeURIComponent(
         project as RepoName
       )}~${changeNum}`;
-      if (patchNum) {
-        url += `/revisions/${patchNum}`;
+      if (revisionId) {
+        url += `/revisions/${revisionId}`;
       }
       return url;
     });
@@ -3022,26 +3048,32 @@
     });
   }
 
-  setChangeTopic(changeNum: NumericChangeId, topic: string | null) {
-    return this._getChangeURLAndSend({
+  setChangeTopic(
+    changeNum: NumericChangeId,
+    topic: string | null
+  ): Promise<string> {
+    return (this._getChangeURLAndSend({
       changeNum,
       method: HttpMethod.PUT,
       endpoint: '/topic',
       body: {topic},
       parseResponse: true,
       reportUrlAsIs: true,
-    });
+    }) as unknown) as Promise<string>;
   }
 
-  setChangeHashtag(changeNum: NumericChangeId, hashtag: HashtagsInput) {
-    return this._getChangeURLAndSend({
+  setChangeHashtag(
+    changeNum: NumericChangeId,
+    hashtag: HashtagsInput
+  ): Promise<Hashtag[]> {
+    return (this._getChangeURLAndSend({
       changeNum,
       method: HttpMethod.POST,
       endpoint: '/hashtags',
       body: hashtag,
       parseResponse: true,
       reportUrlAsIs: true,
-    });
+    }) as unknown) as Promise<Hashtag[]>;
   }
 
   deleteAccountHttpPassword() {
@@ -3165,7 +3197,7 @@
     });
   }
 
-  confirmEmail(token: string) {
+  confirmEmail(token: string): Promise<string | null> {
     const req = {
       method: HttpMethod.PUT,
       url: '/config/server/email.confirm',
@@ -3190,25 +3222,29 @@
     }) as Promise<CapabilityInfoMap | undefined>;
   }
 
-  getTopMenus(errFn?: ErrorCallback) {
+  getTopMenus(errFn?: ErrorCallback): Promise<TopMenuEntryInfo[] | undefined> {
     return this._fetchSharedCacheURL({
       url: '/config/server/top-menus',
       errFn,
       reportUrlAsIs: true,
-    });
+    }) as Promise<TopMenuEntryInfo[] | undefined>;
   }
 
-  setAssignee(changeNum: NumericChangeId, assignee: AssigneeInput) {
+  setAssignee(
+    changeNum: NumericChangeId,
+    assignee: AccountId
+  ): Promise<Response> {
+    const body: AssigneeInput = {assignee};
     return this._getChangeURLAndSend({
       changeNum,
       method: HttpMethod.PUT,
       endpoint: '/assignee',
-      body: {assignee},
+      body,
       reportUrlAsIs: true,
     });
   }
 
-  deleteAssignee(changeNum: NumericChangeId) {
+  deleteAssignee(changeNum: NumericChangeId): Promise<Response> {
     return this._getChangeURLAndSend({
       changeNum,
       method: HttpMethod.DELETE,
@@ -3279,7 +3315,7 @@
    * Given a changeNum, gets the change.
    */
   getChange(
-    changeNum: NumericChangeId,
+    changeNum: ChangeId | NumericChangeId,
     errFn: ErrorCallback
   ): Promise<ChangeInfo | null> {
     // Cannot use _changeBaseURL, as this function is used by _projectLookup.
@@ -3416,7 +3452,7 @@
 
   executeChangeAction(
     changeNum: NumericChangeId,
-    method: HttpMethod,
+    method: HttpMethod | undefined,
     endpoint: string,
     patchNum?: PatchSetNum,
     payload?: RequestPayload
@@ -3424,7 +3460,7 @@
 
   executeChangeAction(
     changeNum: NumericChangeId,
-    method: HttpMethod,
+    method: HttpMethod | undefined,
     endpoint: string,
     patchNum: PatchSetNum | undefined,
     payload: RequestPayload | undefined,
@@ -3436,7 +3472,7 @@
    */
   executeChangeAction(
     changeNum: NumericChangeId,
-    method: HttpMethod,
+    method: HttpMethod | undefined,
     endpoint: string,
     patchNum?: PatchSetNum,
     payload?: RequestPayload,
@@ -3549,11 +3585,12 @@
     });
   }
 
-  deleteDraftComments(query: DeleteDraftCommentsInput) {
+  deleteDraftComments(query: string): Promise<Response> {
+    const body: DeleteDraftCommentsInput = {query};
     return this._restApiHelper.send({
       method: HttpMethod.POST,
       url: '/accounts/self/drafts:delete',
-      body: {query},
+      body,
     });
   }
 }
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper.ts b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper.ts
index 6354aab..eee48d2 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper.ts
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper.ts
@@ -168,7 +168,7 @@
 };
 
 interface SendRequestBase {
-  method: HttpMethod;
+  method: HttpMethod | undefined;
   body?: RequestPayload;
   contentType?: string;
   headers?: Record<string, string>;
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 80df630..b255ea5 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
@@ -1082,7 +1082,7 @@
   _shortcut_v_key_last_pressed: number | null;
   _shortcut_go_table: Map<string, string>;
   _shortcut_v_table: Map<string, string>;
-  keyboardShortcuts(): {[key: string]: string};
+  keyboardShortcuts(): {[key: string]: string | null};
   createTitle(name: Shortcut, section: ShortcutSection): string;
   bindShortcut(shortcut: Shortcut, ...bindings: string[]): void;
   shouldSuppressKeyboardShortcut(event: CustomKeyboardEvent): boolean;
diff --git a/polygerrit-ui/app/scripts/util.ts b/polygerrit-ui/app/scripts/util.ts
index 59ade33..bf7120f 100644
--- a/polygerrit-ui/app/scripts/util.ts
+++ b/polygerrit-ui/app/scripts/util.ts
@@ -49,8 +49,7 @@
     // True if the promise is either resolved or reject (possibly cancelled)
     let isDone = false;
 
-    // eslint-disable-next-line @typescript-eslint/no-explicit-any
-    let rejectPromise: (reason?: any) => void;
+    let rejectPromise: (reason?: unknown) => void;
 
     const wrappedPromise: CancelablePromise<T> = new Promise(
       (resolve, reject) => {
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 10715cf..6b93082 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
@@ -22,7 +22,6 @@
   NumericChangeId,
   ServerInfo,
   ProjectInfo,
-  ActionInfo,
   AccountCapabilityInfo,
   SuggestedReviewerInfo,
   GroupNameToGroupInfoMap,
@@ -91,6 +90,14 @@
   BlameInfo,
   PatchRange,
   ImagesForDiff,
+  ActionNameToActionInfoMap,
+  RevisionId,
+  GroupName,
+  DashboardId,
+  HashtagsInput,
+  Hashtag,
+  FileNameToFileInfoMap,
+  TopMenuEntryInfo,
 } from '../../../types/common';
 import {ParsedChangeInfo} from '../../../elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser';
 import {HttpMethod, IgnoreWhitespaceType} from '../../../constants/constants';
@@ -121,27 +128,6 @@
   REVISION = 1,
 }
 
-// TODO(TS) remove interface when GrChangeActions is converted to typescript
-export interface GrChangeActions extends Element {
-  RevisionActions?: Record<string, string>;
-  ChangeActions: Record<string, string>;
-  ActionType: Record<string, string>;
-  primaryActionKeys: string[];
-  push(propName: 'primaryActionKeys', value: string): void;
-  hideQuickApproveAction(): void;
-  setActionOverflow(type: ActionType, key: string, overflow: boolean): void;
-  setActionPriority(
-    type: ActionType,
-    key: string,
-    overflow: ActionPriority
-  ): void;
-  setActionHidden(type: ActionType, key: string, hidden: boolean): void;
-  addActionButton(type: ActionType, label: string): string;
-  removeActionButton(key: string): void;
-  setActionButtonProp(key: string, prop: string, value: string): void;
-  getActionDetails(actionName: string): ActionInfo;
-}
-
 export interface GetDiffCommentsOutput {
   baseComments: CommentInfo[];
   comments: CommentInfo[];
@@ -214,7 +200,7 @@
   ): Promise<GroupNameToGroupInfoMap | undefined>;
   executeChangeAction(
     changeNum: NumericChangeId,
-    method: HttpMethod,
+    method: HttpMethod | undefined,
     endpoint: string,
     patchNum?: PatchSetNum,
     payload?: RequestPayload,
@@ -234,6 +220,11 @@
     opt_cancelCondition?: Function
   ): Promise<ParsedChangeInfo | null | undefined>;
 
+  getChange(
+    changeNum: ChangeId | NumericChangeId,
+    errFn: ErrorCallback
+  ): Promise<ChangeInfo | null>;
+
   savePreferences(prefs: PreferencesInput): Promise<Response>;
 
   getDiffPreferences(): Promise<DiffPreferencesInfo | undefined>;
@@ -376,17 +367,23 @@
   ): Promise<GroupNameToGroupInfoMap | undefined>;
 
   getGroupConfig(
-    group: GroupId,
+    group: GroupId | GroupName,
     errFn?: ErrorCallback
   ): Promise<GroupInfo | undefined>;
 
   getIsAdmin(): Promise<boolean | undefined>;
 
-  getIsGroupOwner(groupName: GroupId): Promise<boolean>;
+  getIsGroupOwner(groupName: GroupName): Promise<boolean>;
 
-  saveGroupName(groupId: GroupId, name: string): Promise<Response>;
+  saveGroupName(
+    groupId: GroupId | GroupName,
+    name: GroupName
+  ): Promise<Response>;
 
-  saveGroupOwner(groupId: GroupId, ownerId: string): Promise<Response>;
+  saveGroupOwner(
+    groupId: GroupId | GroupName,
+    ownerId: string
+  ): Promise<Response>;
 
   saveGroupDescription(
     groupId: GroupId,
@@ -399,19 +396,19 @@
   ): Promise<Response>;
 
   saveChangeReview(
-    changeNum: NumericChangeId,
-    patchNum: PatchSetNum,
+    changeNum: ChangeId | NumericChangeId,
+    patchNum: RevisionId,
     review: ReviewInput
   ): Promise<Response>;
   saveChangeReview(
-    changeNum: NumericChangeId,
-    patchNum: PatchSetNum,
+    changeNum: ChangeId | NumericChangeId,
+    patchNum: RevisionId,
     review: ReviewInput,
     errFn: ErrorCallback
   ): Promise<Response | undefined>;
   saveChangeReview(
-    changeNum: NumericChangeId,
-    patchNum: PatchSetNum,
+    changeNum: ChangeId | NumericChangeId,
+    patchNum: RevisionId,
     review: ReviewInput,
     errFn?: ErrorCallback
   ): Promise<Response>;
@@ -421,6 +418,12 @@
     downloadCommands?: boolean
   ): Promise<false | EditInfo | undefined>;
 
+  getChangeActionURL(
+    changeNum: NumericChangeId,
+    patchNum: PatchSetNum | undefined,
+    endpoint: string
+  ): Promise<string>;
+
   createChange(
     project: RepoName,
     branch: BranchName,
@@ -646,30 +649,32 @@
   ): Promise<GroupAuditEventInfo[] | undefined>;
 
   getGroupMembers(
-    groupName: GroupId,
+    groupName: GroupId | GroupName,
     errFn?: ErrorCallback
   ): Promise<AccountInfo[] | undefined>;
 
-  getIncludedGroup(groupName: GroupId): Promise<GroupInfo[] | undefined>;
+  getIncludedGroup(
+    groupName: GroupId | GroupName
+  ): Promise<GroupInfo[] | undefined>;
 
   saveGroupMember(
-    groupName: GroupId,
+    groupName: GroupId | GroupName,
     groupMember: AccountId
   ): Promise<AccountInfo>;
 
   saveIncludedGroup(
-    groupName: GroupId,
+    groupName: GroupId | GroupName,
     includedGroup: GroupId,
     errFn?: ErrorCallback
   ): Promise<GroupInfo | undefined>;
 
   deleteGroupMember(
-    groupName: GroupId,
+    groupName: GroupId | GroupName,
     groupMember: AccountId
   ): Promise<Response>;
 
   deleteIncludedGroup(
-    groupName: GroupId,
+    groupName: GroupId | GroupName,
     includedGroup: GroupId
   ): Promise<Response>;
 
@@ -761,4 +766,82 @@
     diff: DiffInfo,
     patchRange: PatchRange
   ): Promise<ImagesForDiff>;
+
+  getChangeRevisionActions(
+    changeNum: NumericChangeId,
+    patchNum: PatchSetNum
+  ): Promise<ActionNameToActionInfoMap | undefined>;
+
+  confirmEmail(token: string): Promise<string | null>;
+
+  getDefaultPreferences(): Promise<PreferencesInfo | undefined>;
+
+  addAccountEmail(email: string): Promise<Response>;
+
+  addAccountEmail(
+    email: string,
+    errFn?: ErrorCallback
+  ): Promise<Response | undefined>;
+
+  saveChangeReviewed(
+    changeNum: NumericChangeId,
+    reviewed: boolean
+  ): Promise<Response | undefined>;
+
+  saveChangeStarred(
+    changeNum: NumericChangeId,
+    starred: boolean
+  ): Promise<Response>;
+
+  getDashboard(
+    project: RepoName,
+    dashboard: DashboardId,
+    errFn?: ErrorCallback
+  ): Promise<DashboardInfo | undefined>;
+
+  deleteDraftComments(query: string): Promise<Response>;
+
+  setAssignee(
+    changeNum: NumericChangeId,
+    assignee: AccountId
+  ): Promise<Response>;
+
+  deleteAssignee(changeNum: NumericChangeId): Promise<Response>;
+
+  setChangeHashtag(
+    changeNum: NumericChangeId,
+    hashtag: HashtagsInput
+  ): Promise<Hashtag[]>;
+
+  setChangeTopic(
+    changeNum: NumericChangeId,
+    topic: string | null
+  ): Promise<string>;
+
+  getChangeOrEditFiles(
+    changeNum: NumericChangeId,
+    patchRange: PatchRange
+  ): Promise<FileNameToFileInfoMap | undefined>;
+
+  getReviewedFiles(
+    changeNum: NumericChangeId,
+    patchNum: PatchSetNum
+  ): Promise<string[] | undefined>;
+
+  saveFileReviewed(
+    changeNum: NumericChangeId,
+    patchNum: PatchSetNum,
+    path: string,
+    reviewed: boolean
+  ): Promise<Response>;
+
+  saveFileReviewed(
+    changeNum: NumericChangeId,
+    patchNum: PatchSetNum,
+    path: string,
+    reviewed: boolean,
+    errFn: ErrorCallback
+  ): Promise<Response | undefined>;
+
+  getTopMenus(errFn?: ErrorCallback): Promise<TopMenuEntryInfo[] | undefined>;
 }
diff --git a/polygerrit-ui/app/styles/gr-change-list-styles.ts b/polygerrit-ui/app/styles/gr-change-list-styles.ts
index 1dbc917..da62936 100644
--- a/polygerrit-ui/app/styles/gr-change-list-styles.ts
+++ b/polygerrit-ui/app/styles/gr-change-list-styles.ts
@@ -32,6 +32,13 @@
       gr-change-list-item:focus {
         background-color: var(--selection-background-color);
       }
+      gr-change-list-item[highlight] {
+        background-color: var(--assignee-highlight-color);
+      }
+      gr-change-list-item[highlight][selected],
+      gr-change-list-item[highlight]:focus {
+        background-color: var(--assignee-highlight-selection-color);
+      }
       .groupTitle td,
       .cell {
         vertical-align: middle;
@@ -84,6 +91,8 @@
       .owner,
       .assignee,
       .updated,
+      .submitted,
+      .since,
       .size,
       .status,
       .repo {
@@ -163,6 +172,8 @@
         .repo,
         .branch,
         .updated,
+        .submitted,
+        .since,
         .label,
         .assignee,
         .groupHeader .star,
diff --git a/polygerrit-ui/app/styles/themes/app-theme.ts b/polygerrit-ui/app/styles/themes/app-theme.ts
index 835ef0a..2a3e91b 100644
--- a/polygerrit-ui/app/styles/themes/app-theme.ts
+++ b/polygerrit-ui/app/styles/themes/app-theme.ts
@@ -71,6 +71,9 @@
     --view-background-color: var(--background-color-primary);
     /* unique background colors */
     --assignee-highlight-color: #fcfad6;
+    /* TODO: Find a nicer way to combine the --assignee-highlight-color and the
+       --selection-background-color than to just invent another unique color. */
+    --assignee-highlight-selection-color: #f6f4d0;
     --chip-selected-background-color: #e8f0fe;
     --edit-mode-background-color: #ebf5fb;
     --emphasis-color: #fff9c4;
diff --git a/polygerrit-ui/app/styles/themes/dark-theme.ts b/polygerrit-ui/app/styles/themes/dark-theme.ts
index 3032984..03a3ab5 100644
--- a/polygerrit-ui/app/styles/themes/dark-theme.ts
+++ b/polygerrit-ui/app/styles/themes/dark-theme.ts
@@ -55,6 +55,7 @@
       /*   empty, because inheriting from app-theme is just fine
       /* unique background colors */
       --assignee-highlight-color: #3a361c;
+      --assignee-highlight-selection-color: #423e24;
       --chip-selected-background-color: #3c4455;
       --edit-mode-background-color: #5c0a36;
       --emphasis-color: #383f4a;
diff --git a/polygerrit-ui/app/types/common.ts b/polygerrit-ui/app/types/common.ts
index caa5f7f..5bcf0b8 100644
--- a/polygerrit-ui/app/types/common.ts
+++ b/polygerrit-ui/app/types/common.ts
@@ -42,7 +42,10 @@
   DiffViewMode,
   DraftsAction,
   NotifyType,
+  EmailFormat,
+  AuthType,
 } from '../constants/constants';
+import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
 
 export type BrandType<T, BrandName extends string> = T &
   {[__brand in BrandName]: never};
@@ -53,6 +56,13 @@
 export type RequireProperties<T, K extends keyof T> = Omit<T, K> &
   Required<Pick<T, K>>;
 
+export type PropertyType<T, K extends keyof T> = ReturnType<() => T[K]>;
+
+export type ElementPropertyDeepChange<
+  T,
+  K extends keyof T
+> = PolymerDeepPropertyChange<PropertyType<T, K>, PropertyType<T, K>>;
+
 /**
  * Type alias for parsed json object to make code cleaner
  */
@@ -80,6 +90,10 @@
 export type RobotId = BrandType<string, '_robotId'>;
 export type RobotRunId = BrandType<string, '_robotRunId'>;
 
+// RevisionId '0' is the same as 'current'. However, we want to avoid '0'
+// in our code, so it is not added here as a possible value.
+export type RevisionId = 'current' | CommitId | PatchSetNum;
+
 // The UUID of the suggested fix.
 export type FixId = BrandType<string, '_fixId'>;
 export type EmailAddress = BrandType<string, '_emailAddress'>;
@@ -120,6 +134,7 @@
 export type StarLabel = BrandType<string, '_startLabel'>;
 export type CommitId = BrandType<string, '_commitId'>;
 export type LabelName = BrandType<string, '_labelName'>;
+export type GroupName = BrandType<string, '_groupName'>;
 
 // The UUID of the group
 export type GroupId = BrandType<string, '_groupId'>;
@@ -145,10 +160,14 @@
 export type LabelValueToDescriptionMap = {[labelValue: string]: string};
 
 /**
- * The LabelInfo entity contains information about a label on a change, always corresponding to the current patch set.
+ * The LabelInfo entity contains information about a label on a change, always
+ * corresponding to the current patch set.
  * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#label-info
  */
-export type LabelInfo = QuickLabelInfo | DetailedLabelInfo;
+export type LabelInfo =
+  | QuickLabelInfo
+  | DetailedLabelInfo
+  | (QuickLabelInfo & DetailedLabelInfo);
 
 interface LabelCommonInfo {
   optional?: boolean; // not set if false
@@ -164,12 +183,39 @@
   default_value?: number;
 }
 
+/**
+ * LabelInfo when DETAILED_LABELS are requested.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#_fields_set_by_code_detailed_labels_code
+ */
 export interface DetailedLabelInfo extends LabelCommonInfo {
+  // This is not set when the change has no reviewers.
   all?: ApprovalInfo[];
-  values?: LabelValueToDescriptionMap; // A map of all values that are allowed for this label
+  // Docs claim that 'values' is optional, but it is actually always set.
+  values: LabelValueToDescriptionMap; // A map of all values that are allowed for this label
   default_value?: number;
 }
 
+export function isQuickLabelInfo(
+  l: LabelInfo
+): l is QuickLabelInfo | (QuickLabelInfo & DetailedLabelInfo) {
+  const quickLabelInfo = l as QuickLabelInfo;
+  return (
+    quickLabelInfo.approved !== undefined ||
+    quickLabelInfo.rejected !== undefined ||
+    quickLabelInfo.recommended !== undefined ||
+    quickLabelInfo.disliked !== undefined ||
+    quickLabelInfo.blocking !== undefined ||
+    quickLabelInfo.blocking !== undefined ||
+    quickLabelInfo.value !== undefined
+  );
+}
+
+export function isDetailedLabelInfo(
+  label: LabelInfo
+): label is DetailedLabelInfo | (QuickLabelInfo & DetailedLabelInfo) {
+  return !!(label as DetailedLabelInfo).values;
+}
+
 // https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#contributor-agreement-input
 export interface ContributorAgreementInput {
   name?: string;
@@ -212,9 +258,10 @@
   deletions: number; // Number of deleted lines
   total_comment_count?: number;
   unresolved_comment_count?: number;
+  // TODO(TS): Use changed_id everywhere in code instead of (legacy) _number
   _number: NumericChangeId;
   owner: AccountInfo;
-  actions?: ActionInfo[];
+  actions?: ActionNameToActionInfoMap;
   requirements?: Requirement[];
   labels?: LabelNameToInfoMap;
   permitted_labels?: LabelNameToValueMap;
@@ -325,17 +372,17 @@
  */
 export interface GroupBaseInfo {
   id: GroupId;
-  name: string;
+  name: GroupName;
 }
 
 /**
  * The GroupInfo entity contains information about a group. This can be a
  * Gerrit internal group, or an external group that is known to Gerrit.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-groups.html
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-groups.html#group-info
  */
 export interface GroupInfo {
   id: GroupId;
-  name?: string;
+  name?: GroupName;
   url?: string;
   options?: GroupOptionsInfo;
   description?: string;
@@ -353,10 +400,10 @@
 /**
  * The 'GroupInput' entity contains information for the creation of a new
  * internal group.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-groups.html
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-groups.html#group-input
  */
 export interface GroupInput {
-  name?: string;
+  name?: GroupName;
   uuid?: string;
   description?: string;
   visible_to_all?: string;
@@ -407,14 +454,34 @@
  * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#action-info
  */
 export interface ActionInfo {
-  __key?: string;
-  __url?: string;
   method?: HttpMethod; // Most actions use POST, PUT or DELETE to cause state changes.
   label?: string; // Short title to display to a user describing the action
   title?: string; // Longer text to display describing the action
   enabled?: boolean; // not set if false
 }
 
+export interface ActionNameToActionInfoMap {
+  [actionType: string]: ActionInfo | undefined;
+  // List of actions explicitly used in code:
+  wip?: ActionInfo;
+  publishEdit?: ActionInfo;
+  rebaseEdit?: ActionInfo;
+  deleteEdit?: ActionInfo;
+  edit?: ActionInfo;
+  stopEdit?: ActionInfo;
+  download?: ActionInfo;
+  rebase?: ActionInfo;
+  cherrypick?: ActionInfo;
+  move?: ActionInfo;
+  revert?: ActionInfo;
+  revert_submission?: ActionInfo;
+  abandon?: ActionInfo;
+  submit?: ActionInfo;
+  topic?: ActionInfo;
+  hashtags?: ActionInfo;
+  assignee?: ActionInfo;
+}
+
 /**
  * The Requirement entity contains information about a requirement relative to
  * a change.
@@ -677,10 +744,10 @@
 /**
  * The AuthInfo entity contains information about the authentication
  * configuration of the Gerrit server.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#auth-info
  */
 export interface AuthInfo {
-  type: string;
+  auth_type: AuthType; // docs incorrectly names it 'type'
   use_contributor_agreements: boolean;
   contributor_agreements?: ContributorAgreementInfo;
   editable_account_fields: string;
@@ -982,15 +1049,24 @@
   download: DownloadInfo;
   gerrit: GerritInfo;
   index: IndexConfigInfo;
-  note_db_enabled: boolean;
+  note_db_enabled?: boolean;
   plugin: PluginConfigInfo;
   receive?: ReceiveInfo;
+  sshd?: SshdInfo;
   suggest: SuggestInfo;
   user: UserConfigInfo;
   default_theme?: string;
 }
 
 /**
+ * The SshdInfo entity contains information about Gerrit configuration from the sshd section.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#sshd-info
+ * This entity doesn’t contain any data, but the presence of this (empty) entity
+ * in the ServerInfo entity means that SSHD is enabled on the server.
+ */
+export type SshdInfo = {};
+
+/**
  * The SuggestInfo entity contains information about Gerritconfiguration from
  * the suggest section.
  * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#suggest-info
@@ -1049,17 +1125,17 @@
 
 /**
  * The TopMenuEntryInfo entity contains information about a top menu entry.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#top-menu-entry-info
  */
 export interface TopMenuEntryInfo {
   name: string;
-  items: string;
+  items: TopMenuItemInfo[];
 }
 
 /**
  * The TopMenuItemInfo entity contains information about a menu item ina top
  * menu entry.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#top-menu-item-info
  */
 export interface TopMenuItemInfo {
   url: string;
@@ -1081,7 +1157,7 @@
  * The CommentInfo entity contains information about an inline comment.
  * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#comment-info
  */
-export interface CommentInfo extends CommentInput {
+export interface CommentInfo {
   patch_set?: PatchSetNum;
   id: UrlEncodedCommentId;
   path?: string;
@@ -1455,11 +1531,12 @@
   project: RepoName;
   defining_project: RepoName;
   ref: string; // The name of the ref in which the dashboard is defined, without the refs/meta/dashboards/ prefix
+  path: string;
   description?: string;
   foreach?: string;
   url: string;
   is_default?: boolean;
-  title?: boolean;
+  title?: string;
   sections: DashboardSectionInfo[];
 }
 
@@ -1683,25 +1760,11 @@
 /**
  * The PreferencesInput entity contains information for setting the user preferences. Fields which are not set will not be updated
  * https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#preferences-input
+ *
+ * Note: the doc missed several properties. Java code uses the same class (GeneralPreferencesInfo)
+ * both for input data and for response data.
  */
-export interface PreferencesInput {
-  changes_per_page?: 10 | 25 | 50 | 100;
-  theme?: AppTheme;
-  expand_inline_diffs?: boolean;
-  download_scheme?: string;
-  date_format?: DateFormat;
-  time_format?: TimeFormat;
-  relative_date_in_change_table?: boolean;
-  diff_view?: DiffViewMode;
-  size_bar_in_change_table?: boolean;
-  legacycid_in_change_table?: boolean;
-  mute_common_path_prefixes?: boolean;
-  signed_off_by?: boolean;
-  my?: TopMenuItemInfo[];
-  change_table?: string[];
-  email_strategy?: EmailStrategy;
-  default_base_for_merges?: DefaultBase;
-}
+export type PreferencesInput = Partial<PreferencesInfo>;
 
 /**
  * The DiffPreferencesInput entity contains information for setting the diff preferences of a user. Fields which are not set will not be updated
@@ -1798,6 +1861,8 @@
   default_base_for_merges: DefaultBase;
   publish_comments_on_push?: boolean;
   work_in_progress_by_default?: boolean;
+  // The email_format doesn't mentioned in doc, but exists in Java class GeneralPreferencesInfo
+  email_format?: EmailFormat;
   // The following property doesn't exist in RestAPI, it is added by GrRestApiInterface
   default_diff_view?: DiffViewMode;
 }
@@ -2143,3 +2208,28 @@
   changes: ChangeInfo[];
   non_visible_changes: number;
 }
+
+/**
+ * The RevertSubmissionInfo entity describes the revert changes.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#revert-submission-info
+ */
+export interface RevertSubmissionInfo {
+  revert_changes: ChangeInfo[];
+}
+
+/**
+ * The CherryPickInput entity contains information for cherry-picking a change to a new branch.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#cherrypick-input
+ */
+export interface CherryPickInput {
+  message?: string;
+  destination: BranchName;
+  base?: CommitId;
+  parent?: number;
+  notify?: NotifyType;
+  notify_details: RecipientTypeToNotifyInfoMap;
+  keep_reviewers?: boolean;
+  allow_conflicts?: boolean;
+  topic?: TopicName;
+  allow_empty?: boolean;
+}
diff --git a/polygerrit-ui/app/types/globals.ts b/polygerrit-ui/app/types/globals.ts
index 645c991..1b5f330 100644
--- a/polygerrit-ui/app/types/globals.ts
+++ b/polygerrit-ui/app/types/globals.ts
@@ -52,6 +52,10 @@
     };
     STATIC_RESOURCE_PATH?: string;
 
+    PRELOADED_QUERIES?: {
+      dashboardQuery?: string[];
+    };
+
     /** Enhancements on Gr elements or utils */
     // 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/
diff --git a/polygerrit-ui/app/utils/account-util.ts b/polygerrit-ui/app/utils/account-util.ts
index 4504ffd..7e425f8 100644
--- a/polygerrit-ui/app/utils/account-util.ts
+++ b/polygerrit-ui/app/utils/account-util.ts
@@ -15,13 +15,7 @@
  * limitations under the License.
  */
 
-import {
-  AccountId,
-  AccountInfo,
-  ChangeInfo,
-  EmailAddress,
-  ServerInfo,
-} from '../types/common';
+import {AccountId, AccountInfo, EmailAddress} from '../types/common';
 import {AccountTag} from '../constants/constants';
 
 export function accountKey(account: AccountInfo): AccountId | EmailAddress {
@@ -37,24 +31,3 @@
 export function removeServiceUsers(accounts?: AccountInfo[]): AccountInfo[] {
   return accounts?.filter(a => !isServiceUser(a)) || [];
 }
-
-export function isAttentionSetEnabled(config: ServerInfo): boolean {
-  return !!config?.change?.enable_attention_set;
-}
-
-export function canHaveAttention(account: AccountInfo): boolean {
-  return !!account && !!account._account_id && !isServiceUser(account);
-}
-
-export function hasAttention(
-  config: ServerInfo,
-  account: AccountInfo,
-  change: ChangeInfo
-): boolean {
-  return (
-    isAttentionSetEnabled(config) &&
-    canHaveAttention(account) &&
-    !!account._account_id &&
-    !!change?.attention_set?.hasOwnProperty(account._account_id)
-  );
-}
diff --git a/polygerrit-ui/app/utils/admin-nav-util.ts b/polygerrit-ui/app/utils/admin-nav-util.ts
index bb95066..06f4e3a 100644
--- a/polygerrit-ui/app/utils/admin-nav-util.ts
+++ b/polygerrit-ui/app/utils/admin-nav-util.ts
@@ -24,7 +24,7 @@
   RepoName,
   GroupId,
   AccountDetailInfo,
-  CapabilityInfo,
+  AccountCapabilityInfo,
 } from '../types/common';
 import {MenuLink} from '../elements/plugins/gr-admin-api/gr-admin-api';
 import {hasOwnProperty} from './common-util';
@@ -54,12 +54,27 @@
   },
 ];
 
+export interface AdminLink {
+  url: string;
+  text: string;
+  capability: string | null;
+  noBaseUrl: boolean;
+  view: null;
+  viewableToAll: boolean;
+  target: '_blank' | null;
+}
+
+export interface AdminLinks {
+  links: NavLink[];
+  expandedSection?: SubsectionInterface;
+}
+
 export function getAdminLinks(
-  account: AccountDetailInfo,
-  getAccountCapabilities: (params?: string[]) => Promise<CapabilityInfo>,
+  account: AccountDetailInfo | undefined,
+  getAccountCapabilities: () => Promise<AccountCapabilityInfo>,
   getAdminMenuLinks: () => MenuLink[],
   options?: AdminNavLinksOption
-) {
+): Promise<AdminLinks> {
   if (!account) {
     return Promise.resolve(
       _filterLinks(link => !!link.viewableToAll, getAdminMenuLinks, options)
@@ -78,9 +93,9 @@
   filterFn: (link: NavLink) => boolean,
   getAdminMenuLinks: () => MenuLink[],
   options?: AdminNavLinksOption
-) {
-  let links = ADMIN_LINKS.slice(0);
-  let expandedSection;
+): AdminLinks {
+  let links: NavLink[] = ADMIN_LINKS.slice(0);
+  let expandedSection: SubsectionInterface | undefined = undefined;
 
   const isExternalLink = (link: MenuLink) => link.url[0] !== '/';
 
@@ -90,18 +105,18 @@
       return {
         url: link.url,
         name: link.text,
-        capability: link.capability || null,
+        capability: link.capability || undefined,
         noBaseUrl: !isExternalLink(link),
         view: null,
         viewableToAll: !link.capability,
         target: isExternalLink(link) ? '_blank' : null,
-      } as NavLink;
+      };
     })
   );
 
   links = links.filter(filterFn);
 
-  const filteredLinks = [];
+  const filteredLinks: NavLink[] = [];
   const repoName = options && options.repoName;
   const groupId = options && options.groupId;
   const groupName = options && options.groupName;
diff --git a/polygerrit-ui/app/utils/async-util.ts b/polygerrit-ui/app/utils/async-util.ts
index 7e63f70..119b09b 100644
--- a/polygerrit-ui/app/utils/async-util.ts
+++ b/polygerrit-ui/app/utils/async-util.ts
@@ -26,7 +26,7 @@
  */
 export function asyncForeach<T>(
   array: T[],
-  fn: (item: T, stopCallback: () => void) => Promise<T>
+  fn: (item: T, stopCallback: () => void) => Promise<unknown>
 ): Promise<T | void> {
   if (!array.length) {
     return Promise.resolve();
diff --git a/polygerrit-ui/app/utils/attention-set-util.ts b/polygerrit-ui/app/utils/attention-set-util.ts
new file mode 100644
index 0000000..b0aefcb
--- /dev/null
+++ b/polygerrit-ui/app/utils/attention-set-util.ts
@@ -0,0 +1,63 @@
+/**
+ * @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 {AccountInfo, ChangeInfo} from '../types/common';
+import {isServiceUser} from './account-util';
+
+// You would typically use a ServerInfo here, but this utility does not care
+// about all the other parameters in that object.
+interface SimpleServerInfo {
+  change?: {
+    enable_attention_set?: boolean;
+  };
+}
+
+const CONFIG_ENABLED: SimpleServerInfo = {
+  change: {enable_attention_set: true},
+};
+
+export function isAttentionSetEnabled(config?: SimpleServerInfo): boolean {
+  return !!config?.change?.enable_attention_set;
+}
+
+export function canHaveAttention(account?: AccountInfo): boolean {
+  return !!account?._account_id && !isServiceUser(account);
+}
+
+export function hasAttention(
+  config?: SimpleServerInfo,
+  account?: AccountInfo,
+  change?: ChangeInfo
+): boolean {
+  return (
+    isAttentionSetEnabled(config) &&
+    canHaveAttention(account) &&
+    !!change?.attention_set?.hasOwnProperty(account!._account_id!)
+  );
+}
+
+export function getReason(account?: AccountInfo, change?: ChangeInfo) {
+  if (!hasAttention(CONFIG_ENABLED, account, change)) return '';
+  const entry = change!.attention_set![account!._account_id!];
+  return entry?.reason ? entry.reason : '';
+}
+
+export function getLastUpdate(account?: AccountInfo, change?: ChangeInfo) {
+  if (!hasAttention(CONFIG_ENABLED, account, change)) return '';
+  const entry = change!.attention_set![account!._account_id!];
+  return entry?.last_update ? entry.last_update : '';
+}
diff --git a/polygerrit-ui/app/utils/attention-set-util_test.js b/polygerrit-ui/app/utils/attention-set-util_test.js
new file mode 100644
index 0000000..71735d5
--- /dev/null
+++ b/polygerrit-ui/app/utils/attention-set-util_test.js
@@ -0,0 +1,57 @@
+/**
+ * @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.js';
+import {
+  hasAttention, getReason,
+} from './attention-set-util.js';
+
+const KERMIT = {
+  email: 'kermit@gmail.com',
+  username: 'kermit',
+  name: 'Kermit The Frog',
+  _account_id: '31415926535',
+};
+
+suite('attention-set-util', () => {
+  test('hasAttention', () => {
+    const config = {
+      change: {enable_attention_set: true},
+    };
+    const change = {
+      attention_set: {
+        31415926535: {
+          reason: 'a good reason',
+        },
+      },
+    };
+
+    assert.isTrue(hasAttention(config, KERMIT, change));
+  });
+
+  test('getReason', () => {
+    const change = {
+      attention_set: {
+        31415926535: {
+          reason: 'a good reason',
+        },
+      },
+    };
+
+    assert.equal(getReason(KERMIT, change), 'a good reason');
+  });
+});
diff --git a/polygerrit-ui/app/utils/change-util.ts b/polygerrit-ui/app/utils/change-util.ts
index 2c9d9c3..47924e6 100644
--- a/polygerrit-ui/app/utils/change-util.ts
+++ b/polygerrit-ui/app/utils/change-util.ts
@@ -16,7 +16,12 @@
  */
 import {getBaseUrl} from './url-util';
 import {ChangeStatus} from '../constants/constants';
-import {NumericChangeId, PatchSetNum, ChangeInfo} from '../types/common';
+import {
+  NumericChangeId,
+  PatchSetNum,
+  ChangeInfo,
+  AccountInfo,
+} from '../types/common';
 import {ParsedChangeInfo} from '../elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser';
 
 // This can be wrong! See WARNING above
@@ -167,6 +172,23 @@
   return states;
 }
 
+export function isOwner(change?: ChangeInfo, account?: AccountInfo) {
+  if (!change || !account) return false;
+  return change.owner?._account_id === account._account_id;
+}
+
 export function changeStatusString(change: ChangeInfo) {
   return changeStatuses(change).join(', ');
 }
+
+export function isRemovableReviewer(
+  change?: ChangeInfo,
+  reviewer?: AccountInfo
+): boolean {
+  if (!change?.removable_reviewers || !reviewer) return false;
+  return change.removable_reviewers.some(
+    account =>
+      account._account_id === reviewer._account_id ||
+      (!reviewer._account_id && account.email === reviewer.email)
+  );
+}
diff --git a/polygerrit-ui/app/utils/change-util_test.js b/polygerrit-ui/app/utils/change-util_test.js
index 20b9578..fd181fe 100644
--- a/polygerrit-ui/app/utils/change-util_test.js
+++ b/polygerrit-ui/app/utils/change-util_test.js
@@ -21,6 +21,7 @@
   changePath,
   changeStatuses,
   changeStatusString,
+  isRemovableReviewer,
 } from './change-util.js';
 
 suite('change-util tests', () => {
@@ -198,5 +199,19 @@
     assert.deepEqual(statuses, ['Merge Conflict', 'WIP', 'Private']);
     assert.equal(statusString, 'Merge Conflict, WIP, Private');
   });
+
+  test('isRemovableReviewer', () => {
+    let change = {
+      removable_reviewers: [{_account_id: 1}],
+    };
+    const reviewer = {_account_id: 1};
+
+    assert.equal(isRemovableReviewer(change, reviewer), true);
+
+    change = {
+      removable_reviewers: [{_account_id: 2}],
+    };
+    assert.equal(isRemovableReviewer(change, reviewer), false);
+  });
 });
 
diff --git a/polygerrit-ui/app/utils/comment-util.ts b/polygerrit-ui/app/utils/comment-util.ts
new file mode 100644
index 0000000..e598e2b
--- /dev/null
+++ b/polygerrit-ui/app/utils/comment-util.ts
@@ -0,0 +1,140 @@
+/**
+ * @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 {
+  CommentBasics,
+  CommentInfo,
+  PatchSetNum,
+  RobotCommentInfo,
+  Timestamp,
+  UrlEncodedCommentId,
+} from '../types/common';
+import {CommentSide, Side} from '../constants/constants';
+import {parseDate} from './date-util';
+
+export interface DraftCommentProps {
+  __draft?: boolean;
+  __draftID?: string;
+  __date?: Date;
+}
+
+export type DraftInfo = CommentBasics & DraftCommentProps;
+
+/**
+ * Each of the type implements or extends CommentBasics.
+ */
+export type Comment = DraftInfo | CommentInfo | RobotCommentInfo;
+
+export interface UIStateCommentProps {
+  // The `side` of the comment is PARENT or REVISION, but this is LEFT or RIGHT.
+  // TODO(TS): Remove the naming confusion of commentSide being of type of Side,
+  // but side being of type CommentSide. :-)
+  __commentSide?: Side;
+  // TODO(TS): Remove this. Seems to be exactly the same as `path`??
+  __path?: string;
+  collapsed?: boolean;
+  // TODO(TS): Consider allowing this only for drafts.
+  __editing?: boolean;
+  __otherEditing?: boolean;
+}
+
+export type UIDraft = DraftInfo & UIStateCommentProps;
+
+export type UIHuman = CommentInfo & UIStateCommentProps;
+
+export type UIRobot = RobotCommentInfo & UIStateCommentProps;
+
+export type UIComment = UIHuman | UIRobot | UIDraft;
+
+export type CommentMap = {[path: string]: boolean};
+
+export function isRobot<T extends CommentInfo>(
+  x: T | DraftInfo | RobotCommentInfo | undefined
+): x is RobotCommentInfo {
+  return !!x && !!(x as RobotCommentInfo).robot_id;
+}
+
+export function isDraft<T extends CommentInfo>(
+  x: T | UIDraft | undefined
+): x is UIDraft {
+  return !!x && !!(x as UIDraft).__draft;
+}
+
+export interface PatchSetFile {
+  path: string;
+  basePath?: string;
+  patchNum?: PatchSetNum;
+}
+
+export interface PatchNumOnly {
+  patchNum: PatchSetNum;
+}
+
+export function isPatchSetFile(
+  x: PatchSetFile | PatchNumOnly
+): x is PatchSetFile {
+  return !!(x as PatchSetFile).path;
+}
+
+interface SortableComment {
+  __draft?: boolean;
+  __date?: Date;
+  updated?: Timestamp;
+  id?: UrlEncodedCommentId;
+}
+
+export function sortComments<T extends SortableComment>(comments: T[]): T[] {
+  return comments.slice(0).sort((c1, c2) => {
+    const d1 = !!c1.__draft;
+    const d2 = !!c2.__draft;
+    if (d1 !== d2) return d1 ? 1 : -1;
+
+    const date1 = (c1.updated && parseDate(c1.updated)) || c1.__date;
+    const date2 = (c2.updated && parseDate(c2.updated)) || c2.__date;
+    const dateDiff = date1!.valueOf() - date2!.valueOf();
+    if (dateDiff !== 0) return dateDiff;
+
+    const id1 = c1.id ?? '';
+    const id2 = c2.id ?? '';
+    return id1.localeCompare(id2);
+  });
+}
+
+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;
+}
+
+export function getLastComment(thread?: CommentThread): UIComment | undefined {
+  const len = thread?.comments.length;
+  return thread && len ? thread.comments[len - 1] : undefined;
+}
+
+export function isUnresolved(thread?: CommentThread): boolean {
+  return !!getLastComment(thread)?.unresolved;
+}
+
+export function isDraftThread(thread?: CommentThread): boolean {
+  return isDraft(getLastComment(thread));
+}
diff --git a/polygerrit-ui/app/utils/comment-util_test.js b/polygerrit-ui/app/utils/comment-util_test.js
new file mode 100644
index 0000000..ad19974
--- /dev/null
+++ b/polygerrit-ui/app/utils/comment-util_test.js
@@ -0,0 +1,34 @@
+/**
+ * @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.js';
+import {
+  isUnresolved,
+} from './comment-util.js';
+
+suite('comment-util', () => {
+  test('isUnresolved', () => {
+    assert.isFalse(isUnresolved(undefined));
+    assert.isFalse(isUnresolved({comments: []}));
+    assert.isTrue(isUnresolved({comments: [{unresolved: true}]}));
+    assert.isFalse(isUnresolved({comments: [{unresolved: false}]}));
+    assert.isTrue(isUnresolved(
+        {comments: [{unresolved: false}, {unresolved: true}]}));
+    assert.isFalse(isUnresolved(
+        {comments: [{unresolved: true}, {unresolved: false}]}));
+  });
+});
diff --git a/polygerrit-ui/app/utils/common-util.ts b/polygerrit-ui/app/utils/common-util.ts
index d144272..183e56c 100644
--- a/polygerrit-ui/app/utils/common-util.ts
+++ b/polygerrit-ui/app/utils/common-util.ts
@@ -44,3 +44,25 @@
   console.error(msg, obj);
   throw new Error(msg);
 }
+
+/**
+ * Returns true, if both sets contain the same members.
+ */
+export function areSetsEqual<T>(a: Set<T>, b: Set<T>): boolean {
+  if (a.size !== b.size) {
+    return false;
+  }
+  return containsAll(a, b);
+}
+
+/**
+ * Returns true, if 'set' contains 'subset'.
+ */
+export function containsAll<T>(set: Set<T>, subSet: Set<T>): boolean {
+  for (const value of subSet) {
+    if (!set.has(value)) {
+      return false;
+    }
+  }
+  return true;
+}
diff --git a/polygerrit-ui/app/utils/common-util_test.js b/polygerrit-ui/app/utils/common-util_test.js
index 60c0b0a..917d652b 100644
--- a/polygerrit-ui/app/utils/common-util_test.js
+++ b/polygerrit-ui/app/utils/common-util_test.js
@@ -16,7 +16,7 @@
  */
 
 import '../test/common-test-setup-karma.js';
-import {hasOwnProperty} from './common-util.js';
+import {hasOwnProperty, areSetsEqual, containsAll} from './common-util.js';
 
 suite('common-util tests', () => {
   suite('hasOwnProperty', () => {
@@ -41,4 +41,29 @@
       assert.isFalse(hasOwnProperty(obj, 'def'));
     });
   });
+
+  test('areSetsEqual', () => {
+    assert.isTrue(areSetsEqual(new Set(), new Set()));
+    assert.isTrue(areSetsEqual(new Set([1]), new Set([1])));
+    assert.isTrue(areSetsEqual(new Set([1, 1, 1, 1]), new Set([1])));
+    assert.isTrue(areSetsEqual(new Set([1, 1, 2, 2]), new Set([2, 1, 2, 1])));
+    assert.isTrue(areSetsEqual(new Set([1, 2, 3, 4]), new Set([4, 3, 2, 1])));
+    assert.isFalse(areSetsEqual(new Set(), new Set([1])));
+    assert.isFalse(areSetsEqual(new Set([1]), new Set([2])));
+    assert.isFalse(areSetsEqual(new Set([1, 2, 4]), new Set([1, 2, 3])));
+  });
+
+  test('containsAll', () => {
+    assert.isTrue(containsAll(new Set(), new Set()));
+    assert.isTrue(containsAll(new Set([1]), new Set()));
+    assert.isTrue(containsAll(new Set([1]), new Set([1])));
+    assert.isTrue(containsAll(new Set([1, 2]), new Set([1])));
+    assert.isTrue(containsAll(new Set([1, 2]), new Set([2])));
+    assert.isTrue(containsAll(new Set([1, 2, 3, 4]), new Set([1, 4])));
+    assert.isTrue(containsAll(new Set([1, 2, 3, 4]), new Set([1, 2, 3, 4])));
+    assert.isFalse(containsAll(new Set(), new Set([2])));
+    assert.isFalse(containsAll(new Set([1]), new Set([2])));
+    assert.isFalse(containsAll(new Set([1, 2, 3, 4]), new Set([5])));
+    assert.isFalse(containsAll(new Set([1, 2, 3, 4]), new Set([1, 2, 3, 5])));
+  });
 });
diff --git a/polygerrit-ui/app/utils/date-util.ts b/polygerrit-ui/app/utils/date-util.ts
index 7748b9d..3cad21a 100644
--- a/polygerrit-ui/app/utils/date-util.ts
+++ b/polygerrit-ui/app/utils/date-util.ts
@@ -36,25 +36,29 @@
 }
 
 // similar to fromNow from moment.js
-export function fromNow(date: Date) {
+export function fromNow(date: Date, noAgo = false) {
   const now = new Date();
+  const ago = noAgo ? '' : ' ago';
   const secondsAgo = Math.round((now.valueOf() - date.valueOf()) / 1000);
-  if (secondsAgo <= 44) return 'just now';
-  if (secondsAgo <= 89) return 'a minute ago';
+  if (secondsAgo <= 59) return 'just now';
+  if (secondsAgo <= 119) return `1 minute${ago}`;
   const minutesAgo = Math.round(secondsAgo / 60);
-  if (minutesAgo <= 44) return `${minutesAgo} minutes ago`;
-  if (minutesAgo <= 89) return 'an hour ago';
+  if (minutesAgo <= 59) return `${minutesAgo} minutes${ago}`;
+  if (minutesAgo === 60) return `1 hour${ago}`;
+  if (minutesAgo <= 119) return `1 hour ${minutesAgo - 60} min${ago}`;
   const hoursAgo = Math.round(minutesAgo / 60);
-  if (hoursAgo <= 21) return `${hoursAgo} hours ago`;
-  if (hoursAgo <= 35) return 'a day ago';
+  if (hoursAgo <= 23) return `${hoursAgo} hours${ago}`;
+  if (hoursAgo === 24) return `1 day${ago}`;
+  if (hoursAgo <= 47) return `1 day ${hoursAgo - 24} hr${ago}`;
   const daysAgo = Math.round(hoursAgo / 24);
-  if (daysAgo <= 25) return `${daysAgo} days ago`;
-  if (daysAgo <= 45) return 'a month ago';
+  if (daysAgo <= 30) return `${daysAgo} days${ago}`;
+  if (daysAgo <= 60) return `1 month${ago}`;
   const monthsAgo = Math.round(daysAgo / 30);
-  if (daysAgo <= 319) return `${monthsAgo} months ago`;
-  if (daysAgo <= 547) return 'a year ago';
+  if (monthsAgo <= 11) return `${monthsAgo} months${ago}`;
+  if (monthsAgo === 12) return `1 year${ago}`;
+  if (monthsAgo <= 24) return `1 year ${monthsAgo - 12} m${ago}`;
   const yearsAgo = Math.round(daysAgo / 365);
-  return `${yearsAgo} years ago`;
+  return `${yearsAgo} years${ago}`;
 }
 
 /**
diff --git a/polygerrit-ui/app/utils/date-util_test.js b/polygerrit-ui/app/utils/date-util_test.js
index 7b22cc6..a003c65 100644
--- a/polygerrit-ui/app/utils/date-util_test.js
+++ b/polygerrit-ui/app/utils/date-util_test.js
@@ -39,15 +39,18 @@
       const fakeNow = new Date('May 08 2020 12:00:00');
       sinon.useFakeTimers(fakeNow.getTime());
       assert.equal('just now', fromNow(new Date('May 08 2020 11:59:30')));
-      assert.equal('a minute ago', fromNow(new Date('May 08 2020 11:59:00')));
+      assert.equal('1 minute ago', fromNow(new Date('May 08 2020 11:59:00')));
       assert.equal('5 minutes ago', fromNow(new Date('May 08 2020 11:55:00')));
-      assert.equal('an hour ago', fromNow(new Date('May 08 2020 11:00:00')));
+      assert.equal('1 hour ago', fromNow(new Date('May 08 2020 11:00:00')));
+      assert.equal(
+          '1 hour 5 min ago', fromNow(new Date('May 08 2020 10:55:00')));
       assert.equal('3 hours ago', fromNow(new Date('May 08 2020 9:00:00')));
-      assert.equal('a day ago', fromNow(new Date('May 07 2020 12:00:00')));
+      assert.equal('1 day ago', fromNow(new Date('May 07 2020 12:00:00')));
+      assert.equal('1 day 2 hr ago', fromNow(new Date('May 07 2020 10:00:00')));
       assert.equal('3 days ago', fromNow(new Date('May 05 2020 12:00:00')));
-      assert.equal('a month ago', fromNow(new Date('Apr 05 2020 12:00:00')));
+      assert.equal('1 month ago', fromNow(new Date('Apr 05 2020 12:00:00')));
       assert.equal('2 months ago', fromNow(new Date('Mar 05 2020 12:00:00')));
-      assert.equal('a year ago', fromNow(new Date('May 05 2019 12:00:00')));
+      assert.equal('1 year ago', fromNow(new Date('May 05 2019 12:00:00')));
       assert.equal('10 years ago', fromNow(new Date('May 05 2010 12:00:00')));
     });
   });
@@ -118,4 +121,4 @@
           formatDate(new Date('Jul 03 2013 00:15:00'), timeFormat));
     });
   });
-});
\ No newline at end of file
+});
diff --git a/polygerrit-ui/app/utils/display-name-util.ts b/polygerrit-ui/app/utils/display-name-util.ts
index 21c4e09..7114f98 100644
--- a/polygerrit-ui/app/utils/display-name-util.ts
+++ b/polygerrit-ui/app/utils/display-name-util.ts
@@ -84,5 +84,5 @@
 export const _testOnly_accountEmail = _accountEmail;
 
 export function getGroupDisplayName(group: GroupInfo) {
-  return (group.name || '') + ' (group)';
+  return `${group.name || ''} (group)`;
 }
diff --git a/polygerrit-ui/app/utils/dom-util.ts b/polygerrit-ui/app/utils/dom-util.ts
index ae6d616..76db40b 100644
--- a/polygerrit-ui/app/utils/dom-util.ts
+++ b/polygerrit-ui/app/utils/dom-util.ts
@@ -211,13 +211,13 @@
 export function descendedFromClass(
   element: Element,
   className: string,
-  opt_stopElement: Element
+  stopElement?: Element
 ) {
   let isDescendant = element.classList.contains(className);
   while (
     !isDescendant &&
     element.parentElement &&
-    (!opt_stopElement || element.parentElement !== opt_stopElement)
+    (!stopElement || element.parentElement !== stopElement)
   ) {
     isDescendant = element.classList.contains(className);
     element = element.parentElement;
diff --git a/polygerrit-ui/app/utils/label-util.ts b/polygerrit-ui/app/utils/label-util.ts
new file mode 100644
index 0000000..3a2556f
--- /dev/null
+++ b/polygerrit-ui/app/utils/label-util.ts
@@ -0,0 +1,44 @@
+/**
+ * @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 {
+  ApprovalInfo,
+  isDetailedLabelInfo,
+  LabelInfo,
+  VotingRangeInfo,
+} from '../types/common';
+
+// Name of the standard Code-Review label.
+export const CODE_REVIEW = 'Code-Review';
+
+export function getVotingRange(label?: LabelInfo): VotingRangeInfo | undefined {
+  if (!label || !isDetailedLabelInfo(label)) return undefined;
+  const values = Object.keys(label.values).map(v => parseInt(v, 10));
+  values.sort((a, b) => a - b);
+  if (!values.length) return undefined;
+  return {min: values[0], max: values[values.length - 1]};
+}
+
+export function getVotingRangeOrDefault(label?: LabelInfo): VotingRangeInfo {
+  const range = getVotingRange(label);
+  return range ? range : {min: 0, max: 0};
+}
+
+export function getMaxAccounts(label?: LabelInfo): ApprovalInfo[] {
+  if (!label || !isDetailedLabelInfo(label) || !label.all) return [];
+  const votingRange = getVotingRangeOrDefault(label);
+  return label.all.filter(account => account.value === votingRange.max);
+}
diff --git a/polygerrit-ui/app/utils/label-util_test.js b/polygerrit-ui/app/utils/label-util_test.js
new file mode 100644
index 0000000..d6f7b3e
--- /dev/null
+++ b/polygerrit-ui/app/utils/label-util_test.js
@@ -0,0 +1,90 @@
+/**
+ * @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.js';
+import {
+  getVotingRange,
+  getVotingRangeOrDefault,
+  getMaxAccounts,
+} from './label-util.js';
+
+const VALUES_1 = {
+  '-1': 'bad',
+  '0': 'neutral',
+  '+1': 'good',
+};
+
+const VALUES_2 = {
+  '-1': 'bad',
+  '+2': 'perfect',
+  '0': 'neutral',
+  '-2': 'blocking',
+  '+1': 'good',
+};
+
+suite('label-util', () => {
+  test('getVotingRange -1 to +1', () => {
+    const label = {values: VALUES_1};
+    const expectedRange = {min: -1, max: 1};
+    assert.deepEqual(getVotingRange(label), expectedRange);
+    assert.deepEqual(getVotingRangeOrDefault(label), expectedRange);
+  });
+
+  test('getVotingRange -2 to +2', () => {
+    const label = {values: VALUES_2};
+    const expectedRange = {min: -2, max: 2};
+    assert.deepEqual(getVotingRange(label), expectedRange);
+    assert.deepEqual(getVotingRangeOrDefault(label), expectedRange);
+  });
+
+  test('getVotingRange empty values', () => {
+    const label = {
+      values: {},
+    };
+    const expectedRange = {min: 0, max: 0};
+    assert.isUndefined(getVotingRange(label));
+    assert.deepEqual(getVotingRangeOrDefault(label), expectedRange);
+  });
+
+  test('getVotingRange no values', () => {
+    const label = {};
+    const expectedRange = {min: 0, max: 0};
+    assert.isUndefined(getVotingRange(label));
+    assert.deepEqual(getVotingRangeOrDefault(label), expectedRange);
+  });
+
+  test('getMaxAccounts', () => {
+    const label = {
+      values: VALUES_2,
+      all: [
+        {value: 2, _account_id: 314},
+        {value: 1, _account_id: 777},
+      ],
+    };
+
+    const maxAccounts = getMaxAccounts(label);
+
+    assert.equal(maxAccounts.length, 1);
+    assert.equal(maxAccounts[0]._account_id, 314);
+  });
+
+  test('getMaxAccounts unset parameters', () => {
+    assert.isEmpty(getMaxAccounts());
+    assert.isEmpty(getMaxAccounts({}));
+    assert.isEmpty(getMaxAccounts({values: VALUES_2}));
+  });
+});
diff --git a/polygerrit-ui/app/utils/url-util.ts b/polygerrit-ui/app/utils/url-util.ts
index 612f05c..0c6fabc 100644
--- a/polygerrit-ui/app/utils/url-util.ts
+++ b/polygerrit-ui/app/utils/url-util.ts
@@ -29,13 +29,10 @@
 /**
  * Get the docs base URL from either the server config or by probing.
  *
- * @param config The server config.
- * @param restApi A REST API instance
- * @return A promise that resolves with the docs base
- *     URL.
+ * @return A promise that resolves with the docs base URL.
  */
 export function getDocsBaseUrl(
-  config: ServerInfo,
+  config: ServerInfo | undefined,
   restApi: RestApiService
 ): Promise<string | null> {
   if (!getDocsBaseUrlCachedPromise) {
diff --git a/proto/cache.proto b/proto/cache.proto
index 7924cbd..121cdb8 100644
--- a/proto/cache.proto
+++ b/proto/cache.proto
@@ -76,7 +76,7 @@
 // Instead, we just take the tedious yet simple approach of having a "has_foo"
 // field for each nullable field "foo", indicating whether or not foo is null.
 //
-// Next ID: 24
+// Next ID: 25
 message ChangeNotesStateProto {
   // Effectively required, even though the corresponding ChangeNotesState field
   // is optional, since the field is only absent when NoteDb is disabled, in
@@ -218,7 +218,11 @@
     string operation = 3;
     string reason = 4;
   }
+  // Only includes the most recent attention set update for each user.
   repeated AttentionSetUpdateProto attention_set_update = 23;
+
+  // Includes all attention set updates.
+  repeated AttentionSetUpdateProto all_attention_set_update = 24;
 }
 
 // Serialized form of com.google.gerrit.server.query.change.ConflictKey
@@ -500,3 +504,25 @@
   bytes global_config_revision = 3; // Hash of All-Projects-projects.config. This
                                     // will only be populated for All-Projects.
 }
+
+// Serialized form of com.google.gerrit.server.comment.CommentContextCacheImpl.Key
+// Next ID: 6
+message CommentContextKeyProto {
+  string project = 1;
+  string change_id = 2;
+  int32 patchset = 3;
+  string commentId = 4;
+
+  // hashed with the murmur3_128 hash function
+  string path_hash = 5;
+}
+
+// Serialized form of a list of com.google.gerrit.extensions.common.ContextLineInfo
+// Next ID: 2
+message AllCommentContextProto {
+  message CommentContextProto {
+    int32 line_number = 1;
+    string context_line = 2;
+  }
+  repeated CommentContextProto context = 1;
+}
diff --git a/resources/com/google/gerrit/server/mail/HeaderHtml.soy b/resources/com/google/gerrit/server/mail/ChangeHeader.soy
similarity index 62%
copy from resources/com/google/gerrit/server/mail/HeaderHtml.soy
copy to resources/com/google/gerrit/server/mail/ChangeHeader.soy
index 4710d8c..fde69f1 100644
--- a/resources/com/google/gerrit/server/mail/HeaderHtml.soy
+++ b/resources/com/google/gerrit/server/mail/ChangeHeader.soy
@@ -1,5 +1,5 @@
 /**
- * Copyright (C) 2016 The Android Open Source Project
+ * 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.
@@ -16,5 +16,17 @@
 
 {namespace com.google.gerrit.server.mail.template}
 
-{template .HeaderHtml}
+{template .ChangeHeader kind="text"}
+  {@param attentionSet: ?}
+  {if $attentionSet}
+    Attention is currently required from:{sp}
+    {for $attentionSetUser in $attentionSet}
+      {$attentionSetUser}
+      // add commas or dot.
+      {if isLast($attentionSetUser)}.
+      {else},{sp}
+      {/if}
+    {/for}
+    {\n}
+  {/if}
 {/template}
diff --git a/resources/com/google/gerrit/server/mail/HeaderHtml.soy b/resources/com/google/gerrit/server/mail/ChangeHeaderHtml.soy
similarity index 62%
rename from resources/com/google/gerrit/server/mail/HeaderHtml.soy
rename to resources/com/google/gerrit/server/mail/ChangeHeaderHtml.soy
index 4710d8c..ea12455 100644
--- a/resources/com/google/gerrit/server/mail/HeaderHtml.soy
+++ b/resources/com/google/gerrit/server/mail/ChangeHeaderHtml.soy
@@ -1,5 +1,5 @@
 /**
- * Copyright (C) 2016 The Android Open Source Project
+ * 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.
@@ -14,7 +14,20 @@
  * limitations under the License.
 */
 
+
 {namespace com.google.gerrit.server.mail.template}
 
-{template .HeaderHtml}
+{template .ChangeHeaderHtml}
+  {@param attentionSet: ?}
+  {if $attentionSet}
+    <p> Attention is currently required from:{sp}
+    {for $attentionSetUser in $attentionSet}
+      {$attentionSetUser}
+      //add commas or dot.
+      {if isLast($attentionSetUser)}.
+      {else},{sp}
+      {/if}
+    {/for} </p>
+    {\n}
+  {/if}
 {/template}
diff --git a/tools/BUILD b/tools/BUILD
index 5159177..be12735 100644
--- a/tools/BUILD
+++ b/tools/BUILD
@@ -17,6 +17,54 @@
     visibility = ["//visibility:public"],
 )
 
+JDK11_JVM_OPTS = select({
+    "@bazel_tools//src/conditions:openbsd": ["-Xbootclasspath/p:$(location @bazel_tools//tools/jdk:javac_jar)"],
+    "//conditions:default": [
+        "--add-exports=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED",
+        "--add-exports=jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED",
+        "--add-exports=jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED",
+        "--add-exports=jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED",
+        "--add-exports=jdk.compiler/com.sun.tools.javac.main=ALL-UNNAMED",
+        "--add-exports=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED",
+        "--add-exports=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED",
+        "--add-opens=jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED",
+        "--patch-module=java.compiler=$(location @bazel_tools//tools/jdk:java_compiler_jar)",
+        "--patch-module=jdk.compiler=$(location @bazel_tools//tools/jdk:jdk_compiler_jar)",
+        "--add-opens=java.base/java.nio=ALL-UNNAMED",
+        "--add-opens=java.base/java.lang=ALL-UNNAMED",
+    ],
+})
+
+default_java_toolchain(
+    name = "error_prone_warnings_toolchain_java11",
+    bootclasspath = ["@bazel_tools//tools/jdk:platformclasspath.jar"],
+    forcibly_disable_header_compilation = False,
+    genclass = ["@bazel_tools//tools/jdk:genclass"],
+    header_compiler = ["@bazel_tools//tools/jdk:turbine"],
+    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 = [
+        "-XDskipDuplicateBridges=true",
+        "-g",
+        "-parameters",
+    ],
+    package_configuration = [
+        ":error_prone",
+    ],
+    singlejar = ["@bazel_tools//tools/jdk:singlejar"],
+    source_version = "11",
+    target_version = "11",
+    tools = [
+        "@bazel_tools//tools/jdk:java_compiler_jar",
+        "@bazel_tools//tools/jdk:jdk_compiler_jar",
+    ],
+    visibility = ["//visibility:public"],
+)
+
 # Error Prone errors enabled by default; see ../.bazelrc for how this is
 # enabled. This warnings list is originally based on:
 # https://github.com/bazelbuild/BUILD_file_generator/blob/master/tools/bazel_defs/java.bzl
diff --git a/tools/maven/gerrit-acceptance-framework_pom.xml b/tools/maven/gerrit-acceptance-framework_pom.xml
index 14c726e..2f8c5bc 100644
--- a/tools/maven/gerrit-acceptance-framework_pom.xml
+++ b/tools/maven/gerrit-acceptance-framework_pom.xml
@@ -2,7 +2,7 @@
   <modelVersion>4.0.0</modelVersion>
   <groupId>com.google.gerrit</groupId>
   <artifactId>gerrit-acceptance-framework</artifactId>
-  <version>3.3.0-SNAPSHOT</version>
+  <version>3.4.0-SNAPSHOT</version>
   <packaging>jar</packaging>
   <name>Gerrit Code Review - Acceptance Test Framework</name>
   <description>Framework for Gerrit's acceptance tests</description>
diff --git a/tools/maven/gerrit-extension-api_pom.xml b/tools/maven/gerrit-extension-api_pom.xml
index bd323ba..31cce3b 100644
--- a/tools/maven/gerrit-extension-api_pom.xml
+++ b/tools/maven/gerrit-extension-api_pom.xml
@@ -2,7 +2,7 @@
   <modelVersion>4.0.0</modelVersion>
   <groupId>com.google.gerrit</groupId>
   <artifactId>gerrit-extension-api</artifactId>
-  <version>3.3.0-SNAPSHOT</version>
+  <version>3.4.0-SNAPSHOT</version>
   <packaging>jar</packaging>
   <name>Gerrit Code Review - Extension API</name>
   <description>API for Gerrit Extensions</description>
diff --git a/tools/maven/gerrit-plugin-api_pom.xml b/tools/maven/gerrit-plugin-api_pom.xml
index 3b059e5..5689009 100644
--- a/tools/maven/gerrit-plugin-api_pom.xml
+++ b/tools/maven/gerrit-plugin-api_pom.xml
@@ -2,7 +2,7 @@
   <modelVersion>4.0.0</modelVersion>
   <groupId>com.google.gerrit</groupId>
   <artifactId>gerrit-plugin-api</artifactId>
-  <version>3.3.0-SNAPSHOT</version>
+  <version>3.4.0-SNAPSHOT</version>
   <packaging>jar</packaging>
   <name>Gerrit Code Review - Plugin API</name>
   <description>API for Gerrit Plugins</description>
diff --git a/tools/maven/gerrit-war_pom.xml b/tools/maven/gerrit-war_pom.xml
index b8fa132..4e0d545 100644
--- a/tools/maven/gerrit-war_pom.xml
+++ b/tools/maven/gerrit-war_pom.xml
@@ -2,7 +2,7 @@
   <modelVersion>4.0.0</modelVersion>
   <groupId>com.google.gerrit</groupId>
   <artifactId>gerrit-war</artifactId>
-  <version>3.3.0-SNAPSHOT</version>
+  <version>3.4.0-SNAPSHOT</version>
   <packaging>war</packaging>
   <name>Gerrit Code Review - WAR</name>
   <description>Gerrit WAR</description>
diff --git a/tools/nongoogle.bzl b/tools/nongoogle.bzl
index ff8116b..a8f5af0 100644
--- a/tools/nongoogle.bzl
+++ b/tools/nongoogle.bzl
@@ -102,8 +102,8 @@
 
     maven_jar(
         name = "jackson-core",
-        artifact = "com.fasterxml.jackson.core:jackson-core:2.11.2",
-        sha1 = "bc022ab0f0c83c07f9c52c5ab9a6a4932b15cc35",
+        artifact = "com.fasterxml.jackson.core:jackson-core:2.11.3",
+        sha1 = "c2351800432bdbdd8284c3f5a7f0782a352aa84a",
     )
 
     # Google internal dependencies: these are developed at Google, so there is
diff --git a/version.bzl b/version.bzl
index 78b286b..066d07e 100644
--- a/version.bzl
+++ b/version.bzl
@@ -2,4 +2,4 @@
 # Used by :api_install and :api_deploy targets
 # when talking to the destination repository.
 #
-GERRIT_VERSION = "3.3.0-SNAPSHOT"
+GERRIT_VERSION = "3.4.0-SNAPSHOT"