Merge changes I6b227724,Ic843c87d

* changes:
  Split tokens using the regexp \w word class
  Do not consider `-` part of tokens to highlight
diff --git a/Documentation/dev-bazel.txt b/Documentation/dev-bazel.txt
index 61565f8..c3237ed 100644
--- a/Documentation/dev-bazel.txt
+++ b/Documentation/dev-bazel.txt
@@ -365,6 +365,13 @@
   bazel test --test_tag_filters=api,git //...
 ----
 
+To run the tests against a specific index backend (LUCENE, FAKE):
+----
+  bazel test --test_env=GERRIT_INDEX_TYPE=LUCENE //...
+----
+
+Elastic search is not currently supported in integration tests.
+
 The following values are currently supported for the group name:
 
 * annotation
diff --git a/Documentation/images/gwt-user-review-ui-change-screen-change-info-cannot-merge.png b/Documentation/images/gwt-user-review-ui-change-screen-change-info-cannot-merge.png
deleted file mode 100644
index 69a28ec..0000000
--- a/Documentation/images/gwt-user-review-ui-change-screen-change-info-cannot-merge.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/gwt-user-review-ui-change-screen-change-info.png b/Documentation/images/gwt-user-review-ui-change-screen-change-info.png
deleted file mode 100644
index e92b49d..0000000
--- a/Documentation/images/gwt-user-review-ui-change-screen-change-info.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/gwt-user-review-ui-change-screen-change-update.png b/Documentation/images/gwt-user-review-ui-change-screen-change-update.png
deleted file mode 100644
index 227db40..0000000
--- a/Documentation/images/gwt-user-review-ui-change-screen-change-update.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/gwt-user-review-ui-change-screen-commit-info-merge-commit.png b/Documentation/images/gwt-user-review-ui-change-screen-commit-info-merge-commit.png
deleted file mode 100644
index 097637e..0000000
--- a/Documentation/images/gwt-user-review-ui-change-screen-commit-info-merge-commit.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/gwt-user-review-ui-change-screen-commit-info.png b/Documentation/images/gwt-user-review-ui-change-screen-commit-info.png
deleted file mode 100644
index fe0c1d1..0000000
--- a/Documentation/images/gwt-user-review-ui-change-screen-commit-info.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/gwt-user-review-ui-change-screen-included-in-list.png b/Documentation/images/gwt-user-review-ui-change-screen-included-in-list.png
deleted file mode 100644
index ad30fe2..0000000
--- a/Documentation/images/gwt-user-review-ui-change-screen-included-in-list.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/gwt-user-review-ui-change-screen-not-current.png b/Documentation/images/gwt-user-review-ui-change-screen-not-current.png
deleted file mode 100644
index 9a87c67..0000000
--- a/Documentation/images/gwt-user-review-ui-change-screen-not-current.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/gwt-user-review-ui-change-screen-permalink.png b/Documentation/images/gwt-user-review-ui-change-screen-permalink.png
deleted file mode 100644
index a1aede9..0000000
--- a/Documentation/images/gwt-user-review-ui-change-screen-permalink.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/gwt-user-review-ui-change-screen-plugin-extensions.png b/Documentation/images/gwt-user-review-ui-change-screen-plugin-extensions.png
deleted file mode 100644
index 120b99c..0000000
--- a/Documentation/images/gwt-user-review-ui-change-screen-plugin-extensions.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/gwt-user-review-ui-side-by-side-diff-screen-comment.png b/Documentation/images/gwt-user-review-ui-side-by-side-diff-screen-comment.png
deleted file mode 100644
index 2ecc47e..0000000
--- a/Documentation/images/gwt-user-review-ui-side-by-side-diff-screen-comment.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/gwt-user-review-ui-side-by-side-diff-screen-commented.png b/Documentation/images/gwt-user-review-ui-side-by-side-diff-screen-commented.png
deleted file mode 100644
index 598d18d..0000000
--- a/Documentation/images/gwt-user-review-ui-side-by-side-diff-screen-commented.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/gwt-user-review-ui-side-by-side-diff-screen-keyboard-shortcuts.png b/Documentation/images/gwt-user-review-ui-side-by-side-diff-screen-keyboard-shortcuts.png
deleted file mode 100644
index 6f63f0e4..0000000
--- a/Documentation/images/gwt-user-review-ui-side-by-side-diff-screen-keyboard-shortcuts.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/gwt-user-review-ui-side-by-side-diff-screen-project-and-file.png b/Documentation/images/gwt-user-review-ui-side-by-side-diff-screen-project-and-file.png
deleted file mode 100644
index 836964b..0000000
--- a/Documentation/images/gwt-user-review-ui-side-by-side-diff-screen-project-and-file.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/gwt-user-review-ui-side-by-side-diff-screen.png b/Documentation/images/gwt-user-review-ui-side-by-side-diff-screen.png
deleted file mode 100644
index d76ecef..0000000
--- a/Documentation/images/gwt-user-review-ui-side-by-side-diff-screen.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/user-review-ui-change-screen-change-update.png b/Documentation/images/user-review-ui-change-screen-change-update.png
new file mode 100644
index 0000000..fe07ef9
--- /dev/null
+++ b/Documentation/images/user-review-ui-change-screen-change-update.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-change-screen-plugin-extensions.png b/Documentation/images/user-review-ui-change-screen-plugin-extensions.png
new file mode 100644
index 0000000..5d6fee7
--- /dev/null
+++ b/Documentation/images/user-review-ui-change-screen-plugin-extensions.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-side-by-side-diff-screen.png b/Documentation/images/user-review-ui-side-by-side-diff-screen.png
new file mode 100644
index 0000000..74d02e3
--- /dev/null
+++ b/Documentation/images/user-review-ui-side-by-side-diff-screen.png
Binary files differ
diff --git a/Documentation/js_licenses.txt b/Documentation/js_licenses.txt
index 0d8da89..813ff44 100644
--- a/Documentation/js_licenses.txt
+++ b/Documentation/js_licenses.txt
@@ -1077,6 +1077,38 @@
 ----
 
 
+[[immer]]
+immer
+
+* immer
+
+[[immer_license]]
+----
+MIT License
+
+Copyright (c) 2017 Michel Weststrate
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+----
+
+
 [[isarray]]
 isarray
 
diff --git a/Documentation/licenses.txt b/Documentation/licenses.txt
index ed1a336..11f9ff3 100644
--- a/Documentation/licenses.txt
+++ b/Documentation/licenses.txt
@@ -4036,6 +4036,38 @@
 ----
 
 
+[[immer]]
+immer
+
+* immer
+
+[[immer_license]]
+----
+MIT License
+
+Copyright (c) 2017 Michel Weststrate
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+----
+
+
 [[isarray]]
 isarray
 
diff --git a/Documentation/linux-quickstart.txt b/Documentation/linux-quickstart.txt
index e34071f..c45de05 100644
--- a/Documentation/linux-quickstart.txt
+++ b/Documentation/linux-quickstart.txt
@@ -104,7 +104,7 @@
 Now that you have a simple version of Gerrit running, use the installation to
 explore the user interface and learn about Gerrit. For more detailed
 installation instructions, see
-link:[Standalone Daemon Installation Guide](install.html).
+link:install.html[Standalone Daemon Installation Guide].
 
 GERRIT
 ------
diff --git a/Documentation/repository-maintenance.txt b/Documentation/repository-maintenance.txt
index 1672436..4bf84b5 100644
--- a/Documentation/repository-maintenance.txt
+++ b/Documentation/repository-maintenance.txt
@@ -28,7 +28,7 @@
 
 Unlike a typical server database, access to Git repositories is not
 marshalled through a single process or a set of inter communicating
-processes. Unfortuntatlely the design of the on-disk layout of a Git
+processes. Unfortunately the design of the on-disk layout of a Git
 repository does not allow for 100% race free operations when accessed by
 multiple actors concurrently. These design shortcomings are more likely
 to impact the operations of busy repositories since racy conditions are
diff --git a/Documentation/rest-api-accounts.txt b/Documentation/rest-api-accounts.txt
index 8aa3173..410bf42 100644
--- a/Documentation/rest-api-accounts.txt
+++ b/Documentation/rest-api-accounts.txt
@@ -1760,7 +1760,7 @@
   [
     {
       "identity": "username:john",
-      "email": "john.doe@example.com",
+      "email_address": "john.doe@example.com",
       "trusted": true
     }
   ]
@@ -2250,7 +2250,7 @@
 |============================
 |Field Name        ||Description
 |`identity`        ||The account external id.
-|`email`           |optional|The email address for the external id.
+|`email_address`   |optional|The email address for the external id.
 |`trusted`         |not set if `false`|
 Whether the external id is trusted.
 |`can_delete`      |not set if `false`|
diff --git a/Documentation/user-review-ui.txt b/Documentation/user-review-ui.txt
index d4fe6b5..6f5f729 100644
--- a/Documentation/user-review-ui.txt
+++ b/Documentation/user-review-ui.txt
@@ -218,7 +218,7 @@
 ** [[plugin-actions]]Further actions may be available if plugins are installed.
 
 +
-image::images/user-review-ui-change-screen-change-info-actions.png[width=600, link="images/user-review-ui-change-screen-change-info-actions.png"]
+image::images/user-review-ui-change-screen-change-info-actions.png[width=400, link="images/user-review-ui-change-screen-change-info-actions.png"]
 
 - [[labels]]Labels & Votes:
 +
@@ -259,7 +259,7 @@
 set is currently viewed can be seen from the `Patch Sets` drop-down
 panel in the change header.
 
-image::images/user-review-ui-change-screen-patch-sets.png[width=487, link="images/user-review-ui-change-screen-patch-sets.png"]
+image::images/user-review-ui-change-screen-patch-sets.png[width=300, link="images/user-review-ui-change-screen-patch-sets.png"]
 
 
 [[download]]
@@ -458,7 +458,7 @@
 comments; a summary comment is only added if the reply popup panel is
 open when the quick approve button is clicked.
 
-image::images/user-review-ui-change-screen-quick-approve.png[width=800, link="images/gwt-user-review-ui-change-screen-quick-approve.png"]
+image::images/user-review-ui-change-screen-quick-approve.png[width=800, link="images/user-review-ui-change-screen-quick-approve.png"]
 
 [[history]]
 === History
@@ -480,16 +480,16 @@
 it is 30 seconds. Polling may also be completely disabled by the
 administrator.
 
-image::images/gwt-user-review-ui-change-screen-change-update.png[width=800, link="images/gwt-user-review-ui-change-screen-change-update.png"]
+image::images/user-review-ui-change-screen-change-update.png[width=400, link="images/user-review-ui-change-screen-change-update.png"]
 
 [[plugin-extensions]]
 === Plugin Extensions
 
-Gerrit plugins may extend the change screen; they can add buttons for
-additional actions to the change info block and display arbitrary UI
-controls below the change info block.
+Gerrit plugins may extend the change screen. Java plugins in the
+backend can add additional actions to the triple-dot menu block.
+Frontend plugins can change the UI controls in arbitrary ways.
 
-image::images/gwt-user-review-ui-change-screen-plugin-extensions.png[width=800, link="images/gwt-user-review-ui-change-screen-plugin-extensions.png"]
+image::images/user-review-ui-change-screen-plugin-extensions.png[width=300, link="images/user-review-ui-change-screen-plugin-extensions.png"]
 
 [[side-by-side]]
 == Side-by-Side Diff Screen
@@ -500,17 +500,8 @@
 
 This screen allows to review a patch and to comment on it.
 
-image::images/gwt-user-review-ui-side-by-side-diff-screen.png[width=800, link="images/gwt-user-review-ui-side-by-side-diff-screen.png"]
+image::images/user-review-ui-side-by-side-diff-screen.png[width=800, link="images/user-review-ui-side-by-side-diff-screen.png"]
 
-[[side-by-side-header]]
-In the screen header the project name and the name of the viewed patch
-file are shown.
-
-If a Git web browser is configured on the server, the project name and
-the file path are displayed as links to the project and the folder in
-the Git web browser.
-
-image::images/gwt-user-review-ui-side-by-side-diff-screen-project-and-file.png[width=800, link="images/gwt-user-review-ui-side-by-side-diff-screen-project-and-file.png"]
 
 [[side-by-side-mark-reviewed]]
 The checkbox in front of the file name allows the
@@ -518,7 +509,7 @@
 diff preference allows to control whether the files should be
 automatically marked as reviewed when they are viewed.
 
-image::images/user-review-ui-side-by-side-diff-screen-reviewed.png[width=800, link="images/gwt-user-review-ui-side-by-side-diff-screen-reviewed.png"]
+image::images/user-review-ui-side-by-side-diff-screen-reviewed.png[width=800, link="images/user-review-ui-side-by-side-diff-screen-reviewed.png"]
 
 [[patch-set-selection]]
 In the header, on each side, the list of patch sets is shown. Clicking
@@ -615,13 +606,9 @@
 For typing the new comment, a new comment box is shown under the code
 that is commented.
 
-Clicking on the `Save` button saves the new comment as a draft. To make
-it visible to other users it must be published from the change screen
-by link:#reply[replying] to the change.
-
-Clicking on the `Discard` button deletes the new comment.
-
-image::images/gwt-user-review-ui-side-by-side-diff-screen-commented.png[width=800, link="images/gwt-user-review-ui-side-by-side-diff-screen-commented.png"]
+Clicking on the `Save` button saves the new comment as a draft. To
+make it visible to other users it must be published from the change
+screen by link:#reply[replying] to the change.
 
 [[file-level-comments]]
 === File Level Comments
@@ -629,7 +616,7 @@
 File level comments are added by clicking the 'File' header at the top
 of the file.
 
-image::images/user-review-ui-side-by-side-diff-screen-file-level-comment.png[width=800, link="images/gwt-user-review-ui-side-by-side-diff-screen-file-level-comment.png"]
+image::images/user-review-ui-side-by-side-diff-screen-file-level-comment.png[width=400, link="images/user-review-ui-side-by-side-diff-screen-file-level-comment.png"]
 
 [[diff-preferences]]
 === Diff Preferences
@@ -639,7 +626,7 @@
 preferences. The diff preferences can be accessed by clicking on the
 settings icon in the screen header.
 
-image::images/user-review-ui-side-by-side-diff-screen-preferences.png[width=800, link="images/gwt-user-review-ui-side-by-side-diff-screen-preferences.png"]
+image::images/user-review-ui-side-by-side-diff-screen-preferences.png[width=800, link="images/user-review-ui-side-by-side-diff-screen-preferences.png"]
 
 The following diff preferences can be configured:
 
@@ -686,7 +673,7 @@
 If many lines are skipped there are additional links to expand the
 context by ten lines before and after the skipped block.
 +
-image::images/user-review-ui-side-by-side-diff-screen-expand-skipped-lines.png[width=800, link="images/gwt-user-review-ui-side-by-side-diff-screen-expand-skipped-lines.png"]
+image::images/user-review-ui-side-by-side-diff-screen-expand-skipped-lines.png[width=800, link="images/user-review-ui-side-by-side-diff-screen-expand-skipped-lines.png"]
 
 - [[syntax-highlighting]]`Syntax Highlighting`:
 +
@@ -716,7 +703,7 @@
 a popup that shows a list of available keyboard shortcuts.
 
 
-image::images/user-review-ui-change-screen-keyboard-shortcuts.png[width=800, link="images/gwt-user-review-ui-change-screen-keyboard-shortcuts.png"]
+image::images/user-review-ui-change-screen-keyboard-shortcuts.png[width=800, link="images/user-review-ui-change-screen-keyboard-shortcuts.png"]
 
 
 In addition, Vim-like commands can be used to link:#key-navigation[
diff --git a/Documentation/user-search.txt b/Documentation/user-search.txt
index a2dc31f..a9779b1 100644
--- a/Documentation/user-search.txt
+++ b/Documentation/user-search.txt
@@ -702,6 +702,18 @@
 to one of the fields in the
 link:rest-api-changes.html#submit-record[SubmitRecord] REST API entity.
 
+`label:Code-Review=MAX`::
++
+Matches changes with label voted with the highest possible score.
+
+`label:Code-Review=MIN`::
++
+Matches changes with label voted with the lowest possible score.
+
+`label:Code-Review=ANY`::
++
+Matches changes with label voted with any score.
+
 `label:Non-Author-Code-Review=need`::
 +
 Matches changes where the submit rules indicate that a label named
diff --git a/java/com/google/gerrit/acceptance/BUILD b/java/com/google/gerrit/acceptance/BUILD
index 5ee1a08..fa62cd9 100644
--- a/java/com/google/gerrit/acceptance/BUILD
+++ b/java/com/google/gerrit/acceptance/BUILD
@@ -75,6 +75,7 @@
     "//java/com/google/gerrit/extensions/restapi/testing:restapi-test-util",
     "//java/com/google/gerrit/gpg/testing:gpg-test-util",
     "//java/com/google/gerrit/git/testing",
+    "//java/com/google/gerrit/index/testing",
 ]
 
 PGM_DEPLOY_ENV = [
diff --git a/java/com/google/gerrit/acceptance/GerritServer.java b/java/com/google/gerrit/acceptance/GerritServer.java
index 93c1237..085fef5 100644
--- a/java/com/google/gerrit/acceptance/GerritServer.java
+++ b/java/com/google/gerrit/acceptance/GerritServer.java
@@ -45,6 +45,8 @@
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.annotations.Exports;
 import com.google.gerrit.extensions.config.FactoryModule;
+import com.google.gerrit.index.IndexType;
+import com.google.gerrit.index.testing.FakeIndexModule;
 import com.google.gerrit.lucene.LuceneIndexModule;
 import com.google.gerrit.pgm.Daemon;
 import com.google.gerrit.pgm.Init;
@@ -449,9 +451,29 @@
     cfg.setString("gitweb", null, "cgi", "");
     cfg.setString(
         "accountPatchReviewDb", null, "url", JdbcAccountPatchReviewStore.TEST_IN_MEMORY_URL);
+
+    String configuredIndexBackend = cfg.getString("index", null, "type");
+    IndexType indexType;
+    if (configuredIndexBackend != null) {
+      // Explicitly configured index backend from gerrit.config trumps any other ways to configure
+      // index backends so that Reindex tests can be explicit about the backend they want to test
+      // against.
+      indexType = new IndexType(configuredIndexBackend);
+    } else {
+      // Allow configuring the index backend based on sys/env variables so that integration tests
+      // can be run against different index backends.
+      indexType = IndexType.fromEnvironment().orElse(new IndexType("fake"));
+    }
+    if (indexType.isLucene()) {
+      daemon.setIndexModule(
+          LuceneIndexModule.singleVersionAllLatest(0, ReplicaUtil.isReplica(baseConfig)));
+    } else {
+      daemon.setIndexModule(FakeIndexModule.latestVersion(false));
+    }
+    // Elastic search is not supported in integration tests yet.
+
     daemon.setEnableHttpd(desc.httpd());
-    daemon.setLuceneModule(
-        LuceneIndexModule.singleVersionAllLatest(0, ReplicaUtil.isReplica(baseConfig)));
+    daemon.setInMemory(true);
     daemon.setDatabaseForTesting(
         ImmutableList.of(
             new InMemoryTestingDatabaseModule(cfg, site, inMemoryRepoManager),
@@ -476,6 +498,8 @@
       String[] additionalArgs)
       throws Exception {
     requireNonNull(site);
+    daemon.addAdditionalSysModuleForTesting(
+        new ReindexProjectsAtStartup.Module(), new ReindexGroupsAtStartup.Module());
     ExecutorService daemonService = Executors.newSingleThreadExecutor();
     String[] args =
         Stream.concat(
diff --git a/java/com/google/gerrit/acceptance/InMemoryTestingDatabaseModule.java b/java/com/google/gerrit/acceptance/InMemoryTestingDatabaseModule.java
index de9a43d..e4b0eea 100644
--- a/java/com/google/gerrit/acceptance/InMemoryTestingDatabaseModule.java
+++ b/java/com/google/gerrit/acceptance/InMemoryTestingDatabaseModule.java
@@ -21,7 +21,9 @@
 import com.google.gerrit.extensions.events.LifecycleListener;
 import com.google.gerrit.lifecycle.LifecycleModule;
 import com.google.gerrit.metrics.MetricMaker;
+import com.google.gerrit.server.config.FileBasedGlobalPluginConfigProvider;
 import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.config.GlobalPluginConfigProvider;
 import com.google.gerrit.server.config.SitePath;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.config.TrackingFooters;
@@ -54,6 +56,7 @@
   @Override
   protected void configure() {
     bind(Config.class).annotatedWith(GerritServerConfig.class).toInstance(cfg);
+    bind(GlobalPluginConfigProvider.class).to(FileBasedGlobalPluginConfigProvider.class);
     bind(Path.class).annotatedWith(SitePath.class).toInstance(sitePath);
 
     if (repoManager != null) {
diff --git a/java/com/google/gerrit/acceptance/testsuite/change/ChangeKindCreator.java b/java/com/google/gerrit/acceptance/testsuite/change/ChangeKindCreator.java
index cb987da..1038a14 100644
--- a/java/com/google/gerrit/acceptance/testsuite/change/ChangeKindCreator.java
+++ b/java/com/google/gerrit/acceptance/testsuite/change/ChangeKindCreator.java
@@ -88,10 +88,10 @@
       throws Exception {
     switch (changeKind) {
       case NO_CODE_CHANGE:
-        noCodeChange(changeId, testRepo, user, project);
+        noCodeChange(changeId, testRepo, user);
         return;
       case REWORK:
-        rework(changeId, testRepo, user, project);
+        rework(changeId, testRepo, user);
         return;
       case TRIVIAL_REBASE:
         trivialRebase(changeId, testRepo, user, project);
@@ -100,7 +100,7 @@
         updateFirstParent(changeId, testRepo, user);
         return;
       case NO_CHANGE:
-        noChange(changeId, testRepo, user, project);
+        noChange(changeId, testRepo, user);
         return;
       default:
         assertWithMessage("unexpected change kind: " + changeKind).fail();
@@ -218,10 +218,7 @@
   }
 
   private void noCodeChange(
-      String changeId,
-      TestRepository<InMemoryRepository> testRepo,
-      TestAccount user,
-      Project.NameKey project)
+      String changeId, TestRepository<InMemoryRepository> testRepo, TestAccount user)
       throws Exception {
     TestRepository<?>.CommitBuilder commitBuilder =
         testRepo.amendRef("HEAD").insertChangeId(changeId.substring(1));
@@ -235,10 +232,7 @@
   }
 
   private void noChange(
-      String changeId,
-      TestRepository<InMemoryRepository> testRepo,
-      TestAccount user,
-      Project.NameKey project)
+      String changeId, TestRepository<InMemoryRepository> testRepo, TestAccount user)
       throws Exception {
     ChangeInfo change = gApi.changes().id(changeId).get();
     String commitMessage = change.revisions.get(change.currentRevision).commit.message;
@@ -255,10 +249,7 @@
   }
 
   private void rework(
-      String changeId,
-      TestRepository<InMemoryRepository> testRepo,
-      TestAccount user,
-      Project.NameKey project)
+      String changeId, TestRepository<InMemoryRepository> testRepo, TestAccount user)
       throws Exception {
     PushOneCommit push =
         pushFactory.create(
diff --git a/java/com/google/gerrit/entities/LabelTypes.java b/java/com/google/gerrit/entities/LabelTypes.java
index 1c38c59..55a9976 100644
--- a/java/com/google/gerrit/entities/LabelTypes.java
+++ b/java/com/google/gerrit/entities/LabelTypes.java
@@ -20,6 +20,7 @@
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.Optional;
 
 public class LabelTypes {
   protected List<LabelType> labelTypes;
@@ -36,12 +37,12 @@
     return labelTypes;
   }
 
-  public LabelType byLabel(LabelId labelId) {
-    return byLabel().get(labelId.get().toLowerCase());
+  public Optional<LabelType> byLabel(LabelId labelId) {
+    return Optional.ofNullable(byLabel().get(labelId.get().toLowerCase()));
   }
 
-  public LabelType byLabel(String labelName) {
-    return byLabel().get(labelName.toLowerCase());
+  public Optional<LabelType> byLabel(String labelName) {
+    return Optional.ofNullable(byLabel().get(labelName.toLowerCase()));
   }
 
   private Map<String, LabelType> byLabel() {
diff --git a/java/com/google/gerrit/entities/Project.java b/java/com/google/gerrit/entities/Project.java
index ef3cbeb..617b827 100644
--- a/java/com/google/gerrit/entities/Project.java
+++ b/java/com/google/gerrit/entities/Project.java
@@ -150,6 +150,7 @@
     return builder;
   }
 
+  @Nullable
   public String getName() {
     return getNameKey() != null ? getNameKey().get() : null;
   }
@@ -183,7 +184,7 @@
 
   @Override
   public final String toString() {
-    return Optional.of(getName()).orElse("<null>");
+    return Optional.ofNullable(getName()).orElse("<null>");
   }
 
   public abstract Builder toBuilder();
diff --git a/java/com/google/gerrit/entities/SubmitRequirement.java b/java/com/google/gerrit/entities/SubmitRequirement.java
index df03fd5..13e0b53 100644
--- a/java/com/google/gerrit/entities/SubmitRequirement.java
+++ b/java/com/google/gerrit/entities/SubmitRequirement.java
@@ -15,6 +15,8 @@
 package com.google.gerrit.entities;
 
 import com.google.auto.value.AutoValue;
+import com.google.gson.Gson;
+import com.google.gson.TypeAdapter;
 import java.util.Optional;
 
 /** Entity describing a requirement that should be met for a change to become submittable. */
@@ -62,6 +64,10 @@
     return new AutoValue_SubmitRequirement.Builder();
   }
 
+  public static TypeAdapter<SubmitRequirement> typeAdapter(Gson gson) {
+    return new AutoValue_SubmitRequirement.GsonTypeAdapter(gson);
+  }
+
   @AutoValue.Builder
   public abstract static class Builder {
 
diff --git a/java/com/google/gerrit/entities/SubmitRequirementExpression.java b/java/com/google/gerrit/entities/SubmitRequirementExpression.java
index c978347..2af1379 100644
--- a/java/com/google/gerrit/entities/SubmitRequirementExpression.java
+++ b/java/com/google/gerrit/entities/SubmitRequirementExpression.java
@@ -17,6 +17,8 @@
 import com.google.auto.value.AutoValue;
 import com.google.common.base.Strings;
 import com.google.gerrit.common.Nullable;
+import com.google.gson.Gson;
+import com.google.gson.TypeAdapter;
 import java.util.Optional;
 
 /** Describe a applicability, blocking or override expression of a {@link SubmitRequirement}. */
@@ -41,4 +43,8 @@
 
   /** Returns the underlying String representing this {@link SubmitRequirementExpression}. */
   public abstract String expressionString();
+
+  public static TypeAdapter<SubmitRequirementExpression> typeAdapter(Gson gson) {
+    return new AutoValue_SubmitRequirementExpression.GsonTypeAdapter(gson);
+  }
 }
diff --git a/java/com/google/gerrit/entities/SubmitRequirementExpressionResult.java b/java/com/google/gerrit/entities/SubmitRequirementExpressionResult.java
index f7a883e..58eb4ac 100644
--- a/java/com/google/gerrit/entities/SubmitRequirementExpressionResult.java
+++ b/java/com/google/gerrit/entities/SubmitRequirementExpressionResult.java
@@ -16,57 +16,78 @@
 
 import com.google.auto.value.AutoValue;
 import com.google.common.collect.ImmutableList;
+import com.google.gson.Gson;
+import com.google.gson.TypeAdapter;
 import java.util.Optional;
 
 /** Result of evaluating a submit requirement expression on a given Change. */
 @AutoValue
 public abstract class SubmitRequirementExpressionResult {
 
-  /**
-   * Entity detailing the result of evaluating a Submit requirement expression. Contains an empty
-   * {@link Optional} if {@link #status()} is equal to {@link Status#ERROR}.
-   */
-  public abstract Optional<PredicateResult> predicateResult();
+  /** Submit requirement expression for which this result is evaluated. */
+  public abstract SubmitRequirementExpression expression();
 
+  /** Status of evaluation. */
+  public abstract Status status();
+
+  /**
+   * Optional error message. Populated if the evaluator fails to evaluate the expression for a
+   * certain change.
+   */
   public abstract Optional<String> errorMessage();
 
-  public Status status() {
-    if (predicateResult().isPresent()) {
-      return predicateResult().get().status() ? Status.PASS : Status.FAIL;
-    }
-    return Status.ERROR;
-  }
-
-  public static SubmitRequirementExpressionResult create(PredicateResult predicateResult) {
-    return new AutoValue_SubmitRequirementExpressionResult(
-        Optional.of(predicateResult), Optional.empty());
-  }
-
-  public static SubmitRequirementExpressionResult error(String errorMessage) {
-    return new AutoValue_SubmitRequirementExpressionResult(
-        Optional.empty(), Optional.of(errorMessage));
-  }
+  /**
+   * List leaf predicates that are fulfilled, for example the expression
+   *
+   * <p><i>label:code-review=+2 and branch:refs/heads/master</i>
+   *
+   * <p>has two leaf predicates:
+   *
+   * <ul>
+   *   <li>label:code-review=+2
+   *   <li>branch:refs/heads/master
+   * </ul>
+   *
+   * This method will return the leaf predicates that were fulfilled, for example if only the first
+   * predicate was fulfilled, the returned list will be equal to ["label:code-review=+2"].
+   */
+  public abstract ImmutableList<String> passingAtoms();
 
   /**
-   * Returns a list of leaf predicate results whose {@link PredicateResult#status()} is true. If
-   * {@link #status()} is equal to {@link Status#ERROR}, an empty list is returned.
+   * List of leaf predicates that are not fulfilled. See {@link #passingAtoms()} for more details.
    */
-  public ImmutableList<PredicateResult> getPassingAtoms() {
-    if (predicateResult().isPresent()) {
-      return predicateResult().get().getAtoms(/* status= */ true);
-    }
-    return ImmutableList.of();
+  public abstract ImmutableList<String> failingAtoms();
+
+  public static SubmitRequirementExpressionResult create(
+      SubmitRequirementExpression expression, PredicateResult predicateResult) {
+    return create(
+        expression,
+        predicateResult.status() ? Status.PASS : Status.FAIL,
+        predicateResult.getPassingAtoms(),
+        predicateResult.getFailingAtoms());
   }
 
-  /**
-   * Returns a list of leaf predicate results whose {@link PredicateResult#status()} is false. If
-   * {@link #status()} is equal to {@link Status#ERROR}, an empty list is returned.
-   */
-  public ImmutableList<PredicateResult> getFailingAtoms() {
-    if (predicateResult().isPresent()) {
-      return predicateResult().get().getAtoms(/* status= */ false);
-    }
-    return ImmutableList.of();
+  public static SubmitRequirementExpressionResult create(
+      SubmitRequirementExpression expression,
+      Status status,
+      ImmutableList<String> passingAtoms,
+      ImmutableList<String> failingAtoms) {
+    return new AutoValue_SubmitRequirementExpressionResult(
+        expression, status, Optional.empty(), passingAtoms, failingAtoms);
+  }
+
+  public static SubmitRequirementExpressionResult error(
+      SubmitRequirementExpression expression, String errorMessage) {
+    return new AutoValue_SubmitRequirementExpressionResult(
+        expression,
+        Status.ERROR,
+        Optional.of(errorMessage),
+        ImmutableList.of(),
+        ImmutableList.of());
+  }
+
+  public static TypeAdapter<SubmitRequirementExpressionResult> typeAdapter(Gson gson) {
+    return new AutoValue_SubmitRequirementExpressionResult.GsonTypeAdapter(gson);
   }
 
   public enum Status {
@@ -103,11 +124,25 @@
     /** true if the predicate is passing for a given change. */
     abstract boolean status();
 
+    /** Returns a list of leaf predicate results whose {@link PredicateResult#status()} is true. */
+    ImmutableList<String> getPassingAtoms() {
+      return getAtoms(/* status= */ true).stream()
+          .map(PredicateResult::predicateString)
+          .collect(ImmutableList.toImmutableList());
+    }
+
+    /** Returns a list of leaf predicate results whose {@link PredicateResult#status()} is false. */
+    ImmutableList<String> getFailingAtoms() {
+      return getAtoms(/* status= */ false).stream()
+          .map(PredicateResult::predicateString)
+          .collect(ImmutableList.toImmutableList());
+    }
+
     /**
      * Returns the list of leaf {@link PredicateResult} whose {@link #status()} is equal to the
      * {@code status} parameter.
      */
-    ImmutableList<PredicateResult> getAtoms(boolean status) {
+    private ImmutableList<PredicateResult> getAtoms(boolean status) {
       ImmutableList.Builder<PredicateResult> atomsList = ImmutableList.builder();
       getAtomsRecursively(atomsList, status);
       return atomsList.build();
diff --git a/java/com/google/gerrit/entities/SubmitRequirementResult.java b/java/com/google/gerrit/entities/SubmitRequirementResult.java
index 7b4d609..e1d5f39 100644
--- a/java/com/google/gerrit/entities/SubmitRequirementResult.java
+++ b/java/com/google/gerrit/entities/SubmitRequirementResult.java
@@ -16,12 +16,17 @@
 
 import com.google.auto.value.AutoValue;
 import com.google.auto.value.extension.memoized.Memoized;
-import com.google.gerrit.entities.SubmitRequirementExpressionResult.Status;
+import com.google.gson.Gson;
+import com.google.gson.TypeAdapter;
 import java.util.Optional;
+import org.eclipse.jgit.lib.ObjectId;
 
 /** Result of evaluating a {@link SubmitRequirement} on a given Change. */
 @AutoValue
 public abstract class SubmitRequirementResult {
+  /** Submit requirement for which this result is evaluated. */
+  public abstract SubmitRequirement submitRequirement();
+
   /** Result of evaluating a {@link SubmitRequirement#applicabilityExpression()} on a change. */
   public abstract Optional<SubmitRequirementExpressionResult> applicabilityExpressionResult();
 
@@ -33,6 +38,9 @@
   /** Result of evaluating a {@link SubmitRequirement#overrideExpression()} ()} on a change. */
   public abstract Optional<SubmitRequirementExpressionResult> overrideExpressionResult();
 
+  /** SHA-1 of the patchset commit ID for which the submit requirement was evaluated. */
+  public abstract ObjectId patchSetCommitId();
+
   @Memoized
   public Status status() {
     if (assertError(submittabilityExpressionResult())
@@ -54,6 +62,10 @@
     return new AutoValue_SubmitRequirementResult.Builder();
   }
 
+  public static TypeAdapter<SubmitRequirementResult> typeAdapter(Gson gson) {
+    return new AutoValue_SubmitRequirementResult.GsonTypeAdapter(gson);
+  }
+
   public enum Status {
     /** Submit requirement is fulfilled. */
     SATISFIED,
@@ -85,6 +97,7 @@
 
   @AutoValue.Builder
   public abstract static class Builder {
+    public abstract Builder submitRequirement(SubmitRequirement submitRequirement);
 
     public abstract Builder applicabilityExpressionResult(
         Optional<SubmitRequirementExpressionResult> value);
@@ -94,6 +107,8 @@
     public abstract Builder overrideExpressionResult(
         Optional<SubmitRequirementExpressionResult> value);
 
+    public abstract Builder patchSetCommitId(ObjectId value);
+
     public abstract SubmitRequirementResult build();
   }
 
diff --git a/java/com/google/gerrit/entities/SubscribeSection.java b/java/com/google/gerrit/entities/SubscribeSection.java
index b95517c..574cae8 100644
--- a/java/com/google/gerrit/entities/SubscribeSection.java
+++ b/java/com/google/gerrit/entities/SubscribeSection.java
@@ -99,9 +99,10 @@
   public ImmutableSet<BranchNameKey> getDestinationBranches(
       BranchNameKey src, Collection<Ref> allRefsInRefsHeads) {
     Set<BranchNameKey> ret = new HashSet<>();
-    logger.atFine().log("Inspecting SubscribeSection %s", this);
-    for (RefSpec r : matchingRefSpecs()) {
-      logger.atFine().log("Inspecting [matching] ref %s", r);
+
+    ImmutableList<RefSpec> matching = matchingRefSpecs();
+    ImmutableList<RefSpec> multiMatch = multiMatchRefSpecs();
+    for (RefSpec r : matching) {
       if (!r.matchSource(src.branch())) {
         continue;
       }
@@ -118,8 +119,7 @@
       }
     }
 
-    for (RefSpec r : multiMatchRefSpecs()) {
-      logger.atFine().log("Inspecting [all] ref %s", r);
+    for (RefSpec r : multiMatch) {
       if (!r.matchSource(src.branch())) {
         continue;
       }
@@ -133,7 +133,9 @@
         }
       }
     }
-    logger.atFine().log("Returning possible branches: %s for project %s", ret, project());
+    logger.atFine().log(
+        "getDestinationBranches(%s): %s. matching refs: %s, multimatch refs: %s",
+        this, ret, matching, multiMatch);
     return ImmutableSet.copyOf(ret);
   }
 
diff --git a/java/com/google/gerrit/extensions/common/ChangeInfoDiffer.java b/java/com/google/gerrit/extensions/common/ChangeInfoDiffer.java
index 0fff0ba..0447e80 100644
--- a/java/com/google/gerrit/extensions/common/ChangeInfoDiffer.java
+++ b/java/com/google/gerrit/extensions/common/ChangeInfoDiffer.java
@@ -71,12 +71,12 @@
   @SuppressWarnings("unchecked") // reflection is used to construct instances of T
   private static <T> T getAdded(T oldValue, T newValue) {
     if (newValue instanceof Collection) {
-      List result = getAddedForCollection((Collection<?>) oldValue, (Collection<?>) newValue);
+      List<?> result = getAddedForCollection((Collection<?>) oldValue, (Collection<?>) newValue);
       return (T) result;
     }
 
     if (newValue instanceof Map) {
-      Map result = getAddedForMap((Map<?, ?>) oldValue, (Map<?, ?>) newValue);
+      Map<?, ?> result = getAddedForMap((Map<?, ?>) oldValue, (Map<?, ?>) newValue);
       return (T) result;
     }
 
diff --git a/java/com/google/gerrit/httpd/init/WebAppInitializer.java b/java/com/google/gerrit/httpd/init/WebAppInitializer.java
index 079f306..91d032e 100644
--- a/java/com/google/gerrit/httpd/init/WebAppInitializer.java
+++ b/java/com/google/gerrit/httpd/init/WebAppInitializer.java
@@ -122,6 +122,8 @@
 import com.google.inject.spi.Message;
 import com.google.inject.util.Providers;
 import java.io.IOException;
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
 import java.nio.file.Path;
 import java.nio.file.Paths;
 import java.util.ArrayList;
@@ -365,6 +367,19 @@
       return LuceneIndexModule.latestVersion(false);
     } else if (indexType.isElasticsearch()) {
       return ElasticIndexModule.latestVersion(false);
+    } else if (indexType.isFake()) {
+      // Use Reflection so that we can omit the fake index binary in production code. Test code does
+      // compile the component in.
+      try {
+        Class<?> clazz = Class.forName("com.google.gerrit.index.testing.FakeIndexModule");
+        Method m = clazz.getMethod("latestVersion", boolean.class);
+        return (Module) m.invoke(null, false);
+      } catch (NoSuchMethodException
+          | ClassNotFoundException
+          | IllegalAccessException
+          | InvocationTargetException e) {
+        throw new IllegalStateException("can't create index", e);
+      }
     } else {
       throw new IllegalStateException("unsupported index.type = " + indexType);
     }
diff --git a/java/com/google/gerrit/httpd/restapi/RestApiServlet.java b/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
index 39db61d..7e6ab58 100644
--- a/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
+++ b/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
@@ -46,6 +46,7 @@
 import static javax.servlet.http.HttpServletResponse.SC_NOT_MODIFIED;
 import static javax.servlet.http.HttpServletResponse.SC_OK;
 import static javax.servlet.http.HttpServletResponse.SC_PRECONDITION_FAILED;
+import static javax.servlet.http.HttpServletResponse.SC_REQUEST_TIMEOUT;
 
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Joiner;
@@ -112,6 +113,7 @@
 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.cancellation.RequestCancelledException;
 import com.google.gerrit.server.change.ChangeFinder;
 import com.google.gerrit.server.change.RevisionResource;
 import com.google.gerrit.server.config.GerritServerConfig;
@@ -225,6 +227,7 @@
   public static final String XD_METHOD = "$m";
   public static final int SC_UNPROCESSABLE_ENTITY = 422;
   public static final int SC_TOO_MANY_REQUESTS = 429;
+  public static final int SC_CLIENT_CLOSED_REQUEST = 499;
 
   private static final int HEAP_EST_SIZE = 10 * 8 * 1024; // Presize 10 blocks.
   private static final String PLAIN_TEXT = "text/plain";
@@ -709,6 +712,25 @@
                 messageOr(e, "Quota limit reached"),
                 e.caching(),
                 e);
+      } catch (RequestCancelledException e) {
+        cause = Optional.of(e);
+        switch (e.getCancellationReason()) {
+          case CLIENT_CLOSED_REQUEST:
+            statusCode = SC_CLIENT_CLOSED_REQUEST;
+            break;
+          case CLIENT_PROVIDED_DEADLINE_EXCEEDED:
+          case SERVER_DEADLINE_EXCEEDED:
+            statusCode = SC_REQUEST_TIMEOUT;
+            break;
+        }
+
+        StringBuilder msg = new StringBuilder(e.formatCancellationReason());
+        if (e.getCancellationMessage().isPresent()) {
+          msg.append("\n\n");
+          msg.append(e.getCancellationMessage().get());
+        }
+
+        responseBytes = replyError(req, res, statusCode, msg.toString(), e);
       } catch (Exception e) {
         cause = Optional.of(e);
         statusCode = SC_INTERNAL_SERVER_ERROR;
diff --git a/java/com/google/gerrit/index/IndexType.java b/java/com/google/gerrit/index/IndexType.java
index cade439..0c3a76a 100644
--- a/java/com/google/gerrit/index/IndexType.java
+++ b/java/com/google/gerrit/index/IndexType.java
@@ -14,8 +14,12 @@
 
 package com.google.gerrit.index;
 
+import static com.google.common.base.Preconditions.checkArgument;
+
+import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.common.Nullable;
+import java.util.Optional;
 
 /**
  * Index types supported by the secondary index.
@@ -28,12 +32,42 @@
  * allows to not break that case upon core implementation changes.
  */
 public class IndexType {
+  public static final String SYS_PROP = "gerrit.index.type";
+  private static final String ENV_VAR = "GERRIT_INDEX_TYPE";
+
   private static final String LUCENE = "lucene";
   private static final String ELASTICSEARCH = "elasticsearch";
   private static final String FAKE = "fake";
 
   private final String type;
 
+  /**
+   * Returns the index type in case it was set by an environment variable. This is useful to run
+   * tests against a certain index backend.
+   */
+  public static Optional<IndexType> fromEnvironment() {
+    String value = System.getenv(ENV_VAR);
+    if (Strings.isNullOrEmpty(value)) {
+      value = System.getProperty(SYS_PROP);
+    }
+    if (Strings.isNullOrEmpty(value)) {
+      return Optional.empty();
+    }
+    value = value.toUpperCase().replace("-", "_");
+    IndexType type = new IndexType(value);
+    if (!Strings.isNullOrEmpty(System.getenv(ENV_VAR))) {
+      checkArgument(
+          type != null, "Invalid value for env variable %s: %s", ENV_VAR, System.getenv(ENV_VAR));
+    } else {
+      checkArgument(
+          type != null,
+          "Invalid value for system property %s: %s",
+          SYS_PROP,
+          System.getProperty(SYS_PROP));
+    }
+    return Optional.of(type);
+  }
+
   public IndexType(@Nullable String type) {
     this.type = type == null ? getDefault() : type.toLowerCase();
   }
diff --git a/java/com/google/gerrit/index/query/IndexPredicate.java b/java/com/google/gerrit/index/query/IndexPredicate.java
index b255833..18d7fbc 100644
--- a/java/com/google/gerrit/index/query/IndexPredicate.java
+++ b/java/com/google/gerrit/index/query/IndexPredicate.java
@@ -62,29 +62,28 @@
    * integer, long) , the matching logic is consistent across this method and all known index
    * implementations. For text fields (i.e. prefix and full-text) the semantics vary between this
    * implementation and known index implementations:
-   * <li>Prefix: Lucene as well as {@link #match(I)} matches terms as true prefixes (prefix:foo ->
-   *     `foo bar` matches, but `baz foo bar` does not match). The index implementation at Google
+   * <li>Prefix: Lucene as well as {@link #match(Object)} matches terms as true prefixes (prefix:foo
+   *     -> `foo bar` matches, but `baz foo bar` does not match). The index implementation at Google
    *     tokenizes both the query and the indexed text and matches tokens individually (prefix:fo ba
    *     -> `baz foo bar` matches).
    * <li>Full text: Lucene uses a {@code PhraseQuery} to search for terms in full text fields
-   *     in-order. The index implementation at Google as well as {@link #match(I)} tokenizes both
-   *     the query and the indexed text and matches tokens individually.
+   *     in-order. The index implementation at Google as well as {@link #match(Object)} tokenizes
+   *     both the query and the indexed text and matches tokens individually.
    *
-   * @return true if the predicate matches the provided {@link I}.
+   * @return true if the predicate matches the provided {@code I}.
    */
   @Override
   public boolean match(I doc) {
     if (getField().isRepeatable()) {
-      Iterable<Object> values = (Iterable<Object>) getField().get(doc);
+      Iterable<?> values = (Iterable<?>) getField().get(doc);
       for (Object v : values) {
         if (matchesSingleObject(v)) {
           return true;
         }
       }
       return false;
-    } else {
-      return matchesSingleObject(getField().get(doc));
     }
+    return matchesSingleObject(getField().get(doc));
   }
 
   @Override
diff --git a/java/com/google/gerrit/index/testing/FakeIndexModuleOnInit.java b/java/com/google/gerrit/index/testing/FakeIndexModuleOnInit.java
new file mode 100644
index 0000000..75d8de2
--- /dev/null
+++ b/java/com/google/gerrit/index/testing/FakeIndexModuleOnInit.java
@@ -0,0 +1,37 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.index.testing;
+
+import com.google.gerrit.index.testing.AbstractFakeIndex.FakeAccountIndex;
+import com.google.gerrit.index.testing.AbstractFakeIndex.FakeGroupIndex;
+import com.google.gerrit.server.index.account.AccountIndex;
+import com.google.gerrit.server.index.group.GroupIndex;
+import com.google.inject.AbstractModule;
+import com.google.inject.assistedinject.FactoryModuleBuilder;
+
+public class FakeIndexModuleOnInit extends AbstractModule {
+  @Override
+  protected void configure() {
+    install(
+        new FactoryModuleBuilder()
+            .implement(AccountIndex.class, FakeAccountIndex.class)
+            .build(AccountIndex.Factory.class));
+
+    install(
+        new FactoryModuleBuilder()
+            .implement(GroupIndex.class, FakeGroupIndex.class)
+            .build(GroupIndex.Factory.class));
+  }
+}
diff --git a/java/com/google/gerrit/pgm/Daemon.java b/java/com/google/gerrit/pgm/Daemon.java
index 29c5788..2b4cfef 100644
--- a/java/com/google/gerrit/pgm/Daemon.java
+++ b/java/com/google/gerrit/pgm/Daemon.java
@@ -87,6 +87,7 @@
 import com.google.gerrit.server.git.SearchingChangeCacheImpl;
 import com.google.gerrit.server.git.WorkQueue;
 import com.google.gerrit.server.group.PeriodicGroupIndexer;
+import com.google.gerrit.server.index.AbstractIndexModule;
 import com.google.gerrit.server.index.IndexModule;
 import com.google.gerrit.server.index.OnlineUpgrader;
 import com.google.gerrit.server.index.VersionManager;
@@ -128,6 +129,8 @@
 import com.google.inject.Provider;
 import com.google.inject.Stage;
 import java.io.IOException;
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
 import java.nio.file.Files;
 import java.nio.file.Path;
 import java.util.ArrayList;
@@ -207,7 +210,7 @@
   private Injector httpdInjector;
   private Path runFile;
   private boolean inMemoryTest;
-  private AbstractModule luceneModule;
+  private AbstractModule indexModule;
   private Module emailModule;
   private List<Module> testSysModules = new ArrayList<>();
   private List<Module> testSshModules = new ArrayList<>();
@@ -336,9 +339,13 @@
   }
 
   @VisibleForTesting
-  public void setLuceneModule(LuceneIndexModule m) {
-    luceneModule = m;
-    inMemoryTest = true;
+  public void setIndexModule(AbstractIndexModule m) {
+    indexModule = m;
+  }
+
+  @VisibleForTesting
+  public void setInMemory(boolean inMemory) {
+    this.inMemoryTest = inMemory;
   }
 
   @VisibleForTesting
@@ -523,8 +530,8 @@
   }
 
   private Module createIndexModule() {
-    if (luceneModule != null) {
-      return luceneModule;
+    if (indexModule != null) {
+      return indexModule;
     }
     if (indexType.isLucene()) {
       return LuceneIndexModule.latestVersion(replica);
@@ -532,6 +539,20 @@
     if (indexType.isElasticsearch()) {
       return ElasticIndexModule.latestVersion(replica);
     }
+    if (indexType.isFake()) {
+      // Use Reflection so that we can omit the fake index binary in production code. Test code does
+      // compile the component in.
+      try {
+        Class<?> clazz = Class.forName("com.google.gerrit.index.testing.FakeIndexModule");
+        Method m = clazz.getMethod("latestVersion", boolean.class);
+        return (Module) m.invoke(null, replica);
+      } catch (NoSuchMethodException
+          | ClassNotFoundException
+          | IllegalAccessException
+          | InvocationTargetException e) {
+        throw new IllegalStateException("can't create index", e);
+      }
+    }
     throw new IllegalStateException("unsupported index.type = " + indexType);
   }
 
diff --git a/java/com/google/gerrit/pgm/Reindex.java b/java/com/google/gerrit/pgm/Reindex.java
index 3935268..6e99007 100644
--- a/java/com/google/gerrit/pgm/Reindex.java
+++ b/java/com/google/gerrit/pgm/Reindex.java
@@ -39,6 +39,8 @@
 import com.google.inject.Injector;
 import com.google.inject.Key;
 import com.google.inject.Module;
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.HashMap;
@@ -154,6 +156,21 @@
     } else if (indexType.isElasticsearch()) {
       indexModule =
           ElasticIndexModule.singleVersionWithExplicitVersions(versions, threads, replica);
+    } else if (indexType.isFake()) {
+      // Use Reflection so that we can omit the fake index binary in production code. Test code does
+      // compile the component in.
+      try {
+        Class<?> clazz = Class.forName("com.google.gerrit.index.testing.FakeIndexModule");
+        Method m =
+            clazz.getMethod(
+                "singleVersionWithExplicitVersions", Map.class, int.class, boolean.class);
+        indexModule = (Module) m.invoke(null, versions, threads, replica);
+      } catch (NoSuchMethodException
+          | ClassNotFoundException
+          | IllegalAccessException
+          | InvocationTargetException e) {
+        throw new IllegalStateException("can't create index", e);
+      }
     } else {
       throw new IllegalStateException("unsupported index.type = " + indexType);
     }
diff --git a/java/com/google/gerrit/pgm/init/BaseInit.java b/java/com/google/gerrit/pgm/init/BaseInit.java
index 62ff66a..c4b0040 100644
--- a/java/com/google/gerrit/pgm/init/BaseInit.java
+++ b/java/com/google/gerrit/pgm/init/BaseInit.java
@@ -32,6 +32,7 @@
 import com.google.gerrit.pgm.init.api.InstallPlugins;
 import com.google.gerrit.pgm.init.api.LibraryDownload;
 import com.google.gerrit.pgm.init.index.IndexManagerOnInit;
+import com.google.gerrit.pgm.init.index.IndexModuleOnInit;
 import com.google.gerrit.pgm.init.index.elasticsearch.ElasticIndexModuleOnInit;
 import com.google.gerrit.pgm.init.index.lucene.LuceneIndexModuleOnInit;
 import com.google.gerrit.pgm.util.SiteProgram;
@@ -57,6 +58,7 @@
 import com.google.inject.util.Providers;
 import java.io.FileNotFoundException;
 import java.io.IOException;
+import java.lang.reflect.InvocationTargetException;
 import java.nio.file.FileVisitResult;
 import java.nio.file.Files;
 import java.nio.file.Path;
@@ -418,6 +420,19 @@
         modules.add(new LuceneIndexModuleOnInit());
       } else if (indexType.isElasticsearch()) {
         modules.add(new ElasticIndexModuleOnInit());
+      } else if (indexType.isFake()) {
+        try {
+          Class<?> clazz = Class.forName("com.google.gerrit.index.testing.FakeIndexModuleOnInit");
+          Module indexOnInitModule = (Module) clazz.getDeclaredConstructor().newInstance();
+          modules.add(indexOnInitModule);
+        } catch (InstantiationException
+            | IllegalAccessException
+            | ClassNotFoundException
+            | NoSuchMethodException
+            | InvocationTargetException e) {
+          throw new IllegalStateException("unable to create fake index", e);
+        }
+        modules.add(new IndexModuleOnInit());
       } else {
         throw new IllegalStateException("unsupported index.type = " + indexType);
       }
diff --git a/java/com/google/gerrit/server/CommentsUtil.java b/java/com/google/gerrit/server/CommentsUtil.java
index 344549e..b18f499 100644
--- a/java/com/google/gerrit/server/CommentsUtil.java
+++ b/java/com/google/gerrit/server/CommentsUtil.java
@@ -438,7 +438,7 @@
       // unignore the test in PortedCommentsIT.
       Map<String, FileDiffOutput> modifiedFiles =
           diffOperations.listModifiedFilesAgainstParent(
-              change.getProject(), patchset.commitId(), /* parentNum= */ null);
+              change.getProject(), patchset.commitId(), /* parentNum= */ 0);
       return modifiedFiles.isEmpty()
           ? null
           : modifiedFiles.values().iterator().next().oldCommitId();
diff --git a/java/com/google/gerrit/server/PatchSetUtil.java b/java/com/google/gerrit/server/PatchSetUtil.java
index d60bc8f..326ddf4 100644
--- a/java/com/google/gerrit/server/PatchSetUtil.java
+++ b/java/com/google/gerrit/server/PatchSetUtil.java
@@ -151,8 +151,10 @@
 
     ApprovalsUtil approvalsUtil = approvalsUtilProvider.get();
     for (PatchSetApproval ap : approvalsUtil.byPatchSet(notes, change.currentPatchSetId())) {
-      LabelType type = projectState.getLabelTypes(notes).byLabel(ap.label());
-      if (type != null && ap.value() == 1 && type.getFunction() == LabelFunction.PATCH_SET_LOCK) {
+      Optional<LabelType> type = projectState.getLabelTypes(notes).byLabel(ap.label());
+      if (type.isPresent()
+          && ap.value() == 1
+          && type.get().getFunction() == LabelFunction.PATCH_SET_LOCK) {
         return true;
       }
     }
diff --git a/java/com/google/gerrit/server/account/AccountResolver.java b/java/com/google/gerrit/server/account/AccountResolver.java
index 75a2b38..103013c 100644
--- a/java/com/google/gerrit/server/account/AccountResolver.java
+++ b/java/com/google/gerrit/server/account/AccountResolver.java
@@ -535,10 +535,10 @@
    * As opposed to {@link #resolve}, the returned result includes all inactive accounts for the
    * input search.
    *
-   * <p>This can be used to resolve Gerrit Account from email to its {@link Account.Id}, to make
-   * sure that if {@link Account} with such email exists in Gerrit (even inactive), user data (email
-   * address) won't be recorded as it is, but instead will be stored as a link to the corresponding
-   * Gerrit Account.
+   * <p>This can be used to resolve Gerrit Account from email to its {@link
+   * com.google.gerrit.entities.Account.Id}, to make sure that if {@link Account} with such email
+   * exists in Gerrit (even inactive), user data (email address) won't be recorded as it is, but
+   * instead will be stored as a link to the corresponding Gerrit Account.
    */
   public Result resolveIncludeInactive(String input) throws ConfigInvalidException, IOException {
     return searchImpl(input, searchers, visibilitySupplierCanSee(), all());
diff --git a/java/com/google/gerrit/server/account/AccountsUpdate.java b/java/com/google/gerrit/server/account/AccountsUpdate.java
index 5a74047..93738b0 100644
--- a/java/com/google/gerrit/server/account/AccountsUpdate.java
+++ b/java/com/google/gerrit/server/account/AccountsUpdate.java
@@ -512,6 +512,10 @@
 
     BatchRefUpdate batchRefUpdate = allUsersRepo.getRefDatabase().newBatchUpdate();
 
+    String externalIdUpdateMessage =
+        updatedAccounts.size() == 1
+            ? Iterables.getOnlyElement(updatedAccounts).message
+            : "Batch update for " + updatedAccounts.size() + " accounts";
     for (UpdatedAccount updatedAccount : updatedAccounts) {
       // These updates are all for different refs (because batches never update the same account
       // more than once), so there can be multiple commits in the same batch, all with the same base
@@ -528,7 +532,7 @@
 
       // These update the same ref, so they need to be stacked on top of one another using the same
       // ExternalIdNotes instance.
-      commitExternalIdUpdates(updatedAccount.message, allUsersRepo, batchRefUpdate);
+      commitExternalIdUpdates(externalIdUpdateMessage, allUsersRepo, batchRefUpdate);
     }
 
     RefUpdateUtil.executeChecked(batchRefUpdate, allUsersRepo);
diff --git a/java/com/google/gerrit/server/account/GroupCache.java b/java/com/google/gerrit/server/account/GroupCache.java
index aaae95a..d8cac71 100644
--- a/java/com/google/gerrit/server/account/GroupCache.java
+++ b/java/com/google/gerrit/server/account/GroupCache.java
@@ -103,6 +103,6 @@
    */
   void evict(AccountGroup.UUID groupUuid);
 
-  /** @see #evict(AccountGroup.UUID); */
+  /** @see #evict(AccountGroup.UUID) */
   void evict(Collection<AccountGroup.UUID> groupUuid);
 }
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalId.java b/java/com/google/gerrit/server/account/externalids/ExternalId.java
index 2d501ad..ac4017a 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalId.java
+++ b/java/com/google/gerrit/server/account/externalids/ExternalId.java
@@ -321,6 +321,8 @@
    */
   public static ExternalId parse(String noteId, byte[] raw, ObjectId blobId)
       throws ConfigInvalidException {
+    requireNonNull(blobId);
+
     Config externalIdConfig = new Config();
     try {
       externalIdConfig.fromText(new String(raw, UTF_8));
@@ -328,13 +330,6 @@
       throw invalidConfig(noteId, e.getMessage());
     }
 
-    return parse(noteId, externalIdConfig, blobId);
-  }
-
-  public static ExternalId parse(String noteId, Config externalIdConfig, ObjectId blobId)
-      throws ConfigInvalidException {
-    requireNonNull(blobId);
-
     Set<String> externalIdKeys = externalIdConfig.getSubsections(EXTERNAL_ID_SECTION);
     if (externalIdKeys.size() != 1) {
       throw invalidConfig(
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalIdNotes.java b/java/com/google/gerrit/server/account/externalids/ExternalIdNotes.java
index e403a5b..50a2f69 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalIdNotes.java
+++ b/java/com/google/gerrit/server/account/externalids/ExternalIdNotes.java
@@ -17,7 +17,6 @@
 import static com.google.common.base.Preconditions.checkState;
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static java.util.Objects.requireNonNull;
-import static java.util.stream.Collectors.joining;
 import static java.util.stream.Collectors.toSet;
 import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
 
@@ -86,7 +85,8 @@
  *
  * <p>After committing the external IDs a cache update can be requested which also reindexes the
  * accounts for which external IDs have been updated (see {@link
- * ExternalIdNotesLoader#updateExternalIdCacheAndMaybeReindexAccounts)}).
+ * ExternalIdNotesLoader#updateExternalIdCacheAndMaybeReindexAccounts(ExternalIdNotes,
+ * Collection)}).
  */
 public class ExternalIdNotes extends VersionedMetaData {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
@@ -487,9 +487,9 @@
 
     Set<ExternalId> newExtIds = new HashSet<>();
     noteMapUpdates.add(
-        (rw, n, f) -> {
+        (rw, n) -> {
           for (ExternalId extId : extIds) {
-            ExternalId insertedExtId = upsert(rw, inserter, noteMap, f, extId);
+            ExternalId insertedExtId = upsert(rw, inserter, noteMap, extId);
             newExtIds.add(insertedExtId);
           }
         });
@@ -516,9 +516,9 @@
     Set<ExternalId> removedExtIds = get(ExternalId.Key.from(extIds));
     Set<ExternalId> updatedExtIds = new HashSet<>();
     noteMapUpdates.add(
-        (rw, n, f) -> {
+        (rw, n) -> {
           for (ExternalId extId : extIds) {
-            ExternalId updatedExtId = upsert(rw, inserter, noteMap, f, extId);
+            ExternalId updatedExtId = upsert(rw, inserter, noteMap, extId);
             updatedExtIds.add(updatedExtId);
           }
         });
@@ -547,9 +547,9 @@
     checkLoaded();
     Set<ExternalId> removedExtIds = new HashSet<>();
     noteMapUpdates.add(
-        (rw, n, f) -> {
+        (rw, n) -> {
           for (ExternalId extId : extIds) {
-            remove(rw, noteMap, f, extId);
+            remove(rw, noteMap, extId);
             removedExtIds.add(extId);
           }
         });
@@ -576,9 +576,9 @@
     checkLoaded();
     Set<ExternalId> removedExtIds = new HashSet<>();
     noteMapUpdates.add(
-        (rw, n, f) -> {
+        (rw, n) -> {
           for (ExternalId.Key extIdKey : extIdKeys) {
-            ExternalId removedExtId = remove(rw, noteMap, f, extIdKey, accountId);
+            ExternalId removedExtId = remove(rw, noteMap, extIdKey, accountId);
             removedExtIds.add(removedExtId);
           }
         });
@@ -594,9 +594,9 @@
     checkLoaded();
     Set<ExternalId> removedExtIds = new HashSet<>();
     noteMapUpdates.add(
-        (rw, n, f) -> {
+        (rw, n) -> {
           for (ExternalId.Key extIdKey : extIdKeys) {
-            ExternalId extId = remove(rw, noteMap, f, extIdKey, null);
+            ExternalId extId = remove(rw, noteMap, extIdKey, null);
             removedExtIds.add(extId);
           }
         });
@@ -624,16 +624,16 @@
     Set<ExternalId> removedExtIds = new HashSet<>();
     Set<ExternalId> updatedExtIds = new HashSet<>();
     noteMapUpdates.add(
-        (rw, n, f) -> {
+        (rw, n) -> {
           for (ExternalId.Key extIdKey : toDelete) {
-            ExternalId removedExtId = remove(rw, noteMap, f, extIdKey, accountId);
+            ExternalId removedExtId = remove(rw, noteMap, extIdKey, accountId);
             if (removedExtId != null) {
               removedExtIds.add(removedExtId);
             }
           }
 
           for (ExternalId extId : toAdd) {
-            ExternalId insertedExtId = upsert(rw, inserter, noteMap, f, extId);
+            ExternalId insertedExtId = upsert(rw, inserter, noteMap, extId);
             updatedExtIds.add(insertedExtId);
           }
         });
@@ -659,14 +659,14 @@
     Set<ExternalId> removedExtIds = new HashSet<>();
     Set<ExternalId> updatedExtIds = new HashSet<>();
     noteMapUpdates.add(
-        (rw, n, f) -> {
+        (rw, n) -> {
           for (ExternalId.Key extIdKey : toDelete) {
-            ExternalId removedExtId = remove(rw, noteMap, f, extIdKey, null);
+            ExternalId removedExtId = remove(rw, noteMap, extIdKey, null);
             removedExtIds.add(removedExtId);
           }
 
           for (ExternalId extId : toAdd) {
-            ExternalId insertedExtId = upsert(rw, inserter, noteMap, f, extId);
+            ExternalId insertedExtId = upsert(rw, inserter, noteMap, extId);
             updatedExtIds.add(insertedExtId);
           }
         });
@@ -745,21 +745,14 @@
     }
 
     try (RevWalk rw = new RevWalk(reader)) {
-      Set<String> footers = new HashSet<>();
       for (NoteMapUpdate noteMapUpdate : noteMapUpdates) {
         try {
-          noteMapUpdate.execute(rw, noteMap, footers);
+          noteMapUpdate.execute(rw, noteMap);
         } catch (DuplicateExternalIdKeyException e) {
           throw new IOException(e);
         }
       }
       noteMapUpdates.clear();
-      if (!footers.isEmpty()) {
-        commit.setMessage(
-            footers.stream()
-                .sorted()
-                .collect(joining("\n", commit.getMessage().trim() + "\n\n", "")));
-      }
 
       RevTree oldTree = revision != null ? rw.parseTree(revision) : null;
       ObjectId newTreeId = noteMap.writeTree(inserter);
@@ -821,17 +814,15 @@
    * <p>If the external ID already exists it is overwritten.
    */
   private static ExternalId upsert(
-      RevWalk rw, ObjectInserter ins, NoteMap noteMap, Set<String> footers, ExternalId extId)
+      RevWalk rw, ObjectInserter ins, NoteMap noteMap, ExternalId extId)
       throws IOException, ConfigInvalidException {
     ObjectId noteId = extId.key().sha1();
     Config c = new Config();
-    if (noteMap.contains(extId.key().sha1())) {
+    if (noteMap.contains(noteId)) {
       ObjectId noteDataId = noteMap.get(noteId);
       byte[] raw = readNoteData(rw, noteDataId);
       try {
         c = new BlobBasedConfig(null, raw);
-        ExternalId oldExtId = ExternalId.parse(noteId.name(), c, noteDataId);
-        addFooters(footers, oldExtId);
       } catch (ConfigInvalidException e) {
         throw new ConfigInvalidException(
             String.format("Invalid external id config for note %s: %s", noteId, e.getMessage()));
@@ -841,9 +832,7 @@
     byte[] raw = c.toText().getBytes(UTF_8);
     ObjectId noteData = ins.insert(OBJ_BLOB, raw);
     noteMap.set(noteId, noteData);
-    ExternalId newExtId = ExternalId.create(extId, noteData);
-    addFooters(footers, newExtId);
-    return newExtId;
+    return ExternalId.create(extId, noteData);
   }
 
   /**
@@ -852,7 +841,7 @@
    * @throws IllegalStateException is thrown if there is an existing external ID that has the same
    *     key, but otherwise doesn't match the specified external ID.
    */
-  private static void remove(RevWalk rw, NoteMap noteMap, Set<String> footers, ExternalId extId)
+  private static void remove(RevWalk rw, NoteMap noteMap, ExternalId extId)
       throws IOException, ConfigInvalidException {
     ObjectId noteId = extId.key().sha1();
     if (!noteMap.contains(noteId)) {
@@ -868,7 +857,6 @@
         extId.toString(),
         actualExtId.toString());
     noteMap.remove(noteId);
-    addFooters(footers, actualExtId);
   }
 
   /**
@@ -880,11 +868,7 @@
    *     exists
    */
   private static ExternalId remove(
-      RevWalk rw,
-      NoteMap noteMap,
-      Set<String> footers,
-      ExternalId.Key extIdKey,
-      Account.Id expectedAccountId)
+      RevWalk rw, NoteMap noteMap, ExternalId.Key extIdKey, Account.Id expectedAccountId)
       throws IOException, ConfigInvalidException {
     ObjectId noteId = extIdKey.sha1();
     if (!noteMap.contains(noteId)) {
@@ -904,17 +888,9 @@
           extId.accountId().get());
     }
     noteMap.remove(noteId);
-    addFooters(footers, extId);
     return extId;
   }
 
-  private static void addFooters(Set<String> footers, ExternalId extId) {
-    footers.add("Account: " + extId.accountId().get());
-    if (extId.email() != null) {
-      footers.add("Email: " + extId.email());
-    }
-  }
-
   private void checkExternalIdsDontExist(Collection<ExternalId> extIds)
       throws DuplicateExternalIdKeyException, IOException {
     checkExternalIdKeysDontExist(ExternalId.Key.from(extIds));
@@ -943,7 +919,7 @@
 
   @FunctionalInterface
   private interface NoteMapUpdate {
-    void execute(RevWalk rw, NoteMap noteMap, Set<String> footers)
+    void execute(RevWalk rw, NoteMap noteMap)
         throws IOException, ConfigInvalidException, DuplicateExternalIdKeyException;
   }
 
diff --git a/java/com/google/gerrit/server/approval/ApprovalInference.java b/java/com/google/gerrit/server/approval/ApprovalInference.java
index 1efbd37..8d409e5 100644
--- a/java/com/google/gerrit/server/approval/ApprovalInference.java
+++ b/java/com/google/gerrit/server/approval/ApprovalInference.java
@@ -52,6 +52,7 @@
 import java.util.Collection;
 import java.util.Collections;
 import java.util.Map;
+import java.util.Optional;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.revwalk.RevWalk;
 
@@ -397,14 +398,14 @@
       if (resultByUser.contains(psa.label(), psa.accountId())) {
         continue;
       }
-      LabelType type = labelTypes.byLabel(psa.labelId());
+      Optional<LabelType> type = labelTypes.byLabel(psa.labelId());
       // Only compute modified files if there is a relevant label, since this is expensive.
       if (modifiedFiles == null
-          && type != null
-          && type.isCopyAllScoresIfListOfFilesDidNotChange()) {
+          && type.isPresent()
+          && type.get().isCopyAllScoresIfListOfFilesDidNotChange()) {
         modifiedFiles = listModifiedFiles(project, ps, priorPatchSet);
       }
-      if (type == null) {
+      if (!type.isPresent()) {
         logger.atFine().log(
             "approval %d on label %s of patch set %d of change %d cannot be copied"
                 + " to patch set %d because the label no longer exists on project %s",
@@ -416,8 +417,8 @@
             project.getName());
         continue;
       }
-      if (!canCopyBasedOnBooleanLabelConfigs(project, psa, ps.id(), kind, type, modifiedFiles)
-          && !canCopyBasedOnCopyCondition(notes, psa, ps.id(), type, kind)) {
+      if (!canCopyBasedOnBooleanLabelConfigs(project, psa, ps.id(), kind, type.get(), modifiedFiles)
+          && !canCopyBasedOnCopyCondition(notes, psa, ps.id(), type.get(), kind)) {
         continue;
       }
       resultByUser.put(psa.label(), psa.accountId(), psa.copyWithPatchSet(ps.id()));
diff --git a/java/com/google/gerrit/server/approval/ApprovalsUtil.java b/java/com/google/gerrit/server/approval/ApprovalsUtil.java
index b1e85e9..a1cdd99 100644
--- a/java/com/google/gerrit/server/approval/ApprovalsUtil.java
+++ b/java/com/google/gerrit/server/approval/ApprovalsUtil.java
@@ -61,6 +61,7 @@
 import java.util.List;
 import java.util.Map;
 import java.util.Objects;
+import java.util.Optional;
 import java.util.Set;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.revwalk.RevWalk;
@@ -299,8 +300,12 @@
     List<PatchSetApproval> cells = new ArrayList<>(approvals.size());
     Date ts = update.getWhen();
     for (Map.Entry<String, Short> vote : approvals.entrySet()) {
-      LabelType lt = labelTypes.byLabel(vote.getKey());
-      cells.add(newApproval(ps.id(), user, lt.getLabelId(), vote.getValue(), ts).build());
+      Optional<LabelType> lt = labelTypes.byLabel(vote.getKey());
+      if (!lt.isPresent()) {
+        throw new BadRequestException(
+            String.format("label \"%s\" is not a configured label", vote.getKey()));
+      }
+      cells.add(newApproval(ps.id(), user, lt.get().getLabelId(), vote.getValue(), ts).build());
     }
     for (PatchSetApproval psa : cells) {
       update.putApproval(psa.label(), psa.value());
@@ -310,11 +315,11 @@
 
   public static void checkLabel(LabelTypes labelTypes, String name, Short value)
       throws BadRequestException {
-    LabelType label = labelTypes.byLabel(name);
-    if (label == null) {
+    Optional<LabelType> label = labelTypes.byLabel(name);
+    if (!label.isPresent()) {
       throw new BadRequestException(String.format("label \"%s\" is not a configured label", name));
     }
-    if (label.getValue(value) == null) {
+    if (label.get().getValue(value) == null) {
       throw new BadRequestException(
           String.format("label \"%s\": %d is not a valid value", name, value));
     }
diff --git a/java/com/google/gerrit/server/cache/serialize/entities/SubmitRequirementExpressionResultSerializer.java b/java/com/google/gerrit/server/cache/serialize/entities/SubmitRequirementExpressionResultSerializer.java
new file mode 100644
index 0000000..4e997b4
--- /dev/null
+++ b/java/com/google/gerrit/server/cache/serialize/entities/SubmitRequirementExpressionResultSerializer.java
@@ -0,0 +1,45 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.cache.serialize.entities;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.entities.SubmitRequirementExpression;
+import com.google.gerrit.entities.SubmitRequirementExpressionResult;
+import com.google.gerrit.server.cache.proto.Cache.SubmitRequirementExpressionResultProto;
+
+/**
+ * Serializer of a {@link SubmitRequirementExpressionResult} to {@link
+ * SubmitRequirementExpressionResultProto}.
+ */
+public class SubmitRequirementExpressionResultSerializer {
+  public static SubmitRequirementExpressionResult deserialize(
+      SubmitRequirementExpressionResultProto proto) {
+    return SubmitRequirementExpressionResult.create(
+        SubmitRequirementExpression.create(proto.getExpression()),
+        SubmitRequirementExpressionResult.Status.valueOf(proto.getStatus()),
+        proto.getPassingAtomsList().stream().collect(ImmutableList.toImmutableList()),
+        proto.getFailingAtomsList().stream().collect(ImmutableList.toImmutableList()));
+  }
+
+  public static SubmitRequirementExpressionResultProto serialize(
+      SubmitRequirementExpressionResult r) {
+    return SubmitRequirementExpressionResultProto.newBuilder()
+        .setExpression(r.expression().expressionString())
+        .setStatus(r.status().name())
+        .addAllPassingAtoms(r.passingAtoms())
+        .addAllFailingAtoms(r.failingAtoms())
+        .build();
+  }
+}
diff --git a/java/com/google/gerrit/server/cancellation/RequestCancelledException.java b/java/com/google/gerrit/server/cancellation/RequestCancelledException.java
new file mode 100644
index 0000000..3c668fb
--- /dev/null
+++ b/java/com/google/gerrit/server/cancellation/RequestCancelledException.java
@@ -0,0 +1,68 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.cancellation;
+
+import com.google.gerrit.common.Nullable;
+import java.util.Optional;
+import org.apache.commons.lang.WordUtils;
+
+/** Exception to signal that the current request is cancelled and should be aborted. */
+public class RequestCancelledException extends RuntimeException {
+  private static final long serialVersionUID = 1L;
+
+  private final RequestStateProvider.Reason cancellationReason;
+  private final Optional<String> cancellationMessage;
+
+  /**
+   * Create a {@code RequestCancelledException}.
+   *
+   * @param cancellationReason the reason why the request is cancelled
+   * @param cancellationMessage an optional message providing details about the cancellation
+   */
+  public RequestCancelledException(
+      RequestStateProvider.Reason cancellationReason, @Nullable String cancellationMessage) {
+    super(createMessage(cancellationReason, cancellationMessage));
+    this.cancellationReason = cancellationReason;
+    this.cancellationMessage = Optional.ofNullable(cancellationMessage);
+  }
+
+  private static String createMessage(
+      RequestStateProvider.Reason cancellationReason, @Nullable String message) {
+    StringBuilder messageBuilder = new StringBuilder();
+    messageBuilder.append(String.format("Request cancelled: %s", cancellationReason.name()));
+    if (message != null) {
+      messageBuilder.append(String.format(" (%s)", message));
+    }
+    return messageBuilder.toString();
+  }
+
+  /** Returns the reason why the request is cancelled. */
+  public RequestStateProvider.Reason getCancellationReason() {
+    return cancellationReason;
+  }
+
+  /** Returns the cancellation reason as a user-readable string. */
+  public String formatCancellationReason() {
+    return WordUtils.capitalizeFully(cancellationReason.name().replaceAll("_", " "));
+  }
+
+  /**
+   * Returns a message providing details about the cancellation, or {@link Optional#empty()} if none
+   * is available.
+   */
+  public Optional<String> getCancellationMessage() {
+    return cancellationMessage;
+  }
+}
diff --git a/java/com/google/gerrit/server/cancellation/RequestStateProvider.java b/java/com/google/gerrit/server/cancellation/RequestStateProvider.java
new file mode 100644
index 0000000..e1716eb
--- /dev/null
+++ b/java/com/google/gerrit/server/cancellation/RequestStateProvider.java
@@ -0,0 +1,59 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.cancellation;
+
+import com.google.gerrit.common.Nullable;
+
+/** Interface that provides information about the state of the current request. */
+public interface RequestStateProvider {
+  /**
+   * Checks whether the current request is cancelled.
+   *
+   * <p>Invoked by Gerrit to check whether the current request is cancelled and should be aborted.
+   *
+   * <p>If the current request is cancelled {@link OnCancelled#onCancel(Reason, String)} is invoked
+   * on the provided callback.
+   *
+   * @param onCancelled callback that should be invoked if the request is cancelled
+   */
+  void checkIfCancelled(OnCancelled onCancelled);
+
+  /** Callback interface to be invoked if a request is cancelled. */
+  interface OnCancelled {
+    /**
+     * Callback that is invoked if the request is cancelled.
+     *
+     * @param reason the reason for the cancellation of the request
+     * @param message an optional message providing details about the cancellation
+     */
+    void onCancel(Reason reason, @Nullable String message);
+  }
+
+  /** Reason why a request is cancelled. */
+  enum Reason {
+    /** The client got disconnected or has cancelled the request. */
+    CLIENT_CLOSED_REQUEST,
+
+    /** The deadline that the client provided for the request exceeded. */
+    CLIENT_PROVIDED_DEADLINE_EXCEEDED,
+
+    /**
+     * A server-side deadline for the request exceeded.
+     *
+     * <p>Server-side deadlines are usually configurable, but may also be hard-coded.
+     */
+    SERVER_DEADLINE_EXCEEDED;
+  }
+}
diff --git a/java/com/google/gerrit/server/change/ChangeJson.java b/java/com/google/gerrit/server/change/ChangeJson.java
index e9c9946..ff8d8cb 100644
--- a/java/com/google/gerrit/server/change/ChangeJson.java
+++ b/java/com/google/gerrit/server/change/ChangeJson.java
@@ -63,7 +63,6 @@
 import com.google.gerrit.entities.SubmitRequirement;
 import com.google.gerrit.entities.SubmitRequirementExpression;
 import com.google.gerrit.entities.SubmitRequirementExpressionResult;
-import com.google.gerrit.entities.SubmitRequirementExpressionResult.PredicateResult;
 import com.google.gerrit.entities.SubmitRequirementResult;
 import com.google.gerrit.entities.SubmitTypeRecord;
 import com.google.gerrit.exceptions.StorageException;
@@ -411,10 +410,8 @@
     SubmitRequirementExpressionInfo info = new SubmitRequirementExpressionInfo();
     info.expression = expression.expressionString();
     info.fulfilled = result.status().equals(SubmitRequirementExpressionResult.Status.PASS);
-    info.passingAtoms =
-        result.getPassingAtoms().stream().map(PredicateResult::predicateString).collect(toList());
-    info.failingAtoms =
-        result.getFailingAtoms().stream().map(PredicateResult::predicateString).collect(toList());
+    info.passingAtoms = result.passingAtoms();
+    info.failingAtoms = result.failingAtoms();
     return info;
   }
 
diff --git a/java/com/google/gerrit/server/change/DeleteReviewersUtil.java b/java/com/google/gerrit/server/change/DeleteReviewersUtil.java
index 3212c8d..79ed043 100644
--- a/java/com/google/gerrit/server/change/DeleteReviewersUtil.java
+++ b/java/com/google/gerrit/server/change/DeleteReviewersUtil.java
@@ -62,9 +62,8 @@
             changeNotes.getChangeId(),
             deleteReviewerOpFactory.create(result.asUnique().account(), deleteReviewerInput));
         return;
-      } else {
-        return;
       }
+      return;
     } catch (AccountResolver.UnresolvableAccountException e) {
       if (e.isSelf()) {
         throw new AuthException(e.getMessage(), e);
diff --git a/java/com/google/gerrit/server/change/FileInfoJson.java b/java/com/google/gerrit/server/change/FileInfoJson.java
index ad6f9c7..ab557dc 100644
--- a/java/com/google/gerrit/server/change/FileInfoJson.java
+++ b/java/com/google/gerrit/server/change/FileInfoJson.java
@@ -46,7 +46,8 @@
    *
    * @param change a Gerrit change.
    * @param objectId a commit SHA-1 identifying a patchset commit.
-   * @param parentNum an integer identifying the parent number used for comparison.
+   * @param parentNum 1-based integer identifying the parent number used for comparison. If zero,
+   *     the only parent will be used or the auto-merge if {@code newCommit} is a merge commit.
    * @return a mapping of the file paths to their related diff information.
    */
   default Map<String, FileInfo> getFileInfoMap(Change change, ObjectId objectId, int parentNum)
@@ -74,7 +75,8 @@
    *
    * @param project a project identifying a repository.
    * @param objectId a commit SHA-1 identifying a patchset commit.
-   * @param parentNum an integer identifying the parent number used for comparison.
+   * @param parentNum 1-based integer identifying the parent number used for comparison. If zero,
+   *     the only parent will be used or the auto-merge if {@code newCommit} is a merge commit.
    * @return a mapping of the file paths to their related diff information.
    */
   Map<String, FileInfo> getFileInfoMap(Project.NameKey project, ObjectId objectId, int parentNum)
diff --git a/java/com/google/gerrit/server/change/FileInfoJsonExperimentImpl.java b/java/com/google/gerrit/server/change/FileInfoJsonExperimentImpl.java
index 3f7ce68..81f014d 100644
--- a/java/com/google/gerrit/server/change/FileInfoJsonExperimentImpl.java
+++ b/java/com/google/gerrit/server/change/FileInfoJsonExperimentImpl.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.change;
 
+import com.google.common.annotations.VisibleForTesting;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
@@ -33,7 +34,9 @@
  * FileInfoJsonNewImpl}.
  */
 public class FileInfoJsonExperimentImpl implements FileInfoJson {
-  private final String NEW_DIFF_CACHE_FEATURE = "GerritBackendRequestFeature__use_new_diff_cache";
+  @VisibleForTesting
+  public static final String NEW_DIFF_CACHE_FEATURE =
+      "GerritBackendRequestFeature__use_new_diff_cache";
 
   private final FileInfoJsonOldImpl oldImpl;
   private final FileInfoJsonNewImpl newImpl;
diff --git a/java/com/google/gerrit/server/change/FileInfoJsonNewImpl.java b/java/com/google/gerrit/server/change/FileInfoJsonNewImpl.java
index 1ca2c93..7277404 100644
--- a/java/com/google/gerrit/server/change/FileInfoJsonNewImpl.java
+++ b/java/com/google/gerrit/server/change/FileInfoJsonNewImpl.java
@@ -47,8 +47,11 @@
       throws ResourceConflictException, PatchListNotAvailableException {
     try {
       if (base == null) {
+        // Setting parentNum=0 requests the default parent, which is the only parent for
+        // single-parent commits, or the auto-merge otherwise
         return asFileInfo(
-            diffs.listModifiedFilesAgainstParent(change.getProject(), objectId, null));
+            diffs.listModifiedFilesAgainstParent(
+                change.getProject(), objectId, /* parentNum= */ 0));
       }
       return asFileInfo(diffs.listModifiedFiles(change.getProject(), base.commitId(), objectId));
     } catch (DiffNotAvailableException e) {
@@ -63,7 +66,7 @@
       throws ResourceConflictException, PatchListNotAvailableException {
     try {
       Map<String, FileDiffOutput> modifiedFiles =
-          diffs.listModifiedFilesAgainstParent(project, objectId, parent + 1);
+          diffs.listModifiedFilesAgainstParent(project, objectId, parent);
       return asFileInfo(modifiedFiles);
     } catch (DiffNotAvailableException e) {
       convertException(e);
diff --git a/java/com/google/gerrit/server/change/FileInfoJsonOldImpl.java b/java/com/google/gerrit/server/change/FileInfoJsonOldImpl.java
index 55d162a..0570296 100644
--- a/java/com/google/gerrit/server/change/FileInfoJsonOldImpl.java
+++ b/java/com/google/gerrit/server/change/FileInfoJsonOldImpl.java
@@ -60,10 +60,10 @@
       Project.NameKey project, ObjectId objectId, int parentNum)
       throws ResourceConflictException, PatchListNotAvailableException {
     PatchListKey key =
-        parentNum == -1
+        parentNum == 0
             ? PatchListKey.againstDefaultBase(objectId, Whitespace.IGNORE_NONE)
             : PatchListKey.againstParentNum(
-                parentNum + 1, objectId, DiffPreferencesInfo.Whitespace.IGNORE_NONE);
+                parentNum, objectId, DiffPreferencesInfo.Whitespace.IGNORE_NONE);
     return toFileInfoMap(project, key);
   }
 
diff --git a/java/com/google/gerrit/server/change/IncludedInResolver.java b/java/com/google/gerrit/server/change/IncludedInResolver.java
index 9216964..3e1b69b 100644
--- a/java/com/google/gerrit/server/change/IncludedInResolver.java
+++ b/java/com/google/gerrit/server/change/IncludedInResolver.java
@@ -27,7 +27,6 @@
 import com.google.common.flogger.FluentLogger;
 import java.io.IOException;
 import java.util.Collection;
-import java.util.Collections;
 import java.util.HashSet;
 import java.util.List;
 import java.util.Set;
@@ -115,33 +114,6 @@
   }
 
   /**
-   * Partition the reference tips into two sets:
-   *
-   * <ul>
-   *   <li>before = commits with time < target.getCommitTime()
-   *   <li>after = commits with time >= target.getCommitTime()
-   * </ul>
-   *
-   * Each of the before/after lists is sorted by the commit time.
-   *
-   * @param before
-   * @param after
-   */
-  private void partition(List<RevCommit> before, List<RevCommit> after) {
-    int insertionPoint =
-        Collections.binarySearch(tipsByCommitTime, target, comparing(RevCommit::getCommitTime));
-    if (insertionPoint < 0) {
-      insertionPoint = -(insertionPoint + 1);
-    }
-    if (0 < insertionPoint) {
-      before.addAll(tipsByCommitTime.subList(0, insertionPoint));
-    }
-    if (insertionPoint < tipsByCommitTime.size()) {
-      after.addAll(tipsByCommitTime.subList(insertionPoint, tipsByCommitTime.size()));
-    }
-  }
-
-  /**
    * Returns the short names of refs which are as well in the matchingRefs list as well as in the
    * allRef list.
    */
diff --git a/java/com/google/gerrit/server/change/LabelNormalizer.java b/java/com/google/gerrit/server/change/LabelNormalizer.java
index 30343d4..b5527d7 100644
--- a/java/com/google/gerrit/server/change/LabelNormalizer.java
+++ b/java/com/google/gerrit/server/change/LabelNormalizer.java
@@ -33,6 +33,7 @@
 import com.google.inject.Singleton;
 import java.util.Collection;
 import java.util.List;
+import java.util.Optional;
 
 /**
  * Normalizes votes on labels according to project config.
@@ -101,12 +102,12 @@
         unchanged.add(psa);
         continue;
       }
-      LabelType label = labelTypes.byLabel(psa.labelId());
-      if (label == null) {
+      Optional<LabelType> label = labelTypes.byLabel(psa.labelId());
+      if (!label.isPresent()) {
         deleted.add(psa);
         continue;
       }
-      PatchSetApproval copy = applyTypeFloor(label, psa);
+      PatchSetApproval copy = applyTypeFloor(label.get(), psa);
       if (copy.value() != psa.value()) {
         updated.add(copy);
       } else {
diff --git a/java/com/google/gerrit/server/change/LabelsJson.java b/java/com/google/gerrit/server/change/LabelsJson.java
index acff03c..5ce121b 100644
--- a/java/com/google/gerrit/server/change/LabelsJson.java
+++ b/java/com/google/gerrit/server/change/LabelsJson.java
@@ -57,6 +57,7 @@
 import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.Optional;
 import java.util.Set;
 import java.util.TreeMap;
 
@@ -103,9 +104,9 @@
     for (SubmitRecord rec : submitRecords(cd)) {
       if (rec.labels != null) {
         for (SubmitRecord.Label r : rec.labels) {
-          LabelType type = labelTypes.byLabel(r.label);
-          if (type != null && (!isMerged || type.isAllowPostSubmit())) {
-            toCheck.put(type.getName(), type);
+          Optional<LabelType> type = labelTypes.byLabel(r.label);
+          if (type.isPresent() && (!isMerged || type.get().isAllowPostSubmit())) {
+            toCheck.put(type.get().getName(), type.get());
           }
         }
       }
@@ -120,18 +121,18 @@
         continue;
       }
       for (SubmitRecord.Label r : rec.labels) {
-        LabelType type = labelTypes.byLabel(r.label);
-        if (type == null || (isMerged && !type.isAllowPostSubmit())) {
+        Optional<LabelType> type = labelTypes.byLabel(r.label);
+        if (!type.isPresent() || (isMerged && !type.get().isAllowPostSubmit())) {
           continue;
         }
 
-        for (LabelValue v : type.getValues()) {
-          boolean ok = can.contains(new LabelPermission.WithValue(type, v));
+        for (LabelValue v : type.get().getValues()) {
+          boolean ok = can.contains(new LabelPermission.WithValue(type.get(), v));
           if (isMerged) {
             if (labels == null) {
               labels = currentLabels(filterApprovalsBy, cd);
             }
-            short prev = labels.getOrDefault(type.getName(), (short) 0);
+            short prev = labels.getOrDefault(type.get().getName(), (short) 0);
             ok &= v.getValue() >= prev;
           }
           if (ok) {
@@ -176,21 +177,21 @@
       setAllApprovals(accountLoader, cd, labels);
     }
     for (Map.Entry<String, LabelWithStatus> e : labels.entrySet()) {
-      LabelType type = labelTypes.byLabel(e.getKey());
-      if (type == null) {
+      Optional<LabelType> type = labelTypes.byLabel(e.getKey());
+      if (!type.isPresent()) {
         continue;
       }
       if (standard) {
         for (PatchSetApproval psa : cd.currentApprovals()) {
-          if (type.matches(psa)) {
+          if (type.get().matches(psa)) {
             short val = psa.value();
             Account.Id accountId = psa.accountId();
-            setLabelScores(accountLoader, type, e.getValue(), val, accountId);
+            setLabelScores(accountLoader, type.get(), e.getValue(), val, accountId);
           }
         }
       }
       if (detailed) {
-        setLabelValues(type, e.getValue());
+        setLabelValues(type.get(), e.getValue());
       }
     }
     return labels;
@@ -261,9 +262,9 @@
         MultimapBuilder.hashKeys().hashSetValues().build();
     for (PatchSetApproval a : cd.currentApprovals()) {
       allUsers.add(a.accountId());
-      LabelType type = labelTypes.byLabel(a.labelId());
-      if (type != null) {
-        labelNames.add(type.getName());
+      Optional<LabelType> type = labelTypes.byLabel(a.labelId());
+      if (type.isPresent()) {
+        labelNames.add(type.get().getName());
         // Not worth the effort to distinguish between votable/non-votable for 0
         // values on closed changes, since they can't vote anyway.
         current.put(a.accountId(), a);
@@ -292,8 +293,8 @@
 
     if (detailed) {
       labels.entrySet().stream()
-          .filter(e -> labelTypes.byLabel(e.getKey()) != null)
-          .forEach(e -> setLabelValues(labelTypes.byLabel(e.getKey()), e.getValue()));
+          .filter(e -> labelTypes.byLabel(e.getKey()).isPresent())
+          .forEach(e -> setLabelValues(labelTypes.byLabel(e.getKey()).get(), e.getValue()));
     }
 
     for (Account.Id accountId : allUsers) {
@@ -308,16 +309,16 @@
         }
       }
       for (PatchSetApproval psa : current.get(accountId)) {
-        LabelType type = labelTypes.byLabel(psa.labelId());
-        if (type == null) {
+        Optional<LabelType> type = labelTypes.byLabel(psa.labelId());
+        if (!type.isPresent()) {
           continue;
         }
 
         short val = psa.value();
-        ApprovalInfo info = byLabel.get(type.getName());
+        ApprovalInfo info = byLabel.get(type.get().getName());
         if (info != null) {
           info.value = Integer.valueOf(val);
-          info.permittedVotingRange = pvr.getOrDefault(type.getName(), null);
+          info.permittedVotingRange = pvr.getOrDefault(type.get().getName(), null);
           info.date = psa.granted();
           info.tag = psa.tag().orElse(null);
           if (psa.postSubmit()) {
@@ -328,7 +329,7 @@
           continue;
         }
 
-        setLabelScores(accountLoader, type, labels.get(type.getName()), val, accountId);
+        setLabelScores(accountLoader, type.get(), labels.get(type.get().getName()), val, accountId);
       }
     }
     return labels;
@@ -428,24 +429,24 @@
       PermissionBackend.ForChange perm = permissionBackend.absentUser(accountId).change(cd);
       Map<String, VotingRangeInfo> pvr = getPermittedVotingRanges(permittedLabels(accountId, cd));
       for (Map.Entry<String, LabelWithStatus> e : labels.entrySet()) {
-        LabelType lt = labelTypes.byLabel(e.getKey());
-        if (lt == null) {
+        Optional<LabelType> lt = labelTypes.byLabel(e.getKey());
+        if (!lt.isPresent()) {
           // Ignore submit record for undefined label; likely the submit rule
           // author didn't intend for the label to show up in the table.
           continue;
         }
         Integer value;
-        VotingRangeInfo permittedVotingRange = pvr.getOrDefault(lt.getName(), null);
+        VotingRangeInfo permittedVotingRange = pvr.getOrDefault(lt.get().getName(), null);
         String tag = null;
         Timestamp date = null;
-        PatchSetApproval psa = current.get(accountId, lt.getName());
+        PatchSetApproval psa = current.get(accountId, lt.get().getName());
         if (psa != null) {
           value = Integer.valueOf(psa.value());
           if (value == 0) {
             // This may be a dummy approval that was inserted when the reviewer
             // was added. Explicitly check whether the user can vote on this
             // label.
-            value = perm.test(new LabelPermission(lt)) ? 0 : null;
+            value = perm.test(new LabelPermission(lt.get())) ? 0 : null;
           }
           tag = psa.tag().orElse(null);
           date = psa.granted();
@@ -456,7 +457,7 @@
           // Either the user cannot vote on this label, or they were added as a
           // reviewer but have not responded yet. Explicitly check whether the
           // user can vote on this label.
-          value = perm.test(new LabelPermission(lt)) ? 0 : null;
+          value = perm.test(new LabelPermission(lt.get())) ? 0 : null;
         }
         addApproval(
             e.getValue().label(),
diff --git a/java/com/google/gerrit/server/change/ReviewerJson.java b/java/com/google/gerrit/server/change/ReviewerJson.java
index d5b74a8..6189708 100644
--- a/java/com/google/gerrit/server/change/ReviewerJson.java
+++ b/java/com/google/gerrit/server/change/ReviewerJson.java
@@ -38,6 +38,7 @@
 import com.google.inject.Singleton;
 import java.util.Collection;
 import java.util.List;
+import java.util.Optional;
 import java.util.TreeMap;
 
 @Singleton
@@ -107,10 +108,8 @@
 
     out.approvals = new TreeMap<>(labelTypes.nameComparator());
     for (PatchSetApproval ca : approvals) {
-      LabelType at = labelTypes.byLabel(ca.labelId());
-      if (at != null) {
-        out.approvals.put(at.getName(), formatValue(ca.value()));
-      }
+      Optional<LabelType> at = labelTypes.byLabel(ca.labelId());
+      at.ifPresent(lt -> out.approvals.put(lt.getName(), formatValue(ca.value())));
     }
 
     // Add dummy approvals for all permitted labels for the user even if they
@@ -125,13 +124,13 @@
         }
         for (SubmitRecord.Label label : rec.labels) {
           String name = label.label;
-          LabelType type = labelTypes.byLabel(name);
-          if (out.approvals.containsKey(name) || type == null) {
+          Optional<LabelType> type = labelTypes.byLabel(name);
+          if (out.approvals.containsKey(name) || !type.isPresent()) {
             continue;
           }
 
           try {
-            perm.check(new LabelPermission(type));
+            perm.check(new LabelPermission(type.get()));
             out.approvals.put(name, formatValue((short) 0));
           } catch (AuthException e) {
             // Do nothing.
diff --git a/java/com/google/gerrit/server/comment/CommentContextCacheImpl.java b/java/com/google/gerrit/server/comment/CommentContextCacheImpl.java
index c40b3d7..5be41d4 100644
--- a/java/com/google/gerrit/server/comment/CommentContextCacheImpl.java
+++ b/java/com/google/gerrit/server/comment/CommentContextCacheImpl.java
@@ -119,12 +119,7 @@
 
       for (CommentContextKey inputKey : inputKeys) {
         CommentContextKey cacheKey = inputKeysToCacheKeys.get(inputKey);
-        CommentContext commentContext = allContext.get(cacheKey);
-        if (commentContext == null) {
-          logger.atSevere().log("comment context for cache key %s is missing", cacheKey);
-          continue;
-        }
-        result.put(inputKey, commentContext);
+        result.put(inputKey, allContext.get(cacheKey));
       }
       return result.build();
     } catch (ExecutionException e) {
@@ -240,13 +235,7 @@
           result.putAll(context);
         }
       }
-      Map<CommentContextKey, CommentContext> allContexts = result.build();
-      if (allContexts.size() < Iterables.size(keys)) {
-        logger.atSevere().log(
-            "incomplete result: requested contexts for %s, got contexts for %s",
-            Iterables.toString(keys), allContexts.keySet());
-      }
-      return allContexts;
+      return result.build();
     }
 
     /**
@@ -267,21 +256,17 @@
       List<HumanComment> allComments =
           Streams.concat(humanComments.stream(), drafts.stream()).collect(Collectors.toList());
       CommentContextLoader loader = factory.create(project);
-      Map<ContextInput, CommentContextKey> commentsToKeys = new HashMap<>();
+      Map<CommentContextKey, ContextInput> keysToComments = new HashMap<>();
       for (CommentContextKey key : keys) {
         Comment comment = getCommentForKey(allComments, key);
-        commentsToKeys.put(ContextInput.fromComment(comment, key.contextPadding()), key);
+        keysToComments.put(key, ContextInput.fromComment(comment, key.contextPadding()));
       }
-      Map<ContextInput, CommentContext> allContext = loader.getContext(commentsToKeys.keySet());
-      Map<CommentContextKey, CommentContext> result =
-          allContext.entrySet().stream()
-              .collect(Collectors.toMap(e -> commentsToKeys.get(e.getKey()), Map.Entry::getValue));
-      if (result.size() < keys.size()) {
-        logger.atSevere().log(
-            "incomplete result: requested contexts for %s for change %s of project %s, got contexts for %s",
-            keys, changeId, project, result.keySet());
-      }
-      return result;
+      Map<ContextInput, CommentContext> allContext =
+          loader.getContext(
+              keysToComments.values().stream().distinct().collect(Collectors.toList()));
+      return keys.stream()
+          .collect(
+              Collectors.toMap(Function.identity(), k -> allContext.get(keysToComments.get(k))));
     }
 
     /**
diff --git a/java/com/google/gerrit/server/config/FileBasedGlobalPluginConfigProvider.java b/java/com/google/gerrit/server/config/FileBasedGlobalPluginConfigProvider.java
new file mode 100644
index 0000000..098d2c2
--- /dev/null
+++ b/java/com/google/gerrit/server/config/FileBasedGlobalPluginConfigProvider.java
@@ -0,0 +1,57 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.config;
+
+import com.google.common.flogger.FluentLogger;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.nio.file.Path;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.storage.file.FileBasedConfig;
+import org.eclipse.jgit.util.FS;
+
+@Singleton
+public class FileBasedGlobalPluginConfigProvider implements GlobalPluginConfigProvider {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+  private final SitePaths site;
+
+  @Inject
+  FileBasedGlobalPluginConfigProvider(SitePaths site) {
+    this.site = site;
+  }
+
+  @Override
+  public Config get(String pluginName) {
+    Path pluginConfigFile = site.etc_dir.resolve(pluginName + ".config");
+    FileBasedConfig cfg = new FileBasedConfig(pluginConfigFile.toFile(), FS.DETECTED);
+    if (!cfg.getFile().exists()) {
+      logger.atInfo().log("No %s; assuming defaults", pluginConfigFile.toAbsolutePath());
+      return cfg;
+    }
+
+    try {
+      cfg.load();
+    } catch (ConfigInvalidException e) {
+      // This is an error in user input, don't spam logs with a stack trace.
+      logger.atWarning().log(
+          "Failed to load %s: %s", pluginConfigFile.toAbsolutePath(), e.getMessage());
+    } catch (IOException e) {
+      logger.atWarning().withCause(e).log("Failed to load %s", pluginConfigFile.toAbsolutePath());
+    }
+    return cfg;
+  }
+}
diff --git a/java/com/google/gerrit/server/config/GerritServerConfigModule.java b/java/com/google/gerrit/server/config/GerritServerConfigModule.java
index 27d1d58..da85834 100644
--- a/java/com/google/gerrit/server/config/GerritServerConfigModule.java
+++ b/java/com/google/gerrit/server/config/GerritServerConfigModule.java
@@ -66,6 +66,7 @@
     bind(Config.class)
         .annotatedWith(GerritServerConfig.class)
         .toProvider(GerritServerConfigProvider.class);
+    bind(GlobalPluginConfigProvider.class).to(FileBasedGlobalPluginConfigProvider.class);
     bind(SecureStore.class).toProvider(SecureStoreProvider.class).in(SINGLETON);
     bind(Boolean.class)
         .annotatedWith(GerritIsReplica.class)
diff --git a/javatests/com/google/gerrit/acceptance/pgm/ReindexIT.java b/java/com/google/gerrit/server/config/GlobalPluginConfigProvider.java
similarity index 68%
copy from javatests/com/google/gerrit/acceptance/pgm/ReindexIT.java
copy to java/com/google/gerrit/server/config/GlobalPluginConfigProvider.java
index 223851e..847708a 100644
--- a/javatests/com/google/gerrit/acceptance/pgm/ReindexIT.java
+++ b/java/com/google/gerrit/server/config/GlobalPluginConfigProvider.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2014 The Android Open Source Project
+// Copyright (C) 2021 The Android Open Source Project
 //
 // Licensed under the Apache License, Version 2.0 (the "License");
 // you may not use this file except in compliance with the License.
@@ -12,11 +12,10 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.acceptance.pgm;
+package com.google.gerrit.server.config;
 
-import com.google.inject.Injector;
+import org.eclipse.jgit.lib.Config;
 
-public class ReindexIT extends AbstractReindexTests {
-  @Override
-  public void configureIndex(Injector injector) {}
+public interface GlobalPluginConfigProvider {
+  Config get(String pluginName);
 }
diff --git a/java/com/google/gerrit/server/config/PluginConfigFactory.java b/java/com/google/gerrit/server/config/PluginConfigFactory.java
index 2d0f9a5..bd4b661 100644
--- a/java/com/google/gerrit/server/config/PluginConfigFactory.java
+++ b/java/com/google/gerrit/server/config/PluginConfigFactory.java
@@ -16,7 +16,6 @@
 
 import static com.google.gerrit.server.project.ProjectCache.noSuchProject;
 
-import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.plugins.Plugin;
 import com.google.gerrit.server.plugins.ReloadPluginListener;
@@ -28,48 +27,37 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-import java.io.File;
-import java.io.IOException;
-import java.nio.file.Path;
 import java.util.HashMap;
 import java.util.Map;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.internal.storage.file.FileSnapshot;
 import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.storage.file.FileBasedConfig;
-import org.eclipse.jgit.util.FS;
 
 @Singleton
 public class PluginConfigFactory implements ReloadPluginListener {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
   private static final String EXTENSION = ".config";
 
-  private final SitePaths site;
+  private final GlobalPluginConfigProvider globalPluginConfigProvider;
   private final Provider<Config> cfgProvider;
   private final ProjectCache projectCache;
   private final ProjectState.Factory projectStateFactory;
   private final SecureStore secureStore;
   private final Map<String, Config> pluginConfigs;
 
-  private volatile FileSnapshot cfgSnapshot;
   private volatile Config cfg;
 
   @Inject
   PluginConfigFactory(
-      SitePaths site,
       @GerritServerConfig Provider<Config> cfgProvider,
+      GlobalPluginConfigProvider globalPluginConfigProvider,
       ProjectCache projectCache,
       ProjectState.Factory projectStateFactory,
       SecureStore secureStore) {
-    this.site = site;
+    this.globalPluginConfigProvider = globalPluginConfigProvider;
     this.cfgProvider = cfgProvider;
     this.projectCache = projectCache;
     this.projectStateFactory = projectStateFactory;
     this.secureStore = secureStore;
 
     this.pluginConfigs = new HashMap<>();
-    this.cfgSnapshot = FileSnapshot.save(site.gerrit_config.toFile());
     this.cfg = cfgProvider.get();
   }
 
@@ -103,12 +91,10 @@
    * @return the plugin configuration from the 'gerrit.config' file
    */
   public PluginConfig getFromGerritConfig(String pluginName, boolean refresh) {
-    if (refresh && secureStore.isOutdated()) {
-      secureStore.reload();
-    }
-    File configFile = site.gerrit_config.toFile();
-    if (refresh && cfgSnapshot.isModified(configFile)) {
-      cfgSnapshot = FileSnapshot.save(configFile);
+    if (refresh) {
+      if (secureStore.isOutdated()) {
+        secureStore.reload();
+      }
       cfg = cfgProvider.get();
     }
     return PluginConfig.createFromGerritConfig(pluginName, cfg);
@@ -217,25 +203,9 @@
       return pluginConfigs.get(pluginName);
     }
 
-    Path pluginConfigFile = site.etc_dir.resolve(pluginName + ".config");
-    FileBasedConfig cfg = new FileBasedConfig(pluginConfigFile.toFile(), FS.DETECTED);
+    Config cfg = globalPluginConfigProvider.get(pluginName);
     GlobalPluginConfig pluginConfig = new GlobalPluginConfig(pluginName, cfg, secureStore);
     pluginConfigs.put(pluginName, pluginConfig);
-    if (!cfg.getFile().exists()) {
-      logger.atInfo().log("No %s; assuming defaults", pluginConfigFile.toAbsolutePath());
-      return pluginConfig;
-    }
-
-    try {
-      cfg.load();
-    } catch (ConfigInvalidException e) {
-      // This is an error in user input, don't spam logs with a stack trace.
-      logger.atWarning().log(
-          "Failed to load %s: %s", pluginConfigFile.toAbsolutePath(), e.getMessage());
-    } catch (IOException e) {
-      logger.atWarning().withCause(e).log("Failed to load %s", pluginConfigFile.toAbsolutePath());
-    }
-
     return pluginConfig;
   }
 
diff --git a/java/com/google/gerrit/server/events/EventFactory.java b/java/com/google/gerrit/server/events/EventFactory.java
index 3a4dcff..1bb694a 100644
--- a/java/com/google/gerrit/server/events/EventFactory.java
+++ b/java/com/google/gerrit/server/events/EventFactory.java
@@ -71,6 +71,7 @@
 import java.util.Collection;
 import java.util.List;
 import java.util.Map;
+import java.util.Optional;
 import java.util.Set;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.PersonIdent;
@@ -399,7 +400,7 @@
     try {
       Map<String, FileDiffOutput> modifiedFiles =
           diffOperations.listModifiedFilesAgainstParent(
-              change.getProject(), patchSet.commitId(), /* parent= */ null);
+              change.getProject(), patchSet.commitId(), /* parent= */ 0);
 
       for (FileDiffOutput diff : modifiedFiles.values()) {
         if (patchSetAttribute.files == null) {
@@ -456,7 +457,7 @@
 
       Map<String, FileDiffOutput> modifiedFiles =
           diffOperations.listModifiedFilesAgainstParent(
-              change.getProject(), patchSet.commitId(), /* parent= */ null);
+              change.getProject(), patchSet.commitId(), /* parent= */ 0);
       for (FileDiffOutput fileDiff : modifiedFiles.values()) {
         p.sizeDeletions += fileDiff.deletions();
         p.sizeInsertions += fileDiff.insertions();
@@ -535,10 +536,8 @@
     a.grantedOn = approval.granted().getTime() / 1000L;
     a.oldValue = null;
 
-    LabelType lt = labelTypes.byLabel(approval.labelId());
-    if (lt != null) {
-      a.description = lt.getName();
-    }
+    Optional<LabelType> lt = labelTypes.byLabel(approval.labelId());
+    lt.ifPresent(l -> a.description = l.getName());
     return a;
   }
 
diff --git a/java/com/google/gerrit/server/events/StreamEventsApiListener.java b/java/com/google/gerrit/server/events/StreamEventsApiListener.java
index 439f53e..edd1928 100644
--- a/java/com/google/gerrit/server/events/StreamEventsApiListener.java
+++ b/java/com/google/gerrit/server/events/StreamEventsApiListener.java
@@ -23,7 +23,6 @@
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.LabelTypes;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
@@ -202,10 +201,7 @@
         a.oldValue = Short.toString(oldApprovals.get(approval.getKey()));
       }
     }
-    LabelType lt = labelTypes.byLabel(approval.getKey());
-    if (lt != null) {
-      a.description = lt.getName();
-    }
+    labelTypes.byLabel(approval.getKey()).ifPresent(lt -> a.description = lt.getName());
     if (approval.getValue() != null) {
       a.value = Short.toString(approval.getValue());
     }
diff --git a/java/com/google/gerrit/server/git/MergeUtil.java b/java/com/google/gerrit/server/git/MergeUtil.java
index 1da14f8..a5ea24d 100644
--- a/java/com/google/gerrit/server/git/MergeUtil.java
+++ b/java/com/google/gerrit/server/git/MergeUtil.java
@@ -600,11 +600,11 @@
       } else if (isVerified(a.labelId())) {
         tag = "Tested-by";
       } else {
-        final LabelType lt = project.getLabelTypes().byLabel(a.labelId());
-        if (lt == null) {
+        final Optional<LabelType> lt = project.getLabelTypes().byLabel(a.labelId());
+        if (!lt.isPresent()) {
           continue;
         }
-        tag = lt.getName();
+        tag = lt.get().getName();
       }
 
       if (!contains(footers, new FooterKey(tag), identbuf.toString())) {
diff --git a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
index 005aebb..d074f1e 100644
--- a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
+++ b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
@@ -114,6 +114,7 @@
 import com.google.gerrit.server.RequestListener;
 import com.google.gerrit.server.account.AccountResolver;
 import com.google.gerrit.server.approval.ApprovalsUtil;
+import com.google.gerrit.server.cancellation.RequestCancelledException;
 import com.google.gerrit.server.change.AttentionSetUnchangedOp;
 import com.google.gerrit.server.change.ChangeInserter;
 import com.google.gerrit.server.change.NotifyResolver;
@@ -640,8 +641,17 @@
       Task commandProgress = progress.beginSubTask("refs", UNKNOWN);
       commands =
           commands.stream().map(c -> wrapReceiveCommand(c, commandProgress)).collect(toList());
-      processCommandsUnsafe(commands, progress);
-      rejectRemaining(commands, INTERNAL_SERVER_ERROR);
+
+      try {
+        processCommandsUnsafe(commands, progress);
+        rejectRemaining(commands, INTERNAL_SERVER_ERROR);
+      } catch (RequestCancelledException e) {
+        StringBuilder msg = new StringBuilder(e.formatCancellationReason());
+        if (e.getCancellationMessage().isPresent()) {
+          msg.append(String.format(" (%s)", e.getCancellationMessage().get()));
+        }
+        rejectRemaining(commands, msg.toString());
+      }
 
       // This sends error messages before the 'done' string of the progress monitor is sent.
       // Currently, the test framework relies on this ordering to understand if pushes completed
@@ -659,7 +669,9 @@
   // Process as many commands as possible, but may leave some commands in state NOT_ATTEMPTED.
   private void processCommandsUnsafe(
       Collection<ReceiveCommand> commands, MultiProgressMonitor progress) {
-    logger.atFine().log("Calling user: %s", user.getLoggableName());
+    logger.atFine().log("Calling user: %s, commands: %d", user.getLoggableName(), commands.size());
+
+    // If the list of groups is large, the log entry may get dropped, so separate out.
     logger.atFine().log("Groups: %s", lazy(() -> user.getEffectiveGroups().getKnownGroups()));
 
     if (!projectState.getProject().getState().permitsWrite()) {
@@ -669,8 +681,6 @@
       return;
     }
 
-    logger.atFine().log("Parsing %d commands", commands.size());
-
     List<ReceiveCommand> magicCommands = new ArrayList<>();
     List<ReceiveCommand> regularCommands = new ArrayList<>();
 
diff --git a/java/com/google/gerrit/server/git/receive/ReplaceOp.java b/java/com/google/gerrit/server/git/receive/ReplaceOp.java
index f00b48eb..b2a31b9 100644
--- a/java/com/google/gerrit/server/git/receive/ReplaceOp.java
+++ b/java/com/google/gerrit/server/git/receive/ReplaceOp.java
@@ -465,10 +465,10 @@
           continue;
         }
 
-        LabelType lt = projectState.getLabelTypes().byLabel(a.labelId());
-        if (lt != null) {
-          current.put(lt.getName(), a);
-        }
+        projectState
+            .getLabelTypes()
+            .byLabel(a.labelId())
+            .ifPresent(l -> current.put(l.getName(), a));
       }
     }
     return current;
diff --git a/java/com/google/gerrit/server/index/change/ChangeField.java b/java/com/google/gerrit/server/index/change/ChangeField.java
index 9c39c6e..1ee12fe 100644
--- a/java/com/google/gerrit/server/index/change/ChangeField.java
+++ b/java/com/google/gerrit/server/index/change/ChangeField.java
@@ -43,11 +43,13 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.common.io.Files;
 import com.google.common.primitives.Longs;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Address;
 import com.google.gerrit.entities.AttentionSetUpdate;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.ChangeMessage;
+import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.LegacySubmitRequirement;
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.entities.Project;
@@ -72,6 +74,7 @@
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.ChangeQueryBuilder;
 import com.google.gerrit.server.query.change.ChangeStatusPredicate;
+import com.google.gerrit.server.query.change.MagicLabelValue;
 import com.google.gson.Gson;
 import com.google.protobuf.MessageLite;
 import java.sql.Timestamp;
@@ -602,16 +605,37 @@
     for (PatchSetApproval a : cd.currentApprovals()) {
       if (a.value() != 0 && !a.isLegacySubmit()) {
         allApprovals.add(formatLabel(a.label(), a.value(), a.accountId()));
+        Optional<LabelType> labelType = cd.getLabelTypes().byLabel(a.labelId());
+        allApprovals.addAll(getMaxMinAnyLabels(a.label(), a.value(), labelType, a.accountId()));
         if (owners && cd.change().getOwner().equals(a.accountId())) {
           allApprovals.add(formatLabel(a.label(), a.value(), ChangeQueryBuilder.OWNER_ACCOUNT_ID));
+          allApprovals.addAll(
+              getMaxMinAnyLabels(
+                  a.label(), a.value(), labelType, ChangeQueryBuilder.OWNER_ACCOUNT_ID));
         }
         distinctApprovals.add(formatLabel(a.label(), a.value()));
+        distinctApprovals.addAll(getMaxMinAnyLabels(a.label(), a.value(), labelType, null));
       }
     }
     allApprovals.addAll(distinctApprovals);
     return allApprovals;
   }
 
+  private static List<String> getMaxMinAnyLabels(
+      String label, short labelVal, Optional<LabelType> labelType, @Nullable Account.Id accountId) {
+    List<String> labels = new ArrayList<>();
+    if (labelType.isPresent()) {
+      if (labelVal == labelType.get().getMaxPositive()) {
+        labels.add(formatLabel(label, MagicLabelValue.MAX.name(), accountId));
+      }
+      if (labelVal == labelType.get().getMaxNegative()) {
+        labels.add(formatLabel(label, MagicLabelValue.MIN.name(), accountId));
+      }
+    }
+    labels.add(formatLabel(label, MagicLabelValue.ANY.name(), accountId));
+    return labels;
+  }
+
   public static Set<String> getAuthorParts(ChangeData cd) {
     return SchemaUtil.getPersonParts(cd.getAuthor());
   }
@@ -696,6 +720,17 @@
         + (accountId != null ? "," + formatAccount(accountId) : "");
   }
 
+  public static String formatLabel(String label, String value) {
+    return formatLabel(label, value, null);
+  }
+
+  public static String formatLabel(String label, String value, @Nullable Account.Id accountId) {
+    return label.toLowerCase()
+        + "="
+        + value
+        + (accountId != null ? "," + formatAccount(accountId) : "");
+  }
+
   private static String formatAccount(Account.Id accountId) {
     if (ChangeQueryBuilder.OWNER_ACCOUNT_ID.equals(accountId)) {
       return ChangeQueryBuilder.ARG_ID_OWNER;
diff --git a/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java b/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
index 6355674..879da4f 100644
--- a/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
+++ b/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
@@ -150,6 +150,9 @@
    */
   static final Schema<ChangeData> V63 = schema(V62, false);
 
+  /** Added support for MIN/MAX/ANY for {@link ChangeField#LABEL} */
+  static final Schema<ChangeData> V64 = schema(V63, false);
+
   /**
    * Name of the change index to be used when contacting index backends or loading configurations.
    */
diff --git a/java/com/google/gerrit/server/mail/send/MergedSender.java b/java/com/google/gerrit/server/mail/send/MergedSender.java
index 6af2345..56528df 100644
--- a/java/com/google/gerrit/server/mail/send/MergedSender.java
+++ b/java/com/google/gerrit/server/mail/send/MergedSender.java
@@ -79,14 +79,14 @@
       Table<Account.Id, String, PatchSetApproval> pos = HashBasedTable.create();
       Table<Account.Id, String, PatchSetApproval> neg = HashBasedTable.create();
       for (PatchSetApproval ca : args.approvalsUtil.byPatchSet(changeData.notes(), patchSet.id())) {
-        LabelType lt = labelTypes.byLabel(ca.labelId());
-        if (lt == null) {
+        Optional<LabelType> lt = labelTypes.byLabel(ca.labelId());
+        if (!lt.isPresent()) {
           continue;
         }
         if (ca.value() > 0) {
-          pos.put(ca.accountId(), lt.getName(), ca);
+          pos.put(ca.accountId(), lt.get().getName(), ca);
         } else if (ca.value() < 0) {
-          neg.put(ca.accountId(), lt.getName(), ca);
+          neg.put(ca.accountId(), lt.get().getName(), ca);
         }
       }
 
diff --git a/java/com/google/gerrit/server/notedb/ChangeNoteJson.java b/java/com/google/gerrit/server/notedb/ChangeNoteJson.java
index 483b2e9..4c41a12 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNoteJson.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNoteJson.java
@@ -14,9 +14,17 @@
 
 package com.google.gerrit.server.notedb;
 
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.entities.EntitiesAdapterFactory;
+import com.google.gerrit.json.EnumTypeAdapterFactory;
 import com.google.gson.Gson;
 import com.google.gson.GsonBuilder;
+import com.google.gson.TypeAdapter;
+import com.google.gson.stream.JsonReader;
+import com.google.gson.stream.JsonWriter;
 import com.google.inject.Singleton;
+import com.google.inject.TypeLiteral;
+import java.io.IOException;
 import java.sql.Timestamp;
 
 @Singleton
@@ -26,6 +34,11 @@
   static Gson newGson() {
     return new GsonBuilder()
         .registerTypeAdapter(Timestamp.class, new CommentTimestampAdapter().nullSafe())
+        .registerTypeAdapterFactory(new EnumTypeAdapterFactory())
+        .registerTypeAdapterFactory(EntitiesAdapterFactory.create())
+        .registerTypeAdapter(
+            new TypeLiteral<ImmutableList<String>>() {}.getType(),
+            new ImmutableListAdapter().nullSafe())
         .setPrettyPrinting()
         .create();
   }
@@ -33,4 +46,27 @@
   public Gson getGson() {
     return gson;
   }
+
+  static class ImmutableListAdapter extends TypeAdapter<ImmutableList<String>> {
+
+    @Override
+    public void write(JsonWriter out, ImmutableList<String> value) throws IOException {
+      out.beginArray();
+      for (String v : value) {
+        out.value(v);
+      }
+      out.endArray();
+    }
+
+    @Override
+    public ImmutableList<String> read(JsonReader in) throws IOException {
+      ImmutableList.Builder<String> builder = ImmutableList.builder();
+      in.beginArray();
+      while (in.hasNext()) {
+        builder.add(in.nextString());
+      }
+      in.endArray();
+      return builder.build();
+    }
+  }
 }
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotes.java b/java/com/google/gerrit/server/notedb/ChangeNotes.java
index 5daf28c..6684493 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotes.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotes.java
@@ -50,6 +50,7 @@
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.entities.RobotComment;
 import com.google.gerrit.entities.SubmitRecord;
+import com.google.gerrit.entities.SubmitRequirementResult;
 import com.google.gerrit.server.AssigneeStatusUpdate;
 import com.google.gerrit.server.ReviewerByEmailSet;
 import com.google.gerrit.server.ReviewerSet;
@@ -413,6 +414,16 @@
   }
 
   /**
+   * Returns the evaluated submit requirements for the change. We only intend to store submit
+   * requirements in NoteDb for closed changes, hence the result will be an empty list for active
+   * changes, or a list of submit requirements results otherwise. For closed changes, the results
+   * represent the state of evaluating submit requirements for this change when it was merged.
+   */
+  public ImmutableList<SubmitRequirementResult> getSubmitRequirementsResult() {
+    return state.submitRequirementsResult();
+  }
+
+  /**
    * @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/ChangeNotesParser.java b/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
index 8be0d82..2a53c29 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
@@ -68,6 +68,7 @@
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.entities.SubmitRecord;
+import com.google.gerrit.entities.SubmitRequirementResult;
 import com.google.gerrit.metrics.Timer0;
 import com.google.gerrit.server.AssigneeStatusUpdate;
 import com.google.gerrit.server.ReviewerByEmailSet;
@@ -125,6 +126,7 @@
   private final List<AssigneeStatusUpdate> assigneeUpdates;
   private final List<SubmitRecord> submitRecords;
   private final ListMultimap<ObjectId, HumanComment> humanComments;
+  private final List<SubmitRequirementResult> submitRequirementResults;
   private final Map<PatchSet.Id, PatchSet.Builder> patchSets;
   private final Set<PatchSet.Id> deletedPatchSets;
   private final Map<PatchSet.Id, PatchSetState> patchSetStates;
@@ -187,6 +189,7 @@
     submitRecords = Lists.newArrayListWithExpectedSize(1);
     allChangeMessages = new ArrayList<>();
     humanComments = MultimapBuilder.hashKeys().arrayListValues().build();
+    submitRequirementResults = new ArrayList<>();
     patchSets = new HashMap<>();
     deletedPatchSets = new HashSet<>();
     patchSetStates = new HashMap<>();
@@ -259,6 +262,7 @@
         submitRecords,
         buildAllMessages(),
         humanComments,
+        submitRequirementResults,
         firstNonNull(isPrivate, false),
         firstNonNull(workInProgress, false),
         firstNonNull(hasReviewStarted, true),
@@ -774,6 +778,9 @@
       for (HumanComment c : e.getValue().getEntities()) {
         humanComments.put(e.getKey(), c);
       }
+      for (SubmitRequirementResult sr : e.getValue().getSubmitRequirementsResult()) {
+        submitRequirementResults.add(sr);
+      }
     }
 
     for (PatchSet.Builder b : patchSets.values()) {
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotesState.java b/java/com/google/gerrit/server/notedb/ChangeNotesState.java
index 33bc039..e7da025 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotesState.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotesState.java
@@ -44,6 +44,7 @@
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.SubmitRecord;
+import com.google.gerrit.entities.SubmitRequirementResult;
 import com.google.gerrit.entities.converter.ChangeMessageProtoConverter;
 import com.google.gerrit.entities.converter.PatchSetApprovalProtoConverter;
 import com.google.gerrit.entities.converter.PatchSetProtoConverter;
@@ -60,10 +61,14 @@
 import com.google.gerrit.server.cache.proto.Cache.ChangeNotesStateProto.ReviewerByEmailSetEntryProto;
 import com.google.gerrit.server.cache.proto.Cache.ChangeNotesStateProto.ReviewerSetEntryProto;
 import com.google.gerrit.server.cache.proto.Cache.ChangeNotesStateProto.ReviewerStatusUpdateProto;
+import com.google.gerrit.server.cache.proto.Cache.SubmitRequirementResultProto;
 import com.google.gerrit.server.cache.serialize.CacheSerializer;
 import com.google.gerrit.server.cache.serialize.ObjectIdConverter;
+import com.google.gerrit.server.cache.serialize.entities.SubmitRequirementExpressionResultSerializer;
+import com.google.gerrit.server.cache.serialize.entities.SubmitRequirementSerializer;
 import com.google.gerrit.server.index.change.ChangeField.StoredSubmitRecord;
 import com.google.gson.Gson;
+import com.google.protobuf.Descriptors.FieldDescriptor;
 import java.sql.Timestamp;
 import java.time.Instant;
 import java.util.List;
@@ -128,6 +133,7 @@
       List<SubmitRecord> submitRecords,
       List<ChangeMessage> changeMessages,
       ListMultimap<ObjectId, HumanComment> publishedComments,
+      List<SubmitRequirementResult> submitRequirementResults,
       boolean isPrivate,
       boolean workInProgress,
       boolean reviewStarted,
@@ -181,6 +187,7 @@
         .submitRecords(submitRecords)
         .changeMessages(changeMessages)
         .publishedComments(publishedComments)
+        .submitRequirementsResult(submitRequirementResults)
         .updateCount(updateCount)
         .mergedOn(mergedOn)
         .build();
@@ -326,6 +333,8 @@
 
   abstract ImmutableListMultimap<ObjectId, HumanComment> publishedComments();
 
+  abstract ImmutableList<SubmitRequirementResult> submitRequirementsResult();
+
   abstract int updateCount();
 
   @Nullable
@@ -404,6 +413,7 @@
           .submitRecords(ImmutableList.of())
           .changeMessages(ImmutableList.of())
           .publishedComments(ImmutableListMultimap.of())
+          .submitRequirementsResult(ImmutableList.of())
           .updateCount(0);
     }
 
@@ -445,6 +455,9 @@
 
     abstract Builder publishedComments(ListMultimap<ObjectId, HumanComment> publishedComments);
 
+    abstract Builder submitRequirementsResult(
+        List<SubmitRequirementResult> submitRequirementsResult);
+
     abstract Builder updateCount(int updateCount);
 
     abstract Builder mergedOn(Timestamp mergedOn);
@@ -465,6 +478,11 @@
     private static final Converter<String, ReviewerStateInternal> REVIEWER_STATE_CONVERTER =
         Enums.stringConverter(ReviewerStateInternal.class);
 
+    private static final FieldDescriptor SR_APPLICABILITY_EXPR_RESULT_FIELD =
+        SubmitRequirementResultProto.getDescriptor().findFieldByNumber(2);
+    private static final FieldDescriptor SR_OVERRIDE_EXPR_RESULT_FIELD =
+        SubmitRequirementResultProto.getDescriptor().findFieldByNumber(4);
+
     @Override
     public byte[] serialize(ChangeNotesState object) {
       checkArgument(object.metaId() != null, "meta ID is required in: %s", object);
@@ -519,6 +537,9 @@
           .changeMessages()
           .forEach(m -> b.addChangeMessage(ChangeMessageProtoConverter.INSTANCE.toProto(m)));
       object.publishedComments().values().forEach(c -> b.addPublishedComment(GSON.toJson(c)));
+      object
+          .submitRequirementsResult()
+          .forEach(sr -> b.addSubmitRequirementResult(toSubmitRequirementResultProto(sr)));
       b.setUpdateCount(object.updateCount());
       if (object.mergedOn() != null) {
         b.setMergedOnMillis(object.mergedOn().getTime());
@@ -613,6 +634,53 @@
       return builder.build();
     }
 
+    private static SubmitRequirementResultProto toSubmitRequirementResultProto(
+        SubmitRequirementResult r) {
+      SubmitRequirementResultProto.Builder builder = SubmitRequirementResultProto.newBuilder();
+      builder
+          .setSubmitRequirement(SubmitRequirementSerializer.serialize(r.submitRequirement()))
+          .setCommit(ObjectIdConverter.create().toByteString(r.patchSetCommitId()));
+      if (r.applicabilityExpressionResult().isPresent()) {
+        builder.setApplicabilityExpressionResult(
+            SubmitRequirementExpressionResultSerializer.serialize(
+                r.applicabilityExpressionResult().get()));
+      }
+      builder.setSubmittabilityExpressionResult(
+          SubmitRequirementExpressionResultSerializer.serialize(
+              r.submittabilityExpressionResult()));
+      if (r.overrideExpressionResult().isPresent()) {
+        builder.setOverrideExpressionResult(
+            SubmitRequirementExpressionResultSerializer.serialize(
+                r.overrideExpressionResult().get()));
+      }
+      return builder.build();
+    }
+
+    private static SubmitRequirementResult toSubmitRequirementResult(
+        SubmitRequirementResultProto proto) {
+      SubmitRequirementResult.Builder builder =
+          SubmitRequirementResult.builder()
+              .patchSetCommitId(ObjectIdConverter.create().fromByteString(proto.getCommit()))
+              .submitRequirement(
+                  SubmitRequirementSerializer.deserialize(proto.getSubmitRequirement()));
+      if (proto.hasField(SR_APPLICABILITY_EXPR_RESULT_FIELD)) {
+        builder.applicabilityExpressionResult(
+            Optional.of(
+                SubmitRequirementExpressionResultSerializer.deserialize(
+                    proto.getApplicabilityExpressionResult())));
+      }
+      builder.submittabilityExpressionResult(
+          SubmitRequirementExpressionResultSerializer.deserialize(
+              proto.getSubmittabilityExpressionResult()));
+      if (proto.hasField(SR_OVERRIDE_EXPR_RESULT_FIELD)) {
+        builder.overrideExpressionResult(
+            Optional.of(
+                SubmitRequirementExpressionResultSerializer.deserialize(
+                    proto.getOverrideExpressionResult())));
+      }
+      return builder.build();
+    }
+
     @Override
     public ChangeNotesState deserialize(byte[] in) {
       ChangeNotesStateProto proto = Protos.parseUnchecked(ChangeNotesStateProto.parser(), in);
@@ -658,6 +726,10 @@
                   proto.getPublishedCommentList().stream()
                       .map(r -> GSON.fromJson(r, HumanComment.class))
                       .collect(toImmutableListMultimap(HumanComment::getCommitId, c -> c)))
+              .submitRequirementsResult(
+                  proto.getSubmitRequirementResultList().stream()
+                      .map(sr -> toSubmitRequirementResult(sr))
+                      .collect(toImmutableList()))
               .updateCount(proto.getUpdateCount())
               .mergedOn(proto.getHasMergedOn() ? new Timestamp(proto.getMergedOnMillis()) : null);
       return b.build();
diff --git a/java/com/google/gerrit/server/notedb/ChangeRevisionNote.java b/java/com/google/gerrit/server/notedb/ChangeRevisionNote.java
index bf2cf07..44475db 100644
--- a/java/com/google/gerrit/server/notedb/ChangeRevisionNote.java
+++ b/java/com/google/gerrit/server/notedb/ChangeRevisionNote.java
@@ -16,7 +16,9 @@
 
 import static java.nio.charset.StandardCharsets.UTF_8;
 
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.entities.HumanComment;
+import com.google.gerrit.entities.SubmitRequirementResult;
 import java.io.ByteArrayInputStream;
 import java.io.IOException;
 import java.io.InputStream;
@@ -34,6 +36,8 @@
   private final HumanComment.Status status;
   private String pushCert;
 
+  private ImmutableList<SubmitRequirementResult> submitRequirementsResult;
+
   ChangeRevisionNote(
       ChangeNoteJson noteJson, ObjectReader reader, ObjectId noteId, HumanComment.Status status) {
     super(reader, noteId);
@@ -41,6 +45,11 @@
     this.status = status;
   }
 
+  public ImmutableList<SubmitRequirementResult> getSubmitRequirementsResult() {
+    checkParsed();
+    return submitRequirementsResult;
+  }
+
   public String getPushCert() {
     checkParsed();
     return pushCert;
@@ -52,20 +61,24 @@
     MutableInteger p = new MutableInteger();
     p.value = offset;
 
-    HumanCommentsRevisionNoteData data = parseJson(noteJson, raw, p.value);
+    ChangeRevisionNoteData data = parseJson(noteJson, raw, p.value);
     if (status == HumanComment.Status.PUBLISHED) {
       pushCert = data.pushCert;
     } else {
       pushCert = null;
     }
+    this.submitRequirementsResult =
+        data.submitRequirementResults == null
+            ? ImmutableList.of()
+            : ImmutableList.copyOf(data.submitRequirementResults);
     return data.comments;
   }
 
-  private HumanCommentsRevisionNoteData parseJson(ChangeNoteJson noteUtil, byte[] raw, int offset)
+  private ChangeRevisionNoteData parseJson(ChangeNoteJson noteUtil, byte[] raw, int offset)
       throws IOException {
     try (InputStream is = new ByteArrayInputStream(raw, offset, raw.length - offset);
         Reader r = new InputStreamReader(is, UTF_8)) {
-      return noteUtil.getGson().fromJson(r, HumanCommentsRevisionNoteData.class);
+      return noteUtil.getGson().fromJson(r, ChangeRevisionNoteData.class);
     }
   }
 }
diff --git a/java/com/google/gerrit/server/notedb/HumanCommentsRevisionNoteData.java b/java/com/google/gerrit/server/notedb/ChangeRevisionNoteData.java
similarity index 78%
rename from java/com/google/gerrit/server/notedb/HumanCommentsRevisionNoteData.java
rename to java/com/google/gerrit/server/notedb/ChangeRevisionNoteData.java
index e570412..8e33023 100644
--- a/java/com/google/gerrit/server/notedb/HumanCommentsRevisionNoteData.java
+++ b/java/com/google/gerrit/server/notedb/ChangeRevisionNoteData.java
@@ -15,14 +15,17 @@
 package com.google.gerrit.server.notedb;
 
 import com.google.gerrit.entities.HumanComment;
+import com.google.gerrit.entities.SubmitRequirementResult;
 import java.util.List;
 
 /**
  * Holds the raw data of a RevisionNote.
  *
- * <p>It is intended for deserialization from JSON only. It is used for human comments only.
+ * <p>It is intended for deserialization from JSON only. It is used for human comments. Submit
+ * requirements are also stored but only for closed changes.
  */
-class HumanCommentsRevisionNoteData {
+class ChangeRevisionNoteData {
   String pushCert;
   List<HumanComment> comments;
+  List<SubmitRequirementResult> submitRequirementResults;
 }
diff --git a/java/com/google/gerrit/server/notedb/ChangeUpdate.java b/java/com/google/gerrit/server/notedb/ChangeUpdate.java
index 8b9d2856..971e0a8 100644
--- a/java/com/google/gerrit/server/notedb/ChangeUpdate.java
+++ b/java/com/google/gerrit/server/notedb/ChangeUpdate.java
@@ -65,6 +65,7 @@
 import com.google.gerrit.entities.RobotComment;
 import com.google.gerrit.entities.SubmissionId;
 import com.google.gerrit.entities.SubmitRecord;
+import com.google.gerrit.entities.SubmitRequirementResult;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.client.ReviewerState;
 import com.google.gerrit.server.CurrentUser;
@@ -78,6 +79,7 @@
 import com.google.inject.assistedinject.AssistedInject;
 import java.io.IOException;
 import java.util.ArrayList;
+import java.util.Collection;
 import java.util.Comparator;
 import java.util.Date;
 import java.util.HashMap;
@@ -129,6 +131,7 @@
   private final Map<Account.Id, ReviewerStateInternal> reviewers = new LinkedHashMap<>();
   private final Map<Address, ReviewerStateInternal> reviewersByEmail = new LinkedHashMap<>();
   private final List<HumanComment> comments = new ArrayList<>();
+  private final List<SubmitRequirementResult> submitRequirementResults = new ArrayList<>();
 
   private String commitSubject;
   private String subject;
@@ -302,6 +305,10 @@
     this.psDescription = psDescription;
   }
 
+  public void putSubmitRequirementResults(Collection<SubmitRequirementResult> rs) {
+    submitRequirementResults.addAll(rs);
+  }
+
   public void putComment(HumanComment.Status status, HumanComment c) {
     verifyComment(c);
     createDraftUpdateIfNull();
@@ -488,7 +495,7 @@
   /** @return the tree id for the updated tree */
   private ObjectId storeRevisionNotes(RevWalk rw, ObjectInserter inserter, ObjectId curr)
       throws ConfigInvalidException, IOException {
-    if (comments.isEmpty() && pushCert == null) {
+    if (submitRequirementResults.isEmpty() && comments.isEmpty() && pushCert == null) {
       return null;
     }
     RevisionNoteMap<ChangeRevisionNote> rnm = getRevisionNoteMap(rw, curr);
@@ -498,6 +505,9 @@
       c.tag = tag;
       cache.get(c.getCommitId()).putComment(c);
     }
+    for (SubmitRequirementResult sr : submitRequirementResults) {
+      cache.get(sr.patchSetCommitId()).putSubmitRequirementResult(sr);
+    }
     if (pushCert != null) {
       checkState(commit != null);
       cache.get(ObjectId.fromString(commit)).setPushCertificate(pushCert);
diff --git a/java/com/google/gerrit/server/notedb/RevisionNoteBuilder.java b/java/com/google/gerrit/server/notedb/RevisionNoteBuilder.java
index 3c1d359..7998476 100644
--- a/java/com/google/gerrit/server/notedb/RevisionNoteBuilder.java
+++ b/java/com/google/gerrit/server/notedb/RevisionNoteBuilder.java
@@ -22,16 +22,20 @@
 import com.google.common.collect.Maps;
 import com.google.common.collect.MultimapBuilder;
 import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.SubmitRequirementResult;
 import java.io.ByteArrayOutputStream;
 import java.io.IOException;
 import java.io.OutputStream;
 import java.io.OutputStreamWriter;
+import java.util.ArrayList;
 import java.util.Collections;
+import java.util.Comparator;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
+import java.util.stream.Collectors;
 import org.eclipse.jgit.lib.AnyObjectId;
 import org.eclipse.jgit.lib.ObjectId;
 
@@ -60,11 +64,16 @@
     }
   }
 
+  /** Submit requirements are sorted w.r.t. their names before storing in NoteDb. */
+  private final Comparator<SubmitRequirementResult> SUBMIT_REQUIREMENT_RESULT_COMPARATOR =
+      Comparator.comparing(sr -> sr.submitRequirement().name());
+
   final byte[] baseRaw;
   private final List<? extends Comment> baseComments;
   final Map<Comment.Key, Comment> put;
   private final Set<Comment.Key> delete;
 
+  private List<SubmitRequirementResult> submitRequirementResults;
   private String pushCert;
 
   private RevisionNoteBuilder(RevisionNote<? extends Comment> base) {
@@ -81,6 +90,7 @@
       put = new HashMap<>();
       pushCert = null;
     }
+    submitRequirementResults = new ArrayList<>();
     delete = new HashSet<>();
   }
 
@@ -99,6 +109,10 @@
     put.put(comment.key, comment);
   }
 
+  void putSubmitRequirementResult(SubmitRequirementResult result) {
+    submitRequirementResults.add(result);
+  }
+
   void deleteComment(Comment.Key key) {
     checkArgument(!put.containsKey(key), "cannot both delete and put %s", key);
     delete.add(key);
@@ -126,13 +140,19 @@
 
   private void buildNoteJson(ChangeNoteJson noteUtil, OutputStream out) throws IOException {
     ListMultimap<Integer, Comment> comments = buildCommentMap();
-    if (comments.isEmpty() && pushCert == null) {
+    if (submitRequirementResults.isEmpty() && comments.isEmpty() && pushCert == null) {
       return;
     }
 
     RevisionNoteData data = new RevisionNoteData();
     data.comments = COMMENT_ORDER.sortedCopy(comments.values());
     data.pushCert = pushCert;
+    if (!submitRequirementResults.isEmpty()) {
+      data.submitRequirementResults =
+          submitRequirementResults.stream()
+              .sorted(SUBMIT_REQUIREMENT_RESULT_COMPARATOR)
+              .collect(Collectors.toList());
+    }
 
     try (OutputStreamWriter osw = new OutputStreamWriter(out, UTF_8)) {
       noteUtil.getGson().toJson(data, osw);
diff --git a/java/com/google/gerrit/server/notedb/RevisionNoteData.java b/java/com/google/gerrit/server/notedb/RevisionNoteData.java
index da15b34..c8770f1 100644
--- a/java/com/google/gerrit/server/notedb/RevisionNoteData.java
+++ b/java/com/google/gerrit/server/notedb/RevisionNoteData.java
@@ -15,15 +15,17 @@
 package com.google.gerrit.server.notedb;
 
 import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.SubmitRequirementResult;
 import java.util.List;
 
 /**
  * Holds the raw data of a RevisionNote.
  *
  * <p>It is intended for serialization to JSON only. It is used for human comments and robot
- * comments.
+ * comments, as well as for storing submit requirements.
  */
 class RevisionNoteData {
   String pushCert;
   List<Comment> comments;
+  List<SubmitRequirementResult> submitRequirementResults;
 }
diff --git a/java/com/google/gerrit/server/notedb/StoreSubmitRequirementsOp.java b/java/com/google/gerrit/server/notedb/StoreSubmitRequirementsOp.java
new file mode 100644
index 0000000..47948d7
--- /dev/null
+++ b/java/com/google/gerrit/server/notedb/StoreSubmitRequirementsOp.java
@@ -0,0 +1,38 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.notedb;
+
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.ChangeContext;
+
+/** A {@link BatchUpdateOp} that stores the evaluated submit requirements of a change in NoteDb. */
+public class StoreSubmitRequirementsOp implements BatchUpdateOp {
+  private final ChangeData.Factory changeDataFactory;
+
+  public StoreSubmitRequirementsOp(ChangeData.Factory changeDataFactory) {
+    this.changeDataFactory = changeDataFactory;
+  }
+
+  @Override
+  public boolean updateChange(ChangeContext ctx) throws Exception {
+    Change change = ctx.getChange();
+    ChangeData changeData = changeDataFactory.create(change);
+    ChangeUpdate update = ctx.getUpdate(change.currentPatchSetId());
+    update.putSubmitRequirementResults(changeData.submitRequirements().values());
+    return !changeData.submitRequirements().isEmpty();
+  }
+}
diff --git a/java/com/google/gerrit/server/patch/AutoMerger.java b/java/com/google/gerrit/server/patch/AutoMerger.java
index 8d1e0ff..97910400 100644
--- a/java/com/google/gerrit/server/patch/AutoMerger.java
+++ b/java/com/google/gerrit/server/patch/AutoMerger.java
@@ -142,7 +142,7 @@
     }
     counter.increment(OperationType.IN_MEMORY_WRITE);
     logger.atInfo().log("Computing in-memory AutoMerge for " + merge.name());
-    try (Timer1.Context ignored = latency.start(OperationType.IN_MEMORY_WRITE)) {
+    try (Timer1.Context<OperationType> ignored = latency.start(OperationType.IN_MEMORY_WRITE)) {
       return rw.parseCommit(createAutoMergeCommit(repo.getConfig(), rw, ins, merge, mergeStrategy));
     }
   }
@@ -171,7 +171,7 @@
     }
 
     ObjectId autoMerge;
-    try (Timer1.Context ignored = latency.start(OperationType.ON_DISK_WRITE)) {
+    try (Timer1.Context<OperationType> ignored = latency.start(OperationType.ON_DISK_WRITE)) {
       autoMerge =
           createAutoMergeCommit(
               repoView.getConfig(), rw, ins, maybeMergeCommit, configuredMergeStrategy);
diff --git a/java/com/google/gerrit/server/patch/DiffOperations.java b/java/com/google/gerrit/server/patch/DiffOperations.java
index 7213581..d2da736 100644
--- a/java/com/google/gerrit/server/patch/DiffOperations.java
+++ b/java/com/google/gerrit/server/patch/DiffOperations.java
@@ -46,8 +46,8 @@
    *
    * @param project a project name representing a git repository.
    * @param newCommit 20 bytes SHA-1 of the new commit used in the diff.
-   * @param parentNum integer specifying which parent to use as base. If null, the only parent will
-   *     be used or the auto-merge if {@code newCommit} is a merge commit.
+   * @param parentNum 1-based integer specifying which parent to use as base. If zero, the only
+   *     parent will be used or the auto-merge if {@code newCommit} is a merge commit.
    * @return map of file paths to the file diffs. The map key is the new file path for all {@link
    *     ChangeType} file diffs except {@link ChangeType#DELETED} entries where the map key contains
    *     the old file path. The map entries are not sorted by key.
@@ -56,8 +56,7 @@
    *     an internal error occurred in Git while evaluating the diff.
    */
   Map<String, FileDiffOutput> listModifiedFilesAgainstParent(
-      Project.NameKey project, ObjectId newCommit, @Nullable Integer parentNum)
-      throws DiffNotAvailableException;
+      Project.NameKey project, ObjectId newCommit, int parentNum) throws DiffNotAvailableException;
 
   /**
    * Returns the list of added, deleted or modified files between two commits (patchsets). The
@@ -85,8 +84,8 @@
    *
    * @param project a project name representing a git repository.
    * @param newCommit 20 bytes SHA-1 of the new commit used in the diff.
-   * @param parentNum integer specifying which parent to use as base. If null, the only parent will
-   *     be used or the auto-merge if {@code newCommit} is a merge commit.
+   * @param parentNum 1-based integer specifying which parent to use as base. If zero, the only
+   *     parent will be used or the auto-merge if {@code newCommit} is a merge commit.
    * @param fileName the file name for which the diff should be evaluated.
    * @param whitespace preference controlling whitespace effect in diff computation.
    * @return the diff for the single file between the two commits.
@@ -96,7 +95,7 @@
   FileDiffOutput getModifiedFileAgainstParent(
       Project.NameKey project,
       ObjectId newCommit,
-      @Nullable Integer parentNum,
+      int parentNum,
       String fileName,
       @Nullable DiffPreferencesInfo.Whitespace whitespace)
       throws DiffNotAvailableException;
diff --git a/java/com/google/gerrit/server/patch/DiffOperationsImpl.java b/java/com/google/gerrit/server/patch/DiffOperationsImpl.java
index f500796..3423b32 100644
--- a/java/com/google/gerrit/server/patch/DiffOperationsImpl.java
+++ b/java/com/google/gerrit/server/patch/DiffOperationsImpl.java
@@ -91,8 +91,7 @@
 
   @Override
   public Map<String, FileDiffOutput> listModifiedFilesAgainstParent(
-      Project.NameKey project, ObjectId newCommit, @Nullable Integer parent)
-      throws DiffNotAvailableException {
+      Project.NameKey project, ObjectId newCommit, int parent) throws DiffNotAvailableException {
     try {
       DiffParameters diffParams = computeDiffParameters(project, newCommit, parent);
       return getModifiedFiles(diffParams);
@@ -120,7 +119,7 @@
   public FileDiffOutput getModifiedFileAgainstParent(
       Project.NameKey project,
       ObjectId newCommit,
-      @Nullable Integer parent,
+      int parent,
       String fileName,
       @Nullable DiffPreferencesInfo.Whitespace whitespace)
       throws DiffNotAvailableException {
@@ -376,7 +375,7 @@
       Project.NameKey project, ObjectId newCommit, Integer parent) throws IOException {
     DiffParameters.Builder result =
         DiffParameters.builder().project(project).newCommit(newCommit).parent(parent);
-    if (parent != null) {
+    if (parent > 0) {
       result.baseCommit(baseCommitUtil.getBaseCommit(project, newCommit, parent));
       result.comparisonType(ComparisonType.againstParent(parent));
       return result.build();
diff --git a/java/com/google/gerrit/server/patch/PatchScriptFactory.java b/java/com/google/gerrit/server/patch/PatchScriptFactory.java
index 885459a..fbb6559 100644
--- a/java/com/google/gerrit/server/patch/PatchScriptFactory.java
+++ b/java/com/google/gerrit/server/patch/PatchScriptFactory.java
@@ -172,7 +172,7 @@
 
     this.fileName = fileName;
     this.psa = patchSetA;
-    this.parentNum = -1;
+    this.parentNum = 0;
     this.psb = patchSetB;
     this.diffPrefs = diffPrefs;
     this.currentUser = currentUser;
@@ -223,7 +223,7 @@
     this.runNewDiffCache = cfg.getBoolean("cache", "diff_cache", "runNewDiffCache_GetDiff", false);
 
     changeId = patchSetB.changeId();
-    checkArgument(parentNum >= 0, "parentNum must be >= 0");
+    checkArgument(parentNum > 0, "parentNum must be > 0");
   }
 
   @Override
@@ -265,9 +265,8 @@
           // the results while rolling out the new diff cache.
           runOldDiffCacheAsyncAndExportMetrics(git, aId, bId, patchScript);
           return patchScript;
-        } else {
-          return getPatchScriptWithOldDiffCache(git, aId, bId);
         }
+        return getPatchScriptWithOldDiffCache(git, aId, bId);
       } catch (PatchListNotAvailableException e) {
         throw new NoSuchChangeException(changeId, e);
       } catch (DiffNotAvailableException e) {
@@ -327,11 +326,7 @@
     FileDiffOutput fileDiffOutput =
         aId == null
             ? diffOperations.getModifiedFileAgainstParent(
-                notes.getProjectName(),
-                bId,
-                parentNum == -1 ? null : parentNum + 1,
-                fileName,
-                diffPrefs.ignoreWhitespace)
+                notes.getProjectName(), bId, parentNum, fileName, diffPrefs.ignoreWhitespace)
             : diffOperations.getModifiedFile(
                 notes.getProjectName(), aId, bId, fileName, diffPrefs.ignoreWhitespace);
     return newBuilder().toPatchScriptNew(git, fileDiffOutput);
@@ -396,7 +391,7 @@
     if (psa == null) {
       return Optional.empty();
     }
-    checkState(parentNum < 0, "expected no parentNum when psa is present");
+    checkState(parentNum == 0, "expected no parentNum when psa is present");
     checkArgument(psa.get() != 0, "edit not supported for left side");
     return Optional.of(getCommitId(psa));
   }
@@ -410,10 +405,10 @@
   }
 
   private PatchListKey keyFor(ObjectId aId, ObjectId bId, Whitespace whitespace) {
-    if (parentNum < 0) {
+    if (parentNum == 0) {
       return PatchListKey.againstCommit(aId, bId, whitespace);
     }
-    return PatchListKey.againstParentNum(parentNum + 1, bId, whitespace);
+    return PatchListKey.againstParentNum(parentNum, bId, whitespace);
   }
 
   private PatchList listFor(PatchListKey key) throws PatchListNotAvailableException {
diff --git a/java/com/google/gerrit/server/patch/SubmitWithStickyApprovalDiff.java b/java/com/google/gerrit/server/patch/SubmitWithStickyApprovalDiff.java
index e33b261..62cfa47 100644
--- a/java/com/google/gerrit/server/patch/SubmitWithStickyApprovalDiff.java
+++ b/java/com/google/gerrit/server/patch/SubmitWithStickyApprovalDiff.java
@@ -18,6 +18,7 @@
 
 import com.google.gerrit.common.data.PatchScript;
 import com.google.gerrit.entities.LabelId;
+import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.Patch;
 import com.google.gerrit.entities.Patch.ChangeType;
 import com.google.gerrit.entities.PatchSet;
@@ -43,6 +44,7 @@
 import java.io.IOException;
 import java.util.List;
 import java.util.Map;
+import java.util.Optional;
 import java.util.stream.Collectors;
 import org.eclipse.jgit.diff.Edit;
 import org.eclipse.jgit.lib.Config;
@@ -242,10 +244,9 @@
       if (!patchSetApproval.label().equals(LabelId.CODE_REVIEW)) {
         continue;
       }
-      if (!projectState
-          .getLabelTypes(notes)
-          .byLabel(patchSetApproval.labelId())
-          .isMaxPositive(patchSetApproval)) {
+      Optional<LabelType> lt =
+          projectState.getLabelTypes(notes).byLabel(patchSetApproval.labelId());
+      if (!lt.isPresent() || !lt.get().isMaxPositive(patchSetApproval)) {
         continue;
       }
       if (patchSetApproval.patchSetId().get() > maxPatchSetId.get()) {
diff --git a/java/com/google/gerrit/server/patch/diff/ModifiedFilesCacheImpl.java b/java/com/google/gerrit/server/patch/diff/ModifiedFilesCacheImpl.java
index b779bf7..e4fd728 100644
--- a/java/com/google/gerrit/server/patch/diff/ModifiedFilesCacheImpl.java
+++ b/java/com/google/gerrit/server/patch/diff/ModifiedFilesCacheImpl.java
@@ -18,11 +18,14 @@
 
 import com.google.common.cache.CacheLoader;
 import com.google.common.cache.LoadingCache;
+import com.google.common.collect.ArrayListMultimap;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.ListMultimap;
 import com.google.common.collect.Sets;
 import com.google.common.collect.Streams;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.Patch.ChangeType;
 import com.google.gerrit.server.cache.CacheModule;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.patch.DiffNotAvailableException;
@@ -37,6 +40,7 @@
 import com.google.inject.TypeLiteral;
 import com.google.inject.name.Named;
 import java.io.IOException;
+import java.util.ArrayList;
 import java.util.List;
 import java.util.Set;
 import java.util.stream.Stream;
@@ -82,7 +86,7 @@
             .valueSerializer(GitModifiedFilesCacheImpl.ValueSerializer.INSTANCE)
             .maximumWeight(10 << 20)
             .weigher(ModifiedFilesWeigher.class)
-            .version(1)
+            .version(2)
             .loader(ModifiedFilesLoader.class);
       }
     };
@@ -139,7 +143,7 @@
               .bTree(bTree)
               .renameScore(key.renameScore())
               .build();
-      List<ModifiedFile> modifiedFiles = gitCache.get(gitKey);
+      List<ModifiedFile> modifiedFiles = mergeRewrittenEntries(gitCache.get(gitKey));
       if (key.aCommit().equals(ObjectId.zeroId())) {
         return ImmutableList.copyOf(modifiedFiles);
       }
@@ -202,5 +206,61 @@
       // value as the set of file paths shouldn't contain it.
       return touchedFilePaths.contains(oldFilePath) || touchedFilePaths.contains(newFilePath);
     }
+
+    /**
+     * Return the {@code modifiedFiles} input list while merging {@link ChangeType#ADDED} and {@link
+     * ChangeType#DELETED} entries for the same file into a single {@link ChangeType#REWRITE} entry.
+     *
+     * <p>Background: In some cases, JGit returns two diff entries (ADDED + DELETED) for the same
+     * file path. This happens e.g. when a file's mode is changed between patchsets, for example
+     * converting a symlink file to a regular file. We identify this case and return a single
+     * modified file with changeType = {@link ChangeType#REWRITE}.
+     */
+    private static List<ModifiedFile> mergeRewrittenEntries(List<ModifiedFile> modifiedFiles) {
+      List<ModifiedFile> result = new ArrayList<>();
+
+      // Handle ADDED and DELETED entries separately.
+      ListMultimap<String, ModifiedFile> byPath = ArrayListMultimap.create();
+      modifiedFiles.stream()
+          .filter(ModifiedFilesLoader::isAddedOrDeleted)
+          .forEach(
+              f -> {
+                if (f.oldPath().isPresent()) {
+                  byPath.get(f.oldPath().get()).add(f);
+                }
+                if (f.newPath().isPresent()) {
+                  byPath.get(f.newPath().get()).add(f);
+                }
+              });
+      for (String path : byPath.keySet()) {
+        List<ModifiedFile> entries = byPath.get(path);
+        if (entries.size() == 1) {
+          result.add(entries.get(0));
+        } else if (entries.size() == 2) {
+          result.add(getAddedEntry(entries).toBuilder().changeType(ChangeType.REWRITE).build());
+        } else {
+          // JGit error. Not expected to happen.
+          logger.atWarning().log(
+              "Found %d ADDED and DELETED entries for the same file path: %s."
+                  + " Adding the first entry only to the result.",
+              entries.size(), entries);
+          result.add(entries.get(0));
+        }
+      }
+
+      // Add the remaining non ADDED/DELETED entries to the result
+      modifiedFiles.stream().filter(f -> !isAddedOrDeleted(f)).forEach(result::add);
+      return result;
+    }
+
+    private static boolean isAddedOrDeleted(ModifiedFile f) {
+      return f.changeType() == ChangeType.ADDED || f.changeType() == ChangeType.DELETED;
+    }
+
+    private static ModifiedFile getAddedEntry(List<ModifiedFile> modifiedFiles) {
+      return modifiedFiles.get(0).changeType() == ChangeType.ADDED
+          ? modifiedFiles.get(0)
+          : modifiedFiles.get(1);
+    }
   }
 }
diff --git a/java/com/google/gerrit/server/patch/gitdiff/ModifiedFile.java b/java/com/google/gerrit/server/patch/gitdiff/ModifiedFile.java
index 9512094..f4e7ca3 100644
--- a/java/com/google/gerrit/server/patch/gitdiff/ModifiedFile.java
+++ b/java/com/google/gerrit/server/patch/gitdiff/ModifiedFile.java
@@ -51,6 +51,8 @@
     return new AutoValue_ModifiedFile.Builder();
   }
 
+  public abstract Builder toBuilder();
+
   /** Computes this object's weight, which is its size in bytes. */
   public int weight() {
     int weight = 1; // the changeType field
diff --git a/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiff.java b/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiff.java
index 2f2d29b..a502a46 100644
--- a/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiff.java
+++ b/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiff.java
@@ -187,6 +187,10 @@
     return result;
   }
 
+  public String getDefaultPath() {
+    return oldPath().isPresent() ? oldPath().get() : newPath().get();
+  }
+
   public static Builder builder() {
     return new AutoValue_GitFileDiff.Builder();
   }
diff --git a/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiffCacheImpl.java b/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiffCacheImpl.java
index 2ce6925..77b8938 100644
--- a/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiffCacheImpl.java
+++ b/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiffCacheImpl.java
@@ -24,7 +24,11 @@
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
+import com.google.common.collect.ListMultimap;
+import com.google.common.collect.MultimapBuilder;
+import com.google.common.collect.Multimaps;
 import com.google.common.collect.Streams;
+import com.google.gerrit.entities.Patch;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
 import com.google.gerrit.server.cache.CacheModule;
@@ -41,6 +45,7 @@
 import com.google.inject.Singleton;
 import com.google.inject.name.Named;
 import java.io.IOException;
+import java.util.ArrayList;
 import java.util.Collection;
 import java.util.List;
 import java.util.Map;
@@ -105,6 +110,9 @@
 
   private final LoadingCache<GitFileDiffCacheKey, GitFileDiff> cache;
 
+  private static final ImmutableSet<Patch.ChangeType> ADDED_AND_DELETED =
+      ImmutableSet.of(Patch.ChangeType.ADDED, Patch.ChangeType.DELETED);
+
   @Inject
   public GitFileDiffCacheImpl(
       @Named(GIT_DIFF) LoadingCache<GitFileDiffCacheKey, GitFileDiff> cache) {
@@ -163,7 +171,7 @@
     }
 
     @Override
-    public GitFileDiff load(GitFileDiffCacheKey key) throws IOException {
+    public GitFileDiff load(GitFileDiffCacheKey key) throws IOException, DiffNotAvailableException {
       try (TraceTimer timer =
           TraceContext.newTimer(
               "Loading a single key from git file diff cache",
@@ -177,7 +185,8 @@
 
     @Override
     public Map<GitFileDiffCacheKey, GitFileDiff> loadAll(
-        Iterable<? extends GitFileDiffCacheKey> keys) throws IOException {
+        Iterable<? extends GitFileDiffCacheKey> keys)
+        throws IOException, DiffNotAvailableException {
       try (TraceTimer timer =
           TraceContext.newTimer("Loading multiple keys from git file diff cache")) {
         ImmutableMap.Builder<GitFileDiffCacheKey, GitFileDiff> result =
@@ -215,13 +224,14 @@
      */
     private Map<GitFileDiffCacheKey, GitFileDiff> loadAllImpl(
         Repository repo, ObjectReader reader, DiffOptions options, List<GitFileDiffCacheKey> keys)
-        throws IOException {
+        throws IOException, DiffNotAvailableException {
       ImmutableMap.Builder<GitFileDiffCacheKey, GitFileDiff> result =
           ImmutableMap.builderWithExpectedSize(keys.size());
       Map<GitFileDiffCacheKey, String> filePaths =
           keys.stream().collect(Collectors.toMap(identity(), GitFileDiffCacheKey::newFilePath));
       DiffFormatter formatter = createDiffFormatter(options, repo, reader);
-      Map<String, DiffEntry> diffEntries = loadDiffEntries(formatter, options, filePaths.values());
+      ListMultimap<String, DiffEntry> diffEntries =
+          loadDiffEntries(formatter, options, filePaths.values());
       for (GitFileDiffCacheKey key : filePaths.keySet()) {
         String newFilePath = filePaths.get(key);
         if (!diffEntries.containsKey(newFilePath)) {
@@ -233,14 +243,25 @@
                   newFilePath));
           continue;
         }
-        DiffEntry diffEntry = diffEntries.get(newFilePath);
-        GitFileDiff gitFileDiff = createGitFileDiff(diffEntry, formatter, key);
-        result.put(key, gitFileDiff);
+        List<DiffEntry> entries = diffEntries.get(newFilePath);
+        if (entries.size() == 1) {
+          result.put(key, createGitFileDiff(entries.get(0), formatter, key));
+        } else {
+          // Handle when JGit returns two {Added, Deleted} entries for the same file. This happens,
+          // for example, when a file's mode is changed between patchsets (e.g. converting a
+          // symlink to a regular file). We combine both diff entries into a single entry with
+          // {changeType = Rewrite}.
+          List<GitFileDiff> gitDiffs = new ArrayList<>();
+          for (DiffEntry entry : diffEntries.get(newFilePath)) {
+            gitDiffs.add(createGitFileDiff(entry, formatter, key));
+          }
+          result.put(key, createRewriteEntry(gitDiffs));
+        }
       }
       return result.build();
     }
 
-    private static Map<String, DiffEntry> loadDiffEntries(
+    private static ListMultimap<String, DiffEntry> loadDiffEntries(
         DiffFormatter diffFormatter, DiffOptions diffOptions, Collection<String> filePaths)
         throws IOException {
       Set<String> filePathsSet = ImmutableSet.copyOf(filePaths);
@@ -251,7 +272,11 @@
 
       return diffEntries.stream()
           .filter(d -> filePathsSet.contains(pathExtractor.apply(d)))
-          .collect(Collectors.toMap(d -> pathExtractor.apply(d), identity()));
+          .collect(
+              Multimaps.toMultimap(
+                  d -> pathExtractor.apply(d),
+                  identity(),
+                  MultimapBuilder.treeKeys().arrayListValues()::build));
     }
 
     private static DiffFormatter createDiffFormatter(
@@ -334,6 +359,39 @@
     }
   }
 
+  /**
+   * Create a single {@link GitFileDiff} with {@link Patch.ChangeType} equals {@link
+   * Patch.ChangeType#REWRITE}, assuming the input list contains two entries with types {@link
+   * Patch.ChangeType#ADDED} and {@link Patch.ChangeType#DELETED}.
+   *
+   * @param gitDiffs input list of exactly two {@link GitFileDiff} for same file path.
+   * @return a single {@link GitFileDiff} with change type equals {@link Patch.ChangeType#REWRITE}.
+   * @throws DiffNotAvailableException if input list contains git diffs with change types other than
+   *     {ADDED, DELETED}. This is a JGit error.
+   */
+  private static GitFileDiff createRewriteEntry(List<GitFileDiff> gitDiffs)
+      throws DiffNotAvailableException {
+    if (gitDiffs.size() != 2) {
+      throw new DiffNotAvailableException(
+          String.format(
+              "JGit error: found %d dff entries for same file path %s",
+              gitDiffs.size(), gitDiffs.get(0).getDefaultPath()));
+    }
+    if (!ImmutableSet.of(gitDiffs.get(0).changeType(), gitDiffs.get(1).changeType())
+        .equals(ADDED_AND_DELETED)) {
+      // This is an illegal state. JGit is not supposed to return this, so we throw an exception.
+      throw new DiffNotAvailableException(
+          String.format(
+              "JGit error: unexpected change types %s and %s for same file path %s",
+              gitDiffs.get(0).changeType(),
+              gitDiffs.get(1).changeType(),
+              gitDiffs.get(0).getDefaultPath()));
+    }
+    GitFileDiff addedEntry =
+        gitDiffs.get(0).changeType() == Patch.ChangeType.ADDED ? gitDiffs.get(0) : gitDiffs.get(1);
+    return addedEntry.toBuilder().changeType(Patch.ChangeType.REWRITE).build();
+  }
+
   /** An entity representing the options affecting the diff computation. */
   @AutoValue
   abstract static class DiffOptions {
diff --git a/java/com/google/gerrit/server/project/ProjectConfig.java b/java/com/google/gerrit/server/project/ProjectConfig.java
index 3253282..0d710b9 100644
--- a/java/com/google/gerrit/server/project/ProjectConfig.java
+++ b/java/com/google/gerrit/server/project/ProjectConfig.java
@@ -126,12 +126,12 @@
   public static final String KEY_CAN_OVERRIDE = "canOverride";
   public static final String KEY_BRANCH = "branch";
 
-  public static final String SUBMIT_REQUIREMENT = "submitRequirement";
+  public static final String SUBMIT_REQUIREMENT = "submit-requirement";
   public static final String KEY_SR_NAME = "name";
   public static final String KEY_SR_DESCRIPTION = "description";
-  public static final String KEY_SR_APPLICABILITY_EXPRESSION = "applicabilityExpression";
-  public static final String KEY_SR_SUBMITTABILITY_EXPRESSION = "submittabilityExpression";
-  public static final String KEY_SR_OVERRIDE_EXPRESSION = "overrideExpression";
+  public static final String KEY_SR_APPLICABILITY_EXPRESSION = "applicableIf";
+  public static final String KEY_SR_SUBMITTABILITY_EXPRESSION = "submittableIf";
+  public static final String KEY_SR_OVERRIDE_EXPRESSION = "overrideIf";
   public static final String KEY_SR_OVERRIDE_IN_CHILD_PROJECTS = "canOverrideInChildProjects";
 
   public static final String KEY_MATCH = "match";
diff --git a/java/com/google/gerrit/server/project/SubmitRequirementsEvaluator.java b/java/com/google/gerrit/server/project/SubmitRequirementsEvaluator.java
index 1d154dd..65d9d9e 100644
--- a/java/com/google/gerrit/server/project/SubmitRequirementsEvaluator.java
+++ b/java/com/google/gerrit/server/project/SubmitRequirementsEvaluator.java
@@ -66,6 +66,8 @@
             : Optional.empty();
 
     return SubmitRequirementResult.builder()
+        .submitRequirement(sr)
+        .patchSetCommitId(cd.currentPatchSet().commitId())
         .submittabilityExpressionResult(blockingResult)
         .applicabilityExpressionResult(applicabilityResult)
         .overrideExpressionResult(overrideResult)
@@ -79,9 +81,9 @@
       Predicate<ChangeData> predicate =
           changeQueryBuilderProvider.get().parse(expression.expressionString());
       PredicateResult predicateResult = evaluatePredicateTree(predicate, changeData);
-      return SubmitRequirementExpressionResult.create(predicateResult);
+      return SubmitRequirementExpressionResult.create(expression, predicateResult);
     } catch (QueryParseException e) {
-      return SubmitRequirementExpressionResult.error(e.getMessage());
+      return SubmitRequirementExpressionResult.error(expression, e.getMessage());
     }
   }
 
diff --git a/java/com/google/gerrit/server/query/approval/ApprovalContext.java b/java/com/google/gerrit/server/query/approval/ApprovalContext.java
index 4c2c7e8..3bf072a 100644
--- a/java/com/google/gerrit/server/query/approval/ApprovalContext.java
+++ b/java/com/google/gerrit/server/query/approval/ApprovalContext.java
@@ -44,11 +44,12 @@
         "approval and target must be the same change. got: %s, %s",
         psa.patchSetId(),
         id);
-    checkState(
-        psa.patchSetId().get() + 1 == id.get(),
-        "approvals can only be copied to the next consecutive patch set. got: %s, %s",
-        psa.patchSetId(),
-        id);
+    // TODO(ekempin): Use checkState to verify that psa.patchSetId().get() + 1 == id.get() so that
+    // it's ensured that approvals are only copied to the next consecutive patch set. To add back
+    // this verification https://gerrit-review.googlesource.com/c/gerrit/+/312633 can be reverted.
+    // As explained in the commit message of this change doing this check is only possible if there
+    // are no changes with gaps in patch set numbers. Since it's planned to fix-up old changes with
+    // gaps in patch set numbers, this todo is a reminder to add back the check once this is done.
     return new AutoValue_ApprovalContext(psa, id, changeNotes, changeKind);
   }
 }
diff --git a/java/com/google/gerrit/server/query/approval/MagicValuePredicate.java b/java/com/google/gerrit/server/query/approval/MagicValuePredicate.java
index 2924e6e..326620d 100644
--- a/java/com/google/gerrit/server/query/approval/MagicValuePredicate.java
+++ b/java/com/google/gerrit/server/query/approval/MagicValuePredicate.java
@@ -23,6 +23,7 @@
 import com.google.inject.assistedinject.Assisted;
 import java.util.Collection;
 import java.util.Objects;
+import java.util.Optional;
 
 /** Predicate that matches patch set approvals we want to copy based on the value. */
 public class MagicValuePredicate extends ApprovalPredicate {
@@ -47,19 +48,23 @@
 
   @Override
   public boolean match(ApprovalContext ctx) {
+    Optional<LabelType> lt =
+        getLabelType(ctx.changeNotes().getProjectName(), ctx.patchSetApproval().labelId());
     short pValue;
     switch (value) {
       case ANY:
         return true;
       case MIN:
-        pValue =
-            getLabelType(ctx.changeNotes().getProjectName(), ctx.patchSetApproval().labelId())
-                .getMaxNegative();
+        if (!lt.isPresent()) {
+          return false;
+        }
+        pValue = lt.get().getMaxNegative();
         break;
       case MAX:
-        pValue =
-            getLabelType(ctx.changeNotes().getProjectName(), ctx.patchSetApproval().labelId())
-                .getMaxPositive();
+        if (!lt.isPresent()) {
+          return false;
+        }
+        pValue = lt.get().getMaxPositive();
         break;
       default:
         throw new IllegalArgumentException("unrecognized label value: " + value);
@@ -67,7 +72,7 @@
     return pValue == ctx.patchSetApproval().value();
   }
 
-  private LabelType getLabelType(Project.NameKey project, LabelId labelId) {
+  private Optional<LabelType> getLabelType(Project.NameKey project, LabelId labelId) {
     return projectCache
         .get(project)
         .orElseThrow(() -> new IllegalStateException(project + " absent"))
diff --git a/java/com/google/gerrit/server/query/change/ChangeData.java b/java/com/google/gerrit/server/query/change/ChangeData.java
index ac28342..f912250 100644
--- a/java/com/google/gerrit/server/query/change/ChangeData.java
+++ b/java/com/google/gerrit/server/query/change/ChangeData.java
@@ -327,12 +327,16 @@
   private SubmitTypeRecord submitTypeRecord;
   private Boolean mergeable;
   private Set<String> hashtags;
-  /** Map from {@link Account.Id} to the tip of the edit ref for this change and a given user. */
+  /**
+   * Map from {@link com.google.gerrit.entities.Account.Id} to the tip of the edit ref for this
+   * change and a given user.
+   */
   private Table<Account.Id, PatchSet.Id, ObjectId> editsByUser;
 
   private Set<Account.Id> reviewedBy;
   /**
-   * Map from {@link Account.Id} to the tip of the draft comments ref for this change and the user.
+   * Map from {@link com.google.gerrit.entities.Account.Id} to the tip of the draft comments ref for
+   * this change and the user.
    */
   private Map<Account.Id, ObjectId> draftsByUser;
 
diff --git a/java/com/google/gerrit/server/query/change/ChangePredicates.java b/java/com/google/gerrit/server/query/change/ChangePredicates.java
index 617002d..044d276 100644
--- a/java/com/google/gerrit/server/query/change/ChangePredicates.java
+++ b/java/com/google/gerrit/server/query/change/ChangePredicates.java
@@ -33,22 +33,24 @@
   private ChangePredicates() {}
 
   /**
-   * Returns a predicate that matches changes where the provided {@link Account.Id} is in the
-   * attention set.
+   * Returns a predicate that matches changes where the provided {@link
+   * com.google.gerrit.entities.Account.Id} is in the attention set.
    */
   public static Predicate<ChangeData> attentionSet(Account.Id id) {
     return new ChangeIndexPredicate(ChangeField.ATTENTION_SET_USERS, id.toString());
   }
 
   /**
-   * Returns a predicate that matches changes that are assigned to the provided {@link Account.Id}.
+   * Returns a predicate that matches changes that are assigned to the provided {@link
+   * com.google.gerrit.entities.Account.Id}.
    */
   public static Predicate<ChangeData> assignee(Account.Id id) {
     return new ChangeIndexPredicate(ChangeField.ASSIGNEE, id.toString());
   }
 
   /**
-   * Returns a predicate that matches changes that are a revert of the provided {@link Change.Id}.
+   * Returns a predicate that matches changes that are a revert of the provided {@link
+   * com.google.gerrit.entities.Change.Id}.
    */
   public static Predicate<ChangeData> revertOf(Change.Id revertOf) {
     return new ChangeIndexPredicate(ChangeField.REVERT_OF, revertOf.toString());
@@ -56,23 +58,23 @@
 
   /**
    * Returns a predicate that matches changes that have a comment authored by the provided {@link
-   * Account.Id}.
+   * com.google.gerrit.entities.Account.Id}.
    */
   public static Predicate<ChangeData> commentBy(Account.Id id) {
     return new ChangeIndexPredicate(ChangeField.COMMENTBY, id.toString());
   }
 
   /**
-   * Returns a predicate that matches changes where the provided {@link Account.Id} has a pending
-   * change edit.
+   * Returns a predicate that matches changes where the provided {@link
+   * com.google.gerrit.entities.Account.Id} has a pending change edit.
    */
   public static Predicate<ChangeData> editBy(Account.Id id) {
     return new ChangeIndexPredicate(ChangeField.EDITBY, id.toString());
   }
 
   /**
-   * Returns a predicate that matches changes where the provided {@link Account.Id} has a pending
-   * draft comment.
+   * Returns a predicate that matches changes where the provided {@link
+   * com.google.gerrit.entities.Account.Id} has a pending draft comment.
    */
   public static Predicate<ChangeData> draftBy(Account.Id id) {
     return new ChangeIndexPredicate(ChangeField.DRAFTBY, id.toString());
@@ -80,7 +82,7 @@
 
   /**
    * Returns a predicate that matches changes that were reviewed by any of the provided {@link
-   * Account.Id}.
+   * com.google.gerrit.entities.Account.Id}.
    */
   public static Predicate<ChangeData> reviewedBy(Collection<Account.Id> ids) {
     List<Predicate<ChangeData>> predicates = new ArrayList<>(ids.size());
@@ -96,26 +98,35 @@
         new ChangeIndexPredicate(ChangeField.REVIEWEDBY, ChangeField.NOT_REVIEWED.toString()));
   }
 
-  /** Returns a predicate that matches the change with the provided {@link Change.Id}. */
+  /**
+   * Returns a predicate that matches the change with the provided {@link
+   * com.google.gerrit.entities.Change.Id}.
+   */
   public static Predicate<ChangeData> id(Change.Id id) {
     return new ChangeIndexPredicate(
         ChangeField.LEGACY_ID, ChangeQueryBuilder.FIELD_CHANGE, id.toString());
   }
 
-  /** Returns a predicate that matches the change with the provided {@link Change.Id}. */
+  /**
+   * Returns a predicate that matches the change with the provided {@link
+   * com.google.gerrit.entities.Change.Id}.
+   */
   public static Predicate<ChangeData> idStr(Change.Id id) {
     return new ChangeIndexPredicate(
         ChangeField.LEGACY_ID_STR, ChangeQueryBuilder.FIELD_CHANGE, id.toString());
   }
 
-  /** Returns a predicate that matches changes owned by the provided {@link Account.Id}. */
+  /**
+   * Returns a predicate that matches changes owned by the provided {@link
+   * com.google.gerrit.entities.Account.Id}.
+   */
   public static Predicate<ChangeData> owner(Account.Id id) {
     return new ChangeIndexPredicate(ChangeField.OWNER, id.toString());
   }
 
   /**
    * Returns a predicate that matches changes that are a cherry pick of the provided {@link
-   * Change.Id}.
+   * com.google.gerrit.entities.Change.Id}.
    */
   public static Predicate<ChangeData> cherryPickOf(Change.Id id) {
     return new ChangeIndexPredicate(ChangeField.CHERRY_PICK_OF_CHANGE, id.toString());
@@ -123,7 +134,7 @@
 
   /**
    * Returns a predicate that matches changes that are a cherry pick of the provided {@link
-   * PatchSet.Id}.
+   * com.google.gerrit.entities.PatchSet.Id}.
    */
   public static Predicate<ChangeData> cherryPickOf(PatchSet.Id psId) {
     return Predicate.and(
@@ -131,7 +142,10 @@
         new ChangeIndexPredicate(ChangeField.CHERRY_PICK_OF_PATCHSET, String.valueOf(psId.get())));
   }
 
-  /** Returns a predicate that matches changes in the provided {@link Project.NameKey}. */
+  /**
+   * Returns a predicate that matches changes in the provided {@link
+   * com.google.gerrit.entities.Project.NameKey}.
+   */
   public static Predicate<ChangeData> project(Project.NameKey id) {
     return new ChangeIndexPredicate(ChangeField.PROJECT, id.get());
   }
diff --git a/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java b/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
index 94b5442..131de74 100644
--- a/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
+++ b/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
@@ -973,7 +973,7 @@
     int eq = name.indexOf('=');
     if (args.getSchema().hasField(ChangeField.SUBMIT_RECORD) && eq > 0) {
       String statusName = name.substring(eq + 1).toUpperCase();
-      if (!isInt(statusName)) {
+      if (!isInt(statusName) && !MagicLabelValue.tryParse(statusName).isPresent()) {
         SubmitRecord.Label.Status status =
             Enums.getIfPresent(SubmitRecord.Label.Status.class, statusName).orNull();
         if (status == null) {
diff --git a/java/com/google/gerrit/server/query/change/EqualsLabelPredicate.java b/java/com/google/gerrit/server/query/change/EqualsLabelPredicate.java
index 30d5e2f..ade615c 100644
--- a/java/com/google/gerrit/server/query/change/EqualsLabelPredicate.java
+++ b/java/com/google/gerrit/server/query/change/EqualsLabelPredicate.java
@@ -91,8 +91,8 @@
   }
 
   protected static LabelType type(LabelTypes types, String toFind) {
-    if (types.byLabel(toFind) != null) {
-      return types.byLabel(toFind);
+    if (types.byLabel(toFind).isPresent()) {
+      return types.byLabel(toFind).get();
     }
 
     for (LabelType lt : types.getLabelTypes()) {
diff --git a/java/com/google/gerrit/server/query/change/LabelPredicate.java b/java/com/google/gerrit/server/query/change/LabelPredicate.java
index 38d1dbe..989b4bb 100644
--- a/java/com/google/gerrit/server/query/change/LabelPredicate.java
+++ b/java/com/google/gerrit/server/query/change/LabelPredicate.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.query.change;
 
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Lists;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
@@ -59,12 +60,12 @@
   protected static class Parsed {
     protected final String label;
     protected final String test;
-    protected final int expVal;
+    protected final int numericValue;
 
-    protected Parsed(String label, String test, int expVal) {
+    protected Parsed(String label, String test, int numericValue) {
       this.label = label;
       this.test = test;
-      this.expVal = expVal;
+      this.numericValue = numericValue;
     }
   }
 
@@ -83,6 +84,14 @@
 
   protected static List<Predicate<ChangeData>> predicates(Args args) {
     String v = args.value;
+
+    try {
+      MagicLabelVote mlv = MagicLabelVote.parseWithEquals(v);
+      return ImmutableList.of(new MagicLabelPredicate(args, mlv));
+    } catch (IllegalArgumentException e) {
+      // Try next format.
+    }
+
     Parsed parsed = null;
 
     try {
@@ -108,7 +117,7 @@
     } else {
       range =
           RangeUtil.getRange(
-              parsed.label, parsed.test, parsed.expVal, -MAX_LABEL_VALUE, MAX_LABEL_VALUE);
+              parsed.label, parsed.test, parsed.numericValue, -MAX_LABEL_VALUE, MAX_LABEL_VALUE);
     }
     String prefix = range.prefix;
     int min = range.min;
diff --git a/java/com/google/gerrit/server/query/change/MagicLabelPredicate.java b/java/com/google/gerrit/server/query/change/MagicLabelPredicate.java
new file mode 100644
index 0000000..2c56322
--- /dev/null
+++ b/java/com/google/gerrit/server/query/change/MagicLabelPredicate.java
@@ -0,0 +1,101 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.query.change;
+
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.LabelTypes;
+import com.google.gerrit.entities.LabelValue;
+import com.google.gerrit.index.query.Predicate;
+import com.google.gerrit.server.index.change.ChangeField;
+import com.google.gerrit.server.project.ProjectState;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
+
+public class MagicLabelPredicate extends ChangeIndexPredicate {
+  protected final LabelPredicate.Args args;
+  private final MagicLabelVote magicLabelVote;
+
+  public MagicLabelPredicate(LabelPredicate.Args args, MagicLabelVote magicLabelVote) {
+    super(ChangeField.LABEL, magicLabelVote.formatLabel());
+    this.args = args;
+    this.magicLabelVote = magicLabelVote;
+  }
+
+  @Override
+  public boolean match(ChangeData changeData) {
+    Change change = changeData.change();
+    if (change == null) {
+      // The change has disappeared.
+      //
+      return false;
+    }
+
+    Optional<ProjectState> project = args.projectCache.get(change.getDest().project());
+    if (!project.isPresent()) {
+      // The project has disappeared.
+      //
+      return false;
+    }
+
+    LabelType labelType = type(project.get().getLabelTypes(), magicLabelVote.label());
+    if (labelType == null) {
+      return false; // Label is not defined by this project.
+    }
+
+    switch (magicLabelVote.value()) {
+      case ANY:
+        return matchAny(changeData, labelType);
+      case MIN:
+        return matchNumeric(changeData, magicLabelVote.label(), labelType.getMin().getValue());
+      case MAX:
+        return matchNumeric(changeData, magicLabelVote.label(), labelType.getMax().getValue());
+    }
+
+    throw new IllegalStateException("Unsupported magic label value: " + magicLabelVote.value());
+  }
+
+  private boolean matchAny(ChangeData changeData, LabelType labelType) {
+    List<Predicate<ChangeData>> predicates = new ArrayList<>();
+    for (LabelValue labelValue : labelType.getValues()) {
+      if (labelValue.getValue() != 0) {
+        predicates.add(numericPredicate(labelType.getName(), labelValue.getValue()));
+      }
+    }
+    return or(predicates).asMatchable().match(changeData);
+  }
+
+  private boolean matchNumeric(ChangeData changeData, String label, short value) {
+    return numericPredicate(label, value).match(changeData);
+  }
+
+  private EqualsLabelPredicate numericPredicate(String label, short value) {
+    return new EqualsLabelPredicate(args, label, value, /* account= */ null);
+  }
+
+  protected static LabelType type(LabelTypes types, String toFind) {
+    if (types.byLabel(toFind).isPresent()) {
+      return types.byLabel(toFind).get();
+    }
+
+    for (LabelType lt : types.getLabelTypes()) {
+      if (toFind.equalsIgnoreCase(lt.getName())) {
+        return lt;
+      }
+    }
+    return null;
+  }
+}
diff --git a/java/com/google/gerrit/server/query/change/MagicLabelValue.java b/java/com/google/gerrit/server/query/change/MagicLabelValue.java
new file mode 100644
index 0000000..c4bcbe3
--- /dev/null
+++ b/java/com/google/gerrit/server/query/change/MagicLabelValue.java
@@ -0,0 +1,31 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.query.change;
+
+import java.util.Optional;
+
+public enum MagicLabelValue {
+  ANY,
+  MIN,
+  MAX;
+
+  public static Optional<MagicLabelValue> tryParse(String value) {
+    try {
+      return Optional.of(MagicLabelValue.valueOf(value));
+    } catch (IllegalArgumentException e) {
+      return Optional.empty();
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/query/change/MagicLabelVote.java b/java/com/google/gerrit/server/query/change/MagicLabelVote.java
new file mode 100644
index 0000000..c29ac72
--- /dev/null
+++ b/java/com/google/gerrit/server/query/change/MagicLabelVote.java
@@ -0,0 +1,47 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.query.change;
+
+import static com.google.common.base.Preconditions.checkArgument;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.base.Strings;
+import com.google.gerrit.entities.LabelType;
+import java.util.Locale;
+
+/** An entity representing a special label vote that's not numeric, e.g. MAX, MIN, etc... */
+@AutoValue
+public abstract class MagicLabelVote {
+  public static MagicLabelVote parseWithEquals(String text) {
+    checkArgument(!Strings.isNullOrEmpty(text), "Empty label vote");
+    int e = text.lastIndexOf('=');
+    checkArgument(e >= 0, "Label vote missing '=': %s", text);
+    String label = text.substring(0, e);
+    String voteValue = text.substring(e + 1);
+    return create(label, MagicLabelValue.valueOf(voteValue));
+  }
+
+  public static MagicLabelVote create(String label, MagicLabelValue value) {
+    return new AutoValue_MagicLabelVote(LabelType.checkNameInternal(label), value);
+  }
+
+  public abstract String label();
+
+  public abstract MagicLabelValue value();
+
+  public String formatLabel() {
+    return label().toLowerCase(Locale.US) + "=" + value().name();
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/Abandon.java b/java/com/google/gerrit/server/restapi/change/Abandon.java
index 826c89d..2cfc3f5 100644
--- a/java/com/google/gerrit/server/restapi/change/Abandon.java
+++ b/java/com/google/gerrit/server/restapi/change/Abandon.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.server.restapi.change;
 
-import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.extensions.api.changes.AbandonInput;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
@@ -44,8 +43,6 @@
 @Singleton
 public class Abandon
     implements RestModifyView<ChangeResource, AbandonInput>, UiAction<ChangeResource> {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
   private final BatchUpdate.Factory updateFactory;
   private final ChangeJson.Factory json;
   private final AbandonOp.Factory abandonOpFactory;
diff --git a/java/com/google/gerrit/server/restapi/change/DeleteVote.java b/java/com/google/gerrit/server/restapi/change/DeleteVote.java
index 35cadb7..2c358d0 100644
--- a/java/com/google/gerrit/server/restapi/change/DeleteVote.java
+++ b/java/com/google/gerrit/server/restapi/change/DeleteVote.java
@@ -89,7 +89,6 @@
       ApprovalsUtil approvalsUtil,
       PatchSetUtil psUtil,
       ChangeMessagesUtil cmUtil,
-      IdentifiedUser.GenericFactory userFactory,
       VoteDeleted voteDeleted,
       DeleteVoteSender.Factory deleteVoteSenderFactory,
       NotifyResolver notifyResolver,
@@ -195,7 +194,7 @@
       for (PatchSetApproval a :
           approvalsUtil.byPatchSetUser(
               ctx.getNotes(), psId, accountId, ctx.getRevWalk(), ctx.getRepoView().getConfig())) {
-        if (labelTypes.byLabel(a.labelId()) == null) {
+        if (!labelTypes.byLabel(a.labelId()).isPresent()) {
           continue; // Ignore undefined labels.
         } else if (!a.label().equals(label)) {
           // Populate map for non-matching labels, needed by VoteDeleted.
diff --git a/java/com/google/gerrit/server/restapi/change/Files.java b/java/com/google/gerrit/server/restapi/change/Files.java
index 1efe378..320e57d 100644
--- a/java/com/google/gerrit/server/restapi/change/Files.java
+++ b/java/com/google/gerrit/server/restapi/change/Files.java
@@ -183,7 +183,7 @@
         r =
             Response.ok(
                 fileInfoJson.getFileInfoMap(
-                    resource.getChange(), resource.getPatchSet().commitId(), parentNum - 1));
+                    resource.getChange(), resource.getPatchSet().commitId(), parentNum));
       } else {
         r = Response.ok(fileInfoJson.getFileInfoMap(resource.getChange(), resource.getPatchSet()));
       }
@@ -280,11 +280,11 @@
 
         Map<String, FileDiffOutput> oldList =
             diffOperations.listModifiedFilesAgainstParent(
-                project, patchSet.commitId(), /* parentNum= */ null);
+                project, patchSet.commitId(), /* parentNum= */ 0);
 
         Map<String, FileDiffOutput> curList =
             diffOperations.listModifiedFilesAgainstParent(
-                project, resource.getPatchSet().commitId(), /* parentNum= */ null);
+                project, resource.getPatchSet().commitId(), /* parentNum= */ 0);
 
         int sz = paths.size();
         List<String> pathList = Lists.newArrayListWithCapacity(sz);
diff --git a/java/com/google/gerrit/server/restapi/change/GetDiff.java b/java/com/google/gerrit/server/restapi/change/GetDiff.java
index 2169d57..dd951a8 100644
--- a/java/com/google/gerrit/server/restapi/change/GetDiff.java
+++ b/java/com/google/gerrit/server/restapi/change/GetDiff.java
@@ -74,6 +74,7 @@
   @Option(name = "--base", metaVar = "REVISION")
   String base;
 
+  /** 1-based index of the parent's position in the commit object. */
   @Option(name = "--parent", metaVar = "parent-number")
   int parentNum;
 
@@ -143,7 +144,7 @@
     } else if (parentNum > 0) {
       psf =
           patchScriptFactoryFactory.create(
-              notes, fileName, parentNum - 1, pId, prefs, currentUser.get());
+              notes, fileName, parentNum, pId, prefs, currentUser.get());
     } else {
       psf = patchScriptFactoryFactory.create(notes, fileName, null, pId, prefs, currentUser.get());
     }
diff --git a/java/com/google/gerrit/server/restapi/change/Move.java b/java/com/google/gerrit/server/restapi/change/Move.java
index 50b7516..8c21841 100644
--- a/java/com/google/gerrit/server/restapi/change/Move.java
+++ b/java/com/google/gerrit/server/restapi/change/Move.java
@@ -21,7 +21,6 @@
 import static com.google.gerrit.server.query.change.ChangeData.asChanges;
 
 import com.google.common.base.Strings;
-import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
@@ -65,6 +64,7 @@
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
+import java.util.Optional;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Repository;
@@ -73,8 +73,6 @@
 
 @Singleton
 public class Move implements RestModifyView<ChangeResource, MoveInput>, UiAction<ChangeResource> {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
   private final PermissionBackend permissionBackend;
   private final BatchUpdate.Factory updateFactory;
   private final ChangeJson.Factory json;
@@ -268,11 +266,13 @@
           approvalsUtil.byPatchSet(
               ctx.getNotes(), psId, ctx.getRevWalk(), ctx.getRepoView().getConfig())) {
         ProjectState projectState = projectCache.get(project).orElseThrow(illegalState(project));
-        LabelType type = projectState.getLabelTypes(ctx.getNotes()).byLabel(psa.labelId());
+        Optional<LabelType> type =
+            projectState.getLabelTypes(ctx.getNotes()).byLabel(psa.labelId());
         // Only keep veto votes, defined as votes where:
         // 1- the label function allows minimum values to block submission.
         // 2- the vote holds the minimum value.
-        if (type == null || (type.isMaxNegative(psa) && type.getFunction().isBlock())) {
+        if (!type.isPresent()
+            || (type.get().isMaxNegative(psa) && type.get().getFunction().isBlock())) {
           continue;
         }
 
diff --git a/java/com/google/gerrit/server/restapi/change/PostReview.java b/java/com/google/gerrit/server/restapi/change/PostReview.java
index 6816361..4dbb6ee 100644
--- a/java/com/google/gerrit/server/restapi/change/PostReview.java
+++ b/java/com/google/gerrit/server/restapi/change/PostReview.java
@@ -22,7 +22,6 @@
 import static com.google.gerrit.server.permissions.LabelPermission.ForUser.ON_BEHALF_OF;
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
 import static java.nio.charset.StandardCharsets.UTF_8;
-import static java.util.Objects.requireNonNull;
 import static java.util.stream.Collectors.groupingBy;
 import static java.util.stream.Collectors.joining;
 import static java.util.stream.Collectors.toList;
@@ -502,8 +501,8 @@
     Iterator<Map.Entry<String, Short>> itr = in.labels.entrySet().iterator();
     while (itr.hasNext()) {
       Map.Entry<String, Short> ent = itr.next();
-      LabelType type = labelTypes.byLabel(ent.getKey());
-      if (type == null) {
+      Optional<LabelType> type = labelTypes.byLabel(ent.getKey());
+      if (!type.isPresent()) {
         logger.atFine().log("label %s not found", ent.getKey());
         if (strictLabels) {
           throw new BadRequestException(
@@ -518,15 +517,15 @@
         logger.atFine().log(
             "skipping on behalf of permission check for label %s"
                 + " because caller is an internal user",
-            type.getName());
+            type.get().getName());
       } else {
         try {
-          perm.check(new LabelPermission.WithValue(ON_BEHALF_OF, type, ent.getValue()));
+          perm.check(new LabelPermission.WithValue(ON_BEHALF_OF, type.get(), ent.getValue()));
         } catch (AuthException e) {
           throw new AuthException(
               String.format(
                   "not permitted to modify label \"%s\" on behalf of \"%s\"",
-                  type.getName(), in.onBehalfOf),
+                  type.get().getName(), in.onBehalfOf),
               e);
         }
       }
@@ -558,8 +557,8 @@
     Iterator<Map.Entry<String, Short>> itr = labels.entrySet().iterator();
     while (itr.hasNext()) {
       Map.Entry<String, Short> ent = itr.next();
-      LabelType lt = labelTypes.byLabel(ent.getKey());
-      if (lt == null) {
+      Optional<LabelType> lt = labelTypes.byLabel(ent.getKey());
+      if (!lt.isPresent()) {
         logger.atFine().log("label %s not found", ent.getKey());
         if (strictLabels) {
           throw new BadRequestException(
@@ -576,7 +575,7 @@
         continue;
       }
 
-      if (lt.getValue(ent.getValue()) == null) {
+      if (lt.get().getValue(ent.getValue()) == null) {
         logger.atFine().log("label value %s not found", ent.getValue());
         if (strictLabels) {
           throw new BadRequestException(
@@ -590,10 +589,10 @@
 
       short val = ent.getValue();
       try {
-        perm.check(new LabelPermission.WithValue(lt, val));
+        perm.check(new LabelPermission.WithValue(lt.get(), val));
       } catch (AuthException e) {
         throw new AuthException(
-            String.format("Applying label \"%s\": %d is restricted", lt.getName(), val), e);
+            String.format("Applying label \"%s\": %d is restricted", lt.get().getName(), val), e);
       }
     }
   }
@@ -1356,7 +1355,10 @@
       ChangeUpdate update = ctx.getUpdate(psId);
       for (Map.Entry<String, Short> ent : allApprovals.entrySet()) {
         String name = ent.getKey();
-        LabelType lt = requireNonNull(labelTypes.byLabel(name), name);
+        LabelType lt =
+            labelTypes
+                .byLabel(name)
+                .orElseThrow(() -> new IllegalStateException("no label config for " + name));
 
         PatchSetApproval c = current.remove(lt.getName());
         String normName = lt.getName();
@@ -1448,7 +1450,10 @@
       List<String> disallowed = new ArrayList<>(labelTypes.getLabelTypes().size());
 
       for (PatchSetApproval psa : del) {
-        LabelType lt = requireNonNull(labelTypes.byLabel(psa.label()));
+        LabelType lt =
+            labelTypes
+                .byLabel(psa.label())
+                .orElseThrow(() -> new IllegalStateException("no label config for " + psa.label()));
         String normName = lt.getName();
         if (!lt.isAllowPostSubmit()) {
           disallowed.add(normName);
@@ -1460,7 +1465,10 @@
       }
 
       for (PatchSetApproval psa : ups) {
-        LabelType lt = requireNonNull(labelTypes.byLabel(psa.label()));
+        LabelType lt =
+            labelTypes
+                .byLabel(psa.label())
+                .orElseThrow(() -> new IllegalStateException("no label config for " + psa.label()));
         String normName = lt.getName();
         if (!lt.isAllowPostSubmit()) {
           disallowed.add(normName);
@@ -1508,9 +1516,9 @@
           continue;
         }
 
-        LabelType lt = labelTypes.byLabel(a.labelId());
-        if (lt != null) {
-          current.put(lt.getName(), a);
+        Optional<LabelType> lt = labelTypes.byLabel(a.labelId());
+        if (lt.isPresent()) {
+          current.put(lt.get().getName(), a);
         } else {
           del.add(a);
         }
diff --git a/java/com/google/gerrit/server/restapi/change/Rebase.java b/java/com/google/gerrit/server/restapi/change/Rebase.java
index 3e1f033..2077fb8 100644
--- a/java/com/google/gerrit/server/restapi/change/Rebase.java
+++ b/java/com/google/gerrit/server/restapi/change/Rebase.java
@@ -18,7 +18,6 @@
 
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Sets;
-import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
@@ -65,8 +64,6 @@
 @Singleton
 public class Rebase
     implements RestModifyView<RevisionResource, RebaseInput>, UiAction<RevisionResource> {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
   private static final ImmutableSet<ListChangesOption> OPTIONS =
       Sets.immutableEnumSet(ListChangesOption.CURRENT_REVISION, ListChangesOption.CURRENT_COMMIT);
 
diff --git a/java/com/google/gerrit/server/restapi/change/ReplyAttentionSetUpdates.java b/java/com/google/gerrit/server/restapi/change/ReplyAttentionSetUpdates.java
index 0356cdd..53d0f18 100644
--- a/java/com/google/gerrit/server/restapi/change/ReplyAttentionSetUpdates.java
+++ b/java/com/google/gerrit/server/restapi/change/ReplyAttentionSetUpdates.java
@@ -19,7 +19,9 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Sets;
 import com.google.common.collect.Sets.SetView;
+import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.AttentionSetUpdate;
 import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.extensions.api.changes.AttentionSetInput;
@@ -60,6 +62,7 @@
  * This class is used to update the attention set when performing a review or replying on a change.
  */
 public class ReplyAttentionSetUpdates {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   private final PermissionBackend permissionBackend;
   private final AddToAttentionSetOp.Factory addToAttentionSetOpFactory;
@@ -316,11 +319,15 @@
     AttentionSetUtil.validateInput(add);
     try {
       Account.Id attentionUserId =
-          getAccountIdAndValidateUser(changeNotes, add.user, accountsChangedInCommit);
+          getAccountIdAndValidateUser(
+              changeNotes, add.user, accountsChangedInCommit, AttentionSetUpdate.Operation.ADD);
       addToAttentionSet(bu, changeNotes, attentionUserId, add.reason, false);
     } catch (AccountResolver.UnresolvableAccountException ex) {
       // This happens only when the account doesn't exist. Silently ignore it. If we threw an error
       // message here, then it would be possible to probe whether an account exists.
+    } catch (AuthException ex) {
+      // adding users without permission to the attention set should fail silently.
+      logger.atFine().log(ex.getMessage());
     }
   }
 
@@ -334,17 +341,25 @@
     AttentionSetUtil.validateInput(remove);
     try {
       Account.Id attentionUserId =
-          getAccountIdAndValidateUser(changeNotes, remove.user, accountsChangedInCommit);
+          getAccountIdAndValidateUser(
+              changeNotes,
+              remove.user,
+              accountsChangedInCommit,
+              AttentionSetUpdate.Operation.REMOVE);
       removeFromAttentionSet(bu, changeNotes, attentionUserId, remove.reason, false);
     } catch (AccountResolver.UnresolvableAccountException ex) {
       // This happens only when the account doesn't exist. Silently ignore it. If we threw an error
       // message here, then it would be possible to probe whether an account exists.
+    } catch (AuthException ex) {
+      // this should never happen since removing users with permissions should work.
+      logger.atSevere().log(ex.getMessage());
     }
   }
 
-  private Account.Id getAccountId(ChangeNotes changeNotes, String user)
+  private Account.Id getAccountId(
+      ChangeNotes changeNotes, String user, AttentionSetUpdate.Operation operation)
       throws ConfigInvalidException, IOException, UnprocessableEntityException,
-          PermissionBackendException {
+          PermissionBackendException, AuthException {
     Account.Id attentionUserId = accountResolver.resolve(user).asUnique().account().id();
     try {
       permissionBackend
@@ -352,22 +367,29 @@
           .change(changeNotes)
           .check(ChangePermission.READ);
     } catch (AuthException e) {
+      // If the change is private, it is okay to add the user to the attention set since that
+      // person will be granted visibility when a reviewer.
       if (!changeNotes.getChange().isPrivate()) {
-        // If the change is private, it is okay to add the user to the attention set since that
-        // person will be granted visibility when a reviewer.
-        throw new UnprocessableEntityException(
-            "Can't add to attention set: Read not permitted for " + attentionUserId, e);
+
+        // Removing users without access is allowed, adding is not allowed
+        if (operation == AttentionSetUpdate.Operation.ADD) {
+          throw new AuthException(
+              "Can't modify attention set: Read not permitted for " + attentionUserId, e);
+        }
       }
     }
     return attentionUserId;
   }
 
   private Account.Id getAccountIdAndValidateUser(
-      ChangeNotes changeNotes, String user, Set<Account.Id> accountsChangedInCommit)
+      ChangeNotes changeNotes,
+      String user,
+      Set<Account.Id> accountsChangedInCommit,
+      AttentionSetUpdate.Operation operation)
       throws ConfigInvalidException, IOException, PermissionBackendException,
-          UnprocessableEntityException, BadRequestException {
+          UnprocessableEntityException, BadRequestException, AuthException {
     try {
-      Account.Id attentionUserId = getAccountId(changeNotes, user);
+      Account.Id attentionUserId = getAccountId(changeNotes, user, operation);
       if (accountsChangedInCommit.contains(attentionUserId)) {
         throw new BadRequestException(
             String.format(
diff --git a/java/com/google/gerrit/server/restapi/change/Revert.java b/java/com/google/gerrit/server/restapi/change/Revert.java
index 7bb43d2..8d48c88 100644
--- a/java/com/google/gerrit/server/restapi/change/Revert.java
+++ b/java/com/google/gerrit/server/restapi/change/Revert.java
@@ -19,7 +19,6 @@
 import static com.google.gerrit.server.permissions.RefPermission.CREATE_CHANGE;
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
 
-import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.extensions.api.changes.RevertInput;
@@ -54,8 +53,6 @@
 @Singleton
 public class Revert
     implements RestModifyView<ChangeResource, RevertInput>, UiAction<ChangeResource> {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
   private final PermissionBackend permissionBackend;
   private final PatchSetUtil psUtil;
   private final ChangeJson.Factory json;
diff --git a/java/com/google/gerrit/server/restapi/project/FilesInCommitCollection.java b/java/com/google/gerrit/server/restapi/project/FilesInCommitCollection.java
index 7bee2f2..6d054bd 100644
--- a/java/com/google/gerrit/server/restapi/project/FilesInCommitCollection.java
+++ b/java/com/google/gerrit/server/restapi/project/FilesInCommitCollection.java
@@ -77,6 +77,10 @@
   }
 
   public static final class ListFiles implements RestReadView<CommitResource> {
+    /**
+     * The 1-based parent number. If zero, the default base commit will be used, which is the only
+     * parent for commits having one parent or the auto-merge commit otherwise.
+     */
     @Option(name = "--parent", metaVar = "parent-number")
     int parentNum;
 
@@ -97,8 +101,7 @@
         throws ResourceConflictException, PatchListNotAvailableException {
       RevCommit commit = resource.getCommit();
       return Response.ok(
-          fileInfoJson.getFileInfoMap(
-              resource.getProjectState().getNameKey(), commit, parentNum - 1));
+          fileInfoJson.getFileInfoMap(resource.getProjectState().getNameKey(), commit, parentNum));
     }
   }
 }
diff --git a/java/com/google/gerrit/server/rules/DefaultSubmitRule.java b/java/com/google/gerrit/server/rules/DefaultSubmitRule.java
index f5709e4..fe429dd 100644
--- a/java/com/google/gerrit/server/rules/DefaultSubmitRule.java
+++ b/java/com/google/gerrit/server/rules/DefaultSubmitRule.java
@@ -17,16 +17,13 @@
 import static com.google.common.base.Preconditions.checkState;
 import static com.google.common.collect.ImmutableList.toImmutableList;
 
-import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.LabelFunction;
 import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.entities.SubmitRecord;
 import com.google.gerrit.extensions.annotations.Exports;
 import com.google.gerrit.extensions.config.FactoryModule;
-import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.query.change.ChangeData;
-import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.util.ArrayList;
 import java.util.Collection;
@@ -42,8 +39,6 @@
  */
 @Singleton
 public final class DefaultSubmitRule implements SubmitRule {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
   public static class Module extends FactoryModule {
     @Override
     public void configure() {
@@ -53,13 +48,6 @@
     }
   }
 
-  private final ProjectCache projectCache;
-
-  @Inject
-  DefaultSubmitRule(ProjectCache projectCache) {
-    this.projectCache = projectCache;
-  }
-
   @Override
   public Optional<SubmitRecord> evaluate(ChangeData cd) {
     SubmitRecord submitRecord = new SubmitRecord();
diff --git a/java/com/google/gerrit/server/rules/PrologRuleEvaluator.java b/java/com/google/gerrit/server/rules/PrologRuleEvaluator.java
index 3dc5be0..179a3d0 100644
--- a/java/com/google/gerrit/server/rules/PrologRuleEvaluator.java
+++ b/java/com/google/gerrit/server/rules/PrologRuleEvaluator.java
@@ -158,6 +158,8 @@
       return ruleError("Error looking up change " + cd.getId(), e);
     }
 
+    logger.atFine().log("input approvals: %s", cd.approvals());
+
     List<Term> results;
     try {
       results =
@@ -178,7 +180,9 @@
               getSubmitRuleName(), cd.getId(), projectState.getName()));
     }
 
-    return resultsToSubmitRecord(getSubmitRule(), results);
+    SubmitRecord submitRecord = resultsToSubmitRecord(getSubmitRule(), results);
+    logger.atFine().log("submit record: %s", submitRecord);
+    return submitRecord;
   }
 
   private String getSubmitRuleName() {
@@ -320,6 +324,7 @@
       logger.atSevere().withCause(e).log(err);
       return createRuleError(DEFAULT_MSG);
     }
+    logger.atFine().log("rule error: %s", err);
     return createRuleError(err);
   }
 
diff --git a/java/com/google/gerrit/server/submit/MergeOp.java b/java/com/google/gerrit/server/submit/MergeOp.java
index 2b4fb3b..363cdca 100644
--- a/java/com/google/gerrit/server/submit/MergeOp.java
+++ b/java/com/google/gerrit/server/submit/MergeOp.java
@@ -67,6 +67,7 @@
 import com.google.gerrit.server.logging.RequestId;
 import com.google.gerrit.server.logging.TraceContext;
 import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.notedb.StoreSubmitRequirementsOp;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.NoSuchProjectException;
 import com.google.gerrit.server.project.SubmitRuleOptions;
@@ -96,6 +97,8 @@
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
+import java.util.function.Function;
+import java.util.stream.Collectors;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.errors.IncorrectObjectTypeException;
 import org.eclipse.jgit.lib.Constants;
@@ -654,6 +657,16 @@
               toSubmit, updateOrderCalculator, submoduleCommits, subscriptionGraph, dryrun);
       this.allProjects = updateOrderCalculator.getProjectsInOrder();
       List<BatchUpdate> batchUpdates = orm.batchUpdates(allProjects);
+      // Group batch updates by project
+      Map<Project.NameKey, BatchUpdate> batchUpdatesByProject =
+          batchUpdates.stream().collect(Collectors.toMap(b -> b.getProject(), Function.identity()));
+      for (Map.Entry<Change.Id, ChangeData> entry : cs.changesById().entrySet()) {
+        Project.NameKey project = entry.getValue().project();
+        Change.Id changeId = entry.getKey();
+        batchUpdatesByProject
+            .get(project)
+            .addOp(changeId, new StoreSubmitRequirementsOp(changeDataFactory));
+      }
       try {
         submissionExecutor.setAdditionalBatchUpdateListeners(
             ImmutableList.of(new SubmitStrategyListener(submitInput, strategies, commitStatus)));
diff --git a/java/com/google/gerrit/server/submit/SubscriptionGraph.java b/java/com/google/gerrit/server/submit/SubscriptionGraph.java
index ad16cb0..dfbbf81 100644
--- a/java/com/google/gerrit/server/submit/SubscriptionGraph.java
+++ b/java/com/google/gerrit/server/submit/SubscriptionGraph.java
@@ -231,7 +231,6 @@
         Map<BranchNameKey, GitModules> branchGitModules,
         MergeOpRepoManager orm)
         throws SubmoduleConflictException {
-      logger.atFine().log("Calculating superprojects - submodules map");
       LinkedHashSet<BranchNameKey> allVisited = new LinkedHashSet<>();
       for (BranchNameKey updatedBranch : updatedBranches) {
         if (allVisited.contains(updatedBranch)) {
@@ -332,15 +331,15 @@
         Map<BranchNameKey, GitModules> branchGitModules,
         MergeOpRepoManager orm)
         throws IOException {
-      logger.atFine().log("Calculating possible superprojects for %s", srcBranch);
       Collection<SubmoduleSubscription> ret = new ArrayList<>();
+      if (RefNames.isGerritRef(srcBranch.branch())) return ret;
+
       Project.NameKey srcProject = srcBranch.project();
       for (SubscribeSection s :
           projectCache
               .get(srcProject)
               .orElseThrow(illegalState(srcProject))
               .getSubscribeSections(srcBranch)) {
-        logger.atFine().log("Checking subscribe section %s", s);
         Collection<BranchNameKey> branches = getDestinationBranches(srcBranch, s, orm);
         for (BranchNameKey targetBranch : branches) {
           Project.NameKey targetProject = targetBranch.project();
@@ -348,11 +347,11 @@
             OpenRepo or = orm.getRepo(targetProject);
             ObjectId id = or.repo.resolve(targetBranch.branch());
             if (id == null) {
-              logger.atFine().log("The branch %s doesn't exist.", targetBranch);
+              logger.atFine().log("SubscribeSection %s: branch %s doesn't exist.", s, targetBranch);
               continue;
             }
           } catch (NoSuchProjectException e) {
-            logger.atFine().log("The project %s doesn't exist", targetProject);
+            logger.atFine().log("SubscribeSection %s: project %s doesn't exist", s, targetProject);
             continue;
           }
 
diff --git a/java/com/google/gerrit/server/update/BatchUpdate.java b/java/com/google/gerrit/server/update/BatchUpdate.java
index 3b0cd9a..f558d30 100644
--- a/java/com/google/gerrit/server/update/BatchUpdate.java
+++ b/java/com/google/gerrit/server/update/BatchUpdate.java
@@ -75,6 +75,7 @@
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.Map.Entry;
 import java.util.Objects;
 import java.util.Optional;
 import java.util.TimeZone;
@@ -553,11 +554,15 @@
     try {
       logDebug("Executing updateRepo on %d ops", ops.size());
       RepoContextImpl ctx = new RepoContextImpl();
-      for (BatchUpdateOp op : ops.values()) {
+      for (Entry<Change.Id, BatchUpdateOp> op : ops.entries()) {
         try (TraceContext.TraceTimer ignored =
             TraceContext.newTimer(
-                op.getClass().getSimpleName() + "#updateRepo", Metadata.empty())) {
-          op.updateRepo(ctx);
+                op.getClass().getSimpleName() + "#updateRepo",
+                Metadata.builder()
+                    .projectName(project.get())
+                    .changeId(op.getKey().get())
+                    .build())) {
+          op.getValue().updateRepo(ctx);
         }
       }
 
@@ -672,7 +677,8 @@
       for (BatchUpdateOp op : e.getValue()) {
         try (TraceContext.TraceTimer ignored =
             TraceContext.newTimer(
-                op.getClass().getSimpleName() + "#updateChange", Metadata.empty())) {
+                op.getClass().getSimpleName() + "#updateChange",
+                Metadata.builder().projectName(project.get()).changeId(id.get()).build())) {
           dirty |= op.updateChange(ctx);
         }
       }
diff --git a/java/com/google/gerrit/sshd/SshCommand.java b/java/com/google/gerrit/sshd/SshCommand.java
index c94b25c..93c6c2c 100644
--- a/java/com/google/gerrit/sshd/SshCommand.java
+++ b/java/com/google/gerrit/sshd/SshCommand.java
@@ -19,6 +19,7 @@
 import com.google.gerrit.server.DynamicOptions;
 import com.google.gerrit.server.RequestInfo;
 import com.google.gerrit.server.RequestListener;
+import com.google.gerrit.server.cancellation.RequestCancelledException;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.logging.PerformanceLogContext;
 import com.google.gerrit.server.logging.PerformanceLogger;
@@ -61,6 +62,12 @@
                   RequestInfo.builder(RequestInfo.RequestType.SSH, user, traceContext).build();
               requestListeners.runEach(l -> l.onRequest(requestInfo));
               SshCommand.this.run();
+            } catch (RequestCancelledException e) {
+              StringBuilder msg = new StringBuilder(e.formatCancellationReason());
+              if (e.getCancellationMessage().isPresent()) {
+                msg.append(String.format(" (%s)", e.getCancellationMessage().get()));
+              }
+              stderr.println(msg.toString());
             } finally {
               stdout.flush();
               stderr.flush();
diff --git a/java/com/google/gerrit/testing/InMemoryModule.java b/java/com/google/gerrit/testing/InMemoryModule.java
index f28f60f..06d9453 100644
--- a/java/com/google/gerrit/testing/InMemoryModule.java
+++ b/java/com/google/gerrit/testing/InMemoryModule.java
@@ -54,6 +54,7 @@
 import com.google.gerrit.server.config.CanonicalWebUrlModule;
 import com.google.gerrit.server.config.CanonicalWebUrlProvider;
 import com.google.gerrit.server.config.DefaultUrlFormatter;
+import com.google.gerrit.server.config.FileBasedGlobalPluginConfigProvider;
 import com.google.gerrit.server.config.GerritGlobalModule;
 import com.google.gerrit.server.config.GerritInstanceIdModule;
 import com.google.gerrit.server.config.GerritInstanceNameModule;
@@ -62,6 +63,7 @@
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.GerritServerId;
 import com.google.gerrit.server.config.GerritServerIdProvider;
+import com.google.gerrit.server.config.GlobalPluginConfigProvider;
 import com.google.gerrit.server.config.SendEmailExecutor;
 import com.google.gerrit.server.config.SitePath;
 import com.google.gerrit.server.config.TrackingFooters;
@@ -195,6 +197,7 @@
     bind(Path.class).annotatedWith(SitePath.class).toInstance(Paths.get("."));
     bind(Config.class).annotatedWith(GerritServerConfig.class).toInstance(cfg);
     bind(GerritOptions.class).toInstance(new GerritOptions(false, false));
+    bind(GlobalPluginConfigProvider.class).to(FileBasedGlobalPluginConfigProvider.class);
 
     bind(GitRepositoryManager.class).to(InMemoryRepositoryManager.class);
     bind(InMemoryRepositoryManager.class).in(SINGLETON);
diff --git a/java/com/google/gerrit/truth/NullAwareCorrespondence.java b/java/com/google/gerrit/truth/NullAwareCorrespondence.java
index 687ad94..5b107a6 100644
--- a/java/com/google/gerrit/truth/NullAwareCorrespondence.java
+++ b/java/com/google/gerrit/truth/NullAwareCorrespondence.java
@@ -7,15 +7,6 @@
 // http://www.apache.org/licenses/LICENSE-2.0
 //
 // Unless required by applicable law or agreed to in writing, software
-// 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
diff --git a/java/gerrit/PRED__load_commit_labels_1.java b/java/gerrit/PRED__load_commit_labels_1.java
index 5ee292ff..9a656b8 100644
--- a/java/gerrit/PRED__load_commit_labels_1.java
+++ b/java/gerrit/PRED__load_commit_labels_1.java
@@ -16,6 +16,7 @@
 import com.googlecode.prolog_cafe.lang.StructureTerm;
 import com.googlecode.prolog_cafe.lang.SymbolTerm;
 import com.googlecode.prolog_cafe.lang.Term;
+import java.util.Optional;
 
 /** Exports list of {@code commit_label( label('Code-Review', 2), user(12345789) )}. */
 class PRED__load_commit_labels_1 extends Predicate.P1 {
@@ -38,13 +39,14 @@
     LabelTypes types = cd.getLabelTypes();
 
     for (PatchSetApproval a : cd.currentApprovals()) {
-      LabelType t = types.byLabel(a.labelId());
-      if (t == null) {
+      Optional<LabelType> t = types.byLabel(a.labelId());
+      if (!t.isPresent()) {
         continue;
       }
 
       StructureTerm labelTerm =
-          new StructureTerm(sym_label, SymbolTerm.intern(t.getName()), new IntegerTerm(a.value()));
+          new StructureTerm(
+              sym_label, SymbolTerm.intern(t.get().getName()), new IntegerTerm(a.value()));
 
       StructureTerm userTerm = new StructureTerm(sym_user, new IntegerTerm(a.accountId().get()));
 
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
index 1d50e82..d54574a 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
@@ -47,8 +47,8 @@
 import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
 import static org.mockito.Mockito.verifyNoMoreInteractions;
-import static org.mockito.Mockito.verifyZeroInteractions;
 
 import com.github.rholder.retry.StopStrategies;
 import com.google.common.collect.FluentIterable;
@@ -686,7 +686,7 @@
               () -> gApi.accounts().id(activatableAccountId.get()).setActive(false));
       assertThat(thrown).hasMessageThat().isEqualTo("account not active");
       assertThat(accountOperations.account(activatableAccountId).get().active()).isFalse();
-      verifyZeroInteractions(listener);
+      verifyNoInteractions(listener);
 
       // Activate account that can be activated
       gApi.accounts().id(activatableAccountId.get()).setActive(true);
@@ -697,7 +697,7 @@
       // Activate account that is already active
       gApi.accounts().id(activatableAccountId.get()).setActive(true);
       assertThat(accountOperations.account(activatableAccountId).get().active()).isTrue();
-      verifyZeroInteractions(listener);
+      verifyNoMoreInteractions(listener);
 
       // Try deactivating account that cannot be deactivated
       thrown =
@@ -706,13 +706,13 @@
               () -> gApi.accounts().id(activatableAccountId.get()).setActive(false));
       assertThat(thrown).hasMessageThat().isEqualTo("not allowed to deactive account");
       assertThat(accountOperations.account(activatableAccountId).get().active()).isTrue();
-      verifyZeroInteractions(listener);
+      verifyNoMoreInteractions(listener);
 
       /* Test account that can be deactivated, but not activated */
       // Activate account that is already inactive
       gApi.accounts().id(deactivatableAccountId.get()).setActive(true);
       assertThat(accountOperations.account(deactivatableAccountId).get().active()).isTrue();
-      verifyZeroInteractions(listener);
+      verifyNoMoreInteractions(listener);
 
       // Deactivate account that can be deactivated
       gApi.accounts().id(deactivatableAccountId.get()).setActive(false);
@@ -727,7 +727,7 @@
               () -> gApi.accounts().id(deactivatableAccountId.get()).setActive(false));
       assertThat(thrown).hasMessageThat().isEqualTo("account not active");
       assertThat(accountOperations.account(deactivatableAccountId).get().active()).isFalse();
-      verifyZeroInteractions(listener);
+      verifyNoMoreInteractions(listener);
 
       // Try activating account that cannot be activated
       thrown =
@@ -736,7 +736,7 @@
               () -> gApi.accounts().id(deactivatableAccountId.get()).setActive(true));
       assertThat(thrown).hasMessageThat().isEqualTo("not allowed to active account");
       assertThat(accountOperations.account(deactivatableAccountId).get().active()).isFalse();
-      verifyZeroInteractions(listener);
+      verifyNoMoreInteractions(listener);
     }
   }
 
@@ -2974,6 +2974,46 @@
     assertThat(e).hasMessageThat().contains("foo:bar");
   }
 
+  @Test
+  public void externalIdBatchUpdates_commitMsg_multipleAccounts() throws Exception {
+    ExternalId extId1 =
+        ExternalId.createWithEmail(ExternalId.Key.parse("foo:bar"), admin.id(), "1@foo.com");
+    ExternalId extId2 =
+        ExternalId.createWithEmail(ExternalId.Key.parse("foo:baz"), user.id(), "2@foo.com");
+
+    AccountsUpdate.UpdateArguments ua1 =
+        new AccountsUpdate.UpdateArguments(
+            "first message", admin.id(), (a, u) -> u.addExternalId(extId1));
+    AccountsUpdate.UpdateArguments ua2 =
+        new AccountsUpdate.UpdateArguments(
+            "second message", user.id(), (a, u) -> u.addExternalId(extId2));
+    accountsUpdateProvider.get().updateBatch(ImmutableList.of(ua1, ua2));
+
+    try (Repository allUsersRepo = repoManager.openRepository(allUsers);
+        RevWalk rw = new RevWalk(allUsersRepo)) {
+      RevCommit commit =
+          rw.parseCommit(allUsersRepo.exactRef(RefNames.REFS_EXTERNAL_IDS).getObjectId());
+
+      assertThat(commit.getFullMessage()).isEqualTo("Batch update for 2 accounts\n");
+    }
+  }
+
+  @Test
+  public void externalIdBatchUpdates_commitMsg_singleAccount() throws Exception {
+    ExternalId extId =
+        ExternalId.createWithEmail(ExternalId.Key.parse("foo:bar"), admin.id(), "1@foo.com");
+
+    accountsUpdateProvider.get().update("foobar", admin.id(), (a, u) -> u.addExternalId(extId));
+
+    try (Repository allUsersRepo = repoManager.openRepository(allUsers);
+        RevWalk rw = new RevWalk(allUsersRepo)) {
+      RevCommit commit =
+          rw.parseCommit(allUsersRepo.exactRef(RefNames.REFS_EXTERNAL_IDS).getObjectId());
+
+      assertThat(commit.getFullMessage()).isEqualTo("foobar\n");
+    }
+  }
+
   private void createDraft(PushOneCommit.Result r, String path, String message) throws Exception {
     DraftInput in = new DraftInput();
     in.path = path;
diff --git a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
index 42354ca..c08aa7f 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
@@ -69,6 +69,7 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Lists;
+import com.google.common.collect.MoreCollectors;
 import com.google.common.truth.ThrowableSubject;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.ChangeIndexedCounter;
@@ -104,6 +105,8 @@
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.entities.SubmitRequirement;
 import com.google.gerrit.entities.SubmitRequirementExpression;
+import com.google.gerrit.entities.SubmitRequirementExpressionResult;
+import com.google.gerrit.entities.SubmitRequirementResult;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.annotations.Exports;
 import com.google.gerrit.extensions.api.accounts.DeleteDraftCommentsInput;
@@ -172,6 +175,7 @@
 import com.google.gerrit.server.index.change.ChangeIndex;
 import com.google.gerrit.server.index.change.ChangeIndexCollection;
 import com.google.gerrit.server.index.change.IndexedChangeQuery;
+import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.patch.DiffSummary;
 import com.google.gerrit.server.patch.DiffSummaryKey;
 import com.google.gerrit.server.patch.IntraLineDiff;
@@ -4020,6 +4024,86 @@
   }
 
   @Test
+  public void submitRequirement_withLabelEqualsMax() throws Exception {
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("code-review")
+            .setSubmittabilityExpression(
+                SubmitRequirementExpression.create("label:code-review=MAX"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(change.submitRequirements, "code-review", Status.UNSATISFIED);
+
+    voteLabel(changeId, "code-review", 2);
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(change.submitRequirements, "code-review", Status.SATISFIED);
+  }
+
+  @Test
+  public void submitRequirement_withLabelEqualsMinBlockingSubmission() throws Exception {
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("code-review")
+            .setSubmittabilityExpression(
+                SubmitRequirementExpression.create("-label:code-review=MIN"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    // Requirement is satisfied because there are no votes
+    assertSubmitRequirementStatus(change.submitRequirements, "code-review", Status.SATISFIED);
+
+    voteLabel(changeId, "code-review", -1);
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    // Requirement is still satisfied because -1 is not the max negative value
+    assertSubmitRequirementStatus(change.submitRequirements, "code-review", Status.SATISFIED);
+
+    voteLabel(changeId, "code-review", -2);
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    // Requirement is now unsatisfied because -2 is the max negative value
+    assertSubmitRequirementStatus(change.submitRequirements, "code-review", Status.UNSATISFIED);
+  }
+
+  @Test
+  public void submitRequirement_withLabelEqualsAny() throws Exception {
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("code-review")
+            .setSubmittabilityExpression(
+                SubmitRequirementExpression.create("label:code-review=ANY"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(change.submitRequirements, "code-review", Status.UNSATISFIED);
+
+    voteLabel(changeId, "code-review", 1);
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(change.submitRequirements, "code-review", Status.SATISFIED);
+  }
+
+  @Test
   public void submitRequirementIsSatisfied_whenSubmittabilityExpressionIsFulfilled()
       throws Exception {
     configSubmitRequirement(
@@ -4207,6 +4291,40 @@
   }
 
   @Test
+  public void submitRequirement_storedForClosedChanges() throws Exception {
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("code-review")
+            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:code-review=+2"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    PushOneCommit.Result r = createChange("Add a file", "foo", "content");
+    String changeId = r.getChangeId();
+
+    voteLabel(changeId, "code-review", 2);
+
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(change.submitRequirements, "code-review", Status.SATISFIED);
+
+    RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
+    revision.review(ReviewInput.approve());
+    revision.submit();
+
+    ChangeNotes notes = notesFactory.create(project, r.getChange().getId());
+
+    SubmitRequirementResult result =
+        notes.getSubmitRequirementsResult().stream().collect(MoreCollectors.onlyElement());
+    assertThat(result.status()).isEqualTo(SubmitRequirementResult.Status.SATISFIED);
+    assertThat(result.submittabilityExpressionResult().status())
+        .isEqualTo(SubmitRequirementExpressionResult.Status.PASS);
+    assertThat(result.submittabilityExpressionResult().expression().expressionString())
+        .isEqualTo("label:code-review=+2");
+  }
+
+  @Test
   public void fourByteEmoji() throws Exception {
     // U+1F601 GRINNING FACE WITH SMILING EYES
     String smile = new String(Character.toChars(0x1f601));
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java b/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java
index d8dab33..5f3b702 100644
--- a/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java
@@ -50,6 +50,7 @@
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.webui.EditWebLink;
+import com.google.gerrit.server.change.FileInfoJsonExperimentImpl;
 import com.google.gerrit.server.patch.DiffOperations;
 import com.google.gerrit.server.patch.filediff.FileDiffOutput;
 import com.google.inject.Inject;
@@ -109,7 +110,8 @@
 
     intraline = baseConfig.getBoolean(TEST_PARAMETER_MARKER, "intraline", false);
     useNewDiffCacheListFiles =
-        baseConfig.getBoolean("cache", "diff_cache", "runNewDiffCache_ListFiles", false);
+        Arrays.asList(baseConfig.getStringList("experiments", null, "enabled"))
+            .contains(FileInfoJsonExperimentImpl.NEW_DIFF_CACHE_FEATURE);
     useNewDiffCacheGetDiff =
         baseConfig.getBoolean("cache", "diff_cache", "runNewDiffCache_GetDiff", false);
 
@@ -153,7 +155,8 @@
     PushOneCommit.Result result = push.to("refs/heads/master");
 
     Map<String, FileDiffOutput> modifiedFiles =
-        diffOperations.listModifiedFilesAgainstParent(project, result.getCommit(), null);
+        diffOperations.listModifiedFilesAgainstParent(
+            project, result.getCommit(), /* parentNum= */ 0);
 
     assertThat(modifiedFiles.keySet()).containsExactly("/COMMIT_MSG", "f.txt");
     assertThat(
@@ -2835,11 +2838,7 @@
   }
 
   @Test
-  public void symlinkConvertedToRegularFileIsIdentifiedAsAdded() throws Exception {
-    // TODO(ghareeb): fix this test for the new diff cache implementation
-    assume().that(useNewDiffCacheListFiles).isFalse();
-    assume().that(useNewDiffCacheGetDiff).isFalse();
-
+  public void symlinkConvertedToRegularFileIsIdentifiedAsRewritten() throws Exception {
     String target = "file.txt";
     String symlink = "link.lnk";
 
@@ -2867,23 +2866,39 @@
         gApi.changes().id(result.getChangeId()).current().files(initialRev);
 
     assertThat(changedFiles.keySet()).containsExactly("/COMMIT_MSG", symlink);
-    assertThat(changedFiles.get(symlink).status).isEqualTo('W'); // Rewrite
+
+    // Both old and new diff caches agree that the state is rewritten
+    assertThat(changedFiles.get(symlink).status).isEqualTo('W'); // Rewritten
 
     DiffInfo diffInfo =
         gApi.changes().id(result.getChangeId()).current().file(symlink).diff(initialRev);
 
-    // The diff logic identifies two entries for the file:
-    // 1. One entry as 'DELETED' for the symlink.
-    // 2. Another entry as 'ADDED' for the new regular file.
-    // Since the diff logic returns a single entry, we prioritize returning the 'ADDED' entry in
-    // this case so that the client is able to see the new content that was added to the file.
-    assertThat(diffInfo.changeType).isEqualTo(ChangeType.ADDED);
-    assertThat(diffInfo.content).hasSize(1);
-    assertThat(diffInfo)
-        .content()
-        .element(0)
-        .linesOfB()
-        .containsExactly("Content of the new file named 'symlink'");
+    // TODO(ghareeb): Remove the else branch when the new diff cache is rolled out as default.
+    if (useNewDiffCacheGetDiff) {
+      // File diff in New diff cache: change type is correctly identified as REWRITTEN
+      assertThat(diffInfo.changeType).isEqualTo(ChangeType.REWRITE);
+      assertThat(diffInfo.content).hasSize(2);
+      assertThat(diffInfo)
+          .content()
+          .element(0)
+          .linesOfB()
+          .containsExactly("Content of the new file named 'symlink'");
+      assertThat(diffInfo).content().element(1).linesOfA().containsExactly("file.txt");
+    } else {
+      // File diff in old diff cache: The diff logic identifies two entries for the file:
+      // 1. One entry as 'DELETED' for the symlink.
+      // 2. Another entry as 'ADDED' for the new regular file.
+      // Since the diff logic returns a single entry, the implementation prioritizes  the 'ADDED'
+      // entry in this case so that the user is able to see the new content that was added to the
+      // file.
+      assertThat(diffInfo.changeType).isEqualTo(ChangeType.ADDED);
+      assertThat(diffInfo.content).hasSize(1);
+      assertThat(diffInfo)
+          .content()
+          .element(0)
+          .linesOfB()
+          .containsExactly("Content of the new file named 'symlink'");
+    }
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java b/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
index 9d0b1f4..4590d34 100644
--- a/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
@@ -1207,10 +1207,15 @@
     ChangeApi originalChange = gApi.changes().id(project.get() + "~master~" + result.getChangeId());
 
     ChangeApi cherryPick = originalChange.revision(result.getCommit().name()).cherryPick(input);
+    String firstCherryPickChangeId = cherryPick.id();
     cherryPick.setWorkInProgress();
-    cherryPick = originalChange.revision(result.getCommit().name()).cherryPick(input);
+    gApi.changes()
+        .id(project.get() + "~master~" + result.getChangeId())
+        .revision(result.getCommit().name())
+        .cherryPick(input);
 
-    ChangeInfo secondCherryPickResult = cherryPick.get(ALL_REVISIONS);
+    ChangeInfo secondCherryPickResult =
+        gApi.changes().id(firstCherryPickChangeId).get(ALL_REVISIONS);
     assertThat(secondCherryPickResult.revisions).hasSize(2);
     assertThat(secondCherryPickResult.workInProgress).isNull();
   }
@@ -1416,22 +1421,12 @@
     BadRequestException thrown =
         assertThrows(
             BadRequestException.class,
-            () ->
-                gApi.changes()
-                    .id(r.getChangeId())
-                    .revision(r.getCommit().name())
-                    .files(3)
-                    .keySet());
+            () -> gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).files(3));
     assertThat(thrown).hasMessageThat().isEqualTo("invalid parent number: 3");
     thrown =
         assertThrows(
             BadRequestException.class,
-            () ->
-                gApi.changes()
-                    .id(r.getChangeId())
-                    .revision(r.getCommit().name())
-                    .files(-1)
-                    .keySet());
+            () -> gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).files(-1));
     assertThat(thrown).hasMessageThat().isEqualTo("invalid parent number: -1");
   }
 
@@ -1444,14 +1439,13 @@
 
     BadRequestException thrown =
         assertThrows(
-            BadRequestException.class,
-            () -> gApi.changes().id(changeId).revision(revId2).files(2).keySet());
+            BadRequestException.class, () -> gApi.changes().id(changeId).revision(revId2).files(2));
     assertThat(thrown).hasMessageThat().isEqualTo("invalid parent number: 2");
 
     thrown =
         assertThrows(
             BadRequestException.class,
-            () -> gApi.changes().id(changeId).revision(revId2).files(-1).keySet());
+            () -> gApi.changes().id(changeId).revision(revId2).files(-1));
     assertThat(thrown).hasMessageThat().isEqualTo("invalid parent number: -1");
   }
 
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/RevisionNewDiffCacheIT.java b/javatests/com/google/gerrit/acceptance/api/revision/RevisionNewDiffCacheIT.java
index ec0bcc6..714bd78 100644
--- a/javatests/com/google/gerrit/acceptance/api/revision/RevisionNewDiffCacheIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/revision/RevisionNewDiffCacheIT.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.acceptance.api.revision;
 
+import com.google.gerrit.server.change.FileInfoJsonExperimentImpl;
 import com.google.gerrit.testing.ConfigSuite;
 import org.eclipse.jgit.lib.Config;
 
@@ -26,7 +27,8 @@
   @ConfigSuite.Default
   public static Config newDiffCacheConfig() {
     Config config = new Config();
-    config.setBoolean("cache", "diff_cache", "runNewDiffCache_ListFiles", true);
+    config.setString(
+        "experiments", null, "enabled", FileInfoJsonExperimentImpl.NEW_DIFF_CACHE_FEATURE);
     return config;
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java b/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java
index b7acbe2..92770ba 100644
--- a/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java
@@ -38,7 +38,6 @@
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.entities.AccessSection;
 import com.google.gerrit.entities.AccountGroup;
-import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Patch;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Permission;
@@ -52,7 +51,6 @@
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.git.receive.ReceiveCommitsAdvertiseRefsHookChain;
 import com.google.gerrit.server.git.receive.testing.TestRefAdvertiser;
-import com.google.gerrit.server.notedb.ChangeNoteUtil;
 import com.google.gerrit.server.notedb.Sequences;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackend.RefFilterOptions;
@@ -69,7 +67,6 @@
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.RefUpdate;
 import org.eclipse.jgit.lib.Repository;
@@ -82,7 +79,6 @@
 @NoHttpd
 public class RefAdvertisementIT extends AbstractDaemonTest {
   @Inject private AllUsersName allUsersName;
-  @Inject private ChangeNoteUtil noteUtil;
   @Inject private PermissionBackend permissionBackend;
   @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
@@ -1106,37 +1102,10 @@
 
   @Test
   public void receivePackOmitsMissingObject() throws Exception {
-    String rev = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
     try (Repository repo = repoManager.openRepository(project);
         TestRepository<Repository> tr = new TestRepository<>(repo)) {
-      String subject = "Subject for missing commit";
-      Change c = new Change(cd3.change());
-      PatchSet.Id psId = PatchSet.id(cd3.getId(), 2);
-      c.setCurrentPatchSet(psId, subject, c.getOriginalSubject());
-
-      PersonIdent committer = serverIdent.get();
-      PersonIdent author =
-          noteUtil.newAccountIdIdent(getAccount(admin.id()).id(), committer.getWhen(), committer);
-      tr.branch(RefNames.changeMetaRef(cd3.getId()))
-          .commit()
-          .author(author)
-          .committer(committer)
-          .message(
-              "Update patch set "
-                  + psId.get()
-                  + "\n"
-                  + "\n"
-                  + "Patch-set: "
-                  + psId.get()
-                  + "\n"
-                  + "Commit: "
-                  + rev
-                  + "\n"
-                  + "Subject: "
-                  + subject
-                  + "\n")
-          .create();
-      indexer.index(c.getProject(), c.getId());
+      PatchSet.Id psId = PatchSet.id(cd3.getId(), 1);
+      tr.delete(psId.toRefName());
     }
 
     assertThat(getReceivePackRefs().additionalHaves()).containsExactly(obj(cd4, 1));
diff --git a/javatests/com/google/gerrit/acceptance/pgm/AbstractReindexTests.java b/javatests/com/google/gerrit/acceptance/pgm/AbstractReindexTests.java
index cad0b83..cac376f 100644
--- a/javatests/com/google/gerrit/acceptance/pgm/AbstractReindexTests.java
+++ b/javatests/com/google/gerrit/acceptance/pgm/AbstractReindexTests.java
@@ -31,14 +31,18 @@
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.GerritApi;
 import com.google.gerrit.extensions.common.ChangeInput;
+import com.google.gerrit.index.IndexDefinition;
 import com.google.gerrit.launcher.GerritLauncher;
 import com.google.gerrit.server.index.GerritIndexStatus;
 import com.google.gerrit.server.index.change.ChangeIndexCollection;
 import com.google.gerrit.server.index.change.ChangeSchemaDefinitions;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
 import com.google.inject.Injector;
+import com.google.inject.Key;
 import com.google.inject.Provider;
+import com.google.inject.TypeLiteral;
 import java.nio.file.Files;
+import java.util.Collection;
 import java.util.Set;
 import java.util.function.Consumer;
 import org.eclipse.jgit.lib.Config;
@@ -48,9 +52,6 @@
 
 @NoHttpd
 public abstract class AbstractReindexTests extends StandaloneSiteTest {
-  /** @param injector injector */
-  public abstract void configureIndex(Injector injector) throws Exception;
-
   private static final String CHANGES = ChangeSchemaDefinitions.NAME;
 
   private Project.NameKey project;
@@ -223,10 +224,18 @@
     }
   }
 
+  protected static void createAllIndexes(Injector injector) {
+    Collection<IndexDefinition<?, ?, ?>> indexDefs =
+        injector.getInstance(Key.get(new TypeLiteral<Collection<IndexDefinition<?, ?, ?>>>() {}));
+    for (IndexDefinition<?, ?, ?> indexDef : indexDefs) {
+      indexDef.getIndexCollection().getSearchIndex().deleteAll();
+    }
+  }
+
   private void setUpChange() throws Exception {
     project = Project.nameKey("reindex-project-test");
     try (ServerContext ctx = startServer()) {
-      configureIndex(ctx.getInjector());
+      createAllIndexes(ctx.getInjector());
       GerritApi gApi = ctx.getInjector().getInstance(GerritApi.class);
       gApi.projects().create(project.get());
 
diff --git a/javatests/com/google/gerrit/acceptance/pgm/ElasticReindexIT.java b/javatests/com/google/gerrit/acceptance/pgm/ElasticReindexIT.java
index f23cc10..0632241 100644
--- a/javatests/com/google/gerrit/acceptance/pgm/ElasticReindexIT.java
+++ b/javatests/com/google/gerrit/acceptance/pgm/ElasticReindexIT.java
@@ -14,23 +14,15 @@
 
 package com.google.gerrit.acceptance.pgm;
 
-import static com.google.gerrit.elasticsearch.ElasticTestUtils.createAllIndexes;
 import static com.google.gerrit.elasticsearch.ElasticTestUtils.getConfig;
 
 import com.google.gerrit.elasticsearch.ElasticVersion;
 import com.google.gerrit.testing.ConfigSuite;
-import com.google.inject.Injector;
 import org.eclipse.jgit.lib.Config;
 
 public class ElasticReindexIT extends AbstractReindexTests {
-
   @ConfigSuite.Default
   public static Config elasticsearchV7() {
     return getConfig(ElasticVersion.V7_8);
   }
-
-  @Override
-  public void configureIndex(Injector injector) {
-    createAllIndexes(injector);
-  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/pgm/ReindexIT.java b/javatests/com/google/gerrit/acceptance/pgm/LuceneReindexIT.java
similarity index 68%
rename from javatests/com/google/gerrit/acceptance/pgm/ReindexIT.java
rename to javatests/com/google/gerrit/acceptance/pgm/LuceneReindexIT.java
index 223851e..e630bca 100644
--- a/javatests/com/google/gerrit/acceptance/pgm/ReindexIT.java
+++ b/javatests/com/google/gerrit/acceptance/pgm/LuceneReindexIT.java
@@ -14,9 +14,14 @@
 
 package com.google.gerrit.acceptance.pgm;
 
-import com.google.inject.Injector;
+import com.google.gerrit.testing.ConfigSuite;
+import org.eclipse.jgit.lib.Config;
 
-public class ReindexIT extends AbstractReindexTests {
-  @Override
-  public void configureIndex(Injector injector) {}
+public class LuceneReindexIT extends AbstractReindexTests {
+  @ConfigSuite.Default
+  public static Config luceneConfig() {
+    Config cfg = new Config();
+    cfg.setString("index", null, "type", "lucene");
+    return cfg;
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/CancellationIT.java b/javatests/com/google/gerrit/acceptance/rest/CancellationIT.java
new file mode 100644
index 0000000..29d54cc
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/CancellationIT.java
@@ -0,0 +1,206 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.rest;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.httpd.restapi.RestApiServlet.SC_CLIENT_CLOSED_REQUEST;
+import static org.apache.http.HttpStatus.SC_REQUEST_TIMEOUT;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.ExtensionRegistry;
+import com.google.gerrit.acceptance.ExtensionRegistry.Registration;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.server.cancellation.RequestCancelledException;
+import com.google.gerrit.server.cancellation.RequestStateProvider;
+import com.google.gerrit.server.events.CommitReceivedEvent;
+import com.google.gerrit.server.git.validators.CommitValidationException;
+import com.google.gerrit.server.git.validators.CommitValidationListener;
+import com.google.gerrit.server.git.validators.CommitValidationMessage;
+import com.google.gerrit.server.project.CreateProjectArgs;
+import com.google.gerrit.server.validators.ProjectCreationValidationListener;
+import com.google.gerrit.server.validators.ValidationException;
+import com.google.inject.Inject;
+import java.util.List;
+import org.junit.Test;
+
+public class CancellationIT extends AbstractDaemonTest {
+  @Inject private ExtensionRegistry extensionRegistry;
+
+  @Test
+  public void handleClientDisconnected() throws Exception {
+    ProjectCreationValidationListener projectCreationListener =
+        new ProjectCreationValidationListener() {
+          @Override
+          public void validateNewProject(CreateProjectArgs args) throws ValidationException {
+            // Simulate a request cancellation by throwing RequestCancelledException. In contrast to
+            // an actual request cancellation this allows us to verify the HTTP status code that is
+            // set when a request is cancelled.
+            throw new RequestCancelledException(
+                RequestStateProvider.Reason.CLIENT_CLOSED_REQUEST, /* cancellationMessage= */ null);
+          }
+        };
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(projectCreationListener)) {
+      RestResponse response = adminRestSession.put("/projects/" + name("new"));
+      assertThat(response.getStatusCode()).isEqualTo(SC_CLIENT_CLOSED_REQUEST);
+      assertThat(response.getEntityContent()).isEqualTo("Client Closed Request");
+    }
+  }
+
+  @Test
+  public void handleClientDeadlineExceeded() throws Exception {
+    ProjectCreationValidationListener projectCreationListener =
+        new ProjectCreationValidationListener() {
+          @Override
+          public void validateNewProject(CreateProjectArgs args) throws ValidationException {
+            // Simulate an exceeded deadline by throwing RequestCancelledException.
+            throw new RequestCancelledException(
+                RequestStateProvider.Reason.CLIENT_PROVIDED_DEADLINE_EXCEEDED,
+                /* cancellationMessage= */ null);
+          }
+        };
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(projectCreationListener)) {
+      RestResponse response = adminRestSession.put("/projects/" + name("new"));
+      assertThat(response.getStatusCode()).isEqualTo(SC_REQUEST_TIMEOUT);
+      assertThat(response.getEntityContent()).isEqualTo("Client Provided Deadline Exceeded");
+    }
+  }
+
+  @Test
+  public void handleServerDeadlineExceeded() throws Exception {
+    ProjectCreationValidationListener projectCreationListener =
+        new ProjectCreationValidationListener() {
+          @Override
+          public void validateNewProject(CreateProjectArgs args) throws ValidationException {
+            // Simulate an exceeded deadline by throwing RequestCancelledException.
+            throw new RequestCancelledException(
+                RequestStateProvider.Reason.SERVER_DEADLINE_EXCEEDED,
+                /* cancellationMessage= */ null);
+          }
+        };
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(projectCreationListener)) {
+      RestResponse response = adminRestSession.put("/projects/" + name("new"));
+      assertThat(response.getStatusCode()).isEqualTo(SC_REQUEST_TIMEOUT);
+      assertThat(response.getEntityContent()).isEqualTo("Server Deadline Exceeded");
+    }
+  }
+
+  @Test
+  public void handleRequestCancellationWithMessage() throws Exception {
+    ProjectCreationValidationListener projectCreationListener =
+        new ProjectCreationValidationListener() {
+          @Override
+          public void validateNewProject(CreateProjectArgs args) throws ValidationException {
+            // Simulate an exceeded deadline by throwing RequestCancelledException.
+            throw new RequestCancelledException(
+                RequestStateProvider.Reason.SERVER_DEADLINE_EXCEEDED, "deadline = 10m");
+          }
+        };
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(projectCreationListener)) {
+      RestResponse response = adminRestSession.put("/projects/" + name("new"));
+      assertThat(response.getStatusCode()).isEqualTo(SC_REQUEST_TIMEOUT);
+      assertThat(response.getEntityContent())
+          .isEqualTo("Server Deadline Exceeded\n\ndeadline = 10m");
+    }
+  }
+
+  @Test
+  public void handleClientDisconnectedForPush() throws Exception {
+    CommitValidationListener commitValidationListener =
+        new CommitValidationListener() {
+          @Override
+          public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent)
+              throws CommitValidationException {
+            // Simulate a request cancellation by throwing RequestCancelledException. In contrast to
+            // an actual request cancellation this allows us verify the error message that is sent
+            // to the client.
+            throw new RequestCancelledException(
+                RequestStateProvider.Reason.CLIENT_CLOSED_REQUEST, /* cancellationMessage= */ null);
+          }
+        };
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(commitValidationListener)) {
+      PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo);
+      PushOneCommit.Result r = push.to("refs/heads/master");
+      r.assertErrorStatus("Client Closed Request");
+    }
+  }
+
+  @Test
+  public void handleClientDeadlineExceededForPush() throws Exception {
+    CommitValidationListener commitValidationListener =
+        new CommitValidationListener() {
+          @Override
+          public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent)
+              throws CommitValidationException {
+            // Simulate an exceeded deadline by throwing RequestCancelledException.
+            throw new RequestCancelledException(
+                RequestStateProvider.Reason.CLIENT_PROVIDED_DEADLINE_EXCEEDED,
+                /* cancellationMessage= */ null);
+          }
+        };
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(commitValidationListener)) {
+      PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo);
+      PushOneCommit.Result r = push.to("refs/heads/master");
+      r.assertErrorStatus("Client Provided Deadline Exceeded");
+    }
+  }
+
+  @Test
+  public void handleServerDeadlineExceededForPush() throws Exception {
+    CommitValidationListener commitValidationListener =
+        new CommitValidationListener() {
+          @Override
+          public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent)
+              throws CommitValidationException {
+            // Simulate an exceeded deadline by throwing RequestCancelledException.
+            throw new RequestCancelledException(
+                RequestStateProvider.Reason.SERVER_DEADLINE_EXCEEDED,
+                /* cancellationMessage= */ null);
+          }
+        };
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(commitValidationListener)) {
+      PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo);
+      PushOneCommit.Result r = push.to("refs/heads/master");
+      r.assertErrorStatus("Server Deadline Exceeded");
+    }
+  }
+
+  @Test
+  public void handleRequestCancellationWithMessageForPush() throws Exception {
+    CommitValidationListener commitValidationListener =
+        new CommitValidationListener() {
+          @Override
+          public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent)
+              throws CommitValidationException {
+            // Simulate an exceeded deadline by throwing RequestCancelledException.
+            throw new RequestCancelledException(
+                RequestStateProvider.Reason.SERVER_DEADLINE_EXCEEDED, "deadline = 10m");
+          }
+        };
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(commitValidationListener)) {
+      PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo);
+      PushOneCommit.Result r = push.to("refs/heads/master");
+      r.assertErrorStatus("Server Deadline Exceeded (deadline = 10m)");
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/rest/TraceIT.java b/javatests/com/google/gerrit/acceptance/rest/TraceIT.java
index 4b780f8..530f2ec 100644
--- a/javatests/com/google/gerrit/acceptance/rest/TraceIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/TraceIT.java
@@ -24,7 +24,7 @@
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.timeout;
 import static org.mockito.Mockito.verify;
-import static org.mockito.Mockito.verifyZeroInteractions;
+import static org.mockito.Mockito.verifyNoInteractions;
 
 import com.google.auto.value.AutoValue;
 import com.google.common.collect.ImmutableList;
@@ -409,7 +409,7 @@
       PushOneCommit.Result r = push.to("refs/heads/master");
       r.assertOkStatus();
 
-      verifyZeroInteractions(testPerformanceLogger);
+      verifyNoInteractions(testPerformanceLogger);
     }
   }
 
diff --git a/javatests/com/google/gerrit/acceptance/rest/account/ExternalIdIT.java b/javatests/com/google/gerrit/acceptance/rest/account/ExternalIdIT.java
index b6033d4..20b378b 100644
--- a/javatests/com/google/gerrit/acceptance/rest/account/ExternalIdIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/account/ExternalIdIT.java
@@ -37,7 +37,6 @@
 import com.google.common.collect.Iterables;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.RestResponse;
-import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.data.GlobalCapability;
@@ -79,8 +78,6 @@
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.FooterLine;
-import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
 import org.eclipse.jgit.transport.PushResult;
 import org.eclipse.jgit.transport.RemoteRefUpdate;
@@ -774,97 +771,6 @@
     }
   }
 
-  @Test
-  public void footers() throws Exception {
-    // Insert external ID for different accounts
-    TestAccount user1 = accountCreator.create("user1");
-    TestAccount user2 = accountCreator.create("user2");
-    ExternalId extId1 = ExternalId.create("foo", "1", user1.id());
-    ExternalId extId2 = ExternalId.create("foo", "2", user1.id());
-    ExternalId extId3 = ExternalId.create("foo", "3", user2.id());
-    try (Repository allUsersRepo = repoManager.openRepository(allUsers);
-        MetaDataUpdate md = metaDataUpdateFactory.create(allUsers)) {
-      ExternalIdNotes extIdNotes = externalIdNotesFactory.load(allUsersRepo);
-      extIdNotes.insert(ImmutableSet.of(extId1, extId2, extId3));
-      RevCommit c = extIdNotes.commit(md);
-      assertThat(getFooters(c))
-          .containsExactly("Account: " + user1.id(), "Account: " + user2.id())
-          .inOrder();
-    }
-
-    // Insert external ID with different emails
-    ExternalId extId4 = ExternalId.createWithEmail("foo", "4", user1.id(), "foo4@example.com");
-    ExternalId extId5 = ExternalId.createWithEmail("foo", "5", user2.id(), "foo5@example.com");
-    try (Repository allUsersRepo = repoManager.openRepository(allUsers);
-        MetaDataUpdate md = metaDataUpdateFactory.create(allUsers)) {
-      ExternalIdNotes extIdNotes = externalIdNotesFactory.load(allUsersRepo);
-      extIdNotes.insert(ImmutableSet.of(extId4, extId5));
-      RevCommit c = extIdNotes.commit(md);
-      assertThat(getFooters(c))
-          .containsExactly(
-              "Account: " + user1.id(),
-              "Account: " + user2.id(),
-              "Email: foo4@example.com",
-              "Email: foo5@example.com")
-          .inOrder();
-    }
-
-    // Update external ID - Add Email
-    ExternalId extId1a = ExternalId.createWithEmail("foo", "1", user1.id(), "foo1@example.com");
-    try (Repository allUsersRepo = repoManager.openRepository(allUsers);
-        MetaDataUpdate md = metaDataUpdateFactory.create(allUsers)) {
-      ExternalIdNotes extIdNotes = externalIdNotesFactory.load(allUsersRepo);
-      extIdNotes.upsert(extId1a);
-      RevCommit c = extIdNotes.commit(md);
-      assertThat(getFooters(c))
-          .containsExactly("Account: " + user1.id(), "Email: foo1@example.com")
-          .inOrder();
-    }
-
-    // Update external ID - Remove Email
-    try (Repository allUsersRepo = repoManager.openRepository(allUsers);
-        MetaDataUpdate md = metaDataUpdateFactory.create(allUsers)) {
-      ExternalIdNotes extIdNotes = externalIdNotesFactory.load(allUsersRepo);
-      extIdNotes.upsert(extId1);
-      RevCommit c = extIdNotes.commit(md);
-      assertThat(getFooters(c))
-          .containsExactly("Account: " + user1.id(), "Email: foo1@example.com")
-          .inOrder();
-    }
-
-    // Delete external IDs
-    try (Repository allUsersRepo = repoManager.openRepository(allUsers);
-        MetaDataUpdate md = metaDataUpdateFactory.create(allUsers)) {
-      ExternalIdNotes extIdNotes = externalIdNotesFactory.load(allUsersRepo);
-      extIdNotes.delete(ImmutableSet.of(extId1, extId5));
-      RevCommit c = extIdNotes.commit(md);
-      assertThat(getFooters(c))
-          .containsExactly(
-              "Account: " + user1.id(), "Account: " + user2.id(), "Email: foo5@example.com")
-          .inOrder();
-    }
-
-    // Delete external ID by key without email
-    try (Repository allUsersRepo = repoManager.openRepository(allUsers);
-        MetaDataUpdate md = metaDataUpdateFactory.create(allUsers)) {
-      ExternalIdNotes extIdNotes = externalIdNotesFactory.load(allUsersRepo);
-      extIdNotes.delete(extId2.accountId(), extId2.key());
-      RevCommit c = extIdNotes.commit(md);
-      assertThat(getFooters(c)).containsExactly("Account: " + user1.id()).inOrder();
-    }
-
-    // Delete external ID by key with email
-    try (Repository allUsersRepo = repoManager.openRepository(allUsers);
-        MetaDataUpdate md = metaDataUpdateFactory.create(allUsers)) {
-      ExternalIdNotes extIdNotes = externalIdNotesFactory.load(allUsersRepo);
-      extIdNotes.delete(extId4.accountId(), extId4.key());
-      RevCommit c = extIdNotes.commit(md);
-      assertThat(getFooters(c))
-          .containsExactly("Account: " + user1.id(), "Email: foo4@example.com")
-          .inOrder();
-    }
-  }
-
   private boolean isPartialCacheReloadingEnabled() {
     return cfg.getBoolean("cache", "external_ids_map", "enablePartialReloads", true);
   }
@@ -915,10 +821,6 @@
     }
   }
 
-  private List<String> getFooters(RevCommit c) {
-    return c.getFooterLines().stream().map(FooterLine::toString).collect(toList());
-  }
-
   private List<AccountExternalIdInfo> toExternalIdInfos(Collection<ExternalId> extIds) {
     return extIds.stream().map(this::toExternalIdInfo).collect(toList());
   }
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java b/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java
index 800ee42..4b45476 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.acceptance.rest.change;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
 import static com.google.gerrit.extensions.restapi.testing.AttentionSetUpdateSubject.assertThat;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
@@ -32,23 +33,29 @@
 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.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.AttentionSetUpdate;
 import com.google.gerrit.entities.AttentionSetUpdate.Operation;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.entities.Patch;
+import com.google.gerrit.entities.Permission;
 import com.google.gerrit.extensions.api.changes.AttentionSetInput;
 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.api.changes.ReviewerInput;
+import com.google.gerrit.extensions.api.groups.GroupInput;
 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.common.GroupInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.server.account.ServiceUserClassifier;
 import com.google.gerrit.server.query.change.ChangeData;
@@ -82,6 +89,7 @@
   @Inject private FakeEmailSender email;
   @Inject private TestCommentHelper testCommentHelper;
   @Inject private Provider<InternalChangeQuery> changeQueryProvider;
+  @Inject private ProjectOperations projectOperations;
 
   /** Simulates a fake clock. Uses second granularity. */
   private static class FakeClock implements LongSupplier {
@@ -622,7 +630,7 @@
     assertThat(attentionSet).hasReasonThat().isEqualTo("reason");
 
     // No emails for adding to attention set were sent.
-    email.getMessages().isEmpty();
+    assertThat(email.getMessages()).isEmpty();
   }
 
   @Test
@@ -631,6 +639,7 @@
     // implictly adds the user to the attention set when adding as reviewer
     change(r).addReviewer(user.email());
     requestScopeOperations.setApiUser(user.id());
+    email.clear();
 
     ReviewInput reviewInput =
         ReviewInput.create().removeUserFromAttentionSet(user.email(), "reason");
@@ -643,7 +652,7 @@
     assertThat(attentionSet).hasReasonThat().isEqualTo("reason");
 
     // No emails for removing from attention set were sent.
-    email.getMessages().isEmpty();
+    assertThat(email.getMessages()).isEmpty();
   }
 
   @Test
@@ -1834,6 +1843,44 @@
     assertThat(attentionSet).hasReasonThat().isEqualTo("Their vote was deleted");
   }
 
+  @Test
+  public void accountsWithNoReadPermissionIgnoredOnReply() throws Exception {
+    // Create a group with user.
+    GroupInput groupInput = new GroupInput();
+    groupInput.name = name("User");
+    groupInput.members = ImmutableList.of(String.valueOf(user.id()));
+    GroupInfo group = gApi.groups().create(groupInput).get();
+
+    PushOneCommit.Result r = createChange();
+    gApi.changes().id(r.getChangeId()).addReviewer(user.email());
+
+    // remove read permission for user.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(Permission.READ).ref("refs/*").group(AccountGroup.uuid(group.id)))
+        .update();
+
+    // removing user without permissions from attention set is allowed on reply.
+    gApi.changes()
+        .id(r.getChangeId())
+        .current()
+        .review(new ReviewInput().removeUserFromAttentionSet(user.email(), "reason"));
+
+    // Add user to attention throws an exception.
+    assertThrows(
+        AuthException.class,
+        () -> change(r).addToAttentionSet(new AttentionSetInput(user.email(), "reason")));
+
+    // Add user to attention set is ignored on reply.
+    gApi.changes()
+        .id(r.getChangeId())
+        .current()
+        .review(new ReviewInput().addUserToAttentionSet(user.email(), "reason"));
+    assertThat(Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r, user)).operation())
+        .isEqualTo(Operation.REMOVE);
+  }
+
   private List<AttentionSetUpdate> getAttentionSetUpdatesForUser(
       PushOneCommit.Result r, TestAccount account) {
     return getAttentionSetUpdates(r.getChange().getId()).stream()
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/ChangeMessagesIT.java b/javatests/com/google/gerrit/acceptance/rest/change/ChangeMessagesIT.java
index d93d3f7..e1a6f99 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/ChangeMessagesIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/ChangeMessagesIT.java
@@ -312,11 +312,7 @@
     assertThat(info.message).isEqualTo(expectedMessage);
     List<ChangeMessageInfo> messagesAfterDeletion = gApi.changes().id(changeNum).messages();
     assertMessagesAfterDeletion(
-        messagesBeforeDeletion,
-        messagesAfterDeletion,
-        deletedMessageIndex,
-        deletedBy,
-        expectedMessage);
+        messagesBeforeDeletion, messagesAfterDeletion, deletedMessageIndex, expectedMessage);
     assertCommentsAfterDeletion(changeNum, commentsBefore);
 
     // Verify change index is updated after deletion.
@@ -331,7 +327,6 @@
       List<ChangeMessageInfo> messagesBeforeDeletion,
       List<ChangeMessageInfo> messagesAfterDeletion,
       int deletedMessageIndex,
-      TestAccount deletedBy,
       String expectedDeleteMessage) {
     assertWithMessage("after: %s; before: %s", messagesAfterDeletion, messagesBeforeDeletion)
         .that(messagesAfterDeletion)
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java b/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
index d788149..ad06226 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
@@ -103,8 +103,8 @@
 
   @Before
   public void addNonCommitHead() throws Exception {
-    try (Repository repo = repoManager.openRepository(project)) {
-      ObjectInserter ins = repo.newObjectInserter();
+    try (Repository repo = repoManager.openRepository(project);
+        ObjectInserter ins = repo.newObjectInserter()) {
       ObjectId answer = ins.insert(Constants.OBJ_BLOB, new byte[] {42});
       ins.flush();
       ins.close();
diff --git a/javatests/com/google/gerrit/acceptance/server/change/CommentContextIT.java b/javatests/com/google/gerrit/acceptance/server/change/CommentContextIT.java
index 9bd8e9c..29058ef 100644
--- a/javatests/com/google/gerrit/acceptance/server/change/CommentContextIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/change/CommentContextIT.java
@@ -445,6 +445,52 @@
     assertThat(comments.get(0).sourceContentType).isEqualTo("text/x-c++src");
   }
 
+  @Test
+  public void listChangeCommentsWithContextEnabled_twoRangeCommentsWithTheSameContext()
+      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,
+                PushOneCommit.SUBJECT,
+                FILE_NAME,
+                content.build().stream().collect(Collectors.joining("\n")),
+                r1.getChangeId())
+            .to("refs/for/master");
+
+    CommentsUtil.addCommentOnRange(gApi, r2, "looks good", createCommentRange(2, 5));
+    CommentsUtil.addCommentOnRange(gApi, r2, "are you sure?", createCommentRange(2, 5));
+
+    List<CommentInfo> comments =
+        gApi.changes().id(r2.getChangeId()).commentsRequest().withContext(true).getAsList();
+
+    assertThat(comments).hasSize(2);
+
+    assertThat(
+            comments.stream()
+                .filter(c -> c.message.equals("looks good"))
+                .collect(MoreCollectors.onlyElement())
+                .contextLines)
+        .containsExactlyElementsIn(
+            createContextLines("2", "line_2", "3", "line_3", "4", "line_4", "5", "line_5"));
+
+    assertThat(
+            comments.stream()
+                .filter(c -> c.message.equals("are you sure?"))
+                .collect(MoreCollectors.onlyElement())
+                .contextLines)
+        .containsExactlyElementsIn(
+            createContextLines("2", "line_2", "3", "line_3", "4", "line_4", "5", "line_5"));
+  }
+
   private String createChangeWithContent(String fileName, String fileContent, int line)
       throws Exception {
     PushOneCommit.Result result =
diff --git a/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java b/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
index cbf8438..89074b7 100644
--- a/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
@@ -1386,11 +1386,10 @@
             r1.getCommit().getName(),
             CommentsUtil.newDraft(FILE_NAME, Side.REVISION, createLineRange(4, 10), "comment 1"));
 
-    CommentInfo draftPs2 =
-        addDraft(
-            r1.getChangeId(),
-            r2.getCommit().getName(),
-            CommentsUtil.newDraft(FILE_NAME, Side.REVISION, createLineRange(3, 12), "comment 3"));
+    addDraft(
+        r1.getChangeId(),
+        r2.getCommit().getName(),
+        CommentsUtil.newDraft(FILE_NAME, Side.REVISION, createLineRange(3, 12), "comment 3"));
 
     ReviewInput reviewInput =
         createReviewInput(
diff --git a/javatests/com/google/gerrit/acceptance/server/change/ConsistencyCheckerIT.java b/javatests/com/google/gerrit/acceptance/server/change/ConsistencyCheckerIT.java
index e39f967..9d821b7 100644
--- a/javatests/com/google/gerrit/acceptance/server/change/ConsistencyCheckerIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/change/ConsistencyCheckerIT.java
@@ -257,7 +257,6 @@
             + "Groups: "
             + rev
             + "\n");
-    indexer.index(c.getProject(), c.getId());
     ChangeNotes notes = changeNotesFactory.create(c.getProject(), c.getId());
 
     FixInput fix = new FixInput();
@@ -817,8 +816,6 @@
             + "Subject: "
             + subject
             + "\n");
-    indexer.index(c.getProject(), c.getId());
-
     return ps;
   }
 
diff --git a/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java b/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java
index 0f50797..65cb97a 100644
--- a/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java
@@ -1718,10 +1718,6 @@
     gApi.changes().id(changeId).current().submit(in);
   }
 
-  private void merge(String changeId, TestAccount by, TestAccount onBehalfOf) throws Exception {
-    merge(changeId, by, onBehalfOf, /*notify=*/ null);
-  }
-
   private void merge(
       String changeId, TestAccount by, TestAccount onBehalfOf, @Nullable NotifyHandling notify)
       throws Exception {
diff --git a/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsEvaluatorIT.java b/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsEvaluatorIT.java
index 23047a4..e848cef 100644
--- a/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsEvaluatorIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsEvaluatorIT.java
@@ -29,7 +29,6 @@
 import com.google.gerrit.entities.SubmitRequirement;
 import com.google.gerrit.entities.SubmitRequirementExpression;
 import com.google.gerrit.entities.SubmitRequirementExpressionResult;
-import com.google.gerrit.entities.SubmitRequirementExpressionResult.PredicateResult;
 import com.google.gerrit.entities.SubmitRequirementExpressionResult.Status;
 import com.google.gerrit.entities.SubmitRequirementResult;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
@@ -102,25 +101,14 @@
 
     assertThat(result.status()).isEqualTo(Status.PASS);
 
-    assertThat(result.getPassingAtoms())
-        .containsExactly(
-            PredicateResult.builder()
-                .predicateString(String.format("project:%s", project.get()))
-                .status(true)
-                .build(),
-            PredicateResult.builder()
-                .predicateString("message:\"Fix a bug\"")
-                .status(true)
-                .build());
+    assertThat(result.passingAtoms())
+        .containsExactly(String.format("project:%s", project.get()), "message:\"Fix a bug\"");
 
-    assertThat(result.getFailingAtoms())
+    assertThat(result.failingAtoms())
         .containsExactly(
-            PredicateResult.builder()
-                // TODO(ghareeb): querying "branch:" creates a RefPredicate. Fix names so that they
-                // match
-                .predicateString(String.format("ref:refs/heads/foo"))
-                .status(false)
-                .build());
+            // TODO(ghareeb): querying "branch:" creates a RefPredicate. Fix names so that they
+            // match
+            String.format("ref:refs/heads/foo"));
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/acceptance/ssh/SshCancellationIT.java b/javatests/com/google/gerrit/acceptance/ssh/SshCancellationIT.java
new file mode 100644
index 0000000..2cb9637
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/ssh/SshCancellationIT.java
@@ -0,0 +1,102 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.ssh;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.ExtensionRegistry;
+import com.google.gerrit.acceptance.ExtensionRegistry.Registration;
+import com.google.gerrit.acceptance.UseSsh;
+import com.google.gerrit.server.cancellation.RequestCancelledException;
+import com.google.gerrit.server.cancellation.RequestStateProvider;
+import com.google.gerrit.server.project.CreateProjectArgs;
+import com.google.gerrit.server.validators.ProjectCreationValidationListener;
+import com.google.gerrit.server.validators.ValidationException;
+import com.google.inject.Inject;
+import org.junit.Test;
+
+@UseSsh
+public class SshCancellationIT extends AbstractDaemonTest {
+  @Inject private ExtensionRegistry extensionRegistry;
+
+  @Test
+  public void handleClientDisconnected() throws Exception {
+    ProjectCreationValidationListener projectCreationListener =
+        new ProjectCreationValidationListener() {
+          @Override
+          public void validateNewProject(CreateProjectArgs args) throws ValidationException {
+            throw new RequestCancelledException(
+                RequestStateProvider.Reason.CLIENT_CLOSED_REQUEST, /* cancellationMessage= */ null);
+          }
+        };
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(projectCreationListener)) {
+      adminSshSession.exec("gerrit create-project " + name("new"));
+      adminSshSession.assertFailure("Client Closed Request");
+    }
+  }
+
+  @Test
+  public void handleClientDeadlineExceeded() throws Exception {
+    ProjectCreationValidationListener projectCreationListener =
+        new ProjectCreationValidationListener() {
+          @Override
+          public void validateNewProject(CreateProjectArgs args) throws ValidationException {
+            throw new RequestCancelledException(
+                RequestStateProvider.Reason.CLIENT_PROVIDED_DEADLINE_EXCEEDED,
+                /* cancellationMessage= */ null);
+          }
+        };
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(projectCreationListener)) {
+      adminSshSession.exec("gerrit create-project " + name("new"));
+      adminSshSession.assertFailure("Client Provided Deadline Exceeded");
+    }
+  }
+
+  @Test
+  public void handleServerDeadlineExceeded() throws Exception {
+    ProjectCreationValidationListener projectCreationListener =
+        new ProjectCreationValidationListener() {
+          @Override
+          public void validateNewProject(CreateProjectArgs args) throws ValidationException {
+            throw new RequestCancelledException(
+                RequestStateProvider.Reason.SERVER_DEADLINE_EXCEEDED,
+                /* cancellationMessage= */ null);
+          }
+        };
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(projectCreationListener)) {
+      adminSshSession.exec("gerrit create-project " + name("new"));
+      adminSshSession.assertFailure("Server Deadline Exceeded");
+    }
+  }
+
+  @Test
+  public void handleRequestCancellationWithMessage() throws Exception {
+    ProjectCreationValidationListener projectCreationListener =
+        new ProjectCreationValidationListener() {
+          @Override
+          public void validateNewProject(CreateProjectArgs args) throws ValidationException {
+            throw new RequestCancelledException(
+                RequestStateProvider.Reason.SERVER_DEADLINE_EXCEEDED, "deadline = 10m");
+          }
+        };
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(projectCreationListener)) {
+      adminSshSession.exec("gerrit create-project " + name("new"));
+      adminSshSession.assertFailure("Server Deadline Exceeded (deadline = 10m)");
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/testsuite/index/DefaultIndexBindingIT.java b/javatests/com/google/gerrit/acceptance/testsuite/index/DefaultIndexBindingIT.java
new file mode 100644
index 0000000..f5e4e09
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/testsuite/index/DefaultIndexBindingIT.java
@@ -0,0 +1,51 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.testsuite.index;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.index.IndexType;
+import com.google.gerrit.index.testing.AbstractFakeIndex;
+import com.google.gerrit.server.index.change.ChangeIndexCollection;
+import javax.inject.Inject;
+import org.junit.AfterClass;
+import org.junit.BeforeClass;
+import org.junit.Test;
+
+/** Test to check that the expected index backend was bound depending on sys/env properties. */
+public class DefaultIndexBindingIT extends AbstractDaemonTest {
+
+  @Inject private ChangeIndexCollection changeIndex;
+
+  private static String propertyBeforeTest;
+
+  @BeforeClass
+  public static void setup() {
+    propertyBeforeTest = System.getProperty(IndexType.SYS_PROP);
+    System.setProperty(IndexType.SYS_PROP, "");
+  }
+
+  @AfterClass
+  public static void teardown() {
+    System.setProperty(IndexType.SYS_PROP, propertyBeforeTest);
+  }
+
+  @Test
+  public void fakeIsBoundByDefault() throws Exception {
+    assertThat(System.getProperty(IndexType.SYS_PROP)).isEmpty();
+    assertThat(changeIndex.getSearchIndex()).isInstanceOf(AbstractFakeIndex.FakeChangeIndex.class);
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/testsuite/index/FakeIndexBindingIT.java b/javatests/com/google/gerrit/acceptance/testsuite/index/FakeIndexBindingIT.java
new file mode 100644
index 0000000..4122426
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/testsuite/index/FakeIndexBindingIT.java
@@ -0,0 +1,51 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.testsuite.index;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.index.IndexType;
+import com.google.gerrit.index.testing.AbstractFakeIndex;
+import com.google.gerrit.server.index.change.ChangeIndexCollection;
+import javax.inject.Inject;
+import org.junit.AfterClass;
+import org.junit.BeforeClass;
+import org.junit.Test;
+
+/** Test to check that the expected index backend was bound depending on sys/env properties. */
+public class FakeIndexBindingIT extends AbstractDaemonTest {
+
+  @Inject private ChangeIndexCollection changeIndex;
+
+  private static String propertyBeforeTest;
+
+  @BeforeClass
+  public static void setup() {
+    propertyBeforeTest = System.getProperty(IndexType.SYS_PROP);
+    System.setProperty(IndexType.SYS_PROP, "fake");
+  }
+
+  @AfterClass
+  public static void teardown() {
+    System.setProperty(IndexType.SYS_PROP, propertyBeforeTest);
+  }
+
+  @Test
+  public void fakeIsBoundWhenConfigured() throws Exception {
+    assertThat(System.getProperty(IndexType.SYS_PROP)).isEqualTo("fake");
+    assertThat(changeIndex.getSearchIndex()).isInstanceOf(AbstractFakeIndex.FakeChangeIndex.class);
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/testsuite/index/LuceneIndexBindingIT.java b/javatests/com/google/gerrit/acceptance/testsuite/index/LuceneIndexBindingIT.java
new file mode 100644
index 0000000..31e31fd
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/testsuite/index/LuceneIndexBindingIT.java
@@ -0,0 +1,51 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.testsuite.index;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.index.IndexType;
+import com.google.gerrit.lucene.LuceneChangeIndex;
+import com.google.gerrit.server.index.change.ChangeIndexCollection;
+import javax.inject.Inject;
+import org.junit.AfterClass;
+import org.junit.BeforeClass;
+import org.junit.Test;
+
+/** Test to check that the expected index backend was bound depending on sys/env properties. */
+public class LuceneIndexBindingIT extends AbstractDaemonTest {
+
+  @Inject private ChangeIndexCollection changeIndex;
+
+  private static String propertyBeforeTest;
+
+  @BeforeClass
+  public static void setup() {
+    propertyBeforeTest = System.getProperty(IndexType.SYS_PROP);
+    System.setProperty(IndexType.SYS_PROP, "lucene");
+  }
+
+  @AfterClass
+  public static void teardown() {
+    System.setProperty(IndexType.SYS_PROP, propertyBeforeTest);
+  }
+
+  @Test
+  public void luceneIsBoundWhenConfigured() throws Exception {
+    assertThat(System.getProperty(IndexType.SYS_PROP)).isEqualTo("lucene");
+    assertThat(changeIndex.getSearchIndex()).isInstanceOf(LuceneChangeIndex.class);
+  }
+}
diff --git a/javatests/com/google/gerrit/server/account/externalids/ExternalIDCacheLoaderTest.java b/javatests/com/google/gerrit/server/account/externalids/ExternalIDCacheLoaderTest.java
index 2f64ed0..2b80601 100644
--- a/javatests/com/google/gerrit/server/account/externalids/ExternalIDCacheLoaderTest.java
+++ b/javatests/com/google/gerrit/server/account/externalids/ExternalIDCacheLoaderTest.java
@@ -17,7 +17,7 @@
 import static com.google.common.truth.Truth.assertThat;
 import static org.mockito.Mockito.times;
 import static org.mockito.Mockito.verify;
-import static org.mockito.Mockito.verifyZeroInteractions;
+import static org.mockito.Mockito.verifyNoInteractions;
 
 import com.google.common.cache.Cache;
 import com.google.common.cache.CacheBuilder;
@@ -80,7 +80,7 @@
     externalIdCache.put(firstState, allFromGit(firstState));
 
     assertThat(loader.load(head)).isEqualTo(allFromGit(head));
-    verifyZeroInteractions(externalIdReaderSpy);
+    verifyNoInteractions(externalIdReaderSpy);
   }
 
   @Test
@@ -92,7 +92,7 @@
     externalIdCache.put(firstState, allFromGit(firstState));
 
     assertThat(loader.load(head)).isEqualTo(allFromGit(head));
-    verifyZeroInteractions(externalIdReaderSpy);
+    verifyNoInteractions(externalIdReaderSpy);
   }
 
   @Test
@@ -142,7 +142,7 @@
     externalIdCache.put(firstState, allFromGit(firstState));
 
     assertThat(loader.load(head)).isEqualTo(allFromGit(head));
-    verifyZeroInteractions(externalIdReaderSpy);
+    verifyNoInteractions(externalIdReaderSpy);
   }
 
   @Test
@@ -156,7 +156,7 @@
     externalIdCache.put(firstState, allFromGit(firstState));
 
     assertThat(loader.load(head)).isEqualTo(allFromGit(head));
-    verifyZeroInteractions(externalIdReaderSpy);
+    verifyNoInteractions(externalIdReaderSpy);
   }
 
   @Test
@@ -173,7 +173,7 @@
     externalIdCache.put(firstState, allFromGit(firstState));
 
     assertThat(loader.load(head)).isEqualTo(allFromGit(head));
-    verifyZeroInteractions(externalIdReaderSpy);
+    verifyNoInteractions(externalIdReaderSpy);
   }
 
   @Test
@@ -186,7 +186,7 @@
     externalIdCache.put(oldState, allFromGit(oldState));
 
     assertThat(loader.load(head)).isEqualTo(allFromGit(head));
-    verifyZeroInteractions(externalIdReaderSpy);
+    verifyNoInteractions(externalIdReaderSpy);
   }
 
   @Test
@@ -200,7 +200,7 @@
     externalIdCache.put(oldState, allFromGit(oldState));
 
     assertThat(loader.load(head)).isEqualTo(allFromGit(head));
-    verifyZeroInteractions(externalIdReaderSpy);
+    verifyNoInteractions(externalIdReaderSpy);
   }
 
   private ExternalIdCacheLoader createLoader(boolean allowPartial) {
diff --git a/javatests/com/google/gerrit/server/mail/send/FromAddressGeneratorProviderTest.java b/javatests/com/google/gerrit/server/mail/send/FromAddressGeneratorProviderTest.java
index f10a281..5980071 100644
--- a/javatests/com/google/gerrit/server/mail/send/FromAddressGeneratorProviderTest.java
+++ b/javatests/com/google/gerrit/server/mail/send/FromAddressGeneratorProviderTest.java
@@ -18,7 +18,7 @@
 import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.verify;
-import static org.mockito.Mockito.verifyZeroInteractions;
+import static org.mockito.Mockito.verifyNoInteractions;
 import static org.mockito.Mockito.when;
 
 import com.google.gerrit.entities.Account;
@@ -125,7 +125,7 @@
     assertThat(r).isNotNull();
     assertThat(r.name()).isEqualTo(ident.getName());
     assertThat(r.email()).isEqualTo(ident.getEmailAddress());
-    verifyZeroInteractions(accountCache);
+    verifyNoInteractions(accountCache);
   }
 
   @Test
@@ -229,7 +229,7 @@
     assertThat(r).isNotNull();
     assertThat(r.name()).isEqualTo(ident.getName());
     assertThat(r.email()).isEqualTo(ident.getEmailAddress());
-    verifyZeroInteractions(accountCache);
+    verifyNoInteractions(accountCache);
   }
 
   @Test
@@ -239,7 +239,7 @@
     assertThat(r).isNotNull();
     assertThat(r.name()).isEqualTo(ident.getName());
     assertThat(r.email()).isEqualTo(ident.getEmailAddress());
-    verifyZeroInteractions(accountCache);
+    verifyNoInteractions(accountCache);
   }
 
   @Test
@@ -304,7 +304,7 @@
     assertThat(r).isNotNull();
     assertThat(r.name()).isEqualTo(ident.getName());
     assertThat(r.email()).isEqualTo(ident.getEmailAddress());
-    verifyZeroInteractions(accountCache);
+    verifyNoInteractions(accountCache);
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java b/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java
index feff89c..ecdb03d 100644
--- a/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java
+++ b/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java
@@ -36,6 +36,10 @@
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.entities.SubmitRecord;
+import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.entities.SubmitRequirementExpression;
+import com.google.gerrit.entities.SubmitRequirementExpressionResult;
+import com.google.gerrit.entities.SubmitRequirementResult;
 import com.google.gerrit.entities.converter.ChangeMessageProtoConverter;
 import com.google.gerrit.entities.converter.PatchSetApprovalProtoConverter;
 import com.google.gerrit.entities.converter.PatchSetProtoConverter;
@@ -52,6 +56,9 @@
 import com.google.gerrit.server.cache.proto.Cache.ChangeNotesStateProto.ReviewerByEmailSetEntryProto;
 import com.google.gerrit.server.cache.proto.Cache.ChangeNotesStateProto.ReviewerSetEntryProto;
 import com.google.gerrit.server.cache.proto.Cache.ChangeNotesStateProto.ReviewerStatusUpdateProto;
+import com.google.gerrit.server.cache.proto.Cache.SubmitRequirementExpressionResultProto;
+import com.google.gerrit.server.cache.proto.Cache.SubmitRequirementProto;
+import com.google.gerrit.server.cache.proto.Cache.SubmitRequirementResultProto;
 import com.google.gerrit.server.cache.serialize.ObjectIdConverter;
 import com.google.gerrit.server.notedb.ChangeNotesState.ChangeColumns;
 import com.google.gerrit.server.notedb.ChangeNotesState.Serializer;
@@ -671,6 +678,69 @@
   }
 
   @Test
+  public void serializeSubmitRequirementsResult() throws Exception {
+    assertRoundTrip(
+        newBuilder()
+            .submitRequirementsResult(
+                ImmutableList.of(
+                    SubmitRequirementResult.builder()
+                        .patchSetCommitId(
+                            ObjectId.fromString("26e50c7d315a33a13e5cc00902781fa876bc36cd"))
+                        .submitRequirement(
+                            SubmitRequirement.builder()
+                                .setName("Code-Review")
+                                .setApplicabilityExpression(
+                                    SubmitRequirementExpression.of("project:foo"))
+                                .setSubmittabilityExpression(
+                                    SubmitRequirementExpression.create("label:code-review=+2"))
+                                .setAllowOverrideInChildProjects(false)
+                                .build())
+                        .applicabilityExpressionResult(
+                            Optional.of(
+                                SubmitRequirementExpressionResult.create(
+                                    SubmitRequirementExpression.create("project:foo"),
+                                    SubmitRequirementExpressionResult.Status.PASS,
+                                    ImmutableList.of("project:foo"),
+                                    ImmutableList.of())))
+                        .submittabilityExpressionResult(
+                            SubmitRequirementExpressionResult.create(
+                                SubmitRequirementExpression.create("label:code-review=+2"),
+                                SubmitRequirementExpressionResult.Status.FAIL,
+                                ImmutableList.of(),
+                                ImmutableList.of("label:code-review=+2")))
+                        .build()))
+            .build(),
+        newProtoBuilder()
+            .addSubmitRequirementResult(
+                SubmitRequirementResultProto.newBuilder()
+                    .setCommit(
+                        ObjectIdConverter.create()
+                            .toByteString(
+                                ObjectId.fromString("26e50c7d315a33a13e5cc00902781fa876bc36cd")))
+                    .setSubmitRequirement(
+                        SubmitRequirementProto.newBuilder()
+                            .setName("Code-Review")
+                            .setApplicabilityExpression("project:foo")
+                            .setSubmittabilityExpression("label:code-review=+2")
+                            .setAllowOverrideInChildProjects(false)
+                            .build())
+                    .setApplicabilityExpressionResult(
+                        SubmitRequirementExpressionResultProto.newBuilder()
+                            .setExpression("project:foo")
+                            .setStatus("PASS")
+                            .addPassingAtoms("project:foo")
+                            .build())
+                    .setSubmittabilityExpressionResult(
+                        SubmitRequirementExpressionResultProto.newBuilder()
+                            .setExpression("label:code-review=+2")
+                            .setStatus("FAIL")
+                            .addFailingAtoms("label:code-review=+2")
+                            .build())
+                    .build())
+            .build());
+  }
+
+  @Test
   public void serializeAssigneeUpdates() throws Exception {
     assertRoundTrip(
         newBuilder()
@@ -842,6 +912,9 @@
                 .put(
                     "publishedComments",
                     new TypeLiteral<ImmutableListMultimap<ObjectId, HumanComment>>() {}.getType())
+                .put(
+                    "submitRequirementsResult",
+                    new TypeLiteral<ImmutableList<SubmitRequirementResult>>() {}.getType())
                 .put("updateCount", int.class)
                 .put("mergedOn", Timestamp.class)
                 .build());
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java b/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java
index fa37704..858a9bb 100644
--- a/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java
+++ b/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java
@@ -56,7 +56,6 @@
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.ReviewerSet;
-import com.google.gerrit.server.config.GerritServerId;
 import com.google.gerrit.server.notedb.ChangeNotesCommit.ChangeNotesRevWalk;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.gerrit.server.validators.ValidationException;
@@ -81,8 +80,6 @@
 
   @Inject private ChangeNoteJson changeNoteJson;
 
-  @Inject private @GerritServerId String serverId;
-
   @Test
   public void tagChangeMessage() throws Exception {
     String tag = "jenkins";
diff --git a/javatests/com/google/gerrit/server/patch/DiffOperationsTest.java b/javatests/com/google/gerrit/server/patch/DiffOperationsTest.java
index 5bf5154..aa313e3 100644
--- a/javatests/com/google/gerrit/server/patch/DiffOperationsTest.java
+++ b/javatests/com/google/gerrit/server/patch/DiffOperationsTest.java
@@ -72,7 +72,7 @@
 
     FileDiffOutput diffOutput =
         diffOperations.getModifiedFileAgainstParent(
-            testProjectName, newCommitId, /* parentNum=*/ null, fileName2, /* whitespace=*/ null);
+            testProjectName, newCommitId, /* parentNum=*/ 0, fileName2, /* whitespace=*/ null);
 
     assertThat(diffOutput.oldCommitId()).isEqualTo(oldCommitId);
     assertThat(diffOutput.newCommitId()).isEqualTo(newCommitId);
diff --git a/javatests/com/google/gerrit/server/project/ProjectConfigTest.java b/javatests/com/google/gerrit/server/project/ProjectConfigTest.java
index 2e934e9..9130d3e 100644
--- a/javatests/com/google/gerrit/server/project/ProjectConfigTest.java
+++ b/javatests/com/google/gerrit/server/project/ProjectConfigTest.java
@@ -209,15 +209,15 @@
             .add("groups", group(developers))
             .add(
                 "project.config",
-                "[submitRequirement \"Code-review\"]\n"
+                "[submit-requirement \"Code-review\"]\n"
                     + "  description =  At least one Code Review +2\n"
-                    + "  applicabilityExpression = branch(refs/heads/master)\n"
-                    + "  submittabilityExpression = label(code-review, +2)\n"
-                    + "[submitRequirement \"api-review\"]\n"
+                    + "  applicableIf =branch(refs/heads/master)\n"
+                    + "  submittableIf =  label(code-review, +2)\n"
+                    + "[submit-requirement \"api-review\"]\n"
                     + "  description =  Additional review required for API modifications\n"
-                    + "  applicabilityExpression = commit_filepath_contains(\\\"/api/.*\\\")\n"
-                    + "  submittabilityExpression = label(api-review, +2)\n"
-                    + "  overrideExpression = label(build-cop-override, +1)\n"
+                    + "  applicableIf =commit_filepath_contains(\\\"/api/.*\\\")\n"
+                    + "  submittableIf =  label(api-review, +2)\n"
+                    + "  overrideIf =  label(build-cop-override, +1)\n"
                     + "  canOverrideInChildProjects = true\n")
             .create();
 
@@ -257,8 +257,8 @@
             .add("groups", group(developers))
             .add(
                 "project.config",
-                "[submitRequirement \"code-review\"]\n"
-                    + "  submittabilityExpression = label(code-review, +2)\n")
+                "[submit-requirement \"code-review\"]\n"
+                    + "  submittableIf =  label(code-review, +2)\n")
             .create();
 
     ProjectConfig cfg = read(rev);
@@ -281,12 +281,12 @@
             .add("groups", group(developers))
             .add(
                 "project.config",
-                "[submitRequirement \"code-review\"]\n"
+                "[submit-requirement \"code-review\"]\n"
                     + "  description = At least one Code Review +2\n"
-                    + "  submittabilityExpression = label(code-review, +2)\n"
-                    + "[submitRequirement \"Code-Review\"]\n"
+                    + "  submittableIf =  label(code-review, +2)\n"
+                    + "[submit-requirement \"Code-Review\"]\n"
                     + "  description = Another code review label\n"
-                    + "  submittabilityExpression = label(code-review, +2)\n"
+                    + "  submittableIf =  label(code-review, +2)\n"
                     + "  canOverrideInChildProjects = true\n")
             .create();
 
@@ -317,8 +317,8 @@
             .add("groups", group(developers))
             .add(
                 "project.config",
-                "[submitRequirement \"code-review\"]\n"
-                    + "  applicabilityExpression = label(code-review, +2)\n")
+                "[submit-requirement \"code-review\"]\n"
+                    + "  applicableIf =label(code-review, +2)\n")
             .create();
 
     ProjectConfig cfg = read(rev);
@@ -943,10 +943,10 @@
         tr.commit()
             .add(
                 "project.config",
-                "[submitRequirement \"code-review\"]\n"
+                "[submit-requirement \"code-review\"]\n"
                     + "  description =  At least one Code Review +2\n"
-                    + "  applicabilityExpression = branch(refs/heads/master)\n"
-                    + "  submittabilityExpression = label(code-review, +2)\n"
+                    + "  applicableIf =branch(refs/heads/master)\n"
+                    + "  submittableIf =  label(code-review, +2)\n"
                     + "[notify \"name\"]\n"
                     + "  email = example@example.com\n")
             .create();
diff --git a/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
index 3c10fbc..1f29f45 100644
--- a/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
+++ b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
@@ -1007,6 +1007,7 @@
     changes.put(-1, reviewMinus1Change);
     changes.put(-2, reviewMinus2Change);
 
+    assertQuery("label:Code-Review=MIN", reviewMinus2Change);
     assertQuery("label:Code-Review=-2", reviewMinus2Change);
     assertQuery("label:Code-Review-2", reviewMinus2Change);
     assertQuery("label:Code-Review=-1", reviewMinus1Change);
@@ -1018,6 +1019,13 @@
     assertQuery("label:Code-Review=+2", reviewPlus2Change);
     assertQuery("label:Code-Review=2", reviewPlus2Change);
     assertQuery("label:Code-Review+2", reviewPlus2Change);
+    assertQuery("label:Code-Review=MAX", reviewPlus2Change);
+    assertQuery(
+        "label:Code-Review=ANY",
+        reviewPlus2Change,
+        reviewPlus1Change,
+        reviewMinus1Change,
+        reviewMinus2Change);
 
     assertQuery("label:Code-Review>-3", codeReviewInRange(changes, -2, 2));
     assertQuery("label:Code-Review>=-2", codeReviewInRange(changes, -2, 2));
diff --git a/javatests/com/google/gerrit/server/schema/SchemaCreatorImplTest.java b/javatests/com/google/gerrit/server/schema/SchemaCreatorImplTest.java
index fc6b412..9cba362 100644
--- a/javatests/com/google/gerrit/server/schema/SchemaCreatorImplTest.java
+++ b/javatests/com/google/gerrit/server/schema/SchemaCreatorImplTest.java
@@ -72,7 +72,7 @@
 
   @Test
   public void createSchema_Label_CodeReview() throws Exception {
-    LabelType codeReview = getLabelTypes().byLabel("Code-Review");
+    LabelType codeReview = getLabelTypes().byLabel("Code-Review").get();
     assertThat(codeReview).isNotNull();
     assertThat(codeReview.getName()).isEqualTo("Code-Review");
     assertThat(codeReview.getDefaultValue()).isEqualTo(0);
diff --git a/plugins/replication b/plugins/replication
index dc9bb2e..46cfb7d 160000
--- a/plugins/replication
+++ b/plugins/replication
@@ -1 +1 @@
-Subproject commit dc9bb2e946e4c6c31e8a4665f30eca6d00017523
+Subproject commit 46cfb7dd5b6891f991cfe66e72c08953487c1c81
diff --git a/plugins/reviewnotes b/plugins/reviewnotes
index 35e6449..a28ae59 160000
--- a/plugins/reviewnotes
+++ b/plugins/reviewnotes
@@ -1 +1 @@
-Subproject commit 35e6449a517691a880c94e7467bc07360f8e6666
+Subproject commit a28ae590486934690e4e0a95d7eb75f8b60644a6
diff --git a/plugins/webhooks b/plugins/webhooks
index 9fc9c2d..73f9dc7 160000
--- a/plugins/webhooks
+++ b/plugins/webhooks
@@ -1 +1 @@
-Subproject commit 9fc9c2d4e69f7e2701cbcd873977d3312a231a81
+Subproject commit 73f9dc72bd52f5d64853db31e711717a995f0a46
diff --git a/polygerrit-ui/app/BUILD b/polygerrit-ui/app/BUILD
index 36fc456..4d0bb1f 100644
--- a/polygerrit-ui/app/BUILD
+++ b/polygerrit-ui/app/BUILD
@@ -109,7 +109,6 @@
     "elements/checks/gr-hovercard-run_html.ts",
     "elements/core/gr-main-header/gr-main-header_html.ts",
     "elements/core/gr-search-bar/gr-search-bar_html.ts",
-    "elements/core/gr-smart-search/gr-smart-search_html.ts",
     "elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog_html.ts",
     "elements/diff/gr-diff-builder/gr-diff-builder-element_html.ts",
     "elements/diff/gr-diff-host/gr-diff-host_html.ts",
@@ -118,32 +117,19 @@
     "elements/diff/gr-diff-view/gr-diff-view_html.ts",
     "elements/diff/gr-diff/gr-diff_html.ts",
     "elements/diff/gr-patch-range-select/gr-patch-range-select_html.ts",
-    "elements/documentation/gr-documentation-search/gr-documentation-search_html.ts",
-    "elements/edit/gr-edit-controls/gr-edit-controls_html.ts",
-    "elements/edit/gr-edit-file-controls/gr-edit-file-controls_html.ts",
     "elements/gr-app-element_html.ts",
-    "elements/settings/gr-cla-view/gr-cla-view_html.ts",
-    "elements/settings/gr-edit-preferences/gr-edit-preferences_html.ts",
-    "elements/settings/gr-email-editor/gr-email-editor_html.ts",
-    "elements/settings/gr-identities/gr-identities_html.ts",
-    "elements/settings/gr-registration-dialog/gr-registration-dialog_html.ts",
     "elements/settings/gr-settings-view/gr-settings-view_html.ts",
     "elements/settings/gr-watched-projects-editor/gr-watched-projects-editor_html.ts",
-    "elements/shared/gr-account-entry/gr-account-entry_html.ts",
-    "elements/shared/gr-account-label/gr-account-label_html.ts",
     "elements/shared/gr-account-list/gr-account-list_html.ts",
     "elements/shared/gr-autocomplete/gr-autocomplete_html.ts",
-    "elements/shared/gr-change-status/gr-change-status_html.ts",
     "elements/shared/gr-comment-thread/gr-comment-thread_html.ts",
     "elements/shared/gr-comment/gr-comment_html.ts",
     "elements/shared/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog_html.ts",
-    "elements/shared/gr-copy-clipboard/gr-copy-clipboard_html.ts",
     "elements/shared/gr-dialog/gr-dialog_html.ts",
     "elements/shared/gr-diff-preferences/gr-diff-preferences_html.ts",
     "elements/shared/gr-download-commands/gr-download-commands_html.ts",
     "elements/shared/gr-dropdown-list/gr-dropdown-list_html.ts",
     "elements/shared/gr-dropdown/gr-dropdown_html.ts",
-    "elements/shared/gr-editable-content/gr-editable-content_html.ts",
     "elements/shared/gr-hovercard-account/gr-hovercard-account_html.ts",
     "elements/shared/gr-label-info/gr-label-info_html.ts",
     "elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete_html.ts",
diff --git a/polygerrit-ui/app/api/annotation.ts b/polygerrit-ui/app/api/annotation.ts
index ad0846d..5922e5e 100644
--- a/polygerrit-ui/app/api/annotation.ts
+++ b/polygerrit-ui/app/api/annotation.ts
@@ -15,6 +15,7 @@
  * limitations under the License.
  */
 import {CoverageRange, Side} from './diff';
+import {ChangeInfo} from './rest-api';
 
 /**
  * This is the callback object that Gerrit calls once for each diff. Gerrit
@@ -26,15 +27,8 @@
   path: string,
   basePatchNum?: number,
   patchNum?: number,
-  /**
-   * This is a ChangeInfo object as defined here:
-   * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#change-info
-   * At the moment we neither want to repeat it nor add a dependency on it here.
-   * TODO: Create a dedicated smaller object for exposing a change in the plugin
-   * API. Or allow the plugin API to depend on the entire rest API.
-   */
-  change?: unknown
-) => Promise<Array<CoverageRange>>;
+  change?: ChangeInfo
+) => Promise<Array<CoverageRange> | undefined>;
 
 export declare interface AnnotationPluginApi {
   /**
diff --git a/polygerrit-ui/app/api/checks.ts b/polygerrit-ui/app/api/checks.ts
index 06f9509..b64cd91 100644
--- a/polygerrit-ui/app/api/checks.ts
+++ b/polygerrit-ui/app/api/checks.ts
@@ -15,6 +15,7 @@
  * limitations under the License.
  */
 import {CommentRange} from './core';
+import {ChangeInfo} from './rest-api';
 
 export declare interface ChecksPluginApi {
   /**
@@ -61,9 +62,8 @@
   patchsetNumber: number;
   patchsetSha: string;
   repo: string;
-  commmitMessage?: string;
-  /* TODO(brohlfs): Add dep to Rest API types and replace type by ChangeInfo. */
-  changeInfo: unknown;
+  commitMessage?: string;
+  changeInfo: ChangeInfo;
 }
 
 export declare interface ChecksProvider {
diff --git a/polygerrit-ui/app/api/diff.ts b/polygerrit-ui/app/api/diff.ts
index 98cd4dc..4b20072 100644
--- a/polygerrit-ui/app/api/diff.ts
+++ b/polygerrit-ui/app/api/diff.ts
@@ -206,12 +206,17 @@
   font_size: number;
   // TODO: Missing documentation
   show_file_comment_button?: boolean;
+  line_wrapping?: boolean;
 }
 
 export declare interface ImageDiffPreferences {
   automatic_blink?: boolean;
 }
 
+export declare type DiffResponsiveMode =
+  | 'FULL_RESPONSIVE'
+  | 'SHRINK_ONLY'
+  | 'NONE';
 export declare interface RenderPreferences {
   hide_left_side?: boolean;
   disable_context_control_buttons?: boolean;
@@ -219,6 +224,7 @@
   hide_line_length_indicator?: boolean;
   use_block_expansion?: boolean;
   image_diff_prefs?: ImageDiffPreferences;
+  responsive_mode?: DiffResponsiveMode;
 }
 
 /**
diff --git a/polygerrit-ui/app/api/gerrit.ts b/polygerrit-ui/app/api/gerrit.ts
index 275282e..b5a349f 100644
--- a/polygerrit-ui/app/api/gerrit.ts
+++ b/polygerrit-ui/app/api/gerrit.ts
@@ -20,6 +20,7 @@
   interface Window {
     Gerrit: Gerrit;
     VERSION_INFO?: string;
+    ENABLED_EXPERIMENTS?: string[];
   }
 }
 
diff --git a/polygerrit-ui/app/api/hook.ts b/polygerrit-ui/app/api/hook.ts
index 0ac6468..f8a6cc1 100644
--- a/polygerrit-ui/app/api/hook.ts
+++ b/polygerrit-ui/app/api/hook.ts
@@ -14,31 +14,32 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-interface GerritElementExtensions {
+import {ChangeInfo, ConfigInfo, RevisionInfo} from './rest-api';
+
+export interface GerritElementExtensions {
   content?: HTMLElement & {hidden?: boolean};
-  change?: unknown;
-  revision?: unknown;
+  change?: ChangeInfo;
+  revision?: RevisionInfo;
   token?: string;
   repoName?: string;
-  /**
-   * This is a ConfigInfo object as defined here:
-   * https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#config-info
-   * We neither want to repeat it nor add a dependency on it here.
-   */
-  config?: unknown;
+  config?: ConfigInfo;
 }
 
-export type HookCallback = (el: HTMLElement & GerritElementExtensions) => void;
+export type PluginElement = HTMLElement & GerritElementExtensions;
+
+export type HookCallback<T extends PluginElement> = (el: T) => void;
 
 export declare interface RegisterOptions {
+  /** Defaults to empty string. */
   slot?: string;
-  replace: unknown;
+  /** Defaults to false. */
+  replace?: boolean;
 }
 
-export declare interface HookApi {
-  onAttached(callback: HookCallback): HookApi;
+export declare interface HookApi<T extends PluginElement> {
+  onAttached(callback: HookCallback<T>): HookApi<T>;
 
-  onDetached(callback: HookCallback): HookApi;
+  onDetached(callback: HookCallback<T>): HookApi<T>;
 
   getAllAttached(): HTMLElement[];
 
diff --git a/polygerrit-ui/app/api/plugin.ts b/polygerrit-ui/app/api/plugin.ts
index 0c91546..7a56ff7 100644
--- a/polygerrit-ui/app/api/plugin.ts
+++ b/polygerrit-ui/app/api/plugin.ts
@@ -58,22 +58,25 @@
   checks(): ChecksPluginApi;
   eventHelper(element: Node): EventHelperPluginApi;
   getPluginName(): string;
-  hook(endpointName: string, opt_options?: RegisterOptions): HookApi;
+  hook<T extends HTMLElement>(
+    endpointName: string,
+    opt_options?: RegisterOptions
+  ): HookApi<T>;
   // eslint-disable-next-line @typescript-eslint/no-explicit-any
   on(eventName: EventType, target: any): void;
   popup(): Promise<PopupPluginApi>;
   popup(moduleName: string): Promise<PopupPluginApi>;
   popup(moduleName?: string): Promise<PopupPluginApi | null>;
-  registerCustomComponent(
+  registerCustomComponent<T extends HTMLElement>(
     endpointName: string,
     moduleName?: string,
     options?: RegisterOptions
-  ): HookApi;
-  registerDynamicCustomComponent(
+  ): HookApi<T>;
+  registerDynamicCustomComponent<T extends HTMLElement>(
     endpointName: string,
     moduleName?: string,
     options?: RegisterOptions
-  ): HookApi;
+  ): HookApi<T>;
   registerStyleModule(endpoint: string, moduleName: string): void;
   reporting(): ReportingPluginApi;
   restApi(): RestPluginApi;
diff --git a/polygerrit-ui/app/api/rest-api.ts b/polygerrit-ui/app/api/rest-api.ts
index 58d57d3..fe9d00d 100644
--- a/polygerrit-ui/app/api/rest-api.ts
+++ b/polygerrit-ui/app/api/rest-api.ts
@@ -35,6 +35,23 @@
 }
 
 /**
+ * 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',
+}
+
+/**
  * @desc Specifies status for a change
  */
 export enum ChangeStatus {
@@ -59,6 +76,25 @@
 }
 
 /**
+ * @desc Used for server config of accounts
+ */
+export enum DefaultDisplayNameConfig {
+  USERNAME = 'USERNAME',
+  FIRST_NAME = 'FIRST_NAME',
+  FULL_NAME = 'FULL_NAME',
+}
+
+/**
+ * Account fields that are editable
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#auth-info
+ */
+export enum EditableAccountField {
+  FULL_NAME = 'FULL_NAME',
+  USER_NAME = 'USER_NAME',
+  REGISTER_NEW_EMAIL = 'REGISTER_NEW_EMAIL',
+}
+
+/**
  * @desc The status of the file
  */
 export enum FileInfoStatus {
@@ -102,6 +138,16 @@
 }
 
 /**
+ * This setting determines when Gerrit computes if a change is mergeable or not.
+ * https://gerrit-review.googlesource.com/Documentation/config-gerrit.html#change.mergeabilityComputationBehavior
+ */
+export enum MergeabilityComputationBehavior {
+  API_REF_UPDATED_AND_CHANGE_REINDEX = 'API_REF_UPDATED_AND_CHANGE_REINDEX',
+  REF_UPDATED_AND_CHANGE_REINDEX = 'REF_UPDATED_AND_CHANGE_REINDEX',
+  NEVER = 'NEVER',
+}
+
+/**
  * @desc The status of fixing the problem
  */
 export enum ProblemInfoStatus {
@@ -195,6 +241,16 @@
 }
 
 /**
+ * The AccountsConfigInfo entity contains information about Gerrit configuration
+ * from the accounts section.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#accounts-config-info
+ */
+export interface AccountsConfigInfo {
+  visibility: string;
+  default_display_name: DefaultDisplayNameConfig;
+}
+
+/**
  * The ActionInfo entity describes a REST API call the client can make to
  * manipulate a resource. These are frequently implemented by plugins and may
  * be discovered at runtime.
@@ -255,6 +311,26 @@
 }
 
 /**
+ * The AuthInfo entity contains information about the authentication
+ * configuration of the Gerrit server.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#auth-info
+ */
+export interface AuthInfo {
+  auth_type: AuthType; // docs incorrectly names it 'type'
+  use_contributor_agreements?: boolean;
+  contributor_agreements?: ContributorAgreementInfo[];
+  editable_account_fields: EditableAccountField[];
+  login_url?: string;
+  login_text?: string;
+  switch_account_url?: string;
+  register_url?: string;
+  register_text?: string;
+  edit_full_name_url?: string;
+  http_password_url?: string;
+  git_basic_auth_policy?: string;
+}
+
+/**
  * The AvartarInfo entity contains information about an avatar image ofan
  * account.
  * https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#avatar-info
@@ -270,6 +346,21 @@
 
 export type BranchName = BrandType<string, '_branchName'>;
 
+/**
+ * The ChangeConfigInfo entity contains information about Gerrit configuration
+ * from the change section.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#change-config-info
+ */
+export interface ChangeConfigInfo {
+  allow_blame?: boolean;
+  large_change: number;
+  update_delay: number;
+  submit_whole_topic?: boolean;
+  disable_private_changes?: boolean;
+  mergeability_computation_behavior: MergeabilityComputationBehavior;
+  enable_assignee: boolean;
+}
+
 export type ChangeId = BrandType<string, '_changeId'>;
 
 /**
@@ -361,6 +452,8 @@
   '_changeSubmissionId'
 >;
 
+export type CloneCommandMap = {[name: string]: string};
+
 /**
  * The CommentLinkInfo entity describes acommentlink.
  * https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#commentlink-info
@@ -454,6 +547,14 @@
   inherited_value?: string;
 }
 
+// https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#contributor-agreement-info
+export interface ContributorAgreementInfo {
+  name: string;
+  description: string;
+  url: string;
+  auto_verify_group?: GroupInfo;
+}
+
 /**
  * LabelInfo when DETAILED_LABELS are requested.
  * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#_fields_set_by_code_detailed_labels_code
@@ -472,6 +573,29 @@
   return !!(label as DetailedLabelInfo).values;
 }
 
+/**
+ * The DownloadInfo entity contains information about supported download
+ * options.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#download-info
+ */
+export interface DownloadInfo {
+  schemes: SchemesInfoMap;
+  archives: string[];
+}
+
+/**
+ * The DownloadSchemeInfo entity contains information about a supported download
+ * scheme and its commands.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html
+ */
+export interface DownloadSchemeInfo {
+  url: string;
+  is_auth_required: boolean;
+  is_auth_supported: boolean;
+  commands: string;
+  clone_commands: CloneCommandMap;
+}
+
 export type EmailAddress = BrandType<string, '_emailAddress'>;
 
 /**
@@ -499,6 +623,22 @@
   size: number; // in bytes
 }
 
+/**
+ * The GerritInfo entity contains information about Gerrit configuration from
+ * the gerrit section.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#gerrit-info
+ */
+export interface GerritInfo {
+  all_projects: string; // Doc contains incorrect name
+  all_users: string; // Doc contains incorrect name
+  doc_search: boolean;
+  doc_url?: string;
+  edit_gpg_keys?: boolean;
+  report_bug_url?: string;
+  // The following property is missed in doc
+  primary_weblink_name?: string;
+}
+
 export type GitRef = BrandType<string, '_gitRef'>;
 // The 40-char (plus spaces) hex GPG key fingerprint
 
@@ -532,6 +672,38 @@
   tz: TimezoneOffset;
 }
 
+export type GroupId = BrandType<string, '_groupId'>;
+
+/**
+ * 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#group-info
+ */
+export interface GroupInfo {
+  id: GroupId;
+  name?: GroupName;
+  url?: string;
+  options?: GroupOptionsInfo;
+  description?: string;
+  group_id?: string;
+  owner?: string;
+  owner_id?: string;
+  created_on?: string;
+  _more_groups?: boolean;
+  members?: AccountInfo[];
+  includes?: GroupInfo[];
+}
+
+export type GroupName = BrandType<string, '_groupName'>;
+
+/**
+ * Options of the group.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-groups.html
+ */
+export interface GroupOptionsInfo {
+  visible_to_all: boolean;
+}
+
 export type Hashtag = BrandType<string, '_hashtag'>;
 
 export type IdToAttentionSetMap = {[accountId: string]: AttentionSetInfo};
@@ -597,6 +769,17 @@
 export type PatchSetNum = BrandType<'PARENT' | 'edit' | number, '_patchSet'>;
 
 /**
+ * The PluginConfigInfo entity contains information about Gerrit extensions by
+ * plugins.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#plugin-config-info
+ */
+export interface PluginConfigInfo {
+  has_avatars: boolean;
+  // Exists in Java class, but not mentioned in docs.
+  js_resource_paths: string[];
+}
+
+/**
  * Plugin configuration values as map which maps the plugin name to a map of parameter names to values
  * https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#config-input
  */
@@ -658,6 +841,15 @@
   );
 }
 
+/**
+ * The ReceiveInfo entity contains information about the configuration of
+ * git-receive-pack behavior on the server.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#receive-info
+ */
+export interface ReceiveInfo {
+  enable_signed_push?: string;
+}
+
 export type RepoName = BrandType<string, '_repoName'>;
 
 /**
@@ -720,6 +912,38 @@
   basePatchNum?: BasePatchSetNum;
 }
 
+export type SchemesInfoMap = {[name: string]: DownloadSchemeInfo};
+
+/**
+ * The ServerInfo entity contains information about the configuration of the
+ * Gerrit server.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#server-info
+ */
+export interface ServerInfo {
+  accounts: AccountsConfigInfo;
+  auth: AuthInfo;
+  change: ChangeConfigInfo;
+  download: DownloadInfo;
+  gerrit: GerritInfo;
+  // docs mentions index property, but it doesn't exists in Java class
+  // index: IndexConfigInfo;
+  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 = {};
+
 export type StarLabel = BrandType<string, '_startLabel'>;
 // Timestamps are given in UTC and have the format
 // "'yyyy-mm-dd hh:mm:ss.fffffffff'"
@@ -736,6 +960,15 @@
   inherited_value: Exclude<SubmitType, SubmitType.INHERIT>;
 }
 
+/**
+ * The SuggestInfo entity contains information about Gerritconfiguration from
+ * the suggest section.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#suggest-info
+ */
+export interface SuggestInfo {
+  from: number;
+}
+
 export type Timestamp = BrandType<string, '_timestamp'>;
 // The timezone offset from UTC in minutes
 
@@ -756,6 +989,15 @@
 }
 
 /**
+ * The UserConfigInfo entity contains information about Gerrit configuration
+ * from the user section.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#user-config-info
+ */
+export interface UserConfigInfo {
+  anonymous_coward_name: string;
+}
+
+/**
  * The VotingRangeInfo entity describes the continuous voting range from minto
  * max values.
  * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#voting-range-info
diff --git a/polygerrit-ui/app/api/rest.ts b/polygerrit-ui/app/api/rest.ts
index 2b91bf6..4c93ef0 100644
--- a/polygerrit-ui/app/api/rest.ts
+++ b/polygerrit-ui/app/api/rest.ts
@@ -78,29 +78,29 @@
   /**
    * Fetch and parse REST API response, if request succeeds.
    */
-  send(
+  send<T>(
     method: HttpMethod,
     url: string,
     payload?: RequestPayload,
     errFn?: ErrorCallback,
     contentType?: string
-  ): Promise<unknown>;
+  ): Promise<T>;
 
-  get(url: string): Promise<unknown>;
+  get<T>(url: string): Promise<T>;
 
-  post(
+  post<T>(
     url: string,
     payload?: RequestPayload,
     errFn?: ErrorCallback,
     contentType?: string
-  ): Promise<unknown>;
+  ): Promise<T>;
 
-  put(
+  put<T>(
     url: string,
     payload?: RequestPayload,
     errFn?: ErrorCallback,
     contentType?: string
-  ): Promise<unknown>;
+  ): Promise<T>;
 
   delete(url: string): Promise<Response>;
 }
diff --git a/polygerrit-ui/app/constants/constants.ts b/polygerrit-ui/app/constants/constants.ts
index 15edd7f..ddd5199 100644
--- a/polygerrit-ui/app/constants/constants.ts
+++ b/polygerrit-ui/app/constants/constants.ts
@@ -23,12 +23,16 @@
 import {EditPreferencesInfo, PreferencesInfo} from '../types/common';
 import {
   AccountTag,
+  AuthType,
   ChangeStatus,
   ConfigParameterInfoType,
+  DefaultDisplayNameConfig,
+  EditableAccountField,
   FileInfoStatus,
   GpgKeyInfoStatus,
   HttpMethod,
   InheritedBooleanInfoConfiguredValue,
+  MergeabilityComputationBehavior,
   ProblemInfoStatus,
   ProjectState,
   RequirementStatus,
@@ -39,12 +43,16 @@
 
 export {
   AccountTag,
+  AuthType,
   ChangeStatus,
   ConfigParameterInfoType,
+  DefaultDisplayNameConfig,
+  EditableAccountField,
   FileInfoStatus,
   GpgKeyInfoStatus,
   HttpMethod,
   InheritedBooleanInfoConfiguredValue,
+  MergeabilityComputationBehavior,
   ProblemInfoStatus,
   ProjectState,
   RequirementStatus,
@@ -115,15 +123,6 @@
   MERGE_LIST = '/MERGE_LIST',
 }
 
-/**
- * @desc Used for server config of accounts
- */
-export enum DefaultDisplayNameConfig {
-  USERNAME = 'USERNAME',
-  FIRST_NAME = 'FIRST_NAME',
-  FULL_NAME = 'FULL_NAME',
-}
-
 export {Side} from '../api/diff';
 
 /**
@@ -247,23 +246,6 @@
 }
 
 /**
- * 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',
-}
-
-/**
  * Controls visibility of other users' dashboard pages and completion suggestions to web users
  * https://gerrit-review.googlesource.com/Documentation/config-gerrit.html#accounts.visibility
  */
@@ -274,26 +256,6 @@
   NONE = 'NONE',
 }
 
-/**
- * Account fields that are editable
- * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#auth-info
- */
-export enum EditableAccountField {
-  FULL_NAME = 'FULL_NAME',
-  USER_NAME = 'USER_NAME',
-  REGISTER_NEW_EMAIL = 'REGISTER_NEW_EMAIL',
-}
-
-/**
- * This setting determines when Gerrit computes if a change is mergeable or not.
- * https://gerrit-review.googlesource.com/Documentation/config-gerrit.html#change.mergeabilityComputationBehavior
- */
-export enum MergeabilityComputationBehavior {
-  API_REF_UPDATED_AND_CHANGE_REINDEX = 'API_REF_UPDATED_AND_CHANGE_REINDEX',
-  REF_UPDATED_AND_CHANGE_REINDEX = 'REF_UPDATED_AND_CHANGE_REINDEX',
-  NEVER = 'NEVER',
-}
-
 // TODO(TS): Many properties are omitted here, but they are required.
 // Add default values for missing properties.
 export function createDefaultPreferences() {
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts
index a0b1794..fce7db9 100644
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts
@@ -1486,6 +1486,7 @@
   }
 
   _handleDeleteConfirm() {
+    this._hideAllDialogs();
     this._fireAction(
       '/',
       assertUIActionInfo(this.actions[ChangeActions.DELETE]),
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_html.ts b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_html.ts
index ca68926..07f9a4a 100644
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_html.ts
@@ -184,7 +184,7 @@
         <gr-account-chip
           account="[[change.owner]]"
           change="[[change]]"
-          highlight-attention
+          highlightAttention
         ></gr-account-chip>
         <template is="dom-if" if="[[_pushCertificateValidation]]">
           <gr-tooltip-content
@@ -213,7 +213,7 @@
         <gr-account-chip
           account="[[_getNonOwnerRole(change, _CHANGE_ROLE.UPLOADER)]]"
           change="[[change]]"
-          highlight-attention
+          highlightAttention
         ></gr-account-chip>
       </span>
     </section>
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
index b78d687..53bdb91 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
@@ -76,8 +76,9 @@
   PatchSet,
 } from '../../../utils/patch-set-util';
 import {
-  changeIsMerged,
   changeIsAbandoned,
+  changeIsMerged,
+  changeIsOpen,
   changeStatuses,
   isCc,
   isOwner,
@@ -104,7 +105,6 @@
   ConfigInfo,
   EditInfo,
   EditPatchSetNum,
-  ElementPropertyDeepChange,
   LabelNameToInfoMap,
   NumericChangeId,
   ParentPatchSetNum,
@@ -131,7 +131,6 @@
 import {GrEditControls} from '../../edit/gr-edit-controls/gr-edit-controls';
 import {
   CommentThread,
-  DraftInfo,
   isDraftThread,
   isRobot,
   isUnresolved,
@@ -164,7 +163,6 @@
   ShowAlertEventDetail,
   SwitchTabEvent,
   TabState,
-  ThreadListModifiedEvent,
 } from '../../../types/events';
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {GrMessagesList} from '../gr-messages-list/gr-messages-list';
@@ -185,6 +183,10 @@
 import {Interaction, Timing} from '../../../constants/reporting';
 import {ChangeStates} from '../../shared/gr-change-status/gr-change-status';
 import {getRevertCreatedChangeIds} from '../../../utils/message-util';
+import {
+  changeComments$,
+  drafts$,
+} from '../../../services/comments/comments-model';
 
 const MIN_LINES_FOR_COMMIT_COLLAPSE = 18;
 
@@ -419,7 +421,7 @@
 
   @property({
     type: String,
-    computed: '_computeReplyButtonLabel(_diffDrafts.*, _canStartReview)',
+    computed: '_computeReplyButtonLabel(_diffDrafts, _canStartReview)',
   })
   _replyButtonLabel = 'Reply';
 
@@ -549,6 +551,8 @@
 
   restApiService = appContext.restApiService;
 
+  private readonly commentsService = appContext.commentsService;
+
   private replyDialogResizeObserver?: ResizeObserver;
 
   keyboardShortcuts() {
@@ -590,6 +594,14 @@
     routerView$.pipe(takeUntil(this.disconnected$)).subscribe(view => {
       this.isViewCurrent = view === GerritView.CHANGE;
     });
+    drafts$.pipe(takeUntil(this.disconnected$)).subscribe(drafts => {
+      this._diffDrafts = {...drafts};
+    });
+    changeComments$
+      .pipe(takeUntil(this.disconnected$))
+      .subscribe(changeComments => {
+        this._changeComments = changeComments;
+      });
   }
 
   constructor() {
@@ -609,15 +621,6 @@
       this._handleShowBackgroundContent()
     );
 
-    this.addEventListener('diff-comments-modified', () =>
-      this._handleReloadCommentThreads()
-    );
-
-    this.addEventListener(
-      'thread-list-modified',
-      (e: ThreadListModifiedEvent) => this._handleReloadDiffComments(e)
-    );
-
     this.addEventListener('open-reply-dialog', () => this._openReplyDialog());
   }
 
@@ -665,11 +668,6 @@
       })
       .then(() => this._initActiveTabs(this.params));
 
-    this.addEventListener('comment-save', e => this._handleCommentSave(e));
-    this.addEventListener('comment-refresh', () => this._reloadDrafts());
-    this.addEventListener('comment-discard', e =>
-      this._handleCommentDiscard(e)
-    );
     this.addEventListener('change-message-deleted', () => fireReload(this));
     this.addEventListener('editable-content-save', e =>
       this._handleCommitMessageSave(e)
@@ -679,7 +677,6 @@
     );
     this.addEventListener('open-fix-preview', e => this._onOpenFixPreview(e));
     this.addEventListener('close-fix-preview', e => this._onCloseFixPreview(e));
-    window.addEventListener('scroll', this.handleScroll);
     document.addEventListener('visibilitychange', this.handleVisibilityChange);
 
     this.addEventListener(EventType.SHOW_PRIMARY_TAB, e =>
@@ -696,7 +693,6 @@
   /** @override */
   disconnectedCallback() {
     this.disconnected$.next();
-    window.removeEventListener('scroll', this.handleScroll);
     document.removeEventListener(
       'visibilitychange',
       this.handleVisibilityChange
@@ -1033,30 +1029,6 @@
     );
   }
 
-  _handleReloadCommentThreads() {
-    // Get any new drafts that have been saved in the diff view and show
-    // in the comment thread view.
-    this._reloadDrafts().then(() => {
-      this._commentThreads = this._changeComments?.getAllThreadsForChange();
-      flush();
-    });
-  }
-
-  _handleReloadDiffComments(
-    e: CustomEvent<{rootId: UrlEncodedCommentId; path: string}>
-  ) {
-    // Keeps the file list counts updated.
-    this._reloadDrafts().then(() => {
-      // Get any new drafts that have been saved in the thread view and show
-      // in the diff view.
-      this.$.fileList.reloadCommentsForThreadWithRootId(
-        e.detail.rootId,
-        e.detail.path
-      );
-      flush();
-    });
-  }
-
   _computeTotalCommentCounts(
     unresolvedCount: number,
     changeComments: ChangeComments
@@ -1075,79 +1047,6 @@
     );
   }
 
-  _handleCommentSave(e: CustomEvent<{comment: DraftInfo}>) {
-    const draft = e.detail.comment;
-    if (!draft.__draft || !draft.path) return;
-    if (!this._patchRange)
-      throw new Error('missing required _patchRange property');
-
-    draft.patch_set = draft.patch_set || this._patchRange.patchNum;
-
-    // The use of path-based notification helpers (set, push) can’t be used
-    // because the paths could contain dots in them. A new object must be
-    // created to satisfy Polymer’s dirty checking.
-    // https://github.com/Polymer/polymer/issues/3127
-    const diffDrafts = {...this._diffDrafts};
-    if (!diffDrafts[draft.path]) {
-      diffDrafts[draft.path] = [draft];
-      this._diffDrafts = diffDrafts;
-      return;
-    }
-    for (let i = 0; i < diffDrafts[draft.path].length; i++) {
-      if (diffDrafts[draft.path][i].id === draft.id) {
-        diffDrafts[draft.path][i] = draft;
-        this._diffDrafts = diffDrafts;
-        return;
-      }
-    }
-    diffDrafts[draft.path].push(draft);
-    diffDrafts[draft.path].sort(
-      (c1, c2) =>
-        // No line number means that it’s a file comment. Sort it above the
-        // others.
-        (c1.line || -1) - (c2.line || -1)
-    );
-    this._diffDrafts = diffDrafts;
-  }
-
-  _handleCommentDiscard(e: CustomEvent<{comment: DraftInfo}>) {
-    const draft = e.detail.comment;
-    if (!draft.__draft || !draft.path) {
-      return;
-    }
-
-    if (!this._diffDrafts || !this._diffDrafts[draft.path]) {
-      return;
-    }
-    let index = -1;
-    for (let i = 0; i < this._diffDrafts[draft.path].length; i++) {
-      if (this._diffDrafts[draft.path][i].id === draft.id) {
-        index = i;
-        break;
-      }
-    }
-    if (index === -1) {
-      // It may be a draft that hasn’t been added to _diffDrafts since it was
-      // never saved.
-      return;
-    }
-
-    if (!this._patchRange)
-      throw new Error('missing required _patchRange property');
-    draft.patch_set = draft.patch_set || this._patchRange.patchNum;
-
-    // The use of path-based notification helpers (set, push) can’t be used
-    // because the paths could contain dots in them. A new object must be
-    // created to satisfy Polymer’s dirty checking.
-    // https://github.com/Polymer/polymer/issues/3127
-    const diffDrafts = {...this._diffDrafts};
-    diffDrafts[draft.path].splice(index, 1);
-    if (diffDrafts[draft.path].length === 0) {
-      delete diffDrafts[draft.path];
-    }
-    this._diffDrafts = diffDrafts;
-  }
-
   _handleReplyTap(e: MouseEvent) {
     e.preventDefault();
     this._openReplyDialog(this.$.replyDialog.FocusTarget.ANY);
@@ -1239,14 +1138,6 @@
     this._openReplyDialog(target);
   }
 
-  readonly handleScroll = () => {
-    this.scrollTask = debounce(
-      this.scrollTask,
-      () => (this.viewState.scrollTop = document.body.scrollTop),
-      150
-    );
-  };
-
   _setShownFiles(e: CustomEvent<{length: number}>) {
     this._shownFileCount = e.detail.length;
   }
@@ -1383,11 +1274,7 @@
     this._sendShowChangeEvent();
 
     setTimeout(() => {
-      if (this.viewState.scrollTop) {
-        document.documentElement.scrollTop = document.body.scrollTop = this.viewState.scrollTop;
-      } else {
-        this._maybeScrollToMessage(window.location.hash);
-      }
+      this._maybeScrollToMessage(window.location.hash);
       this._initialLoadComplete = true;
     });
   }
@@ -1507,7 +1394,6 @@
 
   _resetFileListViewState() {
     this.set('viewState.selectedFileIndex', 0);
-    this.set('viewState.scrollTop', 0);
     if (
       !!this.viewState.changeNum &&
       this.viewState.changeNum !== this._changeNum
@@ -1576,17 +1462,13 @@
   }
 
   _computeReplyButtonLabel(
-    changeRecord?: ElementPropertyDeepChange<
-      GrChangeView,
-      '_diffDrafts'
-    > | null,
+    drafts?: {[path: string]: UIDraft[]},
     canStartReview?: boolean
   ) {
-    if (changeRecord === undefined || canStartReview === undefined) {
+    if (drafts === undefined || canStartReview === undefined) {
       return 'Reply';
     }
 
-    const drafts = (changeRecord && changeRecord.base) || {};
     const draftCount = Object.keys(drafts).reduce(
       (count, file) => count + drafts[file].length,
       0
@@ -1872,6 +1754,17 @@
    */
   _processEdit(change: ParsedChangeInfo, edit?: EditInfo | false) {
     if (
+      !edit &&
+      this._patchRange?.patchNum === EditPatchSetNum &&
+      changeIsOpen(change)
+    ) {
+      fireAlert(this, 'Change edit not found. Please create a change edit.');
+      GerritNav.navigateToChange(change);
+      return;
+    }
+
+    if (
+      !edit &&
       (changeIsMerged(change) || changeIsAbandoned(change)) &&
       this._editMode
     ) {
@@ -1986,9 +1879,9 @@
         // Slice returns a number as a string, convert to an int.
         this._lineHeight = Number(lineHeight.slice(0, lineHeight.length - 2));
 
+        this.changeService.updateChange(change);
         this._change = change;
         this.computeRevertSubmitted(change);
-        this.changeService.updateChange(change);
         if (
           !this._patchRange ||
           !this._patchRange.patchNum ||
@@ -2074,22 +1967,37 @@
 
   _getCommitInfo() {
     if (!this._changeNum)
-      throw new Error('missing required changeNum property');
+      throw new Error('missing required _changeNum property');
     if (!this._patchRange)
       throw new Error('missing required _patchRange property');
     if (this._patchRange.patchNum === undefined)
       throw new Error('missing required patchNum property');
+
+    // We only call _getEdit if the patchset number is an edit.
+    // We have to do this to ensure we can tell if an edit
+    // exists or not.
+    // This safely works even if a edit does not exist.
+    if (this._patchRange!.patchNum! === EditPatchSetNum) {
+      return this._getEdit().then(edit => {
+        if (!edit) {
+          return Promise.resolve();
+        }
+
+        return this._getChangeCommitInfo();
+      });
+    }
+
+    return this._getChangeCommitInfo();
+  }
+
+  _getChangeCommitInfo() {
     return this.restApiService
-      .getChangeCommitInfo(this._changeNum, this._patchRange.patchNum)
+      .getChangeCommitInfo(this._changeNum!, this._patchRange!.patchNum!)
       .then(commitInfo => {
         this._commitInfo = commitInfo;
       });
   }
 
-  _reloadDraftsWithCallback(e: CustomEvent<{resolve: () => void}>) {
-    return this._reloadDrafts().then(() => e.detail.resolve());
-  }
-
   /**
    * Fetches a new changeComment object, and data for all types of comments
    * (comments, robot comments, draft comments) is requested.
@@ -2099,38 +2007,18 @@
     // a new change being loaded and then paired with outdated comments.
     this._changeComments = undefined;
     this._commentThreads = undefined;
-    this._diffDrafts = undefined;
     this._draftCommentThreads = undefined;
     this._robotCommentThreads = undefined;
     if (!this._changeNum)
       throw new Error('missing required changeNum property');
 
-    return this.$.commentAPI
-      .loadAll(this._changeNum, this._patchRange?.patchNum)
-      .then(comments => {
-        this._recomputeComments(comments);
-      });
+    this.commentsService.loadAll(this._changeNum, this._patchRange?.patchNum);
   }
 
-  /**
-   * Fetches a new changeComment object, but only updated data for drafts is
-   * requested.
-   *
-   * TODO(taoalpha): clean up this and _reloadComments, as single comment
-   * can be a thread so it does not make sense to only update drafts
-   * without updating threads
-   */
-  _reloadDrafts() {
-    if (!this._changeNum)
-      throw new Error('missing required changeNum property');
-    return this.$.commentAPI
-      .reloadDrafts(this._changeNum)
-      .then(comments => this._recomputeComments(comments));
-  }
-
-  _recomputeComments(comments: ChangeComments) {
+  @observe('_changeComments')
+  changeCommentsChanged(comments?: ChangeComments) {
+    if (!comments) return;
     this._changeComments = comments;
-    this._diffDrafts = {...this._changeComments.drafts};
     this._commentThreads = this._changeComments.getAllThreadsForChange();
     this._draftCommentThreads = this._commentThreads
       .filter(isDraftThread)
@@ -2201,10 +2089,7 @@
     });
     allDataPromises.push(projectConfigLoaded);
 
-    // Resolves when change comments have loaded (comments, drafts and robot
-    // comments).
-    const commentsLoaded = this._reloadComments();
-    allDataPromises.push(commentsLoaded);
+    this._reloadComments();
 
     let coreDataPromise;
 
@@ -2289,6 +2174,7 @@
     }
 
     Promise.all(allDataPromises).then(() => {
+      // Loading of commments data is no longer part of this reporting
       this.reporting.timeEnd(Timing.CHANGE_DATA);
       if (isLocationChange) {
         this.reporting.changeFullyLoaded();
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 8823377..c6975e6 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
@@ -344,7 +344,7 @@
           <span class="headerSubject">[[_change.subject]]</span>
           <gr-copy-clipboard
             class="changeCopyClipboard"
-            hide-input=""
+            hideInput=""
             text="[[_computeCopyTextForTitle(_change)]]"
           >
           </gr-copy-clipboard>
@@ -554,7 +554,6 @@
           file-list-increment="{{_numFilesShown}}"
           on-files-shown-changed="_setShownFiles"
           on-file-action-tap="_handleFileActionTap"
-          on-reload-drafts="_reloadDraftsWithCallback"
           observer-target="[[_computeObserverTarget()]]"
         >
         </gr-file-list>
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts
index 7d47abb..867b3a7 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts
@@ -26,7 +26,6 @@
   HttpMethod,
   MessageTag,
   PrimaryTab,
-  SecondaryTab,
 } from '../../../constants/constants';
 import {GrEditConstants} from '../../edit/gr-edit-constants';
 import {_testOnly_resetEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints';
@@ -71,7 +70,6 @@
   CommitInfo,
   EditInfo,
   EditPatchSetNum,
-  ElementPropertyDeepChange,
   GitRef,
   NumericChangeId,
   ParentPatchSetNum,
@@ -94,13 +92,7 @@
 import {SinonFakeTimers, SinonStubbedMember} from 'sinon/pkg/sinon-esm';
 import {RestApiService} from '../../../services/gr-rest-api/gr-rest-api';
 import {CustomKeyboardEvent} from '../../../types/events';
-import {
-  CommentThread,
-  DraftInfo,
-  UIDraft,
-  UIRobot,
-} from '../../../utils/comment-util';
-import {ChangeComments} from '../../diff/gr-comment-api/gr-comment-api';
+import {CommentThread, UIRobot} from '../../../utils/comment-util';
 import {GerritView} from '../../../services/router/router-model';
 import {ParsedChangeInfo} from '../../../types/types';
 import {GrRelatedChangesList} from '../gr-related-changes-list/gr-related-changes-list';
@@ -136,8 +128,6 @@
     TestKeyboardShortcutBinder.pop();
   });
 
-  const TEST_SCROLL_TOP_PX = 100;
-
   const ROBOT_COMMENTS_LIMIT = 10;
 
   // TODO: should have a mock service to generate VALID fake data
@@ -865,179 +855,6 @@
     });
   });
 
-  suite('reloading drafts', () => {
-    let reloadStub: SinonStubbedMember<
-      typeof element.$.commentAPI.reloadDrafts
-    >;
-    const drafts: {[path: string]: UIDraft[]} = {
-      'testfile.txt': [
-        {
-          patch_set: 5 as PatchSetNum,
-          id: 'dd2982f5_c01c9e6a' as UrlEncodedCommentId,
-          line: 1,
-          updated: '2017-11-08 18:47:45.000000000' as Timestamp,
-          message: 'test',
-          unresolved: true,
-        },
-      ],
-    };
-    setup(() => {
-      // Fake computeDraftCount as its required for ChangeComments,
-      // see gr-comment-api#reloadDrafts.
-      reloadStub = sinon.stub(element.$.commentAPI, 'reloadDrafts').returns(
-        Promise.resolve({
-          drafts,
-          getAllThreadsForChange: () => [] as CommentThread[],
-          computeDraftCount: () => 1,
-        } as ChangeComments)
-      );
-      element._changeNum = 1 as NumericChangeId;
-    });
-
-    test('drafts are reloaded when reload-drafts fired', done => {
-      element.$.fileList.dispatchEvent(
-        new CustomEvent('reload-drafts', {
-          detail: {
-            resolve: () => {
-              assert.isTrue(reloadStub.called);
-              assert.deepEqual(element._diffDrafts, drafts);
-              done();
-            },
-          },
-          composed: true,
-          bubbles: true,
-        })
-      );
-    });
-
-    test('drafts are reloaded when comment-refresh fired', () => {
-      element.dispatchEvent(
-        new CustomEvent('comment-refresh', {
-          composed: true,
-          bubbles: true,
-        })
-      );
-      assert.isTrue(reloadStub.called);
-    });
-  });
-
-  suite('_recomputeComments', () => {
-    setup(() => {
-      element._changeNum = TEST_NUMERIC_CHANGE_ID;
-      element._change = createChangeViewChange();
-      flush();
-      // Fake computeDraftCount as its required for ChangeComments,
-      // see gr-comment-api#reloadDrafts.
-      sinon.stub(element.$.commentAPI, 'reloadDrafts').returns(
-        Promise.resolve({
-          drafts: {},
-          getAllThreadsForChange: () => THREADS,
-          computeDraftCount: () => 0,
-        } as ChangeComments)
-      );
-      element._change = createChangeViewChange();
-      element._changeNum = element._change._number;
-    });
-
-    test('draft threads should be a new copy with correct states', done => {
-      element.$.fileList.dispatchEvent(
-        new CustomEvent('reload-drafts', {
-          detail: {
-            resolve: () => {
-              assert.equal(element._draftCommentThreads!.length, 2);
-              assert.equal(
-                element._draftCommentThreads![0].rootId,
-                THREADS[0].rootId
-              );
-              assert.notEqual(
-                element._draftCommentThreads![0].comments,
-                THREADS[0].comments
-              );
-              assert.notEqual(
-                element._draftCommentThreads![0].comments[0],
-                THREADS[0].comments[0]
-              );
-              assert.isTrue(
-                element
-                  ._draftCommentThreads![0].comments.slice(0, 2)
-                  .every(c => c.collapsed === true)
-              );
-
-              assert.isTrue(
-                element._draftCommentThreads![0].comments[2].collapsed === false
-              );
-              done();
-            },
-          },
-          composed: true,
-          bubbles: true,
-        })
-      );
-    });
-  });
-
-  test('diff comments modified', () => {
-    const reloadThreadsSpy = sinon.spy(element, '_handleReloadCommentThreads');
-    return element._reloadComments().then(() => {
-      element.dispatchEvent(
-        new CustomEvent('diff-comments-modified', {
-          composed: true,
-          bubbles: true,
-        })
-      );
-      assert.isTrue(reloadThreadsSpy.called);
-    });
-  });
-
-  test('thread list modified', () => {
-    const reloadDiffSpy = sinon.spy(element, '_handleReloadDiffComments');
-    element._activeTabs = [PrimaryTab.COMMENT_THREADS, SecondaryTab.CHANGE_LOG];
-    flush();
-
-    return element._reloadComments().then(() => {
-      element.threadList!.dispatchEvent(
-        new CustomEvent('thread-list-modified', {
-          composed: true,
-          bubbles: true,
-        })
-      );
-      assert.isTrue(reloadDiffSpy.called);
-
-      let draftStub = sinon
-        .stub(element._changeComments!, 'computeDraftCount')
-        .returns(1);
-      assert.equal(
-        element._computeTotalCommentCounts(5, element._changeComments!),
-        '5 unresolved, 1 draft'
-      );
-      assert.equal(
-        element._computeTotalCommentCounts(0, element._changeComments!),
-        '1 draft'
-      );
-      draftStub.restore();
-      draftStub = sinon
-        .stub(element._changeComments!, 'computeDraftCount')
-        .returns(0);
-      assert.equal(
-        element._computeTotalCommentCounts(0, element._changeComments!),
-        ''
-      );
-      assert.equal(
-        element._computeTotalCommentCounts(1, element._changeComments!),
-        '1 unresolved'
-      );
-      draftStub.restore();
-      draftStub = sinon
-        .stub(element._changeComments!, 'computeDraftCount')
-        .returns(2);
-      assert.equal(
-        element._computeTotalCommentCounts(1, element._changeComments!),
-        '1 unresolved, 2 drafts'
-      );
-      draftStub.restore();
-    });
-  });
-
   suite('thread list and change log tabs', () => {
     setup(() => {
       element._changeNum = TEST_NUMERIC_CHANGE_ID;
@@ -1458,66 +1275,18 @@
   test('reply button has updated count when there are drafts', () => {
     const getLabel = element._computeReplyButtonLabel;
 
-    assert.equal(getLabel(null, false), 'Reply');
-    assert.equal(getLabel(null, true), 'Start Review');
+    assert.equal(getLabel(undefined, false), 'Reply');
+    assert.equal(getLabel(undefined, true), 'Reply');
 
-    const changeRecord: ElementPropertyDeepChange<
-      GrChangeView,
-      '_diffDrafts'
-    > = {base: undefined, path: '', value: undefined};
-    assert.equal(getLabel(changeRecord, false), 'Reply');
+    let drafts = {};
+    assert.equal(getLabel(drafts, false), 'Reply');
 
-    changeRecord.base = {};
-    assert.equal(getLabel(changeRecord, false), 'Reply');
-
-    changeRecord.base = {
+    drafts = {
       'file1.txt': [{}],
       'file2.txt': [{}, {}],
     };
-    assert.equal(getLabel(changeRecord, false), 'Reply (3)');
-    assert.equal(getLabel(changeRecord, true), 'Start Review (3)');
-  });
-
-  test('comment events properly update diff drafts', () => {
-    element._patchRange = {
-      basePatchNum: ParentPatchSetNum,
-      patchNum: 2 as RevisionPatchSetNum,
-    };
-    const draft: DraftInfo = {
-      __draft: true,
-      id: 'id1' as UrlEncodedCommentId,
-      path: '/foo/bar.txt',
-      message: 'hello',
-    };
-    element._handleCommentSave(new CustomEvent('', {detail: {comment: draft}}));
-    draft.patch_set = 2 as PatchSetNum;
-    assert.deepEqual(element._diffDrafts, {'/foo/bar.txt': [draft]});
-    draft.patch_set = undefined;
-    draft.message = 'hello, there';
-    element._handleCommentSave(new CustomEvent('', {detail: {comment: draft}}));
-    draft.patch_set = 2 as PatchSetNum;
-    assert.deepEqual(element._diffDrafts, {'/foo/bar.txt': [draft]});
-    const draft2: DraftInfo = {
-      __draft: true,
-      id: 'id2' as UrlEncodedCommentId,
-      path: '/foo/bar.txt',
-      message: 'hola',
-    };
-    element._handleCommentSave(
-      new CustomEvent('', {detail: {comment: draft2}})
-    );
-    draft2.patch_set = 2 as PatchSetNum;
-    assert.deepEqual(element._diffDrafts, {'/foo/bar.txt': [draft, draft2]});
-    draft.patch_set = undefined;
-    element._handleCommentDiscard(
-      new CustomEvent('', {detail: {comment: draft}})
-    );
-    draft.patch_set = 2 as PatchSetNum;
-    assert.deepEqual(element._diffDrafts, {'/foo/bar.txt': [draft2]});
-    element._handleCommentDiscard(
-      new CustomEvent('', {detail: {comment: draft2}})
-    );
-    assert.deepEqual(element._diffDrafts, {});
+    assert.equal(getLabel(drafts, false), 'Reply (3)');
+    assert.equal(getLabel(drafts, true), 'Start Review (3)');
   });
 
   test('change num change', () => {
@@ -2034,42 +1803,6 @@
     assert.isTrue(awaitPluginsLoadedStub.called);
   });
 
-  suite('scroll related tests', () => {
-    test('document scrolling calls function to set scroll height', done => {
-      const originalHeight = document.body.scrollHeight;
-      const scrollStub = sinon.stub(element, 'handleScroll').callsFake(() => {
-        assert.isTrue(scrollStub.called);
-        document.body.style.height = `${originalHeight}px`;
-        scrollStub.restore();
-        done();
-      });
-      document.body.style.height = '10000px';
-      element.handleScroll();
-    });
-
-    test('scrollTop is set correctly', async () => {
-      element.viewState = {scrollTop: TEST_SCROLL_TOP_PX};
-
-      sinon.stub(element, 'loadData').callsFake(() => {
-        // When element is reloaded, ensure that the history
-        // state has the scrollTop set earlier. This will then
-        // be reset.
-        assert.isTrue(element.viewState.scrollTop === TEST_SCROLL_TOP_PX);
-        return Promise.resolve([]);
-      });
-
-      // simulate reloading component, which is done when route
-      // changes to match a regex of change view type.
-      element.params = {...createAppElementChangeViewParams()};
-      await flush();
-    });
-
-    test('scrollTop is reset when new change is loaded', () => {
-      element._resetFileListViewState();
-      assert.equal(element.viewState.scrollTop, 0);
-    });
-  });
-
   suite('reply dialog tests', () => {
     setup(() => {
       sinon.stub(element.$.replyDialog, '_draftChanged');
@@ -2588,7 +2321,6 @@
       };
       sinon.stub(element, '_getChangeDetail').returns(Promise.resolve(false));
       sinon.stub(element, '_getProjectConfig').returns(Promise.resolve());
-      sinon.stub(element, '_reloadComments').returns(Promise.resolve());
       sinon.stub(element, '_getMergeability').returns(Promise.resolve());
       sinon.stub(element, '_getLatestCommitMessage').returns(Promise.resolve());
       sinon
diff --git a/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info_html.ts b/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info_html.ts
index 65ca8b5..02fa090 100644
--- a/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info_html.ts
@@ -31,9 +31,9 @@
       >[[_computeShortHash(change, commitInfo, serverConfig)]]</a
     >
     <gr-copy-clipboard
-      has-tooltip=""
-      button-title="Copy full SHA to clipboard"
-      hide-input=""
+      hasTooltip=""
+      buttonTitle="Copy full SHA to clipboard"
+      hideInput=""
       text="[[commitInfo.commit]]"
     >
     </gr-copy-clipboard>
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_html.ts b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_html.ts
index 6def4a5..bed9240 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_html.ts
@@ -57,12 +57,7 @@
         class="rebaseOption"
         hidden$="[[!_displayParentOption(rebaseOnCurrent, hasParent)]]"
       >
-        <input
-          id="rebaseOnParentInput"
-          name="rebaseOptions"
-          type="radio"
-          on-click="_handleRebaseOnParent"
-        />
+        <input id="rebaseOnParentInput" name="rebaseOptions" type="radio" />
         <label id="rebaseOnParentLabel" for="rebaseOnParentInput">
           Rebase on parent change
         </label>
diff --git a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.ts b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.ts
index 71b4ac4..ff303e3 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.ts
+++ b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.ts
@@ -31,8 +31,6 @@
 import {
   AccountInfo,
   ChangeInfo,
-  EditPatchSetNum,
-  ParentPatchSetNum,
   PatchSetNum,
   CommitInfo,
   ServerInfo,
@@ -189,10 +187,6 @@
     ) {
       return;
     }
-    if (patchNum === EditPatchSetNum && basePatchNum === ParentPatchSetNum) {
-      GerritNav.navigateToChange(this.change, undefined, undefined, true);
-      return;
-    }
     GerritNav.navigateToChange(this.change, patchNum, basePatchNum);
   }
 
diff --git a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_test.js b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_test.js
index 9bc243d..dd70678 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_test.js
+++ b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_test.js
@@ -22,7 +22,6 @@
 import 'lodash/lodash.js';
 import {createRevisions} from '../../../test/test-data-generators.js';
 import {stubRestApi} from '../../../test/test-utils.js';
-import {EditPatchSetNum, ParentPatchSetNum} from '../../../types/common.js';
 
 const basicFixture = fixtureFromElement('gr-file-list-header');
 
@@ -162,34 +161,6 @@
         .calledWithExactly(element.change, 3, 1));
   });
 
-  test('navigateToChange called when range select changes with edit', () => {
-    const navigateToChangeStub = sinon.stub(GerritNav, 'navigateToChange');
-    element.change = {
-      change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
-      revisions: {
-        rev2: {_number: 2},
-        rev1: {_number: 1},
-        rev13: {_number: 13},
-        rev3: {_number: 3},
-      },
-      status: 'NEW',
-      labels: {},
-    };
-    element.basePatchNum = 1;
-    element.patchNum = EditPatchSetNum;
-
-    const detail = {
-      detail: {
-        basePatchNum: ParentPatchSetNum,
-        patchNum: EditPatchSetNum,
-      },
-    };
-    element._handlePatchChange(detail);
-    assert.equal(navigateToChangeStub.callCount, 1);
-    assert.isTrue(navigateToChangeStub.lastCall
-        .calledWithExactly(element.change, undefined, undefined, true));
-  });
-
   test('class is applied to file list on old patch set', () => {
     const allPatchSets = [{num: 4}, {num: 2}, {num: 1}];
     assert.equal(element._computePatchInfoClass(1, allPatchSets),
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts
index 835f77a..5206fdb 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts
@@ -17,6 +17,7 @@
 import '../../../styles/shared-styles';
 import '../../diff/gr-diff-cursor/gr-diff-cursor';
 import '../../diff/gr-diff-host/gr-diff-host';
+import '../../diff/gr-comment-api/gr-comment-api';
 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';
@@ -62,13 +63,12 @@
 import {customElement, observe, property} from '@polymer/decorators';
 import {
   BasePatchSetNum,
+  EditPatchSetNum,
   ElementPropertyDeepChange,
   FileInfo,
   FileNameToFileInfoMap,
   NumericChangeId,
   PatchRange,
-  PreferencesInfo,
-  UrlEncodedCommentId,
 } from '../../../types/common';
 import {DiffPreferencesInfo} from '../../../types/diff';
 import {GrDiffHost} from '../../diff/gr-diff-host/gr-diff-host';
@@ -81,6 +81,14 @@
 import {ParsedChangeInfo, PatchSetFile} from '../../../types/types';
 import {Timing} from '../../../constants/reporting';
 import {RevisionInfo} from '../../shared/revision-info/revision-info';
+import {preferences$} from '../../../services/user/user-model';
+import {
+  changeComments$,
+  drafts$,
+} from '../../../services/comments/comments-model';
+import {Subject} from 'rxjs';
+import {takeUntil} from 'rxjs/operators';
+import {UIDraft} from '../../../utils/comment-util';
 
 export const DEFAULT_NUM_FILES_SHOWN = 200;
 
@@ -174,12 +182,6 @@
     return htmlTemplate;
   }
 
-  /**
-   * Fired when a draft refresh should get triggered
-   *
-   * @event reload-drafts
-   */
-
   @property({type: Object})
   patchRange?: PatchRange;
 
@@ -225,9 +227,6 @@
   @property({type: Object, notify: true, observer: '_updateDiffPreferences'})
   diffPrefs?: DiffPreferencesInfo;
 
-  @property({type: Object})
-  _userPrefs?: PreferencesInfo;
-
   @property({type: Boolean})
   _showInlineDiffs?: boolean;
 
@@ -270,7 +269,7 @@
   @property({type: Object, computed: '_computeSizeBarLayout(_shownFiles.*)'})
   _sizeBarLayout: SizeBarLayout = createDefaultSizeBarLayout();
 
-  @property({type: Boolean, computed: '_computeShowSizeBars(_userPrefs)'})
+  @property({type: Boolean})
   _showSizeBars = true;
 
   // For merge commits vs Auto Merge, an extra file row is shown detailing the
@@ -317,10 +316,15 @@
   @property({type: Array})
   _dynamicPrependedContentEndpoints?: string[];
 
+  @property({type: Object})
+  diffDrafts?: {[path: string]: UIDraft[]} = {};
+
   private readonly reporting = appContext.reportingService;
 
   private readonly restApiService = appContext.restApiService;
 
+  disconnected$ = new Subject();
+
   get keyBindings() {
     return {
       esc: '_handleEscKey',
@@ -369,6 +373,14 @@
   /** @override */
   connectedCallback() {
     super.connectedCallback();
+    drafts$.pipe(takeUntil(this.disconnected$)).subscribe(drafts => {
+      this.diffDrafts = drafts;
+    });
+    changeComments$
+      .pipe(takeUntil(this.disconnected$))
+      .subscribe(changeComments => {
+        this.changeComments = changeComments;
+      });
     getPluginLoader()
       .awaitPluginsLoaded()
       .then(() => {
@@ -411,6 +423,7 @@
 
   /** @override */
   disconnectedCallback() {
+    this.disconnected$.next();
     this.diffCursor.dispose();
     this.fileCursor.unsetCursor();
     this._cancelDiffs();
@@ -476,11 +489,9 @@
       })
     );
 
-    promises.push(
-      this._getPreferences().then(prefs => {
-        this._userPrefs = prefs;
-      })
-    );
+    preferences$.pipe(takeUntil(this.disconnected$)).subscribe(prefs => {
+      this._showSizeBars = !!prefs?.size_bar_in_change_table;
+    });
 
     return Promise.all(promises).then(() => {
       this._loading = false;
@@ -490,12 +501,7 @@
   }
 
   @observe('_filesByPath')
-  async _updateCleanlyMergedPaths(
-    // changeNum?: NumericChangeId,
-    // change?: ParsedChangeInfo,
-    // patchRange?: PatchRange,
-    filesByPath?: FileNameToFileInfoMap
-  ) {
+  async _updateCleanlyMergedPaths(filesByPath?: FileNameToFileInfoMap) {
     // When viewing Auto Merge base vs a patchset, add an additional row that
     // knows how many files were cleanly merged. This requires an additional RPC
     // for the diffs between target parent and the patch set. The cleanly merged
@@ -506,7 +512,8 @@
       this.changeNum &&
       this.patchRange?.patchNum &&
       new RevisionInfo(this.change).isMergeCommit(this.patchRange.patchNum) &&
-      this.patchRange.basePatchNum === 'PARENT'
+      this.patchRange.basePatchNum === 'PARENT' &&
+      this.patchRange.patchNum !== EditPatchSetNum
     ) {
       const allFilesByPath = await this.restApiService.getChangeOrEditFiles(
         this.changeNum,
@@ -670,7 +677,8 @@
     patchRange?: PatchRange,
     file?: NormalizedFileInfo
   ) {
-    const draftCount = changeComments?.computeDraftCountForFile(
+    if (changeComments === undefined) return '';
+    const draftCount = changeComments.computeDraftCountForFile(
       patchRange,
       file
     );
@@ -686,7 +694,8 @@
     patchRange?: PatchRange,
     file?: NormalizedFileInfo
   ) {
-    const draftCount = changeComments?.computeDraftCountForFile(
+    if (changeComments === undefined) return '';
+    const draftCount = changeComments.computeDraftCountForFile(
       patchRange,
       file
     );
@@ -943,7 +952,7 @@
         return;
       }
       e.preventDefault();
-      this.fileCursor.next();
+      this.fileCursor.next({circular: true});
       this.selectedIndex = this.fileCursor.index;
     }
   }
@@ -963,7 +972,7 @@
         return;
       }
       e.preventDefault();
-      this.fileCursor.previous();
+      this.fileCursor.previous({circular: true});
       this.selectedIndex = this.fileCursor.index;
     }
   }
@@ -1470,66 +1479,50 @@
       }
     }
 
-    return new Promise(resolve => {
-      this.dispatchEvent(
-        new CustomEvent('reload-drafts', {
-          detail: {resolve},
-          composed: true,
-          bubbles: true,
-        })
+    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) {
+        this.reporting.error(
+          new Error(`Did not find <gr-diff-host> element for ${path}`)
+        );
+        return Promise.resolve();
+      }
+      if (!this.diffPrefs) {
+        throw new Error('diffPrefs must be set');
+      }
+
+      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(
+        Timing.FILE_EXPAND_ALL,
+        Timing.FILE_EXPAND_ALL_AVG,
+        initialCount
       );
-    }).then(() =>
-      asyncForeach(files, (file, cancel) => {
-        const path = file.path;
-        this._cancelForEachDiff = cancel;
+      /* 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.
 
-        iter++;
-        console.info('Expanding diff', iter, 'of', initialCount, ':', path);
-        const diffElem = this._findDiffByPath(path, diffElements);
-        if (!diffElem) {
-          this.reporting.error(
-            new Error(`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.threads = this.changeComments.getThreadsBySideForFile(
-          file,
-          this.patchRange
-        );
-        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(
-          Timing.FILE_EXPAND_ALL,
-          Timing.FILE_EXPAND_ALL_AVG,
-          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();
-      })
-    );
+      * 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 */
@@ -1552,47 +1545,6 @@
     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;
-    }
-
-    threadEl.comments = newComments.map(c => {
-      return {...c};
-    });
-    flush();
-  }
-
   _handleEscKey(e: CustomKeyboardEvent) {
     if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
       return;
@@ -1723,10 +1675,6 @@
     return stats.deletionOffset;
   }
 
-  _computeShowSizeBars(userPrefs?: PreferencesInfo) {
-    return !!userPrefs?.size_bar_in_change_table;
-  }
-
   _computeSizeBarsClass(showSizeBars?: boolean, path?: string) {
     let hideClass = '';
     if (!showSizeBars) {
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 3b2cacf..4d04744 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
@@ -410,7 +410,7 @@
               </span>
               <gr-file-status-chip file="[[file]]"></gr-file-status-chip>
               <gr-copy-clipboard
-                hide-input=""
+                hideInput=""
                 text="[[file.__path]]"
               ></gr-copy-clipboard>
             </a>
@@ -418,7 +418,7 @@
               <div class="oldPath" title$="[[file.old_path]]">
                 [[file.old_path]]
                 <gr-copy-clipboard
-                  hide-input=""
+                  hideInput=""
                   text="[[file.old_path]]"
                 ></gr-copy-clipboard>
               </div>
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 7016ff1..79bc9f6 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
@@ -33,6 +33,7 @@
   mockPromise,
   query,
 } from '../../../test/test-utils.js';
+import {EditPatchSetNum} from '../../../types/common.js';
 import {Shortcut} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.js';
 import {createCommentThreads} from '../../../utils/comment-util.js';
 import {
@@ -49,8 +50,7 @@
 const commentApiMock = createCommentApiMockWithTemplateElement(
     'gr-file-list-comment-api-mock', html`
     <gr-file-list id="fileList"
-        change-comments="[[_changeComments]]"
-        on-reload-drafts="_reloadDraftsWithCallback"></gr-file-list>
+        change-comments="[[_changeComments]]"></gr-file-list>
     <gr-comment-api id="commentAPI"></gr-comment-api>
 `);
 
@@ -67,7 +67,6 @@
   let commentApiWrapper;
 
   let saveStub;
-  let loadCommentSpy;
 
   suiteSetup(() => {
     const kb = TestKeyboardShortcutBinder.push();
@@ -95,7 +94,6 @@
 
   suite('basic tests', () => {
     setup(done => {
-      stubRestApi('getPreferences').returns(Promise.resolve({}));
       stubRestApi('getDiffComments').returns(Promise.resolve({}));
       stubRestApi('getDiffRobotComments').returns(Promise.resolve({}));
       stubRestApi('getDiffDrafts').returns(Promise.resolve({}));
@@ -110,14 +108,7 @@
       // comment API.
       commentApiWrapper = basicFixture.instantiate();
       element = commentApiWrapper.$.fileList;
-      loadCommentSpy = sinon.spy(commentApiWrapper.$.commentAPI, 'loadAll');
 
-      // Stub methods on the changeComments object after changeComments has
-      // been initialized.
-      commentApiWrapper.loadComments().then(() => {
-        sinon.stub(element.changeComments, 'getPaths').returns({});
-        done();
-      });
       element._loading = false;
       element.diffPrefs = {};
       element.numFilesShown = 200;
@@ -127,6 +118,7 @@
       };
       saveStub = sinon.stub(element, '_saveReviewedState').callsFake(
           () => Promise.resolve());
+      done();
     });
 
     test('correct number of files are shown', () => {
@@ -541,7 +533,7 @@
         assert.equal(element.fileCursor.index, 2);
 
         // up should not move the cursor.
-        MockInteractions.pressAndReleaseKeyOn(element, 38, null, 'down');
+        MockInteractions.pressAndReleaseKeyOn(element, 38, null, 'up');
         assert.equal(element.fileCursor.index, 2);
 
         MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
@@ -556,8 +548,8 @@
         MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
         MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
         MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
-        assert.equal(element.fileCursor.index, 0);
-        assert.equal(element.selectedIndex, 0);
+        assert.equal(element.fileCursor.index, 1);
+        assert.equal(element.selectedIndex, 1);
 
         const createCommentInPlaceStub = sinon.stub(element.diffCursor,
             'createCommentInPlace');
@@ -1009,7 +1001,7 @@
           FilesExpandedState.ALL);
     });
 
-    test('_renderInOrder', done => {
+    test('_renderInOrder', async () => {
       const reviewStub = sinon.stub(element, '_reviewFile');
       let callCount = 0;
       const diffs = [{
@@ -1039,15 +1031,12 @@
       }];
       element._renderInOrder([
         {path: 'p2'}, {path: 'p1'}, {path: 'p0'},
-      ], diffs, 3)
-          .then(() => {
-            assert.isFalse(reviewStub.called);
-            assert.isTrue(loadCommentSpy.called);
-            done();
-          });
+      ], diffs, 3);
+      await flush();
+      assert.isFalse(reviewStub.called);
     });
 
-    test('_renderInOrder logged in', done => {
+    test('_renderInOrder logged in', async () => {
       element._loggedIn = true;
       const reviewStub = sinon.stub(element, '_reviewFile');
       let callCount = 0;
@@ -1081,14 +1070,12 @@
       }];
       element._renderInOrder([
         {path: 'p2'}, {path: 'p1'}, {path: 'p0'},
-      ], diffs, 3)
-          .then(() => {
-            assert.equal(reviewStub.callCount, 3);
-            done();
-          });
+      ], diffs, 3);
+      await flush();
+      assert.equal(reviewStub.callCount, 3);
     });
 
-    test('_renderInOrder respects diffPrefs.manual_review', () => {
+    test('_renderInOrder respects diffPrefs.manual_review', async () => {
       element._loggedIn = true;
       element.diffPrefs = {manual_review: true};
       const reviewStub = sinon.stub(element, '_reviewFile');
@@ -1099,14 +1086,14 @@
         reload() { return Promise.resolve(); },
       }];
 
-      return element._renderInOrder([{path: 'p'}], diffs, 1).then(() => {
-        assert.isFalse(reviewStub.called);
-        delete element.diffPrefs.manual_review;
-        return element._renderInOrder([{path: 'p'}], diffs, 1).then(() => {
-          assert.isTrue(reviewStub.called);
-          assert.isTrue(reviewStub.calledWithExactly('p', true));
-        });
-      });
+      element._renderInOrder([{path: 'p'}], diffs, 1);
+      await flush();
+      assert.isFalse(reviewStub.called);
+      delete element.diffPrefs.manual_review;
+      element._renderInOrder([{path: 'p'}], diffs, 1);
+      await flush();
+      assert.isTrue(reviewStub.called);
+      assert.isTrue(reviewStub.calledWithExactly('p', true));
     });
 
     test('_loadingChanged fired from reload in debouncer', async () => {
@@ -1246,6 +1233,15 @@
         assert.notOk(query(element, '.cleanlyMergedText'));
         assert.notOk(query(element, '.showParentButton'));
       });
+
+      test('not shown in edit mode', async () => {
+        element.patchRange = {basePatchNum: 1, patchNum: EditPatchSetNum};
+        await element.reload();
+        await flush();
+
+        assert.notOk(query(element, '.cleanlyMergedText'));
+        assert.notOk(query(element, '.showParentButton'));
+      });
     });
   });
 
@@ -1505,14 +1501,12 @@
         ignore_whitespace: 'IGNORE_NONE',
       };
       diff.diff = getMockDiffResponse();
-      commentApiWrapper.loadComments().then(() => {
-        sinon.stub(element.changeComments, 'getCommentsForPath')
-            .withArgs('/COMMIT_MSG', {
-              basePatchNum: 'PARENT',
-              patchNum: 2,
-            })
-            .returns(diff.comments);
-      });
+      sinon.stub(diff.changeComments, 'getCommentsForPath')
+          .withArgs('/COMMIT_MSG', {
+            basePatchNum: 'PARENT',
+            patchNum: 2,
+          })
+          .returns(diff.comments);
       await listenOnce(diff, 'render');
     }
 
@@ -1544,17 +1538,10 @@
       // comment API.
       commentApiWrapper = basicFixture.instantiate();
       element = commentApiWrapper.$.fileList;
-      loadCommentSpy = sinon.spy(commentApiWrapper.$.commentAPI, 'loadAll');
       element.diffPrefs = {};
       element.change = {_number: 42, project: 'testRepo'};
       sinon.stub(element, '_reviewFile');
 
-      // Stub methods on the changeComments object after changeComments has
-      // been initialized.
-      commentApiWrapper.loadComments().then(() => {
-        sinon.stub(element.changeComments, 'getPaths').returns({});
-        done();
-      });
       element._loading = false;
       element.numFilesShown = 75;
       element.selectedIndex = 0;
@@ -1582,6 +1569,7 @@
       };
       sinon.stub(window, 'fetch').callsFake(() => Promise.resolve());
       flush();
+      done();
     });
 
     test('cursor with individually opened files', async () => {
@@ -1806,110 +1794,6 @@
               .map(row => row.querySelector('gr-edit-file-controls'));
       assert.isTrue(editControls[0].classList.contains('invisible'));
     });
-
-    test('reloadCommentsForThreadWithRootId', async () => {
-      // Expand the commit message diff
-      MockInteractions.keyUpOn(element, 73, 'shift', 'i');
-      const diffs = await renderAndGetNewDiffs(0);
-      flush();
-
-      // Two comment threads should be generated by renderAndGetNewDiffs
-      const threadEls = diffs[0].getThreadEls();
-      assert.equal(threadEls.length, 2);
-      const threadElsByRootId = new Map(
-          threadEls.map(threadEl => [threadEl.rootId, threadEl]));
-
-      const thread1 = threadElsByRootId.get('503008e2_0ab203ee');
-      assert.equal(thread1.comments.length, 1);
-      assert.equal(thread1.comments[0].message, 'a comment');
-      assert.equal(thread1.comments[0].line, 10);
-
-      const thread2 = threadElsByRootId.get('ecf0b9fa_fe1a5f62');
-      assert.equal(thread2.comments.length, 2);
-      assert.isTrue(thread2.comments[0].unresolved);
-      assert.equal(thread2.comments[0].message, 'another comment');
-      assert.equal(thread2.comments[0].line, 20);
-
-      const commentStub =
-          sinon.stub(element.changeComments, 'getCommentsForThread');
-      const commentStubRes1 = [
-        {
-          patch_set: 2,
-          path: '/p',
-          id: '503008e2_0ab203ee',
-          line: 20,
-          updated: '2018-02-08 18:49:18.000000000',
-          message: 'edited text',
-          unresolved: false,
-        },
-      ];
-      const commentStubRes2 = [
-        {
-          patch_set: 2,
-          path: '/p',
-          id: 'ecf0b9fa_fe1a5f62',
-          line: 20,
-          updated: '2018-02-08 18:49:18.000000000',
-          message: 'another comment',
-          unresolved: true,
-        },
-        {
-          patch_set: 2,
-          path: '/p',
-          id: '503008e2_0ab203ee',
-          line: 10,
-          in_reply_to: 'ecf0b9fa_fe1a5f62',
-          updated: '2018-02-14 22:07:43.000000000',
-          message: 'response',
-          unresolved: true,
-        },
-        {
-          patch_set: 2,
-          path: '/p',
-          id: '503008e2_0ab203ef',
-          line: 20,
-          in_reply_to: '503008e2_0ab203ee',
-          updated: '2018-02-15 22:07:43.000000000',
-          message: 'a third comment in the thread',
-          unresolved: true,
-        },
-      ];
-      commentStub.withArgs('503008e2_0ab203ee').returns(
-          commentStubRes1);
-      commentStub.withArgs('ecf0b9fa_fe1a5f62').returns(
-          commentStubRes2);
-
-      // Reload comments from the first comment thread, which should have a
-      // an updated message and a toggled resolve state.
-      element.reloadCommentsForThreadWithRootId('503008e2_0ab203ee',
-          '/COMMIT_MSG');
-      assert.equal(thread1.comments.length, 1);
-      assert.isFalse(thread1.comments[0].unresolved);
-      assert.equal(thread1.comments[0].message, 'edited text');
-
-      // Reload comments from the second comment thread, which should have a new
-      // reply.
-      element.reloadCommentsForThreadWithRootId('ecf0b9fa_fe1a5f62',
-          '/COMMIT_MSG');
-      assert.equal(thread2.comments.length, 3);
-
-      const commentStubCount = commentStub.callCount;
-      const getThreadsSpy = sinon.spy(diffs[0], 'getThreadEls');
-
-      // Should not be getting threads when the file is not expanded.
-      element.reloadCommentsForThreadWithRootId('ecf0b9fa_fe1a5f62',
-          'other/file');
-      assert.isFalse(getThreadsSpy.called);
-      assert.equal(commentStubCount, commentStub.callCount);
-
-      // Should be query selecting diffs when the file is expanded.
-      // Should not be fetching change comments when the rootId is not found
-      // to match.
-      element.reloadCommentsForThreadWithRootId('acf0b9fa_fe1a5f62',
-          '/COMMIT_MSG');
-      assert.isTrue(getThreadsSpy.called);
-      assert.equal(commentStubCount, commentStub.callCount);
-    });
   });
 });
 
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 1711499..b097340 100644
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message.ts
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message.ts
@@ -121,6 +121,9 @@
   @property({type: Object})
   message: ChangeMessage | undefined;
 
+  @property({type: Array})
+  commentThreads: CommentThread[] = [];
+
   @computed('message')
   get author() {
     return this.message?.author || this.message?.updated_by;
@@ -195,13 +198,13 @@
       '_computeMessageContentCollapsed(message.message,' +
       ' message.accounts_in_message,' +
       ' message.tag,' +
-      ' message.commentThreads)',
+      ' commentThreads)',
   })
   _messageContentCollapsed = '';
 
   @property({
     type: String,
-    computed: '_computeCommentCountText(message.commentThreads.length)',
+    computed: '_computeCommentCountText(commentThreads)',
   })
   _commentCountText = '';
 
@@ -234,12 +237,12 @@
     }
   }
 
-  _computeCommentCountText(threadsLength?: number) {
-    if (!threadsLength) {
+  _computeCommentCountText(commentThreads?: CommentThread[]) {
+    if (!commentThreads?.length) {
       return undefined;
     }
 
-    return pluralize(threadsLength, 'comment');
+    return pluralize(commentThreads.length, 'comment');
   }
 
   _computeMessageContentExpanded(
diff --git a/polygerrit-ui/app/elements/change/gr-message/gr-message_html.ts b/polygerrit-ui/app/elements/change/gr-message/gr-message_html.ts
index e58b8a8..9e24a09 100644
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message_html.ts
@@ -195,10 +195,8 @@
       padding-bottom: 1px;
       color: var(--vote-text-color);
     }
-    gr-account-label {
-      --gr-account-label-text-style: {
-        font-weight: var(--font-weight-bold);
-      }
+    gr-account-label::part(gr-account-label-text) {
+      font-weight: var(--font-weight-bold);
     }
     iron-icon {
       --iron-icon-height: 20px;
@@ -251,7 +249,7 @@
         <div class="content messageContent">
           <div class="message hideOnOpen">[[_messageContentCollapsed]]</div>
           <gr-formatted-text
-            no-trailing-margin=""
+            noTrailingMargin
             class="message hideOnCollapsed"
             content="[[_messageContentExpanded]]"
             config="[[_projectConfig.commentlinks]]"
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
index 4e2aaf1..fae624e 100644
--- 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
@@ -90,9 +90,9 @@
  */
 function computeThreads(
   message: CombinedMessage,
-  changeComments: ChangeComments
+  changeComments?: ChangeComments
 ): CommentThread[] {
-  if (message._index === undefined) {
+  if (message._index === undefined || changeComments === undefined) {
     return [];
   }
   const messageId = getMessageId(message);
@@ -369,6 +369,10 @@
     return combinedMessages;
   }
 
+  getCommentThreads(message: CombinedMessage, changeComments?: ChangeComments) {
+    return computeThreads(message, changeComments);
+  }
+
   _updateExpandedStateOfAllMessages(exp: boolean) {
     if (this._combinedMessages) {
       for (let i = 0; i < this._combinedMessages.length; i++) {
diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_html.ts b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_html.ts
index 8fa8eab..93df77e 100644
--- a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_html.ts
@@ -93,6 +93,7 @@
       change="[[change]]"
       change-num="[[changeNum]]"
       message="[[message]]"
+      comment-threads="[[getCommentThreads(message, changeComments)]]"
       project-name="[[projectName]]"
       show-reply-button="[[showReplyButtons]]"
       on-message-anchor-tap="_handleAnchorClick"
diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.js b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.js
index fd45eec..0939daa 100644
--- a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.js
+++ b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.js
@@ -23,6 +23,7 @@
 import {MessageTag} from '../../../constants/constants.js';
 import {html} from '@polymer/polymer/lib/utils/html-tag.js';
 import {stubRestApi} from '../../../test/test-utils.js';
+import {ChangeComments} from '../../diff/gr-comment-api/gr-comment-api.js';
 
 createCommentApiMockWithTemplateElement(
     'gr-messages-list-comment-mock-api', html`
@@ -143,11 +144,9 @@
       // comment API.
       commentApiWrapper = basicFixture.instantiate();
       element = commentApiWrapper.$.messagesList;
+      element.changeComments = new ChangeComments(comments);
       element.messages = messages;
-
-      // Stub methods on the changeComments object after changeComments has
-      // been initialized.
-      return commentApiWrapper.loadComments();
+      flush();
     });
 
     test('expand/collapse all', () => {
@@ -454,12 +453,9 @@
       // comment API.
       commentApiWrapper = basicFixture.instantiate();
       element = commentApiWrapper.$.messagesList;
-      sinon.spy(commentApiWrapper.$.commentAPI, 'loadAll');
+      element.changeComments = new ChangeComments();
       element.messages = messages;
-
-      // Stub methods on the changeComments object after changeComments has
-      // been initialized.
-      return commentApiWrapper.loadComments();
+      flush();
     });
 
     test('hide autogenerated button is not hidden', () => {
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 bd4cb76..8201dbc 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
@@ -487,8 +487,8 @@
                   account="[[account]]"
                   force-attention="[[_computeHasNewAttention(account, _newAttentionSet)]]"
                   selected="[[_computeHasNewAttention(account, _newAttentionSet)]]"
-                  hide-hovercard=""
-                  selection-chip-style
+                  hideHovercard
+                  selectionChipStyle
                   on-click="_handleAttentionClick"
                 ></gr-account-label>
               </template>
@@ -558,8 +558,8 @@
               account="[[_owner]]"
               force-attention="[[_computeHasNewAttention(_owner, _newAttentionSet)]]"
               selected="[[_computeHasNewAttention(_owner, _newAttentionSet)]]"
-              hide-hovercard=""
-              selection-chip-style
+              hideHovercard
+              selectionChipStyle
               on-click="_handleAttentionClick"
             >
             </gr-account-label>
@@ -573,8 +573,8 @@
                 account="[[_uploader]]"
                 force-attention="[[_computeHasNewAttention(_uploader, _newAttentionSet)]]"
                 selected="[[_computeHasNewAttention(_uploader, _newAttentionSet)]]"
-                hide-hovercard=""
-                selection-chip-style
+                hideHovercard
+                selectionChipStyle
                 on-click="_handleAttentionClick"
               >
               </gr-account-label>
@@ -593,8 +593,8 @@
                 account="[[account]]"
                 force-attention="[[_computeHasNewAttention(account, _newAttentionSet)]]"
                 selected="[[_computeHasNewAttention(account, _newAttentionSet)]]"
-                hide-hovercard=""
-                selection-chip-style
+                hideHovercard
+                selectionChipStyle
                 on-click="_handleAttentionClick"
               >
               </gr-account-label>
@@ -614,8 +614,8 @@
                   account="[[account]]"
                   force-attention="[[_computeHasNewAttention(account, _newAttentionSet)]]"
                   selected="[[_computeHasNewAttention(account, _newAttentionSet)]]"
-                  hide-hovercard=""
-                  selection-chip-style
+                  hideHovercard
+                  selectionChipStyle
                   on-click="_handleAttentionClick"
                 >
                 </gr-account-label>
diff --git a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_html.ts b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_html.ts
index 25d8517..ca8bf87 100644
--- a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_html.ts
@@ -66,7 +66,7 @@
           account="[[reviewer]]"
           change="[[change]]"
           on-remove="_handleRemove"
-          highlight-attention
+          highlightAttention
           voteable-text="[[_computeVoteableText(reviewer, change)]]"
           removable="[[_computeCanRemoveReviewer(reviewer, mutable)]]"
         >
diff --git a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.ts b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.ts
index 354d360..d3f23ad 100644
--- a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.ts
@@ -67,7 +67,7 @@
     await dialogShown;
   });
 
-  test('only show remove for removable reviewers', () => {
+  test('only show remove for removable reviewers', async () => {
     element.mutable = true;
     element.change = {
       ...createChange(),
@@ -112,7 +112,7 @@
         },
       ],
     };
-    flush();
+    await flush();
     const chips = element.root!.querySelectorAll('gr-account-chip');
     assert.equal(chips.length, 4);
 
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 36cb053..5e6b076 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
@@ -47,7 +47,6 @@
   UIComment,
 } from '../../../utils/comment-util';
 import {pluralize} from '../../../utils/string-util';
-import {fireThreadListModifiedEvent} from '../../../utils/event-util';
 import {assertIsDefined, assertNever} from '../../../utils/common-util';
 import {CommentTabState} from '../../../types/events';
 import {DropdownItem} from '../../shared/gr-dropdown-list/gr-dropdown-list';
@@ -571,10 +570,6 @@
     this.removeThread(e.detail.rootId);
   }
 
-  _handleCommentsChanged(e: CustomEvent) {
-    fireThreadListModifiedEvent(this, e.detail.rootId, e.detail.path);
-  }
-
   _isOnParent(side?: CommentSide) {
     // TODO(TS): That looks like a bug? CommentSide.REVISION will also be
     // classified as parent??
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 b5d2b4d..93a432b 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
@@ -109,7 +109,7 @@
           <gr-account-label
             account="[[item]]"
             on-click="handleAccountClicked"
-            selection-chip-style
+            selectionChipStyle
             selected="[[isSelected(item, selectedAuthors)]]"
           > </gr-account-label>
         </template>
@@ -168,7 +168,6 @@
         path="[[thread.path]]"
         root-id="{{thread.rootId}}"
         should-scroll-into-view="[[computeShouldScrollIntoView(thread.comments, scrollCommentId)]]"
-        on-thread-changed="_handleCommentsChanged"
         on-thread-discard="_handleThreadDiscard"
       ></gr-comment-thread>
     </template>
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 56ed8ce..61f737a 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
@@ -651,23 +651,6 @@
     assert.equal(getVisibleThreads().length, 2);
   });
 
-  test('modification events are consumed and displatched', () => {
-    sinon.spy(element, '_handleCommentsChanged');
-    const dispatchSpy = sinon.stub();
-    element.addEventListener('thread-list-modified', dispatchSpy);
-    threadElements[0].dispatchEvent(
-        new CustomEvent('thread-changed', {
-          detail: {
-            rootId: 'ecf0b9fa_fe1a5f62', path: '/COMMIT_MSG'},
-          composed: true, bubbles: true,
-        }));
-    assert.isTrue(element._handleCommentsChanged.called);
-    assert.isTrue(dispatchSpy.called);
-    assert.equal(dispatchSpy.lastCall.args[0].detail.rootId,
-        'ecf0b9fa_fe1a5f62');
-    assert.equal(dispatchSpy.lastCall.args[0].detail.path, '/COMMIT_MSG');
-  });
-
   suite('hideDropdown', () => {
     setup(done => {
       element.hideDropdown = true;
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-results.ts b/polygerrit-ui/app/elements/checks/gr-checks-results.ts
index b348eb5..f27a383 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-results.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-results.ts
@@ -588,10 +588,10 @@
           .value="${this.result}"
         ></gr-endpoint-param>
         <gr-formatted-text
-          no-trailing-margin=""
+          noTrailingMargin
           class="message"
-          content="${this.result.message}"
-          config="${this.repoConfig}"
+          .content="${this.result.message}"
+          .config="${this.repoConfig?.commentlinks}"
         ></gr-formatted-text>
       </gr-endpoint-decorator>
     `;
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 015fdf3..7532101 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
@@ -51,7 +51,6 @@
 import {
   BasePatchSetNum,
   DashboardId,
-  EditPatchSetNum,
   GroupId,
   NumericChangeId,
   PatchSetNum,
@@ -1601,15 +1600,6 @@
       queryMap: ctx.queryMap,
     };
 
-    // We do not want to allow "edit" to be used as a
-    // patch number. Instead redirect to ,edit.
-    if (ctx.params[4] === EditPatchSetNum && !ctx.params[6]) {
-      params.basePatchNum = undefined;
-      params.edit = true;
-      this._redirect(this._generateUrl(params));
-      return;
-    }
-
     this.reporting.setRepoName(params.project);
     this.reporting.setChangeId(changeNum);
     this._redirectOrNavigate(params);
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-router_test.js b/polygerrit-ui/app/elements/core/gr-router/gr-router_test.js
index c4674d2..6e80a35 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router_test.js
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router_test.js
@@ -22,7 +22,6 @@
 import {stubBaseUrl, stubRestApi, addListenerForTest} from '../../../test/test-utils.js';
 import {_testOnly_RoutePattern} from './gr-router.js';
 import {GerritView} from '../../../services/router/router-model.js';
-import {EditPatchSetNum} from '../../../types/common.js';
 
 const basicFixture = fixtureFromElement('gr-router');
 
@@ -1430,16 +1429,16 @@
       suite('_handleChangeRoute', () => {
         let normalizeRangeStub;
 
-        function makeParams(path, hash, baseNum, patchNum) {
+        function makeParams(path, hash) {
           return {
             params: [
               'foo/bar', // 0 Project
               1234, // 1 Change number
               null, // 2 Unused
               null, // 3 Unused
-              baseNum ? baseNum : 4, // 4 Base patch number
+              4, // 4 Base patch number
               null, // 5 Unused
-              patchNum ? patchNum : 7, // 6 Patch number
+              7, // 6 Patch number
             ],
             queryMap: new Map(),
           };
@@ -1477,23 +1476,6 @@
           assert.isFalse(redirectStub.called);
           assert.isTrue(normalizeRangeStub.called);
         });
-
-        test('redirect due to patchNum being an edit', () => {
-          normalizeRangeStub.returns(true);
-          const ctx = makeParams(null, '');
-          element._handleChangeRoute(ctx, undefined, EditPatchSetNum, false);
-          assert.isTrue(normalizeRangeStub.called);
-          assert.isFalse(setParamsStub.called);
-          assert.isTrue(redirectStub.calledOnce);
-
-          const params = {
-            view: GerritView.CHANGE,
-            changeNum: '1234',
-            project: 'test',
-            edit: true,
-          };
-          assert.equal(element._generateUrl(params), '/c/test/+/1234,edit');
-        });
       });
 
       suite('_handleDiffRoute', () => {
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
index aa7e2e0..7419713 100644
--- 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
@@ -32,6 +32,12 @@
 const SELF_EXPRESSION = 'self';
 const ME_EXPRESSION = 'me';
 
+declare global {
+  interface HTMLElementEventMap {
+    'handle-search': CustomEvent<SearchBarHandleSearchDetail>;
+  }
+}
+
 @customElement('gr-smart-search')
 export class GrSmartSearch extends PolymerElement {
   static get template() {
@@ -39,7 +45,7 @@
   }
 
   @property({type: String})
-  searchQuery?: string;
+  searchQuery = '';
 
   @property({type: Object})
   _config?: ServerInfo;
diff --git a/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search_test.js b/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search_test.js
deleted file mode 100644
index f3a9965..0000000
--- a/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search_test.js
+++ /dev/null
@@ -1,137 +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 '../../../test/common-test-setup-karma.js';
-import './gr-smart-search.js';
-import {stubRestApi} from '../../../test/test-utils.js';
-
-const basicFixture = fixtureFromElement('gr-smart-search');
-
-suite('gr-smart-search tests', () => {
-  let element;
-
-  setup(() => {
-    element = basicFixture.instantiate();
-  });
-
-  test('Autocompletes accounts', () => {
-    stubRestApi('getSuggestedAccounts').callsFake(() =>
-      Promise.resolve([
-        {
-          name: 'fred',
-          email: 'fred@goog.co',
-        },
-      ])
-    );
-    return element._fetchAccounts('owner', 'fr').then(s => {
-      assert.deepEqual(s[0], {text: 'owner:fred@goog.co', label: 'fred'});
-    });
-  });
-
-  test('Inserts self as option when valid', () => {
-    stubRestApi('getSuggestedAccounts').callsFake( () =>
-      Promise.resolve([
-        {
-          name: 'fred',
-          email: 'fred@goog.co',
-        },
-      ])
-    );
-    element._fetchAccounts('owner', 's')
-        .then(s => {
-          assert.deepEqual(s[0], {text: 'owner:fred@goog.co', label: 'fred'});
-          assert.deepEqual(s[1], {text: 'owner:self'});
-        })
-        .then(() => element._fetchAccounts('owner', 'selfs'))
-        .then(s => {
-          assert.notEqual(s[0], {text: 'owner:self'});
-        });
-  });
-
-  test('Inserts me as option when valid', () => {
-    stubRestApi('getSuggestedAccounts').callsFake( () =>
-      Promise.resolve([
-        {
-          name: 'fred',
-          email: 'fred@goog.co',
-        },
-      ])
-    );
-    return element._fetchAccounts('owner', 'm')
-        .then(s => {
-          assert.deepEqual(s[0], {text: 'owner:fred@goog.co', label: 'fred'});
-          assert.deepEqual(s[1], {text: 'owner:me'});
-        })
-        .then(() => element._fetchAccounts('owner', 'meme'))
-        .then(s => {
-          assert.notEqual(s[0], {text: 'owner:me'});
-        });
-  });
-
-  test('Autocompletes groups', () => {
-    stubRestApi('getSuggestedGroups').callsFake( () =>
-      Promise.resolve({
-        Polygerrit: 0,
-        gerrit: 0,
-        gerrittest: 0,
-      })
-    );
-    return element._fetchGroups('ownerin', 'pol').then(s => {
-      assert.deepEqual(s[0], {text: 'ownerin:Polygerrit'});
-    });
-  });
-
-  test('Autocompletes projects', () => {
-    stubRestApi('getSuggestedProjects').callsFake( () =>
-      Promise.resolve({Polygerrit: 0}));
-    return element._fetchProjects('project', 'pol').then(s => {
-      assert.deepEqual(s[0], {text: 'project:Polygerrit'});
-    });
-  });
-
-  test('Autocomplete doesnt override exact matches to input', () => {
-    stubRestApi('getSuggestedGroups').callsFake( () =>
-      Promise.resolve({
-        Polygerrit: 0,
-        gerrit: 0,
-        gerrittest: 0,
-      })
-    );
-    return element._fetchGroups('ownerin', 'gerrit').then(s => {
-      assert.deepEqual(s[0], {text: 'ownerin:Polygerrit'});
-      assert.deepEqual(s[1], {text: 'ownerin:gerrit'});
-      assert.deepEqual(s[2], {text: 'ownerin:gerrittest'});
-    });
-  });
-
-  test('Autocompletes accounts with no email', () => {
-    stubRestApi('getSuggestedAccounts').callsFake( () =>
-      Promise.resolve([{name: 'fred'}]));
-    return element._fetchAccounts('owner', 'fr').then(s => {
-      assert.deepEqual(s[0], {text: 'owner:"fred"', label: 'fred'});
-    });
-  });
-
-  test('Autocompletes accounts with email', () => {
-    stubRestApi('getSuggestedAccounts').callsFake( () =>
-      Promise.resolve([{email: 'fred@goog.co'}]));
-    return element._fetchAccounts('owner', 'fr').then(s => {
-      assert.deepEqual(s[0], {text: 'owner:fred@goog.co', label: ''});
-    });
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search_test.ts b/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search_test.ts
new file mode 100644
index 0000000..0218a8f
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search_test.ts
@@ -0,0 +1,143 @@
+/**
+ * @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 '../../../test/common-test-setup-karma';
+import './gr-smart-search';
+import {GrSmartSearch} from './gr-smart-search';
+import {stubRestApi} from '../../../test/test-utils';
+import {EmailAddress, GroupId, UrlEncodedRepoName} from '../../../types/common';
+
+const basicFixture = fixtureFromElement('gr-smart-search');
+
+suite('gr-smart-search tests', () => {
+  let element: GrSmartSearch;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+  });
+
+  test('Autocompletes accounts', () => {
+    stubRestApi('getSuggestedAccounts').callsFake(() =>
+      Promise.resolve([
+        {
+          name: 'fred',
+          email: 'fred@goog.co' as EmailAddress,
+        },
+      ])
+    );
+    return element._fetchAccounts('owner', 'fr').then(s => {
+      assert.deepEqual(s[0], {text: 'owner:fred@goog.co', label: 'fred'});
+    });
+  });
+
+  test('Inserts self as option when valid', () => {
+    stubRestApi('getSuggestedAccounts').callsFake(() =>
+      Promise.resolve([
+        {
+          name: 'fred',
+          email: 'fred@goog.co' as EmailAddress,
+        },
+      ])
+    );
+    element
+      ._fetchAccounts('owner', 's')
+      .then(s => {
+        assert.deepEqual(s[0], {text: 'owner:fred@goog.co', label: 'fred'});
+        assert.deepEqual(s[1], {text: 'owner:self'});
+      })
+      .then(() => element._fetchAccounts('owner', 'selfs'))
+      .then(s => {
+        assert.notEqual(s[0], {text: 'owner:self'});
+      });
+  });
+
+  test('Inserts me as option when valid', () => {
+    stubRestApi('getSuggestedAccounts').callsFake(() =>
+      Promise.resolve([
+        {
+          name: 'fred',
+          email: 'fred@goog.co' as EmailAddress,
+        },
+      ])
+    );
+    return element
+      ._fetchAccounts('owner', 'm')
+      .then(s => {
+        assert.deepEqual(s[0], {text: 'owner:fred@goog.co', label: 'fred'});
+        assert.deepEqual(s[1], {text: 'owner:me'});
+      })
+      .then(() => element._fetchAccounts('owner', 'meme'))
+      .then(s => {
+        assert.notEqual(s[0], {text: 'owner:me'});
+      });
+  });
+
+  test('Autocompletes groups', () => {
+    stubRestApi('getSuggestedGroups').callsFake(() =>
+      Promise.resolve({
+        Polygerrit: {id: '4c97682e6ce61b7247f3381b6f1789356666de7f' as GroupId},
+        gerrit: {id: '4c97682e6ce61b7247f3381b6f1789356666de7f' as GroupId},
+        gerrittest: {id: '4c97682e6ce61b7247f3381b6f1789356666de7f' as GroupId},
+      })
+    );
+    return element._fetchGroups('ownerin', 'pol').then(s => {
+      assert.deepEqual(s[0], {text: 'ownerin:Polygerrit'});
+    });
+  });
+
+  test('Autocompletes projects', () => {
+    stubRestApi('getSuggestedProjects').callsFake(() =>
+      Promise.resolve({Polygerrit: {id: 'test' as UrlEncodedRepoName}})
+    );
+    return element._fetchProjects('project', 'pol').then(s => {
+      assert.deepEqual(s[0], {text: 'project:Polygerrit'});
+    });
+  });
+
+  test('Autocomplete doesnt override exact matches to input', () => {
+    stubRestApi('getSuggestedGroups').callsFake(() =>
+      Promise.resolve({
+        Polygerrit: {id: '4c97682e6ce61b7247f3381b6f1789356666de7f' as GroupId},
+        gerrit: {id: '4c97682e6ce61b7247f3381b6f1789356666de7f' as GroupId},
+        gerrittest: {id: '4c97682e6ce61b7247f3381b6f1789356666de7f' as GroupId},
+      })
+    );
+    return element._fetchGroups('ownerin', 'gerrit').then(s => {
+      assert.deepEqual(s[0], {text: 'ownerin:Polygerrit'});
+      assert.deepEqual(s[1], {text: 'ownerin:gerrit'});
+      assert.deepEqual(s[2], {text: 'ownerin:gerrittest'});
+    });
+  });
+
+  test('Autocompletes accounts with no email', () => {
+    stubRestApi('getSuggestedAccounts').callsFake(() =>
+      Promise.resolve([{name: 'fred'}])
+    );
+    return element._fetchAccounts('owner', 'fr').then(s => {
+      assert.deepEqual(s[0], {text: 'owner:"fred"', label: 'fred'});
+    });
+  });
+
+  test('Autocompletes accounts with email', () => {
+    stubRestApi('getSuggestedAccounts').callsFake(() =>
+      Promise.resolve([{email: 'fred@goog.co' as EmailAddress}])
+    );
+    return element._fetchAccounts('owner', 'fr').then(s => {
+      assert.deepEqual(s[0], {text: 'owner:fred@goog.co', label: ''});
+    });
+  });
+});
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 bf4da14..315fbfb 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
@@ -16,13 +16,11 @@
  */
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-comment-api_html';
-import {CURRENT} from '../../../utils/patch-set-util';
 import {customElement, property} from '@polymer/decorators';
 import {
   CommentBasics,
   PatchRange,
   PatchSetNum,
-  PathToRobotCommentsInfoMap,
   RobotCommentInfo,
   UrlEncodedCommentId,
   NumericChangeId,
@@ -44,6 +42,7 @@
   isInBaseOfPatchRange,
   isInRevisionOfPatchRange,
   isPatchsetLevel,
+  addPath,
 } from '../../../utils/comment-util';
 import {PatchSetFile, PatchNumOnly, isPatchSetFile} from '../../../types/types';
 import {appContext} from '../../../services/app-context';
@@ -56,7 +55,7 @@
 };
 
 export class ChangeComments {
-  private readonly _comments: {[path: string]: CommentInfo[]};
+  private readonly _comments: PathToCommentsInfoMap;
 
   private readonly _robotComments: {[path: string]: RobotCommentInfo[]};
 
@@ -71,41 +70,19 @@
    * elements of that which uses the gr-comment-api.
    */
   constructor(
-    comments: {[path: string]: CommentInfo[]} | undefined,
+    comments: PathToCommentsInfoMap | undefined,
     robotComments: {[path: string]: RobotCommentInfo[]} | undefined,
     drafts: {[path: string]: DraftInfo[]} | undefined,
     portedComments: PathToCommentsInfoMap | undefined,
     portedDrafts: PathToCommentsInfoMap | undefined
   ) {
-    this._comments = this._addPath(comments);
-    this._robotComments = this._addPath(robotComments);
-    this._drafts = this._addPath(drafts);
+    this._comments = addPath(comments);
+    this._robotComments = addPath(robotComments);
+    this._drafts = addPath(drafts);
     this._portedComments = portedComments || {};
     this._portedDrafts = portedDrafts || {};
   }
 
-  /**
-   * Add path info to every comment as CommentInfo returned
-   * from server does not have that.
-   *
-   * TODO(taoalpha): should consider changing BE to send path
-   * back within CommentInfo
-   */
-  _addPath<T>(
-    comments: {[path: string]: T[]} = {}
-  ): {[path: string]: Array<T & {path: string}>} {
-    const updatedComments: {[path: string]: Array<T & {path: string}>} = {};
-    for (const filePath of Object.keys(comments)) {
-      const allCommentsForPath = comments[filePath] || [];
-      if (allCommentsForPath.length) {
-        updatedComments[filePath] = allCommentsForPath.map(comment => {
-          return {...comment, path: filePath};
-        });
-      }
-    }
-    return updatedComments;
-  }
-
   get drafts() {
     return this._drafts;
   }
@@ -627,12 +604,6 @@
   }
 }
 
-// TODO(TS): move findCommentById out of class
-export const _testOnly_findCommentById =
-  ChangeComments.prototype.findCommentById;
-
-export const _testOnly_getCommentsForPath =
-  ChangeComments.prototype.getCommentsForPath;
 @customElement('gr-comment-api')
 export class GrCommentApi extends PolymerElement {
   static get template() {
@@ -644,56 +615,11 @@
 
   private readonly restApiService = appContext.restApiService;
 
-  /**
-   * Load all comments (with drafts and robot comments) for the given change
-   * number. The returned promise resolves when the comments have loaded, but
-   * does not yield the comment data.
-   */
-  loadAll(changeNum: NumericChangeId, patchNum?: PatchSetNum) {
-    const revision = patchNum || CURRENT;
-    const commentsPromise = [
-      this.restApiService.getDiffComments(changeNum),
-      this.restApiService.getDiffRobotComments(changeNum),
-      this.restApiService.getDiffDrafts(changeNum),
-      this.restApiService.getPortedComments(changeNum, revision),
-      this.restApiService.getPortedDrafts(changeNum, revision),
-    ];
-
-    return Promise.all(commentsPromise).then(
-      ([comments, robotComments, drafts, portedComments, portedDrafts]) => {
-        this._changeComments = new ChangeComments(
-          comments,
-          // TS 4.0.5 fails without 'as'
-          robotComments as PathToRobotCommentsInfoMap | undefined,
-          drafts,
-          portedComments,
-          portedDrafts
-        );
-        return this._changeComments;
-      }
-    );
-  }
-
-  /**
-   * Re-initialize _changeComments with a new ChangeComments object, that
-   * uses the previous values for comments and robot comments, but fetches
-   * updated draft comments.
-   */
-  reloadDrafts(changeNum: NumericChangeId) {
-    if (!this._changeComments) {
-      return this.loadAll(changeNum);
-    }
-    return this.restApiService.getDiffDrafts(changeNum).then(drafts => {
-      this._changeComments = this._changeComments!.cloneWithUpdatedDrafts(
-        drafts
-      );
-      return this._changeComments;
-    });
-  }
+  private readonly commentsService = appContext.commentsService;
 
   reloadPortedComments(changeNum: NumericChangeId, patchNum: PatchSetNum) {
     if (!this._changeComments) {
-      this.loadAll(changeNum);
+      this.commentsService.loadAll(changeNum);
       return Promise.resolve();
     }
     return Promise.all([
diff --git a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_test.js b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_test.js
index fbbe1fb..7e01371 100644
--- a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_test.js
@@ -34,107 +34,11 @@
     element = basicFixture.instantiate();
   });
 
-  test('loads logged-out', () => {
-    const changeNum = 1234;
-
-    stubRestApi('getLoggedIn').returns(Promise.resolve(false));
-    const diffCommentsSpy = stubRestApi('getDiffComments').returns(
-        Promise.resolve({
-          'foo.c': [{id: '123', message: 'foo bar', in_reply_to: '321'}],
-        }));
-    const diffRobotCommentsSpy = stubRestApi('getDiffRobotComments').returns(
-        Promise.resolve({'foo.c': [{id: '321', message: 'done'}]}));
-    const diffDraftsSpy = stubRestApi('getDiffDrafts').returns(
-        Promise.resolve({}));
-
-    return element.loadAll(changeNum).then(() => {
-      assert.isTrue(diffCommentsSpy.calledWithExactly(changeNum));
-      assert.isTrue(diffRobotCommentsSpy.calledWithExactly(changeNum));
-      assert.isTrue(diffDraftsSpy.calledWithExactly(changeNum));
-      assert.isOk(element._changeComments._comments);
-      assert.isOk(element._changeComments._robotComments);
-      assert.deepEqual(element._changeComments._drafts, {});
-    });
-  });
-
-  test('loads logged-in', () => {
-    const changeNum = 1234;
-
-    const getCommentsStub = stubRestApi('getDiffComments').returns(
-        Promise.resolve({
-          'foo.c': [{id: '123', message: 'foo bar', in_reply_to: '321'}],
-        })
-    );
-    const getRobotCommentsStub = stubRestApi('getDiffRobotComments')
-        .returns(Promise.resolve({'foo.c': [{id: '321', message: 'done'}]}));
-    const getDraftsStub = stubRestApi('getDiffDrafts')
-        .returns(Promise.resolve({'foo.c': [{id: '555', message: 'ack'}]}));
-
-    return element.loadAll(changeNum).then(() => {
-      assert.isTrue(getCommentsStub.calledWithExactly(changeNum));
-      assert.isTrue(getRobotCommentsStub.calledWithExactly(changeNum));
-      assert.isTrue(getDraftsStub.calledWithExactly(changeNum));
-      assert.isOk(element._changeComments._comments);
-      assert.isOk(element._changeComments._robotComments);
-      assert.notDeepEqual(element._changeComments._drafts, {});
-    });
-  });
-
-  suite('reloadDrafts', () => {
-    let commentStub;
-    let robotCommentStub;
-    let draftStub;
-    setup(() => {
-      commentStub = stubRestApi('getDiffComments')
-          .returns(Promise.resolve({}));
-      robotCommentStub = stubRestApi(
-          'getDiffRobotComments').returns(Promise.resolve({}));
-      draftStub = stubRestApi('getDiffDrafts')
-          .returns(Promise.resolve({}));
-    });
-
-    test('without loadAll first', done => {
-      assert.isNotOk(element._changeComments);
-      sinon.spy(element, 'loadAll');
-      element.reloadDrafts().then(() => {
-        assert.isTrue(element.loadAll.called);
-        assert.isOk(element._changeComments);
-        assert.equal(commentStub.callCount, 1);
-        assert.equal(robotCommentStub.callCount, 1);
-        assert.equal(draftStub.callCount, 1);
-        done();
-      });
-    });
-
-    test('with loadAll first', done => {
-      assert.isNotOk(element._changeComments);
-      element.loadAll()
-          .then(() => {
-            assert.isOk(element._changeComments);
-            assert.equal(commentStub.callCount, 1);
-            assert.equal(robotCommentStub.callCount, 1);
-            assert.equal(draftStub.callCount, 1);
-            return element.reloadDrafts();
-          })
-          .then(() => {
-            assert.isOk(element._changeComments);
-            assert.equal(commentStub.callCount, 1);
-            assert.equal(robotCommentStub.callCount, 1);
-            assert.equal(draftStub.callCount, 2);
-            done();
-          });
-    });
-  });
-
   suite('_changeComment methods', () => {
-    setup(done => {
-      const changeNum = 1234;
+    setup(() => {
       stubRestApi('getDiffComments').returns(Promise.resolve({}));
       stubRestApi('getDiffRobotComments').returns(Promise.resolve({}));
       stubRestApi('getDiffDrafts').returns(Promise.resolve({}));
-      element.loadAll(changeNum).then(() => {
-        done();
-      });
     });
 
     suite('ported comments', () => {
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 733c940..462c334 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
@@ -64,6 +64,10 @@
   };
 }
 
+export function getLineNumberCellWidth(prefs: DiffPreferencesInfo) {
+  return prefs.font_size * 4;
+}
+
 @customElement('gr-diff-builder')
 export class GrDiffBuilderElement extends PolymerElement {
   static get template() {
@@ -212,7 +216,7 @@
     this.$.processor.keyLocations = keyLocations;
 
     this._clearDiffContent();
-    this._builder.addColumns(this.diffElement, prefs.font_size);
+    this._builder.addColumns(this.diffElement, getLineNumberCellWidth(prefs));
 
     const isBinary = !!(this.isImageDiff || this.diff.binary);
 
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-side-by-side.ts b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-side-by-side.ts
index da1d971..51360b9 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-side-by-side.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-side-by-side.ts
@@ -74,8 +74,7 @@
     return sectionEl;
   }
 
-  addColumns(outputEl: HTMLElement, fontSize: number): void {
-    const width = fontSize * 4;
+  addColumns(outputEl: HTMLElement, lineNumberWidth: number): void {
     const colgroup = document.createElement('colgroup');
 
     // Add the blame column.
@@ -84,7 +83,7 @@
 
     // Add left-side line number.
     col = this._createElement('col', 'left');
-    col.setAttribute('width', width.toString());
+    col.setAttribute('width', lineNumberWidth.toString());
     colgroup.appendChild(col);
 
     // Add left-side content.
@@ -92,7 +91,7 @@
 
     // Add right-side line number.
     col = document.createElement('col');
-    col.setAttribute('width', width.toString());
+    col.setAttribute('width', lineNumberWidth.toString());
     colgroup.appendChild(col);
 
     // Add right-side content.
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified.ts b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified.ts
index 4ecfcbf..a16aa07 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified.ts
@@ -78,8 +78,7 @@
     return sectionEl;
   }
 
-  addColumns(outputEl: HTMLElement, fontSize: number): void {
-    const width = fontSize * 4;
+  addColumns(outputEl: HTMLElement, lineNumberWidth: number): void {
     const colgroup = document.createElement('colgroup');
 
     // Add the blame column.
@@ -88,12 +87,12 @@
 
     // Add left-side line number.
     col = document.createElement('col');
-    col.setAttribute('width', width.toString());
+    col.setAttribute('width', lineNumberWidth.toString());
     colgroup.appendChild(col);
 
     // Add right-side line number.
     col = document.createElement('col');
-    col.setAttribute('width', width.toString());
+    col.setAttribute('width', lineNumberWidth.toString());
     colgroup.appendChild(col);
 
     // Add the content.
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.ts b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.ts
index 5634238..864fd79 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.ts
@@ -31,7 +31,11 @@
   GrContextControlsShowConfig,
 } from '../gr-context-controls/gr-context-controls';
 import {BlameInfo} from '../../../types/common';
-import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
+import {
+  DiffInfo,
+  DiffPreferencesInfo,
+  DiffResponsiveMode,
+} from '../../../types/diff';
 import {DiffViewMode, Side} from '../../../constants/constants';
 import {DiffLayer} from '../../../types/types';
 
@@ -71,6 +75,20 @@
   }
 }
 
+export function getResponsiveMode(
+  prefs: DiffPreferencesInfo,
+  renderPrefs?: RenderPreferences
+): DiffResponsiveMode {
+  if (renderPrefs?.responsive_mode) {
+    return renderPrefs.responsive_mode;
+  }
+  // Backwards compatibility to the line_wrapping param.
+  if (prefs.line_wrapping) {
+    return 'FULL_RESPONSIVE';
+  }
+  return 'NONE';
+}
+
 export abstract class GrDiffBuilder {
   private readonly _diff: DiffInfo;
 
@@ -494,9 +512,9 @@
 
     const {beforeNumber, afterNumber} = line;
     if (beforeNumber !== 'FILE' && beforeNumber !== 'LOST') {
-      const lineLimit = !this._prefs.line_wrapping
-        ? this._prefs.line_length
-        : Infinity;
+      const responsiveMode = getResponsiveMode(this._prefs, this._renderPrefs);
+      const lineLimit =
+        responsiveMode === 'NONE' ? this._prefs.line_length : Infinity;
       const contentText = this._formatText(
         line.text,
         this._prefs.tab_size,
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 72c7a62..d26e1af 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
@@ -87,6 +87,10 @@
 import {DiffContextExpandedEventDetail} from '../gr-diff-builder/gr-diff-builder';
 import {TokenHighlightLayer} from '../gr-diff-builder/token-highlight-layer';
 import {Timing} from '../../../constants/reporting';
+import {changeComments$} from '../../../services/comments/comments-model';
+import {takeUntil} from 'rxjs/operators';
+import {ChangeComments} from '../gr-comment-api/gr-comment-api';
+import {Subject} from 'rxjs';
 
 const MSG_EMPTY_BLAME = 'No blame information for this diff.';
 
@@ -145,12 +149,6 @@
    * @event show-auth-required
    */
 
-  /**
-   * Fired when a comment is saved or discarded
-   *
-   * @event diff-comments-modified
-   */
-
   @property({type: Number})
   changeNum?: NumericChangeId;
 
@@ -238,6 +236,9 @@
   diff?: DiffInfo;
 
   @property({type: Object})
+  changeComments?: ChangeComments;
+
+  @property({type: Object})
   _fetchDiffPromise: Promise<DiffInfo> | null = null;
 
   @property({type: Object})
@@ -272,6 +273,8 @@
 
   private readonly syntaxLayer = new GrSyntaxLayer();
 
+  disconnected$ = new Subject();
+
   constructor() {
     super();
     this.addEventListener(
@@ -284,12 +287,6 @@
       'create-comment',
       e => this._handleCreateComment(e)
     );
-    this.addEventListener('comment-discard', () =>
-      this._handleCommentSaveOrDiscard()
-    );
-    this.addEventListener('comment-save', () =>
-      this._handleCommentSaveOrDiscard()
-    );
     this.addEventListener('render-start', () => this._handleRenderStart());
     this.addEventListener('render-content', () => this._handleRenderContent());
     this.addEventListener('normalize-range', event =>
@@ -314,10 +311,16 @@
     this._getLoggedIn().then(loggedIn => {
       this._loggedIn = loggedIn;
     });
+    changeComments$
+      .pipe(takeUntil(this.disconnected$))
+      .subscribe(changeComments => {
+        this.changeComments = changeComments;
+      });
   }
 
   /** @override */
   disconnectedCallback() {
+    this.disconnected$.next();
     this.clear();
     super.disconnectedCallback();
   }
@@ -502,6 +505,16 @@
     );
   }
 
+  @observe('changeComments', 'patchRange', 'file')
+  computeFileThreads(
+    changeComments?: ChangeComments,
+    patchRange?: PatchRange,
+    file?: PatchSetFile
+  ) {
+    if (!changeComments || !patchRange || !file) return;
+    this.threads = changeComments.getThreadsBySideForFile(file, patchRange);
+  }
+
   _getFilesWeblinks(diff: DiffInfo) {
     if (!this.projectName || !this.commitRange || !this.path) return {};
     return {
@@ -1010,10 +1023,6 @@
       : null;
   }
 
-  _handleCommentSaveOrDiscard() {
-    fireEvent(this, 'diff-comments-modified');
-  }
-
   _syntaxHighlightingEnabledChanged(_syntaxHighlightingEnabled: boolean) {
     this.syntaxLayer.setEnabled(_syntaxHighlightingEnabled);
   }
@@ -1168,7 +1177,6 @@
     'normalize-range': CustomEvent;
     'diff-context-expanded': CustomEvent<DiffContextExpandedEventDetail>;
     'create-comment': CustomEvent;
-    'comment-discard': CustomEvent;
     'comment-update': CustomEvent;
     'comment-save': CustomEvent;
     'root-id-changed': CustomEvent;
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector.ts b/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector.ts
index b576896..0f8752d 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector.ts
@@ -43,6 +43,9 @@
   @property({type: Boolean})
   saveOnChange = false;
 
+  @property({type: Boolean})
+  showTooltipBelow = false;
+
   private readonly restApiService = appContext.restApiService;
 
   /** @override */
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector_html.ts b/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector_html.ts
index 9943b58..3ebb58f 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector_html.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector_html.ts
@@ -34,6 +34,7 @@
     id="sideBySideBtn"
     link=""
     has-tooltip=""
+    position-below="[[showTooltipBelow]]"
     class$="[[_computeSideBySideSelected(mode)]]"
     title="Side-by-side diff"
     aria-pressed="[[isSideBySideSelected(mode)]]"
@@ -45,6 +46,7 @@
     id="unifiedBtn"
     link=""
     has-tooltip=""
+    position-below="[[showTooltipBelow]]"
     title="Unified diff"
     class$="[[_computeUnifiedSelected(mode)]]"
     aria-pressed="[[isUnifiedSelected(mode)]]"
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts
index 54985bb..fe8a1f7 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts
@@ -107,6 +107,9 @@
 import {toggleClass, getKeyboardEvent} from '../../../utils/dom-util';
 import {CursorMoveResult} from '../../../api/core';
 import {throttleWrap} from '../../../utils/async-util';
+import {changeComments$} from '../../../services/comments/comments-model';
+import {takeUntil} from 'rxjs/operators';
+import {Subject} from 'rxjs';
 
 const ERR_REVIEW_STATUS = 'Couldn’t change file review status.';
 const MSG_LOADING_BLAME = 'Loading blame...';
@@ -332,12 +335,16 @@
 
   private readonly restApiService = appContext.restApiService;
 
+  private readonly commentsService = appContext.commentsService;
+
   _throttledToggleFileReviewed?: EventListener;
 
   _onRenderHandler?: EventListener;
 
   private cursor = new GrDiffCursor();
 
+  disconnected$ = new Subject();
+
   /** @override */
   connectedCallback() {
     super.connectedCallback();
@@ -347,7 +354,11 @@
     this._getLoggedIn().then(loggedIn => {
       this._loggedIn = loggedIn;
     });
-
+    changeComments$
+      .pipe(takeUntil(this.disconnected$))
+      .subscribe(changeComments => {
+        this._changeComments = changeComments;
+      });
     this.addEventListener('open-fix-preview', e => this._onOpenFixPreview(e));
     this.cursor.replaceDiffs([this.$.diffHost]);
     this._onRenderHandler = (_: Event) => {
@@ -358,6 +369,7 @@
 
   /** @override */
   disconnectedCallback() {
+    this.disconnected$.next();
     this.cursor.dispose();
     if (this._onRenderHandler) {
       this.$.diffHost.removeEventListener('render', this._onRenderHandler);
@@ -365,6 +377,26 @@
     super.disconnectedCallback();
   }
 
+  @observe('_changeComments', '_path', '_patchRange')
+  computeThreads(
+    changeComments?: ChangeComments,
+    path?: string,
+    patchRange?: PatchRange
+  ) {
+    if (
+      changeComments === undefined ||
+      path === undefined ||
+      patchRange === undefined
+    ) {
+      return;
+    }
+    // TODO(dhruvsri): check if basePath should be set here
+    this.$.diffHost.threads = changeComments.getThreadsBySideForFile(
+      {path},
+      patchRange
+    );
+  }
+
   _getLoggedIn(): Promise<boolean> {
     return this.restApiService.getLoggedIn();
   }
@@ -1117,7 +1149,7 @@
     );
 
     promises.push(this._getChangeDetail(this._changeNum));
-    promises.push(this._loadComments(value.patchNum));
+    this._loadComments(value.patchNum);
 
     promises.push(this._getChangeEdit());
 
@@ -1130,17 +1162,6 @@
         this._initPatchRange();
         this._initCommitRange();
 
-        assertIsDefined(this._path, '_path');
-        if (!this._changeComments)
-          throw new Error('change comments must be defined');
-        assertIsDefined(this._patchRange, '_patchRange');
-
-        // TODO(dhruvsri): check if basePath should be set here
-        this.$.diffHost.threads = this._changeComments.getThreadsBySideForFile(
-          {path: this._path},
-          this._patchRange
-        );
-
         const edit = r[4] as EditInfo | undefined;
         if (edit) {
           this.set(`_change.revisions.${edit.commit.commit}`, {
@@ -1558,15 +1579,18 @@
 
   _loadComments(patchSet?: PatchSetNum) {
     assertIsDefined(this._changeNum, '_changeNum');
-    return this.$.commentAPI
-      .loadAll(this._changeNum, patchSet)
-      .then(comments => {
-        this._changeComments = comments;
-      });
+    return this.commentsService.loadAll(this._changeNum, patchSet);
   }
 
-  @observe('_files.changeFilesByPath', '_path', '_patchRange', '_projectConfig')
+  @observe(
+    '_changeComments',
+    '_files.changeFilesByPath',
+    '_path',
+    '_patchRange',
+    '_projectConfig'
+  )
   _recomputeComments(
+    changeComments?: ChangeComments,
     files?: {[path: string]: FileInfo},
     path?: string,
     patchRange?: PatchRange,
@@ -1576,11 +1600,11 @@
     if (!path) return;
     if (!patchRange) return;
     if (!projectConfig) return;
-    if (!this._changeComments) return;
+    if (!changeComments) return;
 
     const file = files[path];
     if (file && file.old_path) {
-      this.$.diffHost.threads = this._changeComments.getThreadsBySideForFile(
+      this.$.diffHost.threads = changeComments.getThreadsBySideForFile(
         {path, basePath: file.old_path},
         patchRange
       );
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_html.ts b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_html.ts
index 8d69007d..743f905 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_html.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_html.ts
@@ -343,6 +343,7 @@
             id="modeSelect"
             save-on-change="[[!_diffPrefsDisabled]]"
             mode="{{changeViewState.diffMode}}"
+            show-tooltip-below=""
           ></gr-diff-mode-selector>
         </div>
         <span
@@ -355,6 +356,7 @@
               link=""
               class="prefsButton"
               has-tooltip=""
+              position-below=""
               title="Diff preferences"
               on-click="_handlePrefsTap"
               ><iron-icon icon="gr-icons:settings"></iron-icon
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 ebcfa18..1960ced 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
@@ -21,7 +21,7 @@
 import {ChangeStatus} from '../../../constants/constants.js';
 import {TestKeyboardShortcutBinder, stubRestApi} from '../../../test/test-utils.js';
 import {Shortcut} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.js';
-import {ChangeComments, _testOnly_findCommentById, _testOnly_getCommentsForPath} from '../gr-comment-api/gr-comment-api.js';
+import {ChangeComments} from '../gr-comment-api/gr-comment-api.js';
 import {GerritView} from '../../../services/router/router-model.js';
 import {
   createChange,
@@ -40,6 +40,7 @@
   suite('basic tests', () => {
     let element;
     let clock;
+    let diffCommentsStub;
 
     suiteSetup(() => {
       const kb = TestKeyboardShortcutBinder.push();
@@ -100,7 +101,8 @@
           Promise.resolve({}));
       stubRestApi('getChangeFiles').returns(Promise.resolve({}));
       stubRestApi('saveFileReviewed').returns(Promise.resolve());
-      stubRestApi('getDiffComments').returns(Promise.resolve({}));
+      diffCommentsStub = stubRestApi('getDiffComments');
+      diffCommentsStub.returns(Promise.resolve({}));
       stubRestApi('getDiffRobotComments').returns(Promise.resolve({}));
       stubRestApi('getDiffDrafts').returns(Promise.resolve({}));
       stubRestApi('getPortedComments').returns(Promise.resolve({}));
@@ -114,32 +116,21 @@
         patchNum: 77,
         basePatchNum: 'PARENT',
       };
-      sinon.stub(element.$.commentAPI, 'loadAll').returns(Promise.resolve({
-        _comments: {'/COMMIT_MSG': [
-          {
-            ...createComment(),
-            id: 'c1',
-            line: 10,
-            patch_set: 2,
-            path: '/COMMIT_MSG',
-          }, {
-            ...createComment(),
-            id: 'c3',
-            line: 10,
-            patch_set: 'PARENT',
-            path: '/COMMIT_MSG',
-          },
-        ]},
-        computeCommentThreadCount: () => {},
-        computeCommentsString: () => '',
-        computeUnresolvedNum: () => {},
-        getPaths: () => {},
-        getThreadsBySideForFile: () => [],
-        getCommentsForPath: _testOnly_getCommentsForPath,
-        findCommentById: _testOnly_findCommentById,
-
-      }));
-      await element._loadComments();
+      element._changeComments = new ChangeComments({'/COMMIT_MSG': [
+        {
+          ...createComment(),
+          id: 'c1',
+          line: 10,
+          patch_set: 2,
+          path: '/COMMIT_MSG',
+        }, {
+          ...createComment(),
+          id: 'c3',
+          line: 10,
+          patch_set: 'PARENT',
+          path: '/COMMIT_MSG',
+        },
+      ]});
       await flush();
     });
 
@@ -186,6 +177,22 @@
       });
 
       test('comment url resolves to comment.patch_set vs latest', () => {
+        diffCommentsStub.returns(Promise.resolve({
+          '/COMMIT_MSG': [
+            {
+              ...createComment(),
+              id: 'c1',
+              line: 10,
+              patch_set: 2,
+              path: '/COMMIT_MSG',
+            }, {
+              ...createComment(),
+              id: 'c3',
+              line: 10,
+              patch_set: 'PARENT',
+              path: '/COMMIT_MSG',
+            },
+          ]}));
         element.params = {
           view: GerritNav.View.DIFF,
           changeNum: '42',
@@ -236,6 +243,22 @@
     test('unchanged diff X vs latest from comment links navigates to base vs X'
         , () => {
           const diffNavStub = sinon.stub(GerritNav, 'navigateToDiff');
+          diffCommentsStub.returns(Promise.resolve({
+            '/COMMIT_MSG': [
+              {
+                ...createComment(),
+                id: 'c1',
+                line: 10,
+                patch_set: 2,
+                path: '/COMMIT_MSG',
+              }, {
+                ...createComment(),
+                id: 'c3',
+                line: 10,
+                patch_set: 'PARENT',
+                path: '/COMMIT_MSG',
+              },
+            ]}));
           sinon.stub(element.reporting, 'diffViewDisplayed');
           sinon.stub(element, '_loadBlame');
           sinon.stub(element.$.diffHost, 'reload').returns(Promise.resolve());
@@ -265,6 +288,22 @@
     test('unchanged diff Base vs latest from comment does not navigate'
         , () => {
           const diffNavStub = sinon.stub(GerritNav, 'navigateToDiff');
+          diffCommentsStub.returns(Promise.resolve({
+            '/COMMIT_MSG': [
+              {
+                ...createComment(),
+                id: 'c1',
+                line: 10,
+                patch_set: 2,
+                path: '/COMMIT_MSG',
+              }, {
+                ...createComment(),
+                id: 'c3',
+                line: 10,
+                patch_set: 'PARENT',
+                path: '/COMMIT_MSG',
+              },
+            ]}));
           sinon.stub(element.reporting, 'diffViewDisplayed');
           sinon.stub(element, '_loadBlame');
           sinon.stub(element.$.diffHost, 'reload').returns(Promise.resolve());
@@ -322,6 +361,22 @@
     });
 
     test('diff toast to go to latest is shown and not base', async () => {
+      diffCommentsStub.returns(Promise.resolve({
+        '/COMMIT_MSG': [
+          {
+            ...createComment(),
+            id: 'c1',
+            line: 10,
+            patch_set: 2,
+            path: '/COMMIT_MSG',
+          }, {
+            ...createComment(),
+            id: 'c3',
+            line: 10,
+            patch_set: 'PARENT',
+            path: '/COMMIT_MSG',
+          },
+        ]}));
       sinon.stub(element.reporting, 'diffViewDisplayed');
       sinon.stub(element, '_loadBlame');
       sinon.stub(element.$.diffHost, 'reload').returns(Promise.resolve());
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 3d9bba7..fdde355 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.ts
@@ -47,7 +47,10 @@
   DiffPreferencesInfoKey,
 } from '../../../types/diff';
 import {GrDiffHighlight} from '../gr-diff-highlight/gr-diff-highlight';
-import {GrDiffBuilderElement} from '../gr-diff-builder/gr-diff-builder-element';
+import {
+  GrDiffBuilderElement,
+  getLineNumberCellWidth,
+} from '../gr-diff-builder/gr-diff-builder-element';
 import {
   CoverageRange,
   DiffLayer,
@@ -74,7 +77,10 @@
 import {isSafari, toggleClass} from '../../../utils/dom-util';
 import {assertIsDefined} from '../../../utils/common-util';
 import {debounce, DelayedTask} from '../../../utils/async-util';
-import {DiffContextExpandedEventDetail} from '../gr-diff-builder/gr-diff-builder';
+import {
+  DiffContextExpandedEventDetail,
+  getResponsiveMode,
+} from '../gr-diff-builder/gr-diff-builder';
 
 const NO_NEWLINE_BASE = 'No newline at end of base file.';
 const NO_NEWLINE_REVISION = 'No newline at end of revision file.';
@@ -726,33 +732,46 @@
     if (!prefs) return;
 
     this.blame = null;
+    this._updatePreferenceStyles(prefs, this.renderPrefs);
 
+    if (this.diff && !this.noRenderOnPrefsChange) {
+      this._debounceRenderDiffTable();
+    }
+  }
+
+  _updatePreferenceStyles(
+    prefs: DiffPreferencesInfo,
+    renderPrefs?: RenderPreferences
+  ) {
     const lineLength =
       this.path === COMMIT_MSG_PATH
         ? COMMIT_MSG_LINE_LENGTH
         : prefs.line_length;
+    const sideBySide = this.viewMode === 'SIDE_BY_SIDE';
     const stylesToUpdate: {[key: string]: string} = {};
 
-    if (prefs.line_wrapping) {
-      this._diffTableClass = 'full-width';
-      if (this.viewMode === 'SIDE_BY_SIDE') {
-        stylesToUpdate['--content-width'] = 'none';
-        stylesToUpdate['--line-limit'] = `${lineLength}ch`;
-      }
+    const responsiveMode = getResponsiveMode(prefs, renderPrefs);
+    const responsive =
+      responsiveMode === 'FULL_RESPONSIVE' || responsiveMode === 'SHRINK_ONLY';
+    this._diffTableClass = responsive ? 'responsive' : '';
+    const lineLimit = `${lineLength}ch`;
+    stylesToUpdate['--line-limit'] = lineLimit;
+    stylesToUpdate['--content-width'] = responsive ? 'none' : lineLimit;
+    if (responsiveMode === 'SHRINK_ONLY') {
+      // Calculating ideal (initial) width for the whole table.
+      const contentWidth = `${sideBySide ? 2 : 1} * ${lineLimit}`;
+      const lineNumberWidth = `2 * ${getLineNumberCellWidth(prefs)}px`;
+      stylesToUpdate[
+        '--diff-max-width'
+      ] = `calc(${contentWidth} + ${lineNumberWidth})`;
     } else {
-      this._diffTableClass = '';
-      stylesToUpdate['--content-width'] = `${lineLength}ch`;
+      stylesToUpdate['--diff-max-width'] = 'none';
     }
-
     if (prefs.font_size) {
       stylesToUpdate['--font-size'] = `${prefs.font_size}px`;
     }
 
     this.updateStyles(stylesToUpdate);
-
-    if (this.diff && !this.noRenderOnPrefsChange) {
-      this._debounceRenderDiffTable();
-    }
   }
 
   _renderPrefsChanged(renderPrefs?: RenderPreferences) {
@@ -766,6 +785,9 @@
     if (renderPrefs.hide_line_length_indicator) {
       this.classList.add('hide-line-length-indicator');
     }
+    if (this.prefs) {
+      this._updatePreferenceStyles(this.prefs, renderPrefs);
+    }
     this.$.diffBuilder.updateRenderPrefs(renderPrefs);
   }
 
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 9da3bf1..5b248da 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
@@ -50,6 +50,9 @@
       background-color: var(--diff-blank-background-color);
     }
     .diffContainer {
+      max-width: var(--diff-max-width, none);
+    }
+    .diffContainer {
       display: flex;
       font-family: var(--monospace-font-family);
       @apply --diff-container-styles;
@@ -169,10 +172,10 @@
     .image-diff .content {
       background-color: var(--diff-blank-background-color);
     }
-    .full-width {
+    .responsive {
       width: 100%;
     }
-    .full-width .contentText {
+    .responsive .contentText {
       white-space: break-spaces;
       word-wrap: break-word;
     }
@@ -423,15 +426,22 @@
       color: var(--link-color);
       text-decoration: none;
     }
-    .full-width td.blame {
+    .responsive td.blame {
       overflow: hidden;
       width: 200px;
     }
     /** Support the line length indicator **/
-    .full-width td.content .contentText {
-      background-image: var(--line-length-indicator);
+    .responsive td.content .contentText {
+      /*
+      Same strategy as in https://stackoverflow.com/questions/1179928/how-can-i-put-a-vertical-line-down-the-center-of-a-div
+      */
+      background-image: linear-gradient(
+        var(--line-length-indicator-color),
+        var(--line-length-indicator-color)
+      );
+      background-size: 1px 100%;
       background-position: var(--line-limit) 0;
-      background-repeat: repeat-y;
+      background-repeat: no-repeat;
     }
     .newlineWarning {
       color: var(--deemphasized-text-color);
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_test.js b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_test.js
index 53a2915..73b587b 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_test.js
@@ -36,7 +36,7 @@
 suite('gr-diff tests', () => {
   let element;
 
-  const MINIMAL_PREFS = {tab_size: 2, line_length: 80};
+  const MINIMAL_PREFS = {tab_size: 2, line_length: 80, font_size: 12};
 
   setup(() => {
 
@@ -85,7 +85,66 @@
     element = basicFixture.instantiate();
     element.prefs = {...MINIMAL_PREFS, line_wrapping: false};
     flush();
-    assert.isNotOk(getComputedStyleValue('--line-limit', element));
+    assert.equal(getComputedStyleValue('--line-limit', element), '80ch');
+  });
+  suite('FULL_RESPONSIVE mode', () => {
+    setup(() => {
+      element = basicFixture.instantiate();
+      element.prefs = {...MINIMAL_PREFS};
+      element.renderPrefs = {responsive_mode: 'FULL_RESPONSIVE'};
+    });
+
+    test('line limit is based on line_length', () => {
+      element.prefs = {...element.prefs, line_length: 100};
+      flush();
+      assert.equal(getComputedStyleValue('--line-limit', element), '100ch');
+    });
+
+    test('content-width should not be defined', () => {
+      flush();
+      assert.equal(getComputedStyleValue('--content-width', element), 'none');
+    });
+  });
+
+  suite('SHRINK_ONLY mode', () => {
+    setup(() => {
+      element = basicFixture.instantiate();
+      element.prefs = {...MINIMAL_PREFS};
+      element.renderPrefs = {responsive_mode: 'SHRINK_ONLY'};
+    });
+
+    test('line limit is based on line_length', () => {
+      element.prefs = {...element.prefs, line_length: 100};
+      flush();
+      assert.equal(getComputedStyleValue('--line-limit', element), '100ch');
+    });
+
+    test('content-width should not be defined', () => {
+      flush();
+      assert.equal(getComputedStyleValue('--content-width', element), 'none');
+    });
+
+    test('max-width considers two content columns in side-by-side', () => {
+      element.viewMode = 'SIDE_BY_SIDE';
+      flush();
+      assert.equal(getComputedStyleValue('--diff-max-width', element),
+          'calc(2 * 80ch + 2 * 48px)');
+    });
+
+    test('max-width considers one content column in unified', () => {
+      element.viewMode = 'UNIFIED_DIFF';
+      flush();
+      assert.equal(getComputedStyleValue('--diff-max-width', element),
+          'calc(1 * 80ch + 2 * 48px)');
+    });
+
+    test('max-width considers font-size', () => {
+      element.prefs = {...element.prefs, font_size: 13};
+      flush();
+      // Each line number column: 4 * 13 = 52px
+      assert.equal(getComputedStyleValue('--diff-max-width', element),
+          'calc(2 * 80ch + 2 * 52px)');
+    });
   });
 
   suite('not logged in', () => {
diff --git a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_html.ts b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_html.ts
index e4ffe0f..5ab8449 100644
--- a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_html.ts
+++ b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_html.ts
@@ -59,12 +59,8 @@
   </span>
   <span is="dom-if" if="[[filesWeblinks.meta_a]]" class="filesWeblinks">
     <template is="dom-repeat" items="[[filesWeblinks.meta_a]]" as="weblink">
-      <a
-        target="_blank"
-        rel="noopener"
-        href$="[[weblink.url]]"
-        aria-label$="Browse with [[weblink.name]]"
-        >Browse with [[weblink.name]]</a
+      <a target="_blank" rel="noopener" href$="[[weblink.url]]"
+        >[[weblink.name]]</a
       >
     </template>
   </span>
@@ -79,13 +75,7 @@
     </gr-dropdown-list>
     <span is="dom-if" if="[[filesWeblinks.meta_b]]" class="filesWeblinks">
       <template is="dom-repeat" items="[[filesWeblinks.meta_b]]" as="weblink">
-        <a
-          target="_blank"
-          rel="noopener"
-          href$="[[weblink.url]]"
-          aria-label$="Browse with [[weblink.name]]"
-          >Browse with [[weblink.name]]</a
-        >
+        <a target="_blank" href$="[[weblink.url]]">[[weblink.name]]</a>
       </template>
     </span>
   </span>
diff --git a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.js b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.js
index 9fc19ff..89b8b4a 100644
--- a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.js
@@ -63,7 +63,7 @@
 
     // Stub methods on the changeComments object after changeComments has
     // been initialized.
-    return commentApiWrapper.loadComments();
+    element.changeComments = new ChangeComments();
   });
 
   test('enabled/disabled options', () => {
@@ -217,11 +217,10 @@
         // Should be recomputed for each available patch
         sinon.stub(element, '_computeBaseDropdownContent');
         assert.equal(element._computeBaseDropdownContent.callCount, 0);
-        commentApiWrapper.loadComments().then()
-            .then(() => {
-              assert.equal(element._computeBaseDropdownContent.callCount, 1);
-              done();
-            });
+        element.changeComments = new ChangeComments();
+        flush();
+        assert.equal(element._computeBaseDropdownContent.callCount, 1);
+        done();
       });
 
   test('_computePatchDropdownContent called when basePatchNum updates', () => {
@@ -248,33 +247,6 @@
     assert.equal(element._computePatchDropdownContent.callCount, 1);
   });
 
-  test('_computePatchDropdownContent called when comments update', done => {
-    element.revisions = [
-      {commit: {parents: []}},
-      {commit: {parents: []}},
-      {commit: {parents: []}},
-      {commit: {parents: []}},
-    ];
-    element.revisionInfo = getInfo(element.revisions);
-    element.availablePatches = [
-      {num: 1, sha: '1'},
-      {num: 2, sha: '2'},
-      {num: 3, sha: '3'},
-      {num: 'edit', sha: '4'},
-    ];
-    element.patchNum = 2;
-    element.basePatchNum = 'PARENT';
-    flush();
-
-    // Should be recomputed for each available patch
-    sinon.stub(element, '_computePatchDropdownContent');
-    assert.equal(element._computePatchDropdownContent.callCount, 0);
-    commentApiWrapper.loadComments().then()
-        .then(() => {
-          done();
-        });
-  });
-
   test('_computePatchDropdownContent', () => {
     const availablePatches = [
       {num: 'edit', sha: '1'},
@@ -349,13 +321,9 @@
     flush();
     const domApi = dom(element.root);
     assert.equal(
-        domApi.querySelector('a[href="f.oo"]').textContent,
-        'Browse with foo'
-    );
+        domApi.querySelector('a[href="f.oo"]').textContent, 'foo');
     assert.equal(
-        domApi.querySelector('a[href="ba.r"]').textContent,
-        'Browse with bar'
-    );
+        domApi.querySelector('a[href="ba.r"]').textContent, 'bar');
   });
 
   test('_computePatchSetCommentsString', () => {
diff --git a/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search.ts b/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search.ts
index 7e4edd6..f125bfa 100644
--- a/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search.ts
+++ b/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search.ts
@@ -48,7 +48,7 @@
   _loading = true;
 
   @property({type: String})
-  _filter = '';
+  _filter?: string;
 
   private readonly restApiService = appContext.restApiService;
 
diff --git a/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_html.ts b/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_html.ts
index dd1faeb..95ce1ec 100644
--- a/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_html.ts
+++ b/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_html.ts
@@ -25,7 +25,6 @@
   </style>
   <gr-list-view
     filter="[[_filter]]"
-    items="false"
     offset="0"
     loading="[[_loading]]"
     path="/Documentation"
diff --git a/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_test.js b/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_test.ts
similarity index 68%
rename from polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_test.js
rename to polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_test.ts
index f5e47ca..9c196c7 100644
--- a/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_test.js
+++ b/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_test.ts
@@ -15,15 +15,18 @@
  * limitations under the License.
  */
 
-import '../../../test/common-test-setup-karma.js';
-import './gr-documentation-search.js';
-import {page} from '../../../utils/page-wrapper-utils.js';
-import 'lodash/lodash.js';
-import {stubRestApi} from '../../../test/test-utils.js';
+import '../../../test/common-test-setup-karma';
+import './gr-documentation-search';
+import {GrDocumentationSearch} from './gr-documentation-search';
+import {page} from '../../../utils/page-wrapper-utils';
+import 'lodash/lodash';
+import {stubRestApi} from '../../../test/test-utils';
+import {ListViewParams} from '../../../mixins/gr-list-view-mixin/gr-list-view-mixin';
+import {DocResult} from '../../../types/common';
 
 const basicFixture = fixtureFromElement('gr-documentation-search');
 
-let counter;
+let counter: number;
 const documentationGenerator = () => {
   return {
     title: `Gerrit Code Review - REST API Developers Notes${++counter}`,
@@ -32,10 +35,10 @@
 };
 
 suite('gr-documentation-search tests', () => {
-  let element;
-  let documentationSearches;
+  let element: GrDocumentationSearch;
+  let documentationSearches: DocResult[];
 
-  let value;
+  let value: ListViewParams;
 
   setup(() => {
     sinon.stub(page, 'show');
@@ -47,16 +50,23 @@
     setup(done => {
       documentationSearches = _.times(26, documentationGenerator);
       stubRestApi('getDocumentationSearches').returns(
-          Promise.resolve(documentationSearches));
-      element._paramsChanged(value).then(() => { flush(done); });
+        Promise.resolve(documentationSearches)
+      );
+      element._paramsChanged(value).then(() => {
+        flush(done);
+      });
     });
 
     test('test for test repo in the list', done => {
       flush(() => {
-        assert.equal(element._documentationSearches[0].title,
-            'Gerrit Code Review - REST API Developers Notes1');
-        assert.equal(element._documentationSearches[0].url,
-            'Documentation/dev-rest-api.html');
+        assert.equal(
+          element._documentationSearches![0].title,
+          'Gerrit Code Review - REST API Developers Notes1'
+        );
+        assert.equal(
+          element._documentationSearches![0].url,
+          'Documentation/dev-rest-api.html'
+        );
         done();
       });
     });
@@ -65,12 +75,12 @@
   suite('filter', () => {
     setup(() => {
       documentationSearches = _.times(25, documentationGenerator);
-      _.times(1, documentationSearches);
     });
 
     test('_paramsChanged', async () => {
       const stub = stubRestApi('getDocumentationSearches').returns(
-          Promise.resolve(documentationSearches));
+        Promise.resolve(documentationSearches)
+      );
       const value = {filter: 'test'};
       await element._paramsChanged(value);
       assert.isTrue(stub.lastCall.calledWithExactly('test'));
@@ -84,7 +94,6 @@
       assert.equal(getComputedStyle(element.$.loading).display, 'block');
 
       element._loading = false;
-      element._repos = _.times(25, documentationGenerator);
 
       flush();
       assert.equal(element.computeLoadingClass(element._loading), '');
@@ -92,4 +101,3 @@
     });
   });
 });
-
diff --git a/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls.ts b/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls.ts
index f8838a3..f0a3ca1 100644
--- a/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls.ts
+++ b/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls.ts
@@ -35,9 +35,12 @@
   AutocompleteSuggestion,
 } from '../../shared/gr-autocomplete/gr-autocomplete';
 import {appContext} from '../../../services/app-context';
+import {IronInputElement} from '@polymer/iron-input';
+import {fireAlert} from '../../../utils/event-util';
 
 export interface GrEditControls {
   $: {
+    newPathIronInput: IronInputElement;
     overlay: GrOverlay;
     openDialog: GrDialog;
     deleteDialog: GrDialog;
@@ -174,11 +177,15 @@
       '.dialog'
     ) as NodeListOf<GrDialog>;
     for (const dialog of dialogs) {
-      this._closeDialog(dialog);
+      // We set the second param to false, because this function
+      // is called by _showDialog which when you open either restore,
+      // delete or rename dialogs, it reseted the automatically
+      // set input.
+      this._closeDialog(dialog, false);
     }
   }
 
-  _closeDialog(dialog?: GrDialog, clearInputs = false) {
+  _closeDialog(dialog?: GrDialog, clearInputs = true) {
     if (!dialog) return;
 
     if (clearInputs) {
@@ -203,19 +210,25 @@
   }
 
   _handleOpenConfirm(e: Event) {
+    if (!this.change || !this._path) {
+      fireAlert(this, 'You must enter a path.');
+      this._closeDialog(this.$.openDialog);
+      return;
+    }
     const url = GerritNav.getEditUrlForDiff(
       this.change,
       this._path,
       this.patchNum
     );
     GerritNav.navigateToRelativeUrl(url);
-    this._closeDialog(this._getDialogFromEvent(e), true);
+    this._closeDialog(this._getDialogFromEvent(e));
   }
 
   _handleUploadConfirm(path: string, fileData: string) {
     if (!this.change || !path || !fileData) {
-      this._closeDialog(this.$.openDialog, true);
-      return;
+      fireAlert(this, 'You must enter a path and data.');
+      this._closeDialog(this.$.openDialog);
+      return Promise.resolve();
     }
     return this.restApiService
       .saveFileUploadChangeEdit(this.change._number, path, fileData)
@@ -223,7 +236,7 @@
         if (!res || !res.ok) {
           return;
         }
-        this._closeDialog(this.$.openDialog, true);
+        this._closeDialog(this.$.openDialog);
         GerritNav.navigateToChange(this.change);
       });
   }
@@ -232,39 +245,54 @@
     // Get the dialog before the api call as the event will change during bubbling
     // which will make Polymer.dom(e).path an empty array in polymer 2
     const dialog = this._getDialogFromEvent(e);
+    if (!this.change || !this._path) {
+      fireAlert(this, 'You must enter a path.');
+      this._closeDialog(dialog);
+      return;
+    }
     this.restApiService
       .deleteFileInChangeEdit(this.change._number, this._path)
       .then(res => {
         if (!res || !res.ok) {
           return;
         }
-        this._closeDialog(dialog, true);
+        this._closeDialog(dialog);
         GerritNav.navigateToChange(this.change);
       });
   }
 
   _handleRestoreConfirm(e: Event) {
     const dialog = this._getDialogFromEvent(e);
+    if (!this.change || !this._path) {
+      fireAlert(this, 'You must enter a path.');
+      this._closeDialog(dialog);
+      return;
+    }
     this.restApiService
       .restoreFileInChangeEdit(this.change._number, this._path)
       .then(res => {
         if (!res || !res.ok) {
           return;
         }
-        this._closeDialog(dialog, true);
+        this._closeDialog(dialog);
         GerritNav.navigateToChange(this.change);
       });
   }
 
   _handleRenameConfirm(e: Event) {
     const dialog = this._getDialogFromEvent(e);
+    if (!this.change || !this._path || !this._newPath) {
+      fireAlert(this, 'You must enter a old path and a new path.');
+      this._closeDialog(dialog);
+      return;
+    }
     return this.restApiService
       .renameFileInChangeEdit(this.change._number, this._path, this._newPath)
       .then(res => {
         if (!res || !res.ok) {
           return;
         }
-        this._closeDialog(dialog, true);
+        this._closeDialog(dialog);
         GerritNav.navigateToChange(this.change);
       });
   }
diff --git a/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls_html.ts b/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls_html.ts
index cd52408..60aa1eb 100644
--- a/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls_html.ts
+++ b/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls_html.ts
@@ -106,7 +106,6 @@
           <p contenteditable="false">
             <iron-input>
               <input
-                is="iron-input"
                 id="fileUploadInput"
                 type="file"
                 on-change="_handleFileUploadChanged"
@@ -158,16 +157,11 @@
           text="{{_path}}"
         ></gr-autocomplete>
         <iron-input
-          class="newPathIronInput"
+          id="newPathIronInput"
           bind-value="{{_newPath}}"
           placeholder="Enter the new path."
         >
-          <input
-            class="newPathInput"
-            is="iron-input"
-            bind-value="{{_newPath}}"
-            placeholder="Enter the new path."
-          />
+          <input id="newPathInput" placeholder="Enter the new path." />
         </iron-input>
       </div>
     </gr-dialog>
@@ -182,7 +176,7 @@
       <div class="header" slot="header">Restore this file?</div>
       <div class="main" slot="main">
         <iron-input disabled="" bind-value="{{_path}}">
-          <input is="iron-input" disabled="" bind-value="{{_path}}" />
+          <input disabled="" />
         </iron-input>
       </div>
     </gr-dialog>
diff --git a/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls_test.js b/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls_test.ts
similarity index 61%
rename from polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls_test.js
rename to polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls_test.ts
index bbf4790..a0b5392 100644
--- a/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls_test.js
+++ b/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls_test.ts
@@ -15,27 +15,34 @@
  * limitations under the License.
  */
 
-import '../../../test/common-test-setup-karma.js';
-import './gr-edit-controls.js';
-import {PolymerElement} from '@polymer/polymer/polymer-element.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-import {stubRestApi} from '../../../test/test-utils.js';
+import '../../../test/common-test-setup-karma';
+import './gr-edit-controls';
+import {GrEditControls} from './gr-edit-controls';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {stubRestApi} from '../../../test/test-utils';
+import {createChange, createRevision} from '../../../test/test-data-generators';
+import {GrAutocomplete} from '../../shared/gr-autocomplete/gr-autocomplete';
+import {CommitId, NumericChangeId, PatchSetNum} from '../../../types/common';
+import {RepoName} from '../../../api/rest-api';
+import {queryAndAssert} from '../../../test/test-utils';
+import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
 
 const basicFixture = fixtureFromElement('gr-edit-controls');
 
 suite('gr-edit-controls tests', () => {
-  let element;
+  let element: GrEditControls;
 
-  let showDialogSpy;
-  let closeDialogSpy;
-  let queryStub;
+  let showDialogSpy: sinon.SinonSpy;
+  let closeDialogSpy: sinon.SinonSpy;
+  let hideDialogStub: sinon.SinonStub;
+  let queryStub: sinon.SinonStub;
 
   setup(() => {
     element = basicFixture.instantiate();
-    element.change = {_number: '42'};
+    element.change = createChange();
     showDialogSpy = sinon.spy(element, '_showDialog');
     closeDialogSpy = sinon.spy(element, '_closeDialog');
-    sinon.stub(element, '_hideAllDialogs');
+    hideDialogStub = sinon.stub(element, '_hideAllDialogs');
     queryStub = stubRestApi('queryChangeFiles').returns(Promise.resolve([]));
     flush();
   });
@@ -44,20 +51,22 @@
     // We take 1 away from the total found, due to an extra button being
     // added for the file uploads (browse).
     assert.equal(
-        element.root.querySelectorAll('gr-button').length - 1,
-        element._actions.length);
+      element.root!.querySelectorAll('gr-button').length - 1,
+      element._actions.length
+    );
   });
 
   suite('edit button CUJ', () => {
-    let navStubs;
-    let openAutoComplete;
+    let editDiffStub: sinon.SinonStub;
+    let navStub: sinon.SinonStub;
+    let openAutoComplete: GrAutocomplete;
 
     setup(() => {
-      navStubs = [
-        sinon.stub(GerritNav, 'getEditUrlForDiff'),
-        sinon.stub(GerritNav, 'navigateToRelativeUrl'),
-      ];
-      openAutoComplete = element.$.openDialog.querySelector('gr-autocomplete');
+      editDiffStub = sinon.stub(GerritNav, 'getEditUrlForDiff');
+      navStub = sinon.stub(GerritNav, 'navigateToRelativeUrl');
+      openAutoComplete = element.$.openDialog!.querySelector(
+        'gr-autocomplete'
+      )!;
     });
 
     test('_isValidPath', () => {
@@ -69,10 +78,11 @@
     });
 
     test('open', () => {
-      MockInteractions.tap(element.shadowRoot.querySelector('#open'));
-      element.patchNum = 1;
+      assert.isFalse(hideDialogStub.called);
+      MockInteractions.tap(queryAndAssert(element, '#open'));
+      element.patchNum = 1 as PatchSetNum;
       return showDialogSpy.lastCall.returnValue.then(() => {
-        assert.isTrue(element._hideAllDialogs.called);
+        assert.isTrue(hideDialogStub.called);
         assert.isTrue(element.$.openDialog.disabled);
         assert.isFalse(queryStub.called);
         // Setup _focused manually - in headless mode Chrome sometimes don't
@@ -82,46 +92,52 @@
         openAutoComplete.text = 'src/test.cpp';
         assert.isTrue(queryStub.called);
         assert.isFalse(element.$.openDialog.disabled);
-        MockInteractions.tap(element.$.openDialog.shadowRoot
-            .querySelector('gr-button[primary]'));
-        for (const stub of navStubs) { assert.isTrue(stub.called); }
-        assert.deepEqual(GerritNav.getEditUrlForDiff.lastCall.args,
-            [element.change, 'src/test.cpp', element.patchNum]);
+        MockInteractions.tap(
+          queryAndAssert(element.$.openDialog, 'gr-button[primary]')
+        );
+        assert.isTrue(editDiffStub.called);
+        assert.isTrue(navStub.called);
+        assert.deepEqual(editDiffStub.lastCall.args, [
+          element.change,
+          'src/test.cpp',
+          element.patchNum,
+        ]);
         assert.isTrue(closeDialogSpy.called);
       });
     });
 
     test('cancel', () => {
-      MockInteractions.tap(element.shadowRoot.querySelector('#open'));
+      MockInteractions.tap(queryAndAssert(element, '#open'));
       return showDialogSpy.lastCall.returnValue.then(() => {
         assert.isTrue(element.$.openDialog.disabled);
         openAutoComplete.noDebounce = true;
         openAutoComplete.text = 'src/test.cpp';
         assert.isFalse(element.$.openDialog.disabled);
-        MockInteractions.tap(element.$.openDialog.shadowRoot
-            .querySelector('gr-button'));
-        for (const stub of navStubs) { assert.isFalse(stub.called); }
+        MockInteractions.tap(queryAndAssert(element.$.openDialog, 'gr-button'));
+        assert.isFalse(editDiffStub.called);
+        assert.isFalse(navStub.called);
         assert.isTrue(closeDialogSpy.called);
-        assert.equal(element._path, 'src/test.cpp');
+        assert.equal(element._path, '');
       });
     });
   });
 
   suite('delete button CUJ', () => {
-    let navStub;
-    let deleteStub;
-    let deleteAutocomplete;
+    let navStub: sinon.SinonStub;
+    let deleteStub: sinon.SinonStub;
+    let deleteAutocomplete: GrAutocomplete;
 
     setup(() => {
       navStub = sinon.stub(GerritNav, 'navigateToChange');
       deleteStub = stubRestApi('deleteFileInChangeEdit');
-      deleteAutocomplete =
-          element.$.deleteDialog.querySelector('gr-autocomplete');
+      deleteAutocomplete = element.$.deleteDialog!.querySelector(
+        'gr-autocomplete'
+      )!;
     });
 
     test('delete', () => {
       deleteStub.returns(Promise.resolve({ok: true}));
-      MockInteractions.tap(element.shadowRoot.querySelector('#delete'));
+      MockInteractions.tap(queryAndAssert(element, '#delete'));
       return showDialogSpy.lastCall.returnValue.then(() => {
         assert.isTrue(element.$.deleteDialog.disabled);
         assert.isFalse(queryStub.called);
@@ -132,8 +148,9 @@
         deleteAutocomplete.text = 'src/test.cpp';
         assert.isTrue(queryStub.called);
         assert.isFalse(element.$.deleteDialog.disabled);
-        MockInteractions.tap(element.$.deleteDialog.shadowRoot
-            .querySelector('gr-button[primary]'));
+        MockInteractions.tap(
+          queryAndAssert(element.$.deleteDialog, 'gr-button[primary]')
+        );
         flush();
 
         assert.isTrue(deleteStub.called);
@@ -148,7 +165,7 @@
 
     test('delete fails', () => {
       deleteStub.returns(Promise.resolve({ok: false}));
-      MockInteractions.tap(element.shadowRoot.querySelector('#delete'));
+      MockInteractions.tap(queryAndAssert(element, '#delete'));
       return showDialogSpy.lastCall.returnValue.then(() => {
         assert.isTrue(element.$.deleteDialog.disabled);
         assert.isFalse(queryStub.called);
@@ -159,8 +176,9 @@
         deleteAutocomplete.text = 'src/test.cpp';
         assert.isTrue(queryStub.called);
         assert.isFalse(element.$.deleteDialog.disabled);
-        MockInteractions.tap(element.$.deleteDialog.shadowRoot
-            .querySelector('gr-button[primary]'));
+        MockInteractions.tap(
+          queryAndAssert(element.$.deleteDialog, 'gr-button[primary]')
+        );
         flush();
 
         assert.isTrue(deleteStub.called);
@@ -173,39 +191,38 @@
     });
 
     test('cancel', () => {
-      MockInteractions.tap(element.shadowRoot.querySelector('#delete'));
+      MockInteractions.tap(queryAndAssert(element, '#delete'));
       return showDialogSpy.lastCall.returnValue.then(() => {
         assert.isTrue(element.$.deleteDialog.disabled);
-        element.$.deleteDialog.querySelector('gr-autocomplete').text =
-            'src/test.cpp';
+        element.$.deleteDialog!.querySelector('gr-autocomplete')!.text =
+          'src/test.cpp';
         assert.isFalse(element.$.deleteDialog.disabled);
-        MockInteractions.tap(element.$.deleteDialog.shadowRoot
-            .querySelector('gr-button'));
+        MockInteractions.tap(
+          queryAndAssert(element.$.deleteDialog, 'gr-button')
+        );
         assert.isFalse(navStub.called);
         assert.isTrue(closeDialogSpy.called);
-        assert.equal(element._path, 'src/test.cpp');
+        assert.equal(element._path, '');
       });
     });
   });
 
   suite('rename button CUJ', () => {
-    let navStub;
-    let renameStub;
-    let renameAutocomplete;
-    const inputSelector = PolymerElement ?
-      '.newPathIronInput' :
-      '.newPathInput';
+    let navStub: sinon.SinonStub;
+    let renameStub: sinon.SinonStub;
+    let renameAutocomplete: GrAutocomplete;
 
     setup(() => {
       navStub = sinon.stub(GerritNav, 'navigateToChange');
       renameStub = stubRestApi('renameFileInChangeEdit');
-      renameAutocomplete =
-          element.$.renameDialog.querySelector('gr-autocomplete');
+      renameAutocomplete = element.$.renameDialog!.querySelector(
+        'gr-autocomplete'
+      )!;
     });
 
     test('rename', () => {
       renameStub.returns(Promise.resolve({ok: true}));
-      MockInteractions.tap(element.shadowRoot.querySelector('#rename'));
+      MockInteractions.tap(queryAndAssert(element, '#rename'));
       return showDialogSpy.lastCall.returnValue.then(() => {
         assert.isTrue(element.$.renameDialog.disabled);
         assert.isFalse(queryStub.called);
@@ -217,12 +234,12 @@
         assert.isTrue(queryStub.called);
         assert.isTrue(element.$.renameDialog.disabled);
 
-        element.$.renameDialog.querySelector(inputSelector).bindValue =
-            'src/test.newPath';
+        element.$.newPathIronInput.bindValue = 'src/test.newPath';
 
         assert.isFalse(element.$.renameDialog.disabled);
-        MockInteractions.tap(element.$.renameDialog.shadowRoot
-            .querySelector('gr-button[primary]'));
+        MockInteractions.tap(
+          queryAndAssert(element.$.renameDialog, 'gr-button[primary]')
+        );
         flush();
 
         assert.isTrue(renameStub.called);
@@ -237,7 +254,7 @@
 
     test('rename fails', () => {
       renameStub.returns(Promise.resolve({ok: false}));
-      MockInteractions.tap(element.shadowRoot.querySelector('#rename'));
+      MockInteractions.tap(queryAndAssert(element, '#rename'));
       return showDialogSpy.lastCall.returnValue.then(() => {
         assert.isTrue(element.$.renameDialog.disabled);
         assert.isFalse(queryStub.called);
@@ -249,12 +266,12 @@
         assert.isTrue(queryStub.called);
         assert.isTrue(element.$.renameDialog.disabled);
 
-        element.$.renameDialog.querySelector(inputSelector).bindValue =
-            'src/test.newPath';
+        element.$.newPathIronInput.bindValue = 'src/test.newPath';
 
         assert.isFalse(element.$.renameDialog.disabled);
-        MockInteractions.tap(element.$.renameDialog.shadowRoot
-            .querySelector('gr-button[primary]'));
+        MockInteractions.tap(
+          queryAndAssert(element.$.renameDialog, 'gr-button[primary]')
+        );
         flush();
 
         assert.isTrue(renameStub.called);
@@ -267,46 +284,47 @@
     });
 
     test('cancel', () => {
-      MockInteractions.tap(element.shadowRoot.querySelector('#rename'));
+      MockInteractions.tap(queryAndAssert(element, '#rename'));
       return showDialogSpy.lastCall.returnValue.then(() => {
         assert.isTrue(element.$.renameDialog.disabled);
-        element.$.renameDialog.querySelector('gr-autocomplete').text =
-            'src/test.cpp';
-        element.$.renameDialog.querySelector(inputSelector).bindValue =
-            'src/test.newPath';
+        element.$.renameDialog!.querySelector('gr-autocomplete')!.text =
+          'src/test.cpp';
+        element.$.newPathIronInput.bindValue = 'src/test.newPath';
         assert.isFalse(element.$.renameDialog.disabled);
-        MockInteractions.tap(element.$.renameDialog.shadowRoot
-            .querySelector('gr-button'));
+        MockInteractions.tap(
+          queryAndAssert(element.$.renameDialog, 'gr-button')
+        );
         assert.isFalse(navStub.called);
         assert.isTrue(closeDialogSpy.called);
-        assert.equal(element._path, 'src/test.cpp');
-        assert.equal(element._newPath, 'src/test.newPath');
+        assert.equal(element._path, '');
+        assert.equal(element._newPath, '');
       });
     });
   });
 
   suite('restore button CUJ', () => {
-    let navStub;
-    let restoreStub;
+    let navStub: sinon.SinonStub;
+    let restoreStub: sinon.SinonStub;
 
     setup(() => {
       navStub = sinon.stub(GerritNav, 'navigateToChange');
-      restoreStub = stubRestApi(
-          'restoreFileInChangeEdit');
+      restoreStub = stubRestApi('restoreFileInChangeEdit');
     });
 
     test('restore hidden by default', () => {
-      assert.isTrue(element.shadowRoot
-          .querySelector('#restore').classList.contains('invisible'));
+      assert.isTrue(
+        queryAndAssert(element, '#restore').classList!.contains('invisible')!
+      );
     });
 
     test('restore', () => {
       restoreStub.returns(Promise.resolve({ok: true}));
       element._path = 'src/test.cpp';
-      MockInteractions.tap(element.shadowRoot.querySelector('#restore'));
+      MockInteractions.tap(queryAndAssert(element, '#restore'));
       return showDialogSpy.lastCall.returnValue.then(() => {
-        MockInteractions.tap(element.$.restoreDialog.shadowRoot
-            .querySelector('gr-button[primary]'));
+        MockInteractions.tap(
+          queryAndAssert(element.$.restoreDialog, 'gr-button[primary]')
+        );
         flush();
 
         assert.isTrue(restoreStub.called);
@@ -322,10 +340,11 @@
     test('restore fails', () => {
       restoreStub.returns(Promise.resolve({ok: false}));
       element._path = 'src/test.cpp';
-      MockInteractions.tap(element.shadowRoot.querySelector('#restore'));
+      MockInteractions.tap(queryAndAssert(element, '#restore'));
       return showDialogSpy.lastCall.returnValue.then(() => {
-        MockInteractions.tap(element.$.restoreDialog.shadowRoot
-            .querySelector('gr-button[primary]'));
+        MockInteractions.tap(
+          queryAndAssert(element.$.restoreDialog, 'gr-button[primary]')
+        );
         flush();
 
         assert.isTrue(restoreStub.called);
@@ -339,20 +358,21 @@
 
     test('cancel', () => {
       element._path = 'src/test.cpp';
-      MockInteractions.tap(element.shadowRoot.querySelector('#restore'));
+      MockInteractions.tap(queryAndAssert(element, '#restore'));
       return showDialogSpy.lastCall.returnValue.then(() => {
-        MockInteractions.tap(element.$.restoreDialog.shadowRoot
-            .querySelector('gr-button'));
+        MockInteractions.tap(
+          queryAndAssert(element.$.restoreDialog, 'gr-button')
+        );
         assert.isFalse(navStub.called);
         assert.isTrue(closeDialogSpy.called);
-        assert.equal(element._path, 'src/test.cpp');
+        assert.equal(element._path, '');
       });
     });
   });
 
   suite('save file upload', () => {
-    let navStub;
-    let fileStub;
+    let navStub: sinon.SinonStub;
+    let fileStub: sinon.SinonStub;
 
     setup(() => {
       navStub = sinon.stub(GerritNav, 'navigateToChange');
@@ -363,32 +383,37 @@
       fileStub.returns(Promise.resolve({ok: true}));
 
       element.change = {
-        _number: '1',
-        project: 'project',
+        ...createChange(),
+        _number: 1 as NumericChangeId,
+        project: 'project' as RepoName,
         revisions: {
-          abcd: {_number: 1},
-          efgh: {_number: 2},
+          abcd: {
+            ...createRevision(1),
+            _number: 1 as PatchSetNum,
+          },
+          efgh: {
+            ...createRevision(2),
+            _number: 2 as PatchSetNum,
+          },
         },
-        current_revision: 'efgh',
+        current_revision: 'efgh' as CommitId,
       };
 
       element._handleUploadConfirm('test.php', 'base64').then(() => {
-        assert.equal(
-            navStub.lastCall.args,
-            '/c/project/+/1');
+        assert.isTrue(navStub.calledWithExactly(1 as NumericChangeId));
       });
     });
   });
 
   test('openOpenDialog', done => {
-    element.openOpenDialog('test/path.cpp')
-        .then(() => {
-          assert.isFalse(element.$.openDialog.hasAttribute('hidden'));
-          assert.equal(
-              element.$.openDialog.querySelector('gr-autocomplete').text,
-              'test/path.cpp');
-          done();
-        });
+    element.openOpenDialog('test/path.cpp').then(() => {
+      assert.isFalse(element.$.openDialog.hasAttribute('hidden'));
+      assert.equal(
+        element.$.openDialog!.querySelector('gr-autocomplete')!.text,
+        'test/path.cpp'
+      );
+      done();
+    });
   });
 
   test('_getDialogFromEvent', () => {
@@ -397,20 +422,20 @@
 
     MockInteractions.tap(element.$.openDialog);
     flush();
-    assert.equal(spy.lastCall.returnValue.id, 'openDialog');
+    assert.equal(spy!.lastCall!.returnValue!.id, 'openDialog');
 
     MockInteractions.tap(element.$.deleteDialog);
     flush();
-    assert.equal(spy.lastCall.returnValue.id, 'deleteDialog');
+    assert.equal(spy!.lastCall!.returnValue!.id, 'deleteDialog');
 
     MockInteractions.tap(
-        element.$.deleteDialog.querySelector('gr-autocomplete'));
+      element.$.deleteDialog!.querySelector('gr-autocomplete')!
+    );
     flush();
-    assert.equal(spy.lastCall.returnValue.id, 'deleteDialog');
+    assert.equal(spy!.lastCall!.returnValue!.id, 'deleteDialog');
 
     MockInteractions.tap(element);
     flush();
-    assert.notOk(spy.lastCall.returnValue);
+    assert.notOk(spy!.lastCall!.returnValue);
   });
 });
-
diff --git a/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls_test.js b/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls_test.js
deleted file mode 100644
index 180a3a4..0000000
--- a/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls_test.js
+++ /dev/null
@@ -1,92 +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 '../../../test/common-test-setup-karma.js';
-import '../gr-edit-constants.js';
-import './gr-edit-file-controls.js';
-import {GrEditConstants} from '../gr-edit-constants.js';
-
-const basicFixture = fixtureFromElement('gr-edit-file-controls');
-
-suite('gr-edit-file-controls tests', () => {
-  let element;
-
-  let fileActionHandler;
-
-  setup(() => {
-    element = basicFixture.instantiate();
-    fileActionHandler = sinon.stub();
-    element.addEventListener('file-action-tap', fileActionHandler);
-  });
-
-  test('open tap emits event', () => {
-    const actions = element.$.actions;
-    element.filePath = 'foo';
-    actions._open();
-    flush();
-
-    MockInteractions.tap(actions.shadowRoot
-        .querySelector('li [data-id="open"]'));
-    assert.isTrue(fileActionHandler.called);
-    assert.deepEqual(fileActionHandler.lastCall.args[0].detail,
-        {action: GrEditConstants.Actions.OPEN.id, path: 'foo'});
-  });
-
-  test('delete tap emits event', () => {
-    const actions = element.$.actions;
-    element.filePath = 'foo';
-    actions._open();
-    flush();
-
-    MockInteractions.tap(actions.shadowRoot
-        .querySelector('li [data-id="delete"]'));
-    assert.isTrue(fileActionHandler.called);
-    assert.deepEqual(fileActionHandler.lastCall.args[0].detail,
-        {action: GrEditConstants.Actions.DELETE.id, path: 'foo'});
-  });
-
-  test('restore tap emits event', () => {
-    const actions = element.$.actions;
-    element.filePath = 'foo';
-    actions._open();
-    flush();
-
-    MockInteractions.tap(actions.shadowRoot
-        .querySelector('li [data-id="restore"]'));
-    assert.isTrue(fileActionHandler.called);
-    assert.deepEqual(fileActionHandler.lastCall.args[0].detail,
-        {action: GrEditConstants.Actions.RESTORE.id, path: 'foo'});
-  });
-
-  test('rename tap emits event', () => {
-    const actions = element.$.actions;
-    element.filePath = 'foo';
-    actions._open();
-    flush();
-
-    MockInteractions.tap(actions.shadowRoot
-        .querySelector('li [data-id="rename"]'));
-    assert.isTrue(fileActionHandler.called);
-    assert.deepEqual(fileActionHandler.lastCall.args[0].detail,
-        {action: GrEditConstants.Actions.RENAME.id, path: 'foo'});
-  });
-
-  test('computed properties', () => {
-    assert.equal(element._allFileActions.length, 4);
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls_test.ts b/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls_test.ts
new file mode 100644
index 0000000..a432ebf
--- /dev/null
+++ b/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls_test.ts
@@ -0,0 +1,102 @@
+/**
+ * @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 '../../../test/common-test-setup-karma';
+import './gr-edit-file-controls';
+import {GrEditFileControls} from './gr-edit-file-controls';
+import {GrEditConstants} from '../gr-edit-constants';
+import {queryAndAssert} from '../../../test/test-utils';
+import {GrDropdown} from '../../shared/gr-dropdown/gr-dropdown';
+import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
+
+const basicFixture = fixtureFromElement('gr-edit-file-controls');
+
+suite('gr-edit-file-controls tests', () => {
+  let element: GrEditFileControls;
+
+  let fileActionHandler: sinon.SinonStub;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+    fileActionHandler = sinon.stub();
+    element.addEventListener('file-action-tap', fileActionHandler);
+  });
+
+  test('open tap emits event', () => {
+    const actions = queryAndAssert<GrDropdown>(element, '#actions');
+    element.filePath = 'foo';
+    actions._open();
+    flush();
+
+    const row = queryAndAssert(actions, 'li [data-id="open"]');
+    MockInteractions.tap(row);
+    assert.isTrue(fileActionHandler.called);
+    assert.deepEqual(fileActionHandler.lastCall.args[0].detail, {
+      action: GrEditConstants.Actions.OPEN.id,
+      path: 'foo',
+    });
+  });
+
+  test('delete tap emits event', () => {
+    const actions = queryAndAssert<GrDropdown>(element, '#actions');
+    element.filePath = 'foo';
+    actions._open();
+    flush();
+
+    const row = queryAndAssert(actions, 'li [data-id="delete"]');
+    MockInteractions.tap(row);
+    assert.isTrue(fileActionHandler.called);
+    assert.deepEqual(fileActionHandler.lastCall.args[0].detail, {
+      action: GrEditConstants.Actions.DELETE.id,
+      path: 'foo',
+    });
+  });
+
+  test('restore tap emits event', () => {
+    const actions = queryAndAssert<GrDropdown>(element, '#actions');
+    element.filePath = 'foo';
+    actions._open();
+    flush();
+
+    const row = queryAndAssert(actions, 'li [data-id="restore"]');
+    MockInteractions.tap(row);
+    assert.isTrue(fileActionHandler.called);
+    assert.deepEqual(fileActionHandler.lastCall.args[0].detail, {
+      action: GrEditConstants.Actions.RESTORE.id,
+      path: 'foo',
+    });
+  });
+
+  test('rename tap emits event', () => {
+    const actions = queryAndAssert<GrDropdown>(element, '#actions');
+    element.filePath = 'foo';
+    actions._open();
+    flush();
+
+    const row = queryAndAssert(actions, 'li [data-id="rename"]');
+    MockInteractions.tap(row);
+    assert.isTrue(fileActionHandler.called);
+    assert.deepEqual(fileActionHandler.lastCall.args[0].detail, {
+      action: GrEditConstants.Actions.RENAME.id,
+      path: 'foo',
+    });
+  });
+
+  test('computed properties', () => {
+    assert.equal(element._allFileActions.length, 4);
+  });
+});
diff --git a/polygerrit-ui/app/elements/gr-app-element.ts b/polygerrit-ui/app/elements/gr-app-element.ts
index c9f0506..ebd143c 100644
--- a/polygerrit-ui/app/elements/gr-app-element.ts
+++ b/polygerrit-ui/app/elements/gr-app-element.ts
@@ -295,7 +295,6 @@
         showDownloadDialog: false,
         diffMode: null,
         numFilesShown: null,
-        scrollTop: 0,
       },
       changeListView: {
         query: null,
diff --git a/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks.ts b/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks.ts
index 3e8f0a4..3613f4c 100644
--- a/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks.ts
@@ -16,10 +16,10 @@
  */
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {PluginApi} from '../../../api/plugin';
-import {HookApi, HookCallback} from '../../../api/hook';
+import {HookApi, HookCallback, PluginElement} from '../../../api/hook';
 
 export class GrDomHooksManager {
-  private hooks: Record<string, GrDomHook>;
+  private hooks: Record<string, GrDomHook<PluginElement>>;
 
   private plugin: PluginApi;
 
@@ -41,23 +41,33 @@
     }
   }
 
-  getDomHook(endpointName: string, moduleName?: string) {
+  getDomHook<T extends PluginElement>(
+    endpointName: string,
+    moduleName?: string
+  ): HookApi<T> {
     const hookName = this._getHookName(endpointName, moduleName);
     if (!this.hooks[hookName]) {
-      this.hooks[hookName] = new GrDomHook(hookName, moduleName);
+      this.hooks[hookName] = (new GrDomHook<T>(
+        hookName,
+        moduleName
+      ) as unknown) as GrDomHook<PluginElement>;
     }
-    return this.hooks[hookName];
+    return (this.hooks[hookName] as unknown) as GrDomHook<T>;
   }
 }
 
-export class GrDomHook implements HookApi {
+export class GrDomHook<T extends PluginElement> implements HookApi<T> {
   private instances: HTMLElement[] = [];
 
-  private attachCallbacks: HookCallback[] = [];
+  private attachCallbacks: HookCallback<T>[] = [];
 
-  private detachCallbacks: HookCallback[] = [];
+  private detachCallbacks: HookCallback<T>[] = [];
 
-  private moduleName: string;
+  /**
+   * The name of the (custom) element that is going to be created. Matches the T
+   * type parameter.
+   */
+  private readonly moduleName: string;
 
   private lastAttachedPromise: Promise<HTMLElement> | null = null;
 
@@ -87,7 +97,7 @@
     customElements.define(HookPlaceholder.is, HookPlaceholder);
   }
 
-  handleInstanceDetached(instance: HTMLElement) {
+  handleInstanceDetached(instance: T) {
     const index = this.instances.indexOf(instance);
     if (index !== -1) {
       this.instances.splice(index, 1);
@@ -95,7 +105,7 @@
     this.detachCallbacks.forEach(callback => callback(instance));
   }
 
-  handleInstanceAttached(instance: HTMLElement) {
+  handleInstanceAttached(instance: T) {
     this.instances.push(instance);
     this.attachCallbacks.forEach(callback => callback(instance));
   }
@@ -109,7 +119,7 @@
       return Promise.resolve(this.instances.slice(-1)[0]);
     }
     if (!this.lastAttachedPromise) {
-      let resolve: HookCallback;
+      let resolve: HookCallback<T>;
       const promise = new Promise<HTMLElement>(r => {
         resolve = r;
         this.attachCallbacks.push(resolve);
@@ -137,7 +147,7 @@
    * Install a new callback to invoke when a new instance of DOM hook element
    * is attached.
    */
-  onAttached(callback: HookCallback) {
+  onAttached(callback: HookCallback<T>) {
     this.attachCallbacks.push(callback);
     return this;
   }
@@ -147,7 +157,7 @@
    * is detached.
    *
    */
-  onDetached(callback: HookCallback) {
+  onDetached(callback: HookCallback<T>) {
     this.detachCallbacks.push(callback);
     return this;
   }
diff --git a/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator.ts b/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator.ts
index 0e8cbcb..4776eac 100644
--- a/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator.ts
@@ -23,7 +23,7 @@
 import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
 import {customElement, property} from '@polymer/decorators';
 import {PluginApi} from '../../../api/plugin';
-import {HookApi} from '../../../api/hook';
+import {HookApi, PluginElement} from '../../../api/hook';
 
 const INIT_PROPERTIES_TIMEOUT_MS = 10000;
 
@@ -37,7 +37,7 @@
   name!: string;
 
   @property({type: Object})
-  _domHooks = new Map<HTMLElement, HookApi>();
+  _domHooks = new Map<PluginElement, HookApi<PluginElement>>();
 
   @property({type: Object})
   _initializedPlugins = new Map<string, boolean>();
diff --git a/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view.ts b/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view.ts
index b0b7ab8..b724e72 100644
--- a/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view.ts
+++ b/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view.ts
@@ -168,7 +168,7 @@
 
   _hideAgreements(
     item: ContributorAgreementInfo,
-    groups: GroupInfo[],
+    groups?: GroupInfo[],
     signedAgreements?: ContributorAgreementInfo[]
   ) {
     return this._disableAgreements(item, groups, signedAgreements)
@@ -176,18 +176,18 @@
       : 'hide';
   }
 
-  _disableAgreementsText(text: string) {
-    return text.toLowerCase() === 'i agree' ? false : true;
+  _disableAgreementsText(text?: string) {
+    return text?.toLowerCase() === 'i agree' ? false : true;
   }
 
   // This checks for auto_verify_group,
   // if specified it returns 'hideAgreementsTextBox' which
   // then hides the text box and submit button.
   _computeHideAgreementClass(
-    name: string,
+    name?: string,
     contributorAgreements?: ContributorAgreementInfo[]
   ) {
-    if (!contributorAgreements) return '';
+    if (!name || !contributorAgreements) return '';
     return contributorAgreements.some(
       (contributorAgreement: ContributorAgreementInfo) =>
         name === contributorAgreement.name &&
diff --git a/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view_html.ts b/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view_html.ts
index 887382e..4800e5b 100644
--- a/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view_html.ts
+++ b/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view_html.ts
@@ -104,12 +104,7 @@
           bind-value="{{_agreementsText}}"
           placeholder="Enter 'I agree' here"
         >
-          <input
-            id="input-agreements"
-            is="iron-input"
-            bind-value="{{_agreementsText}}"
-            placeholder="Enter 'I agree' here"
-          />
+          <input id="input-agreements" placeholder="Enter 'I agree' here" />
         </iron-input>
         <gr-button
           on-click="_handleSaveAgreements"
diff --git a/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences.ts b/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences.ts
index 5e2c5cb..4909bef 100644
--- a/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences.ts
+++ b/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences.ts
@@ -26,6 +26,9 @@
 
 export interface GrEditPreferences {
   $: {
+    editTabWidth: HTMLInputElement;
+    editColumns: HTMLInputElement;
+    editIndentUnit: HTMLInputElement;
     editSyntaxHighlighting: HTMLInputElement;
     showAutoCloseBrackets: HTMLInputElement;
     showIndentWithTabs: HTMLInputElement;
@@ -58,6 +61,21 @@
     this.hasUnsavedChanges = true;
   }
 
+  _handleEditTabWidthChanged() {
+    this.set('editPrefs.tab_size', Number(this.$.editTabWidth.value));
+    this._handleEditPrefsChanged();
+  }
+
+  _handleEditLineLengthChanged() {
+    this.set('editPrefs.line_length', Number(this.$.editColumns.value));
+    this._handleEditPrefsChanged();
+  }
+
+  _handleEditIndentUnitChanged() {
+    this.set('editPrefs.indent_unit', Number(this.$.editIndentUnit.value));
+    this._handleEditPrefsChanged();
+  }
+
   _handleEditSyntaxHighlightingChanged() {
     this.set(
       'editPrefs.syntax_highlighting',
@@ -101,6 +119,16 @@
       this.hasUnsavedChanges = false;
     });
   }
+
+  /**
+   * bind-value has type string so we have to convert
+   * anything inputed to string.
+   *
+   * This is so typescript checker doesn't fail.
+   */
+  _convertToString(key?: number) {
+    return key !== undefined ? String(key) : '';
+  }
 }
 
 declare global {
diff --git a/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences_html.ts b/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences_html.ts
index f6344f0..a51deaf 100644
--- a/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences_html.ts
+++ b/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences_html.ts
@@ -28,23 +28,11 @@
       <label for="editTabWidth" class="title">Tab width</label>
       <span class="value">
         <iron-input
-          type="number"
-          prevent-invalid-input=""
           allowed-pattern="[0-9]"
-          bind-value="{{editPrefs.tab_size}}"
-          on-keypress="_handleEditPrefsChanged"
-          on-change="_handleEditPrefsChanged"
+          bind-value="[[_convertToString(editPrefs.tab_size)]]"
+          on-change="_handleEditTabWidthChanged"
         >
-          <input
-            is="iron-input"
-            id="editTabWidth"
-            type="number"
-            prevent-invalid-input=""
-            allowed-pattern="[0-9]"
-            bind-value="{{editPrefs.tab_size}}"
-            on-keypress="_handleEditPrefsChanged"
-            on-change="_handleEditPrefsChanged"
-          />
+          <input id="editTabWidth" type="number" />
         </iron-input>
       </span>
     </section>
@@ -52,47 +40,23 @@
       <label for="editColumns" class="title">Columns</label>
       <span class="value">
         <iron-input
-          type="number"
-          prevent-invalid-input=""
           allowed-pattern="[0-9]"
-          bind-value="{{editPrefs.line_length}}"
-          on-keypress="_handleEditPrefsChanged"
-          on-change="_handleEditPrefsChanged"
+          bind-value="[[_convertToString(editPrefs.line_length)]]"
+          on-change="_handleEditLineLengthChanged"
         >
-          <input
-            id="editColumns"
-            is="iron-input"
-            type="number"
-            prevent-invalid-input=""
-            allowed-pattern="[0-9]"
-            bind-value="{{editPrefs.line_length}}"
-            on-keypress="_handleEditPrefsChanged"
-            on-change="_handleEditPrefsChanged"
-          />
+          <input id="editColumns" type="number" />
         </iron-input>
       </span>
     </section>
     <section>
-      <label for="indentUnit" class="title">Indent unit</label>
+      <label for="editIndentUnit" class="title">Indent unit</label>
       <span class="value">
         <iron-input
-          type="number"
-          prevent-invalid-input=""
           allowed-pattern="[0-9]"
-          bind-value="{{editPrefs.indent_unit}}"
-          on-keypress="_handleEditPrefsChanged"
-          on-change="_handleEditPrefsChanged"
+          bind-value="[[_convertToString(editPrefs.indent_unit)]]"
+          on-change="_handleEditIndentUnitChanged"
         >
-          <input
-            is="iron-input"
-            id="indentUnit"
-            type="number"
-            prevent-invalid-input=""
-            allowed-pattern="[0-9]"
-            bind-value="{{editPrefs.indent_unit}}"
-            on-keypress="_handleEditPrefsChanged"
-            on-change="_handleEditPrefsChanged"
-          />
+          <input id="indentUnit" type="number" />
         </iron-input>
       </span>
     </section>
diff --git a/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor.ts b/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor.ts
index 74c6ac0..7f25a86 100644
--- a/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor.ts
+++ b/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor.ts
@@ -105,6 +105,10 @@
       }
     }
   }
+
+  _checkPreferred(preferred?: boolean) {
+    return preferred ?? false;
+  }
 }
 
 declare global {
diff --git a/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor_html.ts b/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor_html.ts
index 0591e4b..666afb7 100644
--- a/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor_html.ts
+++ b/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor_html.ts
@@ -71,12 +71,10 @@
                 checked$="[[item.preferred]]"
               >
                 <input
-                  is="iron-input"
                   class="preferredRadio"
                   type="radio"
                   on-change="_handlePreferredChange"
                   name="preferred"
-                  value="[[item.email]]"
                   checked$="[[item.preferred]]"
                 />
               </iron-input>
@@ -85,7 +83,7 @@
               <gr-button
                 data-index$="[[index]]"
                 on-click="_handleDeleteButton"
-                disabled="[[item.preferred]]"
+                disabled="[[_checkPreferred(item.preferred)]]"
                 class="remove-button"
                 >Delete</gr-button
               >
diff --git a/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor_html.ts b/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor_html.ts
index ab24168..f4641c2 100644
--- a/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor_html.ts
+++ b/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor_html.ts
@@ -70,9 +70,9 @@
               </td>
               <td>
                 <gr-copy-clipboard
-                  has-tooltip=""
-                  button-title="Copy GPG public key to clipboard"
-                  hide-input=""
+                  hasTooltip=""
+                  buttonTitle="Copy GPG public key to clipboard"
+                  hideInput=""
                   text="[[key.key]]"
                 >
                 </gr-copy-clipboard>
diff --git a/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password_html.ts b/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password_html.ts
index 549fc93..811b85c 100644
--- a/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password_html.ts
+++ b/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password_html.ts
@@ -78,9 +78,9 @@
         <span class="title">New Password:</span>
         <span class="value">[[_generatedPassword]]</span>
         <gr-copy-clipboard
-          has-tooltip=""
-          button-title="Copy password to clipboard"
-          hide-input=""
+          hasTooltip=""
+          buttonTitle="Copy password to clipboard"
+          hideInput=""
           text="[[_generatedPassword]]"
         >
         </gr-copy-clipboard>
diff --git a/polygerrit-ui/app/elements/settings/gr-identities/gr-identities_test.js b/polygerrit-ui/app/elements/settings/gr-identities/gr-identities_test.js
deleted file mode 100644
index 867473f..0000000
--- a/polygerrit-ui/app/elements/settings/gr-identities/gr-identities_test.js
+++ /dev/null
@@ -1,168 +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 '../../../test/common-test-setup-karma.js';
-import './gr-identities.js';
-import {stubRestApi} from '../../../test/test-utils.js';
-
-const basicFixture = fixtureFromElement('gr-identities');
-
-suite('gr-identities tests', () => {
-  let element;
-
-  const ids = [
-    {
-      identity: 'username:john',
-      email_address: 'john.doe@example.com',
-      trusted: true,
-    }, {
-      identity: 'gerrit:gerrit',
-      email_address: 'gerrit@example.com',
-    }, {
-      identity: 'mailto:gerrit2@example.com',
-      email_address: 'gerrit2@example.com',
-      trusted: true,
-      can_delete: true,
-    },
-  ];
-
-  setup(async () => {
-    stubRestApi('getExternalIds').returns(Promise.resolve(ids));
-
-    element = basicFixture.instantiate();
-    await element.loadData();
-    await flush();
-  });
-
-  test('renders', () => {
-    const rows = Array.from(
-        element.root.querySelectorAll('tbody tr'));
-
-    assert.equal(rows.length, 2);
-
-    const nameCells = rows.map(row =>
-      row.querySelectorAll('td')[2].textContent
-    );
-
-    assert.equal(nameCells[0].trim(), 'gerrit:gerrit');
-    assert.equal(nameCells[1].trim(), '');
-  });
-
-  test('renders email', () => {
-    const rows = Array.from(
-        element.root.querySelectorAll('tbody tr'));
-
-    assert.equal(rows.length, 2);
-
-    const nameCells = rows.map(row =>
-      row.querySelectorAll('td')[1].textContent
-    );
-
-    assert.equal(nameCells[0], 'gerrit@example.com');
-    assert.equal(nameCells[1], 'gerrit2@example.com');
-  });
-
-  test('_computeIdentity', () => {
-    assert.equal(
-        element._computeIdentity(ids[0].identity), 'username:john');
-    assert.equal(element._computeIdentity(ids[2].identity), '');
-  });
-
-  test('filterIdentities', () => {
-    assert.isFalse(element.filterIdentities(ids[0]));
-
-    assert.isTrue(element.filterIdentities(ids[1]));
-  });
-
-  test('delete id', done => {
-    element._idName = 'mailto:gerrit2@example.com';
-    const loadDataStub = sinon.stub(element, 'loadData');
-    element._handleDeleteItemConfirm().then(() => {
-      assert.isTrue(loadDataStub.called);
-      done();
-    });
-  });
-
-  test('_handleDeleteItem opens modal', () => {
-    const deleteBtn =
-        element.root.querySelector('.deleteButton');
-    const deleteItem = sinon.stub(element, '_handleDeleteItem');
-    MockInteractions.tap(deleteBtn);
-    assert.isTrue(deleteItem.called);
-  });
-
-  test('_computeShowLinkAnotherIdentity', () => {
-    let serverConfig;
-
-    serverConfig = {
-      auth: {
-        git_basic_auth_policy: 'OAUTH',
-      },
-    };
-    assert.isTrue(element._computeShowLinkAnotherIdentity(serverConfig));
-
-    serverConfig = {
-      auth: {
-        git_basic_auth_policy: 'OpenID',
-      },
-    };
-    assert.isTrue(element._computeShowLinkAnotherIdentity(serverConfig));
-
-    serverConfig = {
-      auth: {
-        git_basic_auth_policy: 'HTTP_LDAP',
-      },
-    };
-    assert.isFalse(element._computeShowLinkAnotherIdentity(serverConfig));
-
-    serverConfig = {
-      auth: {
-        git_basic_auth_policy: 'LDAP',
-      },
-    };
-    assert.isFalse(element._computeShowLinkAnotherIdentity(serverConfig));
-
-    serverConfig = {
-      auth: {
-        git_basic_auth_policy: 'HTTP',
-      },
-    };
-    assert.isFalse(element._computeShowLinkAnotherIdentity(serverConfig));
-
-    serverConfig = {};
-    assert.isFalse(element._computeShowLinkAnotherIdentity(serverConfig));
-  });
-
-  test('_showLinkAnotherIdentity', () => {
-    element.serverConfig = {
-      auth: {
-        git_basic_auth_policy: 'OAUTH',
-      },
-    };
-
-    assert.isTrue(element._showLinkAnotherIdentity);
-
-    element.serverConfig = {
-      auth: {
-        git_basic_auth_policy: 'LDAP',
-      },
-    };
-
-    assert.isFalse(element._showLinkAnotherIdentity);
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/settings/gr-identities/gr-identities_test.ts b/polygerrit-ui/app/elements/settings/gr-identities/gr-identities_test.ts
new file mode 100644
index 0000000..d1a4f8f
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-identities/gr-identities_test.ts
@@ -0,0 +1,148 @@
+/**
+ * @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 '../../../test/common-test-setup-karma';
+import './gr-identities';
+import {GrIdentities} from './gr-identities';
+import {stubRestApi} from '../../../test/test-utils';
+import {ServerInfo} from '../../../types/common';
+import {createServerInfo} from '../../../test/test-data-generators';
+import {queryAll, queryAndAssert} from '../../../test/test-utils';
+import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
+
+const basicFixture = fixtureFromElement('gr-identities');
+
+suite('gr-identities tests', () => {
+  let element: GrIdentities;
+
+  const ids = [
+    {
+      identity: 'username:john',
+      email_address: 'john.doe@example.com',
+      trusted: true,
+    },
+    {
+      identity: 'gerrit:gerrit',
+      email_address: 'gerrit@example.com',
+    },
+    {
+      identity: 'mailto:gerrit2@example.com',
+      email_address: 'gerrit2@example.com',
+      trusted: true,
+      can_delete: true,
+    },
+  ];
+
+  setup(async () => {
+    stubRestApi('getExternalIds').returns(Promise.resolve(ids));
+
+    element = basicFixture.instantiate();
+    await element.loadData();
+    await flush();
+  });
+
+  test('renders', () => {
+    const rows = Array.from(queryAll(element, 'tbody tr'));
+
+    assert.equal(rows.length, 2);
+
+    const nameCells = rows.map(row => queryAll(row, 'td')[2].textContent);
+
+    assert.equal(nameCells[0]!.trim(), 'gerrit:gerrit');
+    assert.equal(nameCells[1]!.trim(), '');
+  });
+
+  test('renders email', () => {
+    const rows = Array.from(queryAll(element, 'tbody tr'));
+
+    assert.equal(rows.length, 2);
+
+    const nameCells = rows.map(row => queryAll(row, 'td')[1]!.textContent);
+
+    assert.equal(nameCells[0]!, 'gerrit@example.com');
+    assert.equal(nameCells[1]!, 'gerrit2@example.com');
+  });
+
+  test('_computeIdentity', () => {
+    assert.equal(element._computeIdentity(ids[0].identity), 'username:john');
+    assert.equal(element._computeIdentity(ids[2].identity), '');
+  });
+
+  test('filterIdentities', () => {
+    assert.isFalse(element.filterIdentities(ids[0]));
+
+    assert.isTrue(element.filterIdentities(ids[1]));
+  });
+
+  test('delete id', done => {
+    element._idName = 'mailto:gerrit2@example.com';
+    const loadDataStub = sinon.stub(element, 'loadData');
+    element._handleDeleteItemConfirm().then(() => {
+      assert.isTrue(loadDataStub.called);
+      done();
+    });
+  });
+
+  test('_handleDeleteItem opens modal', () => {
+    const deleteBtn = queryAndAssert(element, '.deleteButton');
+    const deleteItem = sinon.stub(element, '_handleDeleteItem');
+    MockInteractions.tap(deleteBtn);
+    assert.isTrue(deleteItem.called);
+  });
+
+  test('_computeShowLinkAnotherIdentity', () => {
+    const config: ServerInfo = {
+      ...createServerInfo(),
+    };
+
+    config.auth.git_basic_auth_policy = 'OAUTH';
+    assert.isTrue(element._computeShowLinkAnotherIdentity(config));
+
+    config.auth.git_basic_auth_policy = 'OpenID';
+    assert.isTrue(element._computeShowLinkAnotherIdentity(config));
+
+    config.auth.git_basic_auth_policy = 'HTTP_LDAP';
+    assert.isFalse(element._computeShowLinkAnotherIdentity(config));
+
+    config.auth.git_basic_auth_policy = 'LDAP';
+    assert.isFalse(element._computeShowLinkAnotherIdentity(config));
+
+    config.auth.git_basic_auth_policy = 'HTTP';
+    assert.isFalse(element._computeShowLinkAnotherIdentity(config));
+
+    assert.isFalse(element._computeShowLinkAnotherIdentity(undefined));
+  });
+
+  test('_showLinkAnotherIdentity', () => {
+    let config: ServerInfo = {
+      ...createServerInfo(),
+    };
+    config.auth.git_basic_auth_policy = 'OAUTH';
+
+    element.serverConfig = config;
+
+    assert.isTrue(element._showLinkAnotherIdentity);
+
+    config = {
+      ...createServerInfo(),
+    };
+    config.auth.git_basic_auth_policy = 'LDAP';
+    element.serverConfig = config;
+
+    assert.isFalse(element._showLinkAnotherIdentity);
+  });
+});
diff --git a/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog_html.ts b/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog_html.ts
index 4b86709..6f270f5 100644
--- a/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog_html.ts
+++ b/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog_html.ts
@@ -75,12 +75,7 @@
         <span class="title">Full Name</span>
         <span class="value">
           <iron-input bind-value="{{_account.name}}">
-            <input
-              is="iron-input"
-              id="name"
-              bind-value="{{_account.name}}"
-              disabled="[[_saving]]"
-            />
+            <input id="name" disabled="[[_saving]]" />
           </iron-input>
         </span>
       </section>
@@ -92,12 +87,7 @@
           >
           <span hidden$="[[!_usernameMutable]]" class="value">
             <iron-input bind-value="{{_username}}">
-              <input
-                is="iron-input"
-                id="username"
-                bind-value="{{_username}}"
-                disabled="[[_saving]]"
-              />
+              <input id="username" disabled="[[_saving]]" />
             </iron-input>
           </span>
         </section>
diff --git a/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor_html.ts b/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor_html.ts
index 0bee1d3..e853b58 100644
--- a/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor_html.ts
+++ b/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor_html.ts
@@ -76,9 +76,9 @@
               </td>
               <td>
                 <gr-copy-clipboard
-                  has-tooltip=""
-                  button-title="Copy SSH public key to clipboard"
-                  hide-input=""
+                  hasTooltip=""
+                  buttonTitle="Copy SSH public key to clipboard"
+                  hideInput=""
                   text="[[key.ssh_public_key]]"
                 >
                 </gr-copy-clipboard>
diff --git a/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor.ts b/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor.ts
index 6178e7a..4381a59 100644
--- a/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor.ts
+++ b/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor.ts
@@ -31,6 +31,7 @@
 import {hasOwnProperty} from '../../../utils/common-util';
 import {ProjectWatchInfo} from '../../../types/common';
 import {appContext} from '../../../services/app-context';
+import {IronInputElement} from '@polymer/iron-input';
 
 const NOTIFICATION_TYPES = [
   {name: 'Changes', key: 'notify_new_changes'},
@@ -43,9 +44,11 @@
 export interface GrWatchedProjectsEditor {
   $: {
     newFilter: HTMLInputElement;
+    newFilterInput: IronInputElement;
     newProject: GrAutocomplete;
   };
 }
+
 @customElement('gr-watched-projects-editor')
 export class GrWatchedProjectsEditor extends PolymerElement {
   static get template() {
@@ -62,7 +65,7 @@
   _projectsToRemove: ProjectWatchInfo[] = [];
 
   @property({type: Object})
-  _query?: AutocompleteQuery;
+  _query: AutocompleteQuery;
 
   private readonly restApiService = appContext.restApiService;
 
diff --git a/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor_html.ts b/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor_html.ts
index edc8fb2..fb65a03 100644
--- a/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor_html.ts
+++ b/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor_html.ts
@@ -102,13 +102,13 @@
           </th>
           <th colspan$="[[_getTypeCount()]]">
             <iron-input
+              id="newFilterInput"
               class="newFilterInput"
               placeholder="branch:name, or other search expression"
             >
               <input
                 id="newFilter"
                 class="newFilterInput"
-                is="iron-input"
                 placeholder="branch:name, or other search expression"
               />
             </iron-input>
diff --git a/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor_test.js b/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor_test.ts
similarity index 76%
rename from polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor_test.js
rename to polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor_test.ts
index aac8995..cb4b86d 100644
--- a/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor_test.js
+++ b/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor_test.ts
@@ -15,14 +15,18 @@
  * limitations under the License.
  */
 
-import '../../../test/common-test-setup-karma.js';
-import './gr-watched-projects-editor.js';
-import {stubRestApi} from '../../../test/test-utils.js';
+import '../../../test/common-test-setup-karma';
+import './gr-watched-projects-editor';
+import {GrWatchedProjectsEditor} from './gr-watched-projects-editor';
+import {stubRestApi} from '../../../test/test-utils';
+import {ProjectWatchInfo} from '../../../types/common';
+import {queryAll, queryAndAssert} from '../../../test/test-utils';
+import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
 
 const basicFixture = fixtureFromElement('gr-watched-projects-editor');
 
 suite('gr-watched-projects-editor tests', () => {
-  let element;
+  let element: GrWatchedProjectsEditor;
 
   setup(done => {
     const projects = [
@@ -30,29 +34,34 @@
         project: 'project a',
         notify_submitted_changes: true,
         notify_abandoned_changes: true,
-      }, {
+      },
+      {
         project: 'project b',
         filter: 'filter 1',
         notify_new_changes: true,
-      }, {
+      },
+      {
         project: 'project b',
         filter: 'filter 2',
-      }, {
+      },
+      {
         project: 'project c',
         notify_new_changes: true,
         notify_new_patch_sets: true,
         notify_all_comments: true,
       },
-    ];
+    ] as ProjectWatchInfo[];
 
     stubRestApi('getWatchedProjects').returns(Promise.resolve(projects));
     stubRestApi('getSuggestedProjects').callsFake(input => {
       if (input.startsWith('th')) {
-        return Promise.resolve({'the project': {
-          id: 'the project',
-          state: 'ACTIVE',
-          web_links: [],
-        }});
+        return Promise.resolve({
+          'the project': {
+            id: 'the project',
+            state: 'ACTIVE',
+            web_links: [],
+          },
+        });
       } else {
         return Promise.resolve({});
       }
@@ -60,18 +69,18 @@
 
     element = basicFixture.instantiate();
 
-    element.loadData().then(() => { flush(done); });
+    element.loadData().then(() => {
+      flush(done);
+    });
   });
 
   test('renders', () => {
-    const rows = element.shadowRoot
-        .querySelector('table').querySelectorAll('tbody tr');
+    const rows = queryAndAssert(element, 'table').querySelectorAll('tbody tr');
     assert.equal(rows.length, 4);
 
-    function getKeysOfRow(row) {
-      const boxes = rows[row].querySelectorAll('input[checked]');
-      return Array.prototype.map.call(boxes,
-          e => e.getAttribute('data-key'));
+    function getKeysOfRow(row: number) {
+      const boxes = queryAll(rows[row], 'input[checked]');
+      return Array.prototype.map.call(boxes, e => e.getAttribute('data-key'));
     }
 
     let checkedKeys = getKeysOfRow(0);
@@ -157,41 +166,44 @@
   test('_handleAddProject', () => {
     element.$.newProject.value = 'project d';
     element.$.newProject.setText('project d');
-    element.$.newFilter.bindValue = '';
+    element.$.newFilterInput.bindValue = '';
 
     element._handleAddProject();
 
-    assert.equal(element._projects.length, 5);
-    assert.equal(element._projects[4].project, 'project d');
-    assert.isNotOk(element._projects[4].filter);
-    assert.isTrue(element._projects[4]._is_local);
+    const projects = element._projects!;
+    assert.equal(projects.length, 5);
+    assert.equal(projects[4].project, 'project d');
+    assert.isNotOk(projects[4].filter);
+    assert.isTrue(projects[4]._is_local);
   });
 
   test('_handleAddProject with invalid inputs', () => {
     element.$.newProject.value = 'project b';
     element.$.newProject.setText('project b');
-    element.$.newFilter.bindValue = 'filter 1';
+    element.$.newFilterInput.bindValue = 'filter 1';
     element.$.newFilter.value = 'filter 1';
 
     element._handleAddProject();
 
-    assert.equal(element._projects.length, 4);
+    assert.equal(element._projects!.length, 4);
   });
 
   test('_handleRemoveProject', () => {
-    assert.equal(element._projectsToRemove, 0);
-    const button = element.shadowRoot
-        .querySelector('table tbody tr:nth-child(2) gr-button');
+    assert.deepEqual(element._projectsToRemove, []);
+
+    const button = queryAndAssert(
+      element,
+      'table tbody tr:nth-child(2) gr-button'
+    );
     MockInteractions.tap(button);
 
     flush();
 
-    const rows = element.shadowRoot
-        .querySelector('table tbody').querySelectorAll('tr');
+    const rows = queryAndAssert(element, 'table tbody').querySelectorAll('tr');
+
     assert.equal(rows.length, 3);
 
     assert.equal(element._projectsToRemove.length, 1);
     assert.equal(element._projectsToRemove[0].project, 'project b');
   });
 });
-
diff --git a/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.ts b/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.ts
index dab778b..f703037 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.ts
+++ b/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.ts
@@ -17,19 +17,14 @@
 import '../gr-account-link/gr-account-link';
 import '../gr-button/gr-button';
 import '../gr-icons/gr-icons';
-import '../../../styles/shared-styles';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-account-chip_html';
-import {customElement, property} from '@polymer/decorators';
 import {AccountInfo, ChangeInfo} from '../../../types/common';
 import {appContext} from '../../../services/app-context';
+import {GrLitElement} from '../../lit/gr-lit-element';
+import {css, customElement, html, property} from 'lit-element';
+import {classMap} from 'lit-html/directives/class-map';
 
 @customElement('gr-account-chip')
-export class GrAccountChip extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrAccountChip extends GrLitElement {
   /**
    * Fired to indicate a key was pressed while this chip was focused.
    *
@@ -64,10 +59,10 @@
   @property({type: String})
   voteableText?: string;
 
-  @property({type: Boolean, reflectToAttribute: true})
+  @property({type: Boolean, reflect: true})
   disabled = false;
 
-  @property({type: Boolean, reflectToAttribute: true})
+  @property({type: Boolean, reflect: true})
   removable = false;
 
   /**
@@ -78,7 +73,7 @@
   @property({type: Boolean})
   highlightAttention = false;
 
-  @property({type: Boolean, reflectToAttribute: true})
+  @property({type: Boolean, reflect: true})
   showAvatar?: boolean;
 
   @property({type: Boolean})
@@ -86,18 +81,126 @@
 
   private readonly restApiService = appContext.restApiService;
 
-  /** @override */
-  ready() {
-    super.ready();
+  static get styles() {
+    return [
+      css`
+        :host {
+          display: block;
+          overflow: hidden;
+        }
+        .container {
+          align-items: center;
+          background-color: var(--background-color-primary);
+          /** round */
+          border-radius: var(--account-chip-border-radius, 20px);
+          border: 1px solid var(--border-color);
+          display: inline-flex;
+          padding: 0 1px;
+        }
+        :host:focus {
+          border-color: transparent;
+          box-shadow: none;
+          outline: none;
+        }
+        :host:focus .container,
+        :host:focus gr-button {
+          background: #ccc;
+        }
+        .transparentBackground,
+        gr-button.transparentBackground {
+          background-color: transparent;
+        }
+        :host([disabled]) {
+          opacity: 0.6;
+          pointer-events: none;
+        }
+        iron-icon {
+          height: 1.2rem;
+          width: 1.2rem;
+        }
+        .container gr-account-link::part(gr-account-link-text) {
+          color: var(--deemphasized-text-color);
+        }
+      `,
+    ];
+  }
+
+  render() {
+    // To pass CSS mixins for @apply to Polymer components, they need to appear
+    // in <style> inside the template.
+    const customStyle = html`
+      <style>
+        .container {
+          --account-label-padding-horizontal: 6px;
+        }
+        gr-button.remove {
+          --gr-remove-button-style: {
+            border-top-width: 0;
+            border-right-width: 0;
+            border-bottom-width: 0;
+            border-left-width: 0;
+            color: var(--deemphasized-text-color);
+            font-weight: var(--font-weight-normal);
+            height: 0.6em;
+            line-height: 10px;
+            /* This cancels most of the --account-label-padding-horizontal. */
+            margin-left: -4px;
+            padding: 0 2px 0 0;
+            text-decoration: none;
+          }
+        }
+
+        gr-button.remove:hover,
+        gr-button.remove:focus {
+          --gr-button: {
+            @apply --gr-remove-button-style;
+          }
+        }
+        gr-button.remove {
+          --gr-button: {
+            @apply --gr-remove-button-style;
+          }
+        }
+      </style>
+    `;
+    return html`${customStyle}
+      <div
+        class="${classMap({
+          container: true,
+          transparentBackground: this.transparentBackground,
+        })}"
+      >
+        <gr-account-link
+          .account="${this.account}"
+          .change="${this.change}"
+          ?forceAttention=${this.forceAttention}
+          ?highlightAttention=${this.highlightAttention}
+          .voteableText=${this.voteableText}
+        >
+        </gr-account-link>
+        <gr-button
+          id="remove"
+          link=""
+          ?hidden=${!this.removable}
+          aria-label="Remove"
+          class="${classMap({
+            remove: true,
+            transparentBackground: this.transparentBackground,
+          })}"
+          @click=${this._handleRemoveTap}
+        >
+          <iron-icon icon="gr-icons:close"></iron-icon>
+        </gr-button>
+      </div>`;
+  }
+
+  constructor() {
+    super();
     this._getHasAvatars().then(hasAvatars => {
       this.showAvatar = hasAvatars;
     });
   }
 
-  _getBackgroundClass(transparent: boolean) {
-    return transparent ? 'transparentBackground' : '';
-  }
-
   _handleRemoveTap(e: MouseEvent) {
     e.preventDefault();
     this.dispatchEvent(
diff --git a/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip_html.ts b/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip_html.ts
deleted file mode 100644
index e5efebb..0000000
--- a/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip_html.ts
+++ /dev/null
@@ -1,114 +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 {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style>
-    :host {
-      display: block;
-      overflow: hidden;
-    }
-    .container {
-      align-items: center;
-      background-color: var(--background-color-primary);
-      /** round */
-      border-radius: var(--account-chip-border-radius, 20px);
-      border: 1px solid var(--border-color);
-      display: inline-flex;
-      padding: 0 1px;
-
-      --account-label-padding-horizontal: 6px;
-      --gr-account-label-text-style: {
-        color: var(--deemphasized-text-color);
-      }
-    }
-    :host([show-avatar]) .container {
-    }
-    :host([removable]) .container {
-    }
-    gr-button.remove {
-      --gr-remove-button-style: {
-        border-top-width: 0;
-        border-right-width: 0;
-        border-bottom-width: 0;
-        border-left-width: 0;
-        color: var(--deemphasized-text-color);
-        font-weight: var(--font-weight-normal);
-        height: 0.6em;
-        line-height: 10px;
-        /* This cancels most of the --account-label-padding-horizontal. */
-        margin-left: -4px;
-        padding: 0 2px 0 0;
-        text-decoration: none;
-      }
-    }
-
-    gr-button.remove:hover,
-    gr-button.remove:focus {
-      --gr-button: {
-        @apply --gr-remove-button-style;
-      }
-    }
-    gr-button.remove {
-      --gr-button: {
-        @apply --gr-remove-button-style;
-      }
-    }
-    :host:focus {
-      border-color: transparent;
-      box-shadow: none;
-      outline: none;
-    }
-    :host:focus .container,
-    :host:focus gr-button {
-      background: #ccc;
-    }
-    .transparentBackground,
-    gr-button.transparentBackground {
-      background-color: transparent;
-    }
-    :host([disabled]) {
-      opacity: 0.6;
-      pointer-events: none;
-    }
-    iron-icon {
-      height: 1.2rem;
-      width: 1.2rem;
-    }
-  </style>
-  <div class$="container [[_getBackgroundClass(transparentBackground)]]">
-    <gr-account-link
-      account="[[account]]"
-      change="[[change]]"
-      force-attention="[[forceAttention]]"
-      highlight-attention="[[highlightAttention]]"
-      voteable-text="[[voteableText]]"
-    >
-    </gr-account-link>
-    <gr-button
-      id="remove"
-      link=""
-      hidden$="[[!removable]]"
-      hidden=""
-      aria-label="Remove"
-      class$="remove [[_getBackgroundClass(transparentBackground)]]"
-      on-click="_handleRemoveTap"
-    >
-      <iron-icon icon="gr-icons:close"></iron-icon>
-    </gr-button>
-  </div>
-`;
diff --git a/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry.ts b/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry.ts
index 944054e..c250428 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry.ts
+++ b/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry.ts
@@ -54,13 +54,13 @@
    */
 
   @property({type: Boolean})
-  allowAnyInput?: boolean;
+  allowAnyInput = false;
 
   @property({type: Boolean})
-  borderless?: boolean;
+  borderless = false;
 
   @property({type: String})
-  placeholder?: string;
+  placeholder = '';
 
   @property({type: Number})
   suggestFrom = 0;
@@ -69,7 +69,7 @@
   querySuggestions: AutocompleteQuery = () => Promise.resolve([]);
 
   @property({type: String, observer: '_inputTextChanged'})
-  _inputText?: string;
+  _inputText = '';
 
   get focusStart() {
     return this.$.input.focusStart;
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 d7078ed..1a340b9 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
@@ -15,30 +15,25 @@
  * limitations under the License.
  */
 import '@polymer/iron-icon/iron-icon';
-import '../../../styles/shared-styles';
 import '../gr-avatar/gr-avatar';
 import '../gr-hovercard-account/gr-hovercard-account';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-account-label_html';
 import {appContext} from '../../../services/app-context';
 import {getDisplayName} from '../../../utils/display-name-util';
 import {isSelf, isServiceUser} from '../../../utils/account-util';
-import {customElement, property} from '@polymer/decorators';
 import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
 import {ChangeInfo, AccountInfo, ServerInfo} from '../../../types/common';
 import {hasOwnProperty} from '../../../utils/common-util';
 import {fireEvent} from '../../../utils/event-util';
 import {isInvolved} from '../../../utils/change-util';
 import {ShowAlertEventDetail} from '../../../types/events';
+import {GrLitElement} from '../../lit/gr-lit-element';
+import {css, customElement, html, property, state} from 'lit-element';
+import {classMap} from 'lit-html/directives/class-map';
 
 @customElement('gr-account-label')
-export class GrAccountLabel extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrAccountLabel extends GrLitElement {
   @property({type: Object})
-  account!: AccountInfo;
+  account?: AccountInfo;
 
   @property({type: Object})
   _selfAccount?: AccountInfo;
@@ -49,7 +44,7 @@
    * related features like adding the user as a reviewer.
    */
   @property({type: Object})
-  change!: ChangeInfo;
+  change?: ChangeInfo;
 
   @property({type: String})
   voteableText?: string;
@@ -83,44 +78,190 @@
 
   @property({
     type: Boolean,
-    reflectToAttribute: true,
-    computed:
-      '_computeCancelLeftPadding(hideAvatar, ' +
-      'highlightAttention, account, change, forceAttention)',
+    reflect: true,
   })
   cancelLeftPadding = false;
 
   @property({type: Boolean})
   hideStatus = false;
 
-  @property({type: Object})
+  @state()
   _config?: ServerInfo;
 
-  @property({type: Boolean, reflectToAttribute: true})
+  @property({type: Boolean, reflect: true})
   selectionChipStyle = false;
 
   @property({
     type: Boolean,
-    reflectToAttribute: true,
-    observer: 'selectedChanged',
+    reflect: true,
   })
   selected = false;
 
-  @property({type: Boolean, reflectToAttribute: true})
+  @property({type: Boolean, reflect: true})
   deselected = false;
 
   reporting: ReportingService;
 
   private readonly restApiService = appContext.restApiService;
 
+  static get styles() {
+    return [
+      css`
+        :host {
+          display: inline-block;
+          vertical-align: top;
+          position: relative;
+          border-radius: var(--label-border-radius);
+          box-sizing: border-box;
+          white-space: nowrap;
+          padding: 0 var(--account-label-padding-horizontal, 0);
+        }
+        /* If the first element is the avatar, then we cancel the left padding,
+        so we can fit nicely into the gr-account-chip rounding. The obvious
+        alternative of 'chip has padding' and 'avatar gets negative margin'
+        does not work, because we need 'overflow:hidden' on the label. */
+        :host([cancelLeftPadding]) {
+          padding-left: 0;
+        }
+        :host::after {
+          content: var(--account-label-suffix);
+        }
+        :host([deselected][selectionChipStyle]) {
+          background-color: var(--background-color-primary);
+          border: 1px solid var(--comment-separator-color);
+          border-radius: 8px;
+          color: var(--deemphasized-text-color);
+        }
+        :host([selected][selectionChipStyle]) {
+          background-color: var(--chip-selected-background-color);
+          border: 1px solid var(--chip-selected-background-color);
+          border-radius: 8px;
+          color: var(--chip-selected-text-color);
+        }
+        :host([selected]) iron-icon.attention {
+          color: var(--chip-selected-text-color);
+        }
+        gr-avatar {
+          height: calc(var(--line-height-normal) - 2px);
+          width: calc(var(--line-height-normal) - 2px);
+          vertical-align: top;
+          position: relative;
+          top: 1px;
+        }
+        #attentionButton {
+          /* This negates the 4px horizontal padding, which we appreciate as a
+         larger click target, but which we don't want to consume space. :-) */
+          margin: 0 -4px 0 -4px;
+          vertical-align: top;
+        }
+        iron-icon.attention {
+          color: var(--deemphasized-text-color);
+          width: 12px;
+          height: 12px;
+          vertical-align: top;
+        }
+        iron-icon.status {
+          color: var(--deemphasized-text-color);
+          width: 14px;
+          height: 14px;
+          vertical-align: top;
+          position: relative;
+          top: 2px;
+        }
+        .name {
+          display: inline-block;
+          text-decoration: inherit;
+          vertical-align: top;
+          overflow: hidden;
+          text-overflow: ellipsis;
+          max-width: var(--account-max-length, 180px);
+        }
+        .hasAttention .name {
+          font-weight: var(--font-weight-bold);
+        }
+      `,
+    ];
+  }
+
+  render() {
+    const {account, change, highlightAttention, forceAttention} = this;
+    if (!account) return;
+    const hasAttention =
+      forceAttention ||
+      this._hasUnforcedAttention(highlightAttention, account, change);
+    this.deselected = !this.selected;
+    this.cancelLeftPadding = !this.hideAvatar && !hasAttention;
+    return html`<span>
+        ${!this.hideHovercard
+          ? html`<gr-hovercard-account
+              for="hovercardTarget"
+              .account="${account}"
+              .change="${change}"
+              ?highlight-attention=${highlightAttention}
+              .voteableText=${this.voteableText}
+            ></gr-hovercard-account>`
+          : ''}
+        ${hasAttention
+          ? html`<gr-button
+              id="attentionButton"
+              link=""
+              aria-label="Remove user from attention set"
+              @click=${this._handleRemoveAttentionClick}
+              ?disabled=${!this._computeAttentionButtonEnabled(
+                highlightAttention,
+                account,
+                change,
+                this.selected,
+                this._selfAccount
+              )}
+              ?has-tooltip=${this._computeAttentionButtonEnabled(
+                highlightAttention,
+                account,
+                change,
+                false,
+                this._selfAccount
+              )}
+              title="${this._computeAttentionIconTitle(
+                highlightAttention,
+                account,
+                change,
+                forceAttention,
+                this.selected,
+                this._selfAccount
+              )}"
+              ><iron-icon
+                class="attention"
+                icon="gr-icons:attention"
+              ></iron-icon>
+            </gr-button>`
+          : ''}
+      </span>
+      <span
+        id="hovercardTarget"
+        class="${classMap({
+          hasAttention: !!hasAttention,
+        })}"
+      >
+        ${!this.hideAvatar
+          ? html`<gr-avatar .account="${account}" imageSize="32"></gr-avatar>`
+          : ''}
+        <span class="text" part="gr-account-label-text">
+          <span class="name"
+            >${this._computeName(account, this.firstName, this._config)}</span
+          >
+          ${!this.hideStatus && account.status
+            ? html`<iron-icon
+                class="status"
+                icon="gr-icons:calendar"
+              ></iron-icon>`
+            : ''}
+        </span>
+      </span>`;
+  }
+
   constructor() {
     super();
     this.reporting = appContext.reportingService;
-  }
-
-  /** @override */
-  ready() {
-    super.ready();
     this.restApiService.getConfig().then(config => {
       this._config = config;
     });
@@ -129,76 +270,42 @@
     });
     this.addEventListener('attention-set-updated', () => {
       // For re-evaluation of everything that depends on 'change'.
-      this.change = {...this.change};
+      if (this.change) this.change = {...this.change};
     });
   }
 
-  selectedChanged(selected?: boolean) {
-    this.deselected = !selected;
-  }
-
   _isAttentionSetEnabled(
     highlight: boolean,
     account: AccountInfo,
-    change: ChangeInfo
+    change?: ChangeInfo
   ) {
     return highlight && !!change && !!account && !isServiceUser(account);
   }
 
-  _computeCancelLeftPadding(
-    hideAvatar: boolean,
-    highlight: boolean,
-    account: AccountInfo,
-    change: ChangeInfo,
-    force: boolean
-  ) {
-    return (
-      !hideAvatar && !this._hasAttention(highlight, account, change, force)
-    );
-  }
-
-  _hasAttention(
-    highlight: boolean,
-    account: AccountInfo,
-    change: ChangeInfo,
-    force: boolean
-  ) {
-    return force || this._hasUnforcedAttention(highlight, account, change);
-  }
-
   _hasUnforcedAttention(
     highlight: boolean,
     account: AccountInfo,
-    change: ChangeInfo
+    change?: ChangeInfo
   ) {
     return (
       this._isAttentionSetEnabled(highlight, account, change) &&
+      change &&
       change.attention_set &&
       !!account._account_id &&
       hasOwnProperty(change.attention_set, account._account_id)
     );
   }
 
-  _computeHasAttentionClass(
-    highlight: boolean,
-    account: AccountInfo,
-    change: ChangeInfo,
-    force: boolean
-  ) {
-    return this._hasAttention(highlight, account, change, force)
-      ? 'hasAttention'
-      : '';
-  }
-
   _computeName(
     account?: AccountInfo,
-    config?: ServerInfo,
-    firstName?: boolean
+    firstName?: boolean,
+    config?: ServerInfo
   ) {
     return getDisplayName(config, account, firstName);
   }
 
   _handleRemoveAttentionClick(e: MouseEvent) {
+    if (!this.account || !this.change) return;
     if (this.selected) return;
     e.preventDefault();
     e.stopPropagation();
@@ -240,6 +347,7 @@
   }
 
   _reportingDetails() {
+    if (!this.account) return;
     const targetId = this.account._account_id;
     const ownerId =
       (this.change && this.change.owner && this.change.owner._account_id) || -1;
@@ -263,13 +371,13 @@
   _computeAttentionButtonEnabled(
     highlight: boolean,
     account: AccountInfo,
-    change: ChangeInfo,
-    selfAccount: AccountInfo,
-    selected: boolean
+    change: ChangeInfo | undefined,
+    selected: boolean,
+    selfAccount?: AccountInfo
   ) {
     if (selected) return true;
     return (
-      this._hasUnforcedAttention(highlight, account, change) &&
+      !!this._hasUnforcedAttention(highlight, account, change) &&
       (isInvolved(change, selfAccount) || isSelf(account, selfAccount))
     );
   }
@@ -277,17 +385,17 @@
   _computeAttentionIconTitle(
     highlight: boolean,
     account: AccountInfo,
-    change: ChangeInfo,
-    selfAccount: AccountInfo,
+    change: ChangeInfo | undefined,
     force: boolean,
-    selected: boolean
+    selected: boolean,
+    selfAccount?: AccountInfo
   ) {
     const enabled = this._computeAttentionButtonEnabled(
       highlight,
       account,
       change,
-      selfAccount,
-      selected
+      selected,
+      selfAccount
     );
     return enabled
       ? 'Click to remove the user from the attention set'
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
deleted file mode 100644
index a642337..0000000
--- a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_html.ts
+++ /dev/null
@@ -1,142 +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 {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style>
-    :host {
-      display: inline-block;
-      vertical-align: top;
-      position: relative;
-      border-radius: var(--label-border-radius);
-      box-sizing: border-box;
-      white-space: nowrap;
-      padding: 0 var(--account-label-padding-horizontal, 0);
-    }
-    /* If the first element is the avatar, then we cancel the left padding, so
-       we can fit nicely into the gr-account-chip rounding.
-       The obvious alternative of 'chip has padding' and 'avatar gets negative
-       margin' does not work, because we need 'overflow:hidden' on the label. */
-    :host([cancel-left-padding]) {
-      padding-left: 0;
-    }
-    :host::after {
-      content: var(--account-label-suffix);
-    }
-    :host([deselected][selection-chip-style]) {
-      background-color: var(--background-color-primary);
-      border: 1px solid var(--comment-separator-color);
-      border-radius: 8px;
-      color: var(--deemphasized-text-color);
-    }
-    :host([selected][selection-chip-style]) {
-      background-color: var(--chip-selected-background-color);
-      border: 1px solid var(--chip-selected-background-color);
-      border-radius: 8px;
-      color: var(--chip-selected-text-color);
-    }
-    :host([selected]) iron-icon.attention {
-      color: var(--chip-selected-text-color);
-    }
-    gr-avatar {
-      height: calc(var(--line-height-normal) - 2px);
-      width: calc(var(--line-height-normal) - 2px);
-      vertical-align: top;
-      position: relative;
-      top: 1px;
-    }
-    .text {
-      @apply --gr-account-label-text-style;
-    }
-    .text:hover {
-      @apply --gr-account-label-text-hover-style;
-    }
-    #attentionButton {
-      /* This negates the 4px horizontal padding, which we appreciate as a
-         larger click target, but which we don't want to consume space. :-) */
-      margin: 0 -4px 0 -4px;
-      vertical-align: top;
-    }
-    iron-icon.attention {
-      color: var(--deemphasized-text-color);
-      width: 12px;
-      height: 12px;
-      vertical-align: top;
-    }
-    iron-icon.status {
-      color: var(--deemphasized-text-color);
-      width: 14px;
-      height: 14px;
-      vertical-align: top;
-      position: relative;
-      top: 2px;
-    }
-    .name {
-      display: inline-block;
-      vertical-align: top;
-      overflow: hidden;
-      text-overflow: ellipsis;
-      max-width: var(--account-max-length, 180px);
-    }
-    .hasAttention .name {
-      font-weight: var(--font-weight-bold);
-    }
-  </style>
-  <span>
-    <template is="dom-if" if="[[!hideHovercard]]">
-      <gr-hovercard-account
-        for="hovercardTarget"
-        account="[[account]]"
-        change="[[change]]"
-        highlight-attention="[[highlightAttention]]"
-        voteable-text="[[voteableText]]"
-      >
-      </gr-hovercard-account>
-    </template>
-    <template
-      is="dom-if"
-      if="[[_hasAttention(highlightAttention, account, change, forceAttention)]]"
-    >
-      <gr-button
-        id="attentionButton"
-        link=""
-        aria-label="Remove user from attention set"
-        on-click="_handleRemoveAttentionClick"
-        disabled="[[!_computeAttentionButtonEnabled(highlightAttention, account, change, _selfAccount, selected)]]"
-        has-tooltip="[[_computeAttentionButtonEnabled(highlightAttention, account, change, _selfAccount, false)]]"
-        title="[[_computeAttentionIconTitle(highlightAttention, account, change, _selfAccount, forceAttention, selected)]]"
-        ><iron-icon class="attention" icon="gr-icons:attention"></iron-icon>
-      </gr-button>
-    </template>
-  </span>
-  <span
-    id="hovercardTarget"
-    class$="[[_computeHasAttentionClass(highlightAttention, account, change, forceAttention)]]"
-  >
-    <template is="dom-if" if="[[!hideAvatar]]">
-      <gr-avatar account="[[account]]" imageSize="32"></gr-avatar>
-    </template>
-    <span class="text">
-      <span class="name">[[_computeName(account, _config, firstName)]]</span>
-      <template is="dom-if" if="[[!hideStatus]]">
-        <template is="dom-if" if="[[account.status]]">
-          <iron-icon class="status" icon="gr-icons:calendar"></iron-icon>
-        </template>
-      </template>
-    </span>
-  </span>
-`;
diff --git a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_test.ts b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_test.ts
index efaa9f7..574e450 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_test.ts
@@ -55,12 +55,12 @@
   suite('_computeName', () => {
     test('not showing anonymous', () => {
       const account = {name: 'Wyatt'};
-      assert.deepEqual(element._computeName(account), 'Wyatt');
+      assert.deepEqual(element._computeName(account, false), 'Wyatt');
     });
 
     test('showing anonymous but no config', () => {
       const account = {};
-      assert.deepEqual(element._computeName(account), 'Anonymous');
+      assert.deepEqual(element._computeName(account, false), 'Anonymous');
     });
 
     test('test for Anonymous Coward user and replace with Anonymous', () => {
@@ -71,7 +71,10 @@
         },
       };
       const account = {};
-      assert.deepEqual(element._computeName(account, config), 'Anonymous');
+      assert.deepEqual(
+        element._computeName(account, false, config),
+        'Anonymous'
+      );
     });
 
     test('test for anonymous_coward_name', () => {
@@ -82,7 +85,10 @@
         },
       };
       const account = {};
-      assert.deepEqual(element._computeName(account, config), 'TestAnon');
+      assert.deepEqual(
+        element._computeName(account, false, config),
+        'TestAnon'
+      );
     });
   });
 
diff --git a/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link.ts b/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link.ts
index 94a24e1..a610ffa 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link.ts
+++ b/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link.ts
@@ -18,10 +18,11 @@
 import '../gr-account-label/gr-account-label';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 import {AccountInfo, ChangeInfo} from '../../../types/common';
-import {css, customElement, html, LitElement, property} from 'lit-element';
+import {css, customElement, html, property} from 'lit-element';
+import {GrLitElement} from '../../lit/gr-lit-element';
 
 @customElement('gr-account-link')
-export class GrAccountLink extends LitElement {
+export class GrAccountLink extends GrLitElement {
   @property({type: String})
   voteableText?: string;
 
@@ -74,10 +75,8 @@
           color: var(--primary-text-color);
           text-decoration: none;
         }
-        gr-account-label {
-          --gr-account-label-text-hover-style: {
-            text-decoration: underline;
-          }
+        gr-account-label::part(gr-account-label-text):hover {
+          text-decoration: underline !important;
         }
       `,
     ];
@@ -90,12 +89,13 @@
         <gr-account-label
           .account="${this.account}"
           .change="${this.change}"
-          ?force-attention=${this.forceAttention}
-          ?highlight-attention=${this.highlightAttention}
-          ?hide-avatar=${this.hideAvatar}
-          ?hide-status=${this.hideStatus}
-          ?first-name=${this.firstName}
-          .voteable-text=${this.voteableText}
+          ?forceAttention=${this.forceAttention}
+          ?highlightAttention=${this.highlightAttention}
+          ?hideAvatar=${this.hideAvatar}
+          ?hideStatus=${this.hideStatus}
+          ?firstName=${this.firstName}
+          .voteableText=${this.voteableText}
+          part="gr-account-link-text => gr-account-label-text"
         >
         </gr-account-label>
       </a>
diff --git a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar_test.js b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar_test.ts
similarity index 60%
rename from polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar_test.js
rename to polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar_test.ts
index df8632f..bb70855 100644
--- a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar_test.ts
@@ -15,19 +15,26 @@
  * limitations under the License.
  */
 
-import '../../../test/common-test-setup-karma.js';
-import './gr-avatar.js';
-import {getPluginLoader} from '../gr-js-api-interface/gr-plugin-loader.js';
-import {appContext} from '../../../services/app-context.js';
+import '../../../test/common-test-setup-karma';
+import './gr-avatar';
+import {GrAvatar} from './gr-avatar';
+import {getPluginLoader} from '../gr-js-api-interface/gr-plugin-loader';
+import {appContext} from '../../../services/app-context';
+import {AvatarInfo} from '../../../types/common';
+import {
+  createAccountWithEmail,
+  createAccountWithId,
+} from '../../../test/test-data-generators';
 
 const basicFixture = fixtureFromElement('gr-avatar');
 
 suite('gr-avatar tests', () => {
-  let element;
-  const defaultAvatars = [
+  let element: GrAvatar;
+  const defaultAvatars: AvatarInfo[] = [
     {
       url: 'https://cdn.example.com/s12-p/photo.jpg',
       height: 12,
+      width: 0,
     },
   ];
 
@@ -36,68 +43,74 @@
   });
 
   test('account without avatar', () => {
-    assert.equal(
-        element._buildAvatarURL({
-          _account_id: 123,
-        }),
-        '');
+    assert.equal(element._buildAvatarURL(createAccountWithId(123)), '');
   });
 
   test('methods', () => {
     assert.equal(
-        element._buildAvatarURL({
-          _account_id: 123,
-          avatars: defaultAvatars,
-        }),
-        '/accounts/123/avatar?s=16');
+      element._buildAvatarURL({
+        ...createAccountWithId(123),
+        avatars: defaultAvatars,
+      }),
+      '/accounts/123/avatar?s=16'
+    );
     assert.equal(
-        element._buildAvatarURL({
-          email: 'test@example.com',
-          avatars: defaultAvatars,
-        }),
-        '/accounts/test%40example.com/avatar?s=16');
+      element._buildAvatarURL({
+        ...createAccountWithEmail('test@example.com'),
+        avatars: defaultAvatars,
+      }),
+      '/accounts/test%40example.com/avatar?s=16'
+    );
     assert.equal(
-        element._buildAvatarURL({
-          name: 'John Doe',
-          avatars: defaultAvatars,
-        }),
-        '/accounts/John%20Doe/avatar?s=16');
+      element._buildAvatarURL({
+        name: 'John Doe',
+        avatars: defaultAvatars,
+      }),
+      '/accounts/John%20Doe/avatar?s=16'
+    );
     assert.equal(
-        element._buildAvatarURL({
-          username: 'John_Doe',
-          avatars: defaultAvatars,
-        }),
-        '/accounts/John_Doe/avatar?s=16');
+      element._buildAvatarURL({
+        username: 'John_Doe',
+        avatars: defaultAvatars,
+      }),
+      '/accounts/John_Doe/avatar?s=16'
+    );
     assert.equal(
-        element._buildAvatarURL({
-          _account_id: 123,
-          avatars: [
-            {
-              url: 'https://cdn.example.com/s12-p/photo.jpg',
-              height: 12,
-            },
-            {
-              url: 'https://cdn.example.com/s16-p/photo.jpg',
-              height: 16,
-            },
-            {
-              url: 'https://cdn.example.com/s100-p/photo.jpg',
-              height: 100,
-            },
-          ],
-        }),
-        'https://cdn.example.com/s16-p/photo.jpg');
+      element._buildAvatarURL({
+        ...createAccountWithId(123),
+        avatars: [
+          {
+            url: 'https://cdn.example.com/s12-p/photo.jpg',
+            height: 12,
+            width: 0,
+          },
+          {
+            url: 'https://cdn.example.com/s16-p/photo.jpg',
+            height: 16,
+            width: 0,
+          },
+          {
+            url: 'https://cdn.example.com/s100-p/photo.jpg',
+            height: 100,
+            width: 0,
+          },
+        ] as AvatarInfo[],
+      }),
+      'https://cdn.example.com/s16-p/photo.jpg'
+    );
     assert.equal(
-        element._buildAvatarURL({
-          _account_id: 123,
-          avatars: [
-            {
-              url: 'https://cdn.example.com/s95-p/photo.jpg',
-              height: 95,
-            },
-          ],
-        }),
-        '/accounts/123/avatar?s=16');
+      element._buildAvatarURL({
+        ...createAccountWithId(123),
+        avatars: [
+          {
+            url: 'https://cdn.example.com/s95-p/photo.jpg',
+            height: 95,
+            width: 0,
+          },
+        ] as AvatarInfo[],
+      }),
+      '/accounts/123/avatar?s=16'
+    );
     assert.equal(element._buildAvatarURL(undefined), '');
   });
 
@@ -114,7 +127,7 @@
 
       element.imageSize = 64;
       element.account = {
-        _account_id: 123,
+        ...createAccountWithId(123),
         avatars: defaultAvatars,
       };
       flush();
@@ -131,14 +144,14 @@
         assert.isFalse(element.hasAttribute('hidden'));
 
         assert.isTrue(
-            element.style.backgroundImage.includes(
-                '/accounts/123/avatar?s=64'));
+          element.style.backgroundImage.includes('/accounts/123/avatar?s=64')
+        );
       });
     });
   });
 
   suite('plugin has avatars', () => {
-    let element;
+    let element: GrAvatar;
 
     setup(() => {
       stub('gr-avatar', '_getConfig').callsFake(() =>
@@ -166,7 +179,7 @@
   });
 
   suite('config not set', () => {
-    let element;
+    let element: GrAvatar;
 
     setup(() => {
       stub('gr-avatar', '_getConfig').callsFake(() => Promise.resolve({}));
@@ -180,7 +193,7 @@
 
       element.imageSize = 64;
       element.account = {
-        _account_id: 123,
+        ...createAccountWithId(123),
         avatars: defaultAvatars,
       };
       // Emulate plugins loaded.
@@ -195,4 +208,3 @@
     });
   });
 });
-
diff --git a/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status.ts b/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status.ts
index 65e8e9f..0bd02d5 100644
--- a/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status.ts
+++ b/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status.ts
@@ -77,11 +77,11 @@
   @property({type: Object})
   resolveWeblinks?: GeneratedWebLink[] = [];
 
-  _computeStatusString(status: ChangeStates) {
+  _computeStatusString(status?: ChangeStates) {
     if (status === ChangeStates.WIP && !this.flat) {
       return 'Work in Progress';
     }
-    return status;
+    return status ?? '';
   }
 
   _toClassName(str?: ChangeStates) {
@@ -107,14 +107,14 @@
     revertedChange?: ChangeInfo,
     resolveWeblinks?: GeneratedWebLink[],
     status?: ChangeStates
-  ): string | undefined {
+  ): string {
     if (revertedChange) {
       return GerritNav.getUrlForSearchQuery(`${revertedChange._number}`);
     }
     if (status === ChangeStates.MERGE_CONFLICT && resolveWeblinks?.length) {
-      return resolveWeblinks[0].url;
+      return resolveWeblinks[0].url ?? '';
     }
-    return undefined;
+    return '';
   }
 
   showResolveIcon(
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 5e8cedd..2ad3be6 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
@@ -92,12 +92,6 @@
    */
 
   /**
-   * Fired when a comment in the thread is permanently modified.
-   *
-   * @event thread-changed
-   */
-
-  /**
    * gr-comment-thread exposes the following attributes that allow a
    * diff widget like gr-diff to show the thread in the right location:
    *
@@ -718,7 +712,6 @@
     if (this.comments.length === 0) {
       this.fireRemoveSelf();
     }
-    this._handleCommentSavedOrDiscarded();
 
     // Check to see if there are any other open comments getting edited and
     // set the local storage value to its message value.
@@ -738,15 +731,6 @@
     }
   }
 
-  _handleCommentSavedOrDiscarded() {
-    this.dispatchEvent(
-      new CustomEvent('thread-changed', {
-        detail: {rootId: this.rootId, path: this.path},
-        bubbles: false,
-      })
-    );
-  }
-
   _handleCommentUpdate(e: CustomEvent) {
     const comment = e.detail.comment;
     const index = this._indexOf(comment, this.comments);
diff --git a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_html.ts b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_html.ts
index 247e8e4..5c01467 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_html.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_html.ts
@@ -165,7 +165,6 @@
           project-config="[[_projectConfig]]"
           on-create-fix-comment="_handleCommentFix"
           on-comment-discard="_handleCommentDiscard"
-          on-comment-save="_handleCommentSavedOrDiscarded"
           on-copy-comment-link="handleCopyLink"
         ></gr-comment>
       </template>
diff --git a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.ts b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.ts
index 31908fd..82a455e 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.ts
@@ -495,21 +495,10 @@
     const commentEl = element.shadowRoot?.querySelector('gr-comment');
     assert.ok(commentEl);
 
-    const saveOrDiscardStub = sinon.stub();
-    element.addEventListener('thread-changed', saveOrDiscardStub);
     element.shadowRoot?.querySelector('gr-comment')?._fireSave();
 
     flush(() => {
-      assert.isTrue(saveOrDiscardStub.called);
-      assert.equal(
-        saveOrDiscardStub.lastCall.args[0].detail.rootId,
-        'baf0414d_60047215'
-      );
       assert.equal(element.rootId, 'baf0414d_60047215' as UrlEncodedCommentId);
-      assert.equal(
-        saveOrDiscardStub.lastCall.args[0].detail.path,
-        '/path/to/file.txt'
-      );
       done();
     });
   });
@@ -556,22 +545,11 @@
     );
     flush();
 
-    const saveOrDiscardStub = sinon.stub();
-    element.addEventListener('thread-changed', saveOrDiscardStub);
     const draftEl = element.root?.querySelectorAll('gr-comment')[1];
     assert.ok(draftEl);
     draftEl!.addEventListener('comment-discard', () => {
       const drafts = element.comments.filter(c => isDraft(c));
       assert.equal(drafts.length, 0);
-      assert.isTrue(saveOrDiscardStub.called);
-      assert.equal(
-        saveOrDiscardStub.lastCall.args[0].detail.rootId,
-        element.rootId
-      );
-      assert.equal(
-        saveOrDiscardStub.lastCall.args[0].detail.path,
-        element.path
-      );
       done();
     });
     draftEl!.dispatchEvent(
@@ -593,18 +571,10 @@
     const rootId = element.rootId;
     assert.isOk(rootId);
 
-    const saveOrDiscardStub = sinon.stub();
-    element.addEventListener('thread-changed', saveOrDiscardStub);
     const draftEl = element.root?.querySelectorAll('gr-comment')[0];
     assert.ok(draftEl);
     draftEl!.addEventListener('comment-discard', () => {
       assert.equal(element.comments.length, 0);
-      assert.isTrue(saveOrDiscardStub.called);
-      assert.equal(saveOrDiscardStub.lastCall.args[0].detail.rootId, rootId);
-      assert.equal(
-        saveOrDiscardStub.lastCall.args[0].detail.path,
-        element.path
-      );
       done();
     });
     draftEl!.dispatchEvent(
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 5dea745..1303e48 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
@@ -273,6 +273,8 @@
 
   private readonly reporting = appContext.reportingService;
 
+  private readonly commentsService = appContext.commentsService;
+
   private fireUpdateTask?: DelayedTask;
 
   private storeTask?: DelayedTask;
@@ -486,6 +488,7 @@
           if (this.comment?.__draftID) {
             resComment.__draftID = this.comment.__draftID;
           }
+          if (!resComment.patch_set) resComment.patch_set = this.patchNum;
           this.comment = resComment;
           this._fireSave();
           return obj;
@@ -542,6 +545,7 @@
   }
 
   _fireSave() {
+    if (this.comment) this.commentsService.addDraft(this.comment);
     this.dispatchEvent(
       new CustomEvent('comment-save', {
         detail: this._getEventPayload(),
@@ -743,6 +747,7 @@
   }
 
   _fireDiscard() {
+    if (this.comment) this.commentsService.deleteDraft(this.comment);
     this.fireUpdateTask?.cancel();
     this.dispatchEvent(
       new CustomEvent('comment-discard', {
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_html.ts b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_html.ts
index 43c949a..3e0b9a4 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_html.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_html.ts
@@ -236,12 +236,12 @@
       margin-left: var(--spacing-s);
     }
     .headerLeft gr-account-label {
-      --gr-account-label-text-style: {
-        font-weight: var(--font-weight-bold);
-      }
       --account-max-length: 130px;
       width: 150px;
     }
+    .headerLeft gr-account-label::part(gr-account-label-text) {
+      font-weight: var(--font-weight-bold);
+    }
     .draft gr-account-label {
       width: unset;
     }
@@ -262,7 +262,7 @@
           <gr-account-label
             account="[[_getAuthor(comment, _selfAccount)]]"
             class$="[[_computeAccountLabelClass(draft)]]"
-            hide-status=""
+            hideStatus
           >
           </gr-account-label>
         </template>
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts
index bbf3442..a218959 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts
@@ -367,23 +367,20 @@
         getTimerStub = stubReporting('getTimer').returns(mockTimer);
       });
 
-      test('create', () => {
+      test('create', async () => {
         element.patchNum = 1 as PatchSetNum;
         element.comment = {};
-        return element._handleSave(mockEvent)!.then(() => {
-          assert.equal(
-            (queryAndAssert(
-              element,
-              'gr-account-label'
-            ).shadowRoot?.querySelector(
-              'span.name'
-            ) as HTMLSpanElement).innerText.trim(),
-            'Dhruv Srivastava'
-          );
-          assert.isTrue(endStub.calledOnce);
-          assert.isTrue(getTimerStub.calledOnce);
-          assert.equal(getTimerStub.lastCall.args[0], 'CreateDraftComment');
-        });
+        await element._handleSave(mockEvent);
+        await flush();
+        const grAccountLabel = queryAndAssert(element, 'gr-account-label');
+        const spanName = queryAndAssert<HTMLSpanElement>(
+          grAccountLabel,
+          'span.name'
+        );
+        assert.equal(spanName.innerText.trim(), 'Dhruv Srivastava');
+        assert.isTrue(endStub.calledOnce);
+        assert.isTrue(getTimerStub.calledOnce);
+        assert.equal(getTimerStub.lastCall.args[0], 'CreateDraftComment');
       });
 
       test('update', () => {
diff --git a/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard.ts b/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard.ts
index 2fe6fed..36cffb7 100644
--- a/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard.ts
+++ b/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard.ts
@@ -15,16 +15,15 @@
  * limitations under the License.
  */
 import '@polymer/iron-input/iron-input';
-import '../../../styles/shared-styles';
 import '../gr-button/gr-button';
 import '../gr-icons/gr-icons';
-import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-copy-clipboard_html';
-import {GrButton} from '../gr-button/gr-button';
-import {customElement, property} from '@polymer/decorators';
 import {IronIconElement} from '@polymer/iron-icon';
-import {assertIsDefined} from '../../../utils/common-util';
+import {assertIsDefined, queryAndAssert} from '../../../utils/common-util';
+import {classMap} from 'lit-html/directives/class-map';
+import {ifDefined} from 'lit-html/directives/if-defined';
+import {css, customElement, html, property} from 'lit-element';
+import {GrLitElement} from '../../lit/gr-lit-element';
+import {GrButton} from '../gr-button/gr-button';
 
 const COPY_TIMEOUT_MS = 1000;
 
@@ -33,17 +32,8 @@
     'gr-copy-clipboard': GrCopyClipboard;
   }
 }
-
-export interface GrCopyClipboard {
-  $: {button: GrButton; icon: IronIconElement; input: HTMLInputElement};
-}
-
 @customElement('gr-copy-clipboard')
-export class GrCopyClipboard extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrCopyClipboard extends GrLitElement {
   @property({type: String})
   text: string | undefined;
 
@@ -56,29 +46,121 @@
   @property({type: Boolean})
   hideInput = false;
 
-  focusOnCopy() {
-    this.$.button.focus();
+  static get styles() {
+    return [
+      css`
+        .text {
+          align-items: center;
+          display: flex;
+          flex-wrap: wrap;
+        }
+        .copyText {
+          flex-grow: 1;
+          margin-right: var(--spacing-s);
+        }
+        .hideInput {
+          display: none;
+        }
+        input#input {
+          font-family: var(--monospace-font-family);
+          font-size: var(--font-size-mono);
+          line-height: var(--line-height-mono);
+          width: 100%;
+        }
+        /*
+         * Typically icons are 20px, which is the normal line-height.
+         * The copy icon is too prominent at 20px, so we choose 16px
+         * here, but add 2x2px padding below, so the entire
+         * component should still fit nicely into a normal inline
+         * layout flow.
+         */
+        #icon {
+          height: 16px;
+          width: 16px;
+        }
+        iron-icon {
+          color: var(--deemphasized-text-color);
+          vertical-align: top;
+        }
+      `,
+    ];
   }
 
-  _computeInputClass(hideInput: boolean) {
-    return hideInput ? 'hideInput' : '';
+  render() {
+    // To pass CSS mixins for @apply to Polymer components, they need to appear
+    // in <style> inside the template.
+    const customStyle = html`
+      <style>
+        iron-icon {
+          --iron-icon-height: 20px;
+          --iron-icon-width: 20px;
+        }
+        gr-button {
+          --gr-button: {
+            padding: 2px;
+          }
+        }
+      </style>
+    `;
+    return html`${customStyle}
+      <div class="text">
+        <iron-input
+          class="copyText"
+          type="text"
+          @click="${this._handleInputClick}"
+          readonly=""
+          bind-value=${this.text}
+        >
+          <input
+            id="input"
+            is="iron-input"
+            class="${classMap({hideInput: this.hideInput})}"
+            type="text"
+            @click="${this._handleInputClick}"
+            readonly=""
+            .value=${this.text}
+            part="text-container-style"
+          />
+        </iron-input>
+        <gr-button
+          id="copy-clipboard-button"
+          link=""
+          ?has-tooltip=${this.hasTooltip}
+          class="copyToClipboard"
+          title="${ifDefined(this.buttonTitle)}"
+          @click="${this._copyToClipboard}"
+          aria-label="Click to copy to clipboard"
+        >
+          <iron-icon id="icon" icon="gr-icons:content-copy"></iron-icon>
+        </gr-button>
+      </div> `;
+  }
+
+  focusOnCopy() {
+    queryAndAssert<GrButton>(this, '#copy-clipboard-button').focus();
   }
 
   _handleInputClick(e: MouseEvent) {
     e.preventDefault();
-    ((dom(e) as EventApi).rootTarget as HTMLInputElement).select();
+    const rootTarget = e.composedPath()[0];
+    (rootTarget as HTMLInputElement).select();
   }
 
   _copyToClipboard(e: MouseEvent) {
     e.preventDefault();
     e.stopPropagation();
 
+    this.text = queryAndAssert<HTMLInputElement>(this, '#input').value;
     assertIsDefined(this.text, 'text');
-    this.$.icon.icon = 'gr-icons:check';
+    this.iconEl.icon = 'gr-icons:check';
     navigator.clipboard.writeText(this.text);
     setTimeout(
-      () => (this.$.icon.icon = 'gr-icons:content-copy'),
+      () => (this.iconEl.icon = 'gr-icons:content-copy'),
       COPY_TIMEOUT_MS
     );
   }
+
+  private get iconEl(): IronIconElement {
+    return queryAndAssert<IronIconElement>(this, '#icon');
+  }
 }
diff --git a/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard_html.ts b/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard_html.ts
deleted file mode 100644
index 3ccc46f..0000000
--- a/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard_html.ts
+++ /dev/null
@@ -1,93 +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 {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style>
-    .text {
-      align-items: center;
-      display: flex;
-      flex-wrap: wrap;
-    }
-    .copyText {
-      flex-grow: 1;
-      margin-right: var(--spacing-s);
-    }
-    .hideInput {
-      display: none;
-    }
-    input#input {
-      font-family: var(--monospace-font-family);
-      font-size: var(--font-size-mono);
-      line-height: var(--line-height-mono);
-      @apply --text-container-style;
-      width: 100%;
-    }
-    /*
-       * Typically icons are 20px, which is the normal line-height.
-       * The copy icon is too prominent at 20px, so we choose 16px
-       * here, but add 2x2px padding below, so the entire
-       * component should still fit nicely into a normal inline
-       * layout flow.
-       */
-    #icon {
-      height: 16px;
-      width: 16px;
-    }
-    iron-icon {
-      color: var(--deemphasized-text-color);
-      vertical-align: top;
-      --iron-icon-height: 20px;
-      --iron-icon-width: 20px;
-    }
-    gr-button {
-      --gr-button: {
-        padding: 2px;
-      }
-    }
-  </style>
-  <div class="text">
-    <iron-input
-      class="copyText"
-      type="text"
-      bind-value="[[text]]"
-      on-click="_handleInputClick"
-      readonly=""
-    >
-      <input
-        id="input"
-        is="iron-input"
-        class$="[[_computeInputClass(hideInput)]]"
-        type="text"
-        bind-value="[[text]]"
-        on-click="_handleInputClick"
-        readonly=""
-      />
-    </iron-input>
-    <gr-button
-      id="button"
-      link=""
-      has-tooltip="[[hasTooltip]]"
-      class="copyToClipboard"
-      title="[[buttonTitle]]"
-      on-click="_copyToClipboard"
-      aria-label="Click to copy to clipboard"
-    >
-      <iron-icon id="icon" icon="gr-icons:content-copy"></iron-icon>
-    </gr-button>
-  </div>
-`;
diff --git a/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard_test.js b/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard_test.ts
similarity index 61%
rename from polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard_test.js
rename to polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard_test.ts
index 55b2483..ef62fe9 100644
--- a/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard_test.ts
@@ -15,14 +15,16 @@
  * limitations under the License.
  */
 
-import '../../../test/common-test-setup-karma.js';
-import './gr-copy-clipboard.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import '../../../test/common-test-setup-karma';
+import './gr-copy-clipboard';
+import {GrCopyClipboard} from './gr-copy-clipboard';
+import {queryAndAssert} from '../../../test/test-utils';
+import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
 
 const basicFixture = fixtureFromElement('gr-copy-clipboard');
 
 suite('gr-copy-clipboard tests', () => {
-  let element;
+  let element: GrCopyClipboard;
 
   setup(async () => {
     element = basicFixture.instantiate();
@@ -32,42 +34,42 @@
   });
 
   test('copy to clipboard', () => {
-    const clipboardSpy = sinon.spy(element, '_copyToClipboard');
-    const copyBtn = element.shadowRoot
-        .querySelector('.copyToClipboard');
-    MockInteractions.tap(copyBtn);
+    const clipboardSpy = sinon.spy(navigator.clipboard, 'writeText');
+    const copyBtn = queryAndAssert(element, '.copyToClipboard');
+    MockInteractions.click(copyBtn);
     assert.isTrue(clipboardSpy.called);
   });
 
   test('focusOnCopy', () => {
     element.focusOnCopy();
-    assert.deepEqual(dom(element.root).activeElement,
-        element.shadowRoot
-            .querySelector('.copyToClipboard'));
+    const activeElement = element.shadowRoot!.activeElement;
+    const button = queryAndAssert(element, '.copyToClipboard');
+    assert.deepEqual(activeElement, button);
   });
 
   test('_handleInputClick', () => {
     // iron-input as parent should never be hidden as copy won't work
     // on nested hidden elements
-    const ironInputElement = element.shadowRoot.querySelector('iron-input');
+    const ironInputElement = queryAndAssert(element, 'iron-input');
     assert.notEqual(getComputedStyle(ironInputElement).display, 'none');
 
-    const inputElement = element.shadowRoot.querySelector('input');
+    const inputElement = queryAndAssert(element, 'input') as HTMLInputElement;
     MockInteractions.tap(inputElement);
     assert.equal(inputElement.selectionStart, 0);
-    assert.equal(inputElement.selectionEnd, element.text.length - 1);
+    assert.equal(inputElement.selectionEnd, element.text!.length! - 1);
   });
 
-  test('hideInput', () => {
+  test('hideInput', async () => {
     // iron-input as parent should never be hidden as copy won't work
     // on nested hidden elements
-    const ironInputElement = element.shadowRoot.querySelector('iron-input');
+    const ironInputElement = queryAndAssert(element, 'iron-input');
     assert.notEqual(getComputedStyle(ironInputElement).display, 'none');
 
-    assert.notEqual(getComputedStyle(element.$.input).display, 'none');
+    const input = queryAndAssert(element, 'input');
+    assert.notEqual(getComputedStyle(input).display, 'none');
     element.hideInput = true;
-    flush();
-    assert.equal(getComputedStyle(element.$.input).display, 'none');
+    await flush();
+    assert.equal(getComputedStyle(input).display, 'none');
   });
 
   test('stop events propagation', () => {
@@ -75,10 +77,8 @@
     divParent.appendChild(element);
     const clickStub = sinon.stub();
     divParent.addEventListener('click', clickStub);
-    element.stopPropagation = true;
-    const copyBtn = element.shadowRoot.querySelector('.copyToClipboard');
+    const copyBtn = queryAndAssert(element, '.copyToClipboard');
     MockInteractions.tap(copyBtn);
     assert.isFalse(clickStub.called);
   });
 });
-
diff --git a/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.ts b/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.ts
index 017ba50..9dce127 100644
--- a/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.ts
+++ b/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.ts
@@ -87,7 +87,7 @@
   }
 
   /**
-   * Move the cursor forward. Clipped to the ends of the stop list.
+   * Move the cursor forward. Clipped to the end of the stop list.
    *
    * @param options.filter Skips any stops for which filter returns false.
    * @param options.getTargetHeight Optional function to calculate the
@@ -95,22 +95,36 @@
    *    sometimes different, used by the diff cursor.
    * @param options.clipToTop When none of the next indices match, move
    *     back to first instead of to last.
+   * @param options.circular When on last element, you get to first element.
    * @return If a move was performed or why not.
-   * @private
    */
   next(
     options: {
       filter?: (stop: HTMLElement) => boolean;
       getTargetHeight?: (target: HTMLElement) => number;
       clipToTop?: boolean;
+      circular?: boolean;
     } = {}
   ): CursorMoveResult {
     return this._moveCursor(1, options);
   }
 
+  /**
+   * Move the cursor backward. Clipped to the beginning of stop list.
+   *
+   * @param options.filter Skips any stops for which filter returns false.
+   * @param options.getTargetHeight Optional function to calculate the
+   *    height of the target's 'section'. The height of the target itself is
+   *    sometimes different, used by the diff cursor.
+   * @param options.clipToTop When none of the next indices match, move
+   * back to first instead of to last.
+   * @param options.circular When on first element, you get to last element.
+   * @return  If a move was performed or why not.
+   */
   previous(
     options: {
       filter?: (stop: HTMLElement) => boolean;
+      circular?: boolean;
     } = {}
   ): CursorMoveResult {
     return this._moveCursor(-1, options);
@@ -276,34 +290,18 @@
     }
   }
 
-  /**
-   * Move the cursor forward or backward by delta. Clipped to the beginning or
-   * end of stop list.
-   *
-   * @param delta either -1 or 1.
-   * @param options.abort Will abort moving the cursor when encountering a
-   *    stop for which this condition is met. Will abort even if the stop
-   *    would have been filtered
-   * @param options.filter Will keep going and skip any stops for which this
-   *    condition is not met.
-   * @param options.getTargetHeight Optional function to calculate the
-   * height of the target's 'section'. The height of the target itself is
-   * sometimes different, used by the diff cursor.
-   * @param options.clipToTop When none of the next indices match, move
-   * back to first instead of to last.
-   * @return  If a move was performed or why not.
-   * @private
-   */
   _moveCursor(
     delta: number,
     {
       filter,
       getTargetHeight,
       clipToTop,
+      circular,
     }: {
       filter?: (stop: HTMLElement) => boolean;
       getTargetHeight?: (target: HTMLElement) => number;
       clipToTop?: boolean;
+      circular?: boolean;
     } = {}
   ): CursorMoveResult {
     if (!this.stops.length) {
@@ -326,7 +324,10 @@
         (delta > 0 && newIndex >= this.stops.length) ||
         (delta < 0 && newIndex < 0)
       ) {
-        newIndex = delta < 0 || clipToTop ? 0 : this.stops.length - 1;
+        newIndex =
+          (delta < 0 && !circular) || (delta > 0 && circular) || clipToTop
+            ? 0
+            : this.stops.length - 1;
         newStop = this.stops[newIndex];
         clipped = true;
         break;
diff --git a/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager_test.js b/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager_test.js
index ba7e4f8..d0bd420 100644
--- a/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager_test.js
@@ -255,6 +255,25 @@
     assert.isTrue(cursor.target.focus.called);
   });
 
+  suite('circular options', () => {
+    const options = {circular: true};
+    setup(() => {
+      cursor.stops = [...list.querySelectorAll('li')];
+    });
+
+    test('previous() on first element goes to last element', () => {
+      cursor.setCursor(list.children[0]);
+      cursor.previous(options);
+      assert.equal(cursor.index, list.children.length - 1);
+    });
+
+    test('next() on last element goes to first element', () => {
+      cursor.setCursor(list.children[list.children.length - 1]);
+      cursor.next(options);
+      assert.equal(cursor.index, 0);
+    });
+  });
+
   suite('_scrollToTarget', () => {
     let scrollStub;
     setup(() => {
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list_html.ts b/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list_html.ts
index c163924..18a46a0 100644
--- a/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list_html.ts
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list_html.ts
@@ -127,7 +127,7 @@
     <span id="triggerText">[[text]]</span>
     <gr-copy-clipboard
       hidden="[[!showCopyForTriggerText]]"
-      hide-input=""
+      hideInput=""
       text="[[text]]"
     ></gr-copy-clipboard>
   </gr-button>
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.ts b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.ts
index 83cd380..592efba 100644
--- a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.ts
+++ b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.ts
@@ -106,13 +106,14 @@
   _saveDisabled!: boolean;
 
   @property({type: String, observer: '_newContentChanged'})
-  _newContent?: string;
+  _newContent = '';
 
   private readonly storage = appContext.storageService;
 
   private readonly reporting = appContext.reportingService;
 
-  private storeTask?: DelayedTask;
+  // Tests use this so needs to be non private
+  storeTask?: DelayedTask;
 
   /** @override */
   ready() {
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_html.ts b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_html.ts
index c6ff903..7877a1f 100644
--- a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_html.ts
+++ b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_html.ts
@@ -69,7 +69,7 @@
       box-shadow: var(--elevation-level-1);
       /* slightly up to cover rounded corner of the commit msg */
       margin-top: calc(-1 * var(--spacing-xs));
-      /* To make this bar pop over editor, since editor has relative position. 
+      /* To make this bar pop over editor, since editor has relative position.
       */
       position: relative;
     }
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_test.js b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_test.ts
similarity index 73%
rename from polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_test.js
rename to polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_test.ts
index 94a7b96..074678e 100644
--- a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_test.ts
@@ -15,13 +15,17 @@
  * limitations under the License.
  */
 
-import '../../../test/common-test-setup-karma.js';
-import './gr-editable-content.js';
+import '../../../test/common-test-setup-karma';
+import './gr-editable-content';
+import {GrEditableContent} from './gr-editable-content';
+import {queryAndAssert, stubStorage} from '../../../test/test-utils';
+import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
+import {GrButton} from '../gr-button/gr-button';
 
 const basicFixture = fixtureFromElement('gr-editable-content');
 
 suite('gr-editable-content tests', () => {
-  let element;
+  let element: GrEditableContent;
 
   setup(() => {
     element = basicFixture.instantiate();
@@ -33,8 +37,7 @@
     const handler = sinon.spy();
     element.addEventListener('editable-content-save', handler);
 
-    MockInteractions.tap(element.shadowRoot
-        .querySelector('gr-button[primary]'));
+    MockInteractions.tap(queryAndAssert(element, 'gr-button[primary]'));
 
     assert.isTrue(handler.called);
     assert.equal(handler.lastCall.args[0].detail.content, 'foo');
@@ -44,8 +47,7 @@
     const handler = sinon.spy();
     element.addEventListener('editable-content-cancel', handler);
 
-    MockInteractions.tap(element.shadowRoot
-        .querySelector('gr-button.cancel-button'));
+    MockInteractions.tap(queryAndAssert(element, 'gr-button.cancel-button'));
 
     assert.isTrue(handler.called);
   });
@@ -79,19 +81,22 @@
     });
 
     test('save button is disabled initially', () => {
-      assert.isTrue(element.shadowRoot
-          .querySelector('gr-button[primary]').disabled);
+      assert.isTrue(
+        queryAndAssert<GrButton>(element, 'gr-button[primary]').disabled
+      );
     });
 
     test('save button is enabled when content changes', () => {
       element._newContent = 'new content';
-      assert.isFalse(element.shadowRoot
-          .querySelector('gr-button[primary]').disabled);
+      assert.isFalse(
+        queryAndAssert<GrButton>(element, 'gr-button[primary]').disabled
+      );
     });
   });
 
   suite('storageKey and related behavior', () => {
-    let dispatchSpy;
+    let dispatchSpy: sinon.SinonSpy;
+
     setup(() => {
       element.content = 'current content';
       element.storageKey = 'test';
@@ -99,8 +104,10 @@
     });
 
     test('editing toggled to true, has stored data', () => {
-      sinon.stub(element.storage, 'getEditableContentItem')
-          .returns({message: 'stored content'});
+      stubStorage('getEditableContentItem').returns({
+        message: 'stored content',
+        updated: 0,
+      });
       element.editing = true;
 
       assert.equal(element._newContent, 'stored content');
@@ -109,8 +116,7 @@
     });
 
     test('editing toggled to true, has no stored data', () => {
-      sinon.stub(element.storage, 'getEditableContentItem')
-          .returns({});
+      stubStorage('getEditableContentItem').returns(null);
       element.editing = true;
 
       assert.equal(element._newContent, 'current content');
@@ -118,28 +124,26 @@
     });
 
     test('edits are cached', () => {
-      const storeStub =
-          sinon.stub(element.storage, 'setEditableContentItem');
-      const eraseStub =
-          sinon.stub(element.storage, 'eraseEditableContentItem');
+      const storeStub = stubStorage('setEditableContentItem');
+      const eraseStub = stubStorage('eraseEditableContentItem');
       element.editing = true;
 
       element._newContent = 'new content';
       flush();
-      element.storeTask.flush();
+      element.storeTask?.flush();
 
       assert.isTrue(storeStub.called);
       assert.deepEqual(
-          [element.storageKey, element._newContent],
-          storeStub.lastCall.args);
+        [element.storageKey, element._newContent],
+        storeStub.lastCall.args
+      );
 
       element._newContent = '';
       flush();
-      element.storeTask.flush();
+      element.storeTask?.flush();
 
       assert.isTrue(eraseStub.called);
       assert.deepEqual([element.storageKey], eraseStub.lastCall.args);
     });
   });
 });
-
diff --git a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.ts b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.ts
index 7298af7..ddca5c5 100644
--- a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.ts
+++ b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.ts
@@ -15,12 +15,10 @@
  * limitations under the License.
  */
 import '../gr-linked-text/gr-linked-text';
-import '../../../styles/shared-styles';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {customElement, property} from '@polymer/decorators';
-import {htmlTemplate} from './gr-formatted-text_html';
 import {CommentLinks} from '../../../types/common';
 import {appContext} from '../../../services/app-context';
+import {GrLitElement} from '../../lit/gr-lit-element';
+import {css, customElement, html, property} from 'lit-element';
 
 const CODE_MARKER_PATTERN = /^(`{1,3})([^`]+?)\1$/;
 
@@ -36,65 +34,73 @@
     'gr-formatted-text': GrFormattedText;
   }
 }
-
-export interface GrFormattedText {
-  $: {
-    container: HTMLElement;
-  };
-}
-
 @customElement('gr-formatted-text')
-export class GrFormattedText extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
-  @property({type: String, observer: '_contentChanged'})
+export class GrFormattedText extends GrLitElement {
+  @property({type: String})
   content?: string;
 
   @property({type: Object})
   config?: CommentLinks;
 
-  @property({type: Boolean})
+  @property({type: Boolean, reflect: true})
   noTrailingMargin = false;
 
   private readonly reporting = appContext.reportingService;
 
-  static get observers() {
-    return ['_contentOrConfigChanged(content, config)'];
+  static get styles() {
+    return [
+      css`
+        :host {
+          display: block;
+          font-family: var(--font-family);
+        }
+        p,
+        ul,
+        code,
+        blockquote,
+        gr-linked-text.pre {
+          margin: 0 0 var(--spacing-m) 0;
+        }
+        p,
+        ul,
+        code,
+        blockquote {
+          max-width: var(--gr-formatted-text-prose-max-width, none);
+        }
+        :host([noTrailingMargin]) p:last-child,
+        :host([noTrailingMargin]) ul:last-child,
+        :host([noTrailingMargin]) blockquote:last-child,
+        :host([noTrailingMargin]) gr-linked-text.pre:last-child {
+          margin: 0;
+        }
+        code,
+        blockquote {
+          border-left: 1px solid #aaa;
+          padding: 0 var(--spacing-m);
+        }
+        code {
+          display: block;
+          white-space: pre-wrap;
+          color: var(--deemphasized-text-color);
+        }
+        li {
+          list-style-type: disc;
+          margin-left: var(--spacing-xl);
+        }
+        code,
+        gr-linked-text.pre {
+          font-family: var(--monospace-font-family);
+          font-size: var(--font-size-code);
+          /* usually 16px = 12px + 4px */
+          line-height: calc(var(--font-size-code) + var(--spacing-s));
+        }
+      `,
+    ];
   }
 
-  /** @override */
-  ready() {
-    super.ready();
-    if (this.noTrailingMargin) {
-      this.classList.add('noTrailingMargin');
-    }
-  }
-
-  _contentChanged(content: string) {
-    // In the case where the config may not be set (perhaps due to the
-    // request for it still being in flight), set the content anyway to
-    // prevent waiting on the config to display the text.
-    if (this.config) return;
-    this._contentOrConfigChanged(content);
-  }
-
-  /**
-   * Given a source string, update the DOM inside #container.
-   */
-  _contentOrConfigChanged(content?: string) {
-    const container = this.$.container;
-
-    // Remove existing content.
-    while (container.firstChild) {
-      container.removeChild(container.firstChild);
-    }
-
-    // Add new content.
-    for (const node of this._computeNodes(this._computeBlocks(content))) {
-      if (node) container.appendChild(node);
-    }
+  render() {
+    const nodes = this._computeNodes(this._computeBlocks(this.content));
+    return html`<div id="container">${nodes}</div>`;
   }
 
   /**
@@ -177,7 +183,9 @@
         // include pre or all regular lines but stop at next new line
         while (
           this._isPreFormat(lines[nextI]) ||
-          (this._isRegularLine(lines[nextI]) && lines[nextI].length)
+          (this._isRegularLine(lines[nextI]) &&
+            !this._isWhitespaceLine(lines[nextI]) &&
+            lines[nextI].length)
         ) {
           nextI++;
         }
@@ -249,13 +257,17 @@
   }
 
   _isPreFormat(line: string) {
-    return line && /^[ \t]/.test(line);
+    return line && /^[ \t]/.test(line) && !this._isWhitespaceLine(line);
   }
 
   _isList(line: string) {
     return line && /^[-*] /.test(line);
   }
 
+  _isWhitespaceLine(line: string) {
+    return line && /^\s+$/.test(line);
+  }
+
   _makeLinkedText(content = '', isPre?: boolean) {
     const text = document.createElement('gr-linked-text');
     text.config = this.config;
diff --git a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_html.ts b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_html.ts
deleted file mode 100644
index 04e4954..0000000
--- a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_html.ts
+++ /dev/null
@@ -1,67 +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 {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style>
-    :host {
-      display: block;
-      font-family: var(--font-family);
-    }
-    p,
-    ul,
-    code,
-    blockquote,
-    gr-linked-text.pre {
-      margin: 0 0 var(--spacing-m) 0;
-    }
-    p,
-    ul,
-    code,
-    blockquote {
-      max-width: var(--gr-formatted-text-prose-max-width, none);
-    }
-    :host(.noTrailingMargin) p:last-child,
-    :host(.noTrailingMargin) ul:last-child,
-    :host(.noTrailingMargin) blockquote:last-child,
-    :host(.noTrailingMargin) gr-linked-text.pre:last-child {
-      margin: 0;
-    }
-    code,
-    blockquote {
-      border-left: 1px solid #aaa;
-      padding: 0 var(--spacing-m);
-    }
-    code {
-      display: block;
-      white-space: pre-wrap;
-      color: var(--deemphasized-text-color);
-    }
-    li {
-      list-style-type: disc;
-      margin-left: var(--spacing-xl);
-    }
-    code,
-    gr-linked-text.pre {
-      font-family: var(--monospace-font-family);
-      font-size: var(--font-size-code);
-      /* usually 16px = 12px + 4px */
-      line-height: calc(var(--font-size-code) + var(--spacing-s));
-    }
-  </style>
-  <div id="container"></div>
-`;
diff --git a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_test.js b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_test.js
index fd5a9ba..8464af7 100644
--- a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_test.js
@@ -297,6 +297,14 @@
     assertBlock(result, 1, 'paragraph', 'B');
   });
 
+  test('pre format 5', () => {
+    const comment = '  Q\n    <R>\n  S\n \nB';
+    const result = element._computeBlocks(comment);
+    assert.lengthOf(result, 2);
+    assertBlock(result, 0, 'pre', '  Q\n    <R>\n  S');
+    assertBlock(result, 1, 'paragraph', ' \nB');
+  });
+
   test('quote 1', () => {
     const comment = '> I\'m happy\n > with quotes!\n\nSee above.';
     const result = element._computeBlocks(comment);
@@ -387,20 +395,5 @@
     assert.equal(result[3].type, 'code');
     assert.equal(result[4].type, 'quote');
   });
-
-  test('_computeNodes called without config', () => {
-    const computeNodesSpy = sinon.spy(element, '_computeNodes');
-    element.content = 'some text';
-    assert.isTrue(computeNodesSpy.called);
-  });
-
-  test('_contentOrConfigChanged called with config', () => {
-    const contentStub = sinon.stub(element, '_contentChanged');
-    const contentConfigStub = sinon.stub(element, '_contentOrConfigChanged');
-    element.content = 'some text';
-    element.config = {};
-    assert.isTrue(contentStub.called);
-    assert.isTrue(contentConfigStub.called);
-  });
 });
 
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api.ts
index fc5a4aa..b8f5aff 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api.ts
@@ -26,6 +26,7 @@
   ValueChangedDetail,
 } from '../../../api/change-reply';
 import {appContext} from '../../../services/app-context';
+import {HookApi, PluginElement} from '../../../api/hook';
 
 /**
  * GrChangeReplyInterface, provides a set of handy methods on reply dialog.
@@ -58,7 +59,7 @@
 
   addReplyTextChangedCallback(handler: ReplyChangedCallback) {
     this.reporting.trackApi(this.plugin, 'reply', 'addReplyTextChangedCb');
-    const hookApi = this.plugin.hook('reply-text');
+    const hookApi = this.plugin.hook('reply-text') as HookApi<PluginElement>;
     const registeredHandler = (e: Event) => {
       const ce = e as CustomEvent<ValueChangedDetail>;
       handler(ce.detail.value);
@@ -79,7 +80,9 @@
 
   addLabelValuesChangedCallback(handler: LabelsChangedCallback) {
     this.reporting.trackApi(this.plugin, 'reply', 'addLabelValuesChangedCb');
-    const hookApi = this.plugin.hook('reply-label-scores');
+    const hookApi = this.plugin.hook(
+      'reply-label-scores'
+    ) as HookApi<PluginElement>;
     const registeredHandler = (e: Event) => {
       const ce = e as CustomEvent<LabelsChangedDetail>;
       handler(ce.detail);
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints.ts
index 0cc95a3..f7475bf 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints.ts
@@ -16,7 +16,7 @@
  */
 import {PluginApi} from '../../../api/plugin';
 import {notUndefined} from '../../../types/types';
-import {HookApi} from '../../../api/hook';
+import {HookApi, PluginElement} from '../../../api/hook';
 
 // eslint-disable-next-line @typescript-eslint/no-explicit-any
 type Callback = (value: any) => void;
@@ -26,7 +26,7 @@
   plugin: PluginApi;
   pluginUrl?: URL;
   type?: string;
-  domHook?: HookApi;
+  domHook?: HookApi<PluginElement>;
   slot?: string;
 }
 
@@ -36,7 +36,7 @@
   slot?: string;
   type?: string;
   moduleName?: string;
-  domHook?: HookApi;
+  domHook?: HookApi<PluginElement>;
 }
 
 export class GrPluginEndpoints {
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api.ts
index 150c45a..903fb2c 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api.ts
@@ -125,7 +125,7 @@
   /**
    * Fetch and parse REST API response, if request succeeds.
    */
-  send(
+  send<T>(
     method: HttpMethod,
     url: string,
     payload?: RequestPayload,
@@ -167,7 +167,7 @@
             Promise.reject(new Error(msg))
           );
         } else {
-          return this.restApi.getResponseObject(response);
+          return this.restApi.getResponseObject(response) as Promise<T>;
         }
       })
       .catch(err => {
@@ -180,29 +180,29 @@
       });
   }
 
-  get(url: string) {
+  get<T>(url: string) {
     this.reporting.trackApi(this.plugin, 'rest', 'get');
-    return this.send(HttpMethod.GET, url);
+    return this.send<T>(HttpMethod.GET, url);
   }
 
-  post(
+  post<T>(
     url: string,
     payload?: RequestPayload,
     errFn?: ErrorCallback,
     contentType?: string
   ) {
     this.reporting.trackApi(this.plugin, 'rest', 'post');
-    return this.send(HttpMethod.POST, url, payload, errFn, contentType);
+    return this.send<T>(HttpMethod.POST, url, payload, errFn, contentType);
   }
 
-  put(
+  put<T>(
     url: string,
     payload?: RequestPayload,
     errFn?: ErrorCallback,
     contentType?: string
   ) {
     this.reporting.trackApi(this.plugin, 'rest', 'put');
-    return this.send(HttpMethod.PUT, url, payload, errFn, contentType);
+    return this.send<T>(HttpMethod.PUT, url, payload, errFn, contentType);
   }
 
   delete(url: string) {
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 fef471d..7d3911a 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
@@ -41,7 +41,7 @@
 import {ChangeActionsPluginApi} from '../../../api/change-actions';
 import {ChangeReplyPluginApi} from '../../../api/change-reply';
 import {RestPluginApi} from '../../../api/rest';
-import {HookApi, RegisterOptions} from '../../../api/hook';
+import {HookApi, PluginElement, RegisterOptions} from '../../../api/hook';
 import {AttributeHelperPluginApi} from '../../../api/attribute-helper';
 
 /**
@@ -108,11 +108,11 @@
   /**
    * Registers an endpoint for the plugin.
    */
-  registerCustomComponent(
+  registerCustomComponent<T extends PluginElement>(
     endpointName: string,
     moduleName?: string,
     options?: RegisterOptions
-  ): HookApi {
+  ): HookApi<T> {
     this.report.trackApi(this, 'plugin', 'registerCustomComponent');
     return this._registerCustomComponent(endpointName, moduleName, options);
   }
@@ -123,11 +123,11 @@
    * Dynamic plugins are registered by specific prefix, such as
    * 'change-list-header'.
    */
-  registerDynamicCustomComponent(
+  registerDynamicCustomComponent<T extends PluginElement>(
     endpointName: string,
     moduleName?: string,
     options?: RegisterOptions
-  ): HookApi {
+  ): HookApi<T> {
     this.report.trackApi(this, 'plugin', 'registerDynamicCustomComponent');
     const fullEndpointName = `${endpointName}-${this.getPluginName()}`;
     return this._registerCustomComponent(
@@ -138,16 +138,17 @@
     );
   }
 
-  _registerCustomComponent(
+  _registerCustomComponent<T extends PluginElement>(
     endpoint: string,
     moduleName?: string,
     options?: RegisterOptions,
     dynamicEndpoint?: string
-  ): HookApi {
-    const type =
-      options && options.replace ? EndpointType.REPLACE : EndpointType.DECORATE;
-    const slot = (options && options.slot) || '';
-    const domHook = this.domHooks.getDomHook(endpoint, moduleName);
+  ): HookApi<T> {
+    const type = options?.replace
+      ? EndpointType.REPLACE
+      : EndpointType.DECORATE;
+    const slot = options?.slot ?? '';
+    const domHook = this.domHooks.getDomHook<T>(endpoint, moduleName);
     moduleName = moduleName || domHook.getModuleName();
     getPluginEndpoints().registerModule(this, {
       slot,
@@ -164,7 +165,10 @@
    * Returns instance of DOM hook API for endpoint. Creates a placeholder
    * element for the first call.
    */
-  hook(endpointName: string, options?: RegisterOptions) {
+  hook<T extends PluginElement>(
+    endpointName: string,
+    options?: RegisterOptions
+  ): HookApi<T> {
     this.report.trackApi(this, 'plugin', 'hook');
     return this.registerCustomComponent(endpointName, undefined, options);
   }
diff --git a/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text.ts b/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text.ts
index 2812b47..287ed1b 100644
--- a/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text.ts
+++ b/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text.ts
@@ -14,12 +14,10 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../styles/shared-styles';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-linked-text_html';
 import {GrLinkTextParser, LinkTextParserConfig} from './link-text-parser';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
-import {customElement, property, observe} from '@polymer/decorators';
+import {GrLitElement} from '../../lit/gr-lit-element';
+import {css, customElement, html, property, query} from 'lit-element';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -27,17 +25,10 @@
   }
 }
 
-export interface GrLinkedText {
-  $: {
-    output: HTMLSpanElement;
-  };
-}
-
 @customElement('gr-linked-text')
-export class GrLinkedText extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
+export class GrLinkedText extends GrLitElement {
+  @query('#output')
+  outputElement?: HTMLSpanElement;
 
   @property({type: Boolean})
   removeZeroWidthSpace?: boolean;
@@ -46,61 +37,63 @@
   @property({type: String})
   content: string | null = null;
 
-  @property({type: Boolean, reflectToAttribute: true})
+  @property({type: Boolean, reflect: true})
   pre = false;
 
-  @property({type: Boolean, reflectToAttribute: true})
+  @property({type: Boolean, reflect: true})
   disabled = false;
 
   @property({type: Object})
   config?: LinkTextParserConfig;
 
-  @observe('content')
-  _contentChanged(content: string | null) {
-    // In the case where the config may not be set (perhaps due to the
-    // request for it still being in flight), set the content anyway to
-    // prevent waiting on the config to display the text.
+  static get styles() {
+    return [
+      css`
+        :host {
+          display: block;
+        }
+        :host([pre]) span {
+          white-space: var(--linked-text-white-space, pre-wrap);
+          word-wrap: var(--linked-text-word-wrap, break-word);
+        }
+        :host([disabled]) a {
+          color: inherit;
+          text-decoration: none;
+          pointer-events: none;
+        }
+        a {
+          color: var(--link-color);
+        }
+      `,
+    ];
+  }
+
+  render() {
     if (!this.config) {
       return;
     }
-    this.$.output.textContent = content;
+    return html`<span id="output">${this.content}</span>`;
   }
 
-  /**
-   * Because either the source text or the linkification config has changed,
-   * the content should be re-parsed.
-   *
-   * @param content The raw, un-linkified source string to parse.
-   * @param config The server config specifying commentLink patterns
-   */
-  @observe('content', 'config')
-  _contentOrConfigChanged(
-    content: string | null,
-    config?: LinkTextParserConfig
-  ) {
-    if (!config) {
-      return;
-    }
-
+  updated() {
+    if (!this.outputElement || !this.config) return;
+    this.outputElement.textContent = '';
     // TODO(TS): mapCommentlinks always has value, remove
     if (!GerritNav.mapCommentlinks) return;
-    config = GerritNav.mapCommentlinks(config);
-    const output = this.$.output;
-    output.textContent = '';
+    const config = GerritNav.mapCommentlinks(this.config);
     const parser = new GrLinkTextParser(
       config,
       (text: string | null, href: string | null, fragment?: DocumentFragment) =>
         this._handleParseResult(text, href, fragment),
       this.removeZeroWidthSpace
     );
-    parser.parse(content);
-
+    parser.parse(this.content);
     // Ensure that external links originating from HTML commentlink configs
     // open in a new tab. @see Issue 5567
     // Ensure links to the same host originating from commentlink configs
     // open in the same tab. When target is not set - default is _self
     // @see Issue 4616
-    output.querySelectorAll('a').forEach(anchor => {
+    this.outputElement.querySelectorAll('a').forEach(anchor => {
       if (anchor.hostname === window.location.hostname) {
         anchor.removeAttribute('target');
       } else {
@@ -124,7 +117,8 @@
     href: string | null,
     fragment?: DocumentFragment
   ) {
-    const output = this.$.output;
+    const output = this.outputElement;
+    if (!output) return;
     if (href) {
       const a = document.createElement('a');
       a.setAttribute('href', href);
diff --git a/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text_html.ts b/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text_html.ts
deleted file mode 100644
index 0d44bc8..0000000
--- a/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text_html.ts
+++ /dev/null
@@ -1,38 +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 {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style>
-    :host {
-      display: block;
-    }
-    :host([pre]) span {
-      white-space: var(--linked-text-white-space, pre-wrap);
-      word-wrap: var(--linked-text-word-wrap, break-word);
-    }
-    :host([disabled]) a {
-      color: inherit;
-      text-decoration: none;
-      pointer-events: none;
-    }
-    a {
-      color: var(--link-color);
-    }
-  </style>
-  <span id="output"></span>
-`;
diff --git a/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text_test.ts b/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text_test.ts
index b2cdba1..c97c168 100644
--- a/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text_test.ts
@@ -85,10 +85,11 @@
     window.CANONICAL_PATH = originalCanonicalPath;
   });
 
-  test('URL pattern was parsed and linked.', () => {
+  test('URL pattern was parsed and linked.', async () => {
     // Regular inline link.
     const url = 'https://bugs.chromium.org/p/gerrit/issues/detail?id=3650';
     element.content = url;
+    await flush();
     const linkEl = queryAndAssert(element, '#output')
       .childNodes[0] as HTMLAnchorElement;
     assert.equal(linkEl.target, '_blank');
@@ -97,9 +98,10 @@
     assert.equal(linkEl.textContent, url);
   });
 
-  test('Bug pattern was parsed and linked', () => {
+  test('Bug pattern was parsed and linked', async () => {
     // "Issue/Bug" pattern.
     element.content = 'Issue 3650';
+    await flush();
 
     let linkEl = queryAndAssert(element, '#output')
       .childNodes[0] as HTMLAnchorElement;
@@ -109,6 +111,7 @@
     assert.equal(linkEl.textContent, 'Issue 3650');
 
     element.content = 'Bug 3650';
+    await flush();
     linkEl = queryAndAssert(element, '#output')
       .childNodes[0] as HTMLAnchorElement;
     assert.equal(linkEl.target, '_blank');
@@ -117,10 +120,10 @@
     assert.equal(linkEl.textContent, 'Bug 3650');
   });
 
-  test('Pattern with same prefix as link was correctly parsed', () => {
+  test('Pattern with same prefix as link was correctly parsed', async () => {
     // Pattern starts with the same prefix (`http`) as the url.
     element.content = 'httpexample 3650';
-
+    await flush();
     assert.equal(queryAndAssert(element, '#output').childNodes.length, 1);
     const linkEl = queryAndAssert(element, '#output')
       .childNodes[0] as HTMLAnchorElement;
@@ -130,12 +133,12 @@
     assert.equal(linkEl.textContent, 'httpexample 3650');
   });
 
-  test('Change-Id pattern was parsed and linked', () => {
+  test('Change-Id pattern was parsed and linked', async () => {
     // "Change-Id:" pattern.
     const changeID = 'I11d6a37f5e9b5df0486f6c922d8836dfa780e03e';
     const prefix = 'Change-Id: ';
     element.content = prefix + changeID;
-
+    await flush();
     const textNode = queryAndAssert(element, '#output').childNodes[0];
     const linkEl = queryAndAssert(element, '#output')
       .childNodes[1] as HTMLAnchorElement;
@@ -147,14 +150,14 @@
     assert.equal(linkEl.textContent, changeID);
   });
 
-  test('Change-Id pattern was parsed and linked with base url', () => {
+  test('Change-Id pattern was parsed and linked with base url', async () => {
     window.CANONICAL_PATH = '/r';
 
     // "Change-Id:" pattern.
     const changeID = 'I11d6a37f5e9b5df0486f6c922d8836dfa780e03e';
     const prefix = 'Change-Id: ';
     element.content = prefix + changeID;
-
+    await flush();
     const textNode = queryAndAssert(element, '#output').childNodes[0];
     const linkEl = queryAndAssert(element, '#output')
       .childNodes[1] as HTMLAnchorElement;
@@ -166,8 +169,9 @@
     assert.equal(linkEl.textContent, changeID);
   });
 
-  test('Multiple matches', () => {
+  test('Multiple matches', async () => {
     element.content = 'Issue 3650\nIssue 3450';
+    await flush();
     const linkEl1 = queryAndAssert(element, '#output')
       .childNodes[0] as HTMLAnchorElement;
     const linkEl2 = queryAndAssert(element, '#output')
@@ -188,7 +192,7 @@
     assert.equal(linkEl2.textContent, 'Issue 3450');
   });
 
-  test('Change-Id pattern parsed before bug pattern', () => {
+  test('Change-Id pattern parsed before bug pattern', async () => {
     // "Change-Id:" pattern.
     const changeID = 'I11d6a37f5e9b5df0486f6c922d8836dfa780e03e';
     const prefix = 'Change-Id: ';
@@ -200,7 +204,7 @@
     const bugUrl = 'https://bugs.chromium.org/p/gerrit/issues/detail?id=3650';
 
     element.content = prefix + changeID + bug;
-
+    await flush();
     const textNode = queryAndAssert(element, '#output').childNodes[0];
     const changeLinkEl = queryAndAssert(element, '#output')
       .childNodes[1] as HTMLAnchorElement;
@@ -218,8 +222,9 @@
     assert.equal(bugLinkEl.textContent, 'Issue 3650');
   });
 
-  test('html field in link config', () => {
+  test('html field in link config', async () => {
     element.content = 'google:do a barrel roll';
+    await flush();
     const linkEl = queryAndAssert(element, '#output')
       .childNodes[0] as HTMLAnchorElement;
     assert.equal(
@@ -229,52 +234,58 @@
     assert.equal(linkEl.textContent, 'do a barrel roll');
   });
 
-  test('removing hash from links', () => {
+  test('removing hash from links', async () => {
     element.content = 'hash:foo';
+    await flush();
     const linkEl = queryAndAssert(element, '#output')
       .childNodes[0] as HTMLAnchorElement;
     assert.isTrue(linkEl.href.endsWith('/awesomesauce'));
     assert.equal(linkEl.textContent, 'foo');
   });
 
-  test('html with base url', () => {
+  test('html with base url', async () => {
     window.CANONICAL_PATH = '/r';
 
     element.content = 'test foo';
+    await flush();
     const linkEl = queryAndAssert(element, '#output')
       .childNodes[0] as HTMLAnchorElement;
     assert.isTrue(linkEl.href.endsWith('/r/awesomesauce'));
     assert.equal(linkEl.textContent, 'foo');
   });
 
-  test('a is not at start', () => {
+  test('a is not at start', async () => {
     window.CANONICAL_PATH = '/r';
 
     element.content = 'a test foo';
+    await flush();
     const linkEl = queryAndAssert(element, '#output')
       .childNodes[1] as HTMLAnchorElement;
     assert.isTrue(linkEl.href.endsWith('/r/awesomesauce'));
     assert.equal(linkEl.textContent, 'foo');
   });
 
-  test('hash html with base url', () => {
+  test('hash html with base url', async () => {
     window.CANONICAL_PATH = '/r';
 
     element.content = 'hash:foo';
+    await flush();
     const linkEl = queryAndAssert(element, '#output')
       .childNodes[0] as HTMLAnchorElement;
     assert.isTrue(linkEl.href.endsWith('/r/awesomesauce'));
     assert.equal(linkEl.textContent, 'foo');
   });
 
-  test('disabled config', () => {
+  test('disabled config', async () => {
     element.content = 'foo:baz';
+    await flush();
     assert.equal(queryAndAssert(element, '#output').innerHTML, 'foo:baz');
   });
 
-  test('R=email labels link correctly', () => {
+  test('R=email labels link correctly', async () => {
     element.removeZeroWidthSpace = true;
     element.content = 'R=\u200Btest@google.com';
+    await flush();
     assert.equal(
       queryAndAssert(element, '#output').textContent,
       'R=test@google.com'
@@ -285,9 +296,10 @@
     );
   });
 
-  test('CC=email labels link correctly', () => {
+  test('CC=email labels link correctly', async () => {
     element.removeZeroWidthSpace = true;
     element.content = 'CC=\u200Btest@google.com';
+    await flush();
     assert.equal(
       queryAndAssert(element, '#output').textContent,
       'CC=test@google.com'
@@ -298,36 +310,42 @@
     );
   });
 
-  test('only {http,https,mailto} protocols are linkified', () => {
+  test('only {http,https,mailto} protocols are linkified', async () => {
     element.content = 'xx mailto:test@google.com yy';
+    await flush();
     let links = queryAndAssert(element, '#output').querySelectorAll('a');
     assert.equal(links.length, 1);
     assert.equal(links[0].getAttribute('href'), 'mailto:test@google.com');
     assert.equal(links[0].innerHTML, 'mailto:test@google.com');
 
     element.content = 'xx http://google.com yy';
+    await flush();
     links = queryAndAssert(element, '#output').querySelectorAll('a');
     assert.equal(links.length, 1);
     assert.equal(links[0].getAttribute('href'), 'http://google.com');
     assert.equal(links[0].innerHTML, 'http://google.com');
 
     element.content = 'xx https://google.com yy';
+    await flush();
     links = queryAndAssert(element, '#output').querySelectorAll('a');
     assert.equal(links.length, 1);
     assert.equal(links[0].getAttribute('href'), 'https://google.com');
     assert.equal(links[0].innerHTML, 'https://google.com');
 
     element.content = 'xx ssh://google.com yy';
+    await flush();
     links = queryAndAssert(element, '#output').querySelectorAll('a');
     assert.equal(links.length, 0);
 
     element.content = 'xx ftp://google.com yy';
+    await flush();
     links = queryAndAssert(element, '#output').querySelectorAll('a');
     assert.equal(links.length, 0);
   });
 
-  test('links without leading whitespace are linkified', () => {
+  test('links without leading whitespace are linkified', async () => {
     element.content = 'xx abcmailto:test@google.com yy';
+    await flush();
     assert.equal(
       queryAndAssert(element, '#output').innerHTML.substr(0, 6),
       'xx abc'
@@ -338,6 +356,7 @@
     assert.equal(links[0].innerHTML, 'mailto:test@google.com');
 
     element.content = 'xx defhttp://google.com yy';
+    await flush();
     assert.equal(
       queryAndAssert(element, '#output').innerHTML.substr(0, 6),
       'xx def'
@@ -348,6 +367,7 @@
     assert.equal(links[0].innerHTML, 'http://google.com');
 
     element.content = 'xx qwehttps://google.com yy';
+    await flush();
     assert.equal(
       queryAndAssert(element, '#output').innerHTML.substr(0, 6),
       'xx qwe'
@@ -359,6 +379,7 @@
 
     // Non-latin character
     element.content = 'xx абвhttps://google.com yy';
+    await flush();
     assert.equal(
       queryAndAssert(element, '#output').innerHTML.substr(0, 6),
       'xx абв'
@@ -369,15 +390,17 @@
     assert.equal(links[0].innerHTML, 'https://google.com');
 
     element.content = 'xx ssh://google.com yy';
+    await flush();
     links = queryAndAssert(element, '#output').querySelectorAll('a');
     assert.equal(links.length, 0);
 
     element.content = 'xx ftp://google.com yy';
+    await flush();
     links = queryAndAssert(element, '#output').querySelectorAll('a');
     assert.equal(links.length, 0);
   });
 
-  test('overlapping links', () => {
+  test('overlapping links', async () => {
     element.config = {
       b1: {
         match: '(B:\\s*)(\\d+)',
@@ -389,7 +412,8 @@
       },
     };
     element.content = '- B: 123, 45';
-    const links = element.root!.querySelectorAll('a');
+    await flush();
+    const links = element.shadowRoot!.querySelectorAll('a');
 
     assert.equal(links.length, 2);
     assert.equal(
@@ -403,12 +427,4 @@
     assert.equal(links[1].href, 'ftp://foo/45');
     assert.equal(links[1].textContent, '45');
   });
-
-  test('_contentOrConfigChanged called with config', () => {
-    const contentStub = sinon.stub(element, '_contentChanged');
-    const contentConfigStub = sinon.stub(element, '_contentOrConfigChanged');
-    element.content = 'some text';
-    assert.isTrue(contentStub.called);
-    assert.isTrue(contentConfigStub.called);
-  });
 });
diff --git a/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command_html.ts b/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command_html.ts
index 6c7f0d8..e43460d 100644
--- a/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command_html.ts
+++ b/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command_html.ts
@@ -45,17 +45,15 @@
       /* Should roughly match the height of .commandContainer without padding. */
       line-height: 26px;
     }
-    .commandContainer gr-copy-clipboard {
-      --text-container-style: {
-        border: none;
-      }
+    .commandContainer gr-copy-clipboard::part(text-container-style) {
+      border: none;
     }
   </style>
   <label>[[label]]</label>
   <div class="commandContainer">
     <gr-copy-clipboard
       text="[[command]]"
-      has-tooltip
+      hasTooltip
       button-title="[[tooltip]]"
     ></gr-copy-clipboard>
   </div>
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 3d5a208..5f53819 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
@@ -815,8 +815,11 @@
           (tagName === 'INPUT' && type !== 'checkbox') ||
           tagName === 'TEXTAREA' ||
           // Suppress shortcuts if the key is 'enter'
-          // and target is an anchor or button.
-          (e.keyCode === 13 && (tagName === 'A' || tagName === 'BUTTON'))
+          // and target is an anchor or button or paper-tab.
+          (e.keyCode === 13 &&
+            (tagName === 'A' ||
+              tagName === 'BUTTON' ||
+              tagName === 'PAPER-TAB'))
         ) {
           return true;
         }
diff --git a/polygerrit-ui/app/node_modules_licenses/licenses.ts b/polygerrit-ui/app/node_modules_licenses/licenses.ts
index f04e224..bcdab0e 100644
--- a/polygerrit-ui/app/node_modules_licenses/licenses.ts
+++ b/polygerrit-ui/app/node_modules_licenses/licenses.ts
@@ -405,6 +405,14 @@
       packageLicenseFile: "LICENSE",
     }
   },
+  {
+    name: "immer",
+    license: {
+      name: "immer",
+      type: LicenseTypes.Mit,
+      packageLicenseFile: "LICENSE",
+    }
+  }
 ];
 
 export default packages;
diff --git a/polygerrit-ui/app/package.json b/polygerrit-ui/app/package.json
index 4e18e47..d26dc97 100644
--- a/polygerrit-ui/app/package.json
+++ b/polygerrit-ui/app/package.json
@@ -37,6 +37,7 @@
     "@webcomponents/webcomponentsjs": "^1.3.3",
     "ba-linkify": "file:../../lib/ba-linkify/src/",
     "codemirror-minified": "^5.62.0",
+    "immer": "^9.0.5",
     "lit-element": "^2.5.1",
     "page": "^1.11.6",
     "polymer-bridges": "file:../../polymer-bridges/",
diff --git a/polygerrit-ui/app/services/app-context-init.ts b/polygerrit-ui/app/services/app-context-init.ts
index 4f750dd..f74962a 100644
--- a/polygerrit-ui/app/services/app-context-init.ts
+++ b/polygerrit-ui/app/services/app-context-init.ts
@@ -26,6 +26,7 @@
 import {GrStorageService} from './storage/gr-storage_impl';
 import {ConfigService} from './config/config-service';
 import {UserService} from './user/user-service';
+import {CommentsService} from './comments/comments-service';
 
 type ServiceName = keyof AppContext;
 type ServiceCreator<T> = () => T;
@@ -74,6 +75,7 @@
     authService: () => new Auth(appContext.eventEmitter),
     restApiService: () => new GrRestApiInterface(appContext.authService),
     changeService: () => new ChangeService(),
+    commentsService: () => new CommentsService(appContext.restApiService),
     checksService: () => new ChecksService(appContext.reportingService),
     jsApiService: () => new GrJsApiInterface(),
     storageService: () => new GrStorageService(),
diff --git a/polygerrit-ui/app/services/app-context.ts b/polygerrit-ui/app/services/app-context.ts
index de74f2a..161378d 100644
--- a/polygerrit-ui/app/services/app-context.ts
+++ b/polygerrit-ui/app/services/app-context.ts
@@ -25,6 +25,7 @@
 import {StorageService} from './storage/gr-storage';
 import {ConfigService} from './config/config-service';
 import {UserService} from './user/user-service';
+import {CommentsService} from './comments/comments-service';
 
 export interface AppContext {
   flagsService: FlagsService;
@@ -33,6 +34,7 @@
   authService: AuthService;
   restApiService: RestApiService;
   changeService: ChangeService;
+  commentsService: CommentsService;
   checksService: ChecksService;
   jsApiService: JsApiService;
   storageService: StorageService;
diff --git a/polygerrit-ui/app/services/checks/checks-service.ts b/polygerrit-ui/app/services/checks/checks-service.ts
index 7844fa6..164074b 100644
--- a/polygerrit-ui/app/services/checks/checks-service.ts
+++ b/polygerrit-ui/app/services/checks/checks-service.ts
@@ -54,7 +54,7 @@
   Subject,
   timer,
 } from 'rxjs';
-import {PatchSetNumber} from '../../types/common';
+import {ChangeInfo, PatchSetNumber} from '../../types/common';
 import {getCurrentRevision} from '../../utils/change-util';
 import {getShaByPatchNum} from '../../utils/patch-set-util';
 import {assertIsDefined} from '../../utils/common-util';
@@ -171,8 +171,8 @@
               patchsetNumber: patchNum,
               patchsetSha,
               repo: change.project,
-              commmitMessage: getCurrentRevision(change)?.commit?.message,
-              changeInfo: change,
+              commitMessage: getCurrentRevision(change)?.commit?.message,
+              changeInfo: change as ChangeInfo,
             };
             return this.fetchResults(pluginName, data, patchset);
           }
diff --git a/polygerrit-ui/app/services/comments/comments-model.ts b/polygerrit-ui/app/services/comments/comments-model.ts
new file mode 100644
index 0000000..b26ec9b
--- /dev/null
+++ b/polygerrit-ui/app/services/comments/comments-model.ts
@@ -0,0 +1,136 @@
+/**
+ * @license
+ * Copyright (C) 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import {BehaviorSubject, Observable} from 'rxjs';
+import {distinctUntilChanged, map} from 'rxjs/operators';
+import {ChangeComments} from '../../elements/diff/gr-comment-api/gr-comment-api';
+import {
+  CommentInfo,
+  PathToCommentsInfoMap,
+  RobotCommentInfo,
+} from '../../types/common';
+import {addPath, DraftInfo} from '../../utils/comment-util';
+
+interface CommentState {
+  comments: PathToCommentsInfoMap;
+  robotComments: {[path: string]: RobotCommentInfo[]};
+  drafts: {[path: string]: DraftInfo[]};
+  portedComments: PathToCommentsInfoMap;
+  portedDrafts: PathToCommentsInfoMap;
+}
+
+const initialState: CommentState = {
+  comments: {},
+  robotComments: {},
+  drafts: {},
+  portedComments: {},
+  portedDrafts: {},
+};
+
+const privateState$ = new BehaviorSubject(initialState);
+
+// Re-exporting as Observable so that you can only subscribe, but not emit.
+export const commentState$: Observable<CommentState> = privateState$;
+
+export const drafts$ = commentState$.pipe(
+  map(commentState => commentState.drafts),
+  distinctUntilChanged()
+);
+
+// Emits a new value even if only a single draft is changed. Components should
+// aim to subsribe to something more specific.
+export const changeComments$ = commentState$.pipe(
+  map(
+    commentState =>
+      new ChangeComments(
+        commentState.comments,
+        commentState.robotComments,
+        commentState.drafts,
+        commentState.portedComments,
+        commentState.portedDrafts
+      )
+  ),
+  distinctUntilChanged()
+);
+
+export function updateStateComments(comments?: {
+  [path: string]: CommentInfo[];
+}) {
+  const nextState = {...privateState$.getValue()};
+  nextState.comments = addPath(comments) || {};
+  privateState$.next(nextState);
+}
+
+export function updateStateRobotComments(robotComments?: {
+  [path: string]: RobotCommentInfo[];
+}) {
+  const nextState = {...privateState$.getValue()};
+  nextState.robotComments = addPath(robotComments) || {};
+  privateState$.next(nextState);
+}
+
+export function updateStateDrafts(drafts?: {[path: string]: DraftInfo[]}) {
+  const nextState = {...privateState$.getValue()};
+  nextState.drafts = addPath(drafts) || {};
+  privateState$.next(nextState);
+}
+
+export function updateStatePortedComments(
+  portedComments?: PathToCommentsInfoMap
+) {
+  const nextState = {...privateState$.getValue()};
+  nextState.portedComments = portedComments || {};
+  privateState$.next(nextState);
+}
+
+export function updateStatePortedDrafts(portedDrafts?: PathToCommentsInfoMap) {
+  const nextState = {...privateState$.getValue()};
+  nextState.portedDrafts = portedDrafts || {};
+  privateState$.next(nextState);
+}
+
+export function updateStateAddDraft(draft: DraftInfo) {
+  const nextState = {...privateState$.getValue()};
+  if (!draft.path) throw new Error('draft path undefined');
+  nextState.drafts = {...nextState.drafts};
+  const drafts = nextState.drafts;
+  if (!drafts[draft.path]) drafts[draft.path] = [] as DraftInfo[];
+  else drafts[draft.path] = [...drafts[draft.path]];
+  const index = drafts[draft.path].findIndex(
+    d => d.__draftID === draft.__draftID || d.id === draft.id
+  );
+  if (index !== -1) {
+    drafts[draft.path][index] = draft;
+  } else {
+    drafts[draft.path].push(draft);
+  }
+  privateState$.next(nextState);
+}
+
+export function updateStateDeleteDraft(draft: DraftInfo) {
+  const nextState = {...privateState$.getValue()};
+  if (!draft.path) throw new Error('draft path undefined');
+  nextState.drafts = {...nextState.drafts};
+  const drafts = nextState.drafts;
+  const index = (drafts[draft.path] || []).findIndex(
+    d => d.__draftID === draft.__draftID || d.id === draft.id
+  );
+  if (index === -1) return;
+  drafts[draft.path] = [...drafts[draft.path]];
+  drafts[draft.path].splice(index, 1);
+  privateState$.next(nextState);
+}
diff --git a/polygerrit-ui/app/services/comments/comments-service.ts b/polygerrit-ui/app/services/comments/comments-service.ts
new file mode 100644
index 0000000..05dfc4c
--- /dev/null
+++ b/polygerrit-ui/app/services/comments/comments-service.ts
@@ -0,0 +1,68 @@
+/**
+ * @license
+ * Copyright (C) 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import {NumericChangeId, RevisionId} from '../../types/common';
+import {DraftInfo} from '../../utils/comment-util';
+import {CURRENT} from '../../utils/patch-set-util';
+import {RestApiService} from '../gr-rest-api/gr-rest-api';
+import {
+  updateStateAddDraft,
+  updateStateDeleteDraft,
+  updateStateComments,
+  updateStateRobotComments,
+  updateStateDrafts,
+  updateStatePortedComments,
+  updateStatePortedDrafts,
+} from './comments-model';
+
+export class CommentsService {
+  constructor(readonly restApiService: RestApiService) {}
+
+  /**
+   * Load all comments (with drafts and robot comments) for the given change
+   * number. The returned promise resolves when the comments have loaded, but
+   * does not yield the comment data.
+   */
+  // TODO(dhruvsri): listen to changeNum changes or reload event to update
+  // automatically
+  loadAll(changeNum: NumericChangeId, patchNum = CURRENT as RevisionId) {
+    const revision = patchNum;
+    this.restApiService
+      .getDiffComments(changeNum)
+      .then(comments => updateStateComments(comments));
+    this.restApiService
+      .getDiffRobotComments(changeNum)
+      .then(robotComments => updateStateRobotComments(robotComments));
+    this.restApiService
+      .getDiffDrafts(changeNum)
+      .then(drafts => updateStateDrafts(drafts));
+    this.restApiService
+      .getPortedComments(changeNum, revision)
+      .then(portedComments => updateStatePortedComments(portedComments));
+    this.restApiService
+      .getPortedDrafts(changeNum, revision)
+      .then(portedDrafts => updateStatePortedDrafts(portedDrafts));
+  }
+
+  addDraft(draft: DraftInfo) {
+    updateStateAddDraft(draft);
+  }
+
+  deleteDraft(draft: DraftInfo) {
+    updateStateDeleteDraft(draft);
+  }
+}
diff --git a/polygerrit-ui/app/services/comments/comments-service_test.ts b/polygerrit-ui/app/services/comments/comments-service_test.ts
new file mode 100644
index 0000000..604b5c4
--- /dev/null
+++ b/polygerrit-ui/app/services/comments/comments-service_test.ts
@@ -0,0 +1,126 @@
+/**
+ * @license
+ * Copyright (C) 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../test/common-test-setup-karma';
+import {
+  createComment,
+  createFixSuggestionInfo,
+} from '../../test/test-data-generators';
+import {stubRestApi} from '../../test/test-utils';
+import {
+  NumericChangeId,
+  RobotId,
+  RobotRunId,
+  Timestamp,
+  UrlEncodedCommentId,
+} from '../../types/common';
+import {appContext} from '../app-context';
+import {CommentsService} from './comments-service';
+
+suite('change service tests', () => {
+  let commentsService: CommentsService;
+
+  test('loads logged-out', () => {
+    const changeNum = 1234 as NumericChangeId;
+    commentsService = new CommentsService(appContext.restApiService);
+    stubRestApi('getLoggedIn').returns(Promise.resolve(false));
+    const diffCommentsSpy = stubRestApi('getDiffComments').returns(
+      Promise.resolve({
+        'foo.c': [
+          {
+            ...createComment(),
+            id: '123' as UrlEncodedCommentId,
+            message: 'Done',
+            updated: '2017-02-08 16:40:49' as Timestamp,
+          },
+        ],
+      })
+    );
+    const diffRobotCommentsSpy = stubRestApi('getDiffRobotComments').returns(
+      Promise.resolve({
+        'foo.c': [
+          {
+            ...createComment(),
+            id: '321' as UrlEncodedCommentId,
+            message: 'Done',
+            updated: '2017-02-08 16:40:49' as Timestamp,
+            robot_id: 'robot_1' as RobotId,
+            robot_run_id: 'run_1' as RobotRunId,
+            properties: {},
+            fix_suggestions: [
+              createFixSuggestionInfo('fix_1'),
+              createFixSuggestionInfo('fix_2'),
+            ],
+          },
+        ],
+      })
+    );
+    const diffDraftsSpy = stubRestApi('getDiffDrafts').returns(
+      Promise.resolve({})
+    );
+
+    commentsService.loadAll(changeNum);
+    assert.isTrue(diffCommentsSpy.calledWithExactly(changeNum));
+    assert.isTrue(diffRobotCommentsSpy.calledWithExactly(changeNum));
+    assert.isTrue(diffDraftsSpy.calledWithExactly(changeNum));
+  });
+
+  test('loads logged-in', () => {
+    const changeNum = 1234 as NumericChangeId;
+
+    stubRestApi('getLoggedIn').returns(Promise.resolve(true));
+    const diffCommentsSpy = stubRestApi('getDiffComments').returns(
+      Promise.resolve({
+        'foo.c': [
+          {
+            ...createComment(),
+            id: '123' as UrlEncodedCommentId,
+            message: 'Done',
+            updated: '2017-02-08 16:40:49' as Timestamp,
+          },
+        ],
+      })
+    );
+    const diffRobotCommentsSpy = stubRestApi('getDiffRobotComments').returns(
+      Promise.resolve({
+        'foo.c': [
+          {
+            ...createComment(),
+            id: '321' as UrlEncodedCommentId,
+            message: 'Done',
+            updated: '2017-02-08 16:40:49' as Timestamp,
+            robot_id: 'robot_1' as RobotId,
+            robot_run_id: 'run_1' as RobotRunId,
+            properties: {},
+            fix_suggestions: [
+              createFixSuggestionInfo('fix_1'),
+              createFixSuggestionInfo('fix_2'),
+            ],
+          },
+        ],
+      })
+    );
+    const diffDraftsSpy = stubRestApi('getDiffDrafts').returns(
+      Promise.resolve({})
+    );
+
+    commentsService.loadAll(changeNum);
+    assert.isTrue(diffCommentsSpy.calledWithExactly(changeNum));
+    assert.isTrue(diffRobotCommentsSpy.calledWithExactly(changeNum));
+    assert.isTrue(diffDraftsSpy.calledWithExactly(changeNum));
+  });
+});
diff --git a/polygerrit-ui/app/services/flags/flags_impl.ts b/polygerrit-ui/app/services/flags/flags_impl.ts
index fbfa833..18e225b 100644
--- a/polygerrit-ui/app/services/flags/flags_impl.ts
+++ b/polygerrit-ui/app/services/flags/flags_impl.ts
@@ -18,7 +18,7 @@
 
 declare global {
   interface Window {
-    ENABLED_EXPERIMENTS: string[];
+    ENABLED_EXPERIMENTS?: string[];
   }
 }
 
@@ -40,7 +40,7 @@
   }
 
   _loadExperiments(): Set<string> {
-    return new Set(window.ENABLED_EXPERIMENTS);
+    return new Set(window.ENABLED_EXPERIMENTS ?? []);
   }
 
   get enabledExperiments() {
diff --git a/polygerrit-ui/app/services/flags/flags_test.ts b/polygerrit-ui/app/services/flags/flags_test.ts
index 8827368..4ae11bf 100644
--- a/polygerrit-ui/app/services/flags/flags_test.ts
+++ b/polygerrit-ui/app/services/flags/flags_test.ts
@@ -19,7 +19,7 @@
 import {FlagsServiceImplementation} from './flags_impl';
 
 suite('flags tests', () => {
-  let originalEnabledExperiments: string[];
+  let originalEnabledExperiments: string[] | undefined;
   let flags: FlagsServiceImplementation;
 
   suiteSetup(() => {
diff --git a/polygerrit-ui/app/services/gr-reporting/gr-reporting_impl.ts b/polygerrit-ui/app/services/gr-reporting/gr-reporting_impl.ts
index 2cc6f41..0df7d12 100644
--- a/polygerrit-ui/app/services/gr-reporting/gr-reporting_impl.ts
+++ b/polygerrit-ui/app/services/gr-reporting/gr-reporting_impl.ts
@@ -419,7 +419,10 @@
       eventInfo.inBackgroundTab = isInBackgroundTab;
     }
 
-    if (this._flagsService.enabledExperiments.length) {
+    if (
+      name === Timing.APP_STARTED &&
+      this._flagsService.enabledExperiments.length
+    ) {
       eventInfo.enabledExperiments = JSON.stringify(
         this._flagsService.enabledExperiments
       );
diff --git a/polygerrit-ui/app/styles/shared-styles.ts b/polygerrit-ui/app/styles/shared-styles.ts
index 287cf68..a8274cc 100644
--- a/polygerrit-ui/app/styles/shared-styles.ts
+++ b/polygerrit-ui/app/styles/shared-styles.ts
@@ -239,9 +239,13 @@
       font-family: var(--header-font-family);
       -webkit-font-smoothing: initial;
     }
+    --paper-tab-content: {
+      margin-bottom: var(--spacing-s);
+    }
     --paper-tab-content-focused: {
       /* paper-tabs uses 700 here, which can look awkward */
       font-weight: var(--font-weight-h3);
+      background: var(--gray-background-focus);
     }
     --paper-tab-content-unselected: {
       /* paper-tabs uses 0.8 here, but we want to control the color directly */
@@ -249,6 +253,10 @@
       color: var(--deemphasized-text-color);
     }
   }
+  paper-tab:focus {
+    padding-left: 0px;
+    padding-right: 0px;
+  }
   iron-autogrow-textarea {
     /** This is needed for firefox */
     --iron-autogrow-textarea_-_white-space: pre-wrap;
diff --git a/polygerrit-ui/app/styles/themes/app-theme.ts b/polygerrit-ui/app/styles/themes/app-theme.ts
index d768c96..134003b 100644
--- a/polygerrit-ui/app/styles/themes/app-theme.ts
+++ b/polygerrit-ui/app/styles/themes/app-theme.ts
@@ -128,20 +128,20 @@
 
     --error-foreground: var(--red-700);
     --error-background: var(--red-50);
-    --error-background-hover: linear-gradient(var(--red-700-04), var(--red-700-04)), var(--red-50);
-    --error-background-focus: linear-gradient(var(--red-700-12), var(--red-700-12)), var(--red-50);
+    --error-background-hover: linear-gradient(var(--red-700-04), var(--red-700-04), var(--red-50));
+    --error-background-focus: linear-gradient(var(--red-700-12), var(--red-700-12), var(--red-50));
     --error-ripple: var(--red-700-10);
 
     --warning-foreground: var(--orange-700);
     --warning-background: var(--orange-50);
-    --warning-background-hover: linear-gradient(var(--orange-700-04), var(--orange-700-04)), var(--orange-50);
-    --warning-background-focus: linear-gradient(var(--orange-700-12), var(--orange-700-12)), var(--orange-50);
+    --warning-background-hover: linear-gradient(var(--orange-700-04), var(--orange-700-04), var(--orange-50));
+    --warning-background-focus: linear-gradient(var(--orange-700-12), var(--orange-700-12), var(--orange-50));
     --warning-ripple: var(--orange-700-10);
 
     --info-foreground: var(--blue-700);
     --info-background: var(--blue-50);
-    --info-background-hover: linear-gradient(var(--blue-700-04), var(--blue-700-04)), var(--blue-50);
-    --info-background-focus: linear-gradient(var(--blue-700-12), var(--blue-700-12)), var(--blue-50);
+    --info-background-hover: linear-gradient(var(--blue-700-04), var(--blue-700-04), var(--blue-50));
+    --info-background-focus: linear-gradient(var(--blue-700-12), var(--blue-700-12), var(--blue-50));
     --info-ripple: var(--blue-700-10);
 
     --primary-button-text-color: white;
@@ -154,14 +154,14 @@
 
     --success-foreground: var(--green-700);
     --success-background: var(--green-50);
-    --success-background-hover: linear-gradient(var(--green-700-04), var(--green-700-04)), var(--green-50);
-    --success-background-focus: linear-gradient(var(--green-700-12), var(--green-700-12)), var(--green-50);
+    --success-background-hover: linear-gradient(var(--green-700-04), var(--green-700-04), var(--green-50));
+    --success-background-focus: linear-gradient(var(--green-700-12), var(--green-700-12), var(--green-50));
     --success-ripple: var(--green-700-10);
 
     --gray-foreground: var(--gray-700);
     --gray-background: var(--gray-100);
-    --gray-background-hover: linear-gradient(var(--gray-700-04), var(--gray-700-04)), var(--gray-100);
-    --gray-background-focus: linear-gradient(var(--gray-700-12), var(--gray-700-12)), var(--gray-100);
+    --gray-background-hover: linear-gradient(var(--gray-700-04), var(--gray-700-04), var(--gray-100));
+    --gray-background-focus: linear-gradient(var(--gray-700-12), var(--gray-700-12), var(--gray-100));
     --gray-ripple: var(--gray-700-10);
 
     --disabled-foreground: var(--gray-800-38);
@@ -377,8 +377,7 @@
     /* misc */
     --border-radius: 4px;
     --reply-overlay-z-index: 1000;
-    /* Base 64 encoded 1x1px of #681da8 */
-    --line-length-indicator: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQYV2PIlF2xAgAD+AHXfBDdKAAAAABJRU5ErkJggg==');
+    --line-length-indicator-color: #681da8;
 
     /* paper and iron component overrides */
     --iron-overlay-backdrop-background-color: black;
diff --git a/polygerrit-ui/app/styles/themes/dark-theme.ts b/polygerrit-ui/app/styles/themes/dark-theme.ts
index dec438d..69256b2 100644
--- a/polygerrit-ui/app/styles/themes/dark-theme.ts
+++ b/polygerrit-ui/app/styles/themes/dark-theme.ts
@@ -38,20 +38,20 @@
 
       --error-foreground: var(--red-200);
       --error-background: var(--red-tonal);
-      --error-background-hover: linear-gradient(var(--white-04), var(--white-04)), var(--red-tonal);
-      --error-background-focus: linear-gradient(var(--white-12), var(--white-12)), var(--red-tonal);
+      --error-background-hover: linear-gradient(var(--white-04), var(--white-04), var(--red-tonal));
+      --error-background-focus: linear-gradient(var(--white-12), var(--white-12), var(--red-tonal));
       --error-ripple: var(--white-10);
 
       --warning-foreground: var(--orange-200);
       --warning-background: var(--orange-tonal);
-      --warning-background-hover: linear-gradient(var(--white-04), var(--white-04)), var(--orange-tonal);
-      --warning-background-focus: linear-gradient(var(--white-12), var(--white-12)), var(--orange-tonal);
+      --warning-background-hover: linear-gradient(var(--white-04), var(--white-04), var(--orange-tonal));
+      --warning-background-focus: linear-gradient(var(--white-12), var(--white-12), var(--orange-tonal));
       --warning-ripple: var(--white-10);
 
       --info-foreground: var(--blue-200);
       --info-background: var(--blue-tonal);
-      --info-background-hover: linear-gradient(var(--white-04), var(--white-04)), var(--blue-tonal);
-      --info-background-focus: linear-gradient(var(--white-12), var(--white-12)), var(--blue-tonal);
+      --info-background-hover: linear-gradient(var(--white-04), var(--white-04), var(--blue-tonal));
+      --info-background-focus: linear-gradient(var(--white-12), var(--white-12), var(--blue-tonal));
       --info-ripple: var(--white-10);
 
       --primary-button-text-color: black;
@@ -64,14 +64,14 @@
 
       --success-foreground: var(--green-200);
       --success-background: var(--green-tonal);
-      --success-background-hover: linear-gradient(var(--white-04), var(--white-04)), var(--green-tonal);
-      --success-background-focus: linear-gradient(var(--white-12), var(--white-12)), var(--green-tonal);
+      --success-background-hover: linear-gradient(var(--white-04), var(--white-04), var(--green-tonal));
+      --success-background-focus: linear-gradient(var(--white-12), var(--white-12), var(--green-tonal));
       --success-ripple: var(--white-10);
 
       --gray-foreground: var(--gray-300);
       --gray-background: var(--gray-tonal);
-      --gray-background-hover: linear-gradient(var(--white-04), var(--white-04)), var(--gray-tonal);
-      --gray-background-focus: linear-gradient(var(--white-12), var(--white-12)), var(--gray-tonal);
+      --gray-background-hover: linear-gradient(var(--white-04), var(--white-04), var(--gray-tonal));
+      --gray-background-focus: linear-gradient(var(--white-12), var(--white-12), var(--gray-tonal));
       --gray-ripple: var(--white-10);
 
       --disabled-foreground: var(--gray-200-38);
@@ -230,8 +230,7 @@
       --syntax-variable-color: #f77669;
 
       /* misc */
-      /* Base 64 encoded 1x1px of #d7aefb; */
-      --line-length-indicator: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQYV2O4vu73fwAIYAOAtqAXCQAAAABJRU5ErkJggg==');
+      --line-length-indicator-color: #d7aefb;
 
       /* paper and iron component overrides */
       --iron-overlay-backdrop-background-color: white;
diff --git a/polygerrit-ui/app/test/mocks/comment-api.js b/polygerrit-ui/app/test/mocks/comment-api.js
index 526aabe..fc4599d 100644
--- a/polygerrit-ui/app/test/mocks/comment-api.js
+++ b/polygerrit-ui/app/test/mocks/comment-api.js
@@ -28,27 +28,6 @@
       _changeComments: Object,
     };
   }
-
-  loadComments() {
-    return this._reloadComments();
-  }
-
-  /**
-   * For the purposes of the mock, _reloadDrafts is not included because its
-   * response is the same type as reloadComments, just makes less API
-   * requests. Since this is for test purposes/mocked data anyway, keep this
-   * file simpler by just using _reloadComments here instead.
-   */
-  _reloadDraftsWithCallback(e) {
-    return this._reloadComments().then(() => e.detail.resolve());
-  }
-
-  _reloadComments() {
-    return this.$.commentAPI.loadAll(this._changeNum)
-        .then(comments => {
-          this._changeComments = this.$.commentAPI._changeComments;
-        });
-  }
 }
 
 /**
diff --git a/polygerrit-ui/app/types/common.ts b/polygerrit-ui/app/types/common.ts
index e9cdba4..96c05ee 100644
--- a/polygerrit-ui/app/types/common.ts
+++ b/polygerrit-ui/app/types/common.ts
@@ -17,7 +17,6 @@
 import {CommentRange} from '../api/core';
 import {
   ChangeStatus,
-  DefaultDisplayNameConfig,
   ProjectState,
   SubmitType,
   InheritedBooleanInfoConfiguredValue,
@@ -33,21 +32,22 @@
   DraftsAction,
   NotifyType,
   EmailFormat,
-  AuthType,
   MergeStrategy,
-  EditableAccountField,
-  MergeabilityComputationBehavior,
 } from '../constants/constants';
 import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
 import {
   AccountId,
   AccountInfo,
+  AccountsConfigInfo,
   ActionInfo,
   ActionNameToActionInfoMap,
   ApprovalInfo,
+  AuthInfo,
+  AvatarInfo,
   BasePatchSetNum,
   BranchName,
   BrandType,
+  ChangeConfigInfo,
   ChangeId,
   ChangeInfo,
   ChangeInfoId,
@@ -63,14 +63,22 @@
   ConfigListParameterInfo,
   ConfigParameterInfo,
   ConfigParameterInfoBase,
+  ContributorAgreementInfo,
   DetailedLabelInfo,
+  DownloadInfo,
+  DownloadSchemeInfo,
   EmailAddress,
   FetchInfo,
   FileInfo,
+  GerritInfo,
   GitPersonInfo,
   GitRef,
   GpgKeyId,
   GpgKeyInfo,
+  GroupId,
+  GroupInfo,
+  GroupName,
+  GroupOptionsInfo,
   Hashtag,
   InheritedBooleanInfo,
   LabelInfo,
@@ -81,9 +89,11 @@
   NumericChangeId,
   ParentCommitInfo,
   PatchSetNum,
+  PluginConfigInfo,
   PluginNameToPluginParametersMap,
   PluginParameterToConfigParameterInfoMap,
   QuickLabelInfo,
+  ReceiveInfo,
   RepoName,
   Requirement,
   RequirementType,
@@ -92,10 +102,14 @@
   ReviewerUpdateInfo,
   Reviewers,
   RevisionInfo,
+  SchemesInfoMap,
+  ServerInfo,
   SubmitTypeInfo,
+  SuggestInfo,
   Timestamp,
   TimezoneOffset,
   TopicName,
+  UserConfigInfo,
   VotingRangeInfo,
   WebLinkInfo,
   isDetailedLabelInfo,
@@ -106,21 +120,25 @@
 export {
   AccountId,
   AccountInfo,
+  AccountsConfigInfo,
   ActionInfo,
   ActionNameToActionInfoMap,
   ApprovalInfo,
+  AuthInfo,
+  AvatarInfo,
   BasePatchSetNum,
   BranchName,
   BrandType,
+  ChangeConfigInfo,
   ChangeId,
   ChangeInfo,
   ChangeInfoId,
   ChangeMessageId,
   ChangeMessageInfo,
   ChangeSubmissionId,
-  CommentRange,
   CommentLinkInfo,
   CommentLinks,
+  CommentRange,
   CommitId,
   CommitInfo,
   ConfigArrayParameterInfo,
@@ -128,13 +146,21 @@
   ConfigListParameterInfo,
   ConfigParameterInfo,
   ConfigParameterInfoBase,
+  ContributorAgreementInfo,
   DetailedLabelInfo,
+  DownloadInfo,
+  DownloadSchemeInfo,
   EmailAddress,
   FileInfo,
+  GerritInfo,
   GitPersonInfo,
   GitRef,
   GpgKeyId,
   GpgKeyInfo,
+  GroupId,
+  GroupInfo,
+  GroupName,
+  GroupOptionsInfo,
   Hashtag,
   InheritedBooleanInfo,
   LabelInfo,
@@ -145,9 +171,11 @@
   NumericChangeId,
   ParentCommitInfo,
   PatchSetNum,
+  PluginConfigInfo,
   PluginNameToPluginParametersMap,
   PluginParameterToConfigParameterInfoMap,
   QuickLabelInfo,
+  ReceiveInfo,
   RepoName,
   Requirement,
   RequirementType,
@@ -155,10 +183,14 @@
   ReviewerUpdateInfo,
   Reviewers,
   RevisionInfo,
+  SchemesInfoMap,
+  ServerInfo,
   SubmitTypeInfo,
+  SuggestInfo,
   Timestamp,
   TimezoneOffset,
   TopicName,
+  UserConfigInfo,
   VotingRangeInfo,
   isDetailedLabelInfo,
   isQuickLabelInfo,
@@ -216,11 +248,6 @@
 
 export type LabelName = BrandType<string, '_labelName'>;
 
-export type GroupName = BrandType<string, '_groupName'>;
-
-// The UUID of the group
-export type GroupId = BrandType<string, '_groupId'>;
-
 // The Encoded UUID of the group
 export type EncodedGroupId = BrandType<string, '_encodedGroupId'>;
 
@@ -229,14 +256,6 @@
   name?: string;
 }
 
-// https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#contributor-agreement-info
-export interface ContributorAgreementInfo {
-  name: string;
-  description: string;
-  url: string;
-  auto_verify_group?: GroupInfo;
-}
-
 /**
  * ChangeView request change detail with ALL_REVISIONS option set.
  * The response always contains current_revision and revisions.
@@ -270,7 +289,7 @@
  */
 export interface AccountExternalIdInfo {
   identity: string;
-  email?: string;
+  email_address?: string;
   trusted?: boolean;
   can_delete?: boolean;
 }
@@ -332,26 +351,6 @@
   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#group-info
- */
-export interface GroupInfo {
-  id: GroupId;
-  name?: GroupName;
-  url?: string;
-  options?: GroupOptionsInfo;
-  description?: string;
-  group_id?: string;
-  owner?: string;
-  owner_id?: string;
-  created_on?: string;
-  _more_groups?: boolean;
-  members?: AccountInfo[];
-  includes?: GroupInfo[];
-}
-
 export type GroupNameToGroupInfoMap = {[groupName: string]: GroupInfo};
 
 /**
@@ -369,14 +368,6 @@
 }
 
 /**
- * Options of the group.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-groups.html
- */
-export interface GroupOptionsInfo {
-  visible_to_all: boolean;
-}
-
-/**
  * New options for a group.
  * https://gerrit-review.googlesource.com/Documentation/rest-api-groups.html
  */
@@ -427,36 +418,6 @@
 }
 
 /**
- * The AccountsConfigInfo entity contains information about Gerrit configuration
- * from the accounts section.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#accounts-config-info
- */
-export interface AccountsConfigInfo {
-  visibility: string;
-  default_display_name: DefaultDisplayNameConfig;
-}
-
-/**
- * The AuthInfo entity contains information about the authentication
- * configuration of the Gerrit server.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#auth-info
- */
-export interface AuthInfo {
-  auth_type: AuthType; // docs incorrectly names it 'type'
-  use_contributor_agreements?: boolean;
-  contributor_agreements?: ContributorAgreementInfo[];
-  editable_account_fields: EditableAccountField[];
-  login_url?: string;
-  login_text?: string;
-  switch_account_url?: string;
-  register_url?: string;
-  register_text?: string;
-  edit_full_name_url?: string;
-  http_password_url?: string;
-  git_basic_auth_policy?: string;
-}
-
-/**
  * The CacheInfo entity contains information about a cache.
  * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html
  */
@@ -490,21 +451,6 @@
 export type CapabilityInfoMap = {[id: string]: CapabilityInfo};
 
 /**
- * The ChangeConfigInfo entity contains information about Gerrit configuration
- * from the change section.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#change-config-info
- */
-export interface ChangeConfigInfo {
-  allow_blame?: boolean;
-  large_change: number;
-  update_delay: number;
-  submit_whole_topic?: boolean;
-  disable_private_changes?: boolean;
-  mergeability_computation_behavior: MergeabilityComputationBehavior;
-  enable_assignee: boolean;
-}
-
-/**
  * The ChangeIndexConfigInfo entity contains information about Gerrit
  * configuration from the index.change section.
  * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#change-index-config-info
@@ -591,32 +537,6 @@
   new_value: string;
 }
 
-export type SchemesInfoMap = {[name: string]: DownloadSchemeInfo};
-
-/**
- * The DownloadInfo entity contains information about supported download
- * options.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#download-info
- */
-export interface DownloadInfo {
-  schemes: SchemesInfoMap;
-  archives: string[];
-}
-
-export type CloneCommandMap = {[name: string]: string};
-/**
- * The DownloadSchemeInfo entity contains information about a supported download
- * scheme and its commands.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html
- */
-export interface DownloadSchemeInfo {
-  url: string;
-  is_auth_required: boolean;
-  is_auth_supported: boolean;
-  commands: string;
-  clone_commands: CloneCommandMap;
-}
-
 /**
  * The EmailConfirmationInput entity contains information for confirming an
  * email address.
@@ -637,22 +557,6 @@
 }
 
 /**
- * The GerritInfo entity contains information about Gerrit configuration from
- * the gerrit section.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#gerrit-info
- */
-export interface GerritInfo {
-  all_projects: string; // Doc contains incorrect name
-  all_users: string; // Doc contains incorrect name
-  doc_search: boolean;
-  doc_url?: string;
-  edit_gpg_keys?: boolean;
-  report_bug_url?: string;
-  // The following property is missed in doc
-  primary_weblink_name?: string;
-}
-
-/**
  * The IndexConfigInfo entity contains information about Gerrit configuration
  * from the index section.
  * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#index-config-info
@@ -710,65 +614,6 @@
 }
 
 /**
- * The PluginConfigInfo entity contains information about Gerrit extensions by
- * plugins.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#plugin-config-info
- */
-export interface PluginConfigInfo {
-  has_avatars: boolean;
-  // Exists in Java class, but not mentioned in docs.
-  js_resource_paths: string[];
-}
-
-/**
- * The ReceiveInfo entity contains information about the configuration of
- * git-receive-pack behavior on the server.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#receive-info
- */
-export interface ReceiveInfo {
-  enable_signed_push?: string;
-}
-
-/**
- * The ServerInfo entity contains information about the configuration of the
- * Gerrit server.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#server-info
- */
-export interface ServerInfo {
-  accounts: AccountsConfigInfo;
-  auth: AuthInfo;
-  change: ChangeConfigInfo;
-  download: DownloadInfo;
-  gerrit: GerritInfo;
-  // docs mentions index property, but it doesn't exists in Java class
-  // index: IndexConfigInfo;
-  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
- */
-export interface SuggestInfo {
-  from: number;
-}
-
-/**
  * The SummaryInfo entity contains information about the current state of the
  * server.
  * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html
@@ -838,15 +683,6 @@
 }
 
 /**
- * The UserConfigInfo entity contains information about Gerrit configuration
- * from the user section.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#user-config-info
- */
-export interface UserConfigInfo {
-  anonymous_coward_name: string;
-}
-
-/*
  * The CommentInfo entity contains information about an inline comment.
  * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#comment-info
  */
@@ -1150,6 +986,7 @@
   notify_all_comments?: boolean;
   notify_submitted_changes?: boolean;
   notify_abandoned_changes?: boolean;
+  _is_local?: boolean; // Added manually
 }
 /**
  * The DeleteDraftCommentsInput entity contains information specifying a set of draft comments that should be deleted
diff --git a/polygerrit-ui/app/types/diff.ts b/polygerrit-ui/app/types/diff.ts
index b29e312..223f290 100644
--- a/polygerrit-ui/app/types/diff.ts
+++ b/polygerrit-ui/app/types/diff.ts
@@ -27,6 +27,7 @@
   DiffFileMetaInfo as DiffFileMetaInfoApi,
   DiffInfo as DiffInfoApi,
   DiffIntralineInfo,
+  DiffResponsiveMode,
   DiffPreferencesInfo as DiffPreferenceInfoApi,
   IgnoreWhitespaceType,
   MarkLength,
@@ -37,6 +38,7 @@
 export {
   ChangeType,
   DiffIntralineInfo,
+  DiffResponsiveMode,
   IgnoreWhitespaceType,
   MarkLength,
   MoveDetails,
@@ -104,7 +106,6 @@
   hide_line_numbers?: boolean;
   hide_empty_pane?: boolean;
   match_brackets?: boolean;
-  line_wrapping?: boolean;
 }
 
 export declare type DiffPreferencesInfoKey = keyof DiffPreferencesInfo;
diff --git a/polygerrit-ui/app/types/events.ts b/polygerrit-ui/app/types/events.ts
index cc163c8..5145527 100644
--- a/polygerrit-ui/app/types/events.ts
+++ b/polygerrit-ui/app/types/events.ts
@@ -15,7 +15,7 @@
  * limitations under the License.
  */
 import {EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {PatchSetNum, UrlEncodedCommentId} from './common';
+import {PatchSetNum} from './common';
 import {UIComment} from '../utils/comment-util';
 import {FetchRequest} from './types';
 import {LineNumberEventDetail, MovedLinkClickedEventDetail} from '../api/diff';
@@ -23,9 +23,12 @@
 import {ChangeMessage} from '../elements/change/gr-message/gr-message';
 
 export enum EventType {
+  CHANGE = 'change',
   CHANGED = 'changed',
   CHANGE_MESSAGE_DELETED = 'change-message-deleted',
+  COMMIT = 'commit',
   DIALOG_CHANGE = 'dialog-change',
+  DROP = 'drop',
   EDITABLE_CONTENT_SAVE = 'editable-content-save',
   GR_RPC_LOG = 'gr-rpc-log',
   LOCATION_CHANGE = 'location-change',
@@ -45,19 +48,27 @@
   SHOW_ERROR = 'show-error',
   SHOW_PRIMARY_TAB = 'show-primary-tab',
   SHOW_SECONDARY_TAB = 'show-secondary-tab',
-  THREAD_LIST_MODIFIED = 'thread-list-modified',
+  TAP_ITEM = 'tap-item',
   TITLE_CHANGE = 'title-change',
 }
 
 declare global {
   interface HTMLElementEventMap {
     /* prettier-ignore */
+    'change': ChangeEvent;
+    /* prettier-ignore */
     'changed': ChangedEvent;
     'change-message-deleted': ChangeMessageDeletedEvent;
+    /* prettier-ignore */
+    'commit': CommitEvent;
     'dialog-change': DialogChangeEvent;
+    /* prettier-ignore */
+    'drop': DropEvent;
     'editable-content-save': EditableContentSaveEvent;
     'location-change': LocationChangeEvent;
     'iron-announce': IronAnnounceEvent;
+    /* prettier-ignore */
+    'keypress': KeypressEvent;
     'line-mouse-enter': LineNumberEvent;
     'line-mouse-leave': LineNumberEvent;
     'line-cursor-moved-in': LineNumberEvent;
@@ -74,7 +85,7 @@
     'show-error': ShowErrorEvent;
     'show-primary-tab': SwitchTabEvent;
     'show-secondary-tab': SwitchTabEvent;
-    'thread-list-modified': ThreadListModifiedEvent;
+    'tap-item': TapItemEvent;
     'title-change': TitleChangeEvent;
   }
 }
@@ -92,6 +103,8 @@
   }
 }
 
+export type ChangeEvent = InputEvent;
+
 export type ChangedEvent = CustomEvent<string>;
 
 export interface ChangeMessageDeletedEventDetail {
@@ -99,6 +112,8 @@
 }
 export type ChangeMessageDeletedEvent = CustomEvent<ChangeMessageDeletedEventDetail>;
 
+export type CommitEvent = CustomEvent;
+
 // TODO(milutin) - remove once new gr-dialog will do it out of the box
 // This informs gr-app-element to remove footer, header from a11y tree
 export interface DialogChangeEventDetail {
@@ -107,6 +122,8 @@
 }
 export type DialogChangeEvent = CustomEvent<DialogChangeEventDetail>;
 
+export type DropEvent = DragEvent;
+
 export interface EditableContentSaveEventDetail {
   content: string;
 }
@@ -125,6 +142,8 @@
 }
 export type IronAnnounceEvent = CustomEvent<IronAnnounceEventDetail>;
 
+export type KeypressEvent = InputEvent;
+
 export interface LocationChangeEventDetail {
   hash: string;
   pathname: string;
@@ -217,11 +236,7 @@
 }
 export type SwitchTabEvent = CustomEvent<SwitchTabEventDetail>;
 
-export interface ThreadListModifiedDetail {
-  rootId: UrlEncodedCommentId;
-  path: string;
-}
-export type ThreadListModifiedEvent = CustomEvent<ThreadListModifiedDetail>;
+export type TapItemEvent = CustomEvent;
 
 export interface TitleChangeEventDetail {
   title: string;
diff --git a/polygerrit-ui/app/types/types.ts b/polygerrit-ui/app/types/types.ts
index fd41cc9..2d4d412 100644
--- a/polygerrit-ui/app/types/types.ts
+++ b/polygerrit-ui/app/types/types.ts
@@ -189,7 +189,6 @@
   showDownloadDialog: boolean;
   diffMode: DiffViewMode | null;
   numFilesShown: number | null;
-  scrollTop?: number;
   diffViewMode?: boolean;
 }
 
diff --git a/polygerrit-ui/app/utils/comment-util.ts b/polygerrit-ui/app/utils/comment-util.ts
index 83a9174..3e85b48 100644
--- a/polygerrit-ui/app/utils/comment-util.ts
+++ b/polygerrit-ui/app/utils/comment-util.ts
@@ -361,3 +361,25 @@
   if (isDraft(comment)) return comment.__draftID;
   throw new Error('Missing id in root comment.');
 }
+
+/**
+ * Add path info to every comment as CommentInfo returned
+ * from server does not have that.
+ *
+ * TODO(taoalpha): should consider changing BE to send path
+ * back within CommentInfo
+ */
+export function addPath<T>(
+  comments: {[path: string]: T[]} = {}
+): {[path: string]: Array<T & {path: string}>} {
+  const updatedComments: {[path: string]: Array<T & {path: string}>} = {};
+  for (const filePath of Object.keys(comments)) {
+    const allCommentsForPath = comments[filePath] || [];
+    if (allCommentsForPath.length) {
+      updatedComments[filePath] = allCommentsForPath.map(comment => {
+        return {...comment, path: filePath};
+      });
+    }
+  }
+  return updatedComments;
+}
diff --git a/polygerrit-ui/app/utils/event-util.ts b/polygerrit-ui/app/utils/event-util.ts
index 4ba27bc..c356893 100644
--- a/polygerrit-ui/app/utils/event-util.ts
+++ b/polygerrit-ui/app/utils/event-util.ts
@@ -15,7 +15,6 @@
  * limitations under the License.
  */
 
-import {UrlEncodedCommentId} from '../types/common';
 import {FetchRequest} from '../types/types';
 import {
   DialogChangeEventDetail,
@@ -108,17 +107,6 @@
   fire(target, EventType.IRON_ANNOUNCE, {text});
 }
 
-export function fireThreadListModifiedEvent(
-  target: EventTarget,
-  rootId: UrlEncodedCommentId,
-  path: string
-) {
-  fire(target, EventType.THREAD_LIST_MODIFIED, {
-    rootId,
-    path,
-  });
-}
-
 export function fireShowPrimaryTab(
   target: EventTarget,
   tab: string,
diff --git a/polygerrit-ui/app/yarn.lock b/polygerrit-ui/app/yarn.lock
index d8657f7..4fb98dd 100644
--- a/polygerrit-ui/app/yarn.lock
+++ b/polygerrit-ui/app/yarn.lock
@@ -600,6 +600,11 @@
     agent-base "6"
     debug "4"
 
+immer@^9.0.5:
+  version "9.0.5"
+  resolved "https://registry.yarnpkg.com/immer/-/immer-9.0.5.tgz#a7154f34fe7064f15f00554cc94c66cc0bf453ec"
+  integrity sha512-2WuIehr2y4lmYz9gaQzetPR2ECniCifk4ORaQbU3g5EalLt+0IVTosEPJ5BoYl/75ky2mivzdRzV8wWgQGOSYQ==
+
 inflight@^1.0.4:
   version "1.0.6"
   resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9"
diff --git a/polygerrit-ui/server.go b/polygerrit-ui/server.go
index e030878..ddfaeb4 100644
--- a/polygerrit-ui/server.go
+++ b/polygerrit-ui/server.go
@@ -202,6 +202,10 @@
 		moduleImportRegexp = regexp.MustCompile("(?m)^((import|export).*'/node_modules/)lit-(element|html).js';$")
 		data = moduleImportRegexp.ReplaceAll(data, []byte("${1}lit-${3}/lit-${3}.js';"))
 
+		// 'immer' imports and exports have to be resolved to 'immer/dist/immer.esm.js'.
+		moduleImportRegexp = regexp.MustCompile("(?m)^((import|export).*'/node_modules/)immer.js';$")
+		data = moduleImportRegexp.ReplaceAll(data, []byte("${1}/immer/dist/immer.esm.js';"))
+
 		if strings.HasSuffix(normalizedContentPath, "/node_modules/page/page.js") {
 			// Can't import page.js directly, because this is undefined.
 			// Replace it with window
diff --git a/proto/cache.proto b/proto/cache.proto
index 781538a..aa04555 100644
--- a/proto/cache.proto
+++ b/proto/cache.proto
@@ -78,7 +78,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: 27
+// Next ID: 28
 message ChangeNotesStateProto {
   // Effectively required, even though the corresponding ChangeNotesState field
   // is optional, since the field is only absent when NoteDb is disabled, in
@@ -226,6 +226,8 @@
   // Epoch millis.
   int64 merged_on_millis = 25;
   bool has_merged_on = 26;
+
+  repeated SubmitRequirementResultProto submit_requirement_result = 27;
 }
 
 // Serialized form of com.google.gerrit.server.query.change.ConflictKey
@@ -480,6 +482,28 @@
   bool allow_override_in_child_projects = 6;
 }
 
+// Serialized form of com.google.gerrit.entities.SubmitRequirementResult.
+// Next ID: 6
+message SubmitRequirementResultProto {
+  SubmitRequirementProto submit_requirement = 1;
+  SubmitRequirementExpressionResultProto applicability_expression_result = 2;
+  SubmitRequirementExpressionResultProto submittability_expression_result = 3;
+  SubmitRequirementExpressionResultProto override_expression_result = 4;
+
+  // Patchset commit ID at which the submit requirements are evaluated.
+  bytes commit = 5;
+}
+
+// Serialized form of com.google.gerrit.entities.SubmitRequirementExpressionResult.
+// Next ID: 6
+message SubmitRequirementExpressionResultProto {
+  string expression = 1;
+  string status = 2; // enum as string
+  string error_message = 3;
+  repeated string passing_atoms = 4;
+  repeated string failing_atoms = 5;
+}
+
 // Serialized form of com.google.gerrit.server.project.ConfiguredMimeTypes.
 // Next ID: 4
 message ConfiguredMimeTypeProto {