Merge changes I343425ab,Ieaa18566

* changes:
  Do not allow creations of comments with invalid inReplyTo
  Add robot comments creation and retrieval to the test api
diff --git a/Documentation/rest-api-changes.txt b/Documentation/rest-api-changes.txt
index fbad212..4227655 100644
--- a/Documentation/rest-api-changes.txt
+++ b/Documentation/rest-api-changes.txt
@@ -7393,6 +7393,10 @@
 endpoint is the topic of the change being reverted, and the default for the
 RevertSubmission endpoint is `revert-{submission_id}-{timestamp.now}`.
 Topic can't contain quotation marks.
+|`work_in_progress` |optional|
+When present, change is marked as Work In Progress. This will also override
+the notify value to `OWNER`. +
+If not set, the default is false.
 |=============================
 
 [[revert-submission-info]]
diff --git a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
index 357ea0c..78a621c 100644
--- a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
+++ b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
@@ -429,13 +429,16 @@
 
     baseConfig.setInt("receive", null, "changeUpdateThreads", 4);
     Module module = createModule();
+    Module auditModule = createAuditModule();
     if (classDesc.equals(methodDesc) && !classDesc.sandboxed() && !methodDesc.sandboxed()) {
       if (commonServer == null) {
-        commonServer = GerritServer.initAndStart(temporaryFolder, classDesc, baseConfig, module);
+        commonServer =
+            GerritServer.initAndStart(temporaryFolder, classDesc, baseConfig, module, auditModule);
       }
       server = commonServer;
     } else {
-      server = GerritServer.initAndStart(temporaryFolder, methodDesc, baseConfig, module);
+      server =
+          GerritServer.initAndStart(temporaryFolder, methodDesc, baseConfig, module, auditModule);
     }
 
     server.getTestInjector().injectMembers(this);
@@ -528,6 +531,11 @@
     return null;
   }
 
+  /** Override to bind an alternative audit Guice module */
+  public Module createAuditModule() {
+    return null;
+  }
+
   protected void initSsh() throws Exception {
     if (testRequiresSsh
         && SshMode.useSsh()
diff --git a/java/com/google/gerrit/acceptance/GerritServer.java b/java/com/google/gerrit/acceptance/GerritServer.java
index 030c402..5942c0f 100644
--- a/java/com/google/gerrit/acceptance/GerritServer.java
+++ b/java/com/google/gerrit/acceptance/GerritServer.java
@@ -330,14 +330,15 @@
       TemporaryFolder temporaryFolder,
       Description desc,
       Config baseConfig,
-      @Nullable Module testSysModule)
+      @Nullable Module testSysModule,
+      @Nullable Module testAuditModule)
       throws Exception {
     Path site = temporaryFolder.newFolder().toPath();
     try {
       if (!desc.memory()) {
         init(desc, baseConfig, site);
       }
-      return start(desc, baseConfig, site, testSysModule, null);
+      return start(desc, baseConfig, site, testSysModule, testAuditModule, null);
     } catch (Exception e) {
       throw e;
     }
@@ -365,6 +366,7 @@
       Config baseConfig,
       Path site,
       @Nullable Module testSysModule,
+      @Nullable Module testAuditModule,
       @Nullable InMemoryRepositoryManager inMemoryRepoManager,
       String... additionalArgs)
       throws Exception {
@@ -383,7 +385,8 @@
             },
             site);
     daemon.setEmailModuleForTesting(new FakeEmailSender.Module());
-    daemon.setAuditEventModuleForTesting(new FakeGroupAuditService.Module());
+    daemon.setAuditEventModuleForTesting(
+        MoreObjects.firstNonNull(testAuditModule, new FakeGroupAuditService.Module()));
     if (testSysModule != null) {
       daemon.addAdditionalSysModuleForTesting(testSysModule);
     }
@@ -611,7 +614,7 @@
 
     server.close();
     server.daemon.stop();
-    return start(server.desc, cfg, site, null, inMemoryRepoManager);
+    return start(server.desc, cfg, site, null, null, inMemoryRepoManager);
   }
 
   private static boolean hasBinding(Injector injector, Class<?> clazz) {
diff --git a/java/com/google/gerrit/acceptance/StandaloneSiteTest.java b/java/com/google/gerrit/acceptance/StandaloneSiteTest.java
index c38f5fa..43fe4eb 100644
--- a/java/com/google/gerrit/acceptance/StandaloneSiteTest.java
+++ b/java/com/google/gerrit/acceptance/StandaloneSiteTest.java
@@ -187,7 +187,7 @@
   private GerritServer startImpl(@Nullable Module testSysModule, String... additionalArgs)
       throws Exception {
     return GerritServer.start(
-        serverDesc, baseConfig, sitePaths.site_path, testSysModule, null, additionalArgs);
+        serverDesc, baseConfig, sitePaths.site_path, testSysModule, null, null, additionalArgs);
   }
 
   protected static void runGerrit(String... args) throws Exception {
diff --git a/java/com/google/gerrit/extensions/api/changes/RevertInput.java b/java/com/google/gerrit/extensions/api/changes/RevertInput.java
index c4272e4..148d24a 100644
--- a/java/com/google/gerrit/extensions/api/changes/RevertInput.java
+++ b/java/com/google/gerrit/extensions/api/changes/RevertInput.java
@@ -17,6 +17,10 @@
 import com.google.gerrit.extensions.restapi.DefaultInput;
 import java.util.Map;
 
+/**
+ * Input passed to {@code POST /changes/[change-id]/revert} and {@code POST
+ * /changes/[change-id]/revert_submission}
+ */
 public class RevertInput {
   @DefaultInput public String message;
 
@@ -26,4 +30,10 @@
   public Map<RecipientType, NotifyInfo> notifyDetails;
 
   public String topic;
+
+  /**
+   * Mark the change as work-in-progress. This will also override the {@link #notify} value to
+   * {@link NotifyHandling#OWNER}
+   */
+  public boolean workInProgress;
 }
diff --git a/java/com/google/gerrit/pgm/Daemon.java b/java/com/google/gerrit/pgm/Daemon.java
index 64a6aa3..28407a7 100644
--- a/java/com/google/gerrit/pgm/Daemon.java
+++ b/java/com/google/gerrit/pgm/Daemon.java
@@ -491,12 +491,13 @@
       modules.add(new AccountDeactivator.Module());
       modules.add(new ChangeCleanupRunner.Module());
     }
-    modules.addAll(testSysModules);
     modules.add(new LocalMergeSuperSetComputation.Module());
     modules.add(new DefaultProjectNameLockManager.Module());
-    return cfgInjector.createChildInjector(
-        ModuleOverloader.override(
-            modules, LibModuleLoader.loadModules(cfgInjector, LibModuleType.SYS_MODULE)));
+
+    List<Module> libModules = LibModuleLoader.loadModules(cfgInjector, LibModuleType.SYS_MODULE);
+    libModules.addAll(testSysModules);
+
+    return cfgInjector.createChildInjector(ModuleOverloader.override(modules, libModules));
   }
 
   private Module createIndexModule() {
diff --git a/java/com/google/gerrit/server/ModuleOverloader.java b/java/com/google/gerrit/server/ModuleOverloader.java
index 9a8fe84..6b7b082 100644
--- a/java/com/google/gerrit/server/ModuleOverloader.java
+++ b/java/com/google/gerrit/server/ModuleOverloader.java
@@ -42,7 +42,7 @@
       return modules;
     }
 
-    // swipe cache implementation with alternative provided in lib
+    // swap module implementations with the matching alternative ones provided in lib
     return modules.stream()
         .map(
             m -> {
diff --git a/java/com/google/gerrit/server/config/PluginConfig.java b/java/com/google/gerrit/server/config/PluginConfig.java
index 2459d0b..2b363f1 100644
--- a/java/com/google/gerrit/server/config/PluginConfig.java
+++ b/java/com/google/gerrit/server/config/PluginConfig.java
@@ -156,7 +156,7 @@
 
   public Optional<GroupReference> getGroupReference(String name) {
     String exactName = GroupReference.extractGroupName(getString(name));
-    return groupReferences().values().stream().filter(g -> exactName.equals(g.getName())).findAny();
+    return groupReferences().values().stream().filter(g -> g.getName().equals(exactName)).findAny();
   }
 
   /** Mutable representation of {@link PluginConfig}. Used for updates. */
diff --git a/java/com/google/gerrit/server/git/CommitUtil.java b/java/com/google/gerrit/server/git/CommitUtil.java
index 7ddf16fd..67c7f01 100644
--- a/java/com/google/gerrit/server/git/CommitUtil.java
+++ b/java/com/google/gerrit/server/git/CommitUtil.java
@@ -266,6 +266,9 @@
     RevCommit revertCommit = revWalk.parseCommit(revertCommitId);
     Change changeToRevert = notes.getChange();
     Change.Id changeId = Change.id(seq.nextChangeId());
+    if (input.workInProgress) {
+      input.notify = NotifyHandling.OWNER;
+    }
     NotifyResolver.Result notify =
         notifyResolver.resolve(firstNonNull(input.notify, NotifyHandling.ALL), input.notifyDetails);
 
@@ -285,6 +288,7 @@
     ccs.remove(user.getAccountId());
     ins.setReviewersAndCcs(reviewers, ccs);
     ins.setRevertOf(notes.getChangeId());
+    ins.setWorkInProgress(input.workInProgress);
 
     try (BatchUpdate bu = updateFactory.create(notes.getProjectName(), user, ts)) {
       bu.setRepository(git, revWalk, oi);
diff --git a/java/com/google/gerrit/server/restapi/change/CherryPickChange.java b/java/com/google/gerrit/server/restapi/change/CherryPickChange.java
index 5fab976..fdac552 100644
--- a/java/com/google/gerrit/server/restapi/change/CherryPickChange.java
+++ b/java/com/google/gerrit/server/restapi/change/CherryPickChange.java
@@ -177,6 +177,7 @@
         TimeUtil.nowTs(),
         null,
         null,
+        null,
         null);
   }
 
@@ -208,7 +209,7 @@
       throws IOException, InvalidChangeOperationException, UpdateException, RestApiException,
           ConfigInvalidException, NoSuchProjectException {
     return cherryPick(
-        sourceChange, project, sourceCommit, input, dest, TimeUtil.nowTs(), null, null, null);
+        sourceChange, project, sourceCommit, input, dest, TimeUtil.nowTs(), null, null, null, null);
   }
 
   /**
@@ -249,7 +250,8 @@
       Timestamp timestamp,
       @Nullable Change.Id revertedChange,
       @Nullable ObjectId changeIdForNewChange,
-      @Nullable Change.Id idForNewChange)
+      @Nullable Change.Id idForNewChange,
+      @Nullable Boolean workInProgress)
       throws IOException, InvalidChangeOperationException, UpdateException, RestApiException,
           ConfigInvalidException, NoSuchProjectException {
 
@@ -370,7 +372,8 @@
                     destChanges.get(0).notes(),
                     cherryPickCommit,
                     sourceChange.currentPatchSetId(),
-                    newTopic);
+                    newTopic,
+                    workInProgress);
           } else {
             // Change key not found on destination branch. We can create a new
             // change.
@@ -385,7 +388,8 @@
                     sourceCommit,
                     input,
                     revertedChange,
-                    idForNewChange);
+                    idForNewChange,
+                    workInProgress);
           }
           bu.execute();
           return Result.create(changeId, cherryPickCommit.getFilesWithGitConflicts());
@@ -453,13 +457,17 @@
       ChangeNotes destNotes,
       CodeReviewCommit cherryPickCommit,
       PatchSet.Id sourcePatchSetId,
-      String topic)
+      String topic,
+      @Nullable Boolean workInProgress)
       throws IOException {
     Change destChange = destNotes.getChange();
     PatchSet.Id psId = ChangeUtil.nextPatchSetId(git, destChange.currentPatchSetId());
     PatchSetInserter inserter = patchSetInserterFactory.create(destNotes, psId, cherryPickCommit);
     inserter.setMessage("Uploaded patch set " + inserter.getPatchSetId().get() + ".");
     inserter.setTopic(topic);
+    if (workInProgress != null) {
+      inserter.setWorkInProgress(workInProgress);
+    }
     bu.addOp(destChange.getId(), inserter);
     if (destChange.getCherryPickOf() == null
         || !destChange.getCherryPickOf().equals(sourcePatchSetId)) {
@@ -479,11 +487,19 @@
       @Nullable ObjectId sourceCommit,
       CherryPickInput input,
       @Nullable Change.Id revertOf,
-      @Nullable Change.Id idForNewChange)
+      @Nullable Change.Id idForNewChange,
+      @Nullable Boolean workInProgress)
       throws IOException, InvalidChangeOperationException {
     Change.Id changeId = idForNewChange != null ? idForNewChange : Change.id(seq.nextChangeId());
     ChangeInserter ins = changeInserterFactory.create(changeId, cherryPickCommit, refName);
     ins.setRevertOf(revertOf);
+    if (workInProgress != null) {
+      ins.setWorkInProgress(workInProgress);
+    } else {
+      ins.setWorkInProgress(
+          (sourceChange != null && sourceChange.isWorkInProgress())
+              || !cherryPickCommit.getFilesWithGitConflicts().isEmpty());
+    }
     BranchNameKey sourceBranch = sourceChange == null ? null : sourceChange.getDest();
     PatchSet.Id sourcePatchSetId = sourceChange == null ? null : sourceChange.currentPatchSetId();
     ins.setMessage(
@@ -493,10 +509,7 @@
                 : "Uploaded patch set 1.") // For revert commits, the message should not include
         // cherry-pick information.
         .setTopic(topic)
-        .setCherryPickOf(sourcePatchSetId)
-        .setWorkInProgress(
-            (sourceChange != null && sourceChange.isWorkInProgress())
-                || !cherryPickCommit.getFilesWithGitConflicts().isEmpty());
+        .setCherryPickOf(sourcePatchSetId);
     if (input.keepReviewers && sourceChange != null) {
       ReviewerSet reviewerSet =
           approvalsUtil.getReviewers(changeNotesFactory.createChecked(sourceChange));
diff --git a/java/com/google/gerrit/server/restapi/change/RevertSubmission.java b/java/com/google/gerrit/server/restapi/change/RevertSubmission.java
index 1f89b94..d128186 100644
--- a/java/com/google/gerrit/server/restapi/change/RevertSubmission.java
+++ b/java/com/google/gerrit/server/restapi/change/RevertSubmission.java
@@ -204,6 +204,7 @@
 
     checkPermissionsForAllChanges(changeResource, changeDatas);
     input.topic = createTopic(input.topic, submissionId);
+
     return Response.ok(revertSubmission(changeDatas, input));
   }
 
@@ -259,6 +260,9 @@
       cherryPickInput.base = null;
       Project.NameKey project = projectAndBranch.project();
       cherryPickInput.destination = projectAndBranch.branch();
+      if (revertInput.workInProgress) {
+        cherryPickInput.notify = NotifyHandling.OWNER;
+      }
       Collection<ChangeData> changesInProjectAndBranch =
           changesPerProjectAndBranch.get(projectAndBranch);
 
@@ -333,7 +337,11 @@
       bu.addOp(
           changeNotes.getChange().getId(),
           new CreateCherryPickOp(
-              revCommitId, generatedChangeId, cherryPickRevertChangeId, timestamp));
+              revCommitId,
+              generatedChangeId,
+              cherryPickRevertChangeId,
+              timestamp,
+              revertInput.workInProgress));
       bu.addOp(changeNotes.getChange().getId(), new PostRevertedMessageOp(generatedChangeId));
       bu.addOp(
           cherryPickRevertChangeId,
@@ -550,16 +558,19 @@
     private final ObjectId computedChangeId;
     private final Change.Id cherryPickRevertChangeId;
     private final Timestamp timestamp;
+    private final boolean workInProgress;
 
     CreateCherryPickOp(
         ObjectId revCommitId,
         ObjectId computedChangeId,
         Change.Id cherryPickRevertChangeId,
-        Timestamp timestamp) {
+        Timestamp timestamp,
+        Boolean workInProgress) {
       this.revCommitId = revCommitId;
       this.computedChangeId = computedChangeId;
       this.cherryPickRevertChangeId = cherryPickRevertChangeId;
       this.timestamp = timestamp;
+      this.workInProgress = workInProgress;
     }
 
     @Override
@@ -576,7 +587,8 @@
               timestamp,
               change.getId(),
               computedChangeId,
-              cherryPickRevertChangeId);
+              cherryPickRevertChangeId,
+              workInProgress);
       // save the commit as base for next cherryPick of that branch
       cherryPickInput.base =
           changeNotesFactory
diff --git a/javatests/com/google/gerrit/acceptance/DaemonOverridesTestLibModulesIT.java b/javatests/com/google/gerrit/acceptance/DaemonOverridesTestLibModulesIT.java
new file mode 100644
index 0000000..f3a2324
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/DaemonOverridesTestLibModulesIT.java
@@ -0,0 +1,62 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.server.ModuleImpl;
+import com.google.gerrit.server.audit.AuditModule;
+import com.google.inject.Inject;
+import com.google.inject.Module;
+import com.google.inject.name.Named;
+import com.google.inject.name.Names;
+import org.junit.Test;
+
+public class DaemonOverridesTestLibModulesIT extends AbstractDaemonTest {
+  private static final String TEST_MODULE = "test-module";
+
+  @Inject
+  @Named(value = TEST_MODULE)
+  private String testModuleClassName;
+
+  public abstract static class TestModule extends AuditModule {
+    @Override
+    protected void configure() {
+      super.configure();
+      bind(String.class).annotatedWith(Names.named(TEST_MODULE)).toInstance(getClass().getName());
+    }
+  }
+
+  @ModuleImpl(name = TEST_MODULE)
+  public static class DefaultModule extends TestModule {}
+
+  @ModuleImpl(name = TEST_MODULE)
+  public static class OverriddenModule extends TestModule {}
+
+  @Override
+  public Module createAuditModule() {
+    return new DefaultModule();
+  }
+
+  @Override
+  public Module createModule() {
+    return new OverriddenModule();
+  }
+
+  @Test
+  public void testSysModuleShouldOverrideTheDefaultOneWithSameModuleAnnotation() {
+    assertThat(testModuleClassName).isEqualTo(OverriddenModule.class.getName());
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/change/RevertIT.java b/javatests/com/google/gerrit/acceptance/api/change/RevertIT.java
index 69278b4..a3a089f 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/RevertIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/RevertIT.java
@@ -250,6 +250,17 @@
   }
 
   @Test
+  public void revertChangeWithWip() throws Exception {
+    PushOneCommit.Result r = createChange();
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.approve());
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).submit();
+
+    RevertInput in = createWipRevertInput();
+    ChangeInfo revertChange = gApi.changes().id(r.getChangeId()).revert(in).get();
+    assertThat(revertChange.workInProgress).isTrue();
+  }
+
+  @Test
   public void revertWithDefaultTopic() throws Exception {
     PushOneCommit.Result result = createChange();
     gApi.changes().id(result.getChangeId()).current().review(ReviewInput.approve());
@@ -321,6 +332,18 @@
   }
 
   @Test
+  public void revertNotificationsSupressedOnWip() throws Exception {
+    PushOneCommit.Result r = createChange();
+    gApi.changes().id(r.getChangeId()).addReviewer(user.email());
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.approve());
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).submit();
+
+    sender.clear();
+    gApi.changes().id(r.getChangeId()).revert(createWipRevertInput()).get();
+    assertThat(sender.getMessages()).isEmpty();
+  }
+
+  @Test
   public void suppressRevertNotifications() throws Exception {
     PushOneCommit.Result r = createChange();
     gApi.changes().id(r.getChangeId()).addReviewer(user.email());
@@ -659,6 +682,49 @@
   }
 
   @Test
+  public void revertSubmissionWipNotificationsAreSupressed() throws Exception {
+    String changeId1 = createChange("first change", "a.txt", "message").getChangeId();
+    approve(changeId1);
+    gApi.changes().id(changeId1).addReviewer(user.email());
+    String changeId2 = createChange("second change", "b.txt", "other").getChangeId();
+    approve(changeId2);
+    gApi.changes().id(changeId2).addReviewer(user.email());
+
+    gApi.changes().id(changeId2).current().submit();
+
+    sender.clear();
+
+    RevertInput revertInput = createWipRevertInput();
+    // Setting the Notifications to ALL will be overridden because the WIP flag overrides the
+    // notifications to OWNER
+    revertInput.notify = NotifyHandling.ALL;
+    gApi.changes().id(changeId2).revertSubmission(revertInput);
+
+    assertThat(sender.getMessages()).isEmpty();
+  }
+
+  @Test
+  public void revertSubmissionWipMarksAllChangesAsWip() throws Exception {
+    String changeId1 = createChange("first change", "a.txt", "message").getChangeId();
+    approve(changeId1);
+    gApi.changes().id(changeId1).addReviewer(user.email());
+    String changeId2 = createChange("second change", "b.txt", "other").getChangeId();
+    approve(changeId2);
+    gApi.changes().id(changeId2).addReviewer(user.email());
+
+    gApi.changes().id(changeId2).current().submit();
+
+    sender.clear();
+
+    RevertInput revertInput = createWipRevertInput();
+    RevertSubmissionInfo revertSubmissionInfo =
+        gApi.changes().id(changeId2).revertSubmission(revertInput);
+
+    assertThat(revertSubmissionInfo.revertChanges.stream().allMatch(r -> r.workInProgress))
+        .isTrue();
+  }
+
+  @Test
   public void revertSubmissionIdenticalTreeIsAllowed() throws Exception {
     String unrelatedChange = createChange("change1", "a.txt", "message").getChangeId();
     approve(unrelatedChange);
@@ -1294,4 +1360,10 @@
     }
     return results;
   }
+
+  private RevertInput createWipRevertInput() {
+    RevertInput input = new RevertInput();
+    input.workInProgress = true;
+    return input;
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/git/PushPermissionsIT.java b/javatests/com/google/gerrit/acceptance/git/PushPermissionsIT.java
index 9638658..1a2ae7c 100644
--- a/javatests/com/google/gerrit/acceptance/git/PushPermissionsIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/PushPermissionsIT.java
@@ -82,14 +82,17 @@
         .update();
   }
 
+  @SuppressWarnings("TruthIncompatibleType")
   @Test
   public void mixingMagicAndRegularPush() throws Exception {
     testRepo.branch("HEAD").commit().create();
     PushResult r = push("HEAD:refs/heads/master", "HEAD:refs/for/master");
 
     String msg = "cannot combine normal pushes and magic pushes";
-    assertThat(r.getRemoteUpdate("refs/heads/master")).isNotEqualTo(Status.OK);
-    assertThat(r.getRemoteUpdate("refs/for/master")).isNotEqualTo(Status.OK);
+    assertThat(r.getRemoteUpdate("refs/heads/master"))
+        .isNotEqualTo(/* expected: RemoteRefUpdate, actual: Status */ Status.OK);
+    assertThat(r.getRemoteUpdate("refs/for/master"))
+        .isNotEqualTo(/* expected: RemoteRefUpdate, actual: Status */ Status.OK);
     assertThat(r.getRemoteUpdate("refs/for/master").getMessage()).isEqualTo(msg);
   }
 
diff --git a/javatests/com/google/gerrit/server/cache/PerThreadCacheTest.java b/javatests/com/google/gerrit/server/cache/PerThreadCacheTest.java
index d19073d..5d420d3 100644
--- a/javatests/com/google/gerrit/server/cache/PerThreadCacheTest.java
+++ b/javatests/com/google/gerrit/server/cache/PerThreadCacheTest.java
@@ -21,12 +21,16 @@
 import org.junit.Test;
 
 public class PerThreadCacheTest {
+
+  @SuppressWarnings("TruthIncompatibleType")
   @Test
   public void key_respectsClass() {
     assertThat(PerThreadCache.Key.create(String.class))
         .isEqualTo(PerThreadCache.Key.create(String.class));
     assertThat(PerThreadCache.Key.create(String.class))
-        .isNotEqualTo(PerThreadCache.Key.create(Integer.class));
+        .isNotEqualTo(
+            /* expected: Key<String>, actual: Key<Integer> */ PerThreadCache.Key.create(
+                Integer.class));
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/server/restapi/change/ListChangeCommentsTest.java b/javatests/com/google/gerrit/server/restapi/change/ListChangeCommentsTest.java
index daefd7c..5cefe74 100644
--- a/javatests/com/google/gerrit/server/restapi/change/ListChangeCommentsTest.java
+++ b/javatests/com/google/gerrit/server/restapi/change/ListChangeCommentsTest.java
@@ -32,6 +32,8 @@
 
 @RunWith(JUnit4.class)
 public class ListChangeCommentsTest {
+
+  @SuppressWarnings("TruthIncompatibleType")
   @Test
   public void commentsLinkedToChangeMessagesIgnoreGerritAutoGenTaggedMessages() {
     /* Comments should not be linked to Gerrit's autogenerated messages */
@@ -54,7 +56,9 @@
 
     // Make sure no comment is linked to the auto-gen message
     assertThat(comments.stream().map(c -> c.changeMessageId).collect(Collectors.toSet()))
-        .doesNotContain(getChangeMessage(changeMessages, "cmAutoGenByGerrit"));
+        .doesNotContain(
+            /* expected: String, actual: ChangeMessage */ getChangeMessage(
+                changeMessages, "cmAutoGenByGerrit"));
   }
 
   @Test
diff --git a/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header.js b/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header.js
deleted file mode 100644
index 7901c53..0000000
--- a/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header.js
+++ /dev/null
@@ -1,116 +0,0 @@
-/**
- * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../../../styles/shared-styles.js';
-import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator.js';
-import '../../plugins/gr-endpoint-param/gr-endpoint-param.js';
-import '../../shared/gr-avatar/gr-avatar.js';
-import '../../shared/gr-date-formatter/gr-date-formatter.js';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
-import '../../../styles/dashboard-header-styles.js';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
-import {PolymerElement} from '@polymer/polymer/polymer-element.js';
-import {htmlTemplate} from './gr-user-header_html.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-
-/**
- * @extends PolymerElement
- */
-class GrUserHeader extends GestureEventListeners(
-    LegacyElementMixin(
-        PolymerElement)) {
-  static get template() { return htmlTemplate; }
-
-  static get is() { return 'gr-user-header'; }
-
-  static get properties() {
-    return {
-    /** @type {?string} */
-      userId: {
-        type: String,
-        observer: '_accountChanged',
-      },
-
-      showDashboardLink: {
-        type: Boolean,
-        value: false,
-      },
-
-      loggedIn: {
-        type: Boolean,
-        value: false,
-      },
-
-      /**
-       * @type {?{name: ?, email: ?, registered_on: ?}}
-       */
-      _accountDetails: {
-        type: Object,
-        value: null,
-      },
-
-      /** @type {?string} */
-      _status: {
-        type: String,
-        value: null,
-      },
-    };
-  }
-
-  _accountChanged(userId) {
-    if (!userId) {
-      this._accountDetails = null;
-      this._status = null;
-      return;
-    }
-
-    this.$.restAPI.getAccountDetails(userId).then(details => {
-      this._accountDetails = details;
-    });
-    this.$.restAPI.getAccountStatus(userId).then(status => {
-      this._status = status;
-    });
-  }
-
-  _computeDisplayClass(status) {
-    return status ? ' ' : 'hide';
-  }
-
-  _computeDetail(accountDetails, name) {
-    return accountDetails ? accountDetails[name] : '';
-  }
-
-  _computeStatusClass(accountDetails) {
-    return this._computeDetail(accountDetails, 'status') ? '' : 'hide';
-  }
-
-  _computeDashboardUrl(accountDetails) {
-    if (!accountDetails) { return null; }
-    const id = accountDetails._account_id;
-    const email = accountDetails.email;
-    if (!id && !email ) { return null; }
-    return GerritNav.getUrlForUserDashboard(id ? id : email);
-  }
-
-  _computeDashboardLinkClass(showDashboardLink, loggedIn) {
-    return showDashboardLink && loggedIn ?
-      'dashboardLink' : 'dashboardLink hide';
-  }
-}
-
-customElements.define(GrUserHeader.is, GrUserHeader);
diff --git a/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header.ts b/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header.ts
new file mode 100644
index 0000000..25369b8
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header.ts
@@ -0,0 +1,113 @@
+/**
+ * @license
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../styles/shared-styles';
+import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
+import '../../plugins/gr-endpoint-param/gr-endpoint-param';
+import '../../shared/gr-avatar/gr-avatar';
+import '../../shared/gr-date-formatter/gr-date-formatter';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../../../styles/dashboard-header-styles';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-user-header_html';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {customElement, property} from '@polymer/decorators';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {AccountDetailInfo, AccountId} from '../../../types/common';
+
+export interface GrUserHeader {
+  $: {
+    restAPI: RestApiService & Element;
+  };
+}
+
+@customElement('gr-user-header')
+export class GrUserHeader extends GestureEventListeners(
+  LegacyElementMixin(PolymerElement)
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  @property({type: String, observer: '_accountChanged'})
+  userId?: AccountId;
+
+  @property({type: Boolean})
+  showDashboardLink = false;
+
+  @property({type: Boolean})
+  loggedIn = false;
+
+  @property({type: Object})
+  _accountDetails: AccountDetailInfo | null = null;
+
+  @property({type: String})
+  _status = '';
+
+  _accountChanged(userId?: AccountId) {
+    if (!userId) {
+      this._accountDetails = null;
+      this._status = '';
+      return;
+    }
+
+    this.$.restAPI.getAccountDetails(userId).then(details => {
+      this._accountDetails = details ?? null;
+      this._status = details?.status ?? '';
+    });
+  }
+
+  _computeDetail(
+    accountDetails: AccountDetailInfo | null,
+    name: keyof AccountDetailInfo
+  ) {
+    return accountDetails ? accountDetails[name] : '';
+  }
+
+  _computeStatusClass(status: string) {
+    return status ? '' : 'hide';
+  }
+
+  _computeDashboardUrl(accountDetails: AccountDetailInfo | null) {
+    if (!accountDetails) {
+      return null;
+    }
+    const id = accountDetails._account_id;
+    if (id) {
+      return GerritNav.getUrlForUserDashboard(String(id));
+    }
+    const email = accountDetails.email;
+    if (email) {
+      return GerritNav.getUrlForUserDashboard(email);
+    }
+    return null;
+  }
+
+  _computeDashboardLinkClass(showDashboardLink: boolean, loggedIn: boolean) {
+    return showDashboardLink && loggedIn
+      ? 'dashboardLink'
+      : 'dashboardLink hide';
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-user-header': GrUserHeader;
+  }
+}
diff --git a/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header_html.ts b/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header_html.ts
index 72bdca6..136835d 100644
--- a/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header_html.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header_html.ts
@@ -37,7 +37,7 @@
       [[_computeDetail(_accountDetails, 'name')]]
     </h1>
     <hr />
-    <div class$="status [[_computeStatusClass(_accountDetails)]]">
+    <div class$="status [[_computeStatusClass(_status)]]">
       <span>Status:</span> [[_status]]
     </div>
     <div>
diff --git a/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header_test.js b/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header_test.js
index 6baacef..15fbf8b 100644
--- a/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header_test.js
+++ b/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header_test.js
@@ -32,10 +32,9 @@
         .returns(Promise.resolve({
           name: 'foo',
           email: 'bar',
+          status: 'OOO',
           registered_on: '2015-03-12 18:32:08.000000000',
         }));
-    sinon.stub(element.$.restAPI, 'getAccountStatus')
-        .returns(Promise.resolve('baz'));
 
     element.userId = 'foo.bar@baz';
     flush(() => {
@@ -46,7 +45,7 @@
       flush(() => {
         flushAsynchronousOperations();
         assert.isNull(element._accountDetails);
-        assert.isNull(element._status);
+        assert.equal(element._status, '');
 
         done();
       });
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 b311efc..f0b8372 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
@@ -753,6 +753,7 @@
     class="scrollable"
     no-cancel-on-outside-click=""
     no-cancel-on-esc-key=""
+    scroll-action="lock"
     with-backdrop=""
   >
     <gr-reply-dialog
diff --git a/polygerrit-ui/app/elements/change/gr-message/gr-message.js b/polygerrit-ui/app/elements/change/gr-message/gr-message.js
deleted file mode 100644
index 0da687f..0000000
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message.js
+++ /dev/null
@@ -1,430 +0,0 @@
-/**
- * @license
- * Copyright (C) 2015 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '@polymer/iron-icon/iron-icon.js';
-import '../../shared/gr-account-label/gr-account-label.js';
-import '../../shared/gr-account-chip/gr-account-chip.js';
-import '../../shared/gr-button/gr-button.js';
-import '../../shared/gr-date-formatter/gr-date-formatter.js';
-import '../../shared/gr-formatted-text/gr-formatted-text.js';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
-import '../../../styles/shared-styles.js';
-import '../../../styles/gr-voting-styles.js';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
-import {PolymerElement} from '@polymer/polymer/polymer-element.js';
-import {htmlTemplate} from './gr-message_html.js';
-import {SpecialFilePath} from '../../../constants/constants.js';
-
-const PATCH_SET_PREFIX_PATTERN = /^Patch Set \d+:\s*(.*)/;
-const LABEL_TITLE_SCORE_PATTERN = /^(-?)([A-Za-z0-9-]+?)([+-]\d+)?$/;
-
-/**
- * @extends PolymerElement
- */
-class GrMessage extends GestureEventListeners(
-    LegacyElementMixin(PolymerElement)) {
-  static get template() { return htmlTemplate; }
-
-  static get is() { return 'gr-message'; }
-  /**
-   * Fired when this message's reply link is tapped.
-   *
-   * @event reply
-   */
-
-  /**
-   * Fired when the message's timestamp is tapped.
-   *
-   * @event message-anchor-tap
-   */
-
-  /**
-   * Fired when a change message is deleted.
-   *
-   * @event change-message-deleted
-   */
-
-  static get properties() {
-    return {
-      /** @type {?} */
-      change: Object,
-      changeNum: Number,
-      /** @type {?} */
-      message: Object,
-      author: {
-        type: Object,
-        computed: '_computeAuthor(message)',
-      },
-      /**
-       * TODO(taoalpha): remove once the change log experiment is launched
-       *
-       * @type {Object} - a map on file and comments on it
-       */
-      comments: {
-        type: Object,
-      },
-      config: Object,
-      hideAutomated: {
-        type: Boolean,
-        value: false,
-      },
-      hidden: {
-        type: Boolean,
-        computed: '_computeIsHidden(hideAutomated, isAutomated)',
-        reflectToAttribute: true,
-      },
-      isAutomated: {
-        type: Boolean,
-        computed: '_computeIsAutomated(message)',
-      },
-      showOnBehalfOf: {
-        type: Boolean,
-        computed: '_computeShowOnBehalfOf(message)',
-      },
-      showReplyButton: {
-        type: Boolean,
-        computed: '_computeShowReplyButton(message, _loggedIn)',
-      },
-      projectName: {
-        type: String,
-        observer: '_projectNameChanged',
-      },
-
-      /**
-       * A mapping from label names to objects representing the minimum and
-       * maximum possible values for that label.
-       */
-      labelExtremes: Object,
-
-      /**
-       * @type {{ commentlinks: Array }}
-       */
-      _projectConfig: Object,
-      // Computed property needed to trigger Polymer value observing.
-      _expanded: {
-        type: Object,
-        computed: '_computeExpanded(message.expanded)',
-      },
-      _messageContentExpanded: {
-        type: String,
-        computed:
-            '_computeMessageContentExpanded(message.message, message.tag)',
-      },
-      _messageContentCollapsed: {
-        type: String,
-        computed:
-            '_computeMessageContentCollapsed(message.message, message.tag,' +
-            ' message.commentThreads)',
-      },
-      _commentCountText: {
-        type: Number,
-        computed: '_computeCommentCountText(message.commentThreads.length)',
-      },
-      _loggedIn: {
-        type: Boolean,
-        value: false,
-      },
-      _isAdmin: {
-        type: Boolean,
-        value: false,
-      },
-      _isDeletingChangeMsg: {
-        type: Boolean,
-        value: false,
-      },
-    };
-  }
-
-  static get observers() {
-    return [
-      '_updateExpandedClass(message.expanded)',
-    ];
-  }
-
-  constructor() {
-    super();
-  }
-
-  /** @override */
-  created() {
-    super.created();
-    this.addEventListener('click',
-        e => this._handleClick(e));
-  }
-
-  /** @override */
-  ready() {
-    super.ready();
-    this.$.restAPI.getConfig().then(config => {
-      this.config = config;
-    });
-    this.$.restAPI.getLoggedIn().then(loggedIn => {
-      this._loggedIn = loggedIn;
-    });
-    this.$.restAPI.getIsAdmin().then(isAdmin => {
-      this._isAdmin = isAdmin;
-    });
-  }
-
-  _updateExpandedClass(expanded) {
-    if (expanded) {
-      this.classList.add('expanded');
-    } else {
-      this.classList.remove('expanded');
-    }
-  }
-
-  _computeCommentCountText(threadsLength) {
-    if (threadsLength === 0) {
-      return undefined;
-    } else if (threadsLength === 1) {
-      return '1 comment';
-    } else {
-      return `${threadsLength} comments`;
-    }
-  }
-
-  _onThreadListModified() {
-    // TODO(taoalpha): this won't propagate the changes to the files
-    // should consider replacing this with either top level events
-    // or gerrit level events
-
-    // emit the event so change-view can also get updated with latest changes
-    this.dispatchEvent(new CustomEvent('comment-refresh', {
-      composed: true, bubbles: true,
-    }));
-  }
-
-  _computeMessageContentExpanded(content, tag) {
-    return this._computeMessageContent(content, tag, true);
-  }
-
-  _patchsetCommentSummary(commentThreads) {
-    const id = this.message.id;
-    if (!id) return '';
-    const patchsetThreads = commentThreads.filter(thread =>
-      thread.path === SpecialFilePath.PATCHSET_LEVEL_COMMENTS);
-    for (const thread of patchsetThreads) {
-      // Find if there was a patchset level comment created through the reply
-      // dialog and use it to determine the summary
-      if (thread.comments[0].change_message_id === id) {
-        return thread.comments[0].message;
-      }
-    }
-    // Find if there is a reply to some patchset comment left
-    for (const thread of patchsetThreads) {
-      for (const comment of thread.comments) {
-        if (comment.change_message_id === id) { return comment.message; }
-      }
-    }
-    return '';
-  }
-
-  _computeMessageContentCollapsed(content, tag, commentThreads) {
-    const summary =
-      this._computeMessageContent(content, tag, false);
-    if (summary || !commentThreads) return summary;
-    return this._patchsetCommentSummary(commentThreads);
-  }
-
-  _computeMessageContent(content, tag, isExpanded) {
-    content = content || '';
-    tag = tag || '';
-    const isNewPatchSet = tag.endsWith(':newPatchSet') ||
-        tag.endsWith(':newWipPatchSet');
-    const lines = content.split('\n');
-    const filteredLines = lines.filter(line => {
-      if (!isExpanded && line.startsWith('>')) {
-        return false;
-      }
-      if (line.startsWith('(') && line.endsWith(' comment)')) {
-        return false;
-      }
-      if (line.startsWith('(') && line.endsWith(' comments)')) {
-        return false;
-      }
-      if (!isNewPatchSet && line.match(PATCH_SET_PREFIX_PATTERN)) {
-        return false;
-      }
-      return true;
-    });
-    const mappedLines = filteredLines.map(line => {
-      // The change message formatting is not very consistent, so
-      // unfortunately we have to do a bit of tweaking here:
-      //   Labels should be stripped from lines like this:
-      //     Patch Set 29: Verified+1
-      //   Rebase messages (which have a ':newPatchSet' tag) should be kept on
-      //   lines like this:
-      //     Patch Set 27: Patch Set 26 was rebased
-      if (isNewPatchSet) {
-        line = line.replace(PATCH_SET_PREFIX_PATTERN, '$1');
-      }
-      return line;
-    });
-    return mappedLines.join('\n').trim();
-  }
-
-  _computeAuthor(message) {
-    return message.author || message.updated_by;
-  }
-
-  _computeShowOnBehalfOf(message) {
-    const author = message.author || message.updated_by;
-    return !!(author && message.real_author &&
-        author._account_id != message.real_author._account_id);
-  }
-
-  _computeShowReplyButton(message, loggedIn) {
-    return message && !!message.message && loggedIn &&
-        !this._computeIsAutomated(message);
-  }
-
-  _computeExpanded(expanded) {
-    return expanded;
-  }
-
-  _handleClick(e) {
-    if (this.message.expanded) { return; }
-    e.stopPropagation();
-    this.set('message.expanded', true);
-  }
-
-  _handleAuthorClick(e) {
-    if (!this.message.expanded) { return; }
-    e.stopPropagation();
-    this.set('message.expanded', false);
-  }
-
-  _computeIsAutomated(message) {
-    return !!(message.reviewer ||
-        this._computeIsReviewerUpdate(message) ||
-        (message.tag && message.tag.startsWith('autogenerated')));
-  }
-
-  _computeIsHidden(hideAutomated, isAutomated) {
-    return hideAutomated && isAutomated;
-  }
-
-  _computeIsReviewerUpdate(message) {
-    return message.type === 'REVIEWER_UPDATE';
-  }
-
-  _getScores(message, labelExtremes) {
-    if (!message || !message.message || !labelExtremes) {
-      return [];
-    }
-    const line = message.message.split('\n', 1)[0];
-    const patchSetPrefix = PATCH_SET_PREFIX_PATTERN;
-    if (!line.match(patchSetPrefix)) {
-      return [];
-    }
-    const scoresRaw = line.split(patchSetPrefix)[1];
-    if (!scoresRaw) {
-      return [];
-    }
-    return scoresRaw.split(' ')
-        .map(s => s.match(LABEL_TITLE_SCORE_PATTERN))
-        .filter(ms =>
-          ms && ms.length === 4 && labelExtremes.hasOwnProperty(ms[2]))
-        .map(ms => {
-          const label = ms[2];
-          const value = ms[1] === '-' ? 'removed' : ms[3];
-          return {label, value};
-        });
-  }
-
-  _computeScoreClass(score, labelExtremes) {
-    // Polymer 2: check for undefined
-    if ([score, labelExtremes].includes(undefined)) {
-      return '';
-    }
-    if (score.value === 'removed') {
-      return 'removed';
-    }
-    const classes = [];
-    if (score.value > 0) {
-      classes.push('positive');
-    } else if (score.value < 0) {
-      classes.push('negative');
-    }
-    const extremes = labelExtremes[score.label];
-    if (extremes) {
-      const intScore = parseInt(score.value, 10);
-      if (intScore === extremes.max) {
-        classes.push('max');
-      } else if (intScore === extremes.min) {
-        classes.push('min');
-      }
-    }
-    return classes.join(' ');
-  }
-
-  _computeClass(expanded) {
-    const classes = [];
-    classes.push(expanded ? 'expanded' : 'collapsed');
-    return classes.join(' ');
-  }
-
-  _handleAnchorClick(e) {
-    e.preventDefault();
-    this.dispatchEvent(new CustomEvent('message-anchor-tap', {
-      bubbles: true,
-      composed: true,
-      detail: {id: this.message.id},
-    }));
-  }
-
-  _handleReplyTap(e) {
-    e.preventDefault();
-    this.dispatchEvent(new CustomEvent('reply', {
-      detail: {message: this.message},
-      composed: true, bubbles: true,
-    }));
-  }
-
-  _handleDeleteMessage(e) {
-    e.preventDefault();
-    if (!this.message || !this.message.id) return;
-    this._isDeletingChangeMsg = true;
-    this.$.restAPI.deleteChangeCommitMessage(this.changeNum, this.message.id)
-        .then(() => {
-          this._isDeletingChangeMsg = false;
-          this.dispatchEvent(new CustomEvent('change-message-deleted', {
-            detail: {message: this.message},
-            composed: true, bubbles: true,
-          }));
-        });
-  }
-
-  _projectNameChanged(name) {
-    this.$.restAPI.getProjectConfig(name).then(config => {
-      this._projectConfig = config;
-    });
-  }
-
-  _computeExpandToggleIcon(expanded) {
-    return expanded ? 'gr-icons:expand-less' : 'gr-icons:expand-more';
-  }
-
-  _toggleExpanded(e) {
-    e.stopPropagation();
-    this.set('message.expanded', !this.message.expanded);
-  }
-}
-
-customElements.define(GrMessage.is, GrMessage);
diff --git a/polygerrit-ui/app/elements/change/gr-message/gr-message.ts b/polygerrit-ui/app/elements/change/gr-message/gr-message.ts
new file mode 100644
index 0000000..2b5d94d
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message.ts
@@ -0,0 +1,493 @@
+/**
+ * @license
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '@polymer/iron-icon/iron-icon';
+import '../../shared/gr-account-label/gr-account-label';
+import '../../shared/gr-account-chip/gr-account-chip';
+import '../../shared/gr-button/gr-button';
+import '../../shared/gr-date-formatter/gr-date-formatter';
+import '../../shared/gr-formatted-text/gr-formatted-text';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../../../styles/shared-styles';
+import '../../../styles/gr-voting-styles';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-message_html';
+import {SpecialFilePath} from '../../../constants/constants';
+import {customElement, property, computed, observe} from '@polymer/decorators';
+import {
+  ChangeInfo,
+  ChangeMessageInfo,
+  ServerInfo,
+  ConfigInfo,
+  RepoName,
+  ReviewInputTag,
+  VotingRangeInfo,
+} from '../../../types/common';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {CommentThread} from '../../diff/gr-comment-api/gr-comment-api';
+import {hasOwnProperty} from '../../../utils/common-util';
+
+const PATCH_SET_PREFIX_PATTERN = /^Patch Set \d+:\s*(.*)/;
+const LABEL_TITLE_SCORE_PATTERN = /^(-?)([A-Za-z0-9-]+?)([+-]\d+)?$/;
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-message': GrMessage;
+  }
+}
+
+export interface GrMessage {
+  $: {
+    restAPI: RestApiService & Element;
+  };
+}
+
+interface ChangeMessage extends ChangeMessageInfo {
+  // TODO(TS): maybe should be an enum instead
+  type: string;
+  expanded: boolean;
+  commentThreads: CommentThread[];
+}
+
+export type LabelExtreme = {[labelName: string]: VotingRangeInfo};
+
+interface Score {
+  label?: string;
+  value?: string;
+}
+
+@customElement('gr-message')
+export class GrMessage extends GestureEventListeners(
+  LegacyElementMixin(PolymerElement)
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  /**
+   * Fired when this message's reply link is tapped.
+   *
+   * @event reply
+   */
+
+  /**
+   * Fired when the message's timestamp is tapped.
+   *
+   * @event message-anchor-tap
+   */
+
+  /**
+   * Fired when a change message is deleted.
+   *
+   * @event change-message-deleted
+   */
+
+  @property({type: Object})
+  change?: ChangeInfo;
+
+  @property({type: Number})
+  changeNum?: number;
+
+  @property({type: Object})
+  message: ChangeMessage | undefined;
+
+  @computed('message')
+  get author() {
+    return this.message?.author || this.message?.updated_by;
+  }
+
+  @property({type: Object})
+  config?: ServerInfo;
+
+  @property({type: Boolean})
+  hideAutomated = false;
+
+  @property({
+    type: Boolean,
+    reflectToAttribute: true,
+    computed: '_computeIsHidden(hideAutomated, isAutomated)',
+  })
+  hidden = false;
+
+  @computed('message')
+  get isAutomated() {
+    return !!this.message && this._computeIsAutomated(this.message);
+  }
+
+  @computed('message')
+  get showOnBehalfOf() {
+    return !!this.message && this._computeShowOnBehalfOf(this.message);
+  }
+
+  @property({
+    type: Boolean,
+    computed: '_computeShowReplyButton(message, _loggedIn)',
+  })
+  showReplyButton = false;
+
+  @property({type: String})
+  projectName?: string;
+
+  /**
+   * A mapping from label names to objects representing the minimum and
+   * maximum possible values for that label.
+   */
+  @property({type: Object})
+  labelExtremes?: LabelExtreme;
+
+  @property({type: Object})
+  _projectConfig?: ConfigInfo;
+
+  @property({type: Boolean})
+  _loggedIn = false;
+
+  @property({type: Boolean})
+  _isAdmin = false;
+
+  @property({type: Boolean})
+  _isDeletingChangeMsg = false;
+
+  @property({type: Boolean, computed: '_computeExpanded(message.expanded)'})
+  _expanded = false;
+
+  @property({
+    type: String,
+    computed: '_computeMessageContentExpanded(message.message, message.tag)',
+  })
+  _messageContentExpanded = '';
+
+  @property({
+    type: String,
+    computed:
+      '_computeMessageContentCollapsed(message.message, message.tag,' +
+      ' message.commentThreads)',
+  })
+  _messageContentCollapsed = '';
+
+  @property({
+    type: String,
+    computed: '_computeCommentCountText(message.commentThreads.length)',
+  })
+  _commentCountText = '';
+
+  created() {
+    super.created();
+    this.addEventListener('click', e => this._handleClick(e));
+  }
+
+  attached() {
+    super.attached();
+    this.$.restAPI.getConfig().then(config => {
+      this.config = config;
+    });
+    this.$.restAPI.getLoggedIn().then(loggedIn => {
+      this._loggedIn = loggedIn;
+    });
+    this.$.restAPI.getIsAdmin().then(isAdmin => {
+      this._isAdmin = !!isAdmin;
+    });
+  }
+
+  @observe('message.expanded')
+  _updateExpandedClass(expanded: boolean) {
+    if (expanded) {
+      this.classList.add('expanded');
+    } else {
+      this.classList.remove('expanded');
+    }
+  }
+
+  _computeCommentCountText(threadsLength?: number) {
+    if (threadsLength === 0) {
+      return undefined;
+    } else if (threadsLength === 1) {
+      return '1 comment';
+    } else {
+      return `${threadsLength} comments`;
+    }
+  }
+
+  _onThreadListModified() {
+    // TODO(taoalpha): this won't propagate the changes to the files
+    // should consider replacing this with either top level events
+    // or gerrit level events
+
+    // emit the event so change-view can also get updated with latest changes
+    this.dispatchEvent(
+      new CustomEvent('comment-refresh', {
+        composed: true,
+        bubbles: true,
+      })
+    );
+  }
+
+  _computeMessageContentExpanded(content?: string, tag?: ReviewInputTag) {
+    return this._computeMessageContent(content, tag, true);
+  }
+
+  _patchsetCommentSummary(commentThreads: CommentThread[] = []) {
+    const id = this.message?.id;
+    if (!id) return '';
+    const patchsetThreads = commentThreads.filter(
+      thread => thread.path === SpecialFilePath.PATCHSET_LEVEL_COMMENTS
+    );
+    for (const thread of patchsetThreads) {
+      // Find if there was a patchset level comment created through the reply
+      // dialog and use it to determine the summary
+      if (thread.comments[0].change_message_id === id) {
+        return thread.comments[0].message;
+      }
+    }
+    // Find if there is a reply to some patchset comment left
+    for (const thread of patchsetThreads) {
+      for (const comment of thread.comments) {
+        if (comment.change_message_id === id) {
+          return comment.message;
+        }
+      }
+    }
+    return '';
+  }
+
+  _computeMessageContentCollapsed(
+    content?: string,
+    tag?: ReviewInputTag,
+    commentThreads?: CommentThread[]
+  ) {
+    const summary = this._computeMessageContent(content, tag, false);
+    if (summary || !commentThreads) return summary;
+    return this._patchsetCommentSummary(commentThreads);
+  }
+
+  _computeMessageContent(
+    content = '',
+    tag: ReviewInputTag = '' as ReviewInputTag,
+    isExpanded: boolean
+  ) {
+    const isNewPatchSet =
+      tag.endsWith(':newPatchSet') || tag.endsWith(':newWipPatchSet');
+    const lines = content.split('\n');
+    const filteredLines = lines.filter(line => {
+      if (!isExpanded && line.startsWith('>')) {
+        return false;
+      }
+      if (line.startsWith('(') && line.endsWith(' comment)')) {
+        return false;
+      }
+      if (line.startsWith('(') && line.endsWith(' comments)')) {
+        return false;
+      }
+      if (!isNewPatchSet && line.match(PATCH_SET_PREFIX_PATTERN)) {
+        return false;
+      }
+      return true;
+    });
+    const mappedLines = filteredLines.map(line => {
+      // The change message formatting is not very consistent, so
+      // unfortunately we have to do a bit of tweaking here:
+      //   Labels should be stripped from lines like this:
+      //     Patch Set 29: Verified+1
+      //   Rebase messages (which have a ':newPatchSet' tag) should be kept on
+      //   lines like this:
+      //     Patch Set 27: Patch Set 26 was rebased
+      if (isNewPatchSet) {
+        line = line.replace(PATCH_SET_PREFIX_PATTERN, '$1');
+      }
+      return line;
+    });
+    return mappedLines.join('\n').trim();
+  }
+
+  _computeAuthor(message: ChangeMessage) {
+    return message.author || message.updated_by;
+  }
+
+  _computeShowOnBehalfOf(message: ChangeMessage) {
+    const author = this._computeAuthor(message);
+    return !!(
+      author &&
+      message.real_author &&
+      author._account_id !== message.real_author._account_id
+    );
+  }
+
+  _computeShowReplyButton(message?: ChangeMessage, loggedIn?: boolean) {
+    return (
+      message &&
+      !!message.message &&
+      loggedIn &&
+      !this._computeIsAutomated(message)
+    );
+  }
+
+  _computeExpanded(expanded: boolean) {
+    return expanded;
+  }
+
+  _handleClick(e: Event) {
+    if (this.message?.expanded) {
+      return;
+    }
+    e.stopPropagation();
+    this.set('message.expanded', true);
+  }
+
+  _handleAuthorClick(e: Event) {
+    if (!this.message?.expanded) {
+      return;
+    }
+    e.stopPropagation();
+    this.set('message.expanded', false);
+  }
+
+  _computeIsAutomated(message: ChangeMessage) {
+    return !!(
+      message.reviewer ||
+      this._computeIsReviewerUpdate(message) ||
+      (message.tag && message.tag.startsWith('autogenerated'))
+    );
+  }
+
+  _computeIsHidden(hideAutomated: boolean, isAutomated: boolean) {
+    return hideAutomated && isAutomated;
+  }
+
+  _computeIsReviewerUpdate(message: ChangeMessage) {
+    return message.type === 'REVIEWER_UPDATE';
+  }
+
+  _getScores(message?: ChangeMessage, labelExtremes?: LabelExtreme): Score[] {
+    if (!message || !message.message || !labelExtremes) {
+      return [];
+    }
+    const line = message.message.split('\n', 1)[0];
+    const patchSetPrefix = PATCH_SET_PREFIX_PATTERN;
+    if (!line.match(patchSetPrefix)) {
+      return [];
+    }
+    const scoresRaw = line.split(patchSetPrefix)[1];
+    if (!scoresRaw) {
+      return [];
+    }
+    return scoresRaw
+      .split(' ')
+      .map(s => s.match(LABEL_TITLE_SCORE_PATTERN))
+      .filter(
+        ms => ms && ms.length === 4 && hasOwnProperty(labelExtremes, ms[2])
+      )
+      .map(ms => {
+        const label = ms?.[2];
+        const value = ms?.[1] === '-' ? 'removed' : ms?.[3];
+        return {label, value};
+      });
+  }
+
+  _computeScoreClass(score?: Score, labelExtremes?: LabelExtreme) {
+    // Polymer 2: check for undefined
+    if (score === undefined || labelExtremes === undefined) {
+      return '';
+    }
+    if (!score.value) {
+      return '';
+    }
+    if (score.value === 'removed') {
+      return 'removed';
+    }
+    const classes = [];
+    if (Number(score.value) > 0) {
+      classes.push('positive');
+    } else if (Number(score.value) < 0) {
+      classes.push('negative');
+    }
+    if (score.label) {
+      const extremes = labelExtremes[score.label];
+      if (extremes) {
+        const intScore = Number(score.value);
+        if (intScore === extremes.max) {
+          classes.push('max');
+        } else if (intScore === extremes.min) {
+          classes.push('min');
+        }
+      }
+    }
+    return classes.join(' ');
+  }
+
+  _computeClass(expanded: boolean) {
+    const classes = [];
+    classes.push(expanded ? 'expanded' : 'collapsed');
+    return classes.join(' ');
+  }
+
+  _handleAnchorClick(e: Event) {
+    e.preventDefault();
+    this.dispatchEvent(
+      new CustomEvent('message-anchor-tap', {
+        bubbles: true,
+        composed: true,
+        detail: {id: this.message?.id},
+      })
+    );
+  }
+
+  _handleReplyTap(e: Event) {
+    e.preventDefault();
+    this.dispatchEvent(
+      new CustomEvent('reply', {
+        detail: {message: this.message},
+        composed: true,
+        bubbles: true,
+      })
+    );
+  }
+
+  _handleDeleteMessage(e: Event) {
+    e.preventDefault();
+    if (!this.message || !this.message.id || !this.changeNum) return;
+    this._isDeletingChangeMsg = true;
+    this.$.restAPI
+      .deleteChangeCommitMessage(this.changeNum, this.message.id)
+      .then(() => {
+        this._isDeletingChangeMsg = false;
+        this.dispatchEvent(
+          new CustomEvent('change-message-deleted', {
+            detail: {message: this.message},
+            composed: true,
+            bubbles: true,
+          })
+        );
+      });
+  }
+
+  @observe('projectName')
+  _projectNameChanged(name: string) {
+    this.$.restAPI.getProjectConfig(name as RepoName).then(config => {
+      this._projectConfig = config;
+    });
+  }
+
+  _computeExpandToggleIcon(expanded: boolean) {
+    return expanded ? 'gr-icons:expand-less' : 'gr-icons:expand-more';
+  }
+
+  _toggleExpanded(e: Event) {
+    e.stopPropagation();
+    this.set('message.expanded', !this.message?.expanded);
+  }
+}
diff --git a/polygerrit-ui/app/elements/change/gr-message/gr-message_test.js b/polygerrit-ui/app/elements/change/gr-message/gr-message_test.js
index c617d2c..66b8ed2 100644
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message_test.js
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message_test.js
@@ -80,6 +80,7 @@
     });
 
     test('delete change message', done => {
+      element.changeNum = 314159;
       element.message = {
         id: '47c43261_55aa2c41',
         author: {
diff --git a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.js b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.js
deleted file mode 100644
index 7608137..0000000
--- a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.js
+++ /dev/null
@@ -1,132 +0,0 @@
-/**
- * @license
- * Copyright (C) 2015 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import '../../shared/gr-button/gr-button.js';
-import '../../shared/gr-dropdown/gr-dropdown.js';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
-import '../../../styles/shared-styles.js';
-import '../../shared/gr-avatar/gr-avatar.js';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
-import {PolymerElement} from '@polymer/polymer/polymer-element.js';
-import {htmlTemplate} from './gr-account-dropdown_html.js';
-import {getUserName} from '../../../utils/display-name-util.js';
-
-const INTERPOLATE_URL_PATTERN = /\${([\w]+)}/g;
-
-/**
- * @extends PolymerElement
- */
-class GrAccountDropdown extends GestureEventListeners(
-    LegacyElementMixin(
-        PolymerElement)) {
-  static get template() { return htmlTemplate; }
-
-  static get is() { return 'gr-account-dropdown'; }
-
-  static get properties() {
-    return {
-      account: Object,
-      config: Object,
-      links: {
-        type: Array,
-        computed: '_getLinks(_switchAccountUrl, _path)',
-      },
-      topContent: {
-        type: Array,
-        computed: '_getTopContent(account)',
-      },
-      _path: {
-        type: String,
-        value: '/',
-      },
-      _hasAvatars: Boolean,
-      _switchAccountUrl: String,
-    };
-  }
-
-  /** @override */
-  attached() {
-    super.attached();
-    this._handleLocationChange();
-    this.listen(window, 'location-change', '_handleLocationChange');
-    this.$.restAPI.getConfig().then(cfg => {
-      this.config = cfg;
-
-      if (cfg && cfg.auth && cfg.auth.switch_account_url) {
-        this._switchAccountUrl = cfg.auth.switch_account_url;
-      } else {
-        this._switchAccountUrl = '';
-      }
-      this._hasAvatars = !!(cfg && cfg.plugin && cfg.plugin.has_avatars);
-    });
-  }
-
-  /** @override */
-  detached() {
-    super.detached();
-    this.unlisten(window, 'location-change', '_handleLocationChange');
-  }
-
-  _getLinks(switchAccountUrl, path) {
-    // Polymer 2: check for undefined
-    if ([switchAccountUrl, path].includes(undefined)) {
-      return undefined;
-    }
-
-    const links = [];
-    links.push({name: 'Settings', url: '/settings/'});
-    links.push({name: 'Keyboard Shortcuts', id: 'shortcuts'});
-    if (switchAccountUrl) {
-      const replacements = {path};
-      const url = this._interpolateUrl(switchAccountUrl, replacements);
-      links.push({name: 'Switch account', url, external: true});
-    }
-    links.push({name: 'Sign out', url: '/logout'});
-    return links;
-  }
-
-  _getTopContent(account) {
-    return [
-      {text: this._accountName(account), bold: true},
-      {text: account.email ? account.email : ''},
-    ];
-  }
-
-  _handleShortcutsTap(e) {
-    this.dispatchEvent(new CustomEvent('show-keyboard-shortcuts',
-        {bubbles: true, composed: true}));
-  }
-
-  _handleLocationChange() {
-    this._path =
-        window.location.pathname +
-        window.location.search +
-        window.location.hash;
-  }
-
-  _interpolateUrl(url, replacements) {
-    return url.replace(
-        INTERPOLATE_URL_PATTERN,
-        (match, p1) => replacements[p1] || '');
-  }
-
-  _accountName(account) {
-    return getUserName(this.config, account);
-  }
-}
-
-customElements.define(GrAccountDropdown.is, GrAccountDropdown);
diff --git a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.ts b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.ts
new file mode 100644
index 0000000..ef0ced8
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.ts
@@ -0,0 +1,146 @@
+/**
+ * @license
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import '../../shared/gr-button/gr-button';
+import '../../shared/gr-dropdown/gr-dropdown';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../../../styles/shared-styles';
+import '../../shared/gr-avatar/gr-avatar';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-account-dropdown_html';
+import {getUserName} from '../../../utils/display-name-util';
+import {customElement, property} from '@polymer/decorators';
+import {AccountInfo, ServerInfo} from '../../../types/common';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+
+const INTERPOLATE_URL_PATTERN = /\${([\w]+)}/g;
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-account-dropdown': GrAccountDropdown;
+  }
+}
+
+export interface GrAccountDropdown {
+  $: {
+    restAPI: RestApiService & Element;
+  };
+}
+
+@customElement('gr-account-dropdown')
+export class GrAccountDropdown extends GestureEventListeners(
+  LegacyElementMixin(PolymerElement)
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  @property({type: Object})
+  account?: AccountInfo;
+
+  @property({type: Object})
+  config?: ServerInfo;
+
+  @property({type: Array, computed: '_getLinks(_switchAccountUrl, _path)'})
+  links?: string[];
+
+  @property({type: Array, computed: '_getTopContent(account)'})
+  topContent?: string[];
+
+  @property({type: String})
+  _path = '/';
+
+  @property({type: Boolean})
+  _hasAvatars = false;
+
+  @property({type: String})
+  _switchAccountUrl = '';
+
+  /** @override */
+  attached() {
+    super.attached();
+    this._handleLocationChange();
+    this.listen(window, 'location-change', '_handleLocationChange');
+    this.$.restAPI.getConfig().then(cfg => {
+      this.config = cfg;
+
+      if (cfg && cfg.auth && cfg.auth.switch_account_url) {
+        this._switchAccountUrl = cfg.auth.switch_account_url;
+      } else {
+        this._switchAccountUrl = '';
+      }
+      this._hasAvatars = !!(cfg && cfg.plugin && cfg.plugin.has_avatars);
+    });
+  }
+
+  /** @override */
+  detached() {
+    super.detached();
+    this.unlisten(window, 'location-change', '_handleLocationChange');
+  }
+
+  _getLinks(switchAccountUrl: string, path: string) {
+    // Polymer 2: check for undefined
+    if (switchAccountUrl === undefined || path === undefined) {
+      return undefined;
+    }
+
+    const links = [];
+    links.push({name: 'Settings', url: '/settings/'});
+    links.push({name: 'Keyboard Shortcuts', id: 'shortcuts'});
+    if (switchAccountUrl) {
+      const replacements = {path};
+      const url = this._interpolateUrl(switchAccountUrl, replacements);
+      links.push({name: 'Switch account', url, external: true});
+    }
+    links.push({name: 'Sign out', url: '/logout'});
+    return links;
+  }
+
+  _getTopContent(account?: AccountInfo) {
+    return [
+      {text: this._accountName(account), bold: true},
+      {text: account?.email ? account.email : ''},
+    ];
+  }
+
+  _handleShortcutsTap() {
+    this.dispatchEvent(
+      new CustomEvent('show-keyboard-shortcuts', {
+        bubbles: true,
+        composed: true,
+      })
+    );
+  }
+
+  _handleLocationChange() {
+    this._path =
+      window.location.pathname + window.location.search + window.location.hash;
+  }
+
+  _interpolateUrl(url: string, replacements: {[key: string]: string}) {
+    return url.replace(
+      INTERPOLATE_URL_PATTERN,
+      (_, p1) => replacements[p1] || ''
+    );
+  }
+
+  _accountName(account?: AccountInfo) {
+    return getUserName(this.config, account);
+  }
+}
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 a2036b7..b49f522 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
@@ -541,6 +541,9 @@
     comments: T[]
   ): T[] {
     return comments.slice(0).sort((c1, c2) => {
+      const d1 = !!(c1 as HumanCommentInfoWithPath).__draft;
+      const d2 = !!(c2 as HumanCommentInfoWithPath).__draft;
+      if (d1 !== d2) return d1 ? 1 : -1;
       const dateDiff =
         parseDate(c1.updated).valueOf() - parseDate(c2.updated).valueOf();
       if (dateDiff) {
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 0a7c3b5..df5e450 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
@@ -227,10 +227,12 @@
             },
             {
               id: 12,
-              in_reply_to: 2,
+              in_reply_to: 4,
               patch_set: 2,
               line: 1,
-              updated: makeTime(3),
+              // Draft gets lower timestamp than published comment, because we
+              // want to test that the draft still gets sorted to the end.
+              updated: makeTime(2),
             },
           ],
           'file/two': [
@@ -262,7 +264,7 @@
               patch_set: 2,
               unresolved: true,
               line: 1,
-              updated: makeTime(2),
+              updated: makeTime(3),
             },
           ],
         };
@@ -549,16 +551,16 @@
                 unresolved: true,
                 line: 1,
                 __path: 'file/one',
-                updated: '2013-02-26 15:02:43.986000000',
+                updated: '2013-02-26 15:03:43.986000000',
               },
               {
                 id: 12,
-                in_reply_to: 2,
+                in_reply_to: 4,
                 patch_set: 2,
                 line: 1,
                 __path: 'file/one',
                 __draft: true,
-                updated: '2013-02-26 15:03:43.986000000',
+                updated: '2013-02-26 15:02:43.986000000',
               },
             ],
             patchNum: 2,
@@ -708,16 +710,16 @@
             patch_set: 2,
             unresolved: true,
             line: 1,
-            updated: '2013-02-26 15:02:43.986000000',
+            updated: '2013-02-26 15:03:43.986000000',
           },
           {
             __path: 'file/one',
             __draft: true,
             id: 12,
-            in_reply_to: 2,
+            in_reply_to: 4,
             patch_set: 2,
             line: 1,
-            updated: '2013-02-26 15:03:43.986000000',
+            updated: '2013-02-26 15:02:43.986000000',
           },
         ];
         assert.deepEqual(element._changeComments.getCommentsForThread(4),
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.js b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.js
index 5a36917..7693d56 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.js
@@ -331,7 +331,7 @@
     const layers = [this.$.syntaxLayer];
     // Get layers from plugins (if any).
     for (const pluginLayer of this.$.jsAPI.getDiffLayers(
-        this.path, this.changeNum, this.patchNum)) {
+        this.path, this.changeNum)) {
       layers.push(pluginLayer);
     }
     this._layers = layers;
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.js b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.js
index 37d8819..cd43fdb 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.js
@@ -979,12 +979,6 @@
     assert.equal(element.$.diff.displayLine, value);
   });
 
-  test('passes in commitRange', () => {
-    const value = {};
-    element.commitRange = value;
-    assert.equal(element.$.diff.commitRange, value);
-  });
-
   test('passes in hidden', () => {
     const value = true;
     element.hidden = value;
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.js b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.js
index 0b02245..ab706e9 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.js
@@ -314,12 +314,14 @@
     this.addEventListener('open-fix-preview',
         e => this._onOpenFixPreview(e));
     this.$.cursor.push('diffs', this.$.diffHost);
-
-    const onRender = () => {
-      this.$.diffHost.removeEventListener('render', onRender);
+    this._onRenderHandler = () => {
       this.$.cursor.reInitCursor();
     };
-    this.$.diffHost.addEventListener('render', onRender);
+    this.$.diffHost.addEventListener('render', this._onRenderHandler);
+  }
+
+  detached() {
+    this.$.diffHost.removeEventListener('render', this._onRenderHandler);
   }
 
   _getLoggedIn() {
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 1c52b5f..08414ae 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.ts
@@ -347,7 +347,7 @@
     // document.getSelection() cannot reference the actual DOM elements making
     // up the diff, because they are in the shadow DOM of the gr-diff element.
     // This takes the shadow DOM selection if one exists.
-    return this.root instanceof ShadowRoot
+    return this.root instanceof ShadowRoot && this.root.getSelection
       ? this.root.getSelection()
       : document.getSelection();
   }
@@ -361,28 +361,31 @@
     });
   }
 
+  // TODO(brohlfs): Rewrite gr-diff to be agnostic of GrCommentThread, because
+  // other users of gr-diff may use different comment widgets.
   _updateRanges(
     addedThreadEls: GrCommentThread[],
     removedThreadEls: GrCommentThread[]
   ) {
     function commentRangeFromThreadEl(
       threadEl: GrCommentThread
-    ): CommentRangeLayer {
+    ): CommentRangeLayer | undefined {
       const side = getSide(threadEl);
 
       const rangeAtt = threadEl.getAttribute('range');
-      if (!rangeAtt) throw Error('comment thread without range');
+      if (!rangeAtt) return undefined;
       const range = JSON.parse(rangeAtt) as CommentRange;
 
       return {side, range, hovering: false, rootId: threadEl.rootId};
     }
 
+    // TODO(brohlfs): Rewrite `.map().filter() as ...` with `.reduce()` instead.
     const addedCommentRanges = addedThreadEls
       .map(commentRangeFromThreadEl)
-      .filter(({range}) => range);
+      .filter(range => !!range) as CommentRangeLayer[];
     const removedCommentRanges = removedThreadEls
       .map(commentRangeFromThreadEl)
-      .filter(({range}) => range);
+      .filter(range => !!range) as CommentRangeLayer[];
     for (const removedCommentRange of removedCommentRanges) {
       const i = this._commentRanges.findIndex(
         cr =>
diff --git a/polygerrit-ui/app/elements/gr-app-global-var-init.js b/polygerrit-ui/app/elements/gr-app-global-var-init.ts
similarity index 69%
rename from polygerrit-ui/app/elements/gr-app-global-var-init.js
rename to polygerrit-ui/app/elements/gr-app-global-var-init.ts
index 07d37fa..e609e6f 100644
--- a/polygerrit-ui/app/elements/gr-app-global-var-init.js
+++ b/polygerrit-ui/app/elements/gr-app-global-var-init.ts
@@ -22,53 +22,81 @@
  * expose these variables until plugins switch to direct import from polygerrit.
  */
 
-import {getAccountDisplayName, getDisplayName, getGroupDisplayName, getUserName} from '../utils/display-name-util.js';
-import {GrAnnotation} from './diff/gr-diff-highlight/gr-annotation.js';
-import {GrAttributeHelper} from './plugins/gr-attribute-helper/gr-attribute-helper.js';
-import {GrDiffLine, GrDiffLineType} from './diff/gr-diff/gr-diff-line.js';
-import {GrDiffGroup, GrDiffGroupType} from './diff/gr-diff/gr-diff-group.js';
-import {GrDiffBuilder} from './diff/gr-diff-builder/gr-diff-builder.js';
-import {GrDiffBuilderSideBySide} from './diff/gr-diff-builder/gr-diff-builder-side-by-side.js';
-import {GrDiffBuilderImage} from './diff/gr-diff-builder/gr-diff-builder-image.js';
-import {GrDiffBuilderUnified} from './diff/gr-diff-builder/gr-diff-builder-unified.js';
-import {GrDiffBuilderBinary} from './diff/gr-diff-builder/gr-diff-builder-binary.js';
-import {GrChangeActionsInterface} from './shared/gr-js-api-interface/gr-change-actions-js-api.js';
-import {GrChangeReplyInterface} from './shared/gr-js-api-interface/gr-change-reply-js-api.js';
-import {GrEditConstants} from './edit/gr-edit-constants.js';
-import {GrDomHooksManager, GrDomHook} from './plugins/gr-dom-hooks/gr-dom-hooks.js';
-import {GrEtagDecorator} from './shared/gr-rest-api-interface/gr-etag-decorator.js';
-import {GrThemeApi} from './plugins/gr-theme-api/gr-theme-api.js';
-import {SiteBasedCache, FetchPromisesCache, GrRestApiHelper} from './shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper.js';
-import {GrLinkTextParser} from './shared/gr-linked-text/link-text-parser.js';
-import {getPluginEndpoints, GrPluginEndpoints} from './shared/gr-js-api-interface/gr-plugin-endpoints.js';
-import {GrReviewerUpdatesParser} from './shared/gr-rest-api-interface/gr-reviewer-updates-parser.js';
-import {GrPopupInterface} from './plugins/gr-popup-interface/gr-popup-interface.js';
-import {GrCountStringFormatter} from './shared/gr-count-string-formatter/gr-count-string-formatter.js';
-import {GrReviewerSuggestionsProvider, SUGGESTIONS_PROVIDERS_USERS_TYPES} from '../scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider.js';
-import {util} from '../scripts/util.js';
-import {page} from '../utils/page-wrapper-utils.js';
-import {appContext} from '../services/app-context.js';
-import {GrAdminApi} from './plugins/gr-admin-api/gr-admin-api.js';
-import {GrAnnotationActionsContext} from './shared/gr-js-api-interface/gr-annotation-actions-context.js';
-import {GrAnnotationActionsInterface} from './shared/gr-js-api-interface/gr-annotation-actions-js-api.js';
-import {GrChangeMetadataApi} from './plugins/gr-change-metadata-api/gr-change-metadata-api.js';
-import {GrEmailSuggestionsProvider} from '../scripts/gr-email-suggestions-provider/gr-email-suggestions-provider.js';
-import {GrGroupSuggestionsProvider} from '../scripts/gr-group-suggestions-provider/gr-group-suggestions-provider.js';
-import {GrEventHelper} from './plugins/gr-event-helper/gr-event-helper.js';
-import {GrPluginRestApi} from './shared/gr-js-api-interface/gr-plugin-rest-api.js';
-import {GrRepoApi} from './plugins/gr-repo-api/gr-repo-api.js';
-import {GrSettingsApi} from './plugins/gr-settings-api/gr-settings-api.js';
-import {GrStylesApi} from './plugins/gr-styles-api/gr-styles-api.js';
-import {getPluginLoader, PluginLoader} from './shared/gr-js-api-interface/gr-plugin-loader.js';
-import {GrPluginActionContext} from './shared/gr-js-api-interface/gr-plugin-action-context.js';
-import {getPluginNameFromUrl, getRestAPI, PLUGIN_LOADING_TIMEOUT_MS, PRELOADED_PROTOCOL, send} from './shared/gr-js-api-interface/gr-api-utils.js';
-import {getBaseUrl} from '../utils/url-util.js';
-import {GerritNav} from './core/gr-navigation/gr-navigation.js';
-import {getRootElement} from '../scripts/rootElement.js';
-import {rangesEqual} from './diff/gr-diff/gr-diff-utils.js';
-import {RevisionInfo} from './shared/revision-info/revision-info.js';
-import {CoverageType} from '../types/types.js';
-import {_setHiddenScroll, getHiddenScroll} from '../scripts/hiddenscroll.js';
+import {
+  getAccountDisplayName,
+  getDisplayName,
+  getGroupDisplayName,
+  getUserName,
+} from '../utils/display-name-util';
+import {GrAnnotation} from './diff/gr-diff-highlight/gr-annotation';
+import {GrAttributeHelper} from './plugins/gr-attribute-helper/gr-attribute-helper';
+import {GrDiffLine, GrDiffLineType} from './diff/gr-diff/gr-diff-line';
+import {GrDiffGroup, GrDiffGroupType} from './diff/gr-diff/gr-diff-group';
+import {GrDiffBuilder} from './diff/gr-diff-builder/gr-diff-builder';
+import {GrDiffBuilderSideBySide} from './diff/gr-diff-builder/gr-diff-builder-side-by-side';
+import {GrDiffBuilderImage} from './diff/gr-diff-builder/gr-diff-builder-image';
+import {GrDiffBuilderUnified} from './diff/gr-diff-builder/gr-diff-builder-unified';
+import {GrDiffBuilderBinary} from './diff/gr-diff-builder/gr-diff-builder-binary';
+import {GrChangeActionsInterface} from './shared/gr-js-api-interface/gr-change-actions-js-api';
+import {GrChangeReplyInterface} from './shared/gr-js-api-interface/gr-change-reply-js-api';
+import {GrEditConstants} from './edit/gr-edit-constants';
+import {
+  GrDomHooksManager,
+  GrDomHook,
+} from './plugins/gr-dom-hooks/gr-dom-hooks';
+import {GrEtagDecorator} from './shared/gr-rest-api-interface/gr-etag-decorator';
+import {GrThemeApi} from './plugins/gr-theme-api/gr-theme-api';
+import {
+  SiteBasedCache,
+  FetchPromisesCache,
+  GrRestApiHelper,
+} from './shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
+import {GrLinkTextParser} from './shared/gr-linked-text/link-text-parser';
+import {
+  getPluginEndpoints,
+  GrPluginEndpoints,
+} from './shared/gr-js-api-interface/gr-plugin-endpoints';
+import {GrReviewerUpdatesParser} from './shared/gr-rest-api-interface/gr-reviewer-updates-parser';
+import {GrPopupInterface} from './plugins/gr-popup-interface/gr-popup-interface';
+import {GrCountStringFormatter} from './shared/gr-count-string-formatter/gr-count-string-formatter';
+import {
+  GrReviewerSuggestionsProvider,
+  SUGGESTIONS_PROVIDERS_USERS_TYPES,
+} from '../scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider';
+import {util} from '../scripts/util';
+import {page} from '../utils/page-wrapper-utils';
+import {appContext} from '../services/app-context';
+import {GrAdminApi} from './plugins/gr-admin-api/gr-admin-api';
+import {GrAnnotationActionsContext} from './shared/gr-js-api-interface/gr-annotation-actions-context';
+import {GrAnnotationActionsInterface} from './shared/gr-js-api-interface/gr-annotation-actions-js-api';
+import {GrChangeMetadataApi} from './plugins/gr-change-metadata-api/gr-change-metadata-api';
+import {GrEmailSuggestionsProvider} from '../scripts/gr-email-suggestions-provider/gr-email-suggestions-provider';
+import {GrGroupSuggestionsProvider} from '../scripts/gr-group-suggestions-provider/gr-group-suggestions-provider';
+import {GrEventHelper} from './plugins/gr-event-helper/gr-event-helper';
+import {GrPluginRestApi} from './shared/gr-js-api-interface/gr-plugin-rest-api';
+import {GrRepoApi} from './plugins/gr-repo-api/gr-repo-api';
+import {GrSettingsApi} from './plugins/gr-settings-api/gr-settings-api';
+import {GrStylesApi} from './plugins/gr-styles-api/gr-styles-api';
+import {
+  getPluginLoader,
+  PluginLoader,
+} from './shared/gr-js-api-interface/gr-plugin-loader';
+import {GrPluginActionContext} from './shared/gr-js-api-interface/gr-plugin-action-context';
+import {
+  getPluginNameFromUrl,
+  getRestAPI,
+  PLUGIN_LOADING_TIMEOUT_MS,
+  PRELOADED_PROTOCOL,
+  send,
+} from './shared/gr-js-api-interface/gr-api-utils';
+import {getBaseUrl} from '../utils/url-util';
+import {GerritNav} from './core/gr-navigation/gr-navigation';
+import {getRootElement} from '../scripts/rootElement';
+import {rangesEqual} from './diff/gr-diff/gr-diff-utils';
+import {RevisionInfo} from './shared/revision-info/revision-info';
+import {CoverageType} from '../types/types';
+import {_setHiddenScroll, getHiddenScroll} from '../scripts/hiddenscroll';
+import {GerritGlobal} from './shared/gr-js-api-interface/gr-gerrit';
 
 export function initGlobalVariables() {
   window.GrDisplayNameUtils = {
@@ -131,7 +159,7 @@
     PLUGIN_LOADING_TIMEOUT_MS,
   };
 
-  window.Gerrit = window.Gerrit || {};
+  window.Gerrit = (window.Gerrit || {}) as GerritGlobal;
   window.Gerrit.Nav = GerritNav;
   window.Gerrit.getRootElement = getRootElement;
   window.Gerrit.Auth = appContext.authService;
@@ -140,10 +168,10 @@
   // TODO: should define as a getter
   window.Gerrit._endpoints = getPluginEndpoints();
 
-  window.Gerrit.slotToContent = slot => slot;
+  // TODO(TS): seems not used, probably just remove
+  window.Gerrit.slotToContent = (slot: any) => slot;
   window.Gerrit.rangesEqual = rangesEqual;
-  window.Gerrit.SUGGESTIONS_PROVIDERS_USERS_TYPES =
-      SUGGESTIONS_PROVIDERS_USERS_TYPES;
+  window.Gerrit.SUGGESTIONS_PROVIDERS_USERS_TYPES = SUGGESTIONS_PROVIDERS_USERS_TYPES;
   window.Gerrit.RevisionInfo = RevisionInfo;
   window.Gerrit.CoverageType = CoverageType;
   Object.defineProperty(window.Gerrit, 'hiddenscroll', {
diff --git a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.ts b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.ts
index e4cf3a1..d970b0a 100644
--- a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.ts
+++ b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.ts
@@ -164,25 +164,30 @@
   }
 
   _maybeSetName() {
+    // Note that we are intentionally not acting on this._account.name being the
+    // empty string (which is falsy).
     return this._hasNameChange && this.nameMutable && this._account?.name
       ? this.$.restAPI.setAccountName(this._account.name)
       : Promise.resolve();
   }
 
   _maybeSetUsername() {
+    // Note that we are intentionally not acting on this._username being the
+    // empty string (which is falsy).
     return this._hasUsernameChange && this.usernameMutable && this._username
       ? this.$.restAPI.setAccountUsername(this._username)
       : Promise.resolve();
   }
 
   _maybeSetDisplayName() {
-    return this._hasDisplayNameChange && this._account?.display_name
+    return this._hasDisplayNameChange &&
+      this._account?.display_name !== undefined
       ? this.$.restAPI.setAccountDisplayName(this._account.display_name)
       : Promise.resolve();
   }
 
   _maybeSetStatus() {
-    return this._hasStatusChange && this._account?.status
+    return this._hasStatusChange && this._account?.status !== undefined
       ? this.$.restAPI.setAccountStatus(this._account.status)
       : Promise.resolve();
   }
diff --git a/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list.js b/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list.js
deleted file mode 100644
index 76899b3..0000000
--- a/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list.js
+++ /dev/null
@@ -1,369 +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 '../gr-account-chip/gr-account-chip.js';
-import '../gr-account-entry/gr-account-entry.js';
-import '../../../styles/shared-styles.js';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
-import {PolymerElement} from '@polymer/polymer/polymer-element.js';
-import {htmlTemplate} from './gr-account-list_html.js';
-import {appContext} from '../../../services/app-context.js';
-
-const VALID_EMAIL_ALERT = 'Please input a valid email.';
-
-/**
- * @extends PolymerElement
- */
-class GrAccountList extends GestureEventListeners(
-    LegacyElementMixin(PolymerElement)) {
-  static get template() { return htmlTemplate; }
-
-  static get is() { return 'gr-account-list'; }
-  /**
-   * Fired when user inputs an invalid email address.
-   *
-   * @event show-alert
-   */
-
-  static get properties() {
-    return {
-      accounts: {
-        type: Array,
-        value() { return []; },
-        notify: true,
-      },
-      change: Object,
-      filter: Function,
-      placeholder: String,
-      disabled: {
-        type: Function,
-        value: false,
-      },
-
-      /**
-       * Returns suggestions and convert them to list item
-       *
-       * @type {Gerrit.GrSuggestionsProvider}
-       */
-      suggestionsProvider: {
-        type: Object,
-      },
-
-      /**
-       * Needed for template checking since value is initially set to null.
-       *
-       * @type {?Object}
-       */
-      pendingConfirmation: {
-        type: Object,
-        value: null,
-        notify: true,
-      },
-      readonly: {
-        type: Boolean,
-        value: false,
-      },
-      /**
-       * When true, allows for non-suggested inputs to be added.
-       */
-      allowAnyInput: {
-        type: Boolean,
-        value: false,
-      },
-
-      /**
-       * Array of values (groups/accounts) that are removable. When this prop is
-       * undefined, all values are removable.
-       */
-      removableValues: Array,
-      maxCount: {
-        type: Number,
-        value: 0,
-      },
-
-      /**
-       * Returns suggestion items
-       *
-       * @type {!function(string): Promise<Array<Gerrit.GrSuggestionItem>>}
-       */
-      _querySuggestions: {
-        type: Function,
-        value() {
-          return input => this._getSuggestions(input);
-        },
-      },
-
-      /**
-       * Set to true to disable suggestions on empty input.
-       */
-      skipSuggestOnEmpty: {
-        type: Boolean,
-        value: false,
-      },
-    };
-  }
-
-  constructor() {
-    super();
-    this.reporting = appContext.reportingService;
-  }
-
-  /** @override */
-  created() {
-    super.created();
-    this.addEventListener('remove',
-        e => this._handleRemove(e));
-  }
-
-  get accountChips() {
-    return Array.from(
-        this.root.querySelectorAll('gr-account-chip'));
-  }
-
-  get focusStart() {
-    return this.$.entry.focusStart;
-  }
-
-  _getSuggestions(input) {
-    if (this.skipSuggestOnEmpty && !input) {
-      return Promise.resolve([]);
-    }
-    const provider = this.suggestionsProvider;
-    if (!provider) {
-      return Promise.resolve([]);
-    }
-    return provider.getSuggestions(input).then(suggestions => {
-      if (!suggestions) { return []; }
-      if (this.filter) {
-        suggestions = suggestions.filter(this.filter);
-      }
-      return suggestions.map(suggestion =>
-        provider.makeSuggestionItem(suggestion));
-    });
-  }
-
-  _handleAdd(e) {
-    this.addAccountItem(e.detail.value);
-  }
-
-  addAccountItem(item) {
-    // Append new account or group to the accounts property. We add our own
-    // internal properties to the account/group here, so we clone the object
-    // to avoid cluttering up the shared change object.
-    let itemTypeAdded = 'unknown';
-    if (item.account) {
-      const account =
-          {...item.account, _pendingAdd: true};
-      this.push('accounts', account);
-      itemTypeAdded = 'account';
-    } else if (item.group) {
-      if (item.confirm) {
-        this.pendingConfirmation = item;
-        return;
-      }
-      const group = {...item.group,
-        _pendingAdd: true, _group: true};
-      this.push('accounts', group);
-      itemTypeAdded = 'group';
-    } else if (this.allowAnyInput) {
-      if (!item.includes('@')) {
-        // Repopulate the input with what the user tried to enter and have
-        // a toast tell them why they can't enter it.
-        this.$.entry.setText(item);
-        this.dispatchEvent(new CustomEvent('show-alert', {
-          detail: {message: VALID_EMAIL_ALERT},
-          bubbles: true,
-          composed: true,
-        }));
-        return false;
-      } else {
-        const account = {email: item, _pendingAdd: true};
-        this.push('accounts', account);
-        itemTypeAdded = 'email';
-      }
-    }
-
-    this.reporting.reportInteraction(`Add to ${this.id}`, {itemTypeAdded});
-    this.pendingConfirmation = null;
-    return true;
-  }
-
-  confirmGroup(group) {
-    group = {
-      ...group, confirmed: true, _pendingAdd: true, _group: true};
-    this.push('accounts', group);
-    this.pendingConfirmation = null;
-  }
-
-  _computeChipClass(account) {
-    const classes = [];
-    if (account._group) {
-      classes.push('group');
-    }
-    if (account._pendingAdd) {
-      classes.push('pendingAdd');
-    }
-    return classes.join(' ');
-  }
-
-  _accountMatches(a, b) {
-    if (a && b) {
-      if (a._account_id) {
-        return a._account_id === b._account_id;
-      }
-      if (a.email) {
-        return a.email === b.email;
-      }
-    }
-    return a === b;
-  }
-
-  _computeRemovable(account, readonly) {
-    if (readonly) { return false; }
-    if (this.removableValues) {
-      for (let i = 0; i < this.removableValues.length; i++) {
-        if (this._accountMatches(this.removableValues[i], account)) {
-          return true;
-        }
-      }
-      return !!account._pendingAdd;
-    }
-    return true;
-  }
-
-  _handleRemove(e) {
-    const toRemove = e.detail.account;
-    this.removeAccount(toRemove);
-    this.$.entry.focus();
-  }
-
-  removeAccount(toRemove) {
-    if (!toRemove || !this._computeRemovable(toRemove, this.readonly)) {
-      return;
-    }
-    for (let i = 0; i < this.accounts.length; i++) {
-      let matches;
-      const account = this.accounts[i];
-      if (toRemove._group) {
-        matches = toRemove.id === account.id;
-      } else {
-        matches = this._accountMatches(toRemove, account);
-      }
-      if (matches) {
-        this.splice('accounts', i, 1);
-        this.reporting.reportInteraction(`Remove from ${this.id}`);
-        return;
-      }
-    }
-    console.warn('received remove event for missing account', toRemove);
-  }
-
-  _getNativeInput(paperInput) {
-    // In Polymer 2 inputElement isn't nativeInput anymore
-    return paperInput.$.nativeInput || paperInput.inputElement;
-  }
-
-  _handleInputKeydown(e) {
-    const input = this._getNativeInput(e.detail.input);
-    if (input.selectionStart !== input.selectionEnd ||
-        input.selectionStart !== 0) {
-      return;
-    }
-    switch (e.detail.keyCode) {
-      case 8: // Backspace
-        this.removeAccount(this.accounts[this.accounts.length - 1]);
-        break;
-      case 37: // Left arrow
-        if (this.accountChips[this.accountChips.length - 1]) {
-          this.accountChips[this.accountChips.length - 1].focus();
-        }
-        break;
-    }
-  }
-
-  _handleChipKeydown(e) {
-    const chip = e.target;
-    const chips = this.accountChips;
-    const index = chips.indexOf(chip);
-    switch (e.keyCode) {
-      case 8: // Backspace
-      case 13: // Enter
-      case 32: // Spacebar
-      case 46: // Delete
-        this.removeAccount(chip.account);
-        // Splice from this array to avoid inconsistent ordering of
-        // event handling.
-        chips.splice(index, 1);
-        if (index < chips.length) {
-          chips[index].focus();
-        } else if (index > 0) {
-          chips[index - 1].focus();
-        } else {
-          this.$.entry.focus();
-        }
-        break;
-      case 37: // Left arrow
-        if (index > 0) {
-          chip.blur();
-          chips[index - 1].focus();
-        }
-        break;
-      case 39: // Right arrow
-        chip.blur();
-        if (index < chips.length - 1) {
-          chips[index + 1].focus();
-        } else {
-          this.$.entry.focus();
-        }
-        break;
-    }
-  }
-
-  /**
-   * Submit the text of the entry as a reviewer value, if it exists. If it is
-   * a successful submit of the text, clear the entry value.
-   *
-   * @return {boolean} If there is text in the entry, return true if the
-   *     submission was successful and false if not. If there is no text,
-   *     return true.
-   */
-  submitEntryText() {
-    const text = this.$.entry.getText();
-    if (!text.length) { return true; }
-    const wasSubmitted = this.addAccountItem(text);
-    if (wasSubmitted) { this.$.entry.clear(); }
-    return wasSubmitted;
-  }
-
-  additions() {
-    return this.accounts
-        .filter(account => account._pendingAdd)
-        .map(account => {
-          if (account._group) {
-            return {group: account};
-          } else {
-            return {account};
-          }
-        });
-  }
-
-  _computeEntryHidden(maxCount, accountsRecord, readonly) {
-    return (maxCount && maxCount <= accountsRecord.base.length) || readonly;
-  }
-}
-
-customElements.define(GrAccountList.is, GrAccountList);
diff --git a/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list.ts b/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list.ts
new file mode 100644
index 0000000..fc0ea79
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list.ts
@@ -0,0 +1,452 @@
+/**
+ * @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 '../gr-account-chip/gr-account-chip';
+import '../gr-account-entry/gr-account-entry';
+import '../../../styles/shared-styles';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-account-list_html';
+import {appContext} from '../../../services/app-context';
+import {customElement, property} from '@polymer/decorators';
+import {
+  ChangeInfo,
+  Suggestion,
+  AccountInfo,
+  GroupInfo,
+} from '../../../types/common';
+import {
+  GrReviewerSuggestionsProvider,
+  SuggestionItem,
+} from '../../../scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider';
+import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
+import {GrAccountEntry} from '../gr-account-entry/gr-account-entry';
+import {GrAccountChip} from '../gr-account-chip/gr-account-chip';
+import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
+import {PaperInputElementExt} from '../../../types/types';
+
+const VALID_EMAIL_ALERT = 'Please input a valid email.';
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-account-list': GrAccountList;
+  }
+}
+
+export interface GrAccountList {
+  $: {
+    entry: GrAccountEntry;
+  };
+}
+
+/**
+ * For item added with account info
+ */
+export interface AccountObjectInput {
+  account: AccountInfo;
+}
+
+/**
+ * For item added with group info
+ */
+export interface GroupObjectInput {
+  group: GroupInfo;
+  confirm: boolean;
+}
+
+/** Supported input to be added */
+export type RawAccountInput = string | AccountObjectInput | GroupObjectInput;
+
+// type guards for AccountObjectInput and GroupObjectInput
+function isAccountObject(x: RawAccountInput): x is AccountObjectInput {
+  return !!(x as AccountObjectInput).account;
+}
+
+function isGroupObjectInput(x: RawAccountInput): x is GroupObjectInput {
+  return !!(x as GroupObjectInput).group;
+}
+
+// Internal input type with account info
+interface AccountInfoInput extends AccountInfo {
+  _group?: boolean;
+  _account?: boolean;
+  _pendingAdd?: boolean;
+  confirmed?: boolean;
+}
+
+// Internal input type with group info
+interface GroupInfoInput extends GroupInfo {
+  _group?: boolean;
+  _account?: boolean;
+  _pendingAdd?: boolean;
+  confirmed?: boolean;
+}
+
+type AccountInput = AccountInfoInput | GroupInfoInput;
+
+@customElement('gr-account-list')
+export class GrAccountList extends GestureEventListeners(
+  LegacyElementMixin(PolymerElement)
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  /**
+   * Fired when user inputs an invalid email address.
+   *
+   * @event show-alert
+   */
+
+  @property({type: Array, notify: true})
+  accounts: AccountInput[] = [];
+
+  @property({type: Object})
+  change?: ChangeInfo;
+
+  @property({type: Object})
+  filter?: (input: Suggestion) => boolean;
+
+  @property({type: String})
+  placeholder = '';
+
+  @property({type: Boolean})
+  disabled = false;
+
+  /**
+   * Returns suggestions and convert them to list item
+   */
+  @property({type: Object})
+  suggestionsProvider?: GrReviewerSuggestionsProvider;
+
+  /**
+   * Needed for template checking since value is initially set to null.
+   */
+  @property({type: Object, notify: true})
+  pendingConfirmation: RawAccountInput | null = null;
+
+  @property({type: Boolean})
+  readonly = false;
+
+  /**
+   * When true, allows for non-suggested inputs to be added.
+   */
+  @property({type: Boolean})
+  allowAnyInput = false;
+
+  /**
+   * Array of values (groups/accounts) that are removable. When this prop is
+   * undefined, all values are removable.
+   */
+  @property({type: Array})
+  removableValues?: AccountInput[];
+
+  @property({type: Number})
+  maxCount = 0;
+
+  /**
+   * Returns suggestion items
+   */
+  @property({type: Object})
+  _querySuggestions: (input: string) => Promise<SuggestionItem[]>;
+
+  /**
+   * Set to true to disable suggestions on empty input.
+   */
+  @property({type: Boolean})
+  skipSuggestOnEmpty = false;
+
+  reporting: ReportingService;
+
+  constructor() {
+    super();
+    this.reporting = appContext.reportingService;
+    this._querySuggestions = input => this._getSuggestions(input);
+  }
+
+  /** @override */
+  created() {
+    super.created();
+    this.addEventListener('remove', e =>
+      this._handleRemove(e as CustomEvent<{account: AccountInput}>)
+    );
+  }
+
+  get accountChips() {
+    return Array.from(this.root?.querySelectorAll('gr-account-chip') || []);
+  }
+
+  get focusStart() {
+    return this.$.entry.focusStart;
+  }
+
+  _getSuggestions(input: string) {
+    if (this.skipSuggestOnEmpty && !input) {
+      return Promise.resolve([]);
+    }
+    const provider = this.suggestionsProvider;
+    if (!provider) {
+      return Promise.resolve([]);
+    }
+    return provider.getSuggestions(input).then(suggestions => {
+      if (!suggestions) {
+        return [];
+      }
+      if (this.filter) {
+        suggestions = suggestions.filter(this.filter);
+      }
+      return suggestions.map(suggestion =>
+        provider.makeSuggestionItem(suggestion)
+      );
+    });
+  }
+
+  _handleAdd(e: CustomEvent<{value: RawAccountInput}>) {
+    this.addAccountItem(e.detail.value);
+  }
+
+  addAccountItem(item: RawAccountInput) {
+    // Append new account or group to the accounts property. We add our own
+    // internal properties to the account/group here, so we clone the object
+    // to avoid cluttering up the shared change object.
+    let itemTypeAdded = 'unknown';
+    if (isAccountObject(item)) {
+      const account = {...item.account, _pendingAdd: true};
+      this.push('accounts', account);
+      itemTypeAdded = 'account';
+    } else if (isGroupObjectInput(item)) {
+      if (item.confirm) {
+        this.pendingConfirmation = item;
+        return;
+      }
+      const group = {...item.group, _pendingAdd: true, _group: true};
+      this.push('accounts', group);
+      itemTypeAdded = 'group';
+    } else if (this.allowAnyInput) {
+      if (!item.includes('@')) {
+        // Repopulate the input with what the user tried to enter and have
+        // a toast tell them why they can't enter it.
+        this.$.entry.setText(item);
+        this.dispatchEvent(
+          new CustomEvent('show-alert', {
+            detail: {message: VALID_EMAIL_ALERT},
+            bubbles: true,
+            composed: true,
+          })
+        );
+        return false;
+      } else {
+        const account = {email: item, _pendingAdd: true};
+        this.push('accounts', account);
+        itemTypeAdded = 'email';
+      }
+    }
+
+    this.reporting.reportInteraction(`Add to ${this.id}`, {itemTypeAdded});
+    this.pendingConfirmation = null;
+    return true;
+  }
+
+  confirmGroup(group: GroupObjectInput) {
+    this.push('accounts', {
+      ...group,
+      confirmed: true,
+      _pendingAdd: true,
+      _group: true,
+    });
+    this.pendingConfirmation = null;
+  }
+
+  _computeChipClass(account: AccountInput) {
+    const classes = [];
+    if (account._group) {
+      classes.push('group');
+    }
+    if (account._pendingAdd) {
+      classes.push('pendingAdd');
+    }
+    return classes.join(' ');
+  }
+
+  _accountMatches(a: AccountInput, b: AccountInput) {
+    // TODO(TS): seems a & b always exists ?
+    if (a && b) {
+      // both conditions are checking against AccountInfo
+      // and only check a not b.. typeguard won't work very good without
+      // changing logic, so keep it as inline casting
+      if ((a as AccountInfoInput)._account_id) {
+        return (
+          (a as AccountInfoInput)._account_id ===
+          (b as AccountInfoInput)._account_id
+        );
+      }
+      if ((a as AccountInfoInput).email) {
+        return (a as AccountInfoInput).email === (b as AccountInfoInput).email;
+      }
+    }
+    return a === b;
+  }
+
+  _computeRemovable(account: AccountInput, readonly: boolean) {
+    if (readonly) {
+      return false;
+    }
+    if (this.removableValues) {
+      for (let i = 0; i < this.removableValues.length; i++) {
+        if (this._accountMatches(this.removableValues[i], account)) {
+          return true;
+        }
+      }
+      return !!account._pendingAdd;
+    }
+    return true;
+  }
+
+  _handleRemove(e: CustomEvent<{account: AccountInput}>) {
+    const toRemove = e.detail.account;
+    this.removeAccount(toRemove);
+    this.$.entry.focus();
+  }
+
+  removeAccount(toRemove?: AccountInput) {
+    if (!toRemove || !this._computeRemovable(toRemove, this.readonly)) {
+      return;
+    }
+    for (let i = 0; i < this.accounts.length; i++) {
+      let matches;
+      const account = this.accounts[i];
+      if (toRemove._group) {
+        matches =
+          (toRemove as GroupInfoInput).id === (account as GroupInfoInput).id;
+      } else {
+        matches = this._accountMatches(toRemove, account);
+      }
+      if (matches) {
+        this.splice('accounts', i, 1);
+        this.reporting.reportInteraction(`Remove from ${this.id}`);
+        return;
+      }
+    }
+    console.warn('received remove event for missing account', toRemove);
+  }
+
+  _getNativeInput(paperInput: PaperInputElementExt) {
+    // In Polymer 2 inputElement isn't nativeInput anymore
+    return (paperInput.$.nativeInput ||
+      paperInput.inputElement) as HTMLTextAreaElement;
+  }
+
+  _handleInputKeydown(
+    e: CustomEvent<{input: PaperInputElementExt; keyCode: number}>
+  ) {
+    const input = this._getNativeInput(e.detail.input);
+    if (
+      input.selectionStart !== input.selectionEnd ||
+      input.selectionStart !== 0
+    ) {
+      return;
+    }
+    switch (e.detail.keyCode) {
+      case 8: // Backspace
+        this.removeAccount(this.accounts[this.accounts.length - 1]);
+        break;
+      case 37: // Left arrow
+        if (this.accountChips[this.accountChips.length - 1]) {
+          this.accountChips[this.accountChips.length - 1].focus();
+        }
+        break;
+    }
+  }
+
+  _handleChipKeydown(e: KeyboardEvent) {
+    const chip = e.target as GrAccountChip;
+    const chips = this.accountChips;
+    const index = chips.indexOf(chip);
+    switch (e.keyCode) {
+      case 8: // Backspace
+      case 13: // Enter
+      case 32: // Spacebar
+      case 46: // Delete
+        this.removeAccount(chip.account);
+        // Splice from this array to avoid inconsistent ordering of
+        // event handling.
+        chips.splice(index, 1);
+        if (index < chips.length) {
+          chips[index].focus();
+        } else if (index > 0) {
+          chips[index - 1].focus();
+        } else {
+          this.$.entry.focus();
+        }
+        break;
+      case 37: // Left arrow
+        if (index > 0) {
+          chip.blur();
+          chips[index - 1].focus();
+        }
+        break;
+      case 39: // Right arrow
+        chip.blur();
+        if (index < chips.length - 1) {
+          chips[index + 1].focus();
+        } else {
+          this.$.entry.focus();
+        }
+        break;
+    }
+  }
+
+  /**
+   * Submit the text of the entry as a reviewer value, if it exists. If it is
+   * a successful submit of the text, clear the entry value.
+   *
+   * @return If there is text in the entry, return true if the
+   * submission was successful and false if not. If there is no text,
+   * return true.
+   */
+  submitEntryText() {
+    const text = this.$.entry.getText();
+    if (!text.length) {
+      return true;
+    }
+    const wasSubmitted = this.addAccountItem(text);
+    if (wasSubmitted) {
+      this.$.entry.clear();
+    }
+    return wasSubmitted;
+  }
+
+  additions() {
+    return this.accounts
+      .filter(account => account._pendingAdd)
+      .map(account => {
+        if (account._group) {
+          return {group: account};
+        } else {
+          return {account};
+        }
+      });
+  }
+
+  _computeEntryHidden(
+    maxCount: number,
+    accountsRecord: PolymerDeepPropertyChange<AccountInput[], AccountInput[]>,
+    readonly: boolean
+  ) {
+    return (maxCount && maxCount <= accountsRecord.base.length) || readonly;
+  }
+}
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.ts b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.ts
index ea573ab..033b617 100644
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.ts
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.ts
@@ -29,17 +29,17 @@
   CustomKeyboardEvent,
 } from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
 import {property, customElement, observe} from '@polymer/decorators';
-import {PaperInputElement} from '@polymer/paper-input/paper-input';
 import {GrAutocompleteDropdown} from '../gr-autocomplete-dropdown/gr-autocomplete-dropdown';
 import {GrCursorManager} from '../gr-cursor-manager/gr-cursor-manager';
 import {EventWithPath} from '../../plugins/gr-event-helper/gr-event-helper';
+import {PaperInputElementExt} from '../../../types/types';
 
 const TOKENIZE_REGEX = /(?:[^\s"]+|"[^"]*")+/g;
 const DEBOUNCE_WAIT_MS = 200;
 
 export interface GrAutocomplete {
   $: {
-    input: PaperInputElement & {$: {nativeInput?: Element}};
+    input: PaperInputElementExt;
     suggestions: GrAutocompleteDropdown;
     cursor: GrCursorManager;
   };
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.ts b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.ts
index 0c896763..edadfba 100644
--- a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.ts
+++ b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.ts
@@ -29,7 +29,7 @@
 import {htmlTemplate} from './gr-editable-label_html';
 import {IronDropdownElement} from '@polymer/iron-dropdown/iron-dropdown';
 import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {PaperInputElement} from '@polymer/paper-input/paper-input';
+import {PaperInputElementExt} from '../../../types/types';
 
 const AWAIT_MAX_ITERS = 10;
 const AWAIT_STEP = 5;
@@ -42,7 +42,7 @@
 
 export interface GrEditableLabel {
   $: {
-    input: PaperInputElement;
+    input: PaperInputElementExt;
     dropdown: IronDropdownElement;
   };
 }
@@ -188,12 +188,9 @@
   }
 
   get _nativeInput(): HTMLInputElement {
-    // In Polymer 2, the namespace of nativeInput changed from input to
-    // nativeInput.
-    // `this.$.input` has type PaperInputElement, so this is beyond our control
-    // and we cannot force `this.$.input.$` to have a proper type.
-    return (this.$.input.$['nativeInput'] ||
-      this.$.input.$['input']) as HTMLInputElement;
+    // In Polymer 2 inputElement isn't nativeInput anymore
+    return (this.$.input.$.nativeInput ||
+      this.$.input.inputElement) as HTMLInputElement;
   }
 
   _handleEnter(e: CustomKeyboardEvent) {
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-context.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-context.ts
index 4a47a13..0cb628c 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-context.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-context.ts
@@ -17,7 +17,6 @@
 
 import {GrAnnotation} from '../../diff/gr-diff-highlight/gr-annotation';
 import {GrStyleObject} from '../../plugins/gr-styles-api/gr-styles-api';
-import {PatchSetNum} from '../../../types/common';
 import {GrDiffLine} from '../../diff/gr-diff/gr-diff-line';
 
 /**
@@ -43,15 +42,12 @@
 
   changeNum: number;
 
-  patchNum: number;
-
   constructor(
     contentEl: HTMLElement,
     lineNumberEl: HTMLElement,
     line: GrDiffLine,
     path: string,
-    changeNum: string | number,
-    patchNum: PatchSetNum
+    changeNum: string | number
   ) {
     this._contentEl = contentEl;
     this._lineNumberEl = lineNumberEl;
@@ -59,9 +55,10 @@
     this.line = line;
     this.path = path;
     this.changeNum = Number(changeNum);
-    this.patchNum = Number(patchNum);
-    if (isNaN(this.changeNum) || isNaN(this.patchNum)) {
-      console.error('invalid parameters');
+    if (isNaN(this.changeNum)) {
+      console.error(
+        `GrAnnotationActionsContext: Invalid changeNum: ${changeNum}`
+      );
     }
   }
 
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api.ts
index 80e09d4..331fb42 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api.ts
@@ -18,7 +18,6 @@
 import {GrDiffLine, LineNumber} from '../../diff/gr-diff/gr-diff-line';
 import {CoverageRange} from '../../../types/types';
 import {Side} from '../../../constants/constants';
-import {PatchSetNum} from '../../../types/common';
 import {PluginApi} from '../../plugins/gr-plugin-types';
 
 type AddLayerFunc = (ctx: GrAnnotationActionsContext) => void;
@@ -188,13 +187,11 @@
    *
    * @param path The file path (eg: /COMMIT_MSG').
    * @param changeNum The Gerrit change number.
-   * @param patchNum The Gerrit patch number.
    */
-  getLayer(path: string, changeNum: number, patchNum: number) {
+  getLayer(path: string, changeNum: number) {
     const annotationLayer = new AnnotationLayer(
       path,
       changeNum,
-      patchNum,
       this.addLayerFunc
     );
     this.annotationLayers.push(annotationLayer);
@@ -216,14 +213,12 @@
    *
    * @param path The file path (eg: /COMMIT_MSG').
    * @param changeNum The Gerrit change number.
-   * @param patchNum The Gerrit patch number.
    * @param addLayerFunc The function
    * that will be called when the AnnotationLayer is ready to annotate.
    */
   constructor(
     readonly path: string,
     private readonly changeNum: number,
-    private readonly patchNum: number,
     private readonly addLayerFunc: AddLayerFunc
   ) {
     this.listeners = [];
@@ -264,8 +259,7 @@
       lineNumberEl,
       line,
       this.path,
-      this.changeNum,
-      this.patchNum as PatchSetNum
+      this.changeNum
     );
     this.addLayerFunc(annotationActionsContext);
   }
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api_test.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api_test.js
index e819529..8b3f501 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api_test.js
@@ -52,19 +52,17 @@
     const el = document.createElement('div');
     el.textContent = str;
     const changeNum = 1234;
-    const patchNum = 2;
     let testLayerFuncCalled = false;
 
     const testLayerFunc = context => {
       testLayerFuncCalled = true;
       assert.equal(context.line, line);
       assert.equal(context.changeNum, changeNum);
-      assert.equal(context.patchNum, 2);
     };
     annotationActions.addLayer(testLayerFunc);
 
     const annotationLayer = annotationActions.getLayer(
-        '/dummy/path', changeNum, patchNum);
+        '/dummy/path', changeNum);
 
     const lineNumberEl = document.createElement('td');
     annotationLayer.annotate(el, lineNumberEl, line);
@@ -74,8 +72,8 @@
   test('add notifier', () => {
     const path1 = '/dummy/path1';
     const path2 = '/dummy/path2';
-    const annotationLayer1 = annotationActions.getLayer(path1, 1, 2);
-    const annotationLayer2 = annotationActions.getLayer(path2, 1, 2);
+    const annotationLayer1 = annotationActions.getLayer(path1, 1);
+    const annotationLayer2 = annotationActions.getLayer(path2, 1);
     const layer1Spy = sinon.spy(annotationLayer1, 'notifyListeners');
     const layer2Spy = sinon.spy(annotationLayer2, 'notifyListeners');
 
@@ -148,8 +146,7 @@
   });
 
   test('layer notify listeners', () => {
-    const annotationLayer = annotationActions.getLayer(
-        '/dummy/path', 1, 2);
+    const annotationLayer = annotationActions.getLayer('/dummy/path', 1);
     let listenerCalledTimes = 0;
     const startRange = 10;
     const endRange = 20;
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit.ts
index 7f0f5b7..9e9a0ea 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit.ts
@@ -19,7 +19,11 @@
  * This defines the Gerrit instance. All methods directly attached to Gerrit
  * should be defined or linked here.
  */
-import {getPluginLoader, PluginOptionMap} from './gr-plugin-loader';
+import {
+  getPluginLoader,
+  PluginOptionMap,
+  PluginLoader,
+} from './gr-plugin-loader';
 import {getRestAPI, send} from './gr-api-utils';
 import {appContext} from '../../../services/app-context';
 import {PluginApi} from '../../plugins/gr-plugin-types';
@@ -29,8 +33,15 @@
   EventCallback,
   EventEmitterService,
 } from '../../../services/gr-event-interface/gr-event-interface';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {getRootElement} from '../../../scripts/rootElement';
+import {GrPluginEndpoints} from './gr-plugin-endpoints';
+import {rangesEqual} from '../../diff/gr-diff/gr-diff-utils';
+import {SUGGESTIONS_PROVIDERS_USERS_TYPES} from '../../../scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider';
+import {CoverageType} from '../../../types/types';
+import {RevisionInfo} from '../revision-info/revision-info';
 
-interface GerritGlobal extends EventEmitterService {
+export interface GerritGlobal extends EventEmitterService {
   flushPreinstalls?(): void;
   css(rule: string): string;
   install(
@@ -60,6 +71,18 @@
   _isPluginLoaded(pathOrUrl: string): boolean;
   _eventEmitter: EventEmitterService;
   _customStyleSheet: CSSStyleSheet;
+
+  // exposed methods
+  Nav: typeof GerritNav;
+  Auth: typeof appContext.authService;
+  getRootElement: typeof getRootElement;
+  _pluginLoader: PluginLoader;
+  _endpoints: GrPluginEndpoints;
+  slotToContent(slot: unknown): unknown;
+  rangesEqual: typeof rangesEqual;
+  SUGGESTIONS_PROVIDERS_USERS_TYPES: typeof SUGGESTIONS_PROVIDERS_USERS_TYPES;
+  CoverageType: typeof CoverageType;
+  RevisionInfo: typeof RevisionInfo;
 }
 
 /**
@@ -67,7 +90,7 @@
  * This needs to happen before Gerrit as plugin bundle overrides the Gerrit.
  */
 function flushPreinstalls() {
-  const Gerrit = window.Gerrit as GerritGlobal;
+  const Gerrit = window.Gerrit;
   if (Gerrit.flushPreinstalls) {
     Gerrit.flushPreinstalls();
   }
@@ -75,9 +98,9 @@
 export const _testOnly_flushPreinstalls = flushPreinstalls;
 
 export function initGerritPluginApi() {
-  window.Gerrit = {};
+  window.Gerrit = (window.Gerrit || {}) as GerritGlobal;
   flushPreinstalls();
-  initGerritPluginsMethods(window.Gerrit as GerritGlobal);
+  initGerritPluginsMethods(window.Gerrit);
   // Preloaded plugins should be installed after Gerrit.install() is set,
   // since plugin preloader substitutes Gerrit.install() temporarily.
   // (Gerrit.install() is set in initGerritPluginsMethods)
@@ -86,7 +109,7 @@
 
 export function _testOnly_initGerritPluginApi(): GerritGlobal {
   initGerritPluginApi();
-  return window.Gerrit as GerritGlobal;
+  return window.Gerrit;
 }
 
 export function deprecatedDelete(
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface-element.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface-element.ts
index 33ad3ca..ff9446c 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface-element.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface-element.ts
@@ -247,12 +247,12 @@
     return revertSubmissionMsg;
   }
 
-  getDiffLayers(path: string, changeNum: number, patchNum: number) {
+  getDiffLayers(path: string, changeNum: number) {
     const layers = [];
     for (const cb of this._getEventCallbacks(EventType.ANNOTATE_DIFF)) {
       const annotationApi = (cb as unknown) as GrAnnotationActionsInterface;
       try {
-        const layer = annotationApi.getLayer(path, changeNum, patchNum);
+        const layer = annotationApi.getLayer(path, changeNum);
         layers.push(layer);
       } catch (err) {
         console.error(err);
diff --git a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_html.ts b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_html.ts
index a2d1e1a..3955cd4 100644
--- a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_html.ts
+++ b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_html.ts
@@ -23,7 +23,6 @@
   <style include="shared-styles">
     .placeholder {
       color: var(--deemphasized-text-color);
-      padding-top: var(--spacing-xs);
     }
     .hidden {
       display: none;
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.ts b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.ts
index a140f50..f4111e7 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.ts
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.ts
@@ -1125,7 +1125,7 @@
     return this._restApiHelper.fetchJSON({
       url: `/accounts/${encodeURIComponent(userId)}/status`,
       anonymizedUrl: '/accounts/*/status',
-    });
+    }) as Promise<string | undefined>;
   }
 
   // https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#list-groups
diff --git a/polygerrit-ui/app/run_test.sh b/polygerrit-ui/app/run_test.sh
index 2ca1118..0ceca3c 100755
--- a/polygerrit-ui/app/run_test.sh
+++ b/polygerrit-ui/app/run_test.sh
@@ -6,6 +6,11 @@
     bazel_bin=bazel
 fi
 
+# At least temporarily we want to know what is going on even when all tests are
+# passing, so we have a better chance of debugging what happens in CI test runs
+# that were supposed to catch test failures, but did not.
 ${bazel_bin} test \
       "$@" \
+      --test_verbose_timeout_warnings \
+      --test_output=all \
       //polygerrit-ui:karma_test
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 d3b652a..29cdd1e 100644
--- a/polygerrit-ui/app/services/gr-reporting/gr-reporting_impl.ts
+++ b/polygerrit-ui/app/services/gr-reporting/gr-reporting_impl.ts
@@ -155,6 +155,7 @@
   // TODO(dmfilippov): TS-fix-any unclear what is context
   const catchErrors = function (opt_context?: any) {
     const context = opt_context || window;
+    const oldOnError = context.onerror;
     context.onerror = (
       event: Event | string,
       source?: string,
@@ -162,7 +163,7 @@
       colno?: number,
       error?: Error
     ) => {
-      return onError(context.onerror, event, source, lineno, colno, error);
+      return onError(oldOnError, event, source, lineno, colno, error);
     };
     context.addEventListener(
       'unhandledrejection',
diff --git a/polygerrit-ui/app/services/services/gr-rest-api/gr-rest-api.ts b/polygerrit-ui/app/services/services/gr-rest-api/gr-rest-api.ts
index 9fb2882..90112b9 100644
--- a/polygerrit-ui/app/services/services/gr-rest-api/gr-rest-api.ts
+++ b/polygerrit-ui/app/services/services/gr-rest-api/gr-rest-api.ts
@@ -74,6 +74,7 @@
   NameToProjectInfoMap,
   ProjectInput,
   AccountId,
+  ChangeMessageId,
 } from '../../../types/common';
 import {ParsedChangeInfo} from '../../../elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser';
 import {HttpMethod} from '../../../constants/constants';
@@ -533,6 +534,10 @@
 
   getAccountGroups(): Promise<GroupInfo[] | undefined>;
 
+  getAccountDetails(userId: AccountId): Promise<AccountDetailInfo | undefined>;
+
+  getAccountStatus(userId: AccountId): Promise<string | undefined>;
+
   saveAccountAgreement(name: ContributorAgreementInput): Promise<Response>;
 
   generateAccountHttpPassword(): Promise<Password>;
@@ -595,4 +600,9 @@
     account: AccountId,
     label: string
   ): Promise<Response>;
+
+  deleteChangeCommitMessage(
+    changeNum: ChangeNum,
+    messageId: ChangeMessageId
+  ): Promise<Response>;
 }
diff --git a/polygerrit-ui/app/test/common-test-setup.js b/polygerrit-ui/app/test/common-test-setup.js
index 004daf7..500187a 100644
--- a/polygerrit-ui/app/test/common-test-setup.js
+++ b/polygerrit-ui/app/test/common-test-setup.js
@@ -31,6 +31,7 @@
 import sinon from 'sinon/pkg/sinon-esm.js';
 import {safeTypesBridge} from '../utils/safe-types-util.js';
 import {_testOnly_initGerritPluginApi} from '../elements/shared/gr-js-api-interface/gr-gerrit.js';
+import {initGlobalVariables} from '../elements/gr-app-global-var-init.js';
 window.sinon = sinon;
 
 security.polymer_resin.install({
@@ -60,6 +61,9 @@
 };
 
 setup(() => {
+  window.Gerrit = {};
+  initGlobalVariables();
+
   // If the following asserts fails - then window.stub is
   // overwritten by some other code.
   assert.equal(cleanups.length, 0);
diff --git a/polygerrit-ui/app/types/common.ts b/polygerrit-ui/app/types/common.ts
index 52adb7b..e131b4f 100644
--- a/polygerrit-ui/app/types/common.ts
+++ b/polygerrit-ui/app/types/common.ts
@@ -410,6 +410,8 @@
 export interface ChangeMessageInfo {
   id: ChangeMessageId;
   author?: AccountInfo;
+  reviewer?: AccountInfo;
+  updated_by?: AccountInfo;
   real_author?: AccountInfo;
   date: Timestamp;
   message: string;
diff --git a/polygerrit-ui/app/types/globals.ts b/polygerrit-ui/app/types/globals.ts
index bdfb8c4..645c991 100644
--- a/polygerrit-ui/app/types/globals.ts
+++ b/polygerrit-ui/app/types/globals.ts
@@ -36,7 +36,7 @@
     ): void;
     ASSETS_PATH?: string;
     // TODO(TS): define gerrit type
-    Gerrit?: unknown;
+    Gerrit?: any;
     // TODO(TS): define polymer type
     Polymer?: unknown;
     // TODO(TS): remove page when better workaround is found
@@ -51,6 +51,56 @@
       dashboardPage?: string;
     };
     STATIC_RESOURCE_PATH?: string;
+
+    /** Enhancements on Gr elements or utils */
+    // TODO(TS): should clean up those and removing them may break certain plugin behaviors
+    // TODO(TS): as @brohlfs suggested, to avoid importing anything from elements/ to types/
+    // use any for them for now
+    GrDisplayNameUtils: any;
+    GrAnnotation: any;
+    GrAttributeHelper: any;
+    GrDiffLine: any;
+    GrDiffLineType: any;
+    GrDiffGroup: any;
+    GrDiffGroupType: any;
+    GrDiffBuilder: any;
+    GrDiffBuilderSideBySide: any;
+    GrDiffBuilderImage: any;
+    GrDiffBuilderUnified: any;
+    GrDiffBuilderBinary: any;
+    GrChangeActionsInterface: any;
+    GrChangeReplyInterface: any;
+    GrEditConstants: any;
+    GrDomHooksManager: any;
+    GrDomHook: any;
+    GrEtagDecorator: any;
+    GrThemeApi: any;
+    SiteBasedCache: any;
+    FetchPromisesCache: any;
+    GrRestApiHelper: any;
+    GrLinkTextParser: any;
+    GrPluginEndpoints: any;
+    GrReviewerUpdatesParser: any;
+    GrPopupInterface: any;
+    GrCountStringFormatter: any;
+    GrReviewerSuggestionsProvider: any;
+    util: any;
+    Auth: any;
+    EventEmitter: any;
+    GrAdminApi: any;
+    GrAnnotationActionsContext: any;
+    GrAnnotationActionsInterface: any;
+    GrChangeMetadataApi: any;
+    GrEmailSuggestionsProvider: any;
+    GrGroupSuggestionsProvider: any;
+    GrEventHelper: any;
+    GrPluginRestApi: any;
+    GrRepoApi: any;
+    GrSettingsApi: any;
+    GrStylesApi: any;
+    PluginLoader: any;
+    GrPluginActionContext: any;
+    _apiUtils: {};
   }
 
   interface Performance {
diff --git a/polygerrit-ui/app/types/types.ts b/polygerrit-ui/app/types/types.ts
index 44178c4..8dd9371 100644
--- a/polygerrit-ui/app/types/types.ts
+++ b/polygerrit-ui/app/types/types.ts
@@ -18,6 +18,7 @@
 import {IronA11yAnnouncer} from '@polymer/iron-a11y-announcer/iron-a11y-announcer';
 import {GrDiffLine} from '../elements/diff/gr-diff/gr-diff-line';
 import {FlattenedNodesObserver} from '@polymer/polymer/lib/utils/flattened-nodes-observer';
+import {PaperInputElement} from '@polymer/paper-input/paper-input';
 
 export function notUndefined<T>(x: T | undefined): x is T {
   return x !== undefined;
@@ -60,6 +61,14 @@
 }
 
 /**
+ * We would like to access the the typed `nativeInput` of PaperInputElement, so
+ * we are creating this wrapper.
+ */
+export type PaperInputElementExt = PaperInputElement & {
+  $: {nativeInput?: Element};
+};
+
+/**
  * If Polymer would have exported DomApiNative from its dom.js utility, then we
  * would probably not need this type. We just use it for casting the return
  * value of dom(element).