Merge "Revert "Switch FileInfoJson to run the new diff cache async by default""
diff --git a/Documentation/config-labels.txt b/Documentation/config-labels.txt
index f5346c1..5889c75 100644
--- a/Documentation/config-labels.txt
+++ b/Documentation/config-labels.txt
@@ -280,7 +280,7 @@
 
 Gerrit currently supports the following predicates:
 
-==== change-kind:{REWORK,TRIVIAL_REBASE,MERGE_FIRST_PARENT_UPDATE,NO_CODE_CHANGE,NO_CHANGE}
+==== changekind:{REWORK,TRIVIAL_REBASE,MERGE_FIRST_PARENT_UPDATE,NO_CODE_CHANGE,NO_CHANGE}
 
 Matches if the diff between two patch sets was of a certain change kind.
 
@@ -288,13 +288,25 @@
 
 Matches votes that are equal to the minimal or maximal voting range. Or any votes.
 
-==== approverin:`groupUUID`
+==== approverin:link:rest-api-groups.html#group-id[\{group-id\}]
 
-Matches votes granted by a user who is a member of `groupUUID`.
+Matches votes granted by a user who is a member of
+link:rest-api-groups.html#group-id[\{group-id\}].
 
-==== uploaderin:`groupUUID`
+Avoid using a group name with spaces (if it has spaces, use the group uuid).
+Although supported for convenience, it's better to use group uuid than group
+name since using names only works as long as the names are unique (and future
+groups with the same name will break the query).
 
-Matches votes where the new patch set was uploaded by a member of `groupUUID`.
+==== uploaderin:link:rest-api-groups.html#group-id[\{group-id\}]
+
+Matches votes where the new patch set was uploaded by a member of
+link:rest-api-groups.html#group-id[\{group-id\}].
+
+Avoid using a group name with spaces (if it has spaces, use the group uuid).
+Although supported for convenience, it's better to use group uuid than group
+name since using names only works as long as the names are unique (and future
+groups with the same name will break the query).
 
 ==== has:unchanged-files
 
diff --git a/Documentation/config-project-config.txt b/Documentation/config-project-config.txt
index a01df50..4dff685 100644
--- a/Documentation/config-project-config.txt
+++ b/Documentation/config-project-config.txt
@@ -165,21 +165,21 @@
 a commit for review that doesn't contain a Change-Id in the commit
 message fails with link:error-missing-changeid.html[missing Change-Id
 in commit message footer].
-
++
 It is recommended to set this option and use a
 link:user-changeid.html#create[commit-msg hook] (or other client side
 tooling like EGit) to automatically generate Change-Id's for new
 commits. This way the Change-Id is automatically in place when changes
 are reworked or rebased and uploading new patch sets gets easy.
-
++
 If this option is not set, commits can be uploaded without a Change-Id,
 but then users have to remember to copy the assigned Change-Id from the
 change screen and insert it manually into the commit message when they
 want to upload a second patch set.
-
++
 Default is `INHERIT`, which means that this property is inherited from
 the parent project. The global default for new hosts is `true`
-
++
 This option is deprecated and future releases will behave as if this
 is always `true`.
 
@@ -262,18 +262,18 @@
 
 [[receive.createNewChangeForAllNotInTarget]]receive.createNewChangeForAllNotInTarget::
 +
-The `create-new-change-for-all-not-in-target` option provides a
-convenience for selecting link:user-upload.html#base[the merge base]
-by setting it automatically to the target branch's tip so you can
-create new changes for all commits not in the target branch.
-
+This option provides a convenience for selecting
+link:user-upload.html#base[the merge base] by setting it automatically
+to the target branch's tip so you can create new changes for all
+commits not in the target branch.
++
 This option is disabled if the tip of the push is a merge commit.
-
++
 This option also only works if there are no merge commits in the
 commit chain, in such cases it fails warning the user that such
 pushes can only be performed by manually specifying
 link:user-upload.html#base[bases]
-
++
 This option is useful if you want to push a change to your personal
 branch first and for review to another branch for example. Or in cases
 where a commit is already merged into a branch and you want to create
@@ -494,9 +494,9 @@
 names in this section defines the branch order. The topmost is considered to be
 the least stable branch (typically the master branch) and the last one the
 most stable (typically the last maintained release branch).
-
++
 Example:
-
++
 ----
 [branchOrder]
   branch = master
@@ -504,13 +504,13 @@
   branch = stable-2.8
   branch = stable-2.7
 ----
-
++
 The `branchOrder` section is inheritable. This is useful when multiple or all
 projects follow the same branch rules. A `branchOrder` section in a child
 project completely overrides any `branchOrder` section from a parent i.e. there
 is no merging of `branchOrder` sections. A present but empty `branchOrder`
 section removes all inherited branch order.
-
++
 Branches not listed in this section will not be included in the mergeability
 check. If the `branchOrder` section is not defined then the mergeability of a
 change into other branches will not be done.
@@ -525,9 +525,9 @@
 +
 A boolean indicating if reviewers and CCs that do not currently have a Gerrit
 account can be added to a change by providing their email address.
-
++
 This setting only takes affect for changes that are readable by anonymous users.
-
++
 Default is `INHERIT`, which means that this property is inherited from
 the parent project. If the property is not set in any parent project, the
 default value is `FALSE`.
diff --git a/Documentation/dev-crafting-changes.txt b/Documentation/dev-crafting-changes.txt
index 15bf785..ac0780d 100644
--- a/Documentation/dev-crafting-changes.txt
+++ b/Documentation/dev-crafting-changes.txt
@@ -26,7 +26,9 @@
 * Improvements of existing features should also generally go into
   `master`. But we understand that if you cannot run `master`, it
   might take a while until you could benefit from it. In that case,
-  start on the newest `stable-*` branch that you can run.
+  implement the feature on master and, if you really need it on an
+  earlier `stable-*` branch, cherry-pick the change and build
+  Gerrit on your own environent.
 * Bug-fixes should generally at least cover the oldest affected and
   still supported version. If you're affected and run an even older
   version, you're welcome to upload to that older version, even if
diff --git a/Documentation/dev-plugins.txt b/Documentation/dev-plugins.txt
index a66d3b5..992d459 100644
--- a/Documentation/dev-plugins.txt
+++ b/Documentation/dev-plugins.txt
@@ -2366,38 +2366,15 @@
 
 If neither resource `Documentation/index.html` or
 `Documentation/index.md` exists in the plugin JAR, Gerrit will
-automatically generate an index page for the plugin's documentation
-tree by scanning every `*.md` and `*.html` file in the Documentation/
-directory.
+automatically generate an index page.
 
-For any discovered Markdown (`*.md`) file, Gerrit will parse the
-header of the file and extract the first level one title. This
-title text will be used as display text for a link to the HTML
-version of the page.
+The generated index page contains 3 sections:
 
-For any discovered HTML (`*.html`) file, Gerrit will use the name
-of the file, minus the `*.html` extension, as the link text. Any
-hyphens in the file name will be replaced with spaces.
-
-If a discovered file is named `about.md` or `about.html`, its
-content will be inserted in an 'About' section at the top of the
-auto-generated index page.  If both `about.md` and `about.html`
-exist, only the first discovered file will be used.
-
-If a discovered file name beings with `cmd-` it will be clustered
-into a 'Commands' section of the generated index page.
-
-If a discovered file name beings with `servlet-` it will be clustered
-into a 'Servlets' section of the generated index page.
-
-If a discovered file name beings with `rest-api-` it will be clustered
-into a 'REST APIs' section of the generated index page.
-
-All other files are clustered under a 'Documentation' section.
-
+1. Manifest section
++
 Some optional information from the manifest is extracted and
 displayed as part of the index page, if present in the manifest:
-
++
 [width="40%",options="header"]
 |===================================================
 |Field       | Source Attribute
@@ -2408,6 +2385,49 @@
 |API Version | Gerrit-ApiVersion
 |===================================================
 
+2. About section
++
+If an `about.md` or `about.html` file exists, its content will be inserted in an
+'About' section.
++
+If both `about.md` and `about.html` exist, only the first discovered file will
+be used.
+
+3. TOC section
++
+If a `toc.md` or `toc.html` file exists, its content will be inserted in a
+'Documentation' section.
++
+`toc.md` or `toc.html` is a manually maintained index of the documentation pages
+that exist in the plugin. Having a manually maintained index has the advantage
+that you can group the documentation pages by topic and sort them by importance.
++
+If both `toc.md` and `toc.html` exist, only the first discovered file will
+be used.
++
+If no `toc` file is present the TOC section is automatically generated by
+scanning every `\*.md` and `*.html` file in the `Documentation/` directory.
++
+For any discovered Markdown (`*.md`) file, Gerrit will parse the
+header of the file and extract the first level one title. This
+title text will be used as display text for a link to the HTML
+version of the page.
++
+For any discovered HTML (`\*.html`) file, Gerrit will use the name
+of the file, minus the `*.html` extension, as the link text. Any
+hyphens in the file name will be replaced with spaces.
++
+If a discovered file name beings with `cmd-` it will be clustered
+into a 'Commands' section of the generated index page.
++
+If a discovered file name beings with `servlet-` it will be clustered
+into a 'Servlets' section of the generated index page.
++
+If a discovered file name beings with `rest-api-` it will be clustered
+into a 'REST APIs' section of the generated index page.
++
+All other files are clustered under a 'Documentation' section.
+
 [[deployment]]
 == Deployment
 
diff --git a/Documentation/pgm-LocalUsernamesToLowerCase.txt b/Documentation/pgm-LocalUsernamesToLowerCase.txt
index 53081a1..a526647 100644
--- a/Documentation/pgm-LocalUsernamesToLowerCase.txt
+++ b/Documentation/pgm-LocalUsernamesToLowerCase.txt
@@ -28,10 +28,14 @@
 Please be aware that the conversion of the local usernames to lower
 case can't be undone.
 
-The program will produce errors if there are accounts that have the
+The program will produce errors if there are accounts with a different
+account-id or other properties (e.g. email, password) that have the
 same local username, but with different case. In this case the local
 username for these accounts is not converted to lower case.
 
+The program will automatically remove duplicates where the username
+differs only in case but all other attributes are identical.
+
 After all usernames have been migrated, the link:pgm-reindex.html[
 reindex] program is automatically invoked to reindex all accounts.
 
diff --git a/Documentation/rest-api-changes.txt b/Documentation/rest-api-changes.txt
index 9c3c8b6..0c5ea40 100644
--- a/Documentation/rest-api-changes.txt
+++ b/Documentation/rest-api-changes.txt
@@ -239,6 +239,12 @@
   current user.
 --
 
+[[submit-requirements]]
+--
+* `SUBMIT_REQUIREMENTS`: detailed result of the evaluated submit requirements
+  for this change.
+--
+
 [[current-revision]]
 --
 * `CURRENT_REVISION`: describe the current revision (patch set)
@@ -6510,7 +6516,11 @@
 entities.
 |`requirements`             |optional|
 List of the link:rest-api-changes.html#requirement[requirements] to be met before this change
-can be submitted.
+can be submitted. This field is deprecated in favour of `submit_requirements`.
+|`submit_requirements`      |optional|
+List of the link:#submit-requirement-result-info[SubmitRequirementResultInfo]
+containing the evaluated submit requirements for the change.
+Only set if link:#submit-requirements[`SUBMIT_REQUIREMENTS`] is requested.
 |`labels`             |optional|
 The labels of the change as a map that maps the label names to
 link:#label-info[LabelInfo] entries. +
@@ -6645,6 +6655,14 @@
 |`new_branch`         |optional, default to `false`|
 Allow creating a new branch when set to `true`. Using this option is
 only possible for non-merge commits (if the `merge` field is not set).
+|`validation_options` |optional|
+Map with key-value pairs that are forwarded as options to the commit validation
+listeners (e.g. can be used to skip certain validations). Which validation
+options are supported depends on the installed commit validation listeners.
+Gerrit core doesn't support any validation options, but commit validation
+listeners that are implemented in plugins may. Please refer to the
+documentation of the installed plugins to learn whether they support validation
+options. Unknown validation options are silently ignored.
 |`merge`              |optional|
 The detail of a merge commit as a link:#merge-input[MergeInput] entity.
 If set, the target branch (see  `branch` field) must exist (it is not
@@ -8137,6 +8155,56 @@
 the failure of the rule predicate.
 |===========================
 
+[[submit-requirement-expression-info]]
+=== SubmitRequirementExpressionInfo
+The `SubmitRequirementExpressionInfo` describes the result of evaluating a
+single submit requirement expression, for example `label:code-review=+2`.
+
+[options="header",cols="1,6"]
+|===========================
+|Field Name      |Description
+|`expression`|
+The submit requirement expression as a string, for example
+`branch:refs/heads/foo and label:verified=+1`.
+|`fulfilled`|
+True if the submit requirement is fulfilled for the change.
+|`passing_atoms`|
+A list of passing atoms as strings. For the above expression,
+`passing_atoms` can contain ["branch:refs/heads/foo"] if the branch predicate is
+fulfilled for the change.
+|`failing_atoms`|
+A list of failing atoms. This is similar to `passing_atoms` except that it
+contains the list of predicates that are not fulfilled for the change.
+|===========================
+
+[[submit-requirement-result-info]]
+=== SubmitRequirementResultInfo
+The `SubmitRequirementResultInfo` describes the result of evaluating a
+submit requirement on a change.
+
+[options="header",cols="1,^1,5"]
+|===========================
+|Field Name      ||Description
+|`name`||
+The submit requirement name.
+|`description`|optional|
+Description of the submit requirement.
+|`status`||
+Status describing the result of evaluating the submit requirement. The status
+is one of (`SATISFIED`, `UNSATISFED`, `OVERRIDDEN`, `NOT_APPLICABLE`).
+|`applicability_expression_result`|optional|
+A link:#submit-requirement-expression-info[SubmitRequirementExpressionInfo]
+containing the result of evaluating the applicability expression. Not set if the
+submit requirement did not define an applicability expression.
+|`submittability_expression_result`||
+A link:#submit-requirement-expression-info[SubmitRequirementExpressionInfo]
+containing the result of evaluating the submittability expression.
+|`override_expression_result`|optional|
+A link:#submit-requirement-expression-info[SubmitRequirementExpressionInfo]
+containing the result of evaluating the override expression. Not set if the
+submit requirement did not define an override expression.
+|===========================
+
 [[submitted-together-info]]
 === SubmittedTogetherInfo
 The `SubmittedTogetherInfo` entity contains information about a
diff --git a/Documentation/user-notify.txt b/Documentation/user-notify.txt
index 5ee3136..128bae6 100644
--- a/Documentation/user-notify.txt
+++ b/Documentation/user-notify.txt
@@ -10,7 +10,7 @@
 == Recipient Type
 
 Those are the available recipient types:
-+
+
 * `to`: The standard To field is used; addresses are visible to all.
 * `cc`: The standard CC field is used; addresses are visible to all.
 * `bcc`: SMTP RCPT TO is used to hide the address.
diff --git a/Documentation/user-upload.txt b/Documentation/user-upload.txt
index 0670968..a04ff35 100644
--- a/Documentation/user-upload.txt
+++ b/Documentation/user-upload.txt
@@ -622,6 +622,20 @@
 point, which could be slow and create lots of unintended new changes.
 To create multiple new changes, run push multiple times.
 
+[[ignore-attention-set]]
+=== Ignore automatic attention set rules
+
+Normally, we add users to the attention set based on several rules such as adding
+reviewers, replying, and many others. The full rule list is in
+link:user-attention-set.html[Attention Set].
+
+--ignore-automatic-attention-set-rules (also known as -ias and
+-ignore-attention-set) can be used to keep the attention set as it were before
+the push.
+
+----
+  git push ssh://john.doe@git.example.com:29418/kernel/common my-merged-commit:refs/for/master%ias
+----
 
 == repo upload
 
diff --git a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
index fd78bd8..7ddf2ba 100644
--- a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
+++ b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
@@ -71,6 +71,7 @@
 import com.google.gerrit.entities.PermissionRule.Action;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.entities.SubmitRequirement;
 import com.google.gerrit.extensions.api.GerritApi;
 import com.google.gerrit.extensions.api.changes.ChangeApi;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
@@ -1595,6 +1596,14 @@
         ObjectId.fromString(get(changeId, ListChangesOption.CURRENT_REVISION).currentRevision));
   }
 
+  protected void configSubmitRequirement(
+      Project.NameKey project, SubmitRequirement submitRequirement) throws Exception {
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      u.getConfig().upsertSubmitRequirement(submitRequirement);
+      u.save();
+    }
+  }
+
   protected void configLabel(String label, LabelFunction func) throws Exception {
     configLabel(label, func, ImmutableList.of());
   }
diff --git a/java/com/google/gerrit/entities/SubmitRequirementExpressionResult.java b/java/com/google/gerrit/entities/SubmitRequirementExpressionResult.java
index 94c0e91..f7a883e 100644
--- a/java/com/google/gerrit/entities/SubmitRequirementExpressionResult.java
+++ b/java/com/google/gerrit/entities/SubmitRequirementExpressionResult.java
@@ -98,7 +98,7 @@
   public abstract static class PredicateResult {
     abstract ImmutableList<PredicateResult> childPredicateResults();
 
-    abstract String predicateString();
+    public abstract String predicateString();
 
     /** true if the predicate is passing for a given change. */
     abstract boolean status();
diff --git a/java/com/google/gerrit/extensions/client/ListChangesOption.java b/java/com/google/gerrit/extensions/client/ListChangesOption.java
index 6071cc7..f1f7831 100644
--- a/java/com/google/gerrit/extensions/client/ListChangesOption.java
+++ b/java/com/google/gerrit/extensions/client/ListChangesOption.java
@@ -85,7 +85,10 @@
    * Skip diffstat computation that compute the insertions field (number of lines inserted) and
    * deletions field (number of lines deleted)
    */
-  SKIP_DIFFSTAT(23);
+  SKIP_DIFFSTAT(23),
+
+  /** Include the evaluated submit requirements for the caller. */
+  SUBMIT_REQUIREMENTS(24);
 
   private final int value;
 
diff --git a/java/com/google/gerrit/extensions/common/ChangeInfo.java b/java/com/google/gerrit/extensions/common/ChangeInfo.java
index 9e915f5..6afe8ac 100644
--- a/java/com/google/gerrit/extensions/common/ChangeInfo.java
+++ b/java/com/google/gerrit/extensions/common/ChangeInfo.java
@@ -112,6 +112,7 @@
   public List<PluginDefinedInfo> plugins;
   public Collection<TrackingIdInfo> trackingIds;
   public Collection<LegacySubmitRequirementInfo> requirements;
+  public Collection<SubmitRequirementResultInfo> submitRequirements;
 
   public ChangeInfo() {}
 
diff --git a/java/com/google/gerrit/extensions/common/ChangeInput.java b/java/com/google/gerrit/extensions/common/ChangeInput.java
index 1949ff4..ea12ef1 100644
--- a/java/com/google/gerrit/extensions/common/ChangeInput.java
+++ b/java/com/google/gerrit/extensions/common/ChangeInput.java
@@ -33,6 +33,7 @@
   public String baseChange;
   public String baseCommit;
   public Boolean newBranch;
+  public Map<String, String> validationOptions;
   public MergeInput merge;
 
   public AccountInput author;
diff --git a/java/com/google/gerrit/extensions/common/SubmitRequirementExpressionInfo.java b/java/com/google/gerrit/extensions/common/SubmitRequirementExpressionInfo.java
new file mode 100644
index 0000000..4d1fce2
--- /dev/null
+++ b/java/com/google/gerrit/extensions/common/SubmitRequirementExpressionInfo.java
@@ -0,0 +1,39 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.common;
+
+import java.util.List;
+
+/** Result of evaluating a single submit requirement expression. */
+public class SubmitRequirementExpressionInfo {
+
+  /** Submit requirement expression as a String. */
+  public String expression;
+
+  /** A boolean indicating if the expression is fulfilled on a change. */
+  public boolean fulfilled;
+
+  /**
+   * A list of all atoms that are passing, for example query "branch:refs/heads/foo and project:bar"
+   * has two atoms: ["branch:refs/heads/foo", "project:bar"].
+   */
+  public List<String> passingAtoms;
+
+  /**
+   * A list of all atoms that are failing, for example query "branch:refs/heads/foo and project:bar"
+   * has two atoms: ["branch:refs/heads/foo", "project:bar"].
+   */
+  public List<String> failingAtoms;
+}
diff --git a/java/com/google/gerrit/extensions/common/SubmitRequirementResultInfo.java b/java/com/google/gerrit/extensions/common/SubmitRequirementResultInfo.java
new file mode 100644
index 0000000..685e81a
--- /dev/null
+++ b/java/com/google/gerrit/extensions/common/SubmitRequirementResultInfo.java
@@ -0,0 +1,58 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.common;
+
+/** Result of evaluating a submit requirement on a change. */
+public class SubmitRequirementResultInfo {
+  public enum Status {
+    /** Submit requirement is fulfilled. */
+    SATISFIED,
+
+    /**
+     * Submit requirement is not satisfied. Happens when {@code submittabilityExpressionResult} is
+     * not fulfilled.
+     */
+    UNSATISFIED,
+
+    /**
+     * Submit requirement is overridden. Happens when {@code overrideExpressionResult} is fulfilled.
+     */
+    OVERRIDDEN,
+
+    /**
+     * Submit requirement is not applicable for the change. Happens when {@code
+     * applicabilityExpressionResult} is not fulfilled.
+     */
+    NOT_APPLICABLE
+  }
+
+  /** Submit requirement name. */
+  public String name;
+
+  /** Submit requirement description. */
+  public String description;
+
+  /** Overall result (status) of evaluating this submit requirement. */
+  public Status status;
+
+  /** Result of evaluating the applicability expression. */
+  public SubmitRequirementExpressionInfo applicabilityExpressionResult;
+
+  /** Result of evaluating the submittability expression. */
+  public SubmitRequirementExpressionInfo submittabilityExpressionResult;
+
+  /** Result of evaluating the override expression. */
+  public SubmitRequirementExpressionInfo overrideExpressionResult;
+}
diff --git a/java/com/google/gerrit/httpd/GitReferenceUpdatedTracker.java b/java/com/google/gerrit/httpd/GitReferenceUpdatedTracker.java
index c37d30b..ee28df9 100644
--- a/java/com/google/gerrit/httpd/GitReferenceUpdatedTracker.java
+++ b/java/com/google/gerrit/httpd/GitReferenceUpdatedTracker.java
@@ -17,11 +17,15 @@
 import com.google.gerrit.extensions.events.GitReferenceUpdatedListener;
 import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.inject.Inject;
+import com.google.inject.ProvisionException;
 import com.google.inject.Singleton;
 
 /**
  * Stores the updated refs whenever they are updated, so that we can export this information in the
  * response headers.
+ *
+ * <p>This is only working for HTTP requests. {@link WebSession} is not bound outside of HTTP
+ * requests.
  */
 @Singleton
 public class GitReferenceUpdatedTracker implements GitReferenceUpdatedListener {
@@ -35,7 +39,15 @@
 
   @Override
   public void onGitReferenceUpdated(GitReferenceUpdatedListener.Event event) {
-    WebSession currentSession = webSession.get();
+    WebSession currentSession = null;
+    try {
+      currentSession = webSession.get();
+    } catch (ProvisionException ex) {
+      // We couldn't bind the current session properly. This is expected to happen at any point we
+      // perform ref updates without an HTTP request (git push for example).
+      // If we can't get a WebSession, we don't need to track the updated references.
+      return;
+    }
     if (currentSession != null) {
       currentSession.addRefUpdatedEvents(event);
     }
diff --git a/java/com/google/gerrit/httpd/plugins/HttpPluginServlet.java b/java/com/google/gerrit/httpd/plugins/HttpPluginServlet.java
index 43eb3a0..ef37fc5 100644
--- a/java/com/google/gerrit/httpd/plugins/HttpPluginServlet.java
+++ b/java/com/google/gerrit/httpd/plugins/HttpPluginServlet.java
@@ -358,6 +358,33 @@
     return cachedResource != null && cachedResource.isUnchanged(lastUpdateTime);
   }
 
+  private void appendPageAsSection(
+      PluginContentScanner scanner, PluginEntry pluginEntry, String sectionTitle, StringBuilder md)
+      throws IOException {
+    InputStreamReader isr = new InputStreamReader(scanner.getInputStream(pluginEntry), UTF_8);
+    StringBuilder content = new StringBuilder();
+    try (BufferedReader reader = new BufferedReader(isr)) {
+      String line;
+      while ((line = reader.readLine()) != null) {
+        line = StringUtils.stripEnd(line, null);
+        if (line.isEmpty()) {
+          content.append("\n");
+        } else {
+          content.append(line).append("\n");
+        }
+      }
+    }
+
+    // Only append the section if there was anything in it
+    if (content.toString().trim().length() > 0) {
+      md.append("## ");
+      md.append(sectionTitle);
+      md.append(" ##\n");
+      md.append("\n").append(content);
+      md.append("\n");
+    }
+  }
+
   private void appendEntriesSection(
       PluginContentScanner scanner,
       List<PluginEntry> entries,
@@ -400,6 +427,7 @@
     List<PluginEntry> restApis = new ArrayList<>();
     List<PluginEntry> docs = new ArrayList<>();
     PluginEntry about = null;
+    PluginEntry toc = null;
 
     Predicate<PluginEntry> filter =
         entry -> {
@@ -437,6 +465,14 @@
               "Plugin %s: Multiple 'about' documents found; using %s",
               pluginName, about.getName().substring(prefix.length()));
         }
+      } else if (name.startsWith("toc.")) {
+        if (toc == null) {
+          toc = entry;
+        } else {
+          logger.atWarning().log(
+              "Plugin %s: Multiple 'toc' documents found; using %s",
+              pluginName, toc.getName().substring(prefix.length()));
+        }
       } else {
         docs.add(entry);
       }
@@ -451,31 +487,17 @@
     appendPluginInfoTable(md, scanner.getManifest().getMainAttributes());
 
     if (about != null) {
-      InputStreamReader isr = new InputStreamReader(scanner.getInputStream(about), UTF_8);
-      StringBuilder aboutContent = new StringBuilder();
-      try (BufferedReader reader = new BufferedReader(isr)) {
-        String line;
-        while ((line = reader.readLine()) != null) {
-          line = StringUtils.stripEnd(line, null);
-          if (line.isEmpty()) {
-            aboutContent.append("\n");
-          } else {
-            aboutContent.append(line).append("\n");
-          }
-        }
-      }
-
-      // Only append the About section if there was anything in it
-      if (aboutContent.toString().trim().length() > 0) {
-        md.append("## About ##\n");
-        md.append("\n").append(aboutContent);
-      }
+      appendPageAsSection(scanner, about, "About", md);
     }
 
-    appendEntriesSection(scanner, docs, "Documentation", md, prefix, 0);
-    appendEntriesSection(scanner, servlets, "Servlets", md, prefix, "servlet-".length());
-    appendEntriesSection(scanner, restApis, "REST APIs", md, prefix, "rest-api-".length());
-    appendEntriesSection(scanner, cmds, "Commands", md, prefix, "cmd-".length());
+    if (toc != null) {
+      appendPageAsSection(scanner, toc, "Documentaion", md);
+    } else {
+      appendEntriesSection(scanner, docs, "Documentation", md, prefix, 0);
+      appendEntriesSection(scanner, servlets, "Servlets", md, prefix, "servlet-".length());
+      appendEntriesSection(scanner, restApis, "REST APIs", md, prefix, "rest-api-".length());
+      appendEntriesSection(scanner, cmds, "Commands", md, prefix, "cmd-".length());
+    }
 
     sendMarkdownAsHtml(md.toString(), pluginName, cacheKey, res, lastModifiedTime);
   }
diff --git a/java/com/google/gerrit/index/query/IndexPredicate.java b/java/com/google/gerrit/index/query/IndexPredicate.java
index 7bbe70b..b255833 100644
--- a/java/com/google/gerrit/index/query/IndexPredicate.java
+++ b/java/com/google/gerrit/index/query/IndexPredicate.java
@@ -19,7 +19,6 @@
 import com.google.common.base.CharMatcher;
 import com.google.common.base.Splitter;
 import com.google.common.collect.ImmutableSet;
-import com.google.common.collect.Sets;
 import com.google.common.primitives.Ints;
 import com.google.common.primitives.Longs;
 import com.google.gerrit.index.FieldDef;
@@ -36,7 +35,7 @@
    * complexity was reduced to the bare minimum at the cost of small discrepancies to the Unicode
    * spec.
    */
-  private static final Splitter FULL_TEXT_SPLITTER = Splitter.on(CharMatcher.anyOf(" ,.-\\/_\n"));
+  private static final Splitter FULL_TEXT_SPLITTER = Splitter.on(CharMatcher.anyOf(" ,.-:\\/_\n"));
 
   private final FieldDef<I, ?> def;
 
@@ -106,7 +105,7 @@
     } else if (fieldTypeName.equals(FieldType.FULL_TEXT.getName())) {
       Set<String> tokenizedField = tokenizeString(String.valueOf(fieldValueFromObject));
       Set<String> tokenizedValue = tokenizeString(value);
-      return !Sets.intersection(tokenizedField, tokenizedValue).isEmpty();
+      return !tokenizedValue.isEmpty() && tokenizedField.containsAll(tokenizedValue);
     } else if (fieldTypeName.equals(FieldType.STORED_ONLY.getName())) {
       throw new IllegalStateException("can't filter for storedOnly field " + getField().getName());
     } else if (fieldTypeName.equals(FieldType.TIMESTAMP.getName())) {
diff --git a/java/com/google/gerrit/pgm/BUILD b/java/com/google/gerrit/pgm/BUILD
index 16eebf2..387ff2d 100644
--- a/java/com/google/gerrit/pgm/BUILD
+++ b/java/com/google/gerrit/pgm/BUILD
@@ -49,6 +49,7 @@
         "//lib:servlet-api-without-neverlink",
         "//lib/auto:auto-value",
         "//lib/auto:auto-value-annotations",
+        "//lib/commons:lang",
         "//lib/flogger:api",
         "//lib/guice",
         "//lib/guice:guice-assistedinject",
diff --git a/java/com/google/gerrit/pgm/LocalUsernamesToLowerCase.java b/java/com/google/gerrit/pgm/LocalUsernamesToLowerCase.java
index e6e091c..8e2f70f 100644
--- a/java/com/google/gerrit/pgm/LocalUsernamesToLowerCase.java
+++ b/java/com/google/gerrit/pgm/LocalUsernamesToLowerCase.java
@@ -36,6 +36,9 @@
 import java.io.IOException;
 import java.util.Collection;
 import java.util.Locale;
+import java.util.Optional;
+import org.apache.commons.lang.StringUtils;
+import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.ProgressMonitor;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.lib.TextProgressMonitor;
@@ -108,8 +111,38 @@
                 extId.accountId(),
                 extId.email(),
                 extId.password());
+        replaceIfNotExists(extIdNotes, extId, extIdLowerCase);
+      }
+    }
+  }
+
+  private void replaceIfNotExists(
+      ExternalIdNotes extIdNotes, ExternalId extId, ExternalId extIdLowerCase) throws IOException {
+    try {
+      Optional<ExternalId> existingExternalId =
+          extIdNotes
+              .get(extIdLowerCase.key())
+              .filter(eid -> eid.accountId().equals(extIdLowerCase.accountId()))
+              .filter(eid -> StringUtils.equalsIgnoreCase(eid.email(), extId.email()))
+              .filter(eid -> StringUtils.equalsIgnoreCase(eid.password(), extId.password()));
+      if (existingExternalId.isPresent()) {
+        System.err.println(
+            "WARNING: external-id "
+                + extIdLowerCase
+                + " already exists with the same account-id "
+                + extId.accountId()
+                + " :"
+                + "removing the duplicate external-id "
+                + extId.key());
+        extIdNotes.delete(extId);
+      } else {
         extIdNotes.replace(extId, extIdLowerCase);
       }
+    } catch (ConfigInvalidException e) {
+      throw new IOException(
+          "Unable to parse external id definition when looking for current external-id "
+              + extIdLowerCase,
+          e);
     }
   }
 
diff --git a/java/com/google/gerrit/pgm/util/BatchProgramModule.java b/java/com/google/gerrit/pgm/util/BatchProgramModule.java
index 30de2f5..4db657d 100644
--- a/java/com/google/gerrit/pgm/util/BatchProgramModule.java
+++ b/java/com/google/gerrit/pgm/util/BatchProgramModule.java
@@ -83,6 +83,8 @@
 import com.google.gerrit.server.query.approval.ApprovalModule;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.ChangeIsVisibleToPredicate;
+import com.google.gerrit.server.query.change.ChangeQueryBuilder;
+import com.google.gerrit.server.query.change.ConflictsCacheImpl;
 import com.google.gerrit.server.restapi.group.GroupModule;
 import com.google.gerrit.server.rules.DefaultSubmitRule;
 import com.google.gerrit.server.rules.IgnoreSelfApprovalRule;
@@ -174,6 +176,7 @@
     modules.add(new NoteDbModule());
     modules.add(AccountCacheImpl.module());
     modules.add(ApprovalCacheImpl.module());
+    modules.add(ConflictsCacheImpl.module());
     modules.add(DefaultPreferencesCacheImpl.module());
     modules.add(GroupCacheImpl.module());
     modules.add(GroupIncludeCacheImpl.module());
@@ -190,6 +193,10 @@
     factory(ChangeIsVisibleToPredicate.Factory.class);
     factory(ProjectState.Factory.class);
 
+    DynamicMap.mapOf(binder(), ChangeQueryBuilder.ChangeOperatorFactory.class);
+    DynamicMap.mapOf(binder(), ChangeQueryBuilder.ChangeHasOperandFactory.class);
+    DynamicMap.mapOf(binder(), ChangeQueryBuilder.ChangeIsOperandFactory.class);
+
     // Submit rules
     DynamicSet.setOf(binder(), SubmitRule.class);
     factory(SubmitRuleEvaluator.Factory.class);
diff --git a/java/com/google/gerrit/server/approval/ApprovalInference.java b/java/com/google/gerrit/server/approval/ApprovalInference.java
index 4635464..1efbd37 100644
--- a/java/com/google/gerrit/server/approval/ApprovalInference.java
+++ b/java/com/google/gerrit/server/approval/ApprovalInference.java
@@ -30,7 +30,6 @@
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.client.ChangeKind;
-import com.google.gerrit.extensions.client.DiffPreferencesInfo;
 import com.google.gerrit.index.query.QueryParseException;
 import com.google.gerrit.server.change.ChangeKindCache;
 import com.google.gerrit.server.change.LabelNormalizer;
@@ -38,10 +37,9 @@
 import com.google.gerrit.server.logging.TraceContext;
 import com.google.gerrit.server.logging.TraceContext.TraceTimer;
 import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.patch.PatchList;
-import com.google.gerrit.server.patch.PatchListCache;
-import com.google.gerrit.server.patch.PatchListKey;
-import com.google.gerrit.server.patch.PatchListNotAvailableException;
+import com.google.gerrit.server.patch.DiffNotAvailableException;
+import com.google.gerrit.server.patch.DiffOperations;
+import com.google.gerrit.server.patch.filediff.FileDiffOutput;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.query.approval.ApprovalContext;
@@ -63,33 +61,34 @@
  * asserting a change's kind and checking the project config for allowed forward-inference.
  *
  * <p>The result of a copy may either be stored, as when stamping approvals in the database at
- * submit time, or refreshed on demand, as when reading approvals from the NoteDb.
+ * submit time, or refreshed on demand, as when reading approvals from the NoteDb. TODO(ghareeb):
+ * migrate to new diff cache
  */
 @Singleton
 class ApprovalInference {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
+  private final DiffOperations diffOperations;
   private final ProjectCache projectCache;
   private final ChangeKindCache changeKindCache;
   private final LabelNormalizer labelNormalizer;
-  private final PatchListCache patchListCache;
   private final ApprovalQueryBuilder approvalQueryBuilder;
   private final OneOffRequestContext requestContext;
   private final ListOfFilesUnchangedPredicate listOfFilesUnchangedPredicate;
 
   @Inject
   ApprovalInference(
+      DiffOperations diffOperations,
       ProjectCache projectCache,
       ChangeKindCache changeKindCache,
       LabelNormalizer labelNormalizer,
-      PatchListCache patchListCache,
       ApprovalQueryBuilder approvalQueryBuilder,
       OneOffRequestContext requestContext,
       ListOfFilesUnchangedPredicate listOfFilesUnchangedPredicate) {
+    this.diffOperations = diffOperations;
     this.projectCache = projectCache;
     this.changeKindCache = changeKindCache;
     this.labelNormalizer = labelNormalizer;
-    this.patchListCache = patchListCache;
     this.approvalQueryBuilder = approvalQueryBuilder;
     this.requestContext = requestContext;
     this.listOfFilesUnchangedPredicate = listOfFilesUnchangedPredicate;
@@ -125,7 +124,7 @@
       PatchSet.Id psId,
       ChangeKind kind,
       LabelType type,
-      @Nullable PatchList patchList) {
+      @Nullable Map<String, FileDiffOutput> modifiedFiles) {
     int n = psa.key().patchSetId().get();
     checkArgument(n != psId.get());
 
@@ -175,7 +174,7 @@
           project.getName());
       return true;
     } else if (type.isCopyAllScoresIfListOfFilesDidNotChange()
-        && listOfFilesUnchangedPredicate.match(patchList)) {
+        && listOfFilesUnchangedPredicate.match(modifiedFiles)) {
       logger.atFine().log(
           "approval %d on label %s of patch set %d of change %d can be copied"
               + " to patch set %d because the label has set "
@@ -392,16 +391,18 @@
     logger.atFine().log(
         "change kind for patch set %d of change %d against prior patch set %s is %s",
         ps.id().get(), ps.id().changeId().get(), priorPatchSet.getValue().id().changeId(), kind);
-    PatchList patchList = null;
+    Map<String, FileDiffOutput> modifiedFiles = null;
     LabelTypes labelTypes = project.getLabelTypes();
     for (PatchSetApproval psa : priorApprovals) {
       if (resultByUser.contains(psa.label(), psa.accountId())) {
         continue;
       }
       LabelType type = labelTypes.byLabel(psa.labelId());
-      // Only compute patchList if there is a relevant label, since this is expensive.
-      if (patchList == null && type != null && type.isCopyAllScoresIfListOfFilesDidNotChange()) {
-        patchList = getPatchList(project, ps, priorPatchSet);
+      // Only compute modified files if there is a relevant label, since this is expensive.
+      if (modifiedFiles == null
+          && type != null
+          && type.isCopyAllScoresIfListOfFilesDidNotChange()) {
+        modifiedFiles = listModifiedFiles(project, ps, priorPatchSet);
       }
       if (type == null) {
         logger.atFine().log(
@@ -415,7 +416,7 @@
             project.getName());
         continue;
       }
-      if (!canCopyBasedOnBooleanLabelConfigs(project, psa, ps.id(), kind, type, patchList)
+      if (!canCopyBasedOnBooleanLabelConfigs(project, psa, ps.id(), kind, type, modifiedFiles)
           && !canCopyBasedOnCopyCondition(notes, psa, ps.id(), type, kind)) {
         continue;
       }
@@ -425,19 +426,15 @@
   }
 
   /**
-   * Gets the {@link PatchList} between the two latest patch-sets. Can be used to compute difference
-   * in files between those two patch-sets .
+   * Gets the modified files between the two latest patch-sets. Can be used to compute difference in
+   * files between those two patch-sets .
    */
-  private PatchList getPatchList(
+  private Map<String, FileDiffOutput> listModifiedFiles(
       ProjectState project, PatchSet ps, Map.Entry<PatchSet.Id, PatchSet> priorPatchSet) {
-    PatchListKey key =
-        PatchListKey.againstCommit(
-            priorPatchSet.getValue().commitId(),
-            ps.commitId(),
-            DiffPreferencesInfo.Whitespace.IGNORE_NONE);
     try {
-      return patchListCache.get(key, project.getNameKey());
-    } catch (PatchListNotAvailableException ex) {
+      return diffOperations.listModifiedFiles(
+          project.getNameKey(), priorPatchSet.getValue().commitId(), ps.commitId());
+    } catch (DiffNotAvailableException ex) {
       throw new StorageException(
           "failed to compute difference in files, so won't copy"
               + " votes on labels even if list of files is the same and "
diff --git a/java/com/google/gerrit/server/cache/h2/H2CacheImpl.java b/java/com/google/gerrit/server/cache/h2/H2CacheImpl.java
index 7a53600..13b8b12 100644
--- a/java/com/google/gerrit/server/cache/h2/H2CacheImpl.java
+++ b/java/com/google/gerrit/server/cache/h2/H2CacheImpl.java
@@ -20,6 +20,7 @@
 import com.google.common.cache.CacheLoader;
 import com.google.common.cache.CacheStats;
 import com.google.common.cache.LoadingCache;
+import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.flogger.FluentLogger;
 import com.google.common.hash.BloomFilter;
@@ -44,7 +45,10 @@
 import java.sql.Timestamp;
 import java.time.Duration;
 import java.time.Instant;
+import java.util.ArrayList;
 import java.util.Calendar;
+import java.util.HashMap;
+import java.util.List;
 import java.util.Map;
 import java.util.concurrent.ArrayBlockingQueue;
 import java.util.concurrent.BlockingQueue;
@@ -137,6 +141,23 @@
   }
 
   @Override
+  public ImmutableMap<K, V> getAll(Iterable<? extends K> keys) throws ExecutionException {
+    if (mem instanceof LoadingCache) {
+      ImmutableMap.Builder<K, V> result = ImmutableMap.builder();
+      LoadingCache<K, ValueHolder<V>> asLoadingCache = (LoadingCache<K, ValueHolder<V>>) mem;
+      ImmutableMap<K, ValueHolder<V>> values = asLoadingCache.getAll(keys);
+      for (Map.Entry<K, ValueHolder<V>> entry : values.entrySet()) {
+        result.put(entry.getKey(), entry.getValue().value);
+        if (store.needsRefresh(entry.getValue().created)) {
+          asLoadingCache.refresh(entry.getKey());
+        }
+      }
+      return result.build();
+    }
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
   public V get(K key, Callable<? extends V> valueLoader) throws ExecutionException {
     return mem.get(
             key,
@@ -265,6 +286,40 @@
     }
 
     @Override
+    public Map<K, ValueHolder<V>> loadAll(Iterable<? extends K> keys) throws Exception {
+      try (TraceTimer timer = TraceContext.newTimer("Loading multiple values from cache")) {
+        List<K> notInMemory = new ArrayList<>();
+        Map<K, ValueHolder<V>> result = new HashMap<>();
+        for (K key : keys) {
+          if (!store.mightContain(key)) {
+            notInMemory.add(key);
+            continue;
+          }
+          ValueHolder<V> h = store.getIfPresent(key);
+          if (h != null) {
+            result.put(key, h);
+          } else {
+            notInMemory.add(key);
+          }
+        }
+        try {
+          Map<K, V> remaining = loader.loadAll(notInMemory);
+          Instant instant = Instant.ofEpochMilli(TimeUtil.nowMs());
+          storeInDatabase(remaining, instant);
+          remaining
+              .entrySet()
+              .forEach(e -> result.put(e.getKey(), new ValueHolder<>(e.getValue(), instant)));
+        } catch (UnsupportedLoadingOperationException e) {
+          // Fallback to the default load() if loadAll() is not implemented
+          for (K k : notInMemory) {
+            result.put(k, load(k)); // No need to storeInDatabase here; load(k) does that.
+          }
+        }
+        return result;
+      }
+    }
+
+    @Override
     public ListenableFuture<ValueHolder<V>> reload(K key, ValueHolder<V> oldValue)
         throws Exception {
       ListenableFuture<V> reloadedValue = loader.reload(key, oldValue.value);
@@ -285,6 +340,15 @@
 
       return Futures.transform(reloadedValue, v -> new ValueHolder<>(v, TimeUtil.now()), executor);
     }
+
+    private void storeInDatabase(Map<K, V> entries, Instant instant) {
+      executor.execute(
+          () -> {
+            for (Map.Entry<K, V> entry : entries.entrySet()) {
+              store.put(entry.getKey(), new ValueHolder<>(entry.getValue(), instant));
+            }
+          });
+    }
   }
 
   static class SqlStore<K, V> {
diff --git a/java/com/google/gerrit/server/change/ChangeInserter.java b/java/com/google/gerrit/server/change/ChangeInserter.java
index 6728ba2..85482e4 100644
--- a/java/com/google/gerrit/server/change/ChangeInserter.java
+++ b/java/com/google/gerrit/server/change/ChangeInserter.java
@@ -131,6 +131,7 @@
   private boolean isPrivate;
   private boolean workInProgress;
   private List<String> groups = Collections.emptyList();
+  private ImmutableListMultimap<String, String> validationOptions = ImmutableListMultimap.of();
   private boolean validate = true;
   private Map<String, Short> approvals;
   private RequestScopePropagator requestScopePropagator;
@@ -305,11 +306,21 @@
 
   public ChangeInserter setGroups(List<String> groups) {
     requireNonNull(groups, "groups may not be empty");
-    checkState(patchSet == null, "setGroups(Iterable<String>) only valid before creating change");
+    checkState(patchSet == null, "setGroups(List<String>) only valid before creating change");
     this.groups = groups;
     return this;
   }
 
+  public ChangeInserter setValidationOptions(
+      ImmutableListMultimap<String, String> validationOptions) {
+    checkState(
+        patchSet == null,
+        "setValidationOptions(ImmutableListMultimap<String, String>) only valid before creating a"
+            + " change");
+    this.validationOptions = validationOptions;
+    return this;
+  }
+
   public ChangeInserter setFireRevisionCreated(boolean fireRevisionCreated) {
     this.fireRevisionCreated = fireRevisionCreated;
     return this;
@@ -563,7 +574,7 @@
               cmd,
               projectState.getProject(),
               change.getDest().branch(),
-              ImmutableListMultimap.of(),
+              validationOptions,
               ctx.getRepoView().getConfig(),
               ctx.getRevWalk().getObjectReader(),
               commitId,
diff --git a/java/com/google/gerrit/server/change/ChangeJson.java b/java/com/google/gerrit/server/change/ChangeJson.java
index 029f231..e9c9946 100644
--- a/java/com/google/gerrit/server/change/ChangeJson.java
+++ b/java/com/google/gerrit/server/change/ChangeJson.java
@@ -31,6 +31,7 @@
 import static com.google.gerrit.extensions.client.ListChangesOption.REVIEWER_UPDATES;
 import static com.google.gerrit.extensions.client.ListChangesOption.SKIP_DIFFSTAT;
 import static com.google.gerrit.extensions.client.ListChangesOption.SUBMITTABLE;
+import static com.google.gerrit.extensions.client.ListChangesOption.SUBMIT_REQUIREMENTS;
 import static com.google.gerrit.extensions.client.ListChangesOption.TRACKING_IDS;
 import static com.google.gerrit.server.ChangeMessagesUtil.createChangeMessageInfo;
 import static com.google.gerrit.server.util.AttentionSetUtil.additionsOnly;
@@ -59,6 +60,11 @@
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.entities.SubmitRecord;
 import com.google.gerrit.entities.SubmitRecord.Status;
+import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.entities.SubmitRequirementExpression;
+import com.google.gerrit.entities.SubmitRequirementExpressionResult;
+import com.google.gerrit.entities.SubmitRequirementExpressionResult.PredicateResult;
+import com.google.gerrit.entities.SubmitRequirementResult;
 import com.google.gerrit.entities.SubmitTypeRecord;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.FixInput;
@@ -75,6 +81,8 @@
 import com.google.gerrit.extensions.common.ProblemInfo;
 import com.google.gerrit.extensions.common.ReviewerUpdateInfo;
 import com.google.gerrit.extensions.common.RevisionInfo;
+import com.google.gerrit.extensions.common.SubmitRequirementExpressionInfo;
+import com.google.gerrit.extensions.common.SubmitRequirementResultInfo;
 import com.google.gerrit.extensions.common.TrackingIdInfo;
 import com.google.gerrit.extensions.restapi.Url;
 import com.google.gerrit.index.RefState;
@@ -362,11 +370,54 @@
     return reqInfos;
   }
 
+  private static Collection<SubmitRequirementResultInfo> submitRequirementsFor(ChangeData cd) {
+    Collection<SubmitRequirementResultInfo> reqInfos = new ArrayList<>();
+    Map<SubmitRequirement, SubmitRequirementResult> requirements = cd.submitRequirements();
+    for (Map.Entry<SubmitRequirement, SubmitRequirementResult> entry : requirements.entrySet()) {
+      reqInfos.add(submitRequirementToInfo(entry.getKey(), entry.getValue()));
+    }
+    return reqInfos;
+  }
+
   private static LegacySubmitRequirementInfo requirementToInfo(
       LegacySubmitRequirement req, Status status) {
     return new LegacySubmitRequirementInfo(status.name(), req.fallbackText(), req.type());
   }
 
+  private static SubmitRequirementResultInfo submitRequirementToInfo(
+      SubmitRequirement req, SubmitRequirementResult result) {
+    SubmitRequirementResultInfo info = new SubmitRequirementResultInfo();
+    info.name = req.name();
+    info.description = req.description().orElse(null);
+    if (req.applicabilityExpression().isPresent()) {
+      info.applicabilityExpressionResult =
+          submitRequirementExpressionToInfo(
+              req.applicabilityExpression().get(), result.applicabilityExpressionResult().get());
+    }
+    if (req.overrideExpression().isPresent()) {
+      info.overrideExpressionResult =
+          submitRequirementExpressionToInfo(
+              req.overrideExpression().get(), result.overrideExpressionResult().get());
+    }
+    info.submittabilityExpressionResult =
+        submitRequirementExpressionToInfo(
+            req.submittabilityExpression(), result.submittabilityExpressionResult());
+    info.status = SubmitRequirementResultInfo.Status.valueOf(result.status().toString());
+    return info;
+  }
+
+  private static SubmitRequirementExpressionInfo submitRequirementExpressionToInfo(
+      SubmitRequirementExpression expression, SubmitRequirementExpressionResult result) {
+    SubmitRequirementExpressionInfo info = new SubmitRequirementExpressionInfo();
+    info.expression = expression.expressionString();
+    info.fulfilled = result.status().equals(SubmitRequirementExpressionResult.Status.PASS);
+    info.passingAtoms =
+        result.getPassingAtoms().stream().map(PredicateResult::predicateString).collect(toList());
+    info.failingAtoms =
+        result.getFailingAtoms().stream().map(PredicateResult::predicateString).collect(toList());
+    return info;
+  }
+
   private static void finish(ChangeInfo info) {
     info.id =
         Joiner.on('~')
@@ -612,6 +663,9 @@
 
     out.labels = labelsJson.labelsFor(accountLoader, cd, has(LABELS), has(DETAILED_LABELS));
     out.requirements = requirementsFor(cd);
+    if (has(SUBMIT_REQUIREMENTS)) {
+      out.submitRequirements = submitRequirementsFor(cd);
+    }
 
     if (out.labels != null && has(DETAILED_LABELS)) {
       // If limited to specific patch sets but not the current patch set, don't
diff --git a/java/com/google/gerrit/server/change/IncludedInResolver.java b/java/com/google/gerrit/server/change/IncludedInResolver.java
index 09ca258..9216964 100644
--- a/java/com/google/gerrit/server/change/IncludedInResolver.java
+++ b/java/com/google/gerrit/server/change/IncludedInResolver.java
@@ -26,7 +26,6 @@
 import com.google.common.collect.Lists;
 import com.google.common.flogger.FluentLogger;
 import java.io.IOException;
-import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
 import java.util.HashSet;
@@ -55,20 +54,6 @@
     }
   }
 
-  public static boolean includedInAny(
-      final Repository repo, RevWalk rw, RevCommit commit, Collection<Ref> refs)
-      throws IOException {
-    if (refs.isEmpty()) {
-      return false;
-    }
-    RevFlag flag = newFlag(rw);
-    try {
-      return new IncludedInResolver(repo, rw, commit, flag).includedInOne(refs);
-    } finally {
-      rw.disposeFlag(flag);
-    }
-  }
-
   private static RevFlag newFlag(RevWalk rw) {
     return rw.newFlag("CONTAINS_TARGET");
   }
@@ -104,17 +89,6 @@
         getMatchingRefNames(allMatchingTagsAndBranches, tags));
   }
 
-  private boolean includedInOne(Collection<Ref> refs) throws IOException {
-    parseCommits(refs);
-    List<RevCommit> before = new ArrayList<>();
-    List<RevCommit> after = new ArrayList<>();
-    partition(before, after);
-    rw.reset();
-    // It is highly likely that the target is reachable from the "after" set
-    // Within the "before" set we are trying to handle cases arising from clock skew
-    return !includedIn(after, 1).isEmpty() || !includedIn(before, 1).isEmpty();
-  }
-
   /** Resolves which tip refs include the target commit. */
   private Set<String> includedIn(Collection<RevCommit> tips, int limit)
       throws IOException, MissingObjectException, IncorrectObjectTypeException {
diff --git a/java/com/google/gerrit/server/events/EventFactory.java b/java/com/google/gerrit/server/events/EventFactory.java
index d3faac1..3a4dcff 100644
--- a/java/com/google/gerrit/server/events/EventFactory.java
+++ b/java/com/google/gerrit/server/events/EventFactory.java
@@ -57,11 +57,10 @@
 import com.google.gerrit.server.data.SubmitRequirementAttribute;
 import com.google.gerrit.server.data.TrackingIdAttribute;
 import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.patch.PatchList;
-import com.google.gerrit.server.patch.PatchListCache;
-import com.google.gerrit.server.patch.PatchListEntry;
-import com.google.gerrit.server.patch.PatchListNotAvailableException;
-import com.google.gerrit.server.patch.PatchListObjectTooLargeException;
+import com.google.gerrit.server.patch.DiffNotAvailableException;
+import com.google.gerrit.server.patch.DiffOperations;
+import com.google.gerrit.server.patch.FilePathAdapter;
+import com.google.gerrit.server.patch.filediff.FileDiffOutput;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
 import com.google.inject.Inject;
@@ -84,8 +83,8 @@
 
   private final AccountCache accountCache;
   private final DynamicItem<UrlFormatter> urlFormatter;
+  private final DiffOperations diffOperations;
   private final Emails emails;
-  private final PatchListCache patchListCache;
   private final Provider<PersonIdent> myIdent;
   private final ChangeData.Factory changeDataFactory;
   private final ApprovalsUtil approvalsUtil;
@@ -99,7 +98,7 @@
       AccountCache accountCache,
       Emails emails,
       DynamicItem<UrlFormatter> urlFormatter,
-      PatchListCache patchListCache,
+      DiffOperations diffOperations,
       @GerritPersonIdent Provider<PersonIdent> myIdent,
       ChangeData.Factory changeDataFactory,
       ApprovalsUtil approvalsUtil,
@@ -110,7 +109,7 @@
     this.accountCache = accountCache;
     this.urlFormatter = urlFormatter;
     this.emails = emails;
-    this.patchListCache = patchListCache;
+    this.diffOperations = diffOperations;
     this.myIdent = myIdent;
     this.changeDataFactory = changeDataFactory;
     this.approvalsUtil = approvalsUtil;
@@ -398,23 +397,24 @@
   public void addPatchSetFileNames(
       PatchSetAttribute patchSetAttribute, Change change, PatchSet patchSet) {
     try {
-      PatchList patchList = patchListCache.get(change, patchSet);
-      for (PatchListEntry patch : patchList.getPatches()) {
+      Map<String, FileDiffOutput> modifiedFiles =
+          diffOperations.listModifiedFilesAgainstParent(
+              change.getProject(), patchSet.commitId(), /* parent= */ null);
+
+      for (FileDiffOutput diff : modifiedFiles.values()) {
         if (patchSetAttribute.files == null) {
           patchSetAttribute.files = new ArrayList<>();
         }
 
         PatchAttribute p = new PatchAttribute();
-        p.file = patch.getNewName();
-        p.fileOld = patch.getOldName();
-        p.type = patch.getChangeType();
-        p.deletions -= patch.getDeletions();
-        p.insertions = patch.getInsertions();
+        p.file = FilePathAdapter.getNewPath(diff.oldPath(), diff.newPath(), diff.changeType());
+        p.fileOld = FilePathAdapter.getOldPath(diff.oldPath(), diff.changeType());
+        p.type = diff.changeType();
+        p.deletions -= diff.deletions();
+        p.insertions = diff.insertions();
         patchSetAttribute.files.add(p);
       }
-    } catch (PatchListObjectTooLargeException e) {
-      logger.atWarning().log("Cannot get patch list: %s", e.getMessage());
-    } catch (PatchListNotAvailableException e) {
+    } catch (DiffNotAvailableException e) {
       logger.atSevere().withCause(e).log("Cannot get patch list");
     }
   }
@@ -454,15 +454,17 @@
         p.author = asAccountAttribute(author.getAccount());
       }
 
-      PatchList patchList = patchListCache.get(change, patchSet);
-      p.sizeDeletions = patchList.getDeletions();
-      p.sizeInsertions = patchList.getInsertions();
+      Map<String, FileDiffOutput> modifiedFiles =
+          diffOperations.listModifiedFilesAgainstParent(
+              change.getProject(), patchSet.commitId(), /* parent= */ null);
+      for (FileDiffOutput fileDiff : modifiedFiles.values()) {
+        p.sizeDeletions += fileDiff.deletions();
+        p.sizeInsertions += fileDiff.insertions();
+      }
       p.kind = changeKindCache.getChangeKind(change, patchSet);
     } catch (IOException | StorageException e) {
       logger.atSevere().withCause(e).log("Cannot load patch set data for %s", patchSet.id());
-    } catch (PatchListObjectTooLargeException e) {
-      logger.atWarning().log("Cannot get size information for %s: %s", pId, e.getMessage());
-    } catch (PatchListNotAvailableException e) {
+    } catch (DiffNotAvailableException e) {
       logger.atSevere().withCause(e).log("Cannot get size information for %s.", pId);
     }
     return p;
diff --git a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
index ec2ed4f..6d6c19d2 100644
--- a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
+++ b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
@@ -111,6 +111,7 @@
 import com.google.gerrit.server.RequestListener;
 import com.google.gerrit.server.account.AccountResolver;
 import com.google.gerrit.server.approval.ApprovalsUtil;
+import com.google.gerrit.server.change.AttentionSetUnchangedOp;
 import com.google.gerrit.server.change.ChangeInserter;
 import com.google.gerrit.server.change.NotifyResolver;
 import com.google.gerrit.server.change.SetHashtagsOp;
@@ -1662,6 +1663,12 @@
     @Option(name = "--create-cod-token", usage = "create a token for consistency-on-demand")
     private boolean createCodToken;
 
+    @Option(
+        name = "--ignore-automatic-attention-set-rules",
+        aliases = {"-ias", "-ignore-attention-set"},
+        usage = "do not change the attention set on this push")
+    boolean ignoreAttentionSet;
+
     MagicBranchInput(
         IdentifiedUser user, ProjectState projectState, ReceiveCommand cmd, LabelTypes labelTypes) {
       this.user = user;
@@ -2652,6 +2659,9 @@
           if (!Strings.isNullOrEmpty(magicBranch.topic)) {
             bu.addOp(changeId, setTopicFactory.create(magicBranch.topic));
           }
+          if (magicBranch.ignoreAttentionSet) {
+            bu.addOp(changeId, new AttentionSetUnchangedOp());
+          }
           bu.addOp(
               changeId,
               new BatchUpdateOp() {
diff --git a/java/com/google/gerrit/server/git/receive/ReplaceOp.java b/java/com/google/gerrit/server/git/receive/ReplaceOp.java
index b55e91b..f00b48eb 100644
--- a/java/com/google/gerrit/server/git/receive/ReplaceOp.java
+++ b/java/com/google/gerrit/server/git/receive/ReplaceOp.java
@@ -305,6 +305,9 @@
         change.setWorkInProgress(true);
         update.setWorkInProgress(true);
       }
+      if (magicBranch.ignoreAttentionSet) {
+        update.ignoreFurtherAttentionSetUpdates();
+      }
     }
 
     newPatchSet =
diff --git a/java/com/google/gerrit/server/logging/Metadata.java b/java/com/google/gerrit/server/logging/Metadata.java
index dc9af2b..dbd323b 100644
--- a/java/com/google/gerrit/server/logging/Metadata.java
+++ b/java/com/google/gerrit/server/logging/Metadata.java
@@ -72,6 +72,9 @@
   /** The SHA1 of a commit. */
   public abstract Optional<String> commit();
 
+  /** Diff algorithm used in diff computation. */
+  public abstract Optional<String> diffAlgorithm();
+
   /** The type of an event. */
   public abstract Optional<String> eventType();
 
@@ -295,6 +298,8 @@
 
     public abstract Builder commit(@Nullable String commit);
 
+    public abstract Builder diffAlgorithm(@Nullable String diffAlgorithm);
+
     public abstract Builder eventType(@Nullable String eventType);
 
     public abstract Builder exportValue(@Nullable String exportValue);
diff --git a/java/com/google/gerrit/server/notedb/ChangeUpdate.java b/java/com/google/gerrit/server/notedb/ChangeUpdate.java
index f56e933..93e9c3f 100644
--- a/java/com/google/gerrit/server/notedb/ChangeUpdate.java
+++ b/java/com/google/gerrit/server/notedb/ChangeUpdate.java
@@ -56,6 +56,7 @@
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Address;
 import com.google.gerrit.entities.AttentionSetUpdate;
+import com.google.gerrit.entities.AttentionSetUpdate.Operation;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Comment;
 import com.google.gerrit.entities.HumanComment;
@@ -762,9 +763,7 @@
       }
     }
 
-    if (plannedAttentionSetUpdates != null) {
-      updateAttentionSet(msg);
-    }
+    updateAttentionSet(msg);
 
     CommitBuilder cb = new CommitBuilder();
     cb.setMessage(msg.toString());
@@ -842,7 +841,7 @@
    */
   private void updateAttentionSet(StringBuilder msg) {
     if (plannedAttentionSetUpdates == null) {
-      return;
+      plannedAttentionSetUpdates = new HashMap<>();
     }
     Set<Account.Id> currentUsersInAttentionSet =
         AttentionSetUtil.additionsOnly(getNotes().getAttentionSet()).stream()
@@ -864,6 +863,8 @@
             .map(r -> r.getKey())
             .collect(ImmutableSet.toImmutableSet()));
 
+    removeInactiveUsersFromAttentionSet(currentReviewers);
+
     for (AttentionSetUpdate attentionSetUpdate : plannedAttentionSetUpdates.values()) {
       if (attentionSetUpdate.operation() == AttentionSetUpdate.Operation.ADD
           && currentUsersInAttentionSet.contains(attentionSetUpdate.account())) {
@@ -901,6 +902,38 @@
     }
   }
 
+  private void removeInactiveUsersFromAttentionSet(Set<Account.Id> currentReviewers) {
+    Set<Account.Id> inActiveUsersInTheAttentionSet =
+        // get the current attention set.
+        getNotes().getAttentionSet().stream()
+            .filter(a -> a.operation().equals(Operation.ADD))
+            .map(a -> a.account())
+            // remove users that are currently being removed from the attention set.
+            .filter(
+                a ->
+                    plannedAttentionSetUpdates.getOrDefault(a, /*defaultValue= */ null) == null
+                        || plannedAttentionSetUpdates.get(a).operation().equals(Operation.REMOVE))
+            // remove users that are still active on the change.
+            .filter(a -> !isActiveOnChange(currentReviewers, a))
+            .collect(ImmutableSet.toImmutableSet());
+
+    // We override the flag, as we never want such users in the attention set.
+    ignoreFurtherAttentionSetUpdates = false;
+
+    addToPlannedAttentionSetUpdates(
+        inActiveUsersInTheAttentionSet.stream()
+            .map(
+                a ->
+                    AttentionSetUpdate.createForWrite(
+                        a,
+                        Operation.REMOVE,
+                        /* reason= */ "Only change owner, uploader, reviewers, and cc can "
+                            + "be in the attention set"))
+            .collect(ImmutableSet.toImmutableSet()));
+
+    ignoreFurtherAttentionSetUpdates = true;
+  }
+
   /**
    * Returns whether {@code accountId} is active on a change based on the {@code currentReviewers}.
    * Activity is defined as being a part of the reviewers, an uploader, or an owner of a change.
diff --git a/java/com/google/gerrit/server/patch/SubmitWithStickyApprovalDiff.java b/java/com/google/gerrit/server/patch/SubmitWithStickyApprovalDiff.java
index 18d532b..f33493e 100644
--- a/java/com/google/gerrit/server/patch/SubmitWithStickyApprovalDiff.java
+++ b/java/com/google/gerrit/server/patch/SubmitWithStickyApprovalDiff.java
@@ -34,6 +34,7 @@
 import com.google.gerrit.server.git.LargeObjectException;
 import com.google.gerrit.server.git.validators.CommentCumulativeSizeValidator;
 import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.patch.filediff.FileDiffOutput;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.InvalidChangeOperationException;
 import com.google.gerrit.server.project.ProjectCache;
@@ -41,6 +42,7 @@
 import com.google.inject.Inject;
 import java.io.IOException;
 import java.util.List;
+import java.util.Map;
 import java.util.stream.Collectors;
 import org.eclipse.jgit.diff.Edit;
 import org.eclipse.jgit.lib.Config;
@@ -58,20 +60,20 @@
  * <p>We exclude the magic files from the returned diff to make it shorter and more concise.
  */
 public class SubmitWithStickyApprovalDiff {
+  private final DiffOperations diffOperations;
   private final ProjectCache projectCache;
   private final PatchScriptFactory.Factory patchScriptFactoryFactory;
-  private final PatchListCache patchListCache;
   private final int maxCumulativeSize;
 
   @Inject
   SubmitWithStickyApprovalDiff(
+      DiffOperations diffOperations,
       ProjectCache projectCache,
       PatchScriptFactory.Factory patchScriptFactoryFactory,
-      PatchListCache patchListCache,
       @GerritServerConfig Config serverConfig) {
+    this.diffOperations = diffOperations;
     this.projectCache = projectCache;
     this.patchScriptFactoryFactory = patchScriptFactoryFactory;
-    this.patchListCache = patchListCache;
     maxCumulativeSize =
         serverConfig.getInt(
             "change",
@@ -102,19 +104,19 @@
         new StringBuilder(
             String.format(
                 "\n\n%d is the latest approved patch-set.\n", latestApprovedPatchsetId.get()));
-    PatchList patchList =
-        getPatchList(
+    Map<String, FileDiffOutput> modifiedFiles =
+        listModifiedFiles(
             notes.getProjectName(),
             currentPatchset,
             notes.getPatchSets().get(latestApprovedPatchsetId));
 
     // To make the message a bit more concise, we skip the magic files.
-    List<PatchListEntry> patchListEntryList =
-        patchList.getPatches().stream()
-            .filter(p -> !Patch.isMagic(p.getNewName()))
+    List<FileDiffOutput> modifiedFilesList =
+        modifiedFiles.values().stream()
+            .filter(p -> !Patch.isMagic(p.newPath().orElse("")))
             .collect(Collectors.toList());
 
-    if (patchListEntryList.isEmpty()) {
+    if (modifiedFilesList.isEmpty()) {
       diff.append(
           "No files were changed between the latest approved patch-set and the submitted one.\n");
       return diff.toString();
@@ -122,10 +124,10 @@
 
     diff.append("The change was submitted with unreviewed changes in the following files:\n\n");
 
-    for (PatchListEntry patchListEntry : patchListEntryList) {
+    for (FileDiffOutput fileDiff : modifiedFilesList) {
       diff.append(
           getDiffForFile(
-              notes, currentPatchset.id(), latestApprovedPatchsetId, patchListEntry, currentUser));
+              notes, currentPatchset.id(), latestApprovedPatchsetId, fileDiff, currentUser));
     }
     if (diff.length() > maxCumulativeSize) {
       // The diff length is not counted as part of the limit (for technical reasons, since we'd
@@ -144,7 +146,7 @@
       ChangeNotes notes,
       PatchSet.Id currentPatchsetId,
       PatchSet.Id latestApprovedPatchsetId,
-      PatchListEntry patchListEntry,
+      FileDiffOutput fileDiffOutput,
       CurrentUser currentUser)
       throws AuthException, InvalidChangeOperationException, IOException,
           PermissionBackendException {
@@ -152,14 +154,18 @@
         new StringBuilder(
             String.format(
                 "The name of the file: %s\nInsertions: %d, Deletions: %d.\n\n",
-                patchListEntry.getNewName(),
-                patchListEntry.getInsertions(),
-                patchListEntry.getDeletions()));
+                fileDiffOutput.newPath().isPresent()
+                    ? fileDiffOutput.newPath().get()
+                    : fileDiffOutput.oldPath().get(),
+                fileDiffOutput.insertions(),
+                fileDiffOutput.deletions()));
     DiffPreferencesInfo diffPreferencesInfo = createDefaultDiffPreferencesInfo();
     PatchScriptFactory patchScriptFactory =
         patchScriptFactoryFactory.create(
             notes,
-            patchListEntry.getNewName(),
+            fileDiffOutput.newPath().isPresent()
+                ? fileDiffOutput.newPath().get()
+                : fileDiffOutput.oldPath().get(),
             latestApprovedPatchsetId,
             currentPatchsetId,
             diffPreferencesInfo,
@@ -175,7 +181,7 @@
       diff.append(
           String.format(
               "The file %s was renamed to %s\n",
-              patchListEntry.getOldName(), patchListEntry.getNewName()));
+              fileDiffOutput.oldPath().get(), fileDiffOutput.newPath().get()));
     }
     SparseFileContent.Accessor fileA = patchScript.getA().createAccessor();
     SparseFileContent.Accessor fileB = patchScript.getB().createAccessor();
@@ -259,16 +265,14 @@
   }
 
   /**
-   * Gets the {@link PatchList} between the two latest patch-sets. Can be used to compute difference
-   * in files between those two patch-sets .
+   * Gets the list of modified files between the two latest patch-sets. Can be used to compute
+   * difference in files between those two patch-sets.
    */
-  private PatchList getPatchList(Project.NameKey project, PatchSet ps, PatchSet priorPatchSet) {
-    PatchListKey key =
-        PatchListKey.againstCommit(
-            priorPatchSet.commitId(), ps.commitId(), DiffPreferencesInfo.Whitespace.IGNORE_NONE);
+  private Map<String, FileDiffOutput> listModifiedFiles(
+      Project.NameKey project, PatchSet ps, PatchSet priorPatchSet) {
     try {
-      return patchListCache.get(key, project);
-    } catch (PatchListNotAvailableException ex) {
+      return diffOperations.listModifiedFiles(project, priorPatchSet.commitId(), ps.commitId());
+    } catch (DiffNotAvailableException ex) {
       throw new StorageException(
           "failed to compute difference in files, so won't post diff messsage on submit although "
               + "the latest approved patch-set was not the same as the submitted patch-set.",
diff --git a/java/com/google/gerrit/server/patch/filediff/FileDiffCacheImpl.java b/java/com/google/gerrit/server/patch/filediff/FileDiffCacheImpl.java
index 0794775..ee5e156 100644
--- a/java/com/google/gerrit/server/patch/filediff/FileDiffCacheImpl.java
+++ b/java/com/google/gerrit/server/patch/filediff/FileDiffCacheImpl.java
@@ -32,6 +32,9 @@
 import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
 import com.google.gerrit.server.cache.CacheModule;
 import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.logging.Metadata;
+import com.google.gerrit.server.logging.TraceContext;
+import com.google.gerrit.server.logging.TraceContext.TraceTimer;
 import com.google.gerrit.server.patch.AutoMerger;
 import com.google.gerrit.server.patch.ComparisonType;
 import com.google.gerrit.server.patch.DiffNotAvailableException;
@@ -96,7 +99,7 @@
         persist(DIFF, FileDiffCacheKey.class, FileDiffOutput.class)
             .maximumWeight(10 << 20)
             .weigher(FileDiffWeigher.class)
-            .version(5)
+            .version(6)
             .keySerializer(FileDiffCacheKey.Serializer.INSTANCE)
             .valueSerializer(FileDiffOutput.Serializer.INSTANCE)
             .loader(FileDiffLoader.class);
@@ -154,39 +157,48 @@
 
     @Override
     public FileDiffOutput load(FileDiffCacheKey key) throws IOException, DiffNotAvailableException {
-      return loadAll(ImmutableList.of(key)).get(key);
+      try (TraceTimer timer =
+          TraceContext.newTimer(
+              "Loading a single key from file diff cache",
+              Metadata.builder().filePath(key.newFilePath()).build())) {
+        return loadAll(ImmutableList.of(key)).get(key);
+      }
     }
 
     @Override
     public Map<FileDiffCacheKey, FileDiffOutput> loadAll(Iterable<? extends FileDiffCacheKey> keys)
         throws DiffNotAvailableException {
-      ImmutableMap.Builder<FileDiffCacheKey, FileDiffOutput> result = ImmutableMap.builder();
+      try (TraceTimer timer = TraceContext.newTimer("Loading multiple keys from file diff cache")) {
+        ImmutableMap.Builder<FileDiffCacheKey, FileDiffOutput> result = ImmutableMap.builder();
 
-      Map<Project.NameKey, List<FileDiffCacheKey>> keysByProject =
-          Streams.stream(keys).distinct().collect(Collectors.groupingBy(FileDiffCacheKey::project));
+        Map<Project.NameKey, List<FileDiffCacheKey>> keysByProject =
+            Streams.stream(keys)
+                .distinct()
+                .collect(Collectors.groupingBy(FileDiffCacheKey::project));
 
-      for (Project.NameKey project : keysByProject.keySet()) {
-        List<FileDiffCacheKey> fileKeys = new ArrayList<>();
+        for (Project.NameKey project : keysByProject.keySet()) {
+          List<FileDiffCacheKey> fileKeys = new ArrayList<>();
 
-        try (Repository repo = repoManager.openRepository(project);
-            ObjectReader reader = repo.newObjectReader();
-            RevWalk rw = new RevWalk(reader)) {
+          try (Repository repo = repoManager.openRepository(project);
+              ObjectReader reader = repo.newObjectReader();
+              RevWalk rw = new RevWalk(reader)) {
 
-          for (FileDiffCacheKey key : keysByProject.get(project)) {
-            if (key.newFilePath().equals(Patch.COMMIT_MSG)) {
-              result.put(key, createMagicPathEntry(key, reader, rw, MagicPath.COMMIT));
-            } else if (key.newFilePath().equals(Patch.MERGE_LIST)) {
-              result.put(key, createMagicPathEntry(key, reader, rw, MagicPath.MERGE_LIST));
-            } else {
-              fileKeys.add(key);
+            for (FileDiffCacheKey key : keysByProject.get(project)) {
+              if (key.newFilePath().equals(Patch.COMMIT_MSG)) {
+                result.put(key, createMagicPathEntry(key, reader, rw, MagicPath.COMMIT));
+              } else if (key.newFilePath().equals(Patch.MERGE_LIST)) {
+                result.put(key, createMagicPathEntry(key, reader, rw, MagicPath.MERGE_LIST));
+              } else {
+                fileKeys.add(key);
+              }
             }
+            result.putAll(createFileEntries(reader, fileKeys, rw));
+          } catch (IOException e) {
+            logger.atWarning().log("Failed to open the repository %s: %s", project, e.getMessage());
           }
-          result.putAll(createFileEntries(reader, fileKeys, rw));
-        } catch (IOException e) {
-          logger.atWarning().log("Failed to open the repository %s: %s", project, e.getMessage());
         }
+        return result.build();
       }
-      return result.build();
     }
 
     private ComparisonType getComparisonType(
diff --git a/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiffCacheImpl.java b/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiffCacheImpl.java
index 4bc6b87..31afd17 100644
--- a/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiffCacheImpl.java
+++ b/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiffCacheImpl.java
@@ -28,6 +28,9 @@
 import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
 import com.google.gerrit.server.cache.CacheModule;
 import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.logging.Metadata;
+import com.google.gerrit.server.logging.TraceContext;
+import com.google.gerrit.server.logging.TraceContext.TraceTimer;
 import com.google.gerrit.server.patch.DiffNotAvailableException;
 import com.google.inject.Inject;
 import com.google.inject.Module;
@@ -67,6 +70,7 @@
             .weigher(GitFileDiffWeigher.class)
             .keySerializer(GitFileDiffCacheKey.Serializer.INSTANCE)
             .valueSerializer(GitFileDiff.Serializer.INSTANCE)
+            .version(2)
             .loader(GitFileDiffCacheImpl.Loader.class);
       }
     };
@@ -136,35 +140,47 @@
 
     @Override
     public GitFileDiff load(GitFileDiffCacheKey key) throws IOException {
-      return loadAll(ImmutableList.of(key)).get(key);
+      try (TraceTimer timer =
+          TraceContext.newTimer(
+              "Loading a single key from git file diff cache",
+              Metadata.builder()
+                  .diffAlgorithm(key.diffAlgorithm().name())
+                  .filePath(key.newFilePath())
+                  .build())) {
+        return loadAll(ImmutableList.of(key)).get(key);
+      }
     }
 
     @Override
     public Map<GitFileDiffCacheKey, GitFileDiff> loadAll(
         Iterable<? extends GitFileDiffCacheKey> keys) throws IOException {
-      ImmutableMap.Builder<GitFileDiffCacheKey, GitFileDiff> result =
-          ImmutableMap.builderWithExpectedSize(Iterables.size(keys));
+      try (TraceTimer timer =
+          TraceContext.newTimer("Loading multiple keys from git file diff cache")) {
+        ImmutableMap.Builder<GitFileDiffCacheKey, GitFileDiff> result =
+            ImmutableMap.builderWithExpectedSize(Iterables.size(keys));
 
-      Map<Project.NameKey, List<GitFileDiffCacheKey>> byProject =
-          Streams.stream(keys)
-              .distinct()
-              .collect(Collectors.groupingBy(GitFileDiffCacheKey::project));
+        Map<Project.NameKey, List<GitFileDiffCacheKey>> byProject =
+            Streams.stream(keys)
+                .distinct()
+                .collect(Collectors.groupingBy(GitFileDiffCacheKey::project));
 
-      for (Map.Entry<Project.NameKey, List<GitFileDiffCacheKey>> entry : byProject.entrySet()) {
-        try (Repository repo = repoManager.openRepository(entry.getKey());
-            ObjectReader reader = repo.newObjectReader()) {
+        for (Map.Entry<Project.NameKey, List<GitFileDiffCacheKey>> entry : byProject.entrySet()) {
+          try (Repository repo = repoManager.openRepository(entry.getKey());
+              ObjectReader reader = repo.newObjectReader()) {
 
-          // Grouping keys by diff options because each group of keys will be processed with a
-          // separate call to JGit using the DiffFormatter object.
-          Map<DiffOptions, List<GitFileDiffCacheKey>> optionsGroups =
-              entry.getValue().stream().collect(Collectors.groupingBy(DiffOptions::fromKey));
+            // Grouping keys by diff options because each group of keys will be processed with a
+            // separate call to JGit using the DiffFormatter object.
+            Map<DiffOptions, List<GitFileDiffCacheKey>> optionsGroups =
+                entry.getValue().stream().collect(Collectors.groupingBy(DiffOptions::fromKey));
 
-          for (Map.Entry<DiffOptions, List<GitFileDiffCacheKey>> group : optionsGroups.entrySet()) {
-            result.putAll(loadAllImpl(repo, reader, group.getKey(), group.getValue()));
+            for (Map.Entry<DiffOptions, List<GitFileDiffCacheKey>> group :
+                optionsGroups.entrySet()) {
+              result.putAll(loadAllImpl(repo, reader, group.getKey(), group.getValue()));
+            }
           }
         }
+        return result.build();
       }
-      return result.build();
     }
 
     /**
@@ -221,6 +237,7 @@
           diffFormatter.getRenameDetector().setRenameScore(diffOptions.renameScore());
         }
         diffFormatter.setDiffAlgorithm(DiffAlgorithmFactory.create(diffOptions.diffAlgorithm()));
+        diffFormatter.getRenameDetector().setSkipContentRenamesForBinaryFiles(true);
         return diffFormatter;
       }
     }
diff --git a/java/com/google/gerrit/server/project/ProjectConfig.java b/java/com/google/gerrit/server/project/ProjectConfig.java
index 9f898d9..3253282 100644
--- a/java/com/google/gerrit/server/project/ProjectConfig.java
+++ b/java/com/google/gerrit/server/project/ProjectConfig.java
@@ -529,6 +529,10 @@
     return submitRequirementSections;
   }
 
+  public void upsertSubmitRequirement(SubmitRequirement requirement) {
+    submitRequirementSections.put(requirement.name(), requirement);
+  }
+
   /** Adds or replaces the given {@link LabelType} in this config. */
   public void upsertLabelType(LabelType labelType) {
     labelSections.put(labelType.getName(), labelType);
diff --git a/java/com/google/gerrit/server/project/ProjectState.java b/java/com/google/gerrit/server/project/ProjectState.java
index 03d38b3..4569027 100644
--- a/java/com/google/gerrit/server/project/ProjectState.java
+++ b/java/com/google/gerrit/server/project/ProjectState.java
@@ -22,6 +22,7 @@
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.collect.FluentIterable;
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Lists;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.AccessSection;
@@ -37,6 +38,7 @@
 import com.google.gerrit.entities.PermissionRule;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.StoredCommentLinkInfo;
+import com.google.gerrit.entities.SubmitRequirement;
 import com.google.gerrit.entities.SubscribeSection;
 import com.google.gerrit.extensions.api.projects.CommentLinkInfo;
 import com.google.gerrit.extensions.client.SubmitType;
@@ -392,6 +394,21 @@
     return false;
   }
 
+  /** Get all submit requirements for a project, including those from parent projects. */
+  public Map<String, SubmitRequirement> getSubmitRequirements() {
+    Map<String, SubmitRequirement> requirements = new LinkedHashMap<>();
+    for (ProjectState s : treeInOrder()) {
+      for (SubmitRequirement requirement : s.getConfig().getSubmitRequirementSections().values()) {
+        String lowerName = requirement.name().toLowerCase();
+        SubmitRequirement old = requirements.get(lowerName);
+        if (old == null || old.allowOverrideInChildProjects()) {
+          requirements.put(lowerName, requirement);
+        }
+      }
+    }
+    return ImmutableMap.copyOf(requirements);
+  }
+
   /** All available label types. */
   public LabelTypes getLabelTypes() {
     Map<String, LabelType> types = new LinkedHashMap<>();
diff --git a/java/com/google/gerrit/server/project/Reachable.java b/java/com/google/gerrit/server/project/Reachable.java
index 331b7da..ea50f21 100644
--- a/java/com/google/gerrit/server/project/Reachable.java
+++ b/java/com/google/gerrit/server/project/Reachable.java
@@ -14,10 +14,10 @@
 
 package com.google.gerrit.server.project;
 
+import com.google.common.collect.ImmutableList;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.change.IncludedInResolver;
 import com.google.gerrit.server.logging.Metadata;
 import com.google.gerrit.server.logging.TraceContext;
 import com.google.gerrit.server.logging.TraceContext.TraceTimer;
@@ -27,11 +27,13 @@
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
+import java.util.ArrayList;
 import java.util.Collection;
 import java.util.List;
 import java.util.Optional;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.ReachabilityChecker;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
 
@@ -73,14 +75,33 @@
               .orElse(permissionBackend.currentUser())
               .project(project)
               .filter(refs, repo, RefFilterOptions.defaults());
+      Collection<RevCommit> visible = new ArrayList<>();
+      for (Ref r : filtered) {
+        try {
+          visible.add(rw.parseCommit(r.getObjectId()));
+        } catch (org.eclipse.jgit.errors.IncorrectObjectTypeException notCommit) {
+          // Its OK for a tag reference to point to a blob or a tree, this
+          // is common in the Linux kernel or git.git repository.
+          continue;
+        } catch (org.eclipse.jgit.errors.MissingObjectException notHere) {
+          // Log the problem with this branch, but keep processing.
+          logger.atWarning().log(
+              "Reference %s in %s points to dangling object %s",
+              r.getName(), repo.getDirectory(), r.getObjectId());
+          continue;
+        }
+      }
 
       // The filtering above already produces a voluminous trace. To separate the permission check
       // from the reachability check, do the trace here:
       try (TraceTimer timer =
           TraceContext.newTimer(
-              "IncludedInResolver.includedInAny",
+              "ReachabilityChecker.areAllReachable",
               Metadata.builder().projectName(project.get()).resourceCount(refs.size()).build())) {
-        return IncludedInResolver.includedInAny(repo, rw, commit, filtered);
+        ReachabilityChecker checker = rw.getObjectReader().createReachabilityChecker(rw);
+        Optional<RevCommit> unreachable =
+            checker.areAllReachable(ImmutableList.of(rw.parseCommit(commit)), visible.stream());
+        return !unreachable.isPresent();
       }
     } catch (IOException | PermissionBackendException e) {
       logger.atSevere().withCause(e).log(
diff --git a/java/com/google/gerrit/server/query/approval/ApprovalQueryBuilder.java b/java/com/google/gerrit/server/query/approval/ApprovalQueryBuilder.java
index c3594f5..819f319 100644
--- a/java/com/google/gerrit/server/query/approval/ApprovalQueryBuilder.java
+++ b/java/com/google/gerrit/server/query/approval/ApprovalQueryBuilder.java
@@ -51,7 +51,7 @@
   }
 
   @Operator
-  public Predicate<ApprovalContext> changeKind(String term) throws QueryParseException {
+  public Predicate<ApprovalContext> changekind(String term) throws QueryParseException {
     return new ChangeKindPredicate(toEnumValue(ChangeKind.class, term));
   }
 
@@ -85,13 +85,12 @@
       throws QueryParseException {
     try {
       return Enum.valueOf(clazz, term.toUpperCase().replace('-', '_'));
-    } catch (
-        @SuppressWarnings("UnusedException")
-        IllegalArgumentException unused) {
+    } catch (IllegalArgumentException e) {
       throw new QueryParseException(
           String.format(
               "%s is not a valid term. valid options: %s",
-              term, Arrays.asList(clazz.getEnumConstants())));
+              term, Arrays.asList(clazz.getEnumConstants())),
+          e);
     }
   }
 
diff --git a/java/com/google/gerrit/server/query/approval/ListOfFilesUnchangedPredicate.java b/java/com/google/gerrit/server/query/approval/ListOfFilesUnchangedPredicate.java
index 30097d8..459a8b0 100644
--- a/java/com/google/gerrit/server/query/approval/ListOfFilesUnchangedPredicate.java
+++ b/java/com/google/gerrit/server/query/approval/ListOfFilesUnchangedPredicate.java
@@ -17,12 +17,10 @@
 import com.google.gerrit.entities.Patch.ChangeType;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.extensions.client.DiffPreferencesInfo;
 import com.google.gerrit.index.query.Predicate;
-import com.google.gerrit.server.patch.PatchList;
-import com.google.gerrit.server.patch.PatchListCache;
-import com.google.gerrit.server.patch.PatchListKey;
-import com.google.gerrit.server.patch.PatchListNotAvailableException;
+import com.google.gerrit.server.patch.DiffNotAvailableException;
+import com.google.gerrit.server.patch.DiffOperations;
+import com.google.gerrit.server.patch.filediff.FileDiffOutput;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.util.Collection;
@@ -32,11 +30,11 @@
 /** Predicate that matches when the new patch-set includes the same files as the old patch-set. */
 @Singleton
 public class ListOfFilesUnchangedPredicate extends ApprovalPredicate {
-  private final PatchListCache patchListCache;
+  private final DiffOperations diffOperations;
 
   @Inject
-  public ListOfFilesUnchangedPredicate(PatchListCache patchListCache) {
-    this.patchListCache = patchListCache;
+  public ListOfFilesUnchangedPredicate(DiffOperations diffOperations) {
+    this.diffOperations = diffOperations;
   }
 
   @Override
@@ -44,14 +42,13 @@
     PatchSet currentPatchset = ctx.changeNotes().getCurrentPatchSet();
     Map.Entry<PatchSet.Id, PatchSet> priorPatchSet =
         ctx.changeNotes().getPatchSets().lowerEntry(currentPatchset.id());
-    PatchListKey key =
-        PatchListKey.againstCommit(
-            priorPatchSet.getValue().commitId(),
-            currentPatchset.commitId(),
-            DiffPreferencesInfo.Whitespace.IGNORE_NONE);
     try {
-      return match(patchListCache.get(key, ctx.changeNotes().getProjectName()));
-    } catch (PatchListNotAvailableException ex) {
+      return match(
+          diffOperations.listModifiedFiles(
+              ctx.changeNotes().getProjectName(),
+              priorPatchSet.getValue().commitId(),
+              currentPatchset.commitId()));
+    } catch (DiffNotAvailableException ex) {
       throw new StorageException(
           "failed to compute difference in files, so won't copy"
               + " votes on labels even if list of files is the same and "
@@ -60,24 +57,24 @@
     }
   }
 
-  public boolean match(PatchList patchList) {
-    return patchList.getPatches().stream()
+  public boolean match(Map<String, FileDiffOutput> modifiedFiles) {
+    return modifiedFiles.values().stream()
         .noneMatch(
             p ->
-                p.getChangeType() == ChangeType.ADDED
-                    || p.getChangeType() == ChangeType.DELETED
-                    || p.getChangeType() == ChangeType.RENAMED);
+                p.changeType() == ChangeType.ADDED
+                    || p.changeType() == ChangeType.DELETED
+                    || p.changeType() == ChangeType.RENAMED);
   }
 
   @Override
   public Predicate<ApprovalContext> copy(
       Collection<? extends Predicate<ApprovalContext>> children) {
-    return new ListOfFilesUnchangedPredicate(patchListCache);
+    return new ListOfFilesUnchangedPredicate(diffOperations);
   }
 
   @Override
   public int hashCode() {
-    return Objects.hash(patchListCache);
+    return Objects.hash(diffOperations);
   }
 
   @Override
@@ -86,6 +83,6 @@
       return false;
     }
     ListOfFilesUnchangedPredicate o = (ListOfFilesUnchangedPredicate) other;
-    return Objects.equals(o.patchListCache, patchListCache);
+    return Objects.equals(o.diffOperations, diffOperations);
   }
 }
diff --git a/java/com/google/gerrit/server/query/change/ChangeData.java b/java/com/google/gerrit/server/query/change/ChangeData.java
index 6f8b097..ac28342 100644
--- a/java/com/google/gerrit/server/query/change/ChangeData.java
+++ b/java/com/google/gerrit/server/query/change/ChangeData.java
@@ -53,6 +53,8 @@
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.entities.RobotComment;
 import com.google.gerrit.entities.SubmitRecord;
+import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.entities.SubmitRequirementResult;
 import com.google.gerrit.entities.SubmitTypeRecord;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
@@ -86,6 +88,7 @@
 import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
+import com.google.gerrit.server.project.SubmitRequirementsEvaluator;
 import com.google.gerrit.server.project.SubmitRuleEvaluator;
 import com.google.gerrit.server.project.SubmitRuleOptions;
 import com.google.gerrit.server.util.time.TimeUtil;
@@ -265,7 +268,7 @@
     ChangeData cd =
         new ChangeData(
             null, null, null, null, null, null, null, null, null, null, null, null, null, null,
-            null, project, id, null, null);
+            null, null, project, id, null, null);
     cd.currentPatchSet =
         PatchSet.builder()
             .id(PatchSet.id(id, currentPatchSetId))
@@ -291,6 +294,7 @@
   private final ProjectCache projectCache;
   private final TrackingFooters trackingFooters;
   private final PureRevert pureRevert;
+  private final SubmitRequirementsEvaluator submitRequirementsEvaluator;
   private final SubmitRuleEvaluator.Factory submitRuleEvaluatorFactory;
 
   // Required assisted injected fields.
@@ -302,6 +306,8 @@
   private final Map<SubmitRuleOptions, List<SubmitRecord>> submitRecords =
       Maps.newLinkedHashMapWithExpectedSize(1);
 
+  private Map<SubmitRequirement, SubmitRequirementResult> submitRequirements;
+
   private StorageConstraint storageConstraint = StorageConstraint.NOTEDB_ONLY;
   private Change change;
   private ChangeNotes notes;
@@ -365,6 +371,7 @@
       ProjectCache projectCache,
       TrackingFooters trackingFooters,
       PureRevert pureRevert,
+      SubmitRequirementsEvaluator submitRequirementsEvaluator,
       SubmitRuleEvaluator.Factory submitRuleEvaluatorFactory,
       @Assisted Project.NameKey project,
       @Assisted Change.Id id,
@@ -384,6 +391,7 @@
     this.starredChangesUtil = starredChangesUtil;
     this.trackingFooters = trackingFooters;
     this.pureRevert = pureRevert;
+    this.submitRequirementsEvaluator = submitRequirementsEvaluator;
     this.submitRuleEvaluatorFactory = submitRuleEvaluatorFactory;
 
     this.project = project;
@@ -920,6 +928,21 @@
     return messages;
   }
 
+  /** Get all submit requirements for this change, including those from parent projects. */
+  public Map<SubmitRequirement, SubmitRequirementResult> submitRequirements() {
+    if (submitRequirements == null) {
+      ProjectState state = projectCache.get(project()).orElseThrow(illegalState(project()));
+      Map<String, SubmitRequirement> requirements = state.getSubmitRequirements();
+      ImmutableMap.Builder<SubmitRequirement, SubmitRequirementResult> result =
+          ImmutableMap.builderWithExpectedSize(requirements.size());
+      for (SubmitRequirement requirement : requirements.values()) {
+        result.put(requirement, submitRequirementsEvaluator.evaluate(requirement, this));
+      }
+      submitRequirements = result.build();
+    }
+    return submitRequirements;
+  }
+
   public List<SubmitRecord> submitRecords(SubmitRuleOptions options) {
     // If the change is not submitted yet, 'strict' and 'lenient' both have the same result. If the
     // change is submitted, SubmitRecord requested with 'strict' will contain just a single entry
diff --git a/java/com/google/gerrit/server/restapi/change/CreateChange.java b/java/com/google/gerrit/server/restapi/change/CreateChange.java
index f48c7b8..fa47bef 100644
--- a/java/com/google/gerrit/server/restapi/change/CreateChange.java
+++ b/java/com/google/gerrit/server/restapi/change/CreateChange.java
@@ -19,6 +19,7 @@
 
 import com.google.common.base.Joiner;
 import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.collect.Iterables;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
@@ -387,6 +388,17 @@
       ins.setPrivate(input.isPrivate);
       ins.setWorkInProgress(input.workInProgress || !c.getFilesWithGitConflicts().isEmpty());
       ins.setGroups(groups);
+
+      if (input.validationOptions != null) {
+        ImmutableListMultimap.Builder<String, String> validationOptions =
+            ImmutableListMultimap.builder();
+        input
+            .validationOptions
+            .entrySet()
+            .forEach(e -> validationOptions.put(e.getKey(), e.getValue()));
+        ins.setValidationOptions(validationOptions.build());
+      }
+
       try (BatchUpdate bu = updateFactory.create(projectState.getNameKey(), me, now)) {
         bu.setRepository(git, rw, oi);
         bu.setNotify(
diff --git a/java/com/google/gerrit/server/restapi/change/Files.java b/java/com/google/gerrit/server/restapi/change/Files.java
index f82284e..1efe378 100644
--- a/java/com/google/gerrit/server/restapi/change/Files.java
+++ b/java/com/google/gerrit/server/restapi/change/Files.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.restapi.change;
 
+import com.google.common.collect.Iterables;
 import com.google.common.collect.Lists;
 import com.google.common.flogger.FluentLogger;
 import com.google.common.hash.Hasher;
@@ -43,11 +44,11 @@
 import com.google.gerrit.server.change.FileResource;
 import com.google.gerrit.server.change.RevisionResource;
 import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.patch.PatchList;
-import com.google.gerrit.server.patch.PatchListCache;
+import com.google.gerrit.server.patch.DiffNotAvailableException;
+import com.google.gerrit.server.patch.DiffOperations;
 import com.google.gerrit.server.patch.PatchListKey;
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
-import com.google.gerrit.server.patch.PatchListObjectTooLargeException;
+import com.google.gerrit.server.patch.filediff.FileDiffOutput;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.plugincontext.PluginItemContext;
 import com.google.inject.Inject;
@@ -63,6 +64,7 @@
 import java.util.Set;
 import java.util.concurrent.TimeUnit;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectReader;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
@@ -112,30 +114,30 @@
     @Option(name = "-q")
     String query;
 
+    private final DiffOperations diffOperations;
     private final Provider<CurrentUser> self;
     private final FileInfoJson fileInfoJson;
     private final Revisions revisions;
     private final GitRepositoryManager gitManager;
-    private final PatchListCache patchListCache;
     private final PatchSetUtil psUtil;
     private final PluginItemContext<AccountPatchReviewStore> accountPatchReviewStore;
     private final GerritApi gApi;
 
     @Inject
     ListFiles(
+        DiffOperations diffOperations,
         Provider<CurrentUser> self,
         FileInfoJson fileInfoJson,
         Revisions revisions,
         GitRepositoryManager gitManager,
-        PatchListCache patchListCache,
         PatchSetUtil psUtil,
         PluginItemContext<AccountPatchReviewStore> accountPatchReviewStore,
         GerritApi gApi) {
+      this.diffOperations = diffOperations;
       this.self = self;
       this.fileInfoJson = fileInfoJson;
       this.revisions = revisions;
       this.gitManager = gitManager;
-      this.patchListCache = patchListCache;
       this.psUtil = psUtil;
       this.accountPatchReviewStore = accountPatchReviewStore;
       this.gApi = gApi;
@@ -252,9 +254,7 @@
 
         try {
           return copy(res.files(), res.patchSetId(), resource, userId);
-        } catch (PatchListObjectTooLargeException e) {
-          logger.atWarning().log("Cannot copy patch review flags: %s", e.getMessage());
-        } catch (IOException | PatchListNotAvailableException e) {
+        } catch (IOException | DiffNotAvailableException e) {
           logger.atWarning().withCause(e).log("Cannot copy patch review flags");
         }
       }
@@ -264,7 +264,7 @@
 
     private List<String> copy(
         Set<String> paths, PatchSet.Id old, RevisionResource resource, Account.Id userId)
-        throws IOException, PatchListNotAvailableException {
+        throws IOException, DiffNotAvailableException {
       Project.NameKey project = resource.getChange().getProject();
       try (Repository git = gitManager.openRepository(project);
           ObjectReader reader = git.newObjectReader();
@@ -273,31 +273,35 @@
         Change change = resource.getChange();
         PatchSet patchSet = psUtil.get(resource.getNotes(), old);
         if (patchSet == null) {
-          throw new PatchListNotAvailableException(
+          throw new DiffNotAvailableException(
               String.format(
                   "patch set %s of change %s not found", old.get(), change.getId().get()));
         }
 
-        PatchList oldList = patchListCache.get(change, patchSet);
+        Map<String, FileDiffOutput> oldList =
+            diffOperations.listModifiedFilesAgainstParent(
+                project, patchSet.commitId(), /* parentNum= */ null);
 
-        PatchList curList = patchListCache.get(change, resource.getPatchSet());
+        Map<String, FileDiffOutput> curList =
+            diffOperations.listModifiedFilesAgainstParent(
+                project, resource.getPatchSet().commitId(), /* parentNum= */ null);
 
         int sz = paths.size();
         List<String> pathList = Lists.newArrayListWithCapacity(sz);
 
         tw.setFilter(PathFilterGroup.createFromStrings(paths));
         tw.setRecursive(true);
-        int o = tw.addTree(rw.parseCommit(oldList.getNewId()).getTree());
-        int c = tw.addTree(rw.parseCommit(curList.getNewId()).getTree());
+        int o = tw.addTree(rw.parseCommit(getNewId(oldList)).getTree());
+        int c = tw.addTree(rw.parseCommit(getNewId(curList)).getTree());
 
         int op = -1;
-        if (oldList.getOldId() != null) {
-          op = tw.addTree(rw.parseTree(oldList.getOldId()));
+        if (getOldId(oldList) != null) {
+          op = tw.addTree(rw.parseTree(getOldId(oldList)));
         }
 
         int cp = -1;
-        if (curList.getOldId() != null) {
-          cp = tw.addTree(rw.parseTree(curList.getOldId()));
+        if (getOldId(curList) != null) {
+          cp = tw.addTree(rw.parseTree(getOldId(curList)));
         }
 
         while (tw.next()) {
@@ -354,5 +358,18 @@
       h.putLong(PatchListKey.serialVersionUID);
       return h.hash().toString();
     }
+
+    @Nullable
+    private ObjectId getOldId(Map<String, FileDiffOutput> fileDiffList) {
+      return fileDiffList.isEmpty()
+          ? null
+          : Iterables.getFirst(fileDiffList.values(), null).oldCommitId();
+    }
+
+    private ObjectId getNewId(Map<String, FileDiffOutput> fileDiffList) {
+      return fileDiffList.isEmpty()
+          ? null
+          : Iterables.getFirst(fileDiffList.values(), null).newCommitId();
+    }
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/ReviewerRecommender.java b/java/com/google/gerrit/server/restapi/change/ReviewerRecommender.java
index 9e74eec..7f7c1ad 100644
--- a/java/com/google/gerrit/server/restapi/change/ReviewerRecommender.java
+++ b/java/com/google/gerrit/server/restapi/change/ReviewerRecommender.java
@@ -216,7 +216,7 @@
 
       for (ChangeData cd : result) {
         for (Account.Id reviewer : cd.reviewers().all()) {
-          if (Strings.isNullOrEmpty(query) || accountMatchesQuery(reviewer, query)) {
+          if (accountMatchesQuery(reviewer, query)) {
             suggestions
                 .computeIfAbsent(reviewer, (ignored) -> new MutableDouble(0))
                 .add(baseWeight);
@@ -234,7 +234,8 @@
   private boolean accountMatchesQuery(Account.Id id, String query) {
     Optional<Account> account = accountCache.get(id).map(AccountState::account);
     if (account.isPresent() && account.get().isActive()) {
-      if ((account.get().fullName() != null && account.get().fullName().startsWith(query))
+      if (Strings.isNullOrEmpty(query)
+          || (account.get().fullName() != null && account.get().fullName().startsWith(query))
           || (account.get().preferredEmail() != null
               && account.get().preferredEmail().startsWith(query))) {
         return true;
diff --git a/java/com/google/gerrit/sshd/commands/ConvertRefStorage.java b/java/com/google/gerrit/sshd/commands/ConvertRefStorage.java
index 21d90ed..98626de 100644
--- a/java/com/google/gerrit/sshd/commands/ConvertRefStorage.java
+++ b/java/com/google/gerrit/sshd/commands/ConvertRefStorage.java
@@ -31,6 +31,7 @@
 import org.eclipse.jgit.internal.storage.file.FileRepository;
 import org.eclipse.jgit.lib.Repository;
 import org.kohsuke.args4j.Option;
+import org.kohsuke.args4j.spi.ExplicitBooleanOptionHandler;
 
 @RequiresCapability(GlobalCapability.ADMINISTRATE_SERVER)
 @CommandMetaData(
@@ -45,21 +46,21 @@
     refdir,
   }
 
-  @Option(
-      name = "--format",
-      usage = "storage format to convert to (reftable or refdir) (default: reftable)")
+  @Option(name = "--format", usage = "storage format to convert to (reftable or refdir)")
   private StorageFormatOption storageFormat = StorageFormatOption.reftable;
 
   @Option(
       name = "--backup",
       aliases = {"-b"},
-      usage = "create backup of old ref storage format (default: true)")
+      usage = "create backup of old ref storage format",
+      handler = ExplicitBooleanOptionHandler.class)
   private boolean backup = true;
 
   @Option(
       name = "--reflogs",
       aliases = {"-r"},
-      usage = "write reflogs to reftable (default: true)")
+      usage = "write reflogs to reftable",
+      handler = ExplicitBooleanOptionHandler.class)
   private boolean writeLogs = true;
 
   @Option(
diff --git a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
index 529ce73..bab9640 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.acceptance.api.change;
 
+import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assertWithMessage;
 import static com.google.common.truth.Truth8.assertThat;
@@ -100,6 +101,8 @@
 import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.entities.SubmitRequirementExpression;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.annotations.Exports;
 import com.google.gerrit.extensions.api.accounts.DeleteDraftCommentsInput;
@@ -142,6 +145,8 @@
 import com.google.gerrit.extensions.common.GitPerson;
 import com.google.gerrit.extensions.common.LabelInfo;
 import com.google.gerrit.extensions.common.RevisionInfo;
+import com.google.gerrit.extensions.common.SubmitRequirementResultInfo;
+import com.google.gerrit.extensions.common.SubmitRequirementResultInfo.Status;
 import com.google.gerrit.extensions.common.TrackingIdInfo;
 import com.google.gerrit.extensions.events.WorkInProgressStateChangedListener;
 import com.google.gerrit.extensions.restapi.AuthException;
@@ -209,6 +214,7 @@
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
 import org.eclipse.jgit.transport.PushResult;
+import org.junit.Ignore;
 import org.junit.Test;
 
 @NoHttpd
@@ -3990,6 +3996,193 @@
   }
 
   @Test
+  public void submitRequirementIsSatisfied_whenSubmittabilityExpressionIsFulfilled()
+      throws Exception {
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("code-review")
+            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:code-review=+2"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("verified")
+            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:verified=+1"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(2);
+    assertSubmitRequirementStatus(change.submitRequirements, "code-review", Status.UNSATISFIED);
+    assertSubmitRequirementStatus(change.submitRequirements, "verified", Status.UNSATISFIED);
+
+    voteLabel(changeId, "code-review", 2);
+
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(2);
+    assertSubmitRequirementStatus(change.submitRequirements, "code-review", Status.SATISFIED);
+    assertSubmitRequirementStatus(change.submitRequirements, "verified", Status.UNSATISFIED);
+  }
+
+  @Test
+  public void submitRequirementIsNotApplicable_whenApplicabilityExpressionIsNotFulfilled()
+      throws Exception {
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("code-review")
+            .setApplicabilityExpression(SubmitRequirementExpression.of("project:foo"))
+            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:code-review=+2"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(change.submitRequirements, "code-review", Status.NOT_APPLICABLE);
+  }
+
+  @Test
+  public void submitRequirementIsOverridden_whenOverrideExpressionIsFulfilled() throws Exception {
+    configLabel("build-cop-override", LabelFunction.MAX_WITH_BLOCK);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel("build-cop-override")
+                .ref("refs/heads/master")
+                .group(REGISTERED_USERS)
+                .range(-1, 1))
+        .update();
+
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("code-review")
+            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:code-review=+2"))
+            .setOverrideExpression(SubmitRequirementExpression.of("label:build-cop-override=+1"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(change.submitRequirements, "code-review", Status.UNSATISFIED);
+
+    voteLabel(changeId, "build-cop-override", 1);
+
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(change.submitRequirements, "code-review", Status.OVERRIDDEN);
+  }
+
+  @Test
+  @Ignore("Test is flaky")
+  public void submitRequirement_overriddenInChildProject() throws Exception {
+    configSubmitRequirement(
+        allProjects,
+        SubmitRequirement.builder()
+            .setName("code-review")
+            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:code-review=+1"))
+            .setOverrideExpression(SubmitRequirementExpression.of("label:build-cop-override=+1"))
+            .setAllowOverrideInChildProjects(true)
+            .build());
+
+    // Override submit requirement in child project (requires code-review=+2 instead of +1)
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("code-review")
+            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:code-review=+2"))
+            .setOverrideExpression(SubmitRequirementExpression.of("label:build-cop-override=+1"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(change.submitRequirements, "code-review", Status.UNSATISFIED);
+
+    voteLabel(changeId, "code-review", 1);
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(change.submitRequirements, "code-review", Status.UNSATISFIED);
+
+    voteLabel(changeId, "code-review", 2);
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(change.submitRequirements, "code-review", Status.SATISFIED);
+  }
+
+  @Test
+  public void submitRequirement_inheritedFromParentProject() throws Exception {
+    configSubmitRequirement(
+        allProjects,
+        SubmitRequirement.builder()
+            .setName("code-review")
+            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:code-review=+1"))
+            .setOverrideExpression(SubmitRequirementExpression.of("label:build-cop-override=+1"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(change.submitRequirements, "code-review", Status.UNSATISFIED);
+
+    voteLabel(changeId, "code-review", 1);
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(change.submitRequirements, "code-review", Status.SATISFIED);
+  }
+
+  @Test
+  public void submitRequirement_ignoredInChildProject_ifParentDoesNotAllowOverride()
+      throws Exception {
+    configSubmitRequirement(
+        allProjects,
+        SubmitRequirement.builder()
+            .setName("code-review")
+            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:code-review=+1"))
+            .setOverrideExpression(SubmitRequirementExpression.of("label:build-cop-override=+1"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    // Override submit requirement in child project (requires code-review=+2 instead of +1).
+    // Will have no effect since parent does not allow override.
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("code-review")
+            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:code-review=+2"))
+            .setOverrideExpression(SubmitRequirementExpression.of("label:build-cop-override=+1"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(change.submitRequirements, "code-review", Status.UNSATISFIED);
+
+    voteLabel(changeId, "code-review", 1);
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    // +1 was enough to fulfill the requirement: override in child project was ignored
+    assertSubmitRequirementStatus(change.submitRequirements, "code-review", Status.SATISFIED);
+  }
+
+  @Test
   public void fourByteEmoji() throws Exception {
     // U+1F601 GRINNING FACE WITH SMILING EYES
     String smile = new String(Character.toChars(0x1f601));
@@ -4630,4 +4823,27 @@
           event.getChange().workInProgress != null ? event.getChange().workInProgress : false;
     }
   }
+
+  private void voteLabel(String changeId, String labelName, int score) throws RestApiException {
+    gApi.changes().id(changeId).current().review(new ReviewInput().label(labelName, score));
+  }
+
+  private void assertSubmitRequirementStatus(
+      Collection<SubmitRequirementResultInfo> results,
+      String requirementName,
+      SubmitRequirementResultInfo.Status status) {
+    for (SubmitRequirementResultInfo result : results) {
+      if (result.name.equals(requirementName) && result.status == status) {
+        return;
+      }
+    }
+    throw new AssertionError(
+        String.format(
+            "Could not find submit requirement %s with status %s (results = %s)",
+            requirementName,
+            status,
+            results.stream()
+                .map(r -> String.format("%s=%s", r.name, r.status))
+                .collect(toImmutableList())));
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java b/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
index 45d1b76..5cf0403 100644
--- a/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
+++ b/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
@@ -66,6 +66,7 @@
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.Address;
+import com.google.gerrit.entities.AttentionSetUpdate;
 import com.google.gerrit.entities.BooleanProjectConfig;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.ChangeMessage;
@@ -97,6 +98,7 @@
 import com.google.gerrit.extensions.common.LabelInfo;
 import com.google.gerrit.extensions.common.RevisionInfo;
 import com.google.gerrit.extensions.events.TopicEditedListener;
+import com.google.gerrit.extensions.restapi.testing.AttentionSetUpdateSubject;
 import com.google.gerrit.git.ObjectIds;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.events.CommitReceivedEvent;
@@ -2831,6 +2833,37 @@
     r.assertErrorStatus("\"--skip-validation\" option is only supported for direct push");
   }
 
+  @Test
+  public void pushWithReviewerAddsToAttentionSet() throws Exception {
+    String pushSpec = "refs/for/master%r=" + user.email();
+    PushOneCommit.Result r = pushTo(pushSpec);
+    r.assertOkStatus();
+
+    AttentionSetUpdate attentionSet = Iterables.getOnlyElement(r.getChange().attentionSet());
+    AttentionSetUpdateSubject.assertThat(attentionSet).hasAccountIdThat().isEqualTo(user.id());
+    AttentionSetUpdateSubject.assertThat(attentionSet)
+        .hasOperationThat()
+        .isEqualTo(AttentionSetUpdate.Operation.ADD);
+    AttentionSetUpdateSubject.assertThat(attentionSet)
+        .hasReasonThat()
+        .isEqualTo("Reviewer was added");
+  }
+
+  @Test
+  public void pushWithReviewerAndIgnoreAttentionSetDoesNotAddToAttentionSet() throws Exception {
+    // Create a change
+    String pushSpec = "refs/for/master%r=" + user.email() + ",-ignore-attention-set";
+    PushOneCommit.Result r = pushTo(pushSpec);
+    r.assertOkStatus();
+    assertThat(r.getChange().attentionSet()).isEmpty();
+
+    // push a new patch-set with another reviewer
+    pushSpec = "refs/for/master%r=" + accountCreator.user2().email() + ",-ignore-attention-set";
+    r = pushTo(pushSpec);
+    r.assertOkStatus();
+    assertThat(r.getChange().attentionSet()).isEmpty();
+  }
+
   private DraftInput newDraft(String path, int line, String message) {
     DraftInput d = new DraftInput();
     d.path = path;
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java b/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java
index d480eb1..800ee42 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java
@@ -1758,6 +1758,30 @@
   }
 
   @Test
+  public void usersNotPartOfTheChangeAreNeverInTheAttentionSet() throws Exception {
+    PushOneCommit.Result r = createChange();
+    gApi.changes().id(r.getChangeId()).addReviewer(user.email());
+
+    AttentionSetUpdate attentionSetUpdate =
+        Iterables.getOnlyElement(getAttentionSetUpdates(r.getChange().getId()));
+    assertThat(attentionSetUpdate).hasAccountIdThat().isEqualTo(user.id());
+    assertThat(attentionSetUpdate).hasOperationThat().isEqualTo(Operation.ADD);
+
+    ReviewInput reviewInput = ReviewInput.create();
+    reviewInput.reviewer(user.email(), ReviewerState.REMOVED, /* confirmed= */ true);
+    reviewInput.ignoreAutomaticAttentionSetRules = true;
+    change(r).current().review(reviewInput);
+
+    // user removed from the attention set although we ignored automatic attention set rules.
+    attentionSetUpdate = Iterables.getOnlyElement(getAttentionSetUpdates(r.getChange().getId()));
+    assertThat(attentionSetUpdate).hasAccountIdThat().isEqualTo(user.id());
+    assertThat(attentionSetUpdate).hasOperationThat().isEqualTo(Operation.REMOVE);
+    assertThat(attentionSetUpdate)
+        .hasReasonThat()
+        .isEqualTo("Only change owner, uploader, reviewers, and cc can be in the attention set");
+  }
+
+  @Test
   @GerritConfig(name = "accounts.visibility", value = "NONE")
   public void canModifyAttentionSetForInvisibleUsersOnVisibleChanges() throws Exception {
     PushOneCommit.Result r = createChange();
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java b/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
index 129b546..6b6dffc 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
@@ -28,9 +28,12 @@
 import static org.eclipse.jgit.lib.Constants.SIGNED_OFF_BY_TAG;
 
 import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Iterables;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.ExtensionRegistry;
+import com.google.gerrit.acceptance.ExtensionRegistry.Registration;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.PushOneCommit.Result;
 import com.google.gerrit.acceptance.RestResponse;
@@ -63,6 +66,10 @@
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.server.events.CommitReceivedEvent;
+import com.google.gerrit.server.git.validators.CommitValidationException;
+import com.google.gerrit.server.git.validators.CommitValidationListener;
+import com.google.gerrit.server.git.validators.CommitValidationMessage;
 import com.google.gerrit.server.submit.ChangeAlreadyMergedException;
 import com.google.gerrit.testing.FakeEmailSender.Message;
 import com.google.inject.Inject;
@@ -88,6 +95,7 @@
 public class CreateChangeIT extends AbstractDaemonTest {
   @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
+  @Inject private ExtensionRegistry extensionRegistry;
 
   @Test
   public void createEmptyChange_MissingBranch() throws Exception {
@@ -963,6 +971,24 @@
     assertThrows(BadRequestException.class, () -> gApi.changes().create(in));
   }
 
+  @Test
+  public void createChangeWithValidationOptions() throws Exception {
+    ChangeInput changeInput = new ChangeInput();
+    changeInput.project = project.get();
+    changeInput.branch = "master";
+    changeInput.subject = "A change";
+    changeInput.status = ChangeStatus.NEW;
+    changeInput.validationOptions = ImmutableMap.of("key", "value");
+
+    TestCommitValidationListener testCommitValidationListener = new TestCommitValidationListener();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(testCommitValidationListener)) {
+      assertCreateSucceeds(changeInput);
+      assertThat(testCommitValidationListener.receiveEvent.pushOptions)
+          .containsExactly("key", "value");
+    }
+  }
+
   private ChangeInput newChangeInput(ChangeStatus status) {
     ChangeInput in = new ChangeInput();
     in.project = project.get();
@@ -1132,4 +1158,15 @@
 
     return ImmutableMap.of("master", initialCommit, branchA, changeA, branchB, changeB);
   }
+
+  private static class TestCommitValidationListener implements CommitValidationListener {
+    public CommitReceivedEvent receiveEvent;
+
+    @Override
+    public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent)
+        throws CommitValidationException {
+      this.receiveEvent = receiveEvent;
+      return ImmutableList.of();
+    }
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/SuggestReviewersIT.java b/javatests/com/google/gerrit/acceptance/rest/change/SuggestReviewersIT.java
index ed6254a..3850e13 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/SuggestReviewersIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/SuggestReviewersIT.java
@@ -447,6 +447,8 @@
     gApi.accounts().id(foo2.username()).setActive(false);
     assertThat(gApi.accounts().id(foo2.id().get()).getActive()).isFalse();
     assertReviewers(suggestReviewers(changeId, name), ImmutableList.of(foo1), ImmutableList.of());
+    assertReviewers(
+        suggestReviewers(changeId, /*query=*/ ""), ImmutableList.of(foo1), ImmutableList.of());
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/server/cache/h2/H2CacheTest.java b/javatests/com/google/gerrit/server/cache/h2/H2CacheTest.java
index 3ade4d0..14af43b 100644
--- a/javatests/com/google/gerrit/server/cache/h2/H2CacheTest.java
+++ b/javatests/com/google/gerrit/server/cache/h2/H2CacheTest.java
@@ -26,6 +26,7 @@
 import com.google.common.cache.CacheBuilder;
 import com.google.common.cache.CacheLoader;
 import com.google.common.cache.LoadingCache;
+import com.google.common.collect.ImmutableMap;
 import com.google.common.util.concurrent.Futures;
 import com.google.common.util.concurrent.MoreExecutors;
 import com.google.gerrit.server.cache.h2.H2CacheImpl.SqlStore;
@@ -34,6 +35,10 @@
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.TypeLiteral;
 import java.time.Duration;
+import java.time.Instant;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.Map;
 import java.util.concurrent.ExecutionException;
 import java.util.concurrent.atomic.AtomicBoolean;
 import javax.annotation.Nullable;
@@ -105,6 +110,62 @@
   }
 
   @Test
+  public void getAll_WithLoadingCache_LoaderNotImplementingLoadAll() throws ExecutionException {
+    Cache<String, ValueHolder<String>> mem =
+        CacheBuilder.newBuilder()
+            .build(
+                new CacheLoader<String, ValueHolder<String>>() {
+                  @Override
+                  public ValueHolder<String> load(String s) throws Exception {
+                    return new ValueHolder<>(s + "_loaded", Instant.now());
+                  }
+                });
+
+    H2CacheImpl<String, String> impl =
+        newH2CacheImpl(newStore(nextDbId(), DEFAULT_VERSION, null, null), mem);
+
+    assertThat(impl.getAll(Arrays.asList("S1", "S2")))
+        .containsExactlyEntriesIn(ImmutableMap.of("S1", "S1_loaded", "S2", "S2_loaded"));
+
+    // Make sure the values were cached
+    assertWithMessage("in-memory value").that(impl.getIfPresent("S1")).isEqualTo("S1_loaded");
+    assertWithMessage("in-memory value").that(impl.getIfPresent("S2")).isEqualTo("S2_loaded");
+  }
+
+  @Test
+  public void getAll_WithLoadingCache_LoaderImplementingLoadAll() throws ExecutionException {
+    Cache<String, ValueHolder<String>> mem =
+        CacheBuilder.newBuilder()
+            .build(
+                new CacheLoader<String, ValueHolder<String>>() {
+                  @Override
+                  public ValueHolder<String> load(String s) throws Exception {
+                    return new ValueHolder<>(s + "_loaded", Instant.now());
+                  }
+
+                  @Override
+                  public Map<String, ValueHolder<String>> loadAll(Iterable<? extends String> keys)
+                      throws Exception {
+                    Map<String, ValueHolder<String>> result = new HashMap<>();
+                    for (String k : keys) {
+                      result.put(k, load(k));
+                    }
+                    return result;
+                  }
+                });
+
+    H2CacheImpl<String, String> impl =
+        newH2CacheImpl(newStore(nextDbId(), DEFAULT_VERSION, null, null), mem);
+
+    assertThat(impl.getAll(Arrays.asList("S1", "S2")))
+        .containsExactlyEntriesIn(ImmutableMap.of("S1", "S1_loaded", "S2", "S2_loaded"));
+
+    // Make sure the values were cached
+    assertWithMessage("in-memory value").that(impl.getIfPresent("S1")).isEqualTo("S1_loaded");
+    assertWithMessage("in-memory value").that(impl.getIfPresent("S2")).isEqualTo("S2_loaded");
+  }
+
+  @Test
   public void stringSerializer() {
     String input = "foo";
     byte[] serialized = StringCacheSerializer.INSTANCE.serialize(input);
diff --git a/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
index 6c8026b..8505473 100644
--- a/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
+++ b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
@@ -934,6 +934,21 @@
   }
 
   @Test
+  public void fullTextMultipleTerms() throws Exception {
+    TestRepository<Repo> repo = createProject("repo");
+    RevCommit commit1 = repo.parseBody(repo.commit().message("Signed-off: owner").create());
+    Change change1 = insert(repo, newChangeForCommit(repo, commit1));
+    RevCommit commit2 = repo.parseBody(repo.commit().message("Signed by owner").create());
+    Change change2 = insert(repo, newChangeForCommit(repo, commit2));
+    RevCommit commit3 = repo.parseBody(repo.commit().message("This change is off").create());
+    Change change3 = insert(repo, newChangeForCommit(repo, commit3));
+
+    assertQuery("message:\"Signed-off: owner\"", change1);
+    assertQuery("message:\"Signed\"", change2, change1);
+    assertQuery("message:\"off\"", change3, change1);
+  }
+
+  @Test
   public void byMessageMixedCase() throws Exception {
     TestRepository<Repo> repo = createProject("repo");
     RevCommit commit1 = repo.parseBody(repo.commit().message("Hello gerrit").create());
diff --git a/modules/jgit b/modules/jgit
index a9579ba..1cbfea9 160000
--- a/modules/jgit
+++ b/modules/jgit
@@ -1 +1 @@
-Subproject commit a9579ba60cd2fd72179dfd8c2c37d389db5ec402
+Subproject commit 1cbfea9ece03b40669377a7f858218f6994562ea
diff --git a/plugins/codemirror-editor b/plugins/codemirror-editor
index 42d5fe0..e0a6721 160000
--- a/plugins/codemirror-editor
+++ b/plugins/codemirror-editor
@@ -1 +1 @@
-Subproject commit 42d5fe041ee2ef6be579c0085396fa5e60889222
+Subproject commit e0a67217ae5359797570481cbb6e8aa1f5e0a7c3
diff --git a/plugins/commit-message-length-validator b/plugins/commit-message-length-validator
index 556e427..c38e0a9 160000
--- a/plugins/commit-message-length-validator
+++ b/plugins/commit-message-length-validator
@@ -1 +1 @@
-Subproject commit 556e427fd737744ce8a6a37b89fd427ae59bc8ea
+Subproject commit c38e0a9d36767092b20558b28eff7f546c6d754c
diff --git a/plugins/download-commands b/plugins/download-commands
index 774e915..c99bc84 160000
--- a/plugins/download-commands
+++ b/plugins/download-commands
@@ -1 +1 @@
-Subproject commit 774e9159128a72a76a0b226033b038c8f24fd88b
+Subproject commit c99bc8457910ec19315c1384e20267288b019592
diff --git a/polygerrit-ui/app/api/checks.ts b/polygerrit-ui/app/api/checks.ts
index 2682158..06f9509 100644
--- a/polygerrit-ui/app/api/checks.ts
+++ b/polygerrit-ui/app/api/checks.ts
@@ -28,6 +28,23 @@
    * polling interval to pass.
    */
   announceUpdate(): void;
+
+  /**
+   * Updates an individual result.
+   *
+   * This can be used for lazy loading detailed information. For example, if you
+   * are using the `check-result-expanded` endpoint, then you can load more
+   * result details when the user expands a result row.
+   *
+   * The parameter `run` is only used to *find* the correct run for updating the
+   * result. It will only be used for comparing `change`, `patchset`, `attempt`
+   * and `checkName`. Its properties other than `results` will not be updated.
+   *
+   * For us being able to identify the result that you want to update you have
+   * to set the `externalId` property. An undefined `externalId` will result in
+   * an error.
+   */
+  updateResult(run: CheckRun, result: CheckResult): void;
 }
 
 export declare interface ChecksApiConfig {
@@ -450,4 +467,5 @@
   HELP_PAGE = 'help_page',
   REPORT_BUG = 'report_bug',
   CODE = 'code',
+  FILE_PRESENT = 'file_present',
 }
diff --git a/polygerrit-ui/app/api/diff.ts b/polygerrit-ui/app/api/diff.ts
index ad83fb8..3f51cf0 100644
--- a/polygerrit-ui/app/api/diff.ts
+++ b/polygerrit-ui/app/api/diff.ts
@@ -406,17 +406,26 @@
   ): void;
 }
 
-// The current setup requires API users to register GrDiff instances with the
-// cursor, but we do not at this point want to expose the API that GrDiffCursor
-// uses to the public as it is likely to change. So for now, we allow any type
-// and cast. This works fine so long as API users do provide whatever the
-// gr-diff tag creates.
-export type GrDiff = unknown;
+/** An instance of the GrDiff Webcomponent */
+export declare interface GrDiff extends HTMLElement {
+  /**
+   * Return line number element for reading only,
+   *
+   * This is useful e.g. to determine where on screen certain lines are,
+   * whether they are covered up etc.
+   */
+  getLineNumEls(side: Side): readonly HTMLElement[];
+}
 
 /** A service to interact with the line cursor in gr-diff instances. */
 export declare interface GrDiffCursor {
-  replaceDiffs(diffs: GrDiff[]): void;
-  unregisterDiff(diff: GrDiff): void;
+  // The current setup requires API users to register GrDiff instances with the
+  // cursor, but we do not at this point want to expose the API that GrDiffCursor
+  // uses to the public as it is likely to change. So for now, we allow any type
+  // and cast. This works fine so long as API users do provide whatever the
+  // gr-diff tag creates.
+  replaceDiffs(diffs: unknown[]): void;
+  unregisterDiff(diff: unknown): void;
 
   isAtStart(): boolean;
   isAtEnd(): boolean;
diff --git a/polygerrit-ui/app/constants/reporting.ts b/polygerrit-ui/app/constants/reporting.ts
index d22026a..a09c1c3 100644
--- a/polygerrit-ui/app/constants/reporting.ts
+++ b/polygerrit-ui/app/constants/reporting.ts
@@ -30,6 +30,8 @@
   PLUGIN_API = 'plugin-api',
   REACHABLE_CODE = 'reachable code',
   METHOD_USED = 'method used',
+  CHECKS_API_NOT_LOGGED_IN = 'checks-api not-logged-in',
+  CHECKS_API_ERROR = 'checks-api error',
 }
 
 export enum Timing {
diff --git a/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log_test.js b/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log_test.ts
similarity index 62%
rename from polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log_test.js
rename to polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log_test.ts
index a00b8142..c4f1df7 100644
--- a/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log_test.js
+++ b/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log_test.ts
@@ -17,12 +17,23 @@
 
 import '../../../test/common-test-setup-karma.js';
 import './gr-group-audit-log.js';
-import {stubRestApi, addListenerForTest, mockPromise} from '../../../test/test-utils.js';
+import {
+  stubRestApi,
+  addListenerForTest,
+  mockPromise,
+} from '../../../test/test-utils.js';
+import {GrGroupAuditLog} from './gr-group-audit-log.js';
+import {EncodedGroupId, GroupInfo, GroupName} from '../../../types/common.js';
+import {
+  createAccountWithId,
+  createGroupInfo,
+} from '../../../test/test-data-generators.js';
+import {PageErrorEvent} from '../../../types/events.js';
 
 const basicFixture = fixtureFromElement('gr-group-audit-log');
 
 suite('gr-group-audit-log tests', () => {
-  let element;
+  let element: GrGroupAuditLog;
 
   setup(() => {
     element = basicFixture.instantiate();
@@ -30,19 +41,14 @@
 
   suite('members', () => {
     test('test _getNameForGroup', () => {
-      let group = {
-        member: {
-          name: 'test-name',
-        },
+      let member: GroupInfo = {
+        ...createGroupInfo(),
+        name: 'test-name' as GroupName,
       };
-      assert.equal(element._getNameForGroup(group.member), 'test-name');
+      assert.equal(element._getNameForGroup(member), 'test-name');
 
-      group = {
-        member: {
-          id: 'test-id',
-        },
-      };
-      assert.equal(element._getNameForGroup(group.member), 'test-id');
+      member = createGroupInfo('test-id');
+      assert.equal(element._getNameForGroup(member), 'test-id');
     });
 
     test('test _isGroupEvent', () => {
@@ -56,13 +62,11 @@
 
   suite('users', () => {
     test('test _getIdForUser', () => {
-      const account = {
-        user: {
-          username: 'test-user',
-          _account_id: 12,
-        },
+      const user = {
+        ...createAccountWithId(12),
+        username: 'test-user',
       };
-      assert.equal(element._getIdForUser(account.user), ' (12)');
+      assert.equal(element._getIdForUser(user), ' (12)');
     });
 
     test('test _account_id not present', () => {
@@ -77,18 +81,18 @@
 
   suite('404', () => {
     test('fires page-error', async () => {
-      element.groupId = 1;
+      element.groupId = '1' as EncodedGroupId;
       await flush();
 
-      const response = {status: 404};
-      stubRestApi('getGroupAuditLog').callsFake((group, errFn) => {
-        errFn(response);
+      const response = {...new Response(), status: 404};
+      stubRestApi('getGroupAuditLog').callsFake((_group, errFn) => {
+        if (errFn) errFn(response);
         return Promise.resolve(undefined);
       });
 
       const pageErrorCalled = mockPromise();
       addListenerForTest(document, 'page-error', e => {
-        assert.deepEqual(e.detail.response, response);
+        assert.deepEqual((e as PageErrorEvent).detail.response, response);
         pageErrorCalled.resolve();
       });
 
@@ -97,4 +101,3 @@
     });
   });
 });
-
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_html.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_html.ts
index 06087ed..8ee6a3f 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_html.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_html.ts
@@ -158,7 +158,7 @@
     hidden$="[[isColumnHidden('Owner', visibleChangeTableColumns)]]"
   >
     <gr-account-link
-      highlight-attention
+      highlightAttention
       change="[[change]]"
       account="[[change.owner]]"
     ></gr-account-link>
@@ -189,10 +189,10 @@
         indexAs="index"
       >
         <gr-account-link
-          hide-avatar=""
-          hide-status=""
-          first-name
-          highlight-attention
+          hideAvatar=""
+          hideStatus=""
+          firstName
+          highlightAttention
           change="[[change]]"
           account="[[reviewer]]"
         ></gr-account-link
diff --git a/polygerrit-ui/app/elements/change-list/gr-create-commands-dialog/gr-create-commands-dialog_test.js b/polygerrit-ui/app/elements/change-list/gr-create-commands-dialog/gr-create-commands-dialog_test.ts
similarity index 71%
rename from polygerrit-ui/app/elements/change-list/gr-create-commands-dialog/gr-create-commands-dialog_test.js
rename to polygerrit-ui/app/elements/change-list/gr-create-commands-dialog/gr-create-commands-dialog_test.ts
index 9dbcd29..195ccb7 100644
--- a/polygerrit-ui/app/elements/change-list/gr-create-commands-dialog/gr-create-commands-dialog_test.js
+++ b/polygerrit-ui/app/elements/change-list/gr-create-commands-dialog/gr-create-commands-dialog_test.ts
@@ -15,13 +15,14 @@
  * limitations under the License.
  */
 
-import '../../../test/common-test-setup-karma.js';
-import './gr-create-commands-dialog.js';
+import '../../../test/common-test-setup-karma';
+import './gr-create-commands-dialog';
+import {GrCreateCommandsDialog} from './gr-create-commands-dialog';
 
 const basicFixture = fixtureFromElement('gr-create-commands-dialog');
 
 suite('gr-create-commands-dialog tests', () => {
-  let element;
+  let element: GrCreateCommandsDialog;
 
   setup(() => {
     element = basicFixture.instantiate();
@@ -29,12 +30,12 @@
 
   test('_computePushCommand', () => {
     element.branch = 'master';
-    assert.equal(element._pushCommand,
-        'git push origin HEAD:refs/for/master');
+    assert.equal(element._pushCommand, 'git push origin HEAD:refs/for/master');
 
     element.branch = 'stable-2.15';
-    assert.equal(element._pushCommand,
-        'git push origin HEAD:refs/for/stable-2.15');
+    assert.equal(
+      element._pushCommand,
+      'git push origin HEAD:refs/for/stable-2.15'
+    );
   });
 });
-
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.ts b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.ts
index ab8e404..516a31e 100644
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.ts
@@ -939,7 +939,7 @@
   });
 
   suite('plugin endpoints', () => {
-    test('endpoint params', done => {
+    test('endpoint params', async () => {
       element.change = createParsedChange();
       element.revision = createRevision();
       interface MetadataGrEndpointDecorator extends GrEndpointDecorator {
@@ -961,12 +961,10 @@
         'http://some/plugins/url.js'
       );
       getPluginLoader().loadPlugins([]);
-      flush(() => {
-        assert.strictEqual(hookEl!.plugin, plugin);
-        assert.strictEqual(hookEl!.change, element.change);
-        assert.strictEqual(hookEl!.revision, element.revision);
-        done();
-      });
+      await flush();
+      assert.strictEqual(hookEl!.plugin, plugin!);
+      assert.strictEqual(hookEl!.change, element.change);
+      assert.strictEqual(hookEl!.revision, element.revision);
     });
   });
 });
diff --git a/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary.ts b/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary.ts
index c34a6ae..c78d6c0 100644
--- a/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary.ts
@@ -36,10 +36,11 @@
   getResultsOf,
   hasCompletedWithoutResults,
   hasResultsOf,
-  iconForCategory,
-  iconForStatus,
+  iconFor,
   isRunning,
   isRunningOrHasCompleted,
+  isStatus,
+  labelFor,
 } from '../../../services/checks/checks-util';
 import {ChangeComments} from '../../diff/gr-comment-api/gr-comment-api';
 import {
@@ -57,6 +58,7 @@
 import {PrimaryTab} from '../../../constants/constants';
 import {ChecksTabState, CommentTabState} from '../../../types/events';
 import {spinnerStyles} from '../../../styles/gr-spinner-styles';
+import {modifierPressed} from '../../../utils/dom-util';
 
 export enum SummaryChipStyles {
   INFO = 'info',
@@ -106,6 +108,9 @@
           background: var(--warning-background-hover);
           box-shadow: var(--elevation-level-1);
         }
+        .summaryChip.warning:focus-within {
+          background: var(--warning-background-focus);
+        }
         .summaryChip.warning iron-icon {
           color: var(--warning-foreground);
         }
@@ -117,6 +122,9 @@
           background: var(--gray-background-hover);
           box-shadow: var(--elevation-level-1);
         }
+        .summaryChip.check:focus-within {
+          background: var(--gray-background-focus);
+        }
         .summaryChip.check iron-icon {
           color: var(--gray-foreground);
         }
@@ -148,7 +156,7 @@
 @customElement('gr-checks-chip')
 export class GrChecksChip extends GrLitElement {
   @property()
-  icon = '';
+  statusOrCategory?: Category | RunStatus;
 
   @property()
   text = '';
@@ -193,6 +201,9 @@
           background: var(--error-background-hover);
           box-shadow: var(--elevation-level-1);
         }
+        .checksChip.error:focus-within {
+          background: var(--error-background-focus);
+        }
         .checksChip.error iron-icon {
           color: var(--error-foreground);
         }
@@ -204,6 +215,9 @@
           background: var(--warning-background-hover);
           box-shadow: var(--elevation-level-1);
         }
+        .checksChip.warning:focus-within {
+          background: var(--warning-background-focus);
+        }
         .checksChip.warning iron-icon {
           color: var(--warning-foreground);
         }
@@ -215,6 +229,9 @@
           background: var(--info-background-hover);
           box-shadow: var(--elevation-level-1);
         }
+        .checksChip.info-outline:focus-within {
+          background: var(--info-background-focus);
+        }
         .checksChip.info-outline iron-icon {
           color: var(--info-foreground);
         }
@@ -226,6 +243,9 @@
           background: var(--success-background-hover);
           box-shadow: var(--elevation-level-1);
         }
+        .checksChip.check-circle-outline:focus-within {
+          background: var(--success-background-focus);
+        }
         .checksChip.check-circle-outline iron-icon {
           color: var(--success-foreground);
         }
@@ -239,6 +259,9 @@
           background: var(--gray-background-hover);
           box-shadow: var(--elevation-level-1);
         }
+        .checksChip.timelapse:focus-within {
+          background: var(--gray-background-focus);
+        }
         .checksChip.timelapse iron-icon {
           color: var(--gray-foreground);
         }
@@ -248,10 +271,25 @@
 
   render() {
     if (!this.text) return;
-    const chipClass = `checksChip font-small ${this.icon}`;
-    const grIcon = `gr-icons:${this.icon}`;
+    if (!this.statusOrCategory) return;
+    const icon = iconFor(this.statusOrCategory);
+    const label = labelFor(this.statusOrCategory);
+    const count = Number(this.text);
+    let ariaLabel = label;
+    if (!isNaN(count)) {
+      const type = isStatus(this.statusOrCategory) ? 'run' : 'result';
+      const plural = count > 1 ? 's' : '';
+      ariaLabel = `${this.text} ${label} ${type}${plural}`;
+    }
+    const chipClass = `checksChip font-small ${icon}`;
+    const grIcon = `gr-icons:${icon}`;
     return html`
-      <div class="${chipClass}" role="button">
+      <div
+        class="${chipClass}"
+        role="link"
+        tabindex="0"
+        aria-label="${ariaLabel}"
+      >
         <iron-icon icon="${grIcon}"></iron-icon>
         <div class="text">${this.text}</div>
         <slot></slot>
@@ -411,18 +449,17 @@
     if (this.errorMessage || this.loginCallback) return;
     if (this.runs.some(isRunningOrHasCompleted)) return;
     const msg = this.someProvidersAreLoading ? 'Loading results' : 'No results';
-    return html`<span class="loading zeroState">${msg}</span>`;
+    return html`<span role="status" class="loading zeroState">${msg}</span>`;
   }
 
   renderChecksChipForCategory(category: Category) {
     if (this.errorMessage || this.loginCallback) return;
-    const icon = iconForCategory(category);
     const runs = this.runs.filter(run => {
       if (hasResultsOf(run, category)) return true;
       return category === Category.SUCCESS && hasCompletedWithoutResults(run);
     });
     const count = (run: CheckRun) => getResultsOf(run, category);
-    return this.renderChecksChip(icon, runs, category, count);
+    return this.renderChecksChip(runs, category, count);
   }
 
   renderChecksChipForStatus(
@@ -430,13 +467,11 @@
     filter: (run: CheckRun) => boolean
   ) {
     if (this.errorMessage || this.loginCallback) return;
-    const icon = iconForStatus(status);
     const runs = this.runs.filter(filter);
-    return this.renderChecksChip(icon, runs, status, () => []);
+    return this.renderChecksChip(runs, status, () => []);
   }
 
   renderChecksChip(
-    icon: string,
     runs: CheckRun[],
     statusOrCategory: RunStatus | Category,
     resultFilter: (run: CheckRun) => CheckResult[]
@@ -451,7 +486,8 @@
     const hasDetailChipAlready = runs.some(run =>
       this.detailsCheckNames.includes(run.checkName)
     );
-    if (!hasDetailChipAlready && runs.length <= this.detailsQuota) {
+    const notInfo = statusOrCategory !== Category.INFO;
+    if (!hasDetailChipAlready && notInfo && runs.length <= this.detailsQuota) {
       this.detailsQuota -= runs.length;
       return runs.map(run => {
         this.detailsCheckNames.push(run.checkName);
@@ -461,13 +497,19 @@
         const links = allPrimaryLinks.length === 1 ? allPrimaryLinks : [];
         const text = `${run.checkName}`;
         return html`<gr-checks-chip
-          class="${icon}"
-          .icon="${icon}"
+          .statusOrCategory="${statusOrCategory}"
           .text="${text}"
           @click="${() => this.onChipClick({checkName: run.checkName})}"
+          @keydown="${(e: KeyboardEvent) =>
+            this.onChipKeyDown(e, {checkName: run.checkName})}"
           >${links.map(
             link => html`
-              <a href="${link.url}" target="_blank" @click="${this.onLinkClick}"
+              <a
+                href="${link.url}"
+                target="_blank"
+                @click="${this.onLinkClick}"
+                @keydown="${this.onLinkKeyDown}"
+                aria-label="Link to check details"
                 ><iron-icon class="launch" icon="gr-icons:launch"></iron-icon
               ></a>
             `
@@ -483,19 +525,34 @@
     );
     if (sum === 0) return;
     return html`<gr-checks-chip
-      class="${icon}"
-      .icon="${icon}"
+      .statusOrCategory="${statusOrCategory}"
       .text="${sum}"
       @click="${() => this.onChipClick({statusOrCategory})}"
+      @keydown="${(e: KeyboardEvent) =>
+        this.onChipKeyDown(e, {statusOrCategory})}"
     ></gr-checks-chip>`;
   }
 
+  private onChipKeyDown(e: KeyboardEvent, state: ChecksTabState) {
+    if (modifierPressed(e)) return;
+    // Only react to `return` and `space`.
+    if (e.keyCode !== 13 && e.keyCode !== 32) return;
+    e.preventDefault();
+    e.stopPropagation();
+    this.onChipClick(state);
+  }
+
   private onChipClick(state: ChecksTabState) {
     fireShowPrimaryTab(this, PrimaryTab.CHECKS, false, {
       checksTab: state,
     });
   }
 
+  private onLinkKeyDown(e: KeyboardEvent) {
+    // Prevents onConChipKeyDown() from reacting to <a> link keyboard events.
+    e.stopPropagation();
+  }
+
   private onLinkClick(e: MouseEvent) {
     // Prevents onChipClick() from reacting to <a> link clicks.
     e.stopPropagation();
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
index 58cf489..2ede186e 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
@@ -60,11 +60,14 @@
 import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
 import {RevisionInfo as RevisionInfoClass} from '../../shared/revision-info/revision-info';
 import {DiffViewMode} from '../../../api/diff';
-import {PrimaryTab, SecondaryTab} from '../../../constants/constants';
+import {
+  ChangeStatus,
+  PrimaryTab,
+  SecondaryTab,
+} from '../../../constants/constants';
 
 import {NO_ROBOT_COMMENTS_THREADS_MSG} from '../../../constants/messages';
 import {appContext} from '../../../services/app-context';
-import {ChangeStatus} from '../../../constants/constants';
 import {
   computeAllPatchSets,
   computeLatestPatchNum,
@@ -72,9 +75,14 @@
   hasEditPatchsetLoaded,
   PatchSet,
 } from '../../../utils/patch-set-util';
-import {changeStatuses, isOwner, isReviewer} from '../../../utils/change-util';
+import {
+  changeStatuses,
+  isCc,
+  isOwner,
+  isReviewer,
+} from '../../../utils/change-util';
 import {EventType as PluginEventType} from '../../../api/plugin';
-import {customElement, property, observe} from '@polymer/decorators';
+import {customElement, observe, property} from '@polymer/decorators';
 import {GrApplyFixDialog} from '../../diff/gr-apply-fix-dialog/gr-apply-fix-dialog';
 import {GrFileListHeader} from '../gr-file-list-header/gr-file-list-header';
 import {GrEditableContent} from '../../shared/gr-editable-content/gr-editable-content';
@@ -84,60 +92,60 @@
 import {GrChangeActions} from '../gr-change-actions/gr-change-actions';
 import {
   AccountDetailInfo,
-  ChangeInfo,
-  NumericChangeId,
-  PatchRange,
   ActionNameToActionInfoMap,
-  CommitId,
-  PatchSetNum,
-  ParentPatchSetNum,
-  EditPatchSetNum,
-  ServerInfo,
-  ConfigInfo,
-  PreferencesInfo,
-  CommitInfo,
-  RevisionInfo,
-  EditInfo,
-  LabelNameToInfoMap,
-  UrlEncodedCommentId,
-  QuickLabelInfo,
   ApprovalInfo,
-  ElementPropertyDeepChange,
+  BasePatchSetNum,
   ChangeId,
+  ChangeInfo,
+  CommitId,
+  CommitInfo,
+  ConfigInfo,
+  EditInfo,
+  EditPatchSetNum,
+  ElementPropertyDeepChange,
+  LabelNameToInfoMap,
+  NumericChangeId,
+  ParentPatchSetNum,
+  PatchRange,
+  PatchSetNum,
+  PreferencesInfo,
+  QuickLabelInfo,
   RelatedChangeAndCommitInfo,
   RelatedChangesInfo,
-  BasePatchSetNum,
+  RevisionInfo,
+  ServerInfo,
+  UrlEncodedCommentId,
 } from '../../../types/common';
 import {DiffPreferencesInfo} from '../../../types/diff';
-import {GrReplyDialog, FocusTarget} from '../gr-reply-dialog/gr-reply-dialog';
+import {FocusTarget, GrReplyDialog} from '../gr-reply-dialog/gr-reply-dialog';
 import {GrIncludedInDialog} from '../gr-included-in-dialog/gr-included-in-dialog';
 import {GrDownloadDialog} from '../gr-download-dialog/gr-download-dialog';
 import {GrChangeMetadata} from '../gr-change-metadata/gr-change-metadata';
 import {
-  GrCommentApi,
   ChangeComments,
+  GrCommentApi,
 } from '../../diff/gr-comment-api/gr-comment-api';
 import {assertIsDefined, hasOwnProperty} from '../../../utils/common-util';
 import {GrEditControls} from '../../edit/gr-edit-controls/gr-edit-controls';
 import {
   CommentThread,
-  UIDraft,
   DraftInfo,
   isDraftThread,
   isRobot,
   isUnresolved,
+  UIDraft,
 } from '../../../utils/comment-util';
 import {
   PolymerDeepPropertyChange,
-  PolymerSpliceChange,
   PolymerSplice,
+  PolymerSpliceChange,
 } from '@polymer/polymer/interfaces';
 import {AppElementChangeViewParams} from '../../gr-app-types';
 import {DropdownLink} from '../../shared/gr-dropdown/gr-dropdown';
 import {PaperTabsElement} from '@polymer/paper-tabs/paper-tabs';
 import {
-  GrFileList,
   DEFAULT_NUM_FILES_SHOWN,
+  GrFileList,
 } from '../gr-file-list/gr-file-list';
 import {
   ChangeViewState,
@@ -146,37 +154,37 @@
   ParsedChangeInfo,
 } from '../../../types/types';
 import {
+  CloseFixPreviewEvent,
   CustomKeyboardEvent,
   EditableContentSaveEvent,
+  EventType,
   OpenFixPreviewEvent,
   ShowAlertEventDetail,
   SwitchTabEvent,
-  ThreadListModifiedEvent,
   TabState,
-  EventType,
-  CloseFixPreviewEvent,
+  ThreadListModifiedEvent,
 } from '../../../types/events';
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {GrMessagesList} from '../gr-messages-list/gr-messages-list';
 import {GrThreadList} from '../gr-thread-list/gr-thread-list';
 import {
   fireAlert,
+  fireDialogChange,
   fireEvent,
   firePageError,
-  fireDialogChange,
-  fireTitleChange,
   fireReload,
+  fireTitleChange,
 } from '../../../utils/event-util';
-import {GerritView} from '../../../services/router/router-model';
+import {GerritView, routerView$} from '../../../services/router/router-model';
 import {takeUntil} from 'rxjs/operators';
 import {aPluginHasRegistered$} from '../../../services/checks/checks-model';
 import {Subject} from 'rxjs';
-import {debounce, DelayedTask} from '../../../utils/async-util';
+import {debounce, DelayedTask, throttleWrap} from '../../../utils/async-util';
 import {Interaction, Timing} from '../../../constants/reporting';
 import {ChangeStates} from '../../shared/gr-change-status/gr-change-status';
 import {getRevertCreatedChangeIds} from '../../../utils/message-util';
 
-const MIN_LINES_FOR_COMMIT_COLLAPSE = 17;
+const MIN_LINES_FOR_COMMIT_COLLAPSE = 18;
 
 const REVIEWERS_REGEX = /^(R|CC)=/gm;
 const MIN_CHECK_INTERVAL_SECS = 0;
@@ -519,6 +527,9 @@
   @property({type: Boolean})
   _showChecksTab = false;
 
+  @property({type: Boolean})
+  private isViewCurrent = false;
+
   @property({type: String})
   _tabState?: TabState;
 
@@ -533,6 +544,8 @@
 
   restApiService = appContext.restApiService;
 
+  private replyDialogResizeObserver?: ResizeObserver;
+
   keyboardShortcuts() {
     return {
       [Shortcut.SEND_REPLY]: null, // DOC_ONLY binding
@@ -569,6 +582,9 @@
     aPluginHasRegistered$.pipe(takeUntil(this.disconnected$)).subscribe(b => {
       this._showChecksTab = b;
     });
+    routerView$.pipe(takeUntil(this.disconnected$)).subscribe(view => {
+      this.isViewCurrent = view === GerritView.CHANGE;
+    });
   }
 
   constructor() {
@@ -603,7 +619,7 @@
   /** @override */
   connectedCallback() {
     super.connectedCallback();
-    this._throttledToggleChangeStar = this._throttleWrap(e =>
+    this._throttledToggleChangeStar = throttleWrap(e =>
       this._handleToggleChangeStar(e as CustomKeyboardEvent)
     );
     this._getServerConfig().then(config => {
@@ -621,6 +637,11 @@
       this._setDiffViewMode();
     });
 
+    this.replyDialogResizeObserver = new ResizeObserver(() =>
+      this.$.replyOverlay.center()
+    );
+    this.replyDialogResizeObserver.observe(this.$.replyDialog);
+
     getPluginLoader()
       .awaitPluginsLoaded()
       .then(() => {
@@ -1233,16 +1254,37 @@
     this.$.fileList.collapseAllDiffs();
   }
 
+  /**
+   * ChangeView is never re-used for different changes. It is safer and simpler
+   * to just re-create another change view when the user switches to a new
+   * change page. Thus we need a reliable way to detect that the change view
+   * does not match the current change number anymore.
+   *
+   * If this method returns true, then the change view should not do anything
+   * anymore. The app element makes sure that an obsolete change view is not
+   * shown anymore, so if the change view is still and doing some update to
+   * itself, then that is not dangerous. But for example it should not call
+   * navigateToChange() anymore. That would very likely cause erroneous
+   * behavior.
+   */
+  private isChangeObsolete() {
+    // While this._changeNum is undefined the change view is fresh and has just
+    // not updated it to params.changeNum yet. Not obsolete in that case.
+    if (this._changeNum === undefined) return false;
+    // this.params reflects the current state of the URL. If this._changeNum
+    // does not match it anymore, then this view must be considered obsolete.
+    return this._changeNum !== this.params?.changeNum;
+  }
+
   _paramsChanged(value: AppElementChangeViewParams) {
     if (value.view !== GerritView.CHANGE) {
       this._initialLoadComplete = false;
       return;
     }
 
-    // Everything in the change view is tied to the change. It seems better to
-    // force the re-creation of the change view when the change number changes.
-    const changeChanged = this._changeNum !== value.changeNum;
-    if (this._changeNum !== undefined && changeChanged) {
+    if (this.isChangeObsolete()) {
+      // Tell the app element that we are not going to handle the new change
+      // number and that they have to create a new change view.
       fireEvent(this, EventType.RECREATE_CHANGE_VIEW);
       return;
     }
@@ -1277,7 +1319,7 @@
 
     // If the change has already been loaded and the parameter change is only
     // in the patch range, then don't do a full reload.
-    if (!changeChanged && patchChanged && patchKnown) {
+    if (this._changeNum !== undefined && patchChanged && patchKnown) {
       if (!patchRange.patchNum) {
         patchRange.patchNum = computeLatestPatchNum(this._allPatchSets);
         rightPatchNumChanged = true;
@@ -1443,13 +1485,6 @@
 
       if (this.viewState.showReplyDialog) {
         this._openReplyDialog(this.$.replyDialog.FocusTarget.ANY);
-        // TODO(kaspern@): Find a better signal for when to call center.
-        setTimeout(() => {
-          this.$.replyOverlay.center();
-        }, 100);
-        setTimeout(() => {
-          this.$.replyOverlay.center();
-        }, 1000);
         this.set('viewState.showReplyDialog', false);
       }
     });
@@ -1786,8 +1821,6 @@
       // the following code should be executed no matter open succeed or not
       this._resetReplyOverlayFocusStops();
       this.$.replyDialog.open(section);
-      flush();
-      this.$.replyOverlay.center();
     });
     fireDialogChange(this, {opened: true});
     this._changeViewAriaHidden = true;
@@ -2104,6 +2137,7 @@
    * promise resolves.
    */
   loadData(isLocationChange?: boolean, clearPatchset?: boolean) {
+    if (this.isChangeObsolete()) return Promise.resolve([]);
     if (clearPatchset && this._change) {
       GerritNav.navigateToChange(this._change);
       return Promise.resolve([]);
@@ -2133,6 +2167,7 @@
           this.reporting.changeDisplayed({
             isOwner: isOwner(this._change, this._account),
             isReviewer: isReviewer(this._change, this._account),
+            isCc: isCc(this._change, this._account),
           });
         }
       });
@@ -2370,6 +2405,10 @@
     }
 
     this._updateCheckTimerHandle = window.setTimeout(() => {
+      if (!this.isViewCurrent) {
+        this._startUpdateCheckTimer();
+        return;
+      }
       assertIsDefined(this._change, '_change');
       const change = this._change;
       this.changeService.fetchChangeUpdates(change).then(result => {
@@ -2394,7 +2433,12 @@
         // reply, or the change might have been reloaded, or it could be in the
         // process of being reloaded.
         const changeWasReloaded = change !== this._change;
-        if (!toastMessage || this._loading || changeWasReloaded) {
+        if (
+          !toastMessage ||
+          this._loading ||
+          changeWasReloaded ||
+          !this.isViewCurrent
+        ) {
           this._startUpdateCheckTimer();
           return;
         }
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts
index 5128948..7d47abb 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts
@@ -1614,7 +1614,7 @@
     });
   });
 
-  test('don’t reload entire page when patchRange changes', () => {
+  test('don’t reload entire page when patchRange changes', async () => {
     const reloadStub = sinon
       .stub(element, 'loadData')
       .callsFake(() => Promise.resolve([]));
@@ -1629,7 +1629,8 @@
       view: GerritView.CHANGE,
       patchNum: 1 as RevisionPatchSetNum,
     };
-    element._paramsChanged(value);
+    element.params = value;
+    await flush();
     assert.isTrue(reloadStub.calledOnce);
 
     element._initialLoadComplete = true;
@@ -1643,13 +1644,14 @@
 
     value.basePatchNum = 1 as BasePatchSetNum;
     value.patchNum = 2 as RevisionPatchSetNum;
-    element._paramsChanged(value);
+    element.params = {...value};
+    await flush();
     assert.isFalse(reloadStub.calledTwice);
     assert.isTrue(reloadPatchDependentStub.calledOnce);
     assert.isTrue(collapseStub.calledTwice);
   });
 
-  test('reload ported comments when patchNum changes', () => {
+  test('reload ported comments when patchNum changes', async () => {
     sinon.stub(element, 'loadData').callsFake(() => Promise.resolve([]));
     sinon.stub(element, '_getCommitInfo');
     sinon.stub(element.$.fileList, 'reload');
@@ -1665,7 +1667,8 @@
       view: GerritView.CHANGE,
       patchNum: 1 as RevisionPatchSetNum,
     };
-    element._paramsChanged(value);
+    element.params = value;
+    await flush();
 
     element._initialLoadComplete = true;
     element._change = {
@@ -1678,24 +1681,42 @@
 
     value.basePatchNum = 1 as BasePatchSetNum;
     value.patchNum = 2 as RevisionPatchSetNum;
-    element._paramsChanged(value);
+    element.params = {...value};
+    await flush();
     assert.isTrue(reloadPortedCommentsStub.calledOnce);
   });
 
-  test('reload entire page when patchRange doesnt change', () => {
+  test('reload entire page when patchRange doesnt change', async () => {
     const reloadStub = sinon
       .stub(element, 'loadData')
       .callsFake(() => Promise.resolve([]));
     const collapseStub = sinon.stub(element.$.fileList, 'collapseAllDiffs');
     const value: AppElementChangeViewParams = createAppElementChangeViewParams();
-    element._paramsChanged(value);
+    element.params = value;
+    await flush();
     assert.isTrue(reloadStub.calledOnce);
     element._initialLoadComplete = true;
-    element._paramsChanged(value);
+    element.params = {...value};
+    await flush();
     assert.isTrue(reloadStub.calledTwice);
     assert.isTrue(collapseStub.calledTwice);
   });
 
+  test('do not handle new change numbers', async () => {
+    const recreateSpy = sinon.spy();
+    element.addEventListener('recreate-change-view', recreateSpy);
+
+    const value: AppElementChangeViewParams = createAppElementChangeViewParams();
+    element.params = value;
+    await flush();
+    assert.isFalse(recreateSpy.calledOnce);
+
+    value.changeNum = 555111333 as NumericChangeId;
+    element.params = {...value};
+    await flush();
+    assert.isTrue(recreateSpy.calledOnce);
+  });
+
   test('related changes are not updated after other action', done => {
     sinon.stub(element, 'loadData').callsFake(() => Promise.resolve([]));
     flush();
@@ -2026,7 +2047,7 @@
       element.handleScroll();
     });
 
-    test('scrollTop is set correctly', () => {
+    test('scrollTop is set correctly', async () => {
       element.viewState = {scrollTop: TEST_SCROLL_TOP_PX};
 
       sinon.stub(element, 'loadData').callsFake(() => {
@@ -2039,7 +2060,8 @@
 
       // simulate reloading component, which is done when route
       // changes to match a regex of change view type.
-      element._paramsChanged({...createAppElementChangeViewParams()});
+      element.params = {...createAppElementChangeViewParams()};
+      await flush();
     });
 
     test('scrollTop is reset when new change is loaded', () => {
@@ -2591,7 +2613,7 @@
       });
     });
 
-    test('report changeDisplayed on _paramsChanged', done => {
+    test('report changeDisplayed on _paramsChanged', async () => {
       const changeDisplayStub = sinon.stub(
         appContext.reportingService,
         'changeDisplayed'
@@ -2600,16 +2622,14 @@
         appContext.reportingService,
         'changeFullyLoaded'
       );
-      element._paramsChanged({
+      element.params = {
         ...createAppElementChangeViewParams(),
         changeNum: TEST_NUMERIC_CHANGE_ID,
         project: TEST_PROJECT_NAME,
-      });
-      flush(() => {
-        assert.isTrue(changeDisplayStub.called);
-        assert.isTrue(changeFullyLoadedStub.called);
-        done();
-      });
+      };
+      await flush();
+      assert.isTrue(changeDisplayStub.called);
+      assert.isTrue(changeFullyLoadedStub.called);
     });
   });
 
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog_test.js b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog_test.js
deleted file mode 100644
index c98353b..0000000
--- a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog_test.js
+++ /dev/null
@@ -1,61 +0,0 @@
-/**
- * @license
- * Copyright (C) 2018 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../../../test/common-test-setup-karma.js';
-import './gr-confirm-cherrypick-conflict-dialog.js';
-
-const basicFixture =
-    fixtureFromElement('gr-confirm-cherrypick-conflict-dialog');
-
-suite('gr-confirm-cherrypick-conflict-dialog tests', () => {
-  let element;
-
-  setup(() => {
-    element = basicFixture.instantiate();
-  });
-
-  test('_handleConfirmTap', () => {
-    const confirmHandler = sinon.stub();
-    element.addEventListener('confirm', confirmHandler);
-    sinon.spy(element, '_handleConfirmTap');
-    element.shadowRoot
-        .querySelector('gr-dialog').dispatchEvent(
-            new CustomEvent('confirm', {
-              composed: true, bubbles: true,
-            }));
-    assert.isTrue(confirmHandler.called);
-    assert.isTrue(confirmHandler.calledOnce);
-    assert.isTrue(element._handleConfirmTap.called);
-    assert.isTrue(element._handleConfirmTap.calledOnce);
-  });
-
-  test('_handleCancelTap', () => {
-    const cancelHandler = sinon.stub();
-    element.addEventListener('cancel', cancelHandler);
-    sinon.spy(element, '_handleCancelTap');
-    element.shadowRoot
-        .querySelector('gr-dialog').dispatchEvent(
-            new CustomEvent('cancel', {
-              composed: true, bubbles: true,
-            }));
-    assert.isTrue(cancelHandler.called);
-    assert.isTrue(cancelHandler.calledOnce);
-    assert.isTrue(element._handleCancelTap.called);
-    assert.isTrue(element._handleCancelTap.calledOnce);
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog_test.ts b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog_test.ts
new file mode 100644
index 0000000..f811619
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog_test.ts
@@ -0,0 +1,56 @@
+/**
+ * @license
+ * Copyright (C) 2018 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma';
+import {queryAndAssert} from '../../../utils/common-util';
+import {fireEvent} from '../../../utils/event-util';
+import './gr-confirm-cherrypick-conflict-dialog';
+import {GrConfirmCherrypickConflictDialog} from './gr-confirm-cherrypick-conflict-dialog';
+
+const basicFixture = fixtureFromElement(
+  'gr-confirm-cherrypick-conflict-dialog'
+);
+
+suite('gr-confirm-cherrypick-conflict-dialog tests', () => {
+  let element: GrConfirmCherrypickConflictDialog;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+  });
+
+  test('_handleConfirmTap', () => {
+    const confirmHandler = sinon.stub();
+    element.addEventListener('confirm', confirmHandler);
+    const confirmTapStub = sinon.spy(element, '_handleConfirmTap');
+    fireEvent(queryAndAssert(element, 'gr-dialog'), 'confirm');
+    assert.isTrue(confirmHandler.called);
+    assert.isTrue(confirmHandler.calledOnce);
+    assert.isTrue(confirmTapStub.called);
+    assert.isTrue(confirmTapStub.calledOnce);
+  });
+
+  test('_handleCancelTap', () => {
+    const cancelHandler = sinon.stub();
+    element.addEventListener('cancel', cancelHandler);
+    const cancelTapStub = sinon.spy(element, '_handleCancelTap');
+    fireEvent(queryAndAssert(element, 'gr-dialog'), 'cancel');
+    assert.isTrue(cancelHandler.called);
+    assert.isTrue(cancelHandler.calledOnce);
+    assert.isTrue(cancelTapStub.called);
+    assert.isTrue(cancelTapStub.calledOnce);
+  });
+});
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_test.js b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_test.js
deleted file mode 100644
index 7c84043..0000000
--- a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_test.js
+++ /dev/null
@@ -1,83 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../../../test/common-test-setup-karma.js';
-import './gr-confirm-revert-dialog.js';
-
-const basicFixture = fixtureFromElement('gr-confirm-revert-dialog');
-
-suite('gr-confirm-revert-dialog tests', () => {
-  let element;
-
-  setup(() => {
-    element = basicFixture.instantiate();
-  });
-
-  test('no match', () => {
-    assert.isNotOk(element._message);
-    const alertStub = sinon.stub();
-    element.addEventListener('show-alert', alertStub);
-    element._populateRevertSingleChangeMessage({},
-        'not a commitHash in sight', undefined);
-    assert.isTrue(alertStub.calledOnce);
-  });
-
-  test('single line', () => {
-    assert.isNotOk(element._message);
-    element._populateRevertSingleChangeMessage({},
-        'one line commit\n\nChange-Id: abcdefg\n',
-        'abcd123');
-    const expected = 'Revert "one line commit"\n\n' +
-        'This reverts commit abcd123.\n\n' +
-        'Reason for revert: <INSERT REASONING HERE>\n';
-    assert.equal(element._message, expected);
-  });
-
-  test('multi line', () => {
-    assert.isNotOk(element._message);
-    element._populateRevertSingleChangeMessage({},
-        'many lines\ncommit\n\nmessage\n\nChange-Id: abcdefg\n',
-        'abcd123');
-    const expected = 'Revert "many lines"\n\n' +
-        'This reverts commit abcd123.\n\n' +
-        'Reason for revert: <INSERT REASONING HERE>\n';
-    assert.equal(element._message, expected);
-  });
-
-  test('issue above change id', () => {
-    assert.isNotOk(element._message);
-    element._populateRevertSingleChangeMessage({},
-        'much lines\nvery\n\ncommit\n\nBug: Issue 42\nChange-Id: abcdefg\n',
-        'abcd123');
-    const expected = 'Revert "much lines"\n\n' +
-        'This reverts commit abcd123.\n\n' +
-        'Reason for revert: <INSERT REASONING HERE>\n';
-    assert.equal(element._message, expected);
-  });
-
-  test('revert a revert', () => {
-    assert.isNotOk(element._message);
-    element._populateRevertSingleChangeMessage({},
-        'Revert "one line commit"\n\nChange-Id: abcdefg\n',
-        'abcd123');
-    const expected = 'Revert "Revert "one line commit""\n\n' +
-        'This reverts commit abcd123.\n\n' +
-        'Reason for revert: <INSERT REASONING HERE>\n';
-    assert.equal(element._message, expected);
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_test.ts b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_test.ts
new file mode 100644
index 0000000..38429b1
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_test.ts
@@ -0,0 +1,100 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma';
+import {createChange} from '../../../test/test-data-generators';
+import {CommitId} from '../../../types/common';
+import './gr-confirm-revert-dialog';
+import {GrConfirmRevertDialog} from './gr-confirm-revert-dialog';
+
+const basicFixture = fixtureFromElement('gr-confirm-revert-dialog');
+
+suite('gr-confirm-revert-dialog tests', () => {
+  let element: GrConfirmRevertDialog;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+  });
+
+  test('no match', () => {
+    assert.isNotOk(element._message);
+    const alertStub = sinon.stub();
+    element.addEventListener('show-alert', alertStub);
+    element._populateRevertSingleChangeMessage(
+      createChange(),
+      'not a commitHash in sight',
+      undefined
+    );
+    assert.isTrue(alertStub.calledOnce);
+  });
+
+  test('single line', () => {
+    assert.isNotOk(element._message);
+    element._populateRevertSingleChangeMessage(
+      createChange(),
+      'one line commit\n\nChange-Id: abcdefg\n',
+      'abcd123' as CommitId
+    );
+    const expected =
+      'Revert "one line commit"\n\n' +
+      'This reverts commit abcd123.\n\n' +
+      'Reason for revert: <INSERT REASONING HERE>\n';
+    assert.equal(element._message, expected);
+  });
+
+  test('multi line', () => {
+    assert.isNotOk(element._message);
+    element._populateRevertSingleChangeMessage(
+      createChange(),
+      'many lines\ncommit\n\nmessage\n\nChange-Id: abcdefg\n',
+      'abcd123' as CommitId
+    );
+    const expected =
+      'Revert "many lines"\n\n' +
+      'This reverts commit abcd123.\n\n' +
+      'Reason for revert: <INSERT REASONING HERE>\n';
+    assert.equal(element._message, expected);
+  });
+
+  test('issue above change id', () => {
+    assert.isNotOk(element._message);
+    element._populateRevertSingleChangeMessage(
+      createChange(),
+      'much lines\nvery\n\ncommit\n\nBug: Issue 42\nChange-Id: abcdefg\n',
+      'abcd123' as CommitId
+    );
+    const expected =
+      'Revert "much lines"\n\n' +
+      'This reverts commit abcd123.\n\n' +
+      'Reason for revert: <INSERT REASONING HERE>\n';
+    assert.equal(element._message, expected);
+  });
+
+  test('revert a revert', () => {
+    assert.isNotOk(element._message);
+    element._populateRevertSingleChangeMessage(
+      createChange(),
+      'Revert "one line commit"\n\nChange-Id: abcdefg\n',
+      'abcd123' as CommitId
+    );
+    const expected =
+      'Revert "Revert "one line commit""\n\n' +
+      'This reverts commit abcd123.\n\n' +
+      'Reason for revert: <INSERT REASONING HERE>\n';
+    assert.equal(element._message, expected);
+  });
+});
diff --git a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.ts b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.ts
index 52ec8af..ea3c01d 100644
--- a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.ts
@@ -31,6 +31,7 @@
   RepoName,
 } from '../../../types/common';
 import {GrDownloadDialog} from './gr-download-dialog';
+import {mockPromise} from '../../../test/test-utils';
 
 const basicFixture = fixtureFromElement('gr-download-dialog');
 
@@ -168,14 +169,16 @@
       );
     });
 
-    test('close event', done => {
+    test('close event', async () => {
+      const closeCalled = mockPromise();
       element.addEventListener('close', () => {
-        done();
+        closeCalled.resolve();
       });
       const closeButton = element.shadowRoot!.querySelector(
         '.closeButtonContainer gr-button'
       );
       tap(closeButton!);
+      await closeCalled;
     });
   });
 
diff --git a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_html.ts b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_html.ts
index 8b9b80c..be3a0be 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_html.ts
@@ -167,6 +167,30 @@
           <span class="separator"></span>
         </span>
       </template>
+      <div class="fileViewActions">
+        <span class="fileViewActionsLabel">Diff view:</span>
+        <gr-diff-mode-selector
+          id="modeSelect"
+          mode="{{diffViewMode}}"
+          save-on-change="[[!diffPrefsDisabled]]"
+        ></gr-diff-mode-selector>
+        <span
+          id="diffPrefsContainer"
+          class="hideOnEdit"
+          hidden$="[[_computePrefsButtonHidden(diffPrefs, diffPrefsDisabled)]]"
+          hidden=""
+        >
+          <gr-button
+            link=""
+            has-tooltip=""
+            title="Diff preferences"
+            class="prefsButton desktop"
+            on-click="_handlePrefsTap"
+            ><iron-icon icon="gr-icons:settings"></iron-icon
+          ></gr-button>
+        </span>
+      </div>
+      <span class="separator"></span>
       <span class="downloadContainer desktop">
         <gr-button
           link=""
@@ -209,30 +233,6 @@
           Bulk actions disabled because there are too many files.
         </div>
       </template>
-      <div class="fileViewActions">
-        <span class="separator"></span>
-        <span class="fileViewActionsLabel">Diff view:</span>
-        <gr-diff-mode-selector
-          id="modeSelect"
-          mode="{{diffViewMode}}"
-          save-on-change="[[!diffPrefsDisabled]]"
-        ></gr-diff-mode-selector>
-        <span
-          id="diffPrefsContainer"
-          class="hideOnEdit"
-          hidden$="[[_computePrefsButtonHidden(diffPrefs, diffPrefsDisabled)]]"
-          hidden=""
-        >
-          <gr-button
-            link=""
-            has-tooltip=""
-            title="Diff preferences"
-            class="prefsButton desktop"
-            on-click="_handlePrefsTap"
-            ><iron-icon icon="gr-icons:settings"></iron-icon
-          ></gr-button>
-        </span>
-      </div>
     </div>
   </div>
 `;
diff --git a/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores_test.js b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores_test.ts
similarity index 61%
rename from polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores_test.js
rename to polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores_test.ts
index 2335307..58fe189 100644
--- a/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores_test.js
+++ b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores_test.ts
@@ -15,20 +15,28 @@
  * limitations under the License.
  */
 
-import '../../../test/common-test-setup-karma.js';
-import './gr-label-scores.js';
-import {stubRestApi} from '../../../test/test-utils.js';
+import '../../../test/common-test-setup-karma';
+import './gr-label-scores';
+import {queryAndAssert, stubRestApi} from '../../../test/test-utils';
+import {GrLabelScores} from './gr-label-scores';
+import {AccountId} from '../../../types/common';
+import {GrLabelScoreRow} from '../gr-label-score-row/gr-label-score-row';
+import {
+  createAccountWithId,
+  createChange,
+} from '../../../test/test-data-generators';
 
 const basicFixture = fixtureFromElement('gr-label-scores');
 
 suite('gr-label-scores tests', () => {
-  let element;
+  const accountId = 123 as AccountId;
+  let element: GrLabelScores;
 
   setup(async () => {
-    stubRestApi('getLoggedIn').returns(Promise.resolve(false));
+    stubRestApi('getLoggedIn').resolves(false);
     element = basicFixture.instantiate();
     element.change = {
-      _number: '123',
+      ...createChange(),
       labels: {
         'Code-Review': {
           values: {
@@ -40,12 +48,14 @@
           },
           default_value: 0,
           value: 1,
-          all: [{
-            _account_id: 123,
-            value: 1,
-          }],
+          all: [
+            {
+              _account_id: accountId,
+              value: 1,
+            },
+          ],
         },
-        'Verified': {
+        Verified: {
           values: {
             '0': 'No score',
             '+1': 'good',
@@ -55,50 +65,42 @@
           },
           default_value: 0,
           value: 1,
-          all: [{
-            _account_id: 123,
-            value: 1,
-          }],
+          all: [
+            {
+              _account_id: accountId,
+              value: 1,
+            },
+          ],
         },
       },
     };
 
-    element.account = {
-      _account_id: 123,
-    };
+    element.account = createAccountWithId(accountId);
 
     element.permittedLabels = {
-      'Code-Review': [
-        '-2',
-        '-1',
-        ' 0',
-        '+1',
-        '+2',
-      ],
-      'Verified': [
-        '-1',
-        ' 0',
-        '+1',
-      ],
+      'Code-Review': ['-2', '-1', ' 0', '+1', '+2'],
+      Verified: ['-1', ' 0', '+1'],
     };
     await flush();
   });
 
   test('get and set label scores', () => {
-    for (const label of Object.keys(element.permittedLabels)) {
-      const row = element.shadowRoot
-          .querySelector('gr-label-score-row[name="' + label + '"]');
-      row.setSelectedValue(-1);
+    for (const label of Object.keys(element.permittedLabels!)) {
+      const row = queryAndAssert<GrLabelScoreRow>(
+        element,
+        'gr-label-score-row[name="' + label + '"]'
+      );
+      row.setSelectedValue('-1');
     }
     assert.deepEqual(element.getLabelValues(), {
       'Code-Review': -1,
-      'Verified': -1,
+      Verified: -1,
     });
   });
 
   test('getLabelValues includeDefaults', async () => {
     element.change = {
-      _number: '123',
+      ...createChange(),
       labels: {
         'Code-Review': {
           values: {'0': 'meh', '+1': 'good', '-1': 'bad'},
@@ -114,9 +116,14 @@
 
   test('_getVoteForAccount', () => {
     const labelName = 'Code-Review';
-    assert.strictEqual(element._getVoteForAccount(
-        element.change.labels, labelName, element.account),
-    '+1');
+    assert.strictEqual(
+      element._getVoteForAccount(
+        element.change!.labels,
+        labelName,
+        element.account
+      ),
+      '+1'
+    );
   });
 
   test('_computeColumns', () => {
@@ -132,26 +139,30 @@
 
   test('_computeLabelAccessClass undefined case', () => {
     assert.strictEqual(
-        element._computeLabelAccessClass(undefined, undefined), '');
-    assert.strictEqual(
-        element._computeLabelAccessClass('', undefined), '');
-    assert.strictEqual(
-        element._computeLabelAccessClass(undefined, {}), '');
+      element._computeLabelAccessClass(undefined, undefined),
+      ''
+    );
+    assert.strictEqual(element._computeLabelAccessClass('', undefined), '');
+    assert.strictEqual(element._computeLabelAccessClass(undefined, {}), '');
   });
 
   test('_computeLabelAccessClass has access', () => {
     assert.strictEqual(
-        element._computeLabelAccessClass('foo', {foo: ['']}), 'access');
+      element._computeLabelAccessClass('foo', {foo: ['']}),
+      'access'
+    );
   });
 
   test('_computeLabelAccessClass no access', () => {
     assert.strictEqual(
-        element._computeLabelAccessClass('zap', {foo: ['']}), 'no-access');
+      element._computeLabelAccessClass('zap', {foo: ['']}),
+      'no-access'
+    );
   });
 
   test('changes in label score are reflected in _labels', () => {
     element.change = {
-      _number: '123',
+      ...createChange(),
       labels: {
         'Code-Review': {
           values: {
@@ -163,7 +174,7 @@
           },
           default_value: 0,
         },
-        'Verified': {
+        Verified: {
           values: {
             '0': 'No score',
             '+1': 'good',
@@ -175,15 +186,17 @@
         },
       },
     };
-    assert.deepEqual(element._labels [
-        ({name: 'Code-Review', value: null}, {name: 'Verified', value: null})
+    assert.deepEqual(element._labels, [
+      {name: 'Code-Review', value: null},
+      {name: 'Verified', value: null},
     ]);
-    element.set(['change', 'labels', 'Verified', 'all'],
-        [{_account_id: 123, value: 1}]);
+    element.set(
+      ['change', 'labels', 'Verified', 'all'],
+      [{_account_id: accountId, value: 1}]
+    );
     assert.deepEqual(element._labels, [
       {name: 'Code-Review', value: null},
       {name: 'Verified', value: '+1'},
     ]);
   });
 });
-
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.ts b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.ts
index ae9af4a..15bc6bf 100644
--- a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.ts
@@ -599,7 +599,7 @@
       resetPlugins();
     });
 
-    test('endpoint params', done => {
+    test('endpoint params', async () => {
       element.change = {...createParsedChange(), labels: {}};
       interface RelatedChangesListGrEndpointDecorator
         extends GrEndpointDecorator {
@@ -620,11 +620,9 @@
         'http://some/plugins/url1.js'
       );
       getPluginLoader().loadPlugins([]);
-      flush(() => {
-        assert.strictEqual(hookEl.plugin, plugin);
-        assert.strictEqual(hookEl.change, element.change);
-        done();
-      });
+      await flush();
+      assert.strictEqual(hookEl!.plugin, plugin!);
+      assert.strictEqual(hookEl!.change, element.change);
     });
   });
 });
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
index 939b23c..e8d11fe 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
@@ -1325,7 +1325,7 @@
   _computePatchSetWarning(patchNum?: PatchSetNum, labelsChanged?: boolean) {
     let str = `Patch ${patchNum} is not latest.`;
     if (labelsChanged) {
-      str += ' Voting will have no effect.';
+      str += ' Voting may have no effect.';
     }
     return str;
   }
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_html.ts b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_html.ts
index c3c1d54..5c863c9 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_html.ts
@@ -496,7 +496,7 @@
               <gr-account-label
                 account="[[account]]"
                 force-attention="[[_computeHasNewAttention(account, _newAttentionSet)]]"
-                selected="[[_computeHasNewAttention(account, _newAttentionSet)]]
+                selected="[[_computeHasNewAttention(account, _newAttentionSet)]]"
                 hide-hovercard=""
                 selection-chip-style
                 on-click="_handleAttentionClick"
diff --git a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.ts b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.ts
index 4b57651..354d360 100644
--- a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.ts
@@ -17,7 +17,11 @@
 
 import '../../../test/common-test-setup-karma';
 import './gr-reviewer-list';
-import {queryAndAssert, stubRestApi} from '../../../test/test-utils';
+import {
+  mockPromise,
+  queryAndAssert,
+  stubRestApi,
+} from '../../../test/test-utils';
 import {GrReviewerList} from './gr-reviewer-list';
 import {
   createAccountDetailWithId,
@@ -53,12 +57,14 @@
     );
   });
 
-  test('add reviewer button opens reply dialog', done => {
+  test('add reviewer button opens reply dialog', async () => {
+    const dialogShown = mockPromise();
     element.addEventListener('show-reply-dialog', () => {
-      done();
+      dialogShown.resolve();
     });
-    flush();
+    await flush();
     tap(queryAndAssert(element, '.addReviewer'));
+    await dialogShown;
   });
 
   test('only show remove for removable reviewers', () => {
diff --git a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.ts b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.ts
index 8283be79..2e8e4ba 100644
--- a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.ts
@@ -17,18 +17,20 @@
 import '@polymer/paper-toggle-button/paper-toggle-button';
 import '../../../styles/shared-styles';
 import '../../shared/gr-comment-thread/gr-comment-thread';
+import '../../shared/gr-dropdown-list/gr-dropdown-list';
+
 import {flush} from '@polymer/polymer/lib/legacy/polymer.dom';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-thread-list_html';
 import {parseDate} from '../../../utils/date-util';
 
 import {CommentSide, SpecialFilePath} from '../../../constants/constants';
-import {customElement, observe, property} from '@polymer/decorators';
+import {computed, customElement, observe, property} from '@polymer/decorators';
 import {
   PolymerSpliceChange,
   PolymerDeepPropertyChange,
 } from '@polymer/polymer/interfaces';
-import {ChangeInfo} from '../../../types/common';
+import {AccountInfo, ChangeInfo} from '../../../types/common';
 import {
   CommentThread,
   isDraft,
@@ -36,11 +38,14 @@
   isDraftThread,
   isRobotThread,
   hasHumanReply,
+  getCommentAuthors,
 } from '../../../utils/comment-util';
 import {pluralize} from '../../../utils/string-util';
 import {fireThreadListModifiedEvent} from '../../../utils/event-util';
-import {assertNever} from '../../../utils/common-util';
+import {assertIsDefined, assertNever} from '../../../utils/common-util';
 import {CommentTabState} from '../../../types/events';
+import {DropdownItem} from '../../shared/gr-dropdown-list/gr-dropdown-list';
+import {GrAccountChip} from '../../shared/gr-account-chip/gr-account-chip';
 
 interface CommentThreadWithInfo {
   thread: CommentThread;
@@ -52,6 +57,13 @@
   updated?: Date;
 }
 
+enum SortDropdownState {
+  TIMESTAMP = 'Latest timestamp',
+  FILES = 'Files',
+}
+
+export const __testOnly_SortDropdownState = SortDropdownState;
+
 @customElement('gr-thread-list')
 export class GrThreadList extends PolymerElement {
   static get template() {
@@ -79,7 +91,7 @@
   @property({
     computed:
       '_computeDisplayedThreads(_sortedThreads.*, unresolvedOnly, ' +
-      '_draftsOnly, onlyShowRobotCommentsWithHumanReply)',
+      '_draftsOnly, onlyShowRobotCommentsWithHumanReply, selectedAuthors)',
     type: Array,
   })
   _displayedThreads: CommentThread[] = [];
@@ -102,6 +114,21 @@
   @property({type: Object, observer: '_commentTabStateChange'})
   commentTabState?: CommentTabState;
 
+  @property({type: Object})
+  sortDropdownValue: SortDropdownState = SortDropdownState.TIMESTAMP;
+
+  @property({type: Array, notify: true})
+  selectedAuthors: AccountInfo[] = [];
+
+  @computed('unresolvedOnly', '_draftsOnly')
+  get commentsDropdownValue() {
+    // set initial value and triggered when comment summary chips are clicked
+    if (this._draftsOnly) return CommentTabState.DRAFTS;
+    return this.unresolvedOnly
+      ? CommentTabState.UNRESOLVED
+      : CommentTabState.SHOW_ALL;
+  }
+
   _showEmptyThreadsMessage(
     threads: CommentThread[],
     displayedThreads: CommentThread[],
@@ -146,7 +173,74 @@
     this.unresolvedOnly = !this.unresolvedOnly;
   }
 
+  getSortDropdownEntires() {
+    return [
+      {text: SortDropdownState.FILES, value: SortDropdownState.FILES},
+      {text: SortDropdownState.TIMESTAMP, value: SortDropdownState.TIMESTAMP},
+    ];
+  }
+
+  getCommentsDropdownEntires(threads: CommentThread[], loggedIn?: boolean) {
+    const items: DropdownItem[] = [
+      {
+        text: `Unresolved (${this._countUnresolved(threads)})`,
+        value: CommentTabState.UNRESOLVED,
+      },
+      {
+        text: `All (${this._countAllThreads(threads)})`,
+        value: CommentTabState.SHOW_ALL,
+      },
+    ];
+    if (loggedIn)
+      items.splice(1, 0, {
+        text: `Drafts (${this._countDrafts(threads)})`,
+        value: CommentTabState.DRAFTS,
+      });
+    return items;
+  }
+
+  getCommentAuthors(threads?: CommentThread[]) {
+    return getCommentAuthors(threads);
+  }
+
+  handleAccountClicked(e: MouseEvent) {
+    const account = (e.target as GrAccountChip).account;
+    assertIsDefined(account, 'account');
+    const index = this.selectedAuthors.findIndex(
+      author => author._account_id === account._account_id
+    );
+    if (index === -1) this.push('selectedAuthors', account);
+    else this.splice('selectedAuthors', index, 1);
+    // re-assign so that isSelected template method is called
+    this.selectedAuthors = [...this.selectedAuthors];
+  }
+
+  isSelected(author: AccountInfo, selectedAuthors: AccountInfo[]) {
+    return selectedAuthors.some(a => a._account_id === author._account_id);
+  }
+
+  handleSortDropdownValueChange(e: CustomEvent) {
+    this.sortDropdownValue = e.detail.value;
+    /*
+     * Ideally we would have updateSortedThreads observe on sortDropdownValue
+     * but the method triggered re-render only when the length of threads
+     * changes, hence keep the explicit resortThreads method
+     */
+    this.resortThreads(this.threads);
+  }
+
+  handleCommentsDropdownValueChange(e: CustomEvent) {
+    const value = e.detail.value;
+    if (value === CommentTabState.UNRESOLVED) this._handleOnlyUnresolved();
+    else if (value === CommentTabState.DRAFTS) this._handleOnlyDrafts();
+    else this._handleAllComments();
+  }
+
   _compareThreads(c1: CommentThreadWithInfo, c2: CommentThreadWithInfo) {
+    if (this.sortDropdownValue === SortDropdownState.TIMESTAMP) {
+      if (c1.updated && c2.updated) return c1.updated > c2.updated ? -1 : 1;
+    }
+
     if (c1.thread.path !== c2.thread.path) {
       // '/PATCHSET' will not come before '/COMMIT' when sorting
       // alphabetically so move it to the front explicitly
@@ -202,6 +296,15 @@
     return 0;
   }
 
+  resortThreads(threads: CommentThread[]) {
+    const threadsWithInfo = threads.map(thread =>
+      this._getThreadWithStatusInfo(thread)
+    );
+    this._sortedThreads = threadsWithInfo
+      .sort((t1, t2) => this._compareThreads(t1, t2))
+      .map(threadInfo => threadInfo.thread);
+  }
+
   /**
    * Observer on threads and update _sortedThreads when needed.
    * Order as follows:
@@ -254,12 +357,7 @@
       return;
     }
 
-    const threadsWithInfo = threads.map(thread =>
-      this._getThreadWithStatusInfo(thread)
-    );
-    this._sortedThreads = threadsWithInfo
-      .sort((t1, t2) => this._compareThreads(t1, t2))
-      .map(threadInfo => threadInfo.thread);
+    this.resortThreads(threads);
   }
 
   _computeDisplayedThreads(
@@ -269,7 +367,8 @@
     >,
     unresolvedOnly?: boolean,
     draftsOnly?: boolean,
-    onlyShowRobotCommentsWithHumanReply?: boolean
+    onlyShowRobotCommentsWithHumanReply?: boolean,
+    selectedAuthors?: AccountInfo[]
   ) {
     if (!sortedThreadsRecord || !sortedThreadsRecord.base) return [];
     return sortedThreadsRecord.base.filter(t =>
@@ -277,7 +376,8 @@
         t,
         unresolvedOnly,
         draftsOnly,
-        onlyShowRobotCommentsWithHumanReply
+        onlyShowRobotCommentsWithHumanReply,
+        selectedAuthors
       )
     );
   }
@@ -287,14 +387,16 @@
     thread: CommentThread,
     unresolvedOnly?: boolean,
     draftsOnly?: boolean,
-    onlyShowRobotCommentsWithHumanReply?: boolean
+    onlyShowRobotCommentsWithHumanReply?: boolean,
+    selectedAuthors?: AccountInfo[]
   ) {
     const threads = displayedThreads.filter(t =>
       this._shouldShowThread(
         t,
         unresolvedOnly,
         draftsOnly,
-        onlyShowRobotCommentsWithHumanReply
+        onlyShowRobotCommentsWithHumanReply,
+        selectedAuthors
       )
     );
     const index = threads.findIndex(t => t.rootId === thread.rootId);
@@ -309,14 +411,16 @@
     thread: CommentThread,
     unresolvedOnly?: boolean,
     draftsOnly?: boolean,
-    onlyShowRobotCommentsWithHumanReply?: boolean
+    onlyShowRobotCommentsWithHumanReply?: boolean,
+    selectedAuthors?: AccountInfo[]
   ) {
     const threads = displayedThreads.filter(t =>
       this._shouldShowThread(
         t,
         unresolvedOnly,
         draftsOnly,
-        onlyShowRobotCommentsWithHumanReply
+        onlyShowRobotCommentsWithHumanReply,
+        selectedAuthors
       )
     );
     const index = threads.findIndex(t => t.rootId === thread.rootId);
@@ -330,7 +434,8 @@
         thread,
         unresolvedOnly,
         draftsOnly,
-        onlyShowRobotCommentsWithHumanReply
+        onlyShowRobotCommentsWithHumanReply,
+        selectedAuthors
       )
     );
   }
@@ -339,7 +444,8 @@
     thread: CommentThread,
     unresolvedOnly?: boolean,
     draftsOnly?: boolean,
-    onlyShowRobotCommentsWithHumanReply?: boolean
+    onlyShowRobotCommentsWithHumanReply?: boolean,
+    selectedAuthors?: AccountInfo[]
   ) {
     if (
       [
@@ -347,11 +453,26 @@
         unresolvedOnly,
         draftsOnly,
         onlyShowRobotCommentsWithHumanReply,
+        selectedAuthors,
       ].includes(undefined)
     ) {
       return false;
     }
 
+    if (selectedAuthors!.length) {
+      if (
+        !thread.comments.some(
+          c =>
+            c.author &&
+            selectedAuthors!.some(
+              author => c.author!._account_id === author._account_id
+            )
+        )
+      ) {
+        return false;
+      }
+    }
+
     if (
       !draftsOnly &&
       !unresolvedOnly &&
diff --git a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_html.ts b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_html.ts
index da91095..41beed2 100644
--- a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_html.ts
@@ -33,7 +33,7 @@
       border-top: 1px solid var(--border-color);
       display: flex;
       justify-content: left;
-      padding: var(--spacing-m) var(--spacing-l);
+      padding: var(--spacing-s) var(--spacing-l);
     }
     .draftsOnly:not(.unresolvedOnly) gr-comment-thread[has-draft],
     .unresolvedOnly:not(.draftsOnly) gr-comment-thread[unresolved],
@@ -48,54 +48,70 @@
       box-shadow: none;
       padding-left: var(--spacing-m);
     }
-    .header .categoryRadio {
-      height: 18px;
-      width: 18px;
-    }
-    .header label {
-      padding-left: 8px;
-      margin-right: 16px;
-    }
     .partypopper{
       margin-right: var(--spacing-s);
     }
+    gr-dropdown-list {
+      --trigger-style: {
+        color: var(--primary-text-color);
+        text-transform: none;
+        font-family: var(--font-family);
+      }
+    }
+    .filter-text, .sort-text, .author-text {
+      margin-right: var(--spacing-s);
+      color: var(--deemphasized-text-color);
+    }
+    .author-text {
+      margin-left: var(--spacing-m);
+    }
+    gr-account-label {
+      --account-max-length: 120px;
+      display: inline-block;
+      user-select: none;
+      --label-border-radius: 8px;
+      margin: 0 var(--spacing-xs);
+      padding: var(--spacing-xs) var(--spacing-m);
+      line-height: var(--line-height-normal);
+      cursor: pointer;
+    }
+    gr-account-label:focus {
+      outline: none;
+    }
+    gr-account-label:hover,
+    gr-account-label:hover {
+      box-shadow: var(--elevation-level-1);
+      cursor: pointer;
+    }
   </style>
   <template is="dom-if" if="[[!hideToggleButtons]]">
     <div class="header">
-        <input
-          class="categoryRadio"
-          id="unresolvedRadio"
-          name="filterComments"
-          type="radio"
-          on-click="_handleOnlyUnresolved"
-          checked="[[unresolvedOnly]]"
-        />
-        <label for="unresolvedRadio">
-          Unresolved ([[_countUnresolved(threads)]])
-        </label>
-        <input
-          class="categoryRadio"
-          id="draftsRadio"
-          name="filterComments"
-          type="radio"
-          on-click="_handleOnlyDrafts"
-          checked="[[_draftsOnly]]"
-          hidden$="[[!loggedIn]]"
-        />
-        <label for="draftsRadio" hidden$="[[!loggedIn]]">
-          Drafts ([[_countDrafts(threads)]])
-        </label>
-        <input
-          class="categoryRadio"
-          id="allRadio"
-          name="filterComments"
-          type="radio"
-          on-click="_handleAllComments"
-          checked="[[_showAllComments(_draftsOnly, unresolvedOnly)]]"
-        />
-        <label for="allRadio">
-          All ([[_countAllThreads(threads)]])
-        </label>
+      <span class="sort-text">Sort By:</span>
+      <gr-dropdown-list
+        id="sortDropdown"
+        value="[[sortDropdownValue]]"
+        on-value-change="handleSortDropdownValueChange"
+        items="[[getSortDropdownEntires()]]"
+      >
+      </gr-dropdown-list>
+      <span class="separator"></span>
+      <span class="filter-text">Filter By:</span>
+      <gr-dropdown-list
+        id="filterDropdown"
+        value="[[commentsDropdownValue]]"
+        on-value-change="handleCommentsDropdownValueChange"
+        items="[[getCommentsDropdownEntires(threads, loggedIn)]]"
+      >
+      </gr-dropdown-list>
+      <span class="author-text">From:</span>
+      <template is="dom-repeat" items="[[getCommentAuthors(threads)]]">
+        <gr-account-label
+          account="[[item]]"
+          on-click="handleAccountClicked"
+          selection-chip-style
+          selected="[[isSelected(item, selectedAuthors)]]"
+        > </gr-account-label>
+      </template>
     </div>
   </template>
   <div id="threads">
@@ -131,7 +147,7 @@
     >
       <template
         is="dom-if"
-        if="[[_shouldRenderSeparator(_displayedThreads, thread, unresolvedOnly, _draftsOnly, onlyShowRobotCommentsWithHumanReply)]]"
+        if="[[_shouldRenderSeparator(_displayedThreads, thread, unresolvedOnly, _draftsOnly, onlyShowRobotCommentsWithHumanReply, selectedAuthors)]]"
       >
         <div class="thread-separator"></div>
       </template>
@@ -142,7 +158,7 @@
         change-num="[[changeNum]]"
         comments="[[thread.comments]]"
         diff-side="[[thread.diffSide]]"
-        show-file-name="[[_isFirstThreadWithFileName(_displayedThreads, thread, unresolvedOnly, _draftsOnly, onlyShowRobotCommentsWithHumanReply)]]"
+        show-file-name="[[_isFirstThreadWithFileName(_displayedThreads, thread, unresolvedOnly, _draftsOnly, onlyShowRobotCommentsWithHumanReply, selectedAuthors)]]"
         project-name="[[change.project]]"
         is-on-parent="[[_isOnParent(thread.commentSide)]]"
         line-num="[[thread.line]]"
diff --git a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_test.js b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_test.js
index 3d6cfd4..5c87c4b 100644
--- a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_test.js
+++ b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_test.js
@@ -19,6 +19,11 @@
 import './gr-thread-list.js';
 import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
 import {SpecialFilePath} from '../../../constants/constants.js';
+import {CommentTabState} from '../../../types/events.js';
+import {__testOnly_SortDropdownState} from './gr-thread-list.js';
+import {queryAll} from '../../../test/test-utils.js';
+import {accountOrGroupKey} from '../../../utils/account-util.js';
+import {tap} from '@polymer/iron-test-helpers/mock-interactions';
 
 const basicFixture = fixtureFromElement('gr-thread-list');
 
@@ -45,14 +50,14 @@
           {
             path: '/COMMIT_MSG',
             author: {
-              _account_id: 1000000,
+              _account_id: 1000001,
               name: 'user',
               username: 'user',
             },
             patch_set: 4,
             id: 'ecf0b9fa_fe1a5f62',
             line: 5,
-            updated: '2018-02-08 18:49:18.000000000',
+            updated: '1',
             message: 'test',
             unresolved: true,
           },
@@ -61,7 +66,7 @@
             path: '/COMMIT_MSG',
             line: 5,
             in_reply_to: 'ecf0b9fa_fe1a5f62',
-            updated: '2018-02-13 22:48:48.018000000',
+            updated: '1',
             message: 'draft',
             unresolved: true,
             __draft: true,
@@ -74,21 +79,21 @@
         path: '/COMMIT_MSG',
         line: 5,
         rootId: 'ecf0b9fa_fe1a5f62',
-        start_datetime: '2018-02-08 18:49:18.000000000',
+        updated: '1',
       },
       {
         comments: [
           {
             path: 'test.txt',
             author: {
-              _account_id: 1000000,
+              _account_id: 1000002,
               name: 'user',
               username: 'user',
             },
             patch_set: 3,
             id: '09a9fb0a_1484e6cf',
             side: 'PARENT',
-            updated: '2018-02-13 22:47:19.000000000',
+            updated: '2',
             message: 'Some comment on another patchset.',
             unresolved: false,
           },
@@ -96,7 +101,7 @@
         patchNum: 3,
         path: 'test.txt',
         rootId: '09a9fb0a_1484e6cf',
-        start_datetime: '2018-02-13 22:47:19.000000000',
+        updated: '2',
         commentSide: 'PARENT',
       },
       {
@@ -104,13 +109,13 @@
           {
             path: '/COMMIT_MSG',
             author: {
-              _account_id: 1000000,
+              _account_id: 1000002,
               name: 'user',
               username: 'user',
             },
             patch_set: 2,
             id: '8caddf38_44770ec1',
-            updated: '2018-02-13 22:48:40.000000000',
+            updated: '3',
             message: 'Another unresolved comment',
             unresolved: false,
           },
@@ -118,21 +123,21 @@
         patchNum: 2,
         path: '/COMMIT_MSG',
         rootId: '8caddf38_44770ec1',
-        start_datetime: '2018-02-13 22:48:40.000000000',
+        updated: '3',
       },
       {
         comments: [
           {
             path: '/COMMIT_MSG',
             author: {
-              _account_id: 1000000,
+              _account_id: 1000003,
               name: 'user',
               username: 'user',
             },
             patch_set: 2,
             id: 'scaddf38_44770ec1',
             line: 4,
-            updated: '2018-02-14 22:48:40.000000000',
+            updated: '4',
             message: 'Yet another unresolved comment',
             unresolved: true,
           },
@@ -141,7 +146,7 @@
         path: '/COMMIT_MSG',
         line: 4,
         rootId: 'scaddf38_44770ec1',
-        start_datetime: '2018-02-14 22:48:40.000000000',
+        updated: '4',
       },
       {
         comments: [
@@ -149,7 +154,7 @@
             id: 'zcf0b9fa_fe1a5f62',
             path: '/COMMIT_MSG',
             line: 6,
-            updated: '2018-02-15 22:48:48.018000000',
+            updated: '5',
             message: 'resolved draft',
             unresolved: false,
             __draft: true,
@@ -162,14 +167,14 @@
         path: '/COMMIT_MSG',
         line: 6,
         rootId: 'zcf0b9fa_fe1a5f62',
-        start_datetime: '2018-02-09 18:49:18.000000000',
+        updated: '5',
       },
       {
         comments: [
           {
             id: 'patchset_level_1',
             path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS,
-            updated: '2018-02-15 22:48:48.018000000',
+            updated: '6',
             message: 'patchset comment 1',
             unresolved: false,
             __editing: false,
@@ -179,14 +184,14 @@
         patchNum: 2,
         path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS,
         rootId: 'patchset_level_1',
-        start_datetime: '2018-02-09 18:49:18.000000000',
+        updated: '6',
       },
       {
         comments: [
           {
             id: 'patchset_level_2',
             path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS,
-            updated: '2018-02-15 22:48:48.018000000',
+            updated: '7',
             message: 'patchset comment 2',
             unresolved: false,
             __editing: false,
@@ -196,7 +201,7 @@
         patchNum: 3,
         path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS,
         rootId: 'patchset_level_2',
-        start_datetime: '2018-02-09 18:49:18.000000000',
+        updated: '7',
       },
       {
         comments: [
@@ -210,7 +215,7 @@
             patch_set: 4,
             id: 'rc1',
             line: 5,
-            updated: '2019-02-08 18:49:18.000000000',
+            updated: '8',
             message: 'test',
             unresolved: true,
             robot_id: 'rc1',
@@ -220,7 +225,7 @@
         path: '/COMMIT_MSG',
         line: 5,
         rootId: 'rc1',
-        start_datetime: '2019-02-08 18:49:18.000000000',
+        updated: '8',
       },
       {
         comments: [
@@ -234,7 +239,7 @@
             patch_set: 4,
             id: 'rc2',
             line: 7,
-            updated: '2019-03-08 18:49:18.000000000',
+            updated: '9',
             message: 'test',
             unresolved: true,
             robot_id: 'rc2',
@@ -249,7 +254,7 @@
             patch_set: 4,
             id: 'c2_1',
             line: 5,
-            updated: '2019-03-08 18:49:18.000000000',
+            updated: '10',
             message: 'test',
             unresolved: true,
           },
@@ -258,7 +263,7 @@
         path: '/COMMIT_MSG',
         line: 7,
         rootId: 'rc2',
-        start_datetime: '2019-03-08 18:49:18.000000000',
+        updated: '10',
       },
     ];
 
@@ -270,15 +275,15 @@
     });
   });
 
-  test('draft toggle only appears when logged in', () => {
+  test('draft dropdown item only appears when logged in', () => {
     element.loggedIn = false;
-    assert.equal(getComputedStyle(element.shadowRoot
-        .querySelector('#draftsRadio')).display,
-    'none');
+    flush();
+    assert.equal(element.getCommentsDropdownEntires(element.threads,
+        element.loggedIn).length, 2);
     element.loggedIn = true;
-    assert.notEqual(getComputedStyle(element.shadowRoot
-        .querySelector('#draftsRadio')).display,
-    'none');
+    flush();
+    assert.equal(element.getCommentsDropdownEntires(element.threads,
+        element.loggedIn).length, 3);
   });
 
   test('show all threads by default', () => {
@@ -298,13 +303,16 @@
   });
 
   test('showing file name takes visible threads into account', () => {
+    element.sortDropdownValue = __testOnly_SortDropdownState.FILES;
     assert.equal(element._isFirstThreadWithFileName(element._sortedThreads,
         element._sortedThreads[2], element.unresolvedOnly, element._draftsOnly,
-        element.onlyShowRobotCommentsWithHumanReply), true);
+        element.onlyShowRobotCommentsWithHumanReply, element.selectedAuthors),
+    true);
     element.unresolvedOnly = true;
     assert.equal(element._isFirstThreadWithFileName(element._sortedThreads,
         element._sortedThreads[2], element.unresolvedOnly, element._draftsOnly,
-        element.onlyShowRobotCommentsWithHumanReply), false);
+        element.onlyShowRobotCommentsWithHumanReply, element.selectedAuthors),
+    false);
   });
 
   test('onlyShowRobotCommentsWithHumanReply ', () => {
@@ -317,6 +325,10 @@
   });
 
   suite('_compareThreads', () => {
+    setup(() => {
+      element.sortDropdownValue = __testOnly_SortDropdownState.FILES;
+    });
+
     test('patchset comes before any other file', () => {
       const t1 = {thread: {path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS}};
       const t2 = {thread: {path: SpecialFilePath.COMMIT_MESSAGE}};
@@ -448,6 +460,7 @@
   });
 
   test('_computeSortedThreads', () => {
+    element.sortDropdownValue = __testOnly_SortDropdownState.FILES;
     assert.equal(element._sortedThreads.length, 9);
     const expectedSortedRootIds = [
       'patchset_level_2', // Posted on Patchset 3
@@ -465,7 +478,68 @@
     });
   });
 
+  test('_computeSortedThreads with timestamp', () => {
+    element.sortDropdownValue = __testOnly_SortDropdownState.TIMESTAMP;
+    element.resortThreads(element.threads);
+    assert.equal(element._sortedThreads.length, 9);
+    const expectedSortedRootIds = [
+      'rc2',
+      'rc1',
+      'patchset_level_2',
+      'patchset_level_1',
+      'zcf0b9fa_fe1a5f62',
+      'scaddf38_44770ec1',
+      '8caddf38_44770ec1',
+      '09a9fb0a_1484e6cf',
+      'ecf0b9fa_fe1a5f62',
+    ];
+    element._sortedThreads.forEach((thread, index) => {
+      assert.equal(thread.rootId, expectedSortedRootIds[index]);
+    });
+  });
+
+  test('tapping single author chips', () => {
+    const chips = queryAll(element, 'gr-account-label');
+    const authors = Array.from(chips).map(
+        chip => accountOrGroupKey(chip.account))
+        .sort();
+    assert.deepEqual(authors, [1000000, 1000001, 1000002, 1000003]);
+    assert.equal(element.threads.length, 9);
+    assert.equal(element._displayedThreads.length, 9);
+
+    tap(chips[0]); // accountId 1000001
+    flush();
+
+    assert.equal(element.threads.length, 9);
+    assert.equal(element._displayedThreads.length, 1);
+    assert.equal(element._displayedThreads[0].comments[0].author._account_id,
+        1000001);
+
+    tap(chips[0]); // tapping again resets
+    flush();
+    assert.equal(element.threads.length, 9);
+    assert.equal(element._displayedThreads.length, 9);
+  });
+
+  test('tapping multiple author chips', () => {
+    const chips = queryAll(element, 'gr-account-label');
+
+    tap(chips[0]); // accountId 1000001
+    tap(chips[1]); // accountId 1000002
+    flush();
+
+    assert.equal(element.threads.length, 9);
+    assert.equal(element._displayedThreads.length, 3);
+    assert.equal(element._displayedThreads[0].comments[0].author._account_id,
+        1000002);
+    assert.equal(element._displayedThreads[1].comments[0].author._account_id,
+        1000002);
+    assert.equal(element._displayedThreads[2].comments[0].author._account_id,
+        1000001);
+  });
+
   test('thread removal and sort again', () => {
+    element.sortDropdownValue = __testOnly_SortDropdownState.FILES;
     threadElements[1].dispatchEvent(
         new CustomEvent('thread-discard', {
           detail: {rootId: 'rc2'},
@@ -489,6 +563,7 @@
   });
 
   test('modification on thread shold not trigger sort again', () => {
+    element.sortDropdownValue = __testOnly_SortDropdownState.FILES;
     const currentSortedThreads = [...element._sortedThreads];
     for (const thread of currentSortedThreads) {
       thread.comments = [...thread.comments];
@@ -526,6 +601,7 @@
 
   test('non-equal length of sortThreads and threads' +
     ' should trigger sort again', () => {
+    element.sortDropdownValue = __testOnly_SortDropdownState.FILES;
     const modifiedThreads = [...element.threads];
     const currentSortedThreads = [...element._sortedThreads];
     element._sortedThreads = [];
@@ -549,22 +625,23 @@
     });
   });
 
-  test('toggle all shows all all comments', () => {
-    MockInteractions.tap(element.shadowRoot.querySelector(
-        '#allRadio'));
+  test('show all comments', () => {
+    element.handleCommentsDropdownValueChange({detail: {
+      value: CommentTabState.SHOW_ALL}});
     flush();
     assert.equal(getVisibleThreads().length, 9);
   });
 
-  test('toggle unresolved shows all unresolved comments', () => {
-    MockInteractions.tap(element.shadowRoot.querySelector(
-        '#unresolvedRadio'));
+  test('unresolved shows all unresolved comments', () => {
+    element.handleCommentsDropdownValueChange({detail: {
+      value: CommentTabState.UNRESOLVED}});
     flush();
     assert.equal(getVisibleThreads().length, 4);
   });
 
   test('toggle drafts only shows threads with draft comments', () => {
-    MockInteractions.tap(element.shadowRoot.querySelector('#draftsRadio'));
+    element.handleCommentsDropdownValueChange({detail: {
+      value: CommentTabState.DRAFTS}});
     flush();
     assert.equal(getVisibleThreads().length, 2);
   });
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-action.ts b/polygerrit-ui/app/elements/checks/gr-checks-action.ts
index 82680aa..f967ea5 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-action.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-action.ts
@@ -39,12 +39,23 @@
       css`
         :host {
           display: inline-block;
+          white-space: nowrap;
         }
         gr-button {
+          /* It is not fully understood why this is needed, but otherwise the
+             paper-tooltip may render under some iron-icons of the content
+             below. Maybe this has to do with a z-index:0 setting for
+             paper-button, such that a stacking context is created. And the high
+             z-index of the paper-tooltip will then only be interpreted within
+             that stacking context. */
+          z-index: 1;
           --padding: var(--spacing-s) var(--spacing-m);
         }
         gr-button paper-tooltip {
           text-transform: none;
+          text-align: center;
+          white-space: normal;
+          width: 200px;
         }
       `,
     ];
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-attempt.ts b/polygerrit-ui/app/elements/checks/gr-checks-attempt.ts
index 6dce9bc..098f5b4 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-attempt.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-attempt.ts
@@ -18,6 +18,7 @@
 import {css, customElement, property} from 'lit-element';
 import {GrLitElement} from '../lit/gr-lit-element';
 import {CheckRun} from '../../services/checks/checks-model';
+import {ordinal} from '../../utils/string-util';
 
 @customElement('gr-checks-attempt')
 class GrChecksAttempt extends GrLitElement {
@@ -65,7 +66,7 @@
     if (!this.run) return undefined;
     if (this.run.isSingleAttempt) return undefined;
     if (!this.run.attempt) return undefined;
-    const attempt = this.run.attempt;
+    const attempt = ordinal(this.run.attempt);
 
     return html`
       <span class="attempt">
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-results.ts b/polygerrit-ui/app/elements/checks/gr-checks-results.ts
index 7796a6e..fd8cd80 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-results.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-results.ts
@@ -52,7 +52,7 @@
   fireActionTriggered,
   firstPrimaryLink,
   hasCompletedWithoutResults,
-  iconForCategory,
+  iconFor,
   iconForLink,
   otherPrimaryLinks,
   primaryRunAction,
@@ -60,7 +60,7 @@
   tooltipForLink,
 } from '../../services/checks/checks-util';
 import {assertIsDefined, check} from '../../utils/common-util';
-import {toggleClass, whenVisible} from '../../utils/dom-util';
+import {modifierPressed, toggleClass, whenVisible} from '../../utils/dom-util';
 import {durationString} from '../../utils/date-util';
 import {charsOnly} from '../../utils/string-util';
 import {isAttemptSelected} from './gr-checks-util';
@@ -83,6 +83,9 @@
 
 @customElement('gr-result-row')
 class GrResultRow extends GrLitElement {
+  @query('td.nameCol div.name')
+  nameEl?: HTMLElement;
+
   @property()
   result?: RunResult;
 
@@ -119,12 +122,11 @@
         tr.container {
           border-top: 1px solid var(--border-color);
         }
+        a.link {
+          margin-right: var(--spacing-s);
+        }
         iron-icon.link {
           color: var(--link-color);
-          margin-right: var(--spacing-m);
-        }
-        td.iconCol {
-          padding-left: var(--spacing-l);
         }
         td.nameCol div.flex {
           display: flex;
@@ -133,6 +135,7 @@
           overflow: hidden;
           text-overflow: ellipsis;
           margin-right: var(--spacing-s);
+          outline-offset: var(--spacing-xs);
         }
         td.nameCol .space {
           flex-grow: 1;
@@ -140,6 +143,7 @@
         td.nameCol gr-checks-action {
           display: none;
         }
+        tr:focus-within td.nameCol gr-checks-action,
         tr:hover td.nameCol gr-checks-action {
           display: inline-block;
           /* The button should fit into the 20px line-height. The negative
@@ -153,6 +157,14 @@
           white-space: nowrap;
           padding: var(--spacing-s);
         }
+        td.expandedCol,
+        td.nameCol {
+          padding-left: var(--spacing-l);
+        }
+        td.expandedCol,
+        td.expanderCol {
+          padding-right: var(--spacing-l);
+        }
         td .summary-cell {
           display: flex;
         }
@@ -175,8 +187,13 @@
         tr.container:hover {
           background: var(--hover-background-color);
         }
+        tr.container:focus-within {
+          background: var(--selection-background-color);
+        }
         tr.container td .summary-cell .links,
         tr.container td .summary-cell .actions,
+        tr.container.collapsed:focus-within td .summary-cell .links,
+        tr.container.collapsed:focus-within td .summary-cell .actions,
         tr.container.collapsed:hover td .summary-cell .links,
         tr.container.collapsed:hover td .summary-cell .actions,
         :host(.dropdown-open) tr td .summary-cell .links,
@@ -191,10 +208,6 @@
         tr.container.collapsed td .summary-cell .actions {
           display: none;
         }
-        tr.container.collapsed:hover .summary-cell .hoverHide.tags,
-        tr.container.collapsed:hover .summary-cell .hoverHide.label {
-          display: none;
-        }
         tr.detailsRow.collapsed {
           display: none;
         }
@@ -277,6 +290,10 @@
     super.update(changedProperties);
   }
 
+  focus() {
+    if (this.nameEl) this.nameEl.focus();
+  }
+
   firstUpdated() {
     const loading = this.shadowRoot?.querySelector('.container');
     assertIsDefined(loading, '"Loading" element');
@@ -288,7 +305,6 @@
     if (!this.shouldRender) {
       return html`
         <tr class="container">
-          <td class="iconCol"></td>
           <td class="nameCol">
             <div><span class="loading">Loading...</span></div>
           </td>
@@ -299,14 +315,17 @@
     }
     return html`
       <tr class="${classMap({container: true, collapsed: !this.isExpanded})}">
-        <td class="iconCol" @click="${this.toggleExpanded}">
-          <div>${this.runningIcon()}</div>
-        </td>
-        <td class="nameCol" @click="${this.toggleExpanded}">
+        <td class="nameCol" @click="${this.toggleExpandedClick}">
           <div class="flex">
             <gr-hovercard-run .run="${this.result}"></gr-hovercard-run>
-            <div class="name">
-              ${this.result.checkName} ${this.renderStatus()}
+            <div
+              class="name"
+              role="button"
+              tabindex="0"
+              @click="${this.toggleExpandedClick}"
+              @keydown="${this.toggleExpandedPress}"
+            >
+              ${this.result.checkName}
             </div>
             <div class="space"></div>
             ${this.renderPrimaryRunAction()}
@@ -316,16 +335,17 @@
           <div class="summary-cell">
             ${this.renderLink(firstPrimaryLink(this.result))}
             ${this.renderSummary(this.result.summary)}
-            <div class="message" @click="${this.toggleExpanded}">
+            <div class="message" @click="${this.toggleExpandedClick}">
               ${this.isExpanded ? '' : this.result.message}
             </div>
-            <div class="tags ${this.hasLinksOrActions() ? 'hoverHide' : ''}">
+            ${this.renderLinks()} ${this.renderActions()}
+            <div class="tags">
               ${(this.result.tags ?? []).map(t => this.renderTag(t))}
             </div>
-            ${this.renderLabel()} ${this.renderLinks()} ${this.renderActions()}
+            ${this.renderLabel()}
           </div>
         </td>
-        <td class="expanderCol" @click="${this.toggleExpanded}">
+        <td class="expanderCol" @click="${this.toggleExpandedClick}">
           <div
             class="show-hide"
             role="switch"
@@ -335,7 +355,7 @@
             aria-label="${this.isExpanded
               ? 'Collapse result row'
               : 'Expand result row'}"
-            @keydown="${this.toggleExpanded}"
+            @keydown="${this.toggleExpandedPress}"
           >
             <iron-icon
               icon="${this.isExpanded
@@ -346,19 +366,11 @@
         </td>
       </tr>
       <tr class="${classMap({detailsRow: true, collapsed: !this.isExpanded})}">
-        <td></td>
-        <td colspan="3">${this.renderExpanded()}</td>
+        <td class="expandedCol" colspan="3">${this.renderExpanded()}</td>
       </tr>
     `;
   }
 
-  private hasLinksOrActions() {
-    const linkCount = this.result?.links?.length ?? 0;
-    const actionCount = this.result?.actions?.length ?? 0;
-    // The primary link is rendered somewhere else, so it does not count here.
-    return linkCount > 1 || actionCount > 0;
-  }
-
   private renderPrimaryRunAction() {
     if (!this.result) return;
     const action = primaryRunAction(this.result);
@@ -373,6 +385,23 @@
     ></gr-result-expanded>`;
   }
 
+  private toggleExpandedClick(e: MouseEvent) {
+    if (!this.isExpandable) return;
+    e.preventDefault();
+    e.stopPropagation();
+    this.toggleExpanded();
+  }
+
+  private toggleExpandedPress(e: KeyboardEvent) {
+    if (!this.isExpandable) return;
+    if (modifierPressed(e)) return;
+    // Only react to `return` and `space`.
+    if (e.keyCode !== 13 && e.keyCode !== 32) return;
+    e.preventDefault();
+    e.stopPropagation();
+    this.toggleExpanded();
+  }
+
   private toggleExpanded() {
     if (!this.isExpandable) return;
     this.isExpanded = !this.isExpanded;
@@ -387,16 +416,6 @@
     `;
   }
 
-  private renderStatus() {
-    if (this.result?.status !== RunStatus.RUNNING) return;
-    return html`<span>(Running)</span>`;
-  }
-
-  private runningIcon() {
-    if (this.result?.status !== RunStatus.RUNNING) return;
-    return html`<iron-icon icon="gr-icons:timelapse"></iron-icon>`;
-  }
-
   renderLabel() {
     const category = this.result?.category;
     if (category !== Category.ERROR && category !== Category.WARNING) return;
@@ -405,10 +424,18 @@
     if (!this.result?.isLatestAttempt) return;
     const info = this.labels?.[label];
     const status = getLabelStatus(info).toLowerCase();
-    const value = valueString(getRepresentativeValue(info));
-    const hover = this.hasLinksOrActions() ? 'hoverHide' : '';
+    const value = getRepresentativeValue(info);
+    // A neutral vote is not interesting for the user to see and is just
+    // cluttering the UI.
+    if (value === 0) return;
+    const valueStr = valueString(value);
     return html`
-      <div class="label ${status} ${hover}">${label} ${value}</div>
+      <div class="label ${status}">
+        <span>${label} ${valueStr}</span>
+        <paper-tooltip offset="5" fit-to-visible-bounds="true">
+          The check result has (probably) influenced this label vote.
+        </paper-tooltip>
+      </div>
     `;
   }
 
@@ -432,7 +459,7 @@
     if (this.isExpanded) return;
     if (!link) return;
     const tooltipText = link.tooltip ?? tooltipForLink(link.icon);
-    return html`<a href="${link.url}" target="_blank"
+    return html`<a href="${link.url}" class="link" target="_blank"
       ><iron-icon
         aria-label="external link to details"
         class="link"
@@ -500,7 +527,12 @@
   }
 
   renderTag(tag: Tag) {
-    return html`<div class="tag ${tag.color}">${tag.name}</div>`;
+    return html`<div class="tag ${tag.color}">
+      <span>${tag.name}</span>
+      <paper-tooltip offset="5" fit-to-visible-bounds="true">
+        A category tag for this check result
+      </paper-tooltip>
+    </div>`;
   }
 }
 
@@ -520,7 +552,6 @@
       css`
         .links {
           white-space: normal;
-          padding: var(--spacing-s) 0;
         }
         .links a {
           display: inline-block;
@@ -530,7 +561,7 @@
           margin-right: var(--spacing-xs);
         }
         .message {
-          padding: var(--spacing-m) var(--spacing-m) var(--spacing-m) 0;
+          padding: var(--spacing-m) 0;
         }
       `,
     ];
@@ -627,12 +658,6 @@
   }
 }
 
-const SHOW_ALL_THRESHOLDS: Map<Category, number> = new Map();
-SHOW_ALL_THRESHOLDS.set(Category.ERROR, 20);
-SHOW_ALL_THRESHOLDS.set(Category.WARNING, 10);
-SHOW_ALL_THRESHOLDS.set(Category.INFO, 5);
-SHOW_ALL_THRESHOLDS.set(Category.SUCCESS, 5);
-
 const CATEGORY_TOOLTIPS: Map<Category, string> = new Map();
 CATEGORY_TOOLTIPS.set(Category.ERROR, 'Must be fixed and is blocking submit');
 CATEGORY_TOOLTIPS.set(
@@ -884,17 +909,16 @@
           font-weight: var(--font-weight-bold);
           padding: var(--spacing-s);
         }
-        th.iconCol {
-          width: 40px;
-        }
-        th.nameCol {
+        tr.headerRow th.nameCol {
           width: 200px;
+          padding-left: var(--spacing-l);
         }
-        th.summaryCol {
+        tr.headerRow th.summaryCol {
           width: 99%;
         }
-        th.expanderCol {
+        tr.headerRow th.expanderCol {
           width: 30px;
+          padding-right: var(--spacing-l);
         }
 
         gr-button.showAll {
@@ -918,7 +942,7 @@
       ) {
         let cat = statusOrCategory.toString().toLowerCase();
         if (statusOrCategory === RunStatus.COMPLETED) cat = 'success';
-        this.scrollElIntoView(`.categoryHeader .${cat}`);
+        this.scrollElIntoView(`.categoryHeader.${cat} + table gr-result-row`);
       } else if (checkName) {
         this.scrollElIntoView(`gr-result-row.${charsOnly(checkName)}`);
       }
@@ -931,6 +955,7 @@
       // el might be a <gr-result-row> with an empty shadowRoot. Let's wait a
       // moment before trying to find a child element in it.
       setTimeout(() => {
+        if (el) (el as HTMLElement).focus();
         // <gr-result-row> has display:contents and cannot be scrolled into view
         // itself. Thus we are preferring to scroll the first child into view.
         el = el?.shadowRoot?.firstElementChild ?? el;
@@ -1156,6 +1181,9 @@
 
   renderSection(category: Category) {
     const catString = category.toString().toLowerCase();
+    const isScrollTarget = category === this.tabState?.statusOrCategory;
+    const isWarningOrError =
+      category === Category.WARNING || category === Category.ERROR;
     const allRuns = this.runs.filter(run =>
       isAttemptSelected(this.selectedAttempts, run)
     );
@@ -1166,6 +1194,7 @@
       ],
       []
     );
+    const isSelection = this.selectedRuns.length > 0;
     const selected = all.filter(result => this.isRunSelected(result));
     const filtered = selected.filter(
       result =>
@@ -1176,19 +1205,20 @@
     let expanded = this.isSectionExpanded.get(category);
     const expandedByUser = this.isSectionExpandedByUser.get(category) ?? false;
     if (!expandedByUser || expanded === undefined) {
-      expanded = selected.length > 0;
+      expanded =
+        selected.length > 0 &&
+        (isWarningOrError || isSelection || isScrollTarget);
       this.isSectionExpanded.set(category, expanded);
     }
     const expandedClass = expanded ? 'expanded' : 'collapsed';
     const icon = expanded ? 'gr-icons:expand-less' : 'gr-icons:expand-more';
     const isShowAll = this.isShowAll.get(category) ?? false;
-    const showAllThreshold = SHOW_ALL_THRESHOLDS.get(category) ?? 5;
     const resultCount = filtered.length;
-    const resultLimit = isShowAll ? 1000 : showAllThreshold;
+    const resultLimit = isShowAll ? 1000 : 20;
     const showAllButton = this.renderShowAllButton(
       category,
       isShowAll,
-      showAllThreshold,
+      resultLimit,
       resultCount
     );
     return html`
@@ -1200,17 +1230,15 @@
           <iron-icon class="expandIcon" icon="${icon}"></iron-icon>
           <div class="statusIconWrapper">
             <iron-icon
-              icon="gr-icons:${iconForCategory(category)}"
+              icon="gr-icons:${iconFor(category)}"
               class="statusIcon ${catString}"
             ></iron-icon>
             <span class="title">${catString}</span>
+            <span class="count">${this.renderCount(all, filtered)}</span>
             <paper-tooltip offset="5"
               >${CATEGORY_TOOLTIPS.get(category)}</paper-tooltip
             >
           </div>
-          <span class="count"
-            >${this.renderCount(all, selected, filtered)}</span
-          >
         </h3>
         ${this.renderResults(
           all,
@@ -1234,7 +1262,7 @@
     const handler = () => this.toggleShowAll(category);
     return html`
       <tr class="showAllRow">
-        <td colspan="4">
+        <td colspan="3">
           <gr-button class="showAll" link @click="${handler}"
             >${message}</gr-button
           >
@@ -1274,7 +1302,6 @@
       <table class="resultsTable">
         <thead>
           <tr class="headerRow">
-            <th class="iconCol"></th>
             <th class="nameCol">Run</th>
             <th class="summaryCol">Summary</th>
             <th class="expanderCol"></th>
@@ -1297,13 +1324,10 @@
     `;
   }
 
-  renderCount(all: RunResult[], selected: RunResult[], filtered: RunResult[]) {
+  renderCount(all: RunResult[], filtered: RunResult[]) {
     if (all.length === filtered.length) {
       return html`(${all.length})`;
     }
-    if (all.length !== selected.length) {
-      return html`<span class="filtered"> - filtered</span>`;
-    }
     return html`(${filtered.length} of ${all.length})`;
   }
 
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-runs.ts b/polygerrit-ui/app/elements/checks/gr-checks-runs.ts
index 093eecb..0cf47cd 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-runs.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-runs.ts
@@ -33,7 +33,7 @@
   AttemptDetail,
   compareByWorstCategory,
   fireActionTriggered,
-  iconForCategory,
+  iconFor,
   iconForRun,
   PRIMARY_STATUS_ACTIONS,
   primaryRunAction,
@@ -161,6 +161,10 @@
           top: 3px;
           margin-right: var(--spacing-s);
         }
+        .statusLinkIcon {
+          color: var(--link-color);
+          margin-left: var(--spacing-s);
+        }
       `,
     ];
   }
@@ -221,13 +225,14 @@
 
     return html`
       <div @click="${this.handleChipClick}" class="${classMap(classes)}">
-        <gr-hovercard-run .run="${this.run}"></gr-hovercard-run>
         <div class="left">
+          <gr-hovercard-run .run="${this.run}"></gr-hovercard-run>
           ${this.renderFilterIcon()}
           <iron-icon class="${icon}" icon="gr-icons:${icon}"></iron-icon>
           ${this.renderAdditionalIcon()}
           <span class="name">${this.run.checkName}</span>
           <gr-checks-attempt .run="${this.run}"></gr-checks-attempt>
+          ${this.renderStatusLink()}
         </div>
         <div class="right">
           ${action
@@ -274,6 +279,29 @@
     }
   }
 
+  renderStatusLink() {
+    const link = this.run.statusLink;
+    if (!link) return;
+    // For COMPLETED we think that the status link are too much clutter.
+    // That could be re-considered.
+    if (this.run.status !== RunStatus.RUNNING) return;
+    return html`
+      <a href="${link}" target="_blank" @click="${this.onLinkClick}"
+        ><iron-icon
+          class="statusLinkIcon"
+          icon="gr-icons:launch"
+          aria-label="external link to run status details"
+        ></iron-icon>
+        <paper-tooltip offset="5">Link to run status details</paper-tooltip>
+      </a>
+    `;
+  }
+
+  private onLinkClick(e: MouseEvent) {
+    // Prevents handleChipClick() from reacting to <a> link clicks.
+    e.stopPropagation();
+  }
+
   renderFilterIcon() {
     if (!this.selected) return;
     return html`
@@ -289,7 +317,7 @@
     if (this.run.status !== RunStatus.RUNNING) return nothing;
     const category = worstCategory(this.run);
     if (!category) return nothing;
-    const icon = iconForCategory(category);
+    const icon = iconFor(category);
     return html`
       <iron-icon class="${icon}" icon="gr-icons:${icon}"></iron-icon>
     `;
diff --git a/polygerrit-ui/app/elements/checks/gr-hovercard-run.ts b/polygerrit-ui/app/elements/checks/gr-hovercard-run.ts
index 10f036e..9d50b1c 100644
--- a/polygerrit-ui/app/elements/checks/gr-hovercard-run.ts
+++ b/polygerrit-ui/app/elements/checks/gr-hovercard-run.ts
@@ -22,13 +22,13 @@
 import './gr-checks-action';
 import {CheckRun} from '../../services/checks/checks-model';
 import {
-  iconForCategory,
-  iconForStatus,
+  iconFor,
   runActions,
   worstCategory,
 } from '../../services/checks/checks-util';
 import {durationString, fromNow} from '../../utils/date-util';
 import {RunStatus} from '../../api/checks';
+import {ordinal} from '../../utils/string-util';
 
 @customElement('gr-hovercard-run')
 export class GrHovercardRun extends hovercardBehaviorMixin(PolymerElement) {
@@ -42,9 +42,9 @@
   computeIcon(run?: CheckRun) {
     if (!run) return '';
     const category = worstCategory(run);
-    if (category) return iconForCategory(category);
+    if (category) return iconFor(category);
     return run.status === RunStatus.COMPLETED
-      ? iconForStatus(RunStatus.COMPLETED)
+      ? iconFor(RunStatus.COMPLETED)
       : '';
   }
 
@@ -52,6 +52,10 @@
     return runActions(run);
   }
 
+  computeAttempt(attempt?: number) {
+    return ordinal(attempt);
+  }
+
   computeChipIcon(run?: CheckRun) {
     if (run?.status === RunStatus.COMPLETED) return 'check';
     if (run?.status === RunStatus.RUNNING) return 'timelapse';
diff --git a/polygerrit-ui/app/elements/checks/gr-hovercard-run_html.ts b/polygerrit-ui/app/elements/checks/gr-hovercard-run_html.ts
index c4402f5..fe65d9e 100644
--- a/polygerrit-ui/app/elements/checks/gr-hovercard-run_html.ts
+++ b/polygerrit-ui/app/elements/checks/gr-hovercard-run_html.ts
@@ -83,10 +83,16 @@
       position: relative;
       top: 2px;
     }
+    div.sectionContent .attemptIcon iron-icon {
+      margin-right: 0;
+    }
+    .attemptIcon,
     .attemptNumber {
       margin-right: var(--spacing-s);
       color: var(--deemphasized-text-color);
       text-align: center;
+      width: 20px;
+      font-size: var(--font-size-small);
     }
     div.action {
       border-top: 1px solid var(--border-color);
@@ -155,7 +161,7 @@
                   icon="gr-icons:[[item.icon]]"
                 ></iron-icon>
               </div>
-              <div class="attemptNumber">[[item.attempt]]</div>
+              <div class="attemptNumber">[[computeAttempt(item.attempt)]]</div>
             </div>
           </template>
         </div>
diff --git a/polygerrit-ui/app/elements/checks/gr-hovercard-run_test.js b/polygerrit-ui/app/elements/checks/gr-hovercard-run_test.ts
similarity index 67%
rename from polygerrit-ui/app/elements/checks/gr-hovercard-run_test.js
rename to polygerrit-ui/app/elements/checks/gr-hovercard-run_test.ts
index 9c77654..67781f5 100644
--- a/polygerrit-ui/app/elements/checks/gr-hovercard-run_test.js
+++ b/polygerrit-ui/app/elements/checks/gr-hovercard-run_test.ts
@@ -15,27 +15,28 @@
  * limitations under the License.
  */
 
-import '../../test/common-test-setup-karma.js';
-import './gr-hovercard-run.js';
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+import '../../test/common-test-setup-karma';
+import './gr-hovercard-run';
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+import {GrHovercardRun} from './gr-hovercard-run';
 
 const basicFixture = fixtureFromTemplate(html`
-<gr-hovercard-run class="hovered"></gr-hovercard-run>
+  <gr-hovercard-run class="hovered"></gr-hovercard-run>
 `);
 
 suite('gr-hovercard-run tests', () => {
-  let element;
+  let element: GrHovercardRun;
 
   setup(async () => {
-    element = basicFixture.instantiate();
+    element = basicFixture.instantiate() as GrHovercardRun;
     await flush();
   });
 
   teardown(() => {
-    element.hide({});
+    element.hide();
   });
 
   test('hovercard is shown', () => {
+    assert.equal(element.computeIcon(), '');
   });
 });
-
diff --git a/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog_test.ts b/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog_test.ts
index fa57a23..2dec8d2 100644
--- a/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog_test.ts
+++ b/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog_test.ts
@@ -17,7 +17,7 @@
 
 import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
 import '../../../test/common-test-setup-karma';
-import {queryAndAssert} from '../../../test/test-utils';
+import {mockPromise, queryAndAssert} from '../../../test/test-utils';
 import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
 import {GrErrorDialog} from './gr-error-dialog';
 
@@ -30,10 +30,12 @@
     element = basicFixture.instantiate();
   });
 
-  test('dismiss tap fires event', done => {
-    element.addEventListener('dismiss', () => done());
+  test('dismiss tap fires event', async () => {
+    const dismissCalled = mockPromise();
+    element.addEventListener('dismiss', () => dismissCalled.resolve());
     MockInteractions.tap(
       (queryAndAssert(element, '#dialog') as GrDialog).$.confirm
     );
+    await dismissCalled;
   });
 });
diff --git a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.ts b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.ts
index 8f9dfe2..8352319 100644
--- a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.ts
+++ b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.ts
@@ -75,6 +75,37 @@
   };
 }
 
+export function constructServerErrorMsg({
+  errorText,
+  status,
+  statusText,
+  url,
+  trace,
+  tip,
+}: ErrorMsg) {
+  let err = '';
+  if (tip) {
+    err += `${tip}\n\n`;
+  }
+  err += `Error ${status}`;
+  if (statusText) {
+    err += ` (${statusText})`;
+  }
+  if (errorText || url) {
+    err += ': ';
+  }
+  if (errorText) {
+    err += errorText;
+  }
+  if (url) {
+    err += `\nEndpoint: ${url}`;
+  }
+  if (trace) {
+    err += `\nTrace Id: ${trace}`;
+  }
+  return err;
+}
+
 @customElement('gr-error-manager')
 export class GrErrorManager extends PolymerElement {
   static get template() {
@@ -221,7 +252,7 @@
           this._showQuotaExceeded({status, statusText});
         } else {
           this._showErrorDialog(
-            this._constructServerErrorMsg({
+            constructServerErrorMsg({
               status,
               statusText,
               errorText,
@@ -247,7 +278,7 @@
         ? 'You might have not enough privileges.'
         : 'You might have not enough privileges. Sign in and try again.';
       this._showErrorDialog(
-        this._constructServerErrorMsg({
+        constructServerErrorMsg({
           status,
           statusText,
           errorText,
@@ -266,7 +297,7 @@
     const tip = 'Try again later';
     const errorText = 'Too many requests from this client';
     this._showErrorDialog(
-      this._constructServerErrorMsg({
+      constructServerErrorMsg({
         status,
         statusText,
         errorText,
@@ -275,37 +306,6 @@
     );
   }
 
-  _constructServerErrorMsg({
-    errorText,
-    status,
-    statusText,
-    url,
-    trace,
-    tip,
-  }: ErrorMsg) {
-    let err = '';
-    if (tip) {
-      err += `${tip}\n\n`;
-    }
-    err += `Error ${status}`;
-    if (statusText) {
-      err += ` (${statusText})`;
-    }
-    if (errorText || url) {
-      err += ': ';
-    }
-    if (errorText) {
-      err += errorText;
-    }
-    if (url) {
-      err += `\nEndpoint: ${url}`;
-    }
-    if (trace) {
-      err += `\nTrace Id: ${trace}`;
-    }
-    return err;
-  }
-
   private readonly handleShowAlert = (e: CustomEvent<ShowAlertEventDetail>) => {
     this._showAlert(
       e.detail.message,
diff --git a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.js b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.js
deleted file mode 100644
index fe4d9da..0000000
--- a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.js
+++ /dev/null
@@ -1,575 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../../../test/common-test-setup-karma.js';
-import './gr-error-manager.js';
-import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit.js';
-import {__testOnly_ErrorType} from './gr-error-manager.js';
-import {stubRestApi} from '../../../test/test-utils.js';
-import {appContext} from '../../../services/app-context.js';
-import {createPreferences} from '../../../test/test-data-generators.js';
-
-const basicFixture = fixtureFromElement('gr-error-manager');
-
-_testOnly_initGerritPluginApi();
-
-suite('gr-error-manager tests', () => {
-  let element;
-
-  suite('when authed', () => {
-    let toastSpy;
-    let openOverlaySpy;
-    let fetchStub;
-    let getLoggedInStub;
-
-    setup(() => {
-      fetchStub = sinon.stub(window, 'fetch')
-          .returns(Promise.resolve({ok: true, status: 204}));
-      getLoggedInStub = stubRestApi('getLoggedIn')
-          .callsFake(() => appContext.authService.authCheck());
-      stubRestApi('getPreferences').returns(Promise.resolve(
-          createPreferences()));
-      element = basicFixture.instantiate();
-      element._authService.clearCache();
-      toastSpy = sinon.spy(element, '_createToastAlert');
-      openOverlaySpy = sinon.spy(element.$.noInteractionOverlay, 'open');
-    });
-
-    teardown(() => {
-      toastSpy.getCalls().forEach(call => {
-        call.returnValue.remove();
-      });
-    });
-
-    test('does not show auth error on 403 by default', done => {
-      const showAuthErrorStub = sinon.stub(element, '_showAuthErrorAlert');
-      const responseText = Promise.resolve('server says no.');
-      element.dispatchEvent(
-          new CustomEvent('server-error', {
-            detail:
-          {response: {status: 403, text() { return responseText; }}},
-            composed: true, bubbles: true,
-          }));
-      flush(() => {
-        assert.isFalse(showAuthErrorStub.calledOnce);
-        done();
-      });
-    });
-
-    test('show auth required for 403 with auth error and not authed before',
-        done => {
-          const showAuthErrorStub = sinon.stub(
-              element, '_showAuthErrorAlert'
-          );
-          const responseText = Promise.resolve('Authentication required\n');
-          getLoggedInStub.returns(Promise.resolve(true));
-          element.dispatchEvent(
-              new CustomEvent('server-error', {
-                detail:
-              {response: {status: 403, text() { return responseText; }}},
-                composed: true, bubbles: true,
-              }));
-          flush(() => {
-            assert.isTrue(showAuthErrorStub.calledOnce);
-            done();
-          });
-        });
-
-    test('recheck auth for 403 with auth error if authed before', async () => {
-      // Set status to AUTHED.
-      appContext.authService.authCheck();
-      const responseText = Promise.resolve('Authentication required\n');
-      getLoggedInStub.returns(Promise.resolve(true));
-      element.dispatchEvent(
-          new CustomEvent('server-error', {
-            detail:
-          {response: {status: 403, text() { return responseText; }}},
-            composed: true, bubbles: true,
-          }));
-      await flush();
-      assert.isTrue(getLoggedInStub.calledOnce);
-    });
-
-    test('show logged in error', () => {
-      const spy = sinon.spy(element, '_showAuthErrorAlert');
-      element.dispatchEvent(
-          new CustomEvent('show-auth-required', {
-            composed: true, bubbles: true,
-          }));
-      assert.isTrue(spy.calledWithExactly(
-          'Log in is required to perform that action.', 'Log in.'));
-    });
-
-    test('show normal Error', done => {
-      const showErrorSpy = sinon.spy(element, '_showErrorDialog');
-      const textSpy = sinon.spy(() => Promise.resolve('ZOMG'));
-      element.dispatchEvent(
-          new CustomEvent('server-error', {
-            detail: {response: {status: 500, text: textSpy}},
-            composed: true, bubbles: true,
-          }));
-
-      assert.isTrue(textSpy.called);
-      flush(() => {
-        assert.isTrue(showErrorSpy.calledOnce);
-        assert.isTrue(showErrorSpy.lastCall.calledWithExactly(
-            'Error 500: ZOMG'));
-        done();
-      });
-    });
-
-    test('_constructServerErrorMsg', () => {
-      const errorText = 'change conflicts';
-      const status = 409;
-      const statusText = 'Conflict';
-      const url = '/my/test/url';
-
-      assert.equal(element._constructServerErrorMsg({status}),
-          'Error 409');
-      assert.equal(element._constructServerErrorMsg({status, url}),
-          'Error 409: \nEndpoint: /my/test/url');
-      assert.equal(element.
-          _constructServerErrorMsg({status, statusText, url}),
-      'Error 409 (Conflict): \nEndpoint: /my/test/url');
-      assert.equal(element._constructServerErrorMsg({
-        status,
-        statusText,
-        errorText,
-        url,
-      }), 'Error 409 (Conflict): change conflicts' +
-      '\nEndpoint: /my/test/url');
-      assert.equal(element._constructServerErrorMsg({
-        status,
-        statusText,
-        errorText,
-        url,
-        trace: 'xxxxx',
-      }), 'Error 409 (Conflict): change conflicts' +
-      '\nEndpoint: /my/test/url\nTrace Id: xxxxx');
-    });
-
-    test('extract trace id from headers if exists', done => {
-      const textSpy = sinon.spy(
-          () => Promise.resolve('500')
-      );
-      const headers = new Headers();
-      headers.set('X-Gerrit-Trace', 'xxxx');
-      element.dispatchEvent(
-          new CustomEvent('server-error', {
-            detail: {
-              response: {
-                headers,
-                status: 500,
-                text: textSpy,
-              },
-            },
-            composed: true, bubbles: true,
-          }));
-      flush(() => {
-        assert.equal(
-            element.$.errorDialog.text,
-            'Error 500: 500\nTrace Id: xxxx'
-        );
-        done();
-      });
-    });
-
-    test('suppress TOO_MANY_FILES error', done => {
-      const showAlertStub = sinon.stub(element, '_showAlert');
-      const textSpy = sinon.spy(
-          () => Promise.resolve('too many files to find conflicts')
-      );
-      element.dispatchEvent(
-          new CustomEvent('server-error', {
-            detail: {response: {status: 500, text: textSpy}},
-            composed: true, bubbles: true,
-          }));
-
-      assert.isTrue(textSpy.called);
-      flush(() => {
-        assert.isFalse(showAlertStub.called);
-        done();
-      });
-    });
-
-    test('show network error', done => {
-      const showAlertStub = sinon.stub(element, '_showAlert');
-      element.dispatchEvent(
-          new CustomEvent('network-error', {
-            detail: {error: new Error('ZOMG')},
-            composed: true, bubbles: true,
-          }));
-      flush(() => {
-        assert.isTrue(showAlertStub.calledOnce);
-        assert.isTrue(showAlertStub.lastCall.calledWithExactly(
-            'Server unavailable'));
-        done();
-      });
-    });
-
-    test('_canOverride alerts', () => {
-      assert.isFalse(element._canOverride(undefined,
-          __testOnly_ErrorType.AUTH));
-      assert.isFalse(element._canOverride(undefined,
-          __testOnly_ErrorType.NETWORK));
-      assert.isTrue(element._canOverride(undefined,
-          __testOnly_ErrorType.GENERIC));
-      assert.isTrue(element._canOverride(undefined, undefined));
-
-      assert.isTrue(element._canOverride(__testOnly_ErrorType.NETWORK,
-          undefined));
-      assert.isTrue(element._canOverride(__testOnly_ErrorType.AUTH,
-          undefined));
-      assert.isFalse(element._canOverride(__testOnly_ErrorType.NETWORK,
-          __testOnly_ErrorType.AUTH));
-
-      assert.isTrue(element._canOverride(__testOnly_ErrorType.AUTH,
-          __testOnly_ErrorType.NETWORK));
-    });
-
-    test('show auth refresh toast', async () => {
-      // Set status to AUTHED.
-      appContext.authService.authCheck();
-      const refreshStub = stubRestApi(
-          'getAccount').callsFake(
-          () => Promise.resolve({}));
-      const windowOpen = sinon.stub(window, 'open');
-      const responseText = Promise.resolve('Authentication required\n');
-      // fake failed auth
-      fetchStub.returns(Promise.resolve({status: 403}));
-      element.dispatchEvent(
-          new CustomEvent('server-error', {
-            detail:
-          {response: {status: 403, text() { return responseText; }}},
-            composed: true, bubbles: true,
-          }));
-      assert.equal(fetchStub.callCount, 1);
-      await flush();
-
-      // here needs two flush as there are two chanined
-      // promises on server-error handler and flush only flushes one
-      assert.equal(fetchStub.callCount, 2);
-      await flush();
-      // Sometime overlay opens with delay, waiting while open is complete
-      await openOverlaySpy.lastCall.returnValue;
-      // auth-error fired
-      assert.isTrue(toastSpy.called);
-
-      // toast
-      let toast = toastSpy.lastCall.returnValue;
-      assert.isOk(toast);
-      assert.include(
-          toast.root.textContent, 'Credentials expired.');
-      assert.include(
-          toast.root.textContent, 'Refresh credentials');
-
-      // noInteractionOverlay
-      const noInteractionOverlay = element.$.noInteractionOverlay;
-      assert.isOk(noInteractionOverlay);
-      sinon.spy(noInteractionOverlay, 'close');
-      assert.equal(
-          noInteractionOverlay.backdropElement.getAttribute('opened'),
-          '');
-      assert.isFalse(windowOpen.called);
-      MockInteractions.tap(toast.shadowRoot
-          .querySelector('gr-button.action'));
-      assert.isTrue(windowOpen.called);
-
-      // @see Issue 5822: noopener breaks closeAfterLogin
-      assert.equal(windowOpen.lastCall.args[2].indexOf('noopener=yes'),
-          -1);
-
-      const hideToastSpy = sinon.spy(toast, 'hide');
-
-      // now fake authed
-      fetchStub.returns(Promise.resolve({status: 204}));
-      element.handleWindowFocus();
-      element.checkLoggedInTask.flush();
-      await flush();
-      assert.isTrue(refreshStub.called);
-      assert.isTrue(hideToastSpy.called);
-
-      // toast update
-      assert.notStrictEqual(toastSpy.lastCall.returnValue, toast);
-      toast = toastSpy.lastCall.returnValue;
-      assert.isOk(toast);
-      assert.include(
-          toast.root.textContent, 'Credentials refreshed');
-
-      // close overlay
-      assert.isTrue(noInteractionOverlay.close.called);
-    });
-
-    test('auth toast should dismiss existing toast', async () => {
-      // Set status to AUTHED.
-      appContext.authService.authCheck();
-      const responseText = Promise.resolve('Authentication required\n');
-
-      // fake an alert
-      element.dispatchEvent(
-          new CustomEvent('show-alert', {
-            detail: {message: 'test reload', action: 'reload'},
-            composed: true, bubbles: true,
-          }));
-      let toast = toastSpy.lastCall.returnValue;
-      assert.isOk(toast);
-      assert.include(
-          toast.root.textContent, 'test reload');
-
-      // fake auth
-      fetchStub.returns(Promise.resolve({status: 403}));
-      element.dispatchEvent(
-          new CustomEvent('server-error', {
-            detail:
-          {response: {status: 403, text() { return responseText; }}},
-            composed: true, bubbles: true,
-          }));
-      assert.equal(fetchStub.callCount, 1);
-      await flush();
-      // here needs two flush as there are two chained
-      // promises on server-error handler and flush only flushes one
-      assert.equal(fetchStub.callCount, 2);
-      await flush();
-      // Sometime overlay opens with delay, waiting while open is complete
-      await openOverlaySpy.lastCall.returnValue;
-      // toast
-      toast = toastSpy.lastCall.returnValue;
-      assert.include(
-          toast.root.textContent, 'Credentials expired.');
-      assert.include(
-          toast.root.textContent, 'Refresh credentials');
-    });
-
-    test('regular toast should dismiss regular toast', () => {
-      // Set status to AUTHED.
-      appContext.authService.authCheck();
-
-      // fake an alert
-      element.dispatchEvent(
-          new CustomEvent('show-alert', {
-            detail: {message: 'test reload', action: 'reload'},
-            composed: true, bubbles: true,
-          }));
-      let toast = toastSpy.lastCall.returnValue;
-      assert.isOk(toast);
-      assert.include(
-          toast.root.textContent, 'test reload');
-
-      // new alert
-      element.dispatchEvent(
-          new CustomEvent('show-alert', {
-            detail: {message: 'second-test', action: 'reload'},
-            composed: true, bubbles: true,
-          }));
-
-      toast = toastSpy.lastCall.returnValue;
-      assert.include(toast.root.textContent, 'second-test');
-    });
-
-    test('regular toast should not dismiss auth toast', done => {
-      // Set status to AUTHED.
-      appContext.authService.authCheck();
-      const responseText = Promise.resolve('Authentication required\n');
-
-      // fake auth
-      fetchStub.returns(Promise.resolve({status: 403}));
-      element.dispatchEvent(
-          new CustomEvent('server-error', {
-            detail:
-          {response: {status: 403, text() { return responseText; }}},
-            composed: true, bubbles: true,
-          }));
-      assert.equal(fetchStub.callCount, 1);
-      flush(() => {
-        // here needs two flush as there are two chained
-        // promises on server-error handler and flush only flushes one
-        assert.equal(fetchStub.callCount, 2);
-        flush(() => {
-          let toast = toastSpy.lastCall.returnValue;
-          assert.include(
-              toast.root.textContent, 'Credentials expired.');
-          assert.include(
-              toast.root.textContent, 'Refresh credentials');
-
-          // fake an alert
-          element.dispatchEvent(
-              new CustomEvent('show-alert', {
-                detail: {
-                  message: 'test-alert', action: 'reload',
-                },
-                composed: true, bubbles: true,
-              }));
-          flush(() => {
-            toast = toastSpy.lastCall.returnValue;
-            assert.isOk(toast);
-            assert.include(
-                toast.root.textContent, 'Credentials expired.');
-            done();
-          });
-        });
-      });
-    });
-
-    test('show alert', () => {
-      const alertObj = {message: 'foo'};
-      sinon.stub(element, '_showAlert');
-      element.dispatchEvent(
-          new CustomEvent('show-alert', {
-            detail: alertObj,
-            composed: true, bubbles: true,
-          }));
-      assert.isTrue(element._showAlert.calledOnce);
-      assert.equal(element._showAlert.lastCall.args[0], 'foo');
-      assert.isNotOk(element._showAlert.lastCall.args[1]);
-      assert.isNotOk(element._showAlert.lastCall.args[2]);
-    });
-
-    test('checks stale credentials on visibility change', () => {
-      const refreshStub = sinon.stub(element,
-          '_checkSignedIn');
-      sinon.stub(Date, 'now').returns(999999);
-      element._lastCredentialCheck = 0;
-      element.handleVisibilityChange();
-
-      // Since there is no known account, it should not test credentials.
-      assert.isFalse(refreshStub.called);
-      assert.equal(element._lastCredentialCheck, 0);
-
-      element.knownAccountId = 123;
-      element.handleVisibilityChange();
-
-      // Should test credentials, since there is a known account.
-      assert.isTrue(refreshStub.called);
-      assert.equal(element._lastCredentialCheck, 999999);
-    });
-
-    test('refreshes with same credentials', done => {
-      const accountPromise = Promise.resolve({_account_id: 1234});
-      stubRestApi('getAccount')
-          .returns(accountPromise);
-      const requestCheckStub = sinon.stub(element, '_requestCheckLoggedIn');
-      const handleRefreshStub = sinon.stub(element,
-          'handleCredentialRefreshed');
-      const reloadStub = sinon.stub(element, '_reloadPage');
-
-      element.knownAccountId = 1234;
-      element._refreshingCredentials = true;
-      element._checkSignedIn();
-
-      flush(() => {
-        assert.isFalse(requestCheckStub.called);
-        assert.isTrue(handleRefreshStub.called);
-        assert.isFalse(reloadStub.called);
-        done();
-      });
-    });
-
-    test('_showAlert hides existing alerts', () => {
-      element._alertElement = element._createToastAlert();
-      const hideStub = sinon.stub(element, 'hideAlert');
-      element._showAlert();
-      assert.isTrue(hideStub.calledOnce);
-    });
-
-    test('show-error', () => {
-      const openStub = sinon.stub(element.$.errorOverlay, 'open');
-      const closeStub = sinon.stub(element.$.errorOverlay, 'close');
-      const reportStub = sinon.stub(
-          element.reporting,
-          'reportErrorDialog'
-      );
-
-      const message = 'test message';
-      element.dispatchEvent(
-          new CustomEvent('show-error', {
-            detail: {message},
-            composed: true, bubbles: true,
-          }));
-      flush();
-
-      assert.isTrue(openStub.called);
-      assert.isTrue(reportStub.called);
-      assert.equal(element.$.errorDialog.text, message);
-
-      element.$.errorDialog.dispatchEvent(
-          new CustomEvent('dismiss', {
-            composed: true, bubbles: true,
-          }));
-      flush();
-
-      assert.isTrue(closeStub.called);
-    });
-
-    test('reloads when refreshed credentials differ', done => {
-      const accountPromise = Promise.resolve({_account_id: 1234});
-      stubRestApi('getAccount')
-          .returns(accountPromise);
-      const requestCheckStub = sinon.stub(
-          element,
-          '_requestCheckLoggedIn');
-      const handleRefreshStub = sinon.stub(element,
-          'handleCredentialRefreshed');
-      const reloadStub = sinon.stub(element, '_reloadPage');
-
-      element.knownAccountId = 4321; // Different from 1234
-      element._refreshingCredentials = true;
-      element._checkSignedIn();
-
-      flush(() => {
-        assert.isFalse(requestCheckStub.called);
-        assert.isFalse(handleRefreshStub.called);
-        assert.isTrue(reloadStub.called);
-        done();
-      });
-    });
-  });
-
-  suite('when not authed', () => {
-    let toastSpy;
-    setup(() => {
-      stubRestApi('getLoggedIn').returns(Promise.resolve(false));
-      element = basicFixture.instantiate();
-      toastSpy = sinon.spy(element, '_createToastAlert');
-    });
-
-    teardown(() => {
-      toastSpy.getCalls().forEach(call => {
-        call.returnValue.remove();
-      });
-    });
-
-    test('refresh loop continues on credential fail', done => {
-      const requestCheckStub = sinon.stub(
-          element,
-          '_requestCheckLoggedIn');
-      const handleRefreshStub = sinon.stub(element,
-          'handleCredentialRefreshed');
-      const reloadStub = sinon.stub(element, '_reloadPage');
-
-      element._refreshingCredentials = true;
-      element._checkSignedIn();
-
-      flush(() => {
-        assert.isTrue(requestCheckStub.called);
-        assert.isFalse(handleRefreshStub.called);
-        assert.isFalse(reloadStub.called);
-        done();
-      });
-    });
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.ts b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.ts
new file mode 100644
index 0000000..aff5a85
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.ts
@@ -0,0 +1,675 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma';
+import './gr-error-manager';
+import {
+  constructServerErrorMsg,
+  GrErrorManager,
+  __testOnly_ErrorType,
+} from './gr-error-manager';
+import {stubAuth, stubReporting, stubRestApi} from '../../../test/test-utils';
+import {appContext} from '../../../services/app-context';
+import {
+  createAccountDetailWithId,
+  createPreferences,
+} from '../../../test/test-data-generators';
+import {tap} from '@polymer/iron-test-helpers/mock-interactions';
+import {AccountId} from '../../../types/common';
+
+const basicFixture = fixtureFromElement('gr-error-manager');
+
+suite('gr-error-manager tests', () => {
+  let element: GrErrorManager;
+
+  suite('when authed', () => {
+    let toastSpy: sinon.SinonSpy;
+    let fetchStub: sinon.SinonStub;
+    let getLoggedInStub: sinon.SinonStub;
+
+    setup(() => {
+      fetchStub = stubAuth('fetch').returns(
+        Promise.resolve({...new Response(), ok: true, status: 204})
+      );
+      getLoggedInStub = stubRestApi('getLoggedIn').callsFake(() =>
+        appContext.authService.authCheck()
+      );
+      stubRestApi('getPreferences').returns(
+        Promise.resolve(createPreferences())
+      );
+      element = basicFixture.instantiate();
+      appContext.authService.clearCache();
+      toastSpy = sinon.spy(element, '_createToastAlert');
+    });
+
+    teardown(() => {
+      toastSpy.getCalls().forEach(call => {
+        call.returnValue.remove();
+      });
+    });
+
+    test('does not show auth error on 403 by default', done => {
+      const showAuthErrorStub = sinon.stub(element, '_showAuthErrorAlert');
+      const responseText = Promise.resolve('server says no.');
+      element.dispatchEvent(
+        new CustomEvent('server-error', {
+          detail: {
+            response: {
+              status: 403,
+              text() {
+                return responseText;
+              },
+            },
+          },
+          composed: true,
+          bubbles: true,
+        })
+      );
+      flush(() => {
+        assert.isFalse(showAuthErrorStub.calledOnce);
+        done();
+      });
+    });
+
+    test('show auth required for 403 with auth error and not authed before', done => {
+      const showAuthErrorStub = sinon.stub(element, '_showAuthErrorAlert');
+      const responseText = Promise.resolve('Authentication required\n');
+      getLoggedInStub.returns(Promise.resolve(true));
+      element.dispatchEvent(
+        new CustomEvent('server-error', {
+          detail: {
+            response: {
+              status: 403,
+              text() {
+                return responseText;
+              },
+            },
+          },
+          composed: true,
+          bubbles: true,
+        })
+      );
+      flush(() => {
+        assert.isTrue(showAuthErrorStub.calledOnce);
+        done();
+      });
+    });
+
+    test('recheck auth for 403 with auth error if authed before', async () => {
+      // Set status to AUTHED.
+      appContext.authService.authCheck();
+      const responseText = Promise.resolve('Authentication required\n');
+      getLoggedInStub.returns(Promise.resolve(true));
+      element.dispatchEvent(
+        new CustomEvent('server-error', {
+          detail: {
+            response: {
+              status: 403,
+              text() {
+                return responseText;
+              },
+            },
+          },
+          composed: true,
+          bubbles: true,
+        })
+      );
+      await flush();
+      assert.isTrue(getLoggedInStub.calledOnce);
+    });
+
+    test('show logged in error', () => {
+      const spy = sinon.spy(element, '_showAuthErrorAlert');
+      element.dispatchEvent(
+        new CustomEvent('show-auth-required', {
+          composed: true,
+          bubbles: true,
+        })
+      );
+      assert.isTrue(
+        spy.calledWithExactly(
+          'Log in is required to perform that action.',
+          'Log in.'
+        )
+      );
+    });
+
+    test('show normal Error', done => {
+      const showErrorSpy = sinon.spy(element, '_showErrorDialog');
+      const textSpy = sinon.spy(() => Promise.resolve('ZOMG'));
+      element.dispatchEvent(
+        new CustomEvent('server-error', {
+          detail: {response: {status: 500, text: textSpy}},
+          composed: true,
+          bubbles: true,
+        })
+      );
+
+      assert.isTrue(textSpy.called);
+      flush(() => {
+        assert.isTrue(showErrorSpy.calledOnce);
+        assert.isTrue(
+          showErrorSpy.lastCall.calledWithExactly('Error 500: ZOMG')
+        );
+        done();
+      });
+    });
+
+    test('constructServerErrorMsg', () => {
+      const errorText = 'change conflicts';
+      const status = 409;
+      const statusText = 'Conflict';
+      const url = '/my/test/url';
+
+      assert.equal(constructServerErrorMsg({status}), 'Error 409');
+      assert.equal(
+        constructServerErrorMsg({status, url}),
+        'Error 409: \nEndpoint: /my/test/url'
+      );
+      assert.equal(
+        constructServerErrorMsg({status, statusText, url}),
+        'Error 409 (Conflict): \nEndpoint: /my/test/url'
+      );
+      assert.equal(
+        constructServerErrorMsg({
+          status,
+          statusText,
+          errorText,
+          url,
+        }),
+        'Error 409 (Conflict): change conflicts' + '\nEndpoint: /my/test/url'
+      );
+      assert.equal(
+        constructServerErrorMsg({
+          status,
+          statusText,
+          errorText,
+          url,
+          trace: 'xxxxx',
+        }),
+        'Error 409 (Conflict): change conflicts' +
+          '\nEndpoint: /my/test/url\nTrace Id: xxxxx'
+      );
+    });
+
+    test('extract trace id from headers if exists', done => {
+      const textSpy = sinon.spy(() => Promise.resolve('500'));
+      const headers = new Headers();
+      headers.set('X-Gerrit-Trace', 'xxxx');
+      element.dispatchEvent(
+        new CustomEvent('server-error', {
+          detail: {
+            response: {
+              headers,
+              status: 500,
+              text: textSpy,
+            },
+          },
+          composed: true,
+          bubbles: true,
+        })
+      );
+      flush(() => {
+        assert.equal(
+          element.$.errorDialog.text,
+          'Error 500: 500\nTrace Id: xxxx'
+        );
+        done();
+      });
+    });
+
+    test('suppress TOO_MANY_FILES error', done => {
+      const showAlertStub = sinon.stub(element, '_showAlert');
+      const textSpy = sinon.spy(() =>
+        Promise.resolve('too many files to find conflicts')
+      );
+      element.dispatchEvent(
+        new CustomEvent('server-error', {
+          detail: {response: {status: 500, text: textSpy}},
+          composed: true,
+          bubbles: true,
+        })
+      );
+
+      assert.isTrue(textSpy.called);
+      flush(() => {
+        assert.isFalse(showAlertStub.called);
+        done();
+      });
+    });
+
+    test('show network error', done => {
+      const showAlertStub = sinon.stub(element, '_showAlert');
+      element.dispatchEvent(
+        new CustomEvent('network-error', {
+          detail: {error: new Error('ZOMG')},
+          composed: true,
+          bubbles: true,
+        })
+      );
+      flush(() => {
+        assert.isTrue(showAlertStub.calledOnce);
+        assert.isTrue(
+          showAlertStub.lastCall.calledWithExactly('Server unavailable')
+        );
+        done();
+      });
+    });
+
+    test('_canOverride alerts', () => {
+      assert.isFalse(
+        element._canOverride(undefined, __testOnly_ErrorType.AUTH)
+      );
+      assert.isFalse(
+        element._canOverride(undefined, __testOnly_ErrorType.NETWORK)
+      );
+      assert.isTrue(
+        element._canOverride(undefined, __testOnly_ErrorType.GENERIC)
+      );
+      assert.isTrue(element._canOverride(undefined, undefined));
+
+      assert.isTrue(
+        element._canOverride(__testOnly_ErrorType.NETWORK, undefined)
+      );
+      assert.isTrue(element._canOverride(__testOnly_ErrorType.AUTH, undefined));
+      assert.isFalse(
+        element._canOverride(
+          __testOnly_ErrorType.NETWORK,
+          __testOnly_ErrorType.AUTH
+        )
+      );
+
+      assert.isTrue(
+        element._canOverride(
+          __testOnly_ErrorType.AUTH,
+          __testOnly_ErrorType.NETWORK
+        )
+      );
+    });
+
+    test('show auth refresh toast', async () => {
+      const clock = sinon.useFakeTimers();
+
+      // Set status to AUTHED.
+      appContext.authService.authCheck();
+      const refreshStub = stubRestApi('getAccount').callsFake(() =>
+        Promise.resolve(createAccountDetailWithId())
+      );
+      const windowOpen = sinon.stub(window, 'open');
+      const responseText = Promise.resolve('Authentication required\n');
+      // fake failed auth
+      fetchStub.returns(Promise.resolve({...new Response(), status: 403}));
+      element.dispatchEvent(
+        new CustomEvent('server-error', {
+          detail: {
+            response: {
+              status: 403,
+              text() {
+                return responseText;
+              },
+            },
+          },
+          composed: true,
+          bubbles: true,
+        })
+      );
+      assert.equal(fetchStub.callCount, 1);
+      await flush();
+
+      // here needs two flush as there are two chanined
+      // promises on server-error handler and flush only flushes one
+      assert.equal(fetchStub.callCount, 2);
+      await flush();
+      // Sometime overlay opens with delay, waiting while open is complete
+      clock.tick(1000);
+      await flush();
+      // auth-error fired
+      assert.isTrue(toastSpy.called);
+
+      // toast
+      let toast = toastSpy.lastCall.returnValue;
+      assert.isOk(toast);
+      assert.include(toast.root.textContent, 'Credentials expired.');
+      assert.include(toast.root.textContent, 'Refresh credentials');
+
+      // noInteractionOverlay
+      const noInteractionOverlay = element.$.noInteractionOverlay;
+      assert.isOk(noInteractionOverlay);
+      const noInteractionOverlayCloseSpy = sinon.spy(
+        noInteractionOverlay,
+        'close'
+      );
+      assert.equal(
+        noInteractionOverlay.backdropElement.getAttribute('opened'),
+        ''
+      );
+      assert.isFalse(windowOpen.called);
+      tap(toast.shadowRoot.querySelector('gr-button.action'));
+      assert.isTrue(windowOpen.called);
+
+      // @see Issue 5822: noopener breaks closeAfterLogin
+      assert.equal(windowOpen.lastCall.args[2]?.indexOf('noopener=yes'), -1);
+
+      const hideToastSpy = sinon.spy(toast, 'hide');
+
+      // now fake authed
+      fetchStub.returns(Promise.resolve({status: 204}));
+
+      clock.tick(1000);
+      element.knownAccountId = 5 as AccountId;
+      element._checkSignedIn();
+      await flush();
+
+      assert.isTrue(refreshStub.called);
+      assert.isTrue(hideToastSpy.called);
+
+      // toast update
+      assert.notStrictEqual(toastSpy.lastCall.returnValue, toast);
+      toast = toastSpy.lastCall.returnValue;
+      assert.isOk(toast);
+      assert.include(toast.root.textContent, 'Credentials refreshed');
+
+      // close overlay
+      assert.isTrue(noInteractionOverlayCloseSpy.called);
+    });
+
+    test('auth toast should dismiss existing toast', async () => {
+      const clock = sinon.useFakeTimers();
+      // Set status to AUTHED.
+      appContext.authService.authCheck();
+      const responseText = Promise.resolve('Authentication required\n');
+
+      // fake an alert
+      element.dispatchEvent(
+        new CustomEvent('show-alert', {
+          detail: {message: 'test reload', action: 'reload'},
+          composed: true,
+          bubbles: true,
+        })
+      );
+      let toast = toastSpy.lastCall.returnValue;
+      assert.isOk(toast);
+      assert.include(toast.root.textContent, 'test reload');
+
+      // fake auth
+      fetchStub.returns(Promise.resolve({status: 403}));
+      element.dispatchEvent(
+        new CustomEvent('server-error', {
+          detail: {
+            response: {
+              status: 403,
+              text() {
+                return responseText;
+              },
+            },
+          },
+          composed: true,
+          bubbles: true,
+        })
+      );
+      await flush();
+      await flush();
+      // here needs two flush as there are two chained
+      // promises on server-error handler and flush only flushes one
+      assert.equal(fetchStub.callCount, 2);
+      await flush();
+      // Sometime overlay opens with delay, waiting while open is complete
+      clock.tick(1000);
+      await flush();
+      // toast
+      toast = toastSpy.lastCall.returnValue;
+      assert.include(toast.root.textContent, 'Credentials expired.');
+      assert.include(toast.root.textContent, 'Refresh credentials');
+    });
+
+    test('regular toast should dismiss regular toast', () => {
+      // Set status to AUTHED.
+      appContext.authService.authCheck();
+
+      // fake an alert
+      element.dispatchEvent(
+        new CustomEvent('show-alert', {
+          detail: {message: 'test reload', action: 'reload'},
+          composed: true,
+          bubbles: true,
+        })
+      );
+      let toast = toastSpy.lastCall.returnValue;
+      assert.isOk(toast);
+      assert.include(toast.root.textContent, 'test reload');
+
+      // new alert
+      element.dispatchEvent(
+        new CustomEvent('show-alert', {
+          detail: {message: 'second-test', action: 'reload'},
+          composed: true,
+          bubbles: true,
+        })
+      );
+
+      toast = toastSpy.lastCall.returnValue;
+      assert.include(toast.root.textContent, 'second-test');
+    });
+
+    test('regular toast should not dismiss auth toast', done => {
+      // Set status to AUTHED.
+      appContext.authService.authCheck();
+      const responseText = Promise.resolve('Authentication required\n');
+
+      // fake auth
+      fetchStub.returns(Promise.resolve({status: 403}));
+      element.dispatchEvent(
+        new CustomEvent('server-error', {
+          detail: {
+            response: {
+              status: 403,
+              text() {
+                return responseText;
+              },
+            },
+          },
+          composed: true,
+          bubbles: true,
+        })
+      );
+      assert.equal(fetchStub.callCount, 1);
+      flush(() => {
+        // here needs two flush as there are two chained
+        // promises on server-error handler and flush only flushes one
+        assert.equal(fetchStub.callCount, 2);
+        flush(() => {
+          let toast = toastSpy.lastCall.returnValue;
+          assert.include(toast.root.textContent, 'Credentials expired.');
+          assert.include(toast.root.textContent, 'Refresh credentials');
+
+          // fake an alert
+          element.dispatchEvent(
+            new CustomEvent('show-alert', {
+              detail: {
+                message: 'test-alert',
+                action: 'reload',
+              },
+              composed: true,
+              bubbles: true,
+            })
+          );
+          flush(() => {
+            toast = toastSpy.lastCall.returnValue;
+            assert.isOk(toast);
+            assert.include(toast.root.textContent, 'Credentials expired.');
+            done();
+          });
+        });
+      });
+    });
+
+    test('show alert', () => {
+      const alertObj = {message: 'foo'};
+      const showAlertStub = sinon.stub(element, '_showAlert');
+      element.dispatchEvent(
+        new CustomEvent('show-alert', {
+          detail: alertObj,
+          composed: true,
+          bubbles: true,
+        })
+      );
+      assert.isTrue(showAlertStub.calledOnce);
+      assert.equal(showAlertStub.lastCall.args[0], 'foo');
+      assert.isNotOk(showAlertStub.lastCall.args[1]);
+      assert.isNotOk(showAlertStub.lastCall.args[2]);
+    });
+
+    test('checks stale credentials on visibility change', () => {
+      const refreshStub = sinon.stub(element, '_checkSignedIn');
+      sinon.stub(Date, 'now').returns(999999);
+      element._lastCredentialCheck = 0;
+
+      document.dispatchEvent(new CustomEvent('visibilitychange'));
+
+      // Since there is no known account, it should not test credentials.
+      assert.isFalse(refreshStub.called);
+      assert.equal(element._lastCredentialCheck, 0);
+
+      element.knownAccountId = 123 as AccountId;
+
+      document.dispatchEvent(new CustomEvent('visibilitychange'));
+
+      // Should test credentials, since there is a known account.
+      assert.isTrue(refreshStub.called);
+      assert.equal(element._lastCredentialCheck, 999999);
+    });
+
+    test('refreshes with same credentials', done => {
+      const accountPromise = Promise.resolve({
+        ...createAccountDetailWithId(1234),
+      });
+      stubRestApi('getAccount').returns(accountPromise);
+      const requestCheckStub = sinon.stub(element, '_requestCheckLoggedIn');
+      const handleRefreshStub = sinon.stub(
+        element,
+        'handleCredentialRefreshed'
+      );
+      const reloadStub = sinon.stub(element, '_reloadPage');
+
+      element.knownAccountId = 1234 as AccountId;
+      element._refreshingCredentials = true;
+      element._checkSignedIn();
+
+      flush(() => {
+        assert.isFalse(requestCheckStub.called);
+        assert.isTrue(handleRefreshStub.called);
+        assert.isFalse(reloadStub.called);
+        done();
+      });
+    });
+
+    test('_showAlert hides existing alerts', () => {
+      element._alertElement = element._createToastAlert();
+      // const hideStub = sinon.stub(element, 'hideAlert');
+      // element._showAlert('');
+      // assert.isTrue(hideStub.calledOnce);
+    });
+
+    test('show-error', () => {
+      const openStub = sinon.stub(element.$.errorOverlay, 'open');
+      const closeStub = sinon.stub(element.$.errorOverlay, 'close');
+      const reportStub = stubReporting('reportErrorDialog');
+
+      const message = 'test message';
+      element.dispatchEvent(
+        new CustomEvent('show-error', {
+          detail: {message},
+          composed: true,
+          bubbles: true,
+        })
+      );
+      flush();
+
+      assert.isTrue(openStub.called);
+      assert.isTrue(reportStub.called);
+      assert.equal(element.$.errorDialog.text, message);
+
+      element.$.errorDialog.dispatchEvent(
+        new CustomEvent('dismiss', {
+          composed: true,
+          bubbles: true,
+        })
+      );
+      flush();
+
+      assert.isTrue(closeStub.called);
+    });
+
+    test('reloads when refreshed credentials differ', done => {
+      const accountPromise = Promise.resolve({
+        ...createAccountDetailWithId(1234),
+      });
+      stubRestApi('getAccount').returns(accountPromise);
+      const requestCheckStub = sinon.stub(element, '_requestCheckLoggedIn');
+      const handleRefreshStub = sinon.stub(
+        element,
+        'handleCredentialRefreshed'
+      );
+      const reloadStub = sinon.stub(element, '_reloadPage');
+
+      element.knownAccountId = 4321 as AccountId; // Different from 1234
+      element._refreshingCredentials = true;
+      element._checkSignedIn();
+
+      flush(() => {
+        assert.isFalse(requestCheckStub.called);
+        assert.isFalse(handleRefreshStub.called);
+        assert.isTrue(reloadStub.called);
+        done();
+      });
+    });
+  });
+
+  suite('when not authed', () => {
+    let toastSpy: sinon.SinonSpy;
+    setup(() => {
+      stubRestApi('getLoggedIn').returns(Promise.resolve(false));
+      element = basicFixture.instantiate();
+      toastSpy = sinon.spy(element, '_createToastAlert');
+    });
+
+    teardown(() => {
+      toastSpy.getCalls().forEach(call => {
+        call.returnValue.remove();
+      });
+    });
+
+    test('refresh loop continues on credential fail', done => {
+      const requestCheckStub = sinon.stub(element, '_requestCheckLoggedIn');
+      const handleRefreshStub = sinon.stub(
+        element,
+        'handleCredentialRefreshed'
+      );
+      const reloadStub = sinon.stub(element, '_reloadPage');
+
+      element._refreshingCredentials = true;
+      element._checkSignedIn();
+
+      flush(() => {
+        assert.isTrue(requestCheckStub.called);
+        assert.isFalse(handleRefreshStub.called);
+        assert.isFalse(reloadStub.called);
+        done();
+      });
+    });
+  });
+});
diff --git a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.ts b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.ts
index a3ddff3..1612a75 100644
--- a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.ts
+++ b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.ts
@@ -73,6 +73,7 @@
   'has:stars',
   'has:unresolved',
   'hashtag:',
+  'inhashtag:',
   'intopic:',
   'is:',
   'is:abandoned',
diff --git a/polygerrit-ui/app/elements/diff/gr-context-controls/gr-context-controls.ts b/polygerrit-ui/app/elements/diff/gr-context-controls/gr-context-controls.ts
index c502346..86a60ce 100644
--- a/polygerrit-ui/app/elements/diff/gr-context-controls/gr-context-controls.ts
+++ b/polygerrit-ui/app/elements/diff/gr-context-controls/gr-context-controls.ts
@@ -22,7 +22,7 @@
 import '@polymer/paper-icon-button/paper-icon-button';
 import '@polymer/paper-item/paper-item';
 import '@polymer/paper-listbox/paper-listbox';
-import '@polymer/paper-tooltip/paper-tooltip.js';
+import '@polymer/paper-tooltip/paper-tooltip';
 import {of, EMPTY, Subject} from 'rxjs';
 import {switchMap, delay, takeUntil} from 'rxjs/operators';
 
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.ts b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.ts
index 68bdaa6..958f367 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.ts
@@ -15,11 +15,7 @@
  * limitations under the License.
  */
 
-import {customElement, observe, property} from '@polymer/decorators';
-import {afterNextRender} from '@polymer/polymer/lib/utils/render-status';
 import {dom} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {html} from '@polymer/polymer/lib/utils/html-tag';
 import {Subscription} from 'rxjs';
 import {AbortStop, CursorMoveResult, Stop} from '../../../api/core';
 import {
@@ -583,98 +579,3 @@
     return targetableStops.find(stop => stop.querySelector(selector));
   }
 }
-
-// TODO(oler): Remove this once clients have migrated to using GrDiffCursor service.
-@customElement('gr-diff-cursor')
-export class GrDiffCursorElement extends PolymerElement {
-  static get template() {
-    return html``;
-  }
-
-  @property({type: String, observer: '_sideChanged'})
-  side: Side = Side.RIGHT;
-
-  _sideChanged(side: Side) {
-    this.cursor.side = side;
-  }
-
-  @property({type: Object}) diffs: GrDiff[] = [];
-
-  @observe('diffs.splices')
-  _diffsChanged() {
-    if (this.diffs) {
-      this.cursor.replaceDiffs(this.diffs);
-    }
-  }
-
-  private cursor = new GrDiffCursor();
-
-  /** @override */
-  ready() {
-    super.ready();
-    afterNextRender(this, () => {
-      /*
-      This represents the diff cursor is ready for interaction coming from
-      client components. It is more then Polymer "ready" lifecycle, as no
-      "ready" events are automatically fired by Polymer, it means
-      the cursor is completely interactable - in this case attached and
-      painted on the page. We name it "ready" instead of "rendered" as the
-      long-term goal is to make gr-diff-cursor a javascript class - not a DOM
-      element with an actual lifecycle. This will be triggered only once
-      per element.
-      */
-      this.dispatchEvent(
-        new CustomEvent('ready', {
-          composed: true,
-          bubbles: false,
-        })
-      );
-    });
-  }
-
-  /** @override */
-  disconnectedCallback() {
-    this.cursor.dispose();
-    super.disconnectedCallback();
-  }
-
-  isAtStart = this.cursor.isAtStart.bind(this.cursor);
-
-  isAtEnd = this.cursor.isAtEnd.bind(this.cursor);
-
-  moveLeft = this.cursor.moveLeft.bind(this.cursor);
-
-  moveRight = this.cursor.moveRight.bind(this.cursor);
-
-  moveDown = this.cursor.moveDown.bind(this.cursor);
-
-  moveUp = this.cursor.moveUp.bind(this.cursor);
-
-  moveToNextChunk = this.cursor.moveToNextChunk.bind(this.cursor);
-
-  moveToPreviousChunk = this.cursor.moveToPreviousChunk.bind(this.cursor);
-
-  moveToNextCommentThread = this.cursor.moveToNextCommentThread.bind(
-    this.cursor
-  );
-
-  moveToPreviousCommentThread = this.cursor.moveToPreviousCommentThread.bind(
-    this.cursor
-  );
-
-  moveToLineNumber = this.cursor.moveToLineNumber.bind(this.cursor);
-
-  moveToFirstChunk = this.cursor.moveToFirstChunk.bind(this.cursor);
-
-  moveToLastChunk = this.cursor.moveToLastChunk.bind(this.cursor);
-
-  reInit = this.cursor.resetScrollMode.bind(this.cursor);
-
-  createCommentInPlace = this.cursor.createCommentInPlace.bind(this.cursor);
-}
-
-declare global {
-  interface HTMLElementTagNameMap {
-    'gr-diff-cursor': GrDiffCursorElement;
-  }
-}
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-image-viewer/gr-image-viewer.ts b/polygerrit-ui/app/elements/diff/gr-diff-image-viewer/gr-image-viewer.ts
index dad855c..466b1f4 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-image-viewer/gr-image-viewer.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-image-viewer/gr-image-viewer.ts
@@ -252,6 +252,8 @@
       display: flex;
       align-items: center;
       margin: var(--spacing-xl);
+      /* Start a stacking context to contain FAB below. */
+      z-index: 0;
     }
     #version-switcher paper-button {
       flex-grow: 1;
@@ -264,7 +266,7 @@
     }
     #version-switcher paper-fab {
       /* Round button overlaps Base and Revision buttons. */
-      z-index: 10;
+      z-index: 1;
       margin: 0 -12px;
       /* Styled as an outlined button. */
       color: var(--primary-button-background-color);
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts
index dacd8e7..3e8414d 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts
@@ -106,6 +106,7 @@
 import {assertIsDefined} from '../../../utils/common-util';
 import {toggleClass, getKeyboardEvent} from '../../../utils/dom-util';
 import {CursorMoveResult} from '../../../api/core';
+import {throttleWrap} from '../../../utils/async-util';
 
 const ERR_REVIEW_STATUS = 'Couldn’t change file review status.';
 const MSG_LOADING_BLAME = 'Loading blame...';
@@ -342,7 +343,7 @@
   /** @override */
   connectedCallback() {
     super.connectedCallback();
-    this._throttledToggleFileReviewed = this._throttleWrap(e =>
+    this._throttledToggleFileReviewed = throttleWrap(e =>
       this._handleToggleFileReviewed(e as CustomKeyboardEvent)
     );
     this._getLoggedIn().then(loggedIn => {
@@ -461,6 +462,8 @@
     this.$.reviewed.checked = reviewed;
     if (!this._patchRange?.patchNum || !this._path) return;
     const path = this._path;
+    // if file is already reviewed then do not make a saveReview request
+    if (this._reviewedFiles.has(path) && reviewed) return;
     if (reviewed) this._reviewedFiles.add(path);
     else this._reviewedFiles.delete(path);
     this._saveReviewedState(reviewed).catch(err => {
@@ -1205,15 +1208,17 @@
     }
   }
 
-  @observe('_path', '_prefs', '_reviewedFiles')
+  @observe('_path', '_prefs', '_reviewedFiles', '_patchRange')
   _setReviewedObserver(
     path?: string,
     prefs?: DiffPreferencesInfo,
-    reviewedFiles?: Set<string>
+    reviewedFiles?: Set<string>,
+    patchRange?: PatchRange
   ) {
     if (prefs === undefined) return;
     if (path === undefined) return;
     if (reviewedFiles === undefined) return;
+    if (patchRange === undefined) return;
     if (prefs.manual_review) {
       // Checkbox state needs to be set explicitly only when manual_review
       // is specified.
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 7c437fd..0b42f9f 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.ts
@@ -69,6 +69,7 @@
 import {
   CreateCommentEventDetail as CreateCommentEventDetailApi,
   RenderPreferences,
+  GrDiff as GrDiffApi,
 } from '../../../api/diff';
 import {isSafari, toggleClass} from '../../../utils/dom-util';
 import {assertIsDefined} from '../../../utils/common-util';
@@ -109,7 +110,7 @@
 }
 
 @customElement('gr-diff')
-export class GrDiff extends PolymerElement {
+export class GrDiff extends PolymerElement implements GrDiffApi {
   static get template() {
     return htmlTemplate;
   }
@@ -322,6 +323,12 @@
     super.disconnectedCallback();
   }
 
+  getLineNumEls(side: Side): HTMLElement[] {
+    return Array.from(
+      this.root?.querySelectorAll<HTMLElement>(`.lineNum.${side}`) ?? []
+    );
+  }
+
   showNoChangeMessage(
     loading?: boolean,
     prefs?: DiffPreferencesInfo,
diff --git a/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer.ts b/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer.ts
index 081d28d..90fe300 100644
--- a/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer.ts
+++ b/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer.ts
@@ -20,6 +20,7 @@
 import {DiffFileMetaInfo, DiffInfo} from '../../../types/diff';
 import {DiffLayer, DiffLayerListener, HighlightJS} from '../../../types/types';
 import {GrLibLoader} from '../../shared/gr-lib-loader/gr-lib-loader';
+import {HLJS_LIBRARY_CONFIG} from '../../shared/gr-lib-loader/highlightjs_config';
 import {Side} from '../../../constants/constants';
 
 const LANGUAGE_MAP = new Map<string, string>([
@@ -576,8 +577,8 @@
   }
 
   _loadHLJS() {
-    return this.libLoader.getHLJS().then(hljs => {
-      this.hljs = hljs;
+    return this.libLoader.getLibrary(HLJS_LIBRARY_CONFIG).then(hljs => {
+      this.hljs = hljs as HighlightJS;
     });
   }
 }
diff --git a/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_test.js b/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_test.js
index f9100a2..b8c3c16 100644
--- a/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_test.js
@@ -52,6 +52,12 @@
     element.diff = diff;
   });
 
+  teardown(() => {
+    if (window.hljs) {
+      delete window.hljs;
+    }
+  });
+
   test('annotate without range does nothing', () => {
     const annotationSpy = sinon.spy(GrAnnotation, 'annotateElement');
     const el = document.createElement('div');
@@ -171,9 +177,8 @@
     element.diff.meta_b.content_type = 'application/json';
 
     const mockHLJS = getMockHLJS();
+    window.hljs = mockHLJS;
     const highlightSpy = sinon.spy(mockHLJS, 'highlight');
-    sinon.stub(element.libLoader, 'getHLJS').callsFake(
-        () => Promise.resolve(mockHLJS));
     const processNextSpy = sinon.spy(element, '_processNextLine');
     const processPromise = element.process();
 
diff --git a/polygerrit-ui/app/elements/gr-app-element.ts b/polygerrit-ui/app/elements/gr-app-element.ts
index be9c7c5..c9f0506 100644
--- a/polygerrit-ui/app/elements/gr-app-element.ts
+++ b/polygerrit-ui/app/elements/gr-app-element.ts
@@ -55,7 +55,10 @@
   ElementPropertyDeepChange,
   ServerInfo,
 } from '../types/common';
-import {GrErrorManager} from './core/gr-error-manager/gr-error-manager';
+import {
+  constructServerErrorMsg,
+  GrErrorManager,
+} from './core/gr-error-manager/gr-error-manager';
 import {GrOverlay} from './shared/gr-overlay/gr-overlay';
 import {GrRegistrationDialog} from './settings/gr-registration-dialog/gr-registration-dialog';
 import {
@@ -565,7 +568,15 @@
       err.emoji = 'o_O';
       if (response) {
         response.text().then(text => {
-          err.moreInfo = text;
+          const trace =
+            response.headers && response.headers.get('X-Gerrit-Trace');
+          const {status, statusText} = response;
+          err.moreInfo = constructServerErrorMsg({
+            status,
+            statusText,
+            errorText: text,
+            trace,
+          });
           this._lastError = err;
         });
       }
diff --git a/polygerrit-ui/app/elements/plugins/gr-checks-api/gr-checks-api.ts b/polygerrit-ui/app/elements/plugins/gr-checks-api/gr-checks-api.ts
index 39d3c8b..087779e 100644
--- a/polygerrit-ui/app/elements/plugins/gr-checks-api/gr-checks-api.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-checks-api/gr-checks-api.ts
@@ -19,6 +19,8 @@
   ChecksApiConfig,
   ChecksProvider,
   ChecksPluginApi,
+  CheckResult,
+  CheckRun,
 } from '../../../api/checks';
 import {appContext} from '../../../services/app-context';
 
@@ -54,6 +56,13 @@
     this.checksService.reload(this.plugin.getPluginName());
   }
 
+  updateResult(run: CheckRun, result: CheckResult) {
+    if (result.externalId === undefined) {
+      throw new Error('ChecksApi.updateResult() was called without externalId');
+    }
+    this.checksService.updateResult(this.plugin.getPluginName(), run, result);
+  }
+
   register(provider: ChecksProvider, config?: ChecksApiConfig): void {
     this.reporting.trackApi(this.plugin, 'checks', 'register');
     if (this.state === State.REGISTERED)
diff --git a/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list_test.ts b/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list_test.ts
index f08976f..4891759 100644
--- a/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list_test.ts
+++ b/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list_test.ts
@@ -25,7 +25,7 @@
 suite('gr-agreements-list tests', () => {
   let element: GrAgreementsList;
 
-  setup(done => {
+  setup(async () => {
     const agreements: ContributorAgreementInfo[] = [
       {
         url: 'some url',
@@ -38,9 +38,8 @@
 
     element = basicFixture.instantiate();
 
-    element.loadData().then(() => {
-      flush(done);
-    });
+    await element.loadData();
+    await flush();
   });
 
   test('renders', () => {
diff --git a/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view_test.ts b/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view_test.ts
index 9f54cd1..d634483 100644
--- a/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view_test.ts
+++ b/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view_test.ts
@@ -125,16 +125,15 @@
     },
   ];
 
-  setup(done => {
+  setup(async () => {
     stubRestApi('getConfig').returns(Promise.resolve(config));
     stubRestApi('getAccountGroups').returns(Promise.resolve(groups));
     stubRestApi('getAccountAgreements').returns(
       Promise.resolve(signedAgreements)
     );
     element = basicFixture.instantiate();
-    element.loadData().then(() => {
-      flush(done);
-    });
+    await element.loadData();
+    await flush();
   });
 
   test('renders as expected with signed agreement', () => {
diff --git a/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry.ts b/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry.ts
index d793a8d..944054e 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry.ts
+++ b/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry.ts
@@ -19,7 +19,10 @@
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-account-entry_html';
 import {customElement, property} from '@polymer/decorators';
-import {GrAutocomplete} from '../gr-autocomplete/gr-autocomplete';
+import {
+  AutocompleteQuery,
+  GrAutocomplete,
+} from '../gr-autocomplete/gr-autocomplete';
 
 export interface GrAccountEntry {
   $: {
@@ -63,7 +66,7 @@
   suggestFrom = 0;
 
   @property({type: Object, notify: true})
-  querySuggestions = () => Promise.resolve([]);
+  querySuggestions: AutocompleteQuery = () => Promise.resolve([]);
 
   @property({type: String, observer: '_inputTextChanged'})
   _inputText?: string;
diff --git a/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry_test.js b/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry_test.js
deleted file mode 100644
index 4430c65..0000000
--- a/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry_test.js
+++ /dev/null
@@ -1,88 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../../../test/common-test-setup-karma.js';
-import './gr-account-entry.js';
-
-const basicFixture = fixtureFromElement('gr-account-entry');
-
-suite('gr-account-entry tests', () => {
-  let element;
-
-  const suggestion1 = {
-    email: 'email1@example.com',
-    _account_id: 1,
-    some_property: 'value',
-  };
-  const suggestion2 = {
-    email: 'email2@example.com',
-    _account_id: 2,
-  };
-  const suggestion3 = {
-    email: 'email25@example.com',
-    _account_id: 25,
-    some_other_property: 'other value',
-  };
-
-  setup(() => {
-    element = basicFixture.instantiate();
-  });
-
-  suite('stubbed values for querySuggestions', () => {
-    setup(() => {
-      element.querySuggestions = input => Promise.resolve([
-        suggestion1,
-        suggestion2,
-        suggestion3,
-      ]);
-    });
-  });
-
-  test('account-text-changed fired when input text changed and allowAnyInput',
-      () => {
-        // Spy on query, as that is called when _updateSuggestions proceeds.
-        const changeStub = sinon.stub();
-        element.allowAnyInput = true;
-        element.querySuggestions = input => Promise.resolve([]);
-        element.addEventListener('account-text-changed', changeStub);
-        element.$.input.text = 'a';
-        assert.isTrue(changeStub.calledOnce);
-        element.$.input.text = 'ab';
-        assert.isTrue(changeStub.calledTwice);
-      });
-
-  test('account-text-changed not fired when input text changed without ' +
-      'allowAnyInput', () => {
-    // Spy on query, as that is called when _updateSuggestions proceeds.
-    const changeStub = sinon.stub();
-    element.querySuggestions = input => Promise.resolve([]);
-    element.addEventListener('account-text-changed', changeStub);
-    element.$.input.text = 'a';
-    assert.isFalse(changeStub.called);
-  });
-
-  test('setText', () => {
-    // Spy on query, as that is called when _updateSuggestions proceeds.
-    const suggestSpy = sinon.spy(element.$.input, 'query');
-    element.setText('test text');
-    flush();
-
-    assert.equal(element.$.input.$.input.value, 'test text');
-    assert.isFalse(suggestSpy.called);
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry_test.ts b/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry_test.ts
new file mode 100644
index 0000000..4bb2232
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry_test.ts
@@ -0,0 +1,65 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma';
+import './gr-account-entry';
+import {GrAccountEntry} from './gr-account-entry';
+
+const basicFixture = fixtureFromElement('gr-account-entry');
+
+suite('gr-account-entry tests', () => {
+  let element: GrAccountEntry;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+  });
+
+  test('account-text-changed fired when input text changed and allowAnyInput', () => {
+    // Spy on query, as that is called when _updateSuggestions proceeds.
+    const changeStub = sinon.stub();
+    element.allowAnyInput = true;
+    element.querySuggestions = () => Promise.resolve([]);
+    element.addEventListener('account-text-changed', changeStub);
+    element.$.input.text = 'a';
+    assert.isTrue(changeStub.calledOnce);
+    element.$.input.text = 'ab';
+    assert.isTrue(changeStub.calledTwice);
+  });
+
+  test(
+    'account-text-changed not fired when input text changed without ' +
+      'allowAnyInput',
+    () => {
+      // Spy on query, as that is called when _updateSuggestions proceeds.
+      const changeStub = sinon.stub();
+      element.querySuggestions = () => Promise.resolve([]);
+      element.addEventListener('account-text-changed', changeStub);
+      element.$.input.text = 'a';
+      assert.isFalse(changeStub.called);
+    }
+  );
+
+  test('setText', () => {
+    // Spy on query, as that is called when _updateSuggestions proceeds.
+    const suggestSpy = sinon.spy(element.$.input, 'query');
+    element.setText('test text');
+    flush();
+
+    assert.equal(element.$.input.$.input.value, 'test text');
+    assert.isFalse(suggestSpy.called);
+  });
+});
diff --git a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_test.js b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_test.js
deleted file mode 100644
index 459c8c7..0000000
--- a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_test.js
+++ /dev/null
@@ -1,119 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../../../test/common-test-setup-karma.js';
-import './gr-account-label.js';
-import {stubRestApi} from '../../../test/test-utils.js';
-
-const basicFixture = fixtureFromElement('gr-account-label');
-
-suite('gr-account-label tests', () => {
-  let element;
-  const kermit = createAccount('kermit', 31);
-
-  function createAccount(name, id) {
-    return {name, _account_id: id};
-  }
-
-  setup(() => {
-    stubRestApi('getAccount').callsFake(() => Promise.resolve(kermit));
-    stubRestApi('getLoggedIn').returns(Promise.resolve(false));
-    element = basicFixture.instantiate();
-    element._config = {
-      user: {
-        anonymous_coward_name: 'Anonymous Coward',
-      },
-    };
-  });
-
-  test('null guard', () => {
-    assert.doesNotThrow(() => {
-      element.account = null;
-    });
-  });
-
-  suite('_computeName', () => {
-    test('not showing anonymous', () => {
-      const account = {name: 'Wyatt'};
-      assert.deepEqual(element._computeName(account, null), 'Wyatt');
-    });
-
-    test('showing anonymous but no config', () => {
-      const account = {};
-      assert.deepEqual(element._computeName(account, null),
-          'Anonymous');
-    });
-
-    test('test for Anonymous Coward user and replace with Anonymous', () => {
-      const config = {
-        user: {
-          anonymous_coward_name: 'Anonymous Coward',
-        },
-      };
-      const account = {};
-      assert.deepEqual(element._computeName(account, config),
-          'Anonymous');
-    });
-
-    test('test for anonymous_coward_name', () => {
-      const config = {
-        user: {
-          anonymous_coward_name: 'TestAnon',
-        },
-      };
-      const account = {};
-      assert.deepEqual(element._computeName(account, config),
-          'TestAnon');
-    });
-  });
-
-  suite('attention set', () => {
-    setup(async () => {
-      element.highlightAttention = true;
-      element._config = {
-        user: {anonymous_coward_name: 'Anonymous Coward'},
-      };
-      element._selfAccount = kermit;
-      element.account = createAccount('ernie', 42);
-      element.change = {
-        attention_set: {42: {}},
-        owner: kermit,
-        reviewers: {},
-      };
-      await flush();
-    });
-
-    test('show attention button', () => {
-      const button = element.shadowRoot.querySelector('#attentionButton');
-      assert.ok(button);
-      assert.isNull(button.getAttribute('disabled'));
-    });
-
-    test('tap attention button', async () => {
-      const apiStub = stubRestApi(
-          'removeFromAttentionSet')
-          .callsFake(() => Promise.resolve());
-      const button = element.shadowRoot.querySelector('#attentionButton');
-      assert.ok(button);
-      assert.isNull(button.getAttribute('disabled'));
-      MockInteractions.tap(button);
-      assert.isTrue(apiStub.calledOnce);
-      assert.equal(apiStub.lastCall.args[1], 42);
-    });
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_test.ts b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_test.ts
new file mode 100644
index 0000000..efaa9f7
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_test.ts
@@ -0,0 +1,130 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma';
+import './gr-account-label';
+import {
+  queryAndAssert,
+  spyRestApi,
+  stubRestApi,
+} from '../../../test/test-utils';
+import {GrAccountLabel} from './gr-account-label';
+import {AccountDetailInfo, ServerInfo} from '../../../types/common';
+import {
+  createAccountDetailWithId,
+  createChange,
+  createServerInfo,
+} from '../../../test/test-data-generators';
+import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
+
+const basicFixture = fixtureFromElement('gr-account-label');
+
+suite('gr-account-label tests', () => {
+  let element: GrAccountLabel;
+  const kermit: AccountDetailInfo = {
+    ...createAccountDetailWithId(31),
+    name: 'kermit',
+  };
+
+  setup(() => {
+    stubRestApi('getAccount').callsFake(() => Promise.resolve(kermit));
+    stubRestApi('getLoggedIn').returns(Promise.resolve(false));
+    element = basicFixture.instantiate();
+    element._config = {
+      ...createServerInfo(),
+      user: {
+        anonymous_coward_name: 'Anonymous Coward',
+      },
+    };
+  });
+
+  suite('_computeName', () => {
+    test('not showing anonymous', () => {
+      const account = {name: 'Wyatt'};
+      assert.deepEqual(element._computeName(account), 'Wyatt');
+    });
+
+    test('showing anonymous but no config', () => {
+      const account = {};
+      assert.deepEqual(element._computeName(account), 'Anonymous');
+    });
+
+    test('test for Anonymous Coward user and replace with Anonymous', () => {
+      const config: ServerInfo = {
+        ...createServerInfo(),
+        user: {
+          anonymous_coward_name: 'Anonymous Coward',
+        },
+      };
+      const account = {};
+      assert.deepEqual(element._computeName(account, config), 'Anonymous');
+    });
+
+    test('test for anonymous_coward_name', () => {
+      const config = {
+        ...createServerInfo(),
+        user: {
+          anonymous_coward_name: 'TestAnon',
+        },
+      };
+      const account = {};
+      assert.deepEqual(element._computeName(account, config), 'TestAnon');
+    });
+  });
+
+  suite('attention set', () => {
+    setup(async () => {
+      element.highlightAttention = true;
+      element._config = {
+        ...createServerInfo(),
+        user: {anonymous_coward_name: 'Anonymous Coward'},
+      };
+      element._selfAccount = kermit;
+      element.account = {
+        ...createAccountDetailWithId(42),
+        name: 'ernie',
+      };
+      element.change = {
+        ...createChange(),
+        attention_set: {
+          42: {
+            account: createAccountDetailWithId(42),
+          },
+        },
+        owner: kermit,
+        reviewers: {},
+      };
+      await flush();
+    });
+
+    test('show attention button', () => {
+      const button = queryAndAssert(element, '#attentionButton');
+      assert.ok(button);
+      assert.isNull(button.getAttribute('disabled'));
+    });
+
+    test('tap attention button', async () => {
+      const apiSpy = spyRestApi('removeFromAttentionSet');
+      const button = queryAndAssert(element, '#attentionButton');
+      assert.ok(button);
+      assert.isNull(button.getAttribute('disabled'));
+      MockInteractions.tap(button);
+      assert.isTrue(apiSpy.calledOnce);
+      assert.equal(apiSpy.lastCall.args[1], 42);
+    });
+  });
+});
diff --git a/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link.ts b/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link.ts
index 5c4b76a..94a24e1 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link.ts
+++ b/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link.ts
@@ -16,19 +16,12 @@
  */
 
 import '../gr-account-label/gr-account-label';
-import '../../../styles/shared-styles';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-account-link_html';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
-import {customElement, property} from '@polymer/decorators';
 import {AccountInfo, ChangeInfo} from '../../../types/common';
+import {css, customElement, html, LitElement, property} from 'lit-element';
 
 @customElement('gr-account-link')
-class GrAccountLink extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrAccountLink extends LitElement {
   @property({type: String})
   voteableText?: string;
 
@@ -70,6 +63,45 @@
   @property({type: Boolean})
   firstName = false;
 
+  static get styles() {
+    return [
+      css`
+        :host {
+          display: inline-block;
+          vertical-align: top;
+        }
+        a {
+          color: var(--primary-text-color);
+          text-decoration: none;
+        }
+        gr-account-label {
+          --gr-account-label-text-hover-style: {
+            text-decoration: underline;
+          }
+        }
+      `,
+    ];
+  }
+
+  render() {
+    if (!this.account) return;
+    return html`<span>
+      <a href="${this._computeOwnerLink(this.account)}">
+        <gr-account-label
+          .account="${this.account}"
+          .change="${this.change}"
+          ?force-attention=${this.forceAttention}
+          ?highlight-attention=${this.highlightAttention}
+          ?hide-avatar=${this.hideAvatar}
+          ?hide-status=${this.hideStatus}
+          ?first-name=${this.firstName}
+          .voteable-text=${this.voteableText}
+        >
+        </gr-account-label>
+      </a>
+    </span>`;
+  }
+
   _computeOwnerLink(account?: AccountInfo) {
     if (!account) {
       return;
diff --git a/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link_html.ts b/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link_html.ts
deleted file mode 100644
index 98a6e77..0000000
--- a/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link_html.ts
+++ /dev/null
@@ -1,50 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style>
-    :host {
-      display: inline-block;
-      vertical-align: top;
-    }
-    a {
-      color: var(--primary-text-color);
-      text-decoration: none;
-    }
-    gr-account-label {
-      --gr-account-label-text-hover-style: {
-        text-decoration: underline;
-      }
-    }
-  </style>
-  <span>
-    <a href$="[[_computeOwnerLink(account)]]">
-      <gr-account-label
-        account="[[account]]"
-        change="[[change]]"
-        force-attention="[[forceAttention]]"
-        highlight-attention="[[highlightAttention]]"
-        hide-avatar="[[hideAvatar]]"
-        hide-status="[[hideStatus]]"
-        first-name="[[firstName]]"
-        voteable-text="[[voteableText]]"
-      >
-      </gr-account-label>
-    </a>
-  </span>
-`;
diff --git a/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link_test.js b/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link_test.ts
similarity index 73%
rename from polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link_test.js
rename to polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link_test.ts
index 34fef2f..c754e47 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link_test.ts
@@ -15,14 +15,17 @@
  * limitations under the License.
  */
 
-import '../../../test/common-test-setup-karma.js';
-import './gr-account-link.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
+import '../../../test/common-test-setup-karma';
+import './gr-account-link';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {GrAccountLink} from './gr-account-link';
+import {createAccountWithId} from '../../../test/test-data-generators';
+import {AccountId, AccountInfo, EmailAddress} from '../../../types/common';
 
 const basicFixture = fixtureFromElement('gr-account-link');
 
 suite('gr-account-link tests', () => {
-  let element;
+  let element: GrAccountLink;
 
   setup(() => {
     element = basicFixture.instantiate();
@@ -31,11 +34,12 @@
   test('computed fields', () => {
     const url = 'test/url';
     const urlStub = sinon.stub(GerritNav, 'getUrlForOwner').returns(url);
-    const account = {
-      email: 'email',
+    const account: AccountInfo = {
+      ...createAccountWithId(),
+      email: 'email' as EmailAddress,
       username: 'username',
       name: 'name',
-      _account_id: '_account_id',
+      _account_id: 5 as AccountId,
     };
     assert.isNotOk(element._computeOwnerLink());
     assert.equal(element._computeOwnerLink(account), url);
@@ -51,7 +55,6 @@
 
     delete account.name;
     assert.equal(element._computeOwnerLink(account), url);
-    assert.isTrue(urlStub.lastCall.calledWithExactly('_account_id'));
+    assert.isTrue(urlStub.lastCall.calledWithExactly('5'));
   });
 });
-
diff --git a/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list.ts b/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list.ts
index 130969e..d97e38e 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list.ts
+++ b/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list.ts
@@ -29,7 +29,7 @@
   EmailAddress,
 } from '../../../types/common';
 import {
-  GrReviewerSuggestionsProvider,
+  ReviewerSuggestionsProvider,
   SuggestionItem,
 } from '../../../scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider';
 import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
@@ -145,7 +145,7 @@
    * Returns suggestions and convert them to list item
    */
   @property({type: Object})
-  suggestionsProvider?: GrReviewerSuggestionsProvider;
+  suggestionsProvider?: ReviewerSuggestionsProvider;
 
   /**
    * Needed for template checking since value is initially set to null.
diff --git a/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list_test.js b/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list_test.ts
similarity index 64%
rename from polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list_test.js
rename to polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list_test.ts
index 693f4cb..b667aba 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list_test.ts
@@ -14,46 +14,78 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-
-import '../../../test/common-test-setup-karma.js';
-import './gr-account-list.js';
+import '../../../test/common-test-setup-karma';
+import './gr-account-list';
+import {
+  AccountInfoInput,
+  GrAccountList,
+  RawAccountInput,
+} from './gr-account-list';
+import {
+  AccountId,
+  AccountInfo,
+  EmailAddress,
+  GroupId,
+  GroupInfo,
+  SuggestedReviewerAccountInfo,
+  Suggestion,
+} from '../../../types/common';
+import {queryAll} from '../../../test/test-utils';
+import {ReviewerSuggestionsProvider} from '../../../scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider';
+import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
 
 const basicFixture = fixtureFromElement('gr-account-list');
 
-class MockSuggestionsProvider {
-  getSuggestions(input) {
+class MockSuggestionsProvider implements ReviewerSuggestionsProvider {
+  init() {}
+
+  getSuggestions(_: string): Promise<Suggestion[]> {
     return Promise.resolve([]);
   }
 
-  makeSuggestionItem(item) {
-    return item;
+  makeSuggestionItem(_: Suggestion) {
+    return {
+      name: 'test',
+      value: {
+        account: {
+          _account_id: 1 as AccountId,
+        } as AccountInfo,
+        count: 1,
+      } as SuggestedReviewerAccountInfo,
+    };
   }
 }
 
 suite('gr-account-list tests', () => {
   let _nextAccountId = 0;
-  const makeAccount = function() {
+  const makeAccount: () => AccountInfo = function () {
     const accountId = ++_nextAccountId;
     return {
-      _account_id: accountId,
+      _account_id: accountId as AccountId,
     };
   };
-  const makeGroup = function() {
-    const groupId = 'group' + (++_nextAccountId);
+  const makeGroup: () => GroupInfo = function () {
+    const groupId = `group${++_nextAccountId}`;
     return {
-      id: groupId,
+      id: groupId as GroupId,
       _group: true,
     };
   };
 
-  let existingAccount1;
-  let existingAccount2;
+  let existingAccount1: AccountInfo;
+  let existingAccount2: AccountInfo;
 
-  let element;
-  let suggestionsProvider;
+  let element: GrAccountList;
+  let suggestionsProvider: MockSuggestionsProvider;
 
   function getChips() {
-    return element.root.querySelectorAll('gr-account-chip');
+    return queryAll(element, 'gr-account-chip');
+  }
+
+  function handleAdd(value: RawAccountInput) {
+    element._handleAdd(
+      new CustomEvent<{value: RawAccountInput}>('add', {detail: {value}})
+    );
   }
 
   setup(() => {
@@ -84,13 +116,7 @@
 
     // New accounts are added to end with pendingAdd class.
     const newAccount = makeAccount();
-    element._handleAdd({
-      detail: {
-        value: {
-          account: newAccount,
-        },
-      },
-    });
+    handleAdd({account: newAccount});
     flush();
     chips = getChips();
     assert.equal(chips.length, 3);
@@ -100,10 +126,12 @@
 
     // Removed accounts are taken out of the list.
     element.dispatchEvent(
-        new CustomEvent('remove', {
-          detail: {account: existingAccount1},
-          composed: true, bubbles: true,
-        }));
+      new CustomEvent('remove', {
+        detail: {account: existingAccount1},
+        composed: true,
+        bubbles: true,
+      })
+    );
     flush();
     chips = getChips();
     assert.equal(chips.length, 2);
@@ -112,15 +140,19 @@
 
     // Invalid remove is ignored.
     element.dispatchEvent(
-        new CustomEvent('remove', {
-          detail: {account: existingAccount1},
-          composed: true, bubbles: true,
-        }));
+      new CustomEvent('remove', {
+        detail: {account: existingAccount1},
+        composed: true,
+        bubbles: true,
+      })
+    );
     element.dispatchEvent(
-        new CustomEvent('remove', {
-          detail: {account: newAccount},
-          composed: true, bubbles: true,
-        }));
+      new CustomEvent('remove', {
+        detail: {account: newAccount},
+        composed: true,
+        bubbles: true,
+      })
+    );
     flush();
     chips = getChips();
     assert.equal(chips.length, 1);
@@ -128,13 +160,7 @@
 
     // New groups are added to end with pendingAdd and group classes.
     const newGroup = makeGroup();
-    element._handleAdd({
-      detail: {
-        value: {
-          group: newGroup,
-        },
-      },
-    });
+    handleAdd({group: newGroup, confirm: false});
     flush();
     chips = getChips();
     assert.equal(chips.length, 2);
@@ -143,10 +169,12 @@
 
     // Removed groups are taken out of the list.
     element.dispatchEvent(
-        new CustomEvent('remove', {
-          detail: {account: newGroup},
-          composed: true, bubbles: true,
-        }));
+      new CustomEvent('remove', {
+        detail: {account: newGroup},
+        composed: true,
+        bubbles: true,
+      })
+    );
     flush();
     chips = getChips();
     assert.equal(chips.length, 1);
@@ -154,54 +182,67 @@
   });
 
   test('_getSuggestions uses filter correctly', () => {
-    const originalSuggestions = [
+    const originalSuggestions: Suggestion[] = [
       {
-        email: 'abc@example.com',
+        email: 'abc@example.com' as EmailAddress,
         text: 'abcd',
-        _account_id: 3,
-      },
+        _account_id: 3 as AccountId,
+      } as AccountInfo,
       {
-        email: 'qwe@example.com',
+        email: 'qwe@example.com' as EmailAddress,
         text: 'qwer',
-        _account_id: 1,
-      },
+        _account_id: 1 as AccountId,
+      } as AccountInfo,
       {
-        email: 'xyz@example.com',
+        email: 'xyz@example.com' as EmailAddress,
         text: 'aaaaa',
-        _account_id: 25,
-      },
+        _account_id: 25 as AccountId,
+      } as AccountInfo,
     ];
-    sinon.stub(suggestionsProvider, 'getSuggestions')
-        .returns(Promise.resolve(originalSuggestions));
-    sinon.stub(suggestionsProvider, 'makeSuggestionItem')
-        .callsFake( suggestion => {
-          return {
-            name: suggestion.email,
-            value: suggestion._account_id,
-          };
-        });
+    sinon
+      .stub(suggestionsProvider, 'getSuggestions')
+      .returns(Promise.resolve(originalSuggestions));
+    sinon
+      .stub(suggestionsProvider, 'makeSuggestionItem')
+      .callsFake(suggestion => {
+        return {
+          name: ((suggestion as AccountInfo).email as string) ?? '',
+          value: {
+            account: suggestion as AccountInfo,
+            count: 1,
+          },
+        };
+      });
 
-    return element._getSuggestions().then(suggestions => {
-      // Default is no filtering.
-      assert.equal(suggestions.length, 3);
+    return element
+      ._getSuggestions('')
+      .then(suggestions => {
+        // Default is no filtering.
+        assert.equal(suggestions.length, 3);
 
-      // Set up filter that only accepts suggestion1.
-      const accountId = originalSuggestions[0]._account_id;
-      element.filter = function(suggestion) {
-        return suggestion._account_id === accountId;
-      };
+        // Set up filter that only accepts suggestion1.
+        const accountId = (originalSuggestions[0] as AccountInfo)._account_id;
+        element.filter = function (suggestion) {
+          return (suggestion as AccountInfo)._account_id === accountId;
+        };
 
-      return element._getSuggestions();
-    })
-        .then(suggestions => {
-          assert.deepEqual(suggestions,
-              [{name: originalSuggestions[0].email,
-                value: originalSuggestions[0]._account_id}]);
-        });
+        return element._getSuggestions('');
+      })
+      .then(suggestions => {
+        assert.deepEqual(suggestions, [
+          {
+            name: (originalSuggestions[0] as AccountInfo).email as string,
+            value: {
+              account: originalSuggestions[0] as AccountInfo,
+              count: 1,
+            },
+          },
+        ]);
+      });
   });
 
   test('_computeChipClass', () => {
-    const account = makeAccount();
+    const account = makeAccount() as AccountInfoInput;
     assert.equal(element._computeChipClass(account), '');
     account._pendingAdd = true;
     assert.equal(element._computeChipClass(account), 'pendingAdd');
@@ -212,7 +253,7 @@
   });
 
   test('_computeRemovable', () => {
-    const newAccount = makeAccount();
+    const newAccount = makeAccount() as AccountInfoInput;
     newAccount._pendingAdd = true;
     element.readonly = false;
     element.removableValues = [];
@@ -250,28 +291,19 @@
     // When entry is valid, return true and clear text.
     assert.isTrue(element.submitEntryText());
     assert.isTrue(clearStub.called);
-    assert.equal(element.additions()[0].account.email, 'test@test');
+    assert.equal(
+      element.additions()[0].account?.email,
+      'test@test' as EmailAddress
+    );
   });
 
   test('additions returns sanitized new accounts and groups', () => {
     assert.equal(element.additions().length, 0);
 
     const newAccount = makeAccount();
-    element._handleAdd({
-      detail: {
-        value: {
-          account: newAccount,
-        },
-      },
-    });
+    handleAdd({account: newAccount});
     const newGroup = makeGroup();
-    element._handleAdd({
-      detail: {
-        value: {
-          group: newGroup,
-        },
-      },
-    });
+    handleAdd({group: newGroup, confirm: false});
 
     assert.deepEqual(element.additions(), [
       {
@@ -300,11 +332,7 @@
       count: 10,
       confirm: true,
     };
-    element._handleAdd({
-      detail: {
-        value: reviewer,
-      },
-    });
+    handleAdd(reviewer);
 
     assert.deepEqual(element.pendingConfirmation, reviewer);
     assert.deepEqual(element.additions(), []);
@@ -334,35 +362,30 @@
   test('max-count', () => {
     element.maxCount = 1;
     const acct = makeAccount();
-    element._handleAdd({
-      detail: {
-        value: {
-          account: acct,
-        },
-      },
-    });
+    handleAdd({account: acct});
     flush();
     assert.isTrue(element.$.entry.hasAttribute('hidden'));
   });
 
   test('enter text calls suggestions provider', async () => {
-    const suggestions = [
+    const suggestions: Suggestion[] = [
       {
-        email: 'abc@example.com',
+        email: 'abc@example.com' as EmailAddress,
         text: 'abcd',
-      },
+      } as AccountInfo,
       {
-        email: 'qwe@example.com',
+        email: 'qwe@example.com' as EmailAddress,
         text: 'qwer',
-      },
+      } as AccountInfo,
     ];
-    const getSuggestionsStub =
-        sinon.stub(suggestionsProvider, 'getSuggestions')
-            .returns(Promise.resolve(suggestions));
+    const getSuggestionsStub = sinon
+      .stub(suggestionsProvider, 'getSuggestions')
+      .returns(Promise.resolve(suggestions));
 
-    const makeSuggestionItemStub =
-        sinon.stub(suggestionsProvider, 'makeSuggestionItem')
-            .callsFake( item => item);
+    const makeSuggestionItemSpy = sinon.spy(
+      suggestionsProvider,
+      'makeSuggestionItem'
+    );
 
     const input = element.$.entry.$.input;
 
@@ -372,28 +395,29 @@
     await flush();
     assert.isTrue(getSuggestionsStub.calledOnce);
     assert.equal(getSuggestionsStub.lastCall.args[0], 'newTest');
-    assert.equal(makeSuggestionItemStub.getCalls().length, 2);
+    assert.equal(makeSuggestionItemSpy.getCalls().length, 2);
   });
 
   test('suggestion on empty', async () => {
     element.skipSuggestOnEmpty = false;
-    const suggestions = [
+    const suggestions: Suggestion[] = [
       {
-        email: 'abc@example.com',
+        email: 'abc@example.com' as EmailAddress,
         text: 'abcd',
-      },
+      } as AccountInfo,
       {
-        email: 'qwe@example.com',
+        email: 'qwe@example.com' as EmailAddress,
         text: 'qwer',
-      },
+      } as AccountInfo,
     ];
-    const getSuggestionsStub =
-        sinon.stub(suggestionsProvider, 'getSuggestions')
-            .returns(Promise.resolve(suggestions));
+    const getSuggestionsStub = sinon
+      .stub(suggestionsProvider, 'getSuggestions')
+      .returns(Promise.resolve(suggestions));
 
-    const makeSuggestionItemStub =
-        sinon.stub(suggestionsProvider, 'makeSuggestionItem')
-            .callsFake( item => item);
+    const makeSuggestionItemSpy = sinon.spy(
+      suggestionsProvider,
+      'makeSuggestionItem'
+    );
 
     const input = element.$.entry.$.input;
 
@@ -403,14 +427,14 @@
     await flush();
     assert.isTrue(getSuggestionsStub.calledOnce);
     assert.equal(getSuggestionsStub.lastCall.args[0], '');
-    assert.equal(makeSuggestionItemStub.getCalls().length, 2);
+    assert.equal(makeSuggestionItemSpy.getCalls().length, 2);
   });
 
   test('skip suggestion on empty', async () => {
     element.skipSuggestOnEmpty = true;
-    const getSuggestionsStub =
-        sinon.stub(suggestionsProvider, 'getSuggestions')
-            .returns(Promise.resolve([]));
+    const getSuggestionsStub = sinon
+      .stub(suggestionsProvider, 'getSuggestions')
+      .returns(Promise.resolve([]));
 
     const input = element.$.entry.$.input;
 
@@ -428,15 +452,18 @@
 
     test('adds emails', () => {
       const accountLen = element.accounts.length;
-      element._handleAdd({detail: {value: 'test@test'}});
+      handleAdd('test@test');
       assert.equal(element.accounts.length, accountLen + 1);
-      assert.equal(element.accounts[accountLen].email, 'test@test');
+      assert.equal(
+        (element.accounts[accountLen] as AccountInfoInput).email,
+        'test@test' as EmailAddress
+      );
     });
 
     test('toasts on invalid email', () => {
       const toastHandler = sinon.stub();
       element.addEventListener('show-alert', toastHandler);
-      element._handleAdd({detail: {value: 'test'}});
+      handleAdd('test');
       assert.isTrue(toastHandler.called);
     });
   });
@@ -449,18 +476,21 @@
       await flush();
       // Next line is a workaround for Firefox not moving cursor
       // on input field update
-      assert.equal(
-          element._getNativeInput(input.$.input).selectionStart, 0);
+      assert.equal(element._getNativeInput(input.$.input).selectionStart, 0);
       input.text = 'test';
       MockInteractions.focus(input.$.input);
       flush();
       assert.equal(element.accounts.length, 2);
       MockInteractions.pressAndReleaseKeyOn(
-          element._getNativeInput(input.$.input), 8); // Backspace
+        element._getNativeInput(input.$.input),
+        8
+      ); // Backspace
       assert.equal(element.accounts.length, 2);
       input.text = '';
       MockInteractions.pressAndReleaseKeyOn(
-          element._getNativeInput(input.$.input), 8); // Backspace
+        element._getNativeInput(input.$.input),
+        8
+      ); // Backspace
       flush();
       assert.equal(element.accounts.length, 1);
     });
@@ -490,15 +520,12 @@
       flush();
       const focusSpy = sinon.spy(element.accountChips[1], 'focus');
       const removeSpy = sinon.spy(element, 'removeAccount');
-      MockInteractions.pressAndReleaseKeyOn(
-          element.accountChips[0], 8); // Backspace
+      MockInteractions.pressAndReleaseKeyOn(element.accountChips[0], 8); // Backspace
       assert.isTrue(focusSpy.called);
       assert.isTrue(removeSpy.calledOnce);
 
-      MockInteractions.pressAndReleaseKeyOn(
-          element.accountChips[1], 46); // Delete
+      MockInteractions.pressAndReleaseKeyOn(element.accountChips[1], 46); // Delete
       assert.isTrue(removeSpy.calledTwice);
     });
   });
 });
-
diff --git a/polygerrit-ui/app/elements/shared/gr-alert/gr-alert_test.js b/polygerrit-ui/app/elements/shared/gr-alert/gr-alert_test.ts
similarity index 78%
rename from polygerrit-ui/app/elements/shared/gr-alert/gr-alert_test.js
rename to polygerrit-ui/app/elements/shared/gr-alert/gr-alert_test.ts
index 11ec496..3478a9a 100644
--- a/polygerrit-ui/app/elements/shared/gr-alert/gr-alert_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-alert/gr-alert_test.ts
@@ -15,10 +15,13 @@
  * limitations under the License.
  */
 
-import '../../../test/common-test-setup-karma.js';
-import './gr-alert.js';
+import '../../../test/common-test-setup-karma';
+import './gr-alert';
+import {GrAlert} from './gr-alert';
+import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
+
 suite('gr-alert tests', () => {
-  let element;
+  let element: GrAlert;
 
   setup(() => {
     element = document.createElement('gr-alert');
@@ -32,7 +35,7 @@
 
   test('show/hide', () => {
     assert.isNull(element.parentNode);
-    element.show();
+    element.show('Alert text');
     assert.equal(element.parentNode, document.body);
     element.updateStyles({'--gr-alert-transition-duration': '0ms'});
     element.hide();
@@ -41,11 +44,10 @@
 
   test('action event', () => {
     const spy = sinon.spy();
-    element.show();
+    element.show('Alert text');
     element._actionCallback = spy;
     assert.isFalse(spy.called);
-    MockInteractions.tap(element.shadowRoot.querySelector('.action'));
+    MockInteractions.tap(element.shadowRoot!.querySelector('.action')!);
     assert.isTrue(spy.called);
   });
 });
-
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.ts b/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.ts
index fdc72ce..73d1bf0 100644
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.ts
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.ts
@@ -93,7 +93,8 @@
     };
   }
 
-  private cursor = new GrCursorManager();
+  // visible for testing
+  cursor = new GrCursorManager();
 
   constructor() {
     super();
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown_test.js b/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown_test.ts
similarity index 77%
rename from polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown_test.js
rename to polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown_test.ts
index 200fddc..bb47dbc0 100644
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown_test.ts
@@ -14,30 +14,36 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-
-import '../../../test/common-test-setup-karma.js';
-import './gr-autocomplete-dropdown.js';
+import '../../../test/common-test-setup-karma';
+import './gr-autocomplete-dropdown';
+import {GrAutocompleteDropdown} from './gr-autocomplete-dropdown';
+import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
+import {queryAll, queryAndAssert} from '../../../test/test-utils';
+import {assertIsDefined} from '../../../utils/common-util';
 
 const basicFixture = fixtureFromElement('gr-autocomplete-dropdown');
 
 suite('gr-autocomplete-dropdown', () => {
-  let element;
+  let element: GrAutocompleteDropdown;
 
-  setup(() => {
+  const suggestionsEl = () => queryAndAssert(element, '#suggestions');
+
+  setup(async () => {
     element = basicFixture.instantiate();
     element.open();
     element.suggestions = [
-      {dataValue: 'test value 1', name: 'test name 1', text: 1, label: 'hi'},
-      {dataValue: 'test value 2', name: 'test name 2', text: 2}];
-    flush();
+      {dataValue: 'test value 1', name: 'test name 1', text: '1', label: 'hi'},
+      {dataValue: 'test value 2', name: 'test name 2', text: '2'},
+    ];
+    await flush();
   });
 
   teardown(() => {
-    if (element.isOpen) element.close();
+    element.close();
   });
 
   test('shows labels', () => {
-    const els = element.$.suggestions.querySelectorAll('li');
+    const els = queryAll<HTMLElement>(suggestionsEl(), 'li');
     assert.equal(els[0].innerText.trim(), '1\nhi');
     assert.equal(els[1].innerText.trim(), '2');
   });
@@ -106,24 +112,25 @@
     const itemSelectedStub = sinon.stub();
     element.addEventListener('item-selected', itemSelectedStub);
 
-    MockInteractions.tap(element.$.suggestions.querySelectorAll('li')[1]);
+    MockInteractions.tap(suggestionsEl().querySelectorAll('li')[1]);
     flush();
     assert.deepEqual(itemSelectedStub.lastCall.args[0].detail, {
       trigger: 'click',
-      selected: element.$.suggestions.querySelectorAll('li')[1],
+      selected: suggestionsEl().querySelectorAll('li')[1],
     });
   });
 
   test('tapping child still selects item', () => {
     const itemSelectedStub = sinon.stub();
     element.addEventListener('item-selected', itemSelectedStub);
-
-    MockInteractions.tap(element.$.suggestions.querySelectorAll('li')[0]
-        .lastElementChild);
+    const lastElChild = queryAll<HTMLElement>(suggestionsEl(), 'li')[0]
+      ?.lastElementChild;
+    assertIsDefined(lastElChild);
+    MockInteractions.tap(lastElChild);
     flush();
     assert.deepEqual(itemSelectedStub.lastCall.args[0].detail, {
       trigger: 'click',
-      selected: element.$.suggestions.querySelectorAll('li')[0],
+      selected: queryAll<HTMLElement>(suggestionsEl(), 'li')[0],
     });
   });
 
@@ -133,4 +140,3 @@
     assert.isTrue(resetStopsSpy.called);
   });
 });
-
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 1ff5350..9a7b18f 100644
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.ts
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.ts
@@ -341,7 +341,7 @@
     return this.$.suggestions.close();
   }
 
-  _computeClass(borderless: boolean) {
+  _computeClass(borderless?: boolean) {
     return borderless ? 'borderless' : '';
   }
 
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_test.js b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_test.js
deleted file mode 100644
index d72007e..0000000
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_test.js
+++ /dev/null
@@ -1,579 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../../../test/common-test-setup-karma.js';
-import './gr-autocomplete.js';
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-import {flush as flush$0} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-
-const basicFixture = fixtureFromTemplate(
-    html`<gr-autocomplete no-debounce></gr-autocomplete>`);
-
-suite('gr-autocomplete tests', () => {
-  let element;
-
-  const focusOnInput = element => {
-    MockInteractions.pressAndReleaseKeyOn(element.$.input, 13, null,
-        'enter');
-  };
-
-  setup(() => {
-    element = basicFixture.instantiate();
-  });
-
-  test('renders', () => {
-    let promise;
-    const queryStub = sinon.spy(input => promise = Promise.resolve([
-      {name: input + ' 0', value: 0},
-      {name: input + ' 1', value: 1},
-      {name: input + ' 2', value: 2},
-      {name: input + ' 3', value: 3},
-      {name: input + ' 4', value: 4},
-    ]));
-    element.query = queryStub;
-    assert.isTrue(element.$.suggestions.isHidden);
-    assert.equal(element.$.suggestions.cursor.index, -1);
-
-    focusOnInput(element);
-    element.text = 'blah';
-
-    assert.isTrue(queryStub.called);
-    element._focused = true;
-
-    return promise.then(() => {
-      assert.isFalse(element.$.suggestions.isHidden);
-      const suggestions =
-          element.$.suggestions.root.querySelectorAll('li');
-      assert.equal(suggestions.length, 5);
-
-      for (let i = 0; i < 5; i++) {
-        assert.equal(suggestions[i].innerText.trim(), 'blah ' + i);
-      }
-
-      assert.notEqual(element.$.suggestions.cursor.index, -1);
-    });
-  });
-
-  test('selectAll', async () => {
-    await flush();
-    const nativeInput = element._nativeInput;
-    const selectionStub = sinon.stub(nativeInput, 'setSelectionRange');
-
-    element.selectAll();
-    assert.isFalse(selectionStub.called);
-
-    element.$.input.value = 'test';
-    element.selectAll();
-    assert.isTrue(selectionStub.called);
-  });
-
-  test('esc key behavior', () => {
-    let promise;
-    const queryStub = sinon.spy(() => promise = Promise.resolve([
-      {name: 'blah', value: 123},
-    ]));
-    element.query = queryStub;
-
-    assert.isTrue(element.$.suggestions.isHidden);
-
-    element._focused = true;
-    element.text = 'blah';
-
-    return promise.then(() => {
-      assert.isFalse(element.$.suggestions.isHidden);
-
-      const cancelHandler = sinon.spy();
-      element.addEventListener('cancel', cancelHandler);
-
-      MockInteractions.pressAndReleaseKeyOn(element.$.input, 27, null, 'esc');
-      assert.isFalse(cancelHandler.called);
-      assert.isTrue(element.$.suggestions.isHidden);
-      assert.equal(element._suggestions.length, 0);
-
-      MockInteractions.pressAndReleaseKeyOn(element.$.input, 27, null, 'esc');
-      assert.isTrue(cancelHandler.called);
-    });
-  });
-
-  test('emits commit and handles cursor movement', () => {
-    let promise;
-    const queryStub = sinon.spy(input => promise = Promise.resolve([
-      {name: input + ' 0', value: 0},
-      {name: input + ' 1', value: 1},
-      {name: input + ' 2', value: 2},
-      {name: input + ' 3', value: 3},
-      {name: input + ' 4', value: 4},
-    ]));
-    element.query = queryStub;
-
-    assert.isTrue(element.$.suggestions.isHidden);
-    assert.equal(element.$.suggestions.cursor.index, -1);
-    element._focused = true;
-    element.text = 'blah';
-
-    return promise.then(() => {
-      assert.isFalse(element.$.suggestions.isHidden);
-
-      const commitHandler = sinon.spy();
-      element.addEventListener('commit', commitHandler);
-
-      assert.equal(element.$.suggestions.cursor.index, 0);
-
-      MockInteractions.pressAndReleaseKeyOn(element.$.input, 40, null,
-          'down');
-
-      assert.equal(element.$.suggestions.cursor.index, 1);
-
-      MockInteractions.pressAndReleaseKeyOn(element.$.input, 40, null,
-          'down');
-
-      assert.equal(element.$.suggestions.cursor.index, 2);
-
-      MockInteractions.pressAndReleaseKeyOn(element.$.input, 38, null, 'up');
-
-      assert.equal(element.$.suggestions.cursor.index, 1);
-
-      MockInteractions.pressAndReleaseKeyOn(element.$.input, 13, null,
-          'enter');
-
-      assert.equal(element.value, 1);
-      assert.isTrue(commitHandler.called);
-      assert.equal(commitHandler.getCall(0).args[0].detail.value, 1);
-      assert.isTrue(element.$.suggestions.isHidden);
-      assert.isTrue(element._focused);
-    });
-  });
-
-  test('clear-on-commit behavior (off)', () => {
-    let promise;
-    const queryStub = sinon.spy(() => {
-      promise = Promise.resolve([{name: 'suggestion', value: 0}]);
-      return promise;
-    });
-    element.query = queryStub;
-    focusOnInput(element);
-    element.text = 'blah';
-
-    return promise.then(() => {
-      const commitHandler = sinon.spy();
-      element.addEventListener('commit', commitHandler);
-
-      MockInteractions.pressAndReleaseKeyOn(element.$.input, 13, null,
-          'enter');
-
-      assert.isTrue(commitHandler.called);
-      assert.equal(element.text, 'suggestion');
-    });
-  });
-
-  test('clear-on-commit behavior (on)', () => {
-    let promise;
-    const queryStub = sinon.spy(() => {
-      promise = Promise.resolve([{name: 'suggestion', value: 0}]);
-      return promise;
-    });
-    element.query = queryStub;
-    focusOnInput(element);
-    element.text = 'blah';
-    element.clearOnCommit = true;
-
-    return promise.then(() => {
-      const commitHandler = sinon.spy();
-      element.addEventListener('commit', commitHandler);
-
-      MockInteractions.pressAndReleaseKeyOn(element.$.input, 13, null,
-          'enter');
-
-      assert.isTrue(commitHandler.called);
-      assert.equal(element.text, '');
-    });
-  });
-
-  test('threshold guards the query', () => {
-    const queryStub = sinon.spy(() => Promise.resolve([]));
-    element.query = queryStub;
-    element.threshold = 2;
-    focusOnInput(element);
-    element.text = 'a';
-    assert.isFalse(queryStub.called);
-    element.text = 'ab';
-    assert.isTrue(queryStub.called);
-  });
-
-  test('noDebounce=false debounces the query', () => {
-    const clock = sinon.useFakeTimers();
-    const queryStub = sinon.spy(() => Promise.resolve([]));
-    element.query = queryStub;
-    element.noDebounce = false;
-    focusOnInput(element);
-    element.text = 'a';
-
-    // not called right away
-    assert.isFalse(queryStub.called);
-
-    // but called after a while
-    clock.tick(1000);
-    assert.isTrue(queryStub.called);
-  });
-
-  test('_computeClass respects border property', () => {
-    assert.equal(element._computeClass(), '');
-    assert.equal(element._computeClass(false), '');
-    assert.equal(element._computeClass(true), 'borderless');
-  });
-
-  test('undefined or empty text results in no suggestions', () => {
-    element._updateSuggestions(undefined, 0, null);
-    assert.equal(element._suggestions.length, 0);
-  });
-
-  test('when focused', () => {
-    let promise;
-    const queryStub = sinon.stub()
-        .returns(promise = Promise.resolve([
-          {name: 'suggestion', value: 0},
-        ]));
-    element.query = queryStub;
-    element.suggestOnlyWhenFocus = true;
-    focusOnInput(element);
-    element.text = 'bla';
-    assert.equal(element._focused, true);
-    flush();
-    return promise.then(() => {
-      assert.equal(element._suggestions.length, 1);
-      assert.equal(queryStub.notCalled, false);
-    });
-  });
-
-  test('when not focused', () => {
-    let promise;
-    const queryStub = sinon.stub()
-        .returns(promise = Promise.resolve([
-          {name: 'suggestion', value: 0},
-        ]));
-    element.query = queryStub;
-    element.suggestOnlyWhenFocus = true;
-    element.text = 'bla';
-    assert.equal(element._focused, false);
-    flush();
-    return promise.then(() => {
-      assert.equal(element._suggestions.length, 0);
-    });
-  });
-
-  test('suggestions should not carry over', () => {
-    let promise;
-    const queryStub = sinon.stub()
-        .returns(promise = Promise.resolve([
-          {name: 'suggestion', value: 0},
-        ]));
-    element.query = queryStub;
-    focusOnInput(element);
-    element.text = 'bla';
-    flush();
-    return promise.then(() => {
-      assert.equal(element._suggestions.length, 1);
-      element._updateSuggestions('', 0, false);
-      assert.equal(element._suggestions.length, 0);
-    });
-  });
-
-  test('multi completes only the last part of the query', () => {
-    let promise;
-    const queryStub = sinon.stub()
-        .returns(promise = Promise.resolve([
-          {name: 'suggestion', value: 0},
-        ]));
-    element.query = queryStub;
-    focusOnInput(element);
-    element.text = 'blah blah';
-    element.multi = true;
-
-    return promise.then(() => {
-      const commitHandler = sinon.spy();
-      element.addEventListener('commit', commitHandler);
-
-      MockInteractions.pressAndReleaseKeyOn(element.$.input, 13, null,
-          'enter');
-
-      assert.isTrue(commitHandler.called);
-      assert.equal(element.text, 'blah 0');
-    });
-  });
-
-  test('tabComplete flag functions', () => {
-    // commitHandler checks for the commit event, whereas commitSpy checks for
-    // the _commit function of the element.
-    const commitHandler = sinon.spy();
-    element.addEventListener('commit', commitHandler);
-    const commitSpy = sinon.spy(element, '_commit');
-    element._focused = true;
-
-    element._suggestions = ['tunnel snakes rule!'];
-    element.tabComplete = false;
-    MockInteractions.pressAndReleaseKeyOn(element.$.input, 9, null, 'tab');
-    assert.isFalse(commitHandler.called);
-    assert.isFalse(commitSpy.called);
-    assert.isFalse(element._focused);
-
-    element.tabComplete = true;
-    element._focused = true;
-    MockInteractions.pressAndReleaseKeyOn(element.$.input, 9, null, 'tab');
-    assert.isFalse(commitHandler.called);
-    assert.isTrue(commitSpy.called);
-    assert.isTrue(element._focused);
-  });
-
-  test('_focused flag properly triggered', () => {
-    flush();
-    assert.isFalse(element._focused);
-    const input = element.shadowRoot
-        .querySelector('paper-input').inputElement;
-    MockInteractions.focus(input);
-    assert.isTrue(element._focused);
-  });
-
-  test('search icon shows with showSearchIcon property', () => {
-    flush();
-    assert.equal(getComputedStyle(element.shadowRoot
-        .querySelector('iron-icon')).display,
-    'none');
-    element.showSearchIcon = true;
-    assert.notEqual(getComputedStyle(element.shadowRoot
-        .querySelector('iron-icon')).display,
-    'none');
-  });
-
-  test('vertical offset overridden by param if it exists', () => {
-    assert.equal(element.$.suggestions.verticalOffset, 31);
-    element.verticalOffset = 30;
-    assert.equal(element.$.suggestions.verticalOffset, 30);
-  });
-
-  test('_focused flag shows/hides the suggestions', () => {
-    const openStub = sinon.stub(element.$.suggestions, 'open');
-    const closedStub = sinon.stub(element.$.suggestions, 'close');
-    element._suggestions = ['hello', 'its me'];
-    assert.isFalse(openStub.called);
-    assert.isTrue(closedStub.calledOnce);
-    element._focused = true;
-    assert.isTrue(openStub.calledOnce);
-    element._suggestions = [];
-    assert.isTrue(closedStub.calledTwice);
-    assert.isTrue(openStub.calledOnce);
-  });
-
-  test('_handleInputCommit with autocomplete hidden does nothing without' +
-        'without allowNonSuggestedValues', () => {
-    const commitStub = sinon.stub(element, '_commit');
-    element.$.suggestions.isHidden = true;
-    element._handleInputCommit();
-    assert.isFalse(commitStub.called);
-  });
-
-  test('_handleInputCommit with autocomplete hidden with' +
-        'allowNonSuggestedValues', () => {
-    const commitStub = sinon.stub(element, '_commit');
-    element.allowNonSuggestedValues = true;
-    element.$.suggestions.isHidden = true;
-    element._handleInputCommit();
-    assert.isTrue(commitStub.called);
-  });
-
-  test('_handleInputCommit with autocomplete open calls commit', () => {
-    const commitStub = sinon.stub(element, '_commit');
-    element.$.suggestions.isHidden = false;
-    element._handleInputCommit();
-    assert.isTrue(commitStub.calledOnce);
-  });
-
-  test('_handleInputCommit with autocomplete open calls commit' +
-        'with allowNonSuggestedValues', () => {
-    const commitStub = sinon.stub(element, '_commit');
-    element.allowNonSuggestedValues = true;
-    element.$.suggestions.isHidden = false;
-    element._handleInputCommit();
-    assert.isTrue(commitStub.calledOnce);
-  });
-
-  test('issue 8655', () => {
-    function makeSuggestion(s) { return {name: s, text: s, value: s}; }
-    const keydownSpy = sinon.spy(element, '_handleKeydown');
-    element.setText('file:');
-    element._suggestions =
-        [makeSuggestion('file:'), makeSuggestion('-file:')];
-    MockInteractions.pressAndReleaseKeyOn(element.$.input, 88, null, 'x');
-    // Must set the value, because the MockInteraction does not.
-    element.$.input.value = 'file:x';
-    assert.isTrue(keydownSpy.calledOnce);
-    MockInteractions.pressAndReleaseKeyOn(
-        element.$.input,
-        13,
-        null,
-        'enter'
-    );
-    assert.isTrue(keydownSpy.calledTwice);
-    assert.equal(element.text, 'file:x');
-  });
-
-  suite('focus', () => {
-    let commitSpy;
-    let focusSpy;
-
-    setup(() => {
-      commitSpy = sinon.spy(element, '_commit');
-    });
-
-    test('enter does not call focus', () => {
-      element._suggestions = ['sugar bombs'];
-      focusSpy = sinon.spy(element, 'focus');
-      MockInteractions.pressAndReleaseKeyOn(element.$.input, 13, null,
-          'enter');
-      flush();
-
-      assert.isTrue(commitSpy.called);
-      assert.isFalse(focusSpy.called);
-      assert.equal(element._suggestions.length, 0);
-    });
-
-    test('tab in input, tabComplete = true', () => {
-      focusSpy = sinon.spy(element, 'focus');
-      const commitHandler = sinon.stub();
-      element.addEventListener('commit', commitHandler);
-      element.tabComplete = true;
-      element._suggestions = ['tunnel snakes drool'];
-      MockInteractions.pressAndReleaseKeyOn(element.$.input, 9, null, 'tab');
-      flush();
-
-      assert.isTrue(commitSpy.called);
-      assert.isTrue(focusSpy.called);
-      assert.isFalse(commitHandler.called);
-      assert.equal(element._suggestions.length, 0);
-    });
-
-    test('tab in input, tabComplete = false', () => {
-      element._suggestions = ['sugar bombs'];
-      focusSpy = sinon.spy(element, 'focus');
-      MockInteractions.pressAndReleaseKeyOn(element.$.input, 9, null, 'tab');
-      flush();
-
-      assert.isFalse(commitSpy.called);
-      assert.isFalse(focusSpy.called);
-      assert.equal(element._suggestions.length, 1);
-    });
-
-    test('tab on suggestion, tabComplete = false', () => {
-      element._suggestions = [{name: 'sugar bombs'}];
-      element._focused = true;
-      // When tabComplete is false, do not focus.
-      element.tabComplete = false;
-      focusSpy = sinon.spy(element, 'focus');
-      flush$0();
-      assert.isFalse(element.$.suggestions.isHidden);
-
-      MockInteractions.pressAndReleaseKeyOn(
-          element.$.suggestions.shadowRoot
-              .querySelector('li:first-child'), 9, null, 'tab');
-      flush();
-      assert.isFalse(commitSpy.called);
-      assert.isFalse(element._focused);
-    });
-
-    test('tab on suggestion, tabComplete = true', () => {
-      element._suggestions = [{name: 'sugar bombs'}];
-      element._focused = true;
-      // When tabComplete is true, focus.
-      element.tabComplete = true;
-      focusSpy = sinon.spy(element, 'focus');
-      flush$0();
-      assert.isFalse(element.$.suggestions.isHidden);
-
-      MockInteractions.pressAndReleaseKeyOn(
-          element.$.suggestions.shadowRoot
-              .querySelector('li:first-child'), 9, null, 'tab');
-      flush();
-
-      assert.isTrue(commitSpy.called);
-      assert.isTrue(element._focused);
-    });
-
-    test('tap on suggestion commits, does not call focus', () => {
-      focusSpy = sinon.spy(element, 'focus');
-      element._focused = true;
-      element._suggestions = [{name: 'first suggestion'}];
-      flush$0();
-      assert.isFalse(element.$.suggestions.isHidden);
-      MockInteractions.tap(element.$.suggestions.shadowRoot
-          .querySelector('li:first-child'));
-      flush();
-
-      assert.isFalse(focusSpy.called);
-      assert.isTrue(commitSpy.called);
-      assert.isTrue(element.$.suggestions.isHidden);
-    });
-  });
-
-  test('input-keydown event fired', () => {
-    const listener = sinon.spy();
-    element.addEventListener('input-keydown', listener);
-    MockInteractions.pressAndReleaseKeyOn(element.$.input, 9, null, 'tab');
-    flush();
-    assert.isTrue(listener.called);
-  });
-
-  test('enter with modifier does not complete', () => {
-    const handleSpy = sinon.spy(element, '_handleKeydown');
-    const commitStub = sinon.stub(element, '_handleInputCommit');
-    MockInteractions.pressAndReleaseKeyOn(
-        element.$.input, 13, 'ctrl', 'enter');
-    assert.isTrue(handleSpy.called);
-    assert.isFalse(commitStub.called);
-    MockInteractions.pressAndReleaseKeyOn(
-        element.$.input, 13, null, 'enter');
-    assert.isTrue(commitStub.called);
-  });
-
-  suite('warnUncommitted', () => {
-    let inputClassList;
-    setup(() => {
-      inputClassList = element.$.input.classList;
-    });
-
-    test('enabled', () => {
-      element.warnUncommitted = true;
-      element.text = 'blah blah blah';
-      MockInteractions.blur(element.$.input);
-      assert.isTrue(inputClassList.contains('warnUncommitted'));
-      MockInteractions.focus(element.$.input);
-      assert.isFalse(inputClassList.contains('warnUncommitted'));
-    });
-
-    test('disabled', () => {
-      element.warnUncommitted = false;
-      element.text = 'blah blah blah';
-      MockInteractions.blur(element.$.input);
-      assert.isFalse(inputClassList.contains('warnUncommitted'));
-    });
-
-    test('no text', () => {
-      element.warnUncommitted = true;
-      element.text = '';
-      MockInteractions.blur(element.$.input);
-      assert.isFalse(inputClassList.contains('warnUncommitted'));
-    });
-  });
-});
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_test.ts b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_test.ts
new file mode 100644
index 0000000..6fe5e15
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_test.ts
@@ -0,0 +1,619 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import '../../../test/common-test-setup-karma';
+import './gr-autocomplete';
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+import {flush as flush$0} from '@polymer/polymer/lib/legacy/polymer.dom';
+import {AutocompleteSuggestion, GrAutocomplete} from './gr-autocomplete';
+import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
+import {assertIsDefined} from '../../../utils/common-util';
+import {queryAll, queryAndAssert} from '../../../test/test-utils';
+import {GrAutocompleteDropdown} from '../gr-autocomplete-dropdown/gr-autocomplete-dropdown';
+import {PaperInputElement} from '@polymer/paper-input/paper-input';
+
+const basicFixture = fixtureFromTemplate(
+  html`<gr-autocomplete no-debounce></gr-autocomplete>`
+);
+
+suite('gr-autocomplete tests', () => {
+  let element: GrAutocomplete;
+
+  const focusOnInput = () => {
+    MockInteractions.pressAndReleaseKeyOn(inputEl(), 13, null, 'enter');
+  };
+
+  const suggestionsEl = () =>
+    queryAndAssert<GrAutocompleteDropdown>(element, '#suggestions');
+
+  const inputEl = () => queryAndAssert<HTMLInputElement>(element, '#input');
+
+  setup(() => {
+    element = basicFixture.instantiate() as GrAutocomplete;
+  });
+
+  test('renders', () => {
+    let promise: Promise<AutocompleteSuggestion[]> = Promise.resolve([]);
+    const queryStub = sinon.spy(
+      (input: string) =>
+        (promise = Promise.resolve([
+          {name: input + ' 0', value: '0'},
+          {name: input + ' 1', value: '1'},
+          {name: input + ' 2', value: '2'},
+          {name: input + ' 3', value: '3'},
+          {name: input + ' 4', value: '4'},
+        ] as AutocompleteSuggestion[]))
+    );
+    element.query = queryStub;
+    assert.isTrue(suggestionsEl().isHidden);
+    assert.equal(suggestionsEl().cursor.index, -1);
+
+    focusOnInput();
+    element.text = 'blah';
+
+    assert.isTrue(queryStub.called);
+    element._focused = true;
+
+    assertIsDefined(promise);
+    return promise.then(() => {
+      assert.isFalse(suggestionsEl().isHidden);
+      const suggestions = queryAll<HTMLElement>(suggestionsEl(), 'li');
+      assert.equal(suggestions.length, 5);
+
+      for (let i = 0; i < 5; i++) {
+        assert.equal(suggestions[i].innerText.trim(), `blah ${i}`);
+      }
+
+      assert.notEqual(suggestionsEl().cursor.index, -1);
+    });
+  });
+
+  test('selectAll', async () => {
+    await flush();
+    const nativeInput = element._nativeInput;
+    const selectionStub = sinon.stub(nativeInput, 'setSelectionRange');
+
+    element.selectAll();
+    assert.isFalse(selectionStub.called);
+
+    inputEl().value = 'test';
+    element.selectAll();
+    assert.isTrue(selectionStub.called);
+  });
+
+  test('esc key behavior', () => {
+    let promise: Promise<AutocompleteSuggestion[]> = Promise.resolve([]);
+    const queryStub = sinon.spy(
+      (_: string) =>
+        (promise = Promise.resolve([
+          {name: 'blah', value: '123'},
+        ] as AutocompleteSuggestion[]))
+    );
+    element.query = queryStub;
+
+    assert.isTrue(suggestionsEl().isHidden);
+
+    element._focused = true;
+    element.text = 'blah';
+
+    return promise.then(() => {
+      assert.isFalse(suggestionsEl().isHidden);
+
+      const cancelHandler = sinon.spy();
+      element.addEventListener('cancel', cancelHandler);
+
+      MockInteractions.pressAndReleaseKeyOn(inputEl(), 27, null, 'esc');
+      assert.isFalse(cancelHandler.called);
+      assert.isTrue(suggestionsEl().isHidden);
+      assert.equal(element._suggestions.length, 0);
+
+      MockInteractions.pressAndReleaseKeyOn(inputEl(), 27, null, 'esc');
+      assert.isTrue(cancelHandler.called);
+    });
+  });
+
+  test('emits commit and handles cursor movement', () => {
+    let promise: Promise<AutocompleteSuggestion[]> = Promise.resolve([]);
+    const queryStub = sinon.spy(
+      (input: string) =>
+        (promise = Promise.resolve([
+          {name: input + ' 0', value: '0'},
+          {name: input + ' 1', value: '1'},
+          {name: input + ' 2', value: '2'},
+          {name: input + ' 3', value: '3'},
+          {name: input + ' 4', value: '4'},
+        ] as AutocompleteSuggestion[]))
+    );
+    element.query = queryStub;
+
+    assert.isTrue(suggestionsEl().isHidden);
+    assert.equal(suggestionsEl().cursor.index, -1);
+    element._focused = true;
+    element.text = 'blah';
+
+    return promise.then(() => {
+      assert.isFalse(suggestionsEl().isHidden);
+
+      const commitHandler = sinon.spy();
+      element.addEventListener('commit', commitHandler);
+
+      assert.equal(suggestionsEl().cursor.index, 0);
+
+      MockInteractions.pressAndReleaseKeyOn(inputEl(), 40, null, 'down');
+
+      assert.equal(suggestionsEl().cursor.index, 1);
+
+      MockInteractions.pressAndReleaseKeyOn(inputEl(), 40, null, 'down');
+
+      assert.equal(suggestionsEl().cursor.index, 2);
+
+      MockInteractions.pressAndReleaseKeyOn(inputEl(), 38, null, 'up');
+
+      assert.equal(suggestionsEl().cursor.index, 1);
+
+      MockInteractions.pressAndReleaseKeyOn(inputEl(), 13, null, 'enter');
+
+      assert.equal(element.value, '1');
+      assert.isTrue(commitHandler.called);
+      assert.equal(commitHandler.getCall(0).args[0].detail.value, 1);
+      assert.isTrue(suggestionsEl().isHidden);
+      assert.isTrue(element._focused);
+    });
+  });
+
+  test('clear-on-commit behavior (off)', () => {
+    let promise: Promise<AutocompleteSuggestion[]> = Promise.resolve([]);
+    const queryStub = sinon.spy(() => {
+      promise = Promise.resolve([
+        {name: 'suggestion', value: '0'},
+      ] as AutocompleteSuggestion[]);
+      return promise;
+    });
+    element.query = queryStub;
+    focusOnInput();
+    element.text = 'blah';
+
+    return promise.then(() => {
+      const commitHandler = sinon.spy();
+      element.addEventListener('commit', commitHandler);
+
+      MockInteractions.pressAndReleaseKeyOn(inputEl(), 13, null, 'enter');
+
+      assert.isTrue(commitHandler.called);
+      assert.equal(element.text, 'suggestion');
+    });
+  });
+
+  test('clear-on-commit behavior (on)', () => {
+    let promise: Promise<AutocompleteSuggestion[]> = Promise.resolve([]);
+    const queryStub = sinon.spy(() => {
+      promise = Promise.resolve([
+        {name: 'suggestion', value: '0'},
+      ] as AutocompleteSuggestion[]);
+      return promise;
+    });
+    element.query = queryStub;
+    focusOnInput();
+    element.text = 'blah';
+    element.clearOnCommit = true;
+
+    return promise.then(() => {
+      const commitHandler = sinon.spy();
+      element.addEventListener('commit', commitHandler);
+
+      MockInteractions.pressAndReleaseKeyOn(inputEl(), 13, null, 'enter');
+
+      assert.isTrue(commitHandler.called);
+      assert.equal(element.text, '');
+    });
+  });
+
+  test('threshold guards the query', () => {
+    const queryStub = sinon.spy(() =>
+      Promise.resolve([] as AutocompleteSuggestion[])
+    );
+    element.query = queryStub;
+    element.threshold = 2;
+    focusOnInput();
+    element.text = 'a';
+    assert.isFalse(queryStub.called);
+    element.text = 'ab';
+    assert.isTrue(queryStub.called);
+  });
+
+  test('noDebounce=false debounces the query', () => {
+    const clock = sinon.useFakeTimers();
+    const queryStub = sinon.spy(() =>
+      Promise.resolve([] as AutocompleteSuggestion[])
+    );
+    element.query = queryStub;
+    element.noDebounce = false;
+    focusOnInput();
+    element.text = 'a';
+
+    // not called right away
+    assert.isFalse(queryStub.called);
+
+    // but called after a while
+    clock.tick(1000);
+    assert.isTrue(queryStub.called);
+  });
+
+  test('_computeClass respects border property', () => {
+    assert.equal(element._computeClass(), '');
+    assert.equal(element._computeClass(false), '');
+    assert.equal(element._computeClass(true), 'borderless');
+  });
+
+  test('undefined or empty text results in no suggestions', () => {
+    element._updateSuggestions(undefined, 0, undefined);
+    assert.equal(element._suggestions.length, 0);
+  });
+
+  test('when focused', () => {
+    let promise: Promise<AutocompleteSuggestion[]> = Promise.resolve([]);
+    const queryStub = sinon
+      .stub()
+      .returns(
+        (promise = Promise.resolve([
+          {name: 'suggestion', value: '0'},
+        ] as AutocompleteSuggestion[]))
+      );
+    element.query = queryStub;
+    focusOnInput();
+    element.text = 'bla';
+    assert.equal(element._focused, true);
+    flush();
+    return promise.then(() => {
+      assert.equal(element._suggestions.length, 1);
+      assert.equal(queryStub.notCalled, false);
+    });
+  });
+
+  test('when not focused', () => {
+    let promise: Promise<AutocompleteSuggestion[]> = Promise.resolve([]);
+    const queryStub = sinon
+      .stub()
+      .returns(
+        (promise = Promise.resolve([
+          {name: 'suggestion', value: '0'},
+        ] as AutocompleteSuggestion[]))
+      );
+    element.query = queryStub;
+    element.text = 'bla';
+    assert.equal(element._focused, false);
+    flush();
+    return promise.then(() => {
+      assert.equal(element._suggestions.length, 0);
+    });
+  });
+
+  test('suggestions should not carry over', () => {
+    let promise: Promise<AutocompleteSuggestion[]> = Promise.resolve([]);
+    const queryStub = sinon
+      .stub()
+      .returns(
+        (promise = Promise.resolve([
+          {name: 'suggestion', value: '0'},
+        ] as AutocompleteSuggestion[]))
+      );
+    element.query = queryStub;
+    focusOnInput();
+    element.text = 'bla';
+    flush();
+    return promise.then(() => {
+      assert.equal(element._suggestions.length, 1);
+      element._updateSuggestions('', 0, false);
+      assert.equal(element._suggestions.length, 0);
+    });
+  });
+
+  test('multi completes only the last part of the query', () => {
+    let promise;
+    const queryStub = sinon
+      .stub()
+      .returns(
+        (promise = Promise.resolve([
+          {name: 'suggestion', value: '0'},
+        ] as AutocompleteSuggestion[]))
+      );
+    element.query = queryStub;
+    focusOnInput();
+    element.text = 'blah blah';
+    element.multi = true;
+
+    return promise.then(() => {
+      const commitHandler = sinon.spy();
+      element.addEventListener('commit', commitHandler);
+
+      MockInteractions.pressAndReleaseKeyOn(inputEl(), 13, null, 'enter');
+
+      assert.isTrue(commitHandler.called);
+      assert.equal(element.text, 'blah 0');
+    });
+  });
+
+  test('tabComplete flag functions', () => {
+    // commitHandler checks for the commit event, whereas commitSpy checks for
+    // the _commit function of the element.
+    const commitHandler = sinon.spy();
+    element.addEventListener('commit', commitHandler);
+    const commitSpy = sinon.spy(element, '_commit');
+    element._focused = true;
+
+    element._suggestions = [{text: 'tunnel snakes rule!'}];
+    element.tabComplete = false;
+    MockInteractions.pressAndReleaseKeyOn(inputEl(), 9, null, 'tab');
+    assert.isFalse(commitHandler.called);
+    assert.isFalse(commitSpy.called);
+    assert.isFalse(element._focused);
+
+    element.tabComplete = true;
+    element._focused = true;
+    MockInteractions.pressAndReleaseKeyOn(inputEl(), 9, null, 'tab');
+    assert.isFalse(commitHandler.called);
+    assert.isTrue(commitSpy.called);
+    assert.isTrue(element._focused);
+  });
+
+  test('_focused flag properly triggered', () => {
+    flush();
+    assert.isFalse(element._focused);
+    const input = queryAndAssert<PaperInputElement>(element, 'paper-input')
+      .inputElement;
+    MockInteractions.focus(input);
+    assert.isTrue(element._focused);
+  });
+
+  test('search icon shows with showSearchIcon property', () => {
+    flush();
+    assert.equal(
+      getComputedStyle(queryAndAssert(element, 'iron-icon')).display,
+      'none'
+    );
+    element.showSearchIcon = true;
+    assert.notEqual(
+      getComputedStyle(queryAndAssert(element, 'iron-icon')).display,
+      'none'
+    );
+  });
+
+  test('vertical offset overridden by param if it exists', () => {
+    assert.equal(suggestionsEl().verticalOffset, 31);
+    element.verticalOffset = 30;
+    assert.equal(suggestionsEl().verticalOffset, 30);
+  });
+
+  test('_focused flag shows/hides the suggestions', () => {
+    const openStub = sinon.stub(suggestionsEl(), 'open');
+    const closedStub = sinon.stub(suggestionsEl(), 'close');
+    element._suggestions = [{text: 'hello'}, {text: 'its me'}];
+    assert.isFalse(openStub.called);
+    assert.isTrue(closedStub.calledOnce);
+    element._focused = true;
+    assert.isTrue(openStub.calledOnce);
+    element._suggestions = [];
+    assert.isTrue(closedStub.calledTwice);
+    assert.isTrue(openStub.calledOnce);
+  });
+
+  test(
+    '_handleInputCommit with autocomplete hidden does nothing without' +
+      'without allowNonSuggestedValues',
+    () => {
+      const commitStub = sinon.stub(element, '_commit');
+      suggestionsEl().isHidden = true;
+      element._handleInputCommit();
+      assert.isFalse(commitStub.called);
+    }
+  );
+
+  test(
+    '_handleInputCommit with autocomplete hidden with' +
+      'allowNonSuggestedValues',
+    () => {
+      const commitStub = sinon.stub(element, '_commit');
+      element.allowNonSuggestedValues = true;
+      suggestionsEl().isHidden = true;
+      element._handleInputCommit();
+      assert.isTrue(commitStub.called);
+    }
+  );
+
+  test('_handleInputCommit with autocomplete open calls commit', () => {
+    const commitStub = sinon.stub(element, '_commit');
+    suggestionsEl().isHidden = false;
+    element._handleInputCommit();
+    assert.isTrue(commitStub.calledOnce);
+  });
+
+  test(
+    '_handleInputCommit with autocomplete open calls commit' +
+      'with allowNonSuggestedValues',
+    () => {
+      const commitStub = sinon.stub(element, '_commit');
+      element.allowNonSuggestedValues = true;
+      suggestionsEl().isHidden = false;
+      element._handleInputCommit();
+      assert.isTrue(commitStub.calledOnce);
+    }
+  );
+
+  test('issue 8655', () => {
+    function makeSuggestion(s: string) {
+      return {name: s, text: s, value: s};
+    }
+    const keydownSpy = sinon.spy(element, '_handleKeydown');
+    element.setText('file:');
+    element._suggestions = [makeSuggestion('file:'), makeSuggestion('-file:')];
+    MockInteractions.pressAndReleaseKeyOn(inputEl(), 88, null, 'x');
+    // Must set the value, because the MockInteraction does not.
+    inputEl().value = 'file:x';
+    assert.isTrue(keydownSpy.calledOnce);
+    MockInteractions.pressAndReleaseKeyOn(inputEl(), 13, null, 'enter');
+    assert.isTrue(keydownSpy.calledTwice);
+    assert.equal(element.text, 'file:x');
+  });
+
+  suite('focus', () => {
+    let commitSpy: sinon.SinonSpy;
+    let focusSpy: sinon.SinonSpy;
+
+    setup(() => {
+      commitSpy = sinon.spy(element, '_commit');
+    });
+
+    test('enter does not call focus', () => {
+      element._suggestions = [{text: 'sugar bombs'}];
+      focusSpy = sinon.spy(element, 'focus');
+      MockInteractions.pressAndReleaseKeyOn(inputEl(), 13, null, 'enter');
+      flush();
+
+      assert.isTrue(commitSpy.called);
+      assert.isFalse(focusSpy.called);
+      assert.equal(element._suggestions.length, 0);
+    });
+
+    test('tab in input, tabComplete = true', () => {
+      focusSpy = sinon.spy(element, 'focus');
+      const commitHandler = sinon.stub();
+      element.addEventListener('commit', commitHandler);
+      element.tabComplete = true;
+      element._suggestions = [{text: 'tunnel snakes drool'}];
+      MockInteractions.pressAndReleaseKeyOn(inputEl(), 9, null, 'tab');
+      flush();
+
+      assert.isTrue(commitSpy.called);
+      assert.isTrue(focusSpy.called);
+      assert.isFalse(commitHandler.called);
+      assert.equal(element._suggestions.length, 0);
+    });
+
+    test('tab in input, tabComplete = false', () => {
+      element._suggestions = [{text: 'sugar bombs'}];
+      focusSpy = sinon.spy(element, 'focus');
+      MockInteractions.pressAndReleaseKeyOn(inputEl(), 9, null, 'tab');
+      flush();
+
+      assert.isFalse(commitSpy.called);
+      assert.isFalse(focusSpy.called);
+      assert.equal(element._suggestions.length, 1);
+    });
+
+    test('tab on suggestion, tabComplete = false', () => {
+      element._suggestions = [{name: 'sugar bombs'}];
+      element._focused = true;
+      // When tabComplete is false, do not focus.
+      element.tabComplete = false;
+      focusSpy = sinon.spy(element, 'focus');
+      flush$0();
+      assert.isFalse(suggestionsEl().isHidden);
+
+      MockInteractions.pressAndReleaseKeyOn(
+        queryAndAssert(suggestionsEl(), 'li:first-child'),
+        9,
+        null,
+        'tab'
+      );
+      flush();
+      assert.isFalse(commitSpy.called);
+      assert.isFalse(element._focused);
+    });
+
+    test('tab on suggestion, tabComplete = true', () => {
+      element._suggestions = [{name: 'sugar bombs'}];
+      element._focused = true;
+      // When tabComplete is true, focus.
+      element.tabComplete = true;
+      focusSpy = sinon.spy(element, 'focus');
+      flush$0();
+      assert.isFalse(suggestionsEl().isHidden);
+
+      MockInteractions.pressAndReleaseKeyOn(
+        queryAndAssert(suggestionsEl(), 'li:first-child'),
+        9,
+        null,
+        'tab'
+      );
+      flush();
+
+      assert.isTrue(commitSpy.called);
+      assert.isTrue(element._focused);
+    });
+
+    test('tap on suggestion commits, does not call focus', () => {
+      focusSpy = sinon.spy(element, 'focus');
+      element._focused = true;
+      element._suggestions = [{name: 'first suggestion'}];
+      flush$0();
+      assert.isFalse(suggestionsEl().isHidden);
+      MockInteractions.tap(queryAndAssert(suggestionsEl(), 'li:first-child'));
+      flush();
+
+      assert.isFalse(focusSpy.called);
+      assert.isTrue(commitSpy.called);
+      assert.isTrue(suggestionsEl().isHidden);
+    });
+  });
+
+  test('input-keydown event fired', () => {
+    const listener = sinon.spy();
+    element.addEventListener('input-keydown', listener);
+    MockInteractions.pressAndReleaseKeyOn(inputEl(), 9, null, 'tab');
+    flush();
+    assert.isTrue(listener.called);
+  });
+
+  test('enter with modifier does not complete', () => {
+    const handleSpy = sinon.spy(element, '_handleKeydown');
+    const commitStub = sinon.stub(element, '_handleInputCommit');
+    MockInteractions.pressAndReleaseKeyOn(inputEl(), 13, 'ctrl', 'enter');
+    assert.isTrue(handleSpy.called);
+    assert.isFalse(commitStub.called);
+    MockInteractions.pressAndReleaseKeyOn(inputEl(), 13, null, 'enter');
+    assert.isTrue(commitStub.called);
+  });
+
+  suite('warnUncommitted', () => {
+    let inputClassList: DOMTokenList;
+    setup(() => {
+      inputClassList = inputEl().classList;
+    });
+
+    test('enabled', () => {
+      element.warnUncommitted = true;
+      element.text = 'blah blah blah';
+      MockInteractions.blur(inputEl());
+      assert.isTrue(inputClassList.contains('warnUncommitted'));
+      MockInteractions.focus(inputEl());
+      assert.isFalse(inputClassList.contains('warnUncommitted'));
+    });
+
+    test('disabled', () => {
+      element.warnUncommitted = false;
+      element.text = 'blah blah blah';
+      MockInteractions.blur(inputEl());
+      assert.isFalse(inputClassList.contains('warnUncommitted'));
+    });
+
+    test('no text', () => {
+      element.warnUncommitted = true;
+      element.text = '';
+      MockInteractions.blur(inputEl());
+      assert.isFalse(inputClassList.contains('warnUncommitted'));
+    });
+  });
+});
diff --git a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.ts b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.ts
index 2eeb12d..d068cea 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.ts
@@ -44,7 +44,7 @@
   tap,
   pressAndReleaseKeyOn,
 } from '@polymer/iron-test-helpers/mock-interactions';
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+import {html} from '@polymer/polymer/lib/utils/html-tag';
 import {stubRestApi, stubStorage} from '../../../test/test-utils';
 
 const basicFixture = fixtureFromElement('gr-comment-thread');
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
index 2d20510..124d71e 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
@@ -912,15 +912,13 @@
     if (this.changeNum === undefined || this.patchNum === undefined) {
       throw new Error('undefined changeNum or patchNum');
     }
-    this._showStartRequest();
+    fireAlert(this, 'Discarding draft...');
     if (!draft.id) throw new Error('Missing id in comment draft.');
     return this.restApiService
       .deleteDiffDraft(this.changeNum, this.patchNum, {id: draft.id})
       .then(result => {
         if (result.ok) {
-          this._showEndRequest();
-        } else {
-          this._handleFailedDraftRequest();
+          fireAlert(this, 'Draft successfully discarded');
         }
         return result;
       });
diff --git a/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.ts b/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.ts
index 8968e18..519c24d 100644
--- a/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.ts
+++ b/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.ts
@@ -148,8 +148,10 @@
       <g id="playArrow"><path d="M0 0h24v24H0z" fill="none"/><path d="M8 5v14l11-7z"/></g>
       <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material%20Icons%3Apause-->
       <g id="pause"><path d="M0 0h24v24H0z" fill="none"/><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/></g>
-      <!-- This SVG is a copy from material.io https://material.io/icons/#code-->
+      <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material%20Icons%3Acode-->
       <g id="code"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M9.4 16.6L4.8 12l4.6-4.6L8 6l-6 6 6 6 1.4-1.4zm5.2 0l4.6-4.6-4.6-4.6L16 6l6 6-6 6-1.4-1.4z"/></g>
+      <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material%20Icons%3Afile_present-->
+      <g id="file-present"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M15 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V7l-5-5zM6 20V4h8v4h4v12H6zm10-10v5c0 2.21-1.79 4-4 4s-4-1.79-4-4V8.5c0-1.47 1.26-2.64 2.76-2.49 1.3.13 2.24 1.32 2.24 2.63V15h-2V8.5c0-.28-.22-.5-.5-.5s-.5.22-.5.5V15c0 1.1.9 2 2 2s2-.9 2-2v-5h2z"/></g>
     </defs>
   </svg>
 </iron-iconset-svg>`;
diff --git a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_test.js b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_test.js
deleted file mode 100644
index e8a38ec..0000000
--- a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_test.js
+++ /dev/null
@@ -1,236 +0,0 @@
-/**
- * @license
- * Copyright (C) 2018 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../../../test/common-test-setup-karma.js';
-import './gr-label-info.js';
-import {isHidden, stubRestApi} from '../../../test/test-utils.js';
-
-const basicFixture = fixtureFromElement('gr-label-info');
-
-suite('gr-label-info tests', () => {
-  let element;
-
-  setup(() => {
-    element = basicFixture.instantiate();
-
-    // Needed to trigger computed bindings.
-    element.account = {};
-    element.change = {labels: {}};
-  });
-
-  suite('remove reviewer votes', () => {
-    setup(() => {
-      sinon.stub(element, '_computeValueTooltip').returns('');
-      element.account = {
-        _account_id: 1,
-        name: 'bojack',
-      };
-      const test = {
-        all: [{_account_id: 1, name: 'bojack', value: 1}],
-        default_value: 0,
-        values: [],
-      };
-      element.change = {
-        _number: 42,
-        change_id: 'the id',
-        actions: [],
-        topic: 'the topic',
-        status: 'NEW',
-        submit_type: 'CHERRY_PICK',
-        labels: {test},
-        removable_reviewers: [],
-      };
-      element.labelInfo = test;
-      element.label = 'test';
-
-      flush();
-    });
-
-    test('_computeCanDeleteVote', () => {
-      element.mutable = false;
-      const button = element.shadowRoot
-          .querySelector('gr-button');
-      assert.isTrue(isHidden(button));
-      element.change.removable_reviewers = [element.account];
-      element.mutable = true;
-      assert.isFalse(isHidden(button));
-    });
-
-    test('deletes votes', () => {
-      const deleteResponse = Promise.resolve({ok: true});
-      const deleteStub = stubRestApi('deleteVote').returns(deleteResponse);
-
-      element.change.removable_reviewers = [element.account];
-      element.change.labels.test.recommended = {_account_id: 1};
-      element.mutable = true;
-      const button = element.shadowRoot
-          .querySelector('gr-button');
-      MockInteractions.tap(button);
-      assert.isTrue(button.disabled);
-      return deleteResponse.then(() => {
-        assert.isFalse(button.disabled);
-        assert.isTrue(deleteStub.calledWithExactly(42, 1, 'test'));
-      });
-    });
-  });
-
-  suite('label color and order', () => {
-    test('valueless label rejected', () => {
-      element.labelInfo = {rejected: {name: 'someone'}};
-      flush();
-      const labels = element.root.querySelectorAll('gr-label');
-      assert.isTrue(labels[0].classList.contains('negative'));
-    });
-
-    test('valueless label approved', () => {
-      element.labelInfo = {approved: {name: 'someone'}};
-      flush();
-      const labels = element.root.querySelectorAll('gr-label');
-      assert.isTrue(labels[0].classList.contains('positive'));
-    });
-
-    test('-2 to +2', () => {
-      element.labelInfo = {
-        all: [
-          {value: 2, name: 'user 2'},
-          {value: 1, name: 'user 1'},
-          {value: -1, name: 'user 3'},
-          {value: -2, name: 'user 4'},
-        ],
-        values: {
-          '-2': 'Awful',
-          '-1': 'Don\'t submit as-is',
-          ' 0': 'No score',
-          '+1': 'Looks good to me',
-          '+2': 'Ready to submit',
-        },
-      };
-      flush();
-      const labels = element.root.querySelectorAll('gr-label');
-      assert.isTrue(labels[0].classList.contains('max'));
-      assert.isTrue(labels[1].classList.contains('positive'));
-      assert.isTrue(labels[2].classList.contains('negative'));
-      assert.isTrue(labels[3].classList.contains('min'));
-    });
-
-    test('-1 to +1', () => {
-      element.labelInfo = {
-        all: [
-          {value: 1, name: 'user 1'},
-          {value: -1, name: 'user 2'},
-        ],
-        values: {
-          '-1': 'Don\'t submit as-is',
-          ' 0': 'No score',
-          '+1': 'Looks good to me',
-        },
-      };
-      flush();
-      const labels = element.root.querySelectorAll('gr-label');
-      assert.isTrue(labels[0].classList.contains('max'));
-      assert.isTrue(labels[1].classList.contains('min'));
-    });
-
-    test('0 to +2', () => {
-      element.labelInfo = {
-        all: [
-          {value: 1, name: 'user 2'},
-          {value: 2, name: 'user '},
-        ],
-        values: {
-          ' 0': 'Don\'t submit as-is',
-          '+1': 'No score',
-          '+2': 'Looks good to me',
-        },
-      };
-      flush();
-      const labels = element.root.querySelectorAll('gr-label');
-      assert.isTrue(labels[0].classList.contains('max'));
-      assert.isTrue(labels[1].classList.contains('positive'));
-    });
-
-    test('self votes at top', () => {
-      element.account = {
-        _account_id: 1,
-        name: 'bojack',
-      };
-      element.labelInfo = {
-        all: [
-          {value: 1, name: 'user 1', _account_id: 2},
-          {value: -1, name: 'bojack', _account_id: 1},
-        ],
-        values: {
-          '-1': 'Don\'t submit as-is',
-          ' 0': 'No score',
-          '+1': 'Looks good to me',
-        },
-      };
-      flush();
-      const chips =
-          element.root.querySelectorAll('gr-account-link');
-      assert.equal(chips[0].account._account_id, element.account._account_id);
-    });
-  });
-
-  test('_computeValueTooltip', () => {
-    // Existing label.
-    let labelInfo = {values: {0: 'Baz'}};
-    let score = '0';
-    assert.equal(element._computeValueTooltip(labelInfo, score), 'Baz');
-
-    // Non-existent score.
-    score = '2';
-    assert.equal(element._computeValueTooltip(labelInfo, score), '');
-
-    // No values on label.
-    labelInfo = {values: {}};
-    score = '0';
-    assert.equal(element._computeValueTooltip(labelInfo, score), '');
-  });
-
-  test('placeholder', () => {
-    const values = {
-      '0': 'No score',
-      '+1': 'good',
-      '+2': 'excellent',
-      '-1': 'bad',
-      '-2': 'terrible',
-    };
-    element.labelInfo = {};
-    assert.isFalse(isHidden(element.shadowRoot
-        .querySelector('.placeholder')));
-    element.labelInfo = {all: [], values};
-    assert.isFalse(isHidden(element.shadowRoot
-        .querySelector('.placeholder')));
-    element.labelInfo = {all: [{value: 1}], values};
-    assert.isTrue(isHidden(element.shadowRoot
-        .querySelector('.placeholder')));
-    element.labelInfo = {rejected: []};
-    assert.isTrue(isHidden(element.shadowRoot
-        .querySelector('.placeholder')));
-    element.labelInfo = {values: [], rejected: [], all: [{value: 1}, values]};
-    assert.isTrue(isHidden(element.shadowRoot
-        .querySelector('.placeholder')));
-    element.labelInfo = {approved: []};
-    assert.isTrue(isHidden(element.shadowRoot
-        .querySelector('.placeholder')));
-    element.labelInfo = {values: [], approved: [], all: [{value: 1}, values]};
-    assert.isTrue(isHidden(element.shadowRoot
-        .querySelector('.placeholder')));
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_test.ts b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_test.ts
new file mode 100644
index 0000000..b3235fa
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_test.ts
@@ -0,0 +1,257 @@
+/**
+ * @license
+ * Copyright (C) 2018 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma';
+import './gr-label-info';
+import {
+  isHidden,
+  mockPromise,
+  queryAll,
+  queryAndAssert,
+  stubRestApi,
+} from '../../../test/test-utils';
+import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
+import {GrLabelInfo} from './gr-label-info';
+import {GrButton} from '../gr-button/gr-button';
+import {GrLabel} from '../gr-label/gr-label';
+import {GrAccountLink} from '../gr-account-link/gr-account-link';
+import {
+  createAccountWithIdNameAndEmail,
+  createChange,
+} from '../../../test/test-data-generators';
+import {LabelInfo} from '../../../types/common';
+
+const basicFixture = fixtureFromElement('gr-label-info');
+
+suite('gr-label-info tests', () => {
+  let element: GrLabelInfo;
+  const account = createAccountWithIdNameAndEmail(5);
+
+  setup(() => {
+    element = basicFixture.instantiate();
+
+    // Needed to trigger computed bindings.
+    element.account = {};
+    element.change = {...createChange(), labels: {}};
+  });
+
+  suite('remove reviewer votes', () => {
+    const label: LabelInfo = {
+      all: [{...account, value: 1}],
+      default_value: 0,
+      values: {},
+    };
+
+    setup(async () => {
+      sinon.stub(element, '_computeValueTooltip').returns('');
+      element.account = account;
+      element.change = {
+        ...createChange(),
+        labels: {'Code-Review': label},
+      };
+      element.labelInfo = label;
+      element.label = 'Code-Review';
+
+      await flush();
+    });
+
+    test('_computeCanDeleteVote', () => {
+      element.mutable = false;
+      const removeButton = queryAndAssert<GrButton>(element, 'gr-button');
+      assert.isTrue(isHidden(removeButton));
+      element.change!.removable_reviewers = [account];
+      element.mutable = true;
+      assert.isFalse(isHidden(removeButton));
+    });
+
+    test('deletes votes', async () => {
+      const mock = mockPromise();
+      const deleteResponse = mock.then(() => new Response(null, {status: 200}));
+      const deleteStub = stubRestApi('deleteVote').returns(deleteResponse);
+      element.change!.removable_reviewers = [account];
+      element.change!.labels!['Code-Review'] = {
+        ...label,
+        recommended: account,
+      };
+      element.mutable = true;
+      const removeButton = queryAndAssert<GrButton>(element, 'gr-button');
+
+      MockInteractions.tap(removeButton);
+      assert.isTrue(removeButton.disabled);
+      mock.resolve();
+      await deleteResponse;
+
+      assert.isFalse(removeButton.disabled);
+      assert.isTrue(
+        deleteStub.calledWithExactly(
+          element.change!._number,
+          account._account_id!,
+          'Code-Review'
+        )
+      );
+    });
+  });
+
+  suite('label color and order', () => {
+    test('valueless label rejected', async () => {
+      element.labelInfo = {rejected: {name: 'someone'}};
+      await flush();
+      const labels = queryAll<GrLabel>(element, 'gr-label');
+      assert.isTrue(labels[0].classList.contains('negative'));
+    });
+
+    test('valueless label approved', async () => {
+      element.labelInfo = {approved: {name: 'someone'}};
+      await flush();
+      const labels = queryAll<GrLabel>(element, 'gr-label');
+      assert.isTrue(labels[0].classList.contains('positive'));
+    });
+
+    test('-2 to +2', async () => {
+      element.labelInfo = {
+        all: [
+          {value: 2, name: 'user 2'},
+          {value: 1, name: 'user 1'},
+          {value: -1, name: 'user 3'},
+          {value: -2, name: 'user 4'},
+        ],
+        values: {
+          '-2': 'Awful',
+          '-1': "Don't submit as-is",
+          ' 0': 'No score',
+          '+1': 'Looks good to me',
+          '+2': 'Ready to submit',
+        },
+      };
+      await flush();
+      const labels = queryAll<GrLabel>(element, 'gr-label');
+      assert.isTrue(labels[0].classList.contains('max'));
+      assert.isTrue(labels[1].classList.contains('positive'));
+      assert.isTrue(labels[2].classList.contains('negative'));
+      assert.isTrue(labels[3].classList.contains('min'));
+    });
+
+    test('-1 to +1', async () => {
+      element.labelInfo = {
+        all: [
+          {value: 1, name: 'user 1'},
+          {value: -1, name: 'user 2'},
+        ],
+        values: {
+          '-1': "Don't submit as-is",
+          ' 0': 'No score',
+          '+1': 'Looks good to me',
+        },
+      };
+      await flush();
+      const labels = queryAll<GrLabel>(element, 'gr-label');
+      assert.isTrue(labels[0].classList.contains('max'));
+      assert.isTrue(labels[1].classList.contains('min'));
+    });
+
+    test('0 to +2', async () => {
+      element.labelInfo = {
+        all: [
+          {value: 1, name: 'user 2'},
+          {value: 2, name: 'user '},
+        ],
+        values: {
+          ' 0': "Don't submit as-is",
+          '+1': 'No score',
+          '+2': 'Looks good to me',
+        },
+      };
+      await flush();
+      const labels = queryAll<GrLabel>(element, 'gr-label');
+      assert.isTrue(labels[0].classList.contains('max'));
+      assert.isTrue(labels[1].classList.contains('positive'));
+    });
+
+    test('self votes at top', async () => {
+      const otherAccount = createAccountWithIdNameAndEmail(8);
+      element.account = account;
+      element.labelInfo = {
+        all: [
+          {...otherAccount, value: 1},
+          {...account, value: -1},
+        ],
+        values: {
+          '-1': "Don't submit as-is",
+          ' 0': 'No score',
+          '+1': 'Looks good to me',
+        },
+      };
+      await flush();
+      const chips = queryAll<GrAccountLink>(element, 'gr-account-link');
+      assert.equal(chips[0].account!._account_id, element.account._account_id);
+    });
+  });
+
+  test('_computeValueTooltip', () => {
+    // Existing label.
+    let labelInfo: LabelInfo = {values: {0: 'Baz'}};
+    let score = '0';
+    assert.equal(element._computeValueTooltip(labelInfo, score), 'Baz');
+
+    // Non-existent score.
+    score = '2';
+    assert.equal(element._computeValueTooltip(labelInfo, score), '');
+
+    // No values on label.
+    labelInfo = {values: {}};
+    score = '0';
+    assert.equal(element._computeValueTooltip(labelInfo, score), '');
+  });
+
+  test('placeholder', () => {
+    const values = {
+      '0': 'No score',
+      '+1': 'good',
+      '+2': 'excellent',
+      '-1': 'bad',
+      '-2': 'terrible',
+    };
+    element.labelInfo = {};
+    assert.isFalse(
+      isHidden(queryAndAssert<HTMLParagraphElement>(element, '.placeholder'))
+    );
+    element.labelInfo = {all: [], values};
+    assert.isFalse(
+      isHidden(queryAndAssert<HTMLParagraphElement>(element, '.placeholder'))
+    );
+    element.labelInfo = {all: [{value: 1}], values};
+    assert.isTrue(
+      isHidden(queryAndAssert<HTMLParagraphElement>(element, '.placeholder'))
+    );
+    element.labelInfo = {rejected: account};
+    assert.isTrue(
+      isHidden(queryAndAssert<HTMLParagraphElement>(element, '.placeholder'))
+    );
+    element.labelInfo = {rejected: account, all: [{value: 1}], values};
+    assert.isTrue(
+      isHidden(queryAndAssert<HTMLParagraphElement>(element, '.placeholder'))
+    );
+    element.labelInfo = {approved: account};
+    assert.isTrue(
+      isHidden(queryAndAssert<HTMLParagraphElement>(element, '.placeholder'))
+    );
+    element.labelInfo = {approved: account, all: [{value: 1}], values};
+    assert.isTrue(
+      isHidden(queryAndAssert<HTMLParagraphElement>(element, '.placeholder'))
+    );
+  });
+});
diff --git a/polygerrit-ui/app/elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete_test.js b/polygerrit-ui/app/elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete_test.ts
similarity index 62%
rename from polygerrit-ui/app/elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete_test.js
rename to polygerrit-ui/app/elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete_test.ts
index 3e904a2..d6fc45f 100644
--- a/polygerrit-ui/app/elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete_test.ts
@@ -15,13 +15,14 @@
  * limitations under the License.
  */
 
-import '../../../test/common-test-setup-karma.js';
-import './gr-labeled-autocomplete.js';
+import '../../../test/common-test-setup-karma';
+import './gr-labeled-autocomplete';
+import {GrLabeledAutocomplete} from './gr-labeled-autocomplete';
 
 const basicFixture = fixtureFromElement('gr-labeled-autocomplete');
 
 suite('gr-labeled-autocomplete tests', () => {
-  let element;
+  let element: GrLabeledAutocomplete;
 
   setup(() => {
     element = basicFixture.instantiate();
@@ -29,17 +30,16 @@
 
   test('tapping trigger focuses autocomplete', () => {
     const e = {stopPropagation: () => undefined};
-    sinon.stub(e, 'stopPropagation');
-    sinon.stub(element.$.autocomplete, 'focus');
-    element._handleTriggerClick(e);
-    assert.isTrue(e.stopPropagation.calledOnce);
-    assert.isTrue(element.$.autocomplete.focus.calledOnce);
+    const stopPropagationStub = sinon.stub(e, 'stopPropagation');
+    const autocompleteStub = sinon.stub(element.$.autocomplete, 'focus');
+    element._handleTriggerClick(e as Event);
+    assert.isTrue(stopPropagationStub.calledOnce);
+    assert.isTrue(autocompleteStub.calledOnce);
   });
 
   test('setText', () => {
-    sinon.stub(element.$.autocomplete, 'setText');
+    const setTextStub = sinon.stub(element.$.autocomplete, 'setText');
     element.setText('foo-bar');
-    assert.isTrue(element.$.autocomplete.setText.calledWith('foo-bar'));
+    assert.isTrue(setTextStub.calledWith('foo-bar'));
   });
 });
-
diff --git a/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader.ts b/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader.ts
index 655acde..a3f128b 100644
--- a/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader.ts
+++ b/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader.ts
@@ -14,83 +14,50 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../gr-js-api-interface/gr-js-api-interface';
-import {EventType} from '../../../api/plugin';
-import {HighlightJS} from '../../../types/types';
-import {appContext} from '../../../services/app-context';
 
-// preloaded in PolyGerritIndexHtml.soy
-const HLJS_PATH = 'bower_components/highlightjs/highlight.min.js';
-
-type HljsCallback = (value?: HighlightJS) => void;
-
-interface HljsState {
-  configured: boolean;
-  loading: boolean;
-  callbacks: HljsCallback[];
+export interface LibraryConfig {
+  /** Path to the library to be loaded. */
+  src: string;
+  /**
+   * Optional check to see if the library has already been loaded outside of
+   * this class. If this returns true, src will not be loaded, but
+   * configureCallback will be run if present.
+   */
+  checkPresent?: () => boolean;
+  /**
+   * Optional library initialization to be run once after loading the library,
+   * before resolving promises for getLibrary(). Promises returned from
+   * getLibrary() will resolve to the return value of this function, if any.
+   */
+  configureCallback?: () => unknown;
 }
 
 export class GrLibLoader {
-  private readonly jsAPI = appContext.jsApiService;
-
-  _hljsState: HljsState = {
-    configured: false,
-    loading: false,
-    callbacks: [],
-  };
-
-  /**
-   * Get the HLJS library. Returns a promise that resolves with a reference to
-   * the library after it's been loaded. The promise resolves immediately if
-   * it's already been loaded.
+  /*
+   * Pending library loads, keyed by library config, populated when getLibrary()
+   * is first called for a given config. This retains the promise for each
+   * library so that later calls of getLibrary() for the same config can
+   * directly return a resolved promise.
    */
-  getHLJS(): Promise<HighlightJS | undefined> {
-    return new Promise<HighlightJS | undefined>((resolve, reject) => {
-      // If the lib is totally loaded, resolve immediately.
-      if (this._getHighlightLib()) {
-        resolve(this._getHighlightLib());
-        return;
-      }
+  private readonly libraries = new Map<LibraryConfig, Promise<unknown>>();
 
-      // If the library is not currently being loaded, then start loading it.
-      if (!this._hljsState.loading) {
-        this._hljsState.loading = true;
-        this._loadScript(this._getHLJSUrl())
-          .then(() => this._onHLJSLibLoaded())
-          .catch(reject);
-      }
-
-      this._hljsState.callbacks.push(resolve);
-    });
+  _getPath(src: string) {
+    const root = this._getLibRoot();
+    return root ? root + src : null;
   }
 
-  /**
-   * Execute callbacks awaiting the HLJS lib load.
-   */
-  _onHLJSLibLoaded() {
-    const lib = this._getHighlightLib();
-    this._hljsState.loading = false;
-    this.jsAPI.handleEvent(EventType.HIGHLIGHTJS_LOADED, {
-      hljs: lib,
-    });
-    for (const cb of this._hljsState.callbacks) {
-      cb(lib);
+  getLibrary(config: LibraryConfig): Promise<unknown> {
+    if (!this.libraries.has(config)) {
+      const loaded =
+        config.checkPresent && config.checkPresent()
+          ? Promise.resolve()
+          : this._loadScript(this._getPath(config.src));
+      const configured = loaded.then(() =>
+        config.configureCallback ? config.configureCallback() : undefined
+      );
+      this.libraries.set(config, configured);
     }
-    this._hljsState.callbacks = [];
-  }
-
-  /**
-   * Get the HLJS library, assuming it has been loaded. Configure the library
-   * if it hasn't already been configured.
-   */
-  _getHighlightLib(): HighlightJS | undefined {
-    const lib = window.hljs;
-    if (lib && !this._hljsState.configured) {
-      this._hljsState.configured = true;
-
-      lib.configure({classPrefix: 'gr-diff gr-syntax gr-syntax-'});
-    }
-    return lib;
+    return this.libraries.get(config)!;
   }
 
   /**
@@ -126,12 +93,4 @@
       document.head.appendChild(script);
     });
   }
-
-  _getHLJSUrl() {
-    const root = this._getLibRoot();
-    if (!root) {
-      return null;
-    }
-    return root + HLJS_PATH;
-  }
 }
diff --git a/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader_test.js b/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader_test.js
index c89ff8e..e83698f 100644
--- a/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader_test.js
@@ -22,101 +22,182 @@
 suite('gr-lib-loader tests', () => {
   let grLibLoader;
   let resolveLoad;
+  let rejectLoad;
   let loadStub;
 
   setup(() => {
     grLibLoader = new GrLibLoader();
 
     loadStub = sinon.stub(grLibLoader, '_loadScript').callsFake(() =>
-      new Promise(resolve => resolveLoad = resolve)
+      new Promise((resolve, reject) => {
+        resolveLoad = resolve;
+        rejectLoad = reject;
+      })
     );
-
-    // Assert preconditions:
-    assert.isFalse(grLibLoader._hljsState.loading);
   });
 
-  teardown(() => {
-    if (window.hljs) {
-      delete window.hljs;
-    }
+  test('notifies all callers when loaded', async () => {
+    const libraryConfig = {src: 'foo.js'};
 
-    // Because the element state is a singleton, clean it up.
-    grLibLoader._hljsState.configured = false;
-    grLibLoader._hljsState.loading = false;
-    grLibLoader._hljsState.callbacks = [];
-  });
+    const loaded1 = sinon.stub();
+    const loaded2 = sinon.stub();
 
-  test('only load once', async () => {
-    sinon.stub(grLibLoader, '_getHLJSUrl').returns('');
-    const firstCallHandler = sinon.stub();
-    grLibLoader.getHLJS().then(firstCallHandler);
+    grLibLoader.getLibrary(libraryConfig).then(loaded1);
+    grLibLoader.getLibrary(libraryConfig).then(loaded2);
 
-    // It should now be in the loading state.
-    assert.isTrue(loadStub.called);
-    assert.isTrue(grLibLoader._hljsState.loading);
-    assert.isFalse(firstCallHandler.called);
-
-    const secondCallHandler = sinon.stub();
-    grLibLoader.getHLJS().then(secondCallHandler);
-
-    // No change in state.
-    assert.isTrue(grLibLoader._hljsState.loading);
-    assert.isFalse(firstCallHandler.called);
-    assert.isFalse(secondCallHandler.called);
-
-    // Now load the library.
     resolveLoad();
     await flush();
-    // The state should be loaded and both handlers called.
-    assert.isFalse(grLibLoader._hljsState.loading);
-    assert.isTrue(firstCallHandler.called);
-    assert.isTrue(secondCallHandler.called);
+
+    const lateLoaded = sinon.stub();
+    grLibLoader.getLibrary(libraryConfig).then(lateLoaded);
+
+    await flush();
+
+    assert.isTrue(loaded1.calledOnce);
+    assert.isTrue(loaded2.calledOnce);
+    assert.isTrue(lateLoaded.calledOnce);
+  });
+
+  test('notifies all callers when failed', async () => {
+    const libraryConfig = {src: 'foo.js'};
+
+    const failed1 = sinon.stub();
+    const failed2 = sinon.stub();
+
+    grLibLoader.getLibrary(libraryConfig).catch(failed1);
+    grLibLoader.getLibrary(libraryConfig).catch(failed2);
+
+    rejectLoad();
+    await flush();
+
+    const lateFailed = sinon.stub();
+    grLibLoader.getLibrary(libraryConfig).catch(lateFailed);
+
+    await flush();
+
+    assert.isTrue(failed1.calledOnce);
+    assert.isTrue(failed2.calledOnce);
+    assert.isTrue(lateFailed.calledOnce);
+  });
+
+  test('runs library configuration only once', async () => {
+    const configureCallback = sinon.stub();
+    const libraryConfig = {
+      src: 'foo.js',
+      configureCallback,
+    };
+
+    const loaded1 = sinon.stub();
+    const loaded2 = sinon.stub();
+
+    grLibLoader.getLibrary(libraryConfig).then(loaded1);
+    grLibLoader.getLibrary(libraryConfig).then(loaded2);
+
+    resolveLoad();
+    await flush();
+
+    const lateLoaded = sinon.stub();
+    grLibLoader.getLibrary(libraryConfig).then(lateLoaded);
+
+    await flush();
+
+    assert.isTrue(configureCallback.calledOnce);
+  });
+
+  test('resolves to result of configureCallback, if any', async () => {
+    const library = {someFunction: () => 'foobar'};
+
+    const libraryConfig = {
+      src: 'foo.js',
+      configureCallback: () => window.library,
+    };
+
+    const loaded1 = sinon.stub();
+    const loaded2 = sinon.stub();
+
+    grLibLoader.getLibrary(libraryConfig).then(loaded1);
+    grLibLoader.getLibrary(libraryConfig).then(loaded2);
+
+    window.library = library;
+    resolveLoad();
+    await flush();
+
+    assert.isTrue(loaded1.calledWith(library));
+    assert.isTrue(loaded2.calledWith(library));
+
+    const lateLoaded = sinon.stub();
+    grLibLoader.getLibrary(libraryConfig).then(lateLoaded);
+
+    await flush();
+
+    assert.isTrue(lateLoaded.calledWith(library));
   });
 
   suite('preloaded', () => {
-    let hljsStub;
-
     setup(() => {
-      hljsStub = {
-        configure: sinon.stub(),
+      window.library = {
+        initialize: sinon.stub(),
       };
-      window.hljs = hljsStub;
     });
 
     teardown(() => {
-      delete window.hljs;
+      delete window.library;
     });
 
-    test('returns hljs', async () => {
-      const firstCallHandler = sinon.stub();
-      grLibLoader.getHLJS().then(firstCallHandler);
+    test('does not load library again if detected present', async () => {
+      const libraryConfig = {
+        src: 'foo.js',
+        checkPresent: () => window.library !== undefined,
+      };
+
+      const loaded1 = sinon.stub();
+      const loaded2 = sinon.stub();
+
+      grLibLoader.getLibrary(libraryConfig).then(loaded1);
+      grLibLoader.getLibrary(libraryConfig).then(loaded2);
+
+      resolveLoad();
       await flush();
-      assert.isTrue(firstCallHandler.called);
-      assert.isTrue(firstCallHandler.calledWith(hljsStub));
+
+      const lateLoaded = sinon.stub();
+      grLibLoader.getLibrary(libraryConfig).then(lateLoaded);
+
+      await flush();
+
+      assert.isFalse(loadStub.called);
+      assert.isTrue(loaded1.called);
+      assert.isTrue(loaded2.called);
+      assert.isTrue(lateLoaded.called);
     });
 
-    test('configures hljs', () => grLibLoader.getHLJS().then(() => {
-      assert.isTrue(window.hljs.configure.calledOnce);
-    }));
-  });
+    test('runs configuration for externally loaded library', async () => {
+      const libraryConfig = {
+        src: 'foo.js',
+        checkPresent: () => window.library !== undefined,
+        configureCallback: () => window.library.initialize(),
+      };
 
-  suite('_getHLJSUrl', () => {
-    suite('checking _getLibRoot', () => {
-      let root;
+      grLibLoader.getLibrary(libraryConfig);
 
-      setup(() => {
-        sinon.stub(grLibLoader, '_getLibRoot').callsFake(() => root);
-      });
+      resolveLoad();
+      await flush();
 
-      test('with no root', () => {
-        assert.isNull(grLibLoader._getHLJSUrl());
-      });
+      assert.isTrue(window.library.initialize.calledOnce);
+    });
 
-      test('with root', () => {
-        root = 'test-root.com/';
-        assert.equal(grLibLoader._getHLJSUrl(),
-            'test-root.com/bower_components/highlightjs/highlight.min.js');
-      });
+    test('loads library again if not detected present', async () => {
+      window.library = undefined;
+      const libraryConfig = {
+        src: 'foo.js',
+        checkPresent: () => window.library !== undefined,
+      };
+
+      grLibLoader.getLibrary(libraryConfig);
+
+      resolveLoad();
+      await flush();
+
+      assert.isTrue(loadStub.called);
     });
   });
 });
diff --git a/polygerrit-ui/app/elements/shared/gr-lib-loader/highlightjs_config.ts b/polygerrit-ui/app/elements/shared/gr-lib-loader/highlightjs_config.ts
new file mode 100644
index 0000000..da13396
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-lib-loader/highlightjs_config.ts
@@ -0,0 +1,35 @@
+/**
+ * @license
+ * Copyright (C) 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import '../gr-js-api-interface/gr-js-api-interface';
+
+import {EventType} from '../../../api/plugin';
+import {appContext} from '../../../services/app-context';
+
+import {LibraryConfig} from './gr-lib-loader';
+
+export const HLJS_LIBRARY_CONFIG: LibraryConfig = {
+  // preloaded in PolyGerritIndexHtml.soy
+  src: 'bower_components/highlightjs/highlight.min.js',
+  checkPresent: () => window.hljs !== undefined,
+  configureCallback: () => {
+    window.hljs!.configure({classPrefix: 'gr-diff gr-syntax gr-syntax-'});
+    appContext.jsApiService.handleEvent(EventType.HIGHLIGHTJS_LOADED, {
+      hljs: window.hljs,
+    });
+    return window.hljs;
+  },
+};
diff --git a/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip_test.js b/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip_test.ts
similarity index 80%
rename from polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip_test.js
rename to polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip_test.ts
index dd2b98a..972e02c 100644
--- a/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip_test.ts
@@ -15,13 +15,15 @@
  * limitations under the License.
  */
 
-import '../../../test/common-test-setup-karma.js';
-import './gr-linked-chip.js';
+import '../../../test/common-test-setup-karma';
+import './gr-linked-chip';
+import {GrLinkedChip} from './gr-linked-chip';
+import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
 
 const basicFixture = fixtureFromElement('gr-linked-chip');
 
 suite('gr-linked-chip tests', () => {
-  let element;
+  let element: GrLinkedChip;
 
   setup(() => {
     element = basicFixture.instantiate();
@@ -35,4 +37,3 @@
     assert.isTrue(spy.called);
   });
 });
-
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface_test.js b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface_test.js
index 7cbcaef..1aee75a 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface_test.js
@@ -16,7 +16,7 @@
  */
 
 import '../../../test/common-test-setup-karma.js';
-import {addListenerForTest, mockPromise} from '../../../test/test-utils.js';
+import {addListenerForTest, mockPromise, stubAuth} from '../../../test/test-utils.js';
 import {GrReviewerUpdatesParser} from './gr-reviewer-updates-parser.js';
 import {ListChangesOption} from '../../../utils/change-util.js';
 import {appContext} from '../../../services/app-context.js';
@@ -264,7 +264,7 @@
 
   test('server error', () => {
     const getResponseObjectStub = sinon.stub(element, 'getResponseObject');
-    window.fetch.returns(Promise.resolve({ok: false}));
+    stubAuth('fetch').returns(Promise.resolve({ok: false}));
     const serverErrorEventPromise = new Promise(resolve => {
       addListenerForTest(document, 'server-error', resolve);
     });
@@ -832,7 +832,7 @@
   });
 
   test('gerrit auth is used', () => {
-    sinon.stub(appContext.authService, 'fetch').returns(Promise.resolve());
+    stubAuth('fetch').returns(Promise.resolve());
     element._restApiHelper.fetchJSON({url: 'foo'});
     assert(appContext.authService.fetch.called);
   });
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper_test.js b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper_test.js
index 3a5a587..70bd369 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper_test.js
@@ -18,6 +18,7 @@
 import '../../../../test/common-test-setup-karma.js';
 import {SiteBasedCache, FetchPromisesCache, GrRestApiHelper} from './gr-rest-api-helper.js';
 import {appContext} from '../../../../services/app-context.js';
+import {stubAuth} from '../../../../test/test-utils.js';
 
 suite('gr-rest-api-helper tests', () => {
   let helper;
@@ -25,6 +26,7 @@
   let cache;
   let fetchPromisesCache;
   let originalCanonicalPath;
+  let authFetchStub;
 
   setup(() => {
     cache = new SiteBasedCache();
@@ -38,7 +40,7 @@
     };
 
     const testJSON = ')]}\'\n{"hello": "bonjour"}';
-    sinon.stub(window, 'fetch').returns(Promise.resolve({
+    authFetchStub = stubAuth('fetch').returns(Promise.resolve({
       ok: true,
       text() {
         return Promise.resolve(testJSON);
@@ -55,8 +57,6 @@
 
   suite('fetchJSON()', () => {
     test('Sets header to accept application/json', () => {
-      const authFetchStub = sinon.stub(helper._auth, 'fetch')
-          .returns(Promise.resolve());
       helper.fetchJSON({url: '/dummy/url'});
       assert.isTrue(authFetchStub.called);
       assert.equal(authFetchStub.lastCall.args[1].headers.get('Accept'),
@@ -64,8 +64,6 @@
     });
 
     test('Use header option accept when provided', () => {
-      const authFetchStub = sinon.stub(helper._auth, 'fetch')
-          .returns(Promise.resolve());
       const headers = new Headers();
       headers.append('Accept', '*/*');
       const fetchOptions = {headers};
@@ -142,7 +140,7 @@
 
   test('request callbacks can be canceled', () => {
     let cancelCalled = false;
-    window.fetch.returns(Promise.resolve({
+    authFetchStub.returns(Promise.resolve({
       body: {
         cancel() { cancelCalled = true; },
       },
diff --git a/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command_test.js b/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command_test.ts
similarity index 72%
rename from polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command_test.js
rename to polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command_test.ts
index de9f243..a17b171 100644
--- a/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command_test.ts
@@ -15,27 +15,28 @@
  * limitations under the License.
  */
 
-import '../../../test/common-test-setup-karma.js';
-import './gr-shell-command.js';
+import '../../../test/common-test-setup-karma';
+import './gr-shell-command';
+import {GrShellCommand} from './gr-shell-command';
 
 const basicFixture = fixtureFromElement('gr-shell-command');
 
 suite('gr-shell-command tests', () => {
-  let element;
+  let element: GrShellCommand;
 
   setup(() => {
     element = basicFixture.instantiate();
-    element.text = `git fetch http://gerrit@localhost:8080/a/test-project
+    element.command = `git fetch http://gerrit@localhost:8080/a/test-project
         refs/changes/05/5/1 && git checkout FETCH_HEAD`;
     flush();
   });
 
   test('focusOnCopy', () => {
-    const focusStub = sinon.stub(element.shadowRoot
-        .querySelector('gr-copy-clipboard'),
-    'focusOnCopy');
+    const focusStub = sinon.stub(
+      element.shadowRoot!.querySelector('gr-copy-clipboard')!,
+      'focusOnCopy'
+    );
     element.focusOnCopy();
     assert.isTrue(focusStub.called);
   });
 });
-
diff --git a/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip_test.js b/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip_test.js
deleted file mode 100644
index b5f068c..0000000
--- a/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip_test.js
+++ /dev/null
@@ -1,56 +0,0 @@
-/**
- * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../../../test/common-test-setup-karma.js';
-import './gr-tooltip.js';
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-const basicFixture = fixtureFromTemplate(html`
-<gr-tooltip>
-    </gr-tooltip>
-`);
-
-suite('gr-tooltip tests', () => {
-  let element;
-  setup(() => {
-    element = basicFixture.instantiate();
-  });
-
-  test('max-width is respected if set', () => {
-    element.text = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit' +
-        ', sed do eiusmod tempor incididunt ut labore et dolore magna aliqua';
-    element.maxWidth = '50px';
-    assert.equal(getComputedStyle(element).width, '50px');
-  });
-
-  test('the correct arrow is displayed', () => {
-    assert.equal(getComputedStyle(element.shadowRoot
-        .querySelector('.arrowPositionBelow')).display,
-    'none');
-    assert.notEqual(getComputedStyle(element.shadowRoot
-        .querySelector('.arrowPositionAbove'))
-        .display, 'none');
-    element.positionBelow = true;
-    assert.notEqual(getComputedStyle(element.shadowRoot
-        .querySelector('.arrowPositionBelow'))
-        .display, 'none');
-    assert.equal(getComputedStyle(element.shadowRoot
-        .querySelector('.arrowPositionAbove'))
-        .display, 'none');
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip_test.ts b/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip_test.ts
new file mode 100644
index 0000000..8b44047
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip_test.ts
@@ -0,0 +1,66 @@
+/**
+ * @license
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma';
+import './gr-tooltip';
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+import {GrTooltip} from './gr-tooltip';
+
+const basicFixture = fixtureFromTemplate(html` <gr-tooltip> </gr-tooltip> `);
+
+suite('gr-tooltip tests', () => {
+  let element: GrTooltip;
+  setup(() => {
+    element = basicFixture.instantiate() as GrTooltip;
+  });
+
+  test('max-width is respected if set', () => {
+    element.text =
+      'Lorem ipsum dolor sit amet, consectetur adipiscing elit' +
+      ', sed do eiusmod tempor incididunt ut labore et dolore magna aliqua';
+    element.maxWidth = '50px';
+    assert.equal(getComputedStyle(element).width, '50px');
+  });
+
+  test('the correct arrow is displayed', () => {
+    assert.equal(
+      getComputedStyle(
+        element.shadowRoot!.querySelector('.arrowPositionBelow')!
+      ).display,
+      'none'
+    );
+    assert.notEqual(
+      getComputedStyle(
+        element.shadowRoot!.querySelector('.arrowPositionAbove')!
+      ).display,
+      'none'
+    );
+    element.positionBelow = true;
+    assert.notEqual(
+      getComputedStyle(
+        element.shadowRoot!.querySelector('.arrowPositionBelow')!
+      ).display,
+      'none'
+    );
+    assert.equal(
+      getComputedStyle(
+        element.shadowRoot!.querySelector('.arrowPositionAbove')!
+      ).display,
+      'none'
+    );
+  });
+});
diff --git a/polygerrit-ui/app/mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.ts b/polygerrit-ui/app/mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.ts
index 555a256..3d5a208 100644
--- a/polygerrit-ui/app/mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.ts
+++ b/polygerrit-ui/app/mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.ts
@@ -120,8 +120,6 @@
 
 const V_KEY_TIMEOUT_MS = 1000;
 
-const THROTTLE_INTERVAL_MS = 500;
-
 /**
  * Enum for all shortcut sections, where that shortcut should be applied to.
  */
@@ -675,8 +673,6 @@
     if (!bindings) {
       return null;
     }
-    // TODO(TS): should check base on length to differentiate two
-    // cases
     if (bindings[0] === SPECIAL_SHORTCUT.GO_KEY) {
       return bindings
         .slice(1)
@@ -851,13 +847,6 @@
         return getKeyboardEvent(e);
       }
 
-      // TODO(TS): maybe remove, no reference in the code base
-      getRootTarget(e: CustomKeyboardEvent) {
-        // TODO(TS): worth checking if we can limit this to EventApi only
-        // dom currently returns DomNativeApi|EventApi
-        return (dom(getKeyboardEvent(e)) as EventApi).rootTarget;
-      }
-
       bindShortcut(shortcut: Shortcut, ...bindings: string[]) {
         shortcutManager.bindShortcut(shortcut, ...bindings);
       }
@@ -868,20 +857,6 @@
         return desc && shortcut ? `${desc} (shortcut: ${shortcut})` : '';
       }
 
-      _throttleWrap(fn: (e: Event) => void) {
-        let lastCall: number | undefined;
-        return (e: Event) => {
-          if (
-            lastCall !== undefined &&
-            Date.now() - lastCall < THROTTLE_INTERVAL_MS
-          ) {
-            return;
-          }
-          lastCall = Date.now();
-          fn(e);
-        };
-      }
-
       _addOwnKeyBindings(shortcut: Shortcut, handler: string) {
         const bindings = shortcutManager.getBindingsForShortcut(shortcut);
         if (!bindings) {
@@ -1128,8 +1103,6 @@
   modifierPressed(event: CustomKeyboardEvent): boolean;
   addKeyboardShortcutDirectoryListener(listener: ShortcutListener): void;
   removeKeyboardShortcutDirectoryListener(listener: ShortcutListener): void;
-  // TODO(TS): Remove underscore. Apparently not a private method.
-  _throttleWrap(eventListener: EventListener): EventListener;
 }
 
 export function _testOnly_getShortcutManagerInstance() {
diff --git a/polygerrit-ui/app/scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider.ts b/polygerrit-ui/app/scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider.ts
index 45116aa..dae8e2e 100644
--- a/polygerrit-ui/app/scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider.ts
+++ b/polygerrit-ui/app/scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider.ts
@@ -49,7 +49,14 @@
   value: SuggestedReviewerInfo;
 }
 
-export class GrReviewerSuggestionsProvider {
+export interface ReviewerSuggestionsProvider {
+  init(): void;
+  getSuggestions(input: string): Promise<Suggestion[]>;
+  makeSuggestionItem(suggestion: Suggestion): SuggestionItem;
+}
+
+export class GrReviewerSuggestionsProvider
+  implements ReviewerSuggestionsProvider {
   static create(
     restApi: RestApiService,
     changeNumber: NumericChangeId,
diff --git a/polygerrit-ui/app/services/checks/checks-model.ts b/polygerrit-ui/app/services/checks/checks-model.ts
index 5d3da42..3a0bbf2 100644
--- a/polygerrit-ui/app/services/checks/checks-model.ts
+++ b/polygerrit-ui/app/services/checks/checks-model.ts
@@ -114,7 +114,20 @@
   pluginStateSelected: {},
 };
 
-const privateState$ = new BehaviorSubject(initialState);
+// Mutable for testing
+let privateState$ = new BehaviorSubject(initialState);
+
+export function _testOnly_resetState() {
+  privateState$ = new BehaviorSubject(initialState);
+}
+
+export function _testOnly_setState(state: ChecksState) {
+  privateState$.next(state);
+}
+
+export function _testOnly_getState() {
+  return privateState$.getValue();
+}
 
 // Re-exporting as Observable so that you can only subscribe, but not emit.
 export const checksState$: Observable<ChecksState> = privateState$;
@@ -261,10 +274,13 @@
 
 // Must only be used by the checks service or whatever is in control of this
 // model.
-export function updateStateSetProvider(pluginName: string) {
+export function updateStateSetProvider(
+  pluginName: string,
+  patchset: ChecksPatchset
+) {
   const nextState = {...privateState$.getValue()};
-  nextState.pluginStateLatest = {...nextState.pluginStateLatest};
-  nextState.pluginStateLatest[pluginName] = {
+  const pluginState = getPluginState(nextState, patchset);
+  pluginState[pluginName] = {
     pluginName,
     loading: false,
     runs: [],
@@ -345,6 +361,7 @@
 export const fakeRun1: CheckRun = {
   internalRunId: 'f1',
   checkName: 'FAKE Super Check',
+  statusLink: 'https://www.google.com/',
   patchset: 1,
   labelName: 'Verified',
   isSingleAttempt: true,
@@ -529,6 +546,14 @@
       internalResultId: 'f44r0',
       category: Category.INFO,
       summary: 'Dont be afraid. All TODOs will be eliminated.',
+      actions: [
+        {
+          name: 'Re-Run',
+          tooltip: 'More powerful run than before with a long tooltip, really.',
+          primary: true,
+          callback: () => Promise.resolve({message: 'fake "re-run" triggered'}),
+        },
+      ],
     },
   ],
 };
@@ -674,7 +699,7 @@
   for (const attemptInfo of attemptMap.values()) {
     // Per run only one attempt can be undefined, so the '?? -1' is not really
     // relevant for sorting.
-    attemptInfo.attempts.sort((a, b) => (b.attempt ?? -1) - (a.attempt ?? -1));
+    attemptInfo.attempts.sort((a, b) => (a.attempt ?? -1) - (b.attempt ?? -1));
   }
   const nextState = {...privateState$.getValue()};
   const pluginState = getPluginState(nextState, patchset);
@@ -707,6 +732,42 @@
   privateState$.next(nextState);
 }
 
+export function updateStateUpdateResult(
+  pluginName: string,
+  updatedRun: CheckRunApi,
+  updatedResult: CheckResultApi,
+  patchset: ChecksPatchset
+) {
+  const nextState = {...privateState$.getValue()};
+  const pluginState = getPluginState(nextState, patchset);
+  let runUpdated = false;
+  const runs: CheckRun[] = pluginState[pluginName].runs.map(run => {
+    if (run.change !== updatedRun.change) return run;
+    if (run.patchset !== updatedRun.patchset) return run;
+    if (run.attempt !== updatedRun.attempt) return run;
+    if (run.checkName !== updatedRun.checkName) return run;
+    let resultUpdated = false;
+    const results: CheckResult[] = (run.results ?? []).map(result => {
+      if (result.externalId && result.externalId === updatedResult.externalId) {
+        runUpdated = true;
+        resultUpdated = true;
+        return {
+          ...updatedResult,
+          internalResultId: result.internalResultId,
+        };
+      }
+      return result;
+    });
+    return resultUpdated ? {...run, results} : run;
+  });
+  if (!runUpdated) return;
+  pluginState[pluginName] = {
+    ...pluginState[pluginName],
+    runs,
+  };
+  privateState$.next(nextState);
+}
+
 export function updateStateSetPatchset(patchsetNumber?: PatchSetNumber) {
   const nextState = {...privateState$.getValue()};
   nextState.patchsetNumberSelected = patchsetNumber;
diff --git a/polygerrit-ui/app/services/checks/checks-model_test.ts b/polygerrit-ui/app/services/checks/checks-model_test.ts
new file mode 100644
index 0000000..f05facb
--- /dev/null
+++ b/polygerrit-ui/app/services/checks/checks-model_test.ts
@@ -0,0 +1,91 @@
+/**
+ * @license
+ * Copyright (C) 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import '../../test/common-test-setup-karma';
+import './checks-model';
+import {
+  _testOnly_getState,
+  _testOnly_resetState,
+  ChecksPatchset,
+  updateStateSetProvider,
+  updateStateSetResults,
+  updateStateUpdateResult,
+} from './checks-model';
+import {Category, CheckRun, RunStatus} from '../../api/checks';
+
+const PLUGIN_NAME = 'test-plugin';
+
+const RUNS: CheckRun[] = [
+  {
+    checkName: 'MacCheck',
+    change: 123,
+    patchset: 1,
+    attempt: 1,
+    status: RunStatus.COMPLETED,
+    results: [
+      {
+        externalId: 'id-314',
+        category: Category.WARNING,
+        summary: 'Meddle cheddle check and you are weg.',
+      },
+    ],
+  },
+];
+
+suite('checks-model tests', () => {
+  test('updateStateSetProvider', () => {
+    _testOnly_resetState();
+    updateStateSetProvider(PLUGIN_NAME, ChecksPatchset.LATEST);
+    const state = _testOnly_getState().pluginStateLatest[PLUGIN_NAME];
+    assert.deepEqual(state, {
+      pluginName: PLUGIN_NAME,
+      loading: false,
+      runs: [],
+      actions: [],
+      links: [],
+    });
+  });
+
+  test('updateStateSetResults', () => {
+    _testOnly_resetState();
+    updateStateSetResults(PLUGIN_NAME, RUNS, [], [], ChecksPatchset.LATEST);
+    const state = _testOnly_getState().pluginStateLatest[PLUGIN_NAME];
+    assert.lengthOf(state.runs, 1);
+    assert.lengthOf(state.runs[0].results!, 1);
+  });
+
+  test('updateStateUpdateResult', () => {
+    _testOnly_resetState();
+    updateStateSetResults(PLUGIN_NAME, RUNS, [], [], ChecksPatchset.LATEST);
+    let state = _testOnly_getState().pluginStateLatest[PLUGIN_NAME];
+    assert.equal(
+      state.runs[0].results![0].summary,
+      RUNS[0]!.results![0].summary
+    );
+    const result = RUNS[0].results![0];
+    const updatedResult = {...result, summary: 'new'};
+    updateStateUpdateResult(
+      PLUGIN_NAME,
+      RUNS[0],
+      updatedResult,
+      ChecksPatchset.LATEST
+    );
+    state = _testOnly_getState().pluginStateLatest[PLUGIN_NAME];
+    assert.lengthOf(state.runs, 1);
+    assert.lengthOf(state.runs[0].results!, 1);
+    assert.equal(state.runs[0].results![0].summary, 'new');
+  });
+});
diff --git a/polygerrit-ui/app/services/checks/checks-service.ts b/polygerrit-ui/app/services/checks/checks-service.ts
index e1d435b..7844fa6 100644
--- a/polygerrit-ui/app/services/checks/checks-service.ts
+++ b/polygerrit-ui/app/services/checks/checks-service.ts
@@ -25,6 +25,8 @@
 } from 'rxjs/operators';
 import {
   ChangeData,
+  CheckResult,
+  CheckRun,
   ChecksApiConfig,
   ChecksProvider,
   FetchResponse,
@@ -41,6 +43,7 @@
   updateStateSetPatchset,
   updateStateSetProvider,
   updateStateSetResults,
+  updateStateUpdateResult,
 } from './checks-model';
 import {
   BehaviorSubject,
@@ -57,6 +60,7 @@
 import {assertIsDefined} from '../../utils/common-util';
 import {ReportingService} from '../gr-reporting/gr-reporting';
 import {routerPatchNum$} from '../router/router-model';
+import {Execution} from '../../constants/reporting';
 
 export class ChecksService {
   private readonly providers: {[name: string]: ChecksProvider} = {};
@@ -111,6 +115,11 @@
     if (plugin) this.reload(plugin);
   }
 
+  updateResult(pluginName: string, run: CheckRun, result: CheckResult) {
+    updateStateUpdateResult(pluginName, run, result, ChecksPatchset.LATEST);
+    updateStateUpdateResult(pluginName, run, result, ChecksPatchset.SELECTED);
+  }
+
   register(
     pluginName: string,
     provider: ChecksProvider,
@@ -118,7 +127,8 @@
   ) {
     this.providers[pluginName] = provider;
     this.reloadSubjects[pluginName] = new BehaviorSubject<void>(undefined);
-    updateStateSetProvider(pluginName);
+    updateStateSetProvider(pluginName, ChecksPatchset.LATEST);
+    updateStateSetProvider(pluginName, ChecksPatchset.SELECTED);
     this.initFetchingOfData(pluginName, config, ChecksPatchset.LATEST);
     this.initFetchingOfData(pluginName, config, ChecksPatchset.SELECTED);
   }
@@ -172,24 +182,33 @@
           // the Observable has terminated and we won't recover from that. No
           // further attempts to fetch results for this plugin will be made.
           this.reporting.error(e, `checks-service crash for ${pluginName}`);
-          return of(this.createErrorResponse(pluginName, `${e}`));
+          return of(this.createErrorResponse(pluginName, e));
         })
       )
       .subscribe(response => {
         switch (response.responseCode) {
-          case ResponseCode.ERROR:
-            assertIsDefined(response.errorMessage, 'errorMessage');
-            updateStateSetError(pluginName, response.errorMessage, patchset);
+          case ResponseCode.ERROR: {
+            const message = response.errorMessage ?? '-';
+            this.reporting.reportExecution(Execution.CHECKS_API_ERROR, {
+              plugin: pluginName,
+              message,
+            });
+            updateStateSetError(pluginName, message, patchset);
             break;
-          case ResponseCode.NOT_LOGGED_IN:
+          }
+          case ResponseCode.NOT_LOGGED_IN: {
             assertIsDefined(response.loginCallback, 'loginCallback');
+            this.reporting.reportExecution(Execution.CHECKS_API_NOT_LOGGED_IN, {
+              plugin: pluginName,
+            });
             updateStateSetNotLoggedIn(
               pluginName,
               response.loginCallback,
               patchset
             );
             break;
-          case ResponseCode.OK:
+          }
+          case ResponseCode.OK: {
             updateStateSetResults(
               pluginName,
               response.runs ?? [],
@@ -198,6 +217,7 @@
               patchset
             );
             break;
+          }
         }
       });
   }
@@ -211,11 +231,13 @@
 
   private createErrorResponse(
     pluginName: string,
-    message: string
+    message: object
   ): FetchResponse {
     return {
       responseCode: ResponseCode.ERROR,
-      errorMessage: `Error message from plugin '${pluginName}': ${message}`,
+      errorMessage:
+        `Error message from plugin '${pluginName}':` +
+        ` ${JSON.stringify(message)}`,
     };
   }
 
@@ -233,7 +255,7 @@
         return response;
       });
     return from(fetchPromise).pipe(
-      catchError(e => of(this.createErrorResponse(pluginName, `${e}`)))
+      catchError(e => of(this.createErrorResponse(pluginName, e)))
     );
   }
 }
diff --git a/polygerrit-ui/app/services/checks/checks-util.ts b/polygerrit-ui/app/services/checks/checks-util.ts
index 9381f30..897308e 100644
--- a/polygerrit-ui/app/services/checks/checks-util.ts
+++ b/polygerrit-ui/app/services/checks/checks-util.ts
@@ -17,11 +17,11 @@
 import {
   Action,
   Category,
-  CheckRun as CheckRunApi,
   CheckResult as CheckResultApi,
+  CheckRun as CheckRunApi,
+  Link,
   LinkIcon,
   RunStatus,
-  Link,
 } from '../../api/checks';
 import {assertNever} from '../../utils/common-util';
 import {CheckResult, CheckRun} from './checks-model';
@@ -45,6 +45,8 @@
       return 'bug';
     case LinkIcon.CODE:
       return 'code';
+    case LinkIcon.FILE_PRESENT:
+      return 'file-present';
     default:
       // We don't throw an assertion error here, because plugins don't have to
       // be written in TypeScript, so we may encounter arbitrary strings for
@@ -70,6 +72,10 @@
       return 'Link to help page';
     case LinkIcon.REPORT_BUG:
       return 'Link for reporting a problem';
+    case LinkIcon.CODE:
+      return 'Link to code';
+    case LinkIcon.FILE_PRESENT:
+      return 'Link to file';
     default:
       // We don't throw an assertion error here, because plugins don't have to
       // be written in TypeScript, so we may encounter arbitrary strings for
@@ -86,8 +92,37 @@
   return undefined;
 }
 
-export function iconForCategory(category: Category) {
-  switch (category) {
+export function isStatus(catStat: Category | RunStatus) {
+  return (
+    catStat === RunStatus.COMPLETED ||
+    catStat === RunStatus.RUNNABLE ||
+    catStat === RunStatus.RUNNING
+  );
+}
+
+export function labelFor(catStat: Category | RunStatus) {
+  switch (catStat) {
+    case Category.ERROR:
+      return 'error';
+    case Category.INFO:
+      return 'info';
+    case Category.WARNING:
+      return 'warning';
+    case Category.SUCCESS:
+      return 'success';
+    case RunStatus.COMPLETED:
+      return 'completed';
+    case RunStatus.RUNNABLE:
+      return 'runnable';
+    case RunStatus.RUNNING:
+      return 'running';
+    default:
+      assertNever(catStat, `Unsupported category/status: ${catStat}`);
+  }
+}
+
+export function iconFor(catStat: Category | RunStatus) {
+  switch (catStat) {
     case Category.ERROR:
       return 'error';
     case Category.INFO:
@@ -96,8 +131,15 @@
       return 'warning';
     case Category.SUCCESS:
       return 'check-circle-outline';
+    // Note that this is only for COMPLETED without results!
+    case RunStatus.COMPLETED:
+      return 'check-circle-outline';
+    case RunStatus.RUNNABLE:
+      return 'placeholder';
+    case RunStatus.RUNNING:
+      return 'timelapse';
     default:
-      assertNever(category, `Unsupported category: ${category}`);
+      assertNever(catStat, `Unsupported category/status: ${catStat}`);
   }
 }
 
@@ -145,24 +187,10 @@
 
 export function iconForRun(run: CheckRun) {
   if (run.status !== RunStatus.COMPLETED) {
-    return iconForStatus(run.status);
+    return iconFor(run.status);
   } else {
     const category = worstCategory(run);
-    return category ? iconForCategory(category) : iconForStatus(run.status);
-  }
-}
-
-export function iconForStatus(status: RunStatus) {
-  switch (status) {
-    // Note that this is only for COMPLETED without results!
-    case RunStatus.COMPLETED:
-      return 'check-circle-outline';
-    case RunStatus.RUNNABLE:
-      return 'placeholder';
-    case RunStatus.RUNNING:
-      return 'timelapse';
-    default:
-      assertNever(status, `Unsupported status: ${status}`);
+    return category ? iconFor(category) : iconFor(run.status);
   }
 }
 
diff --git a/polygerrit-ui/app/services/flags/flags_test.js b/polygerrit-ui/app/services/flags/flags_test.ts
similarity index 85%
rename from polygerrit-ui/app/services/flags/flags_test.js
rename to polygerrit-ui/app/services/flags/flags_test.ts
index 33508af..8827368 100644
--- a/polygerrit-ui/app/services/flags/flags_test.js
+++ b/polygerrit-ui/app/services/flags/flags_test.ts
@@ -15,12 +15,12 @@
  * limitations under the License.
  */
 
-import '../../test/common-test-setup-karma.js';
-import {FlagsServiceImplementation} from './flags_impl.js';
+import '../../test/common-test-setup-karma';
+import {FlagsServiceImplementation} from './flags_impl';
 
 suite('flags tests', () => {
-  let originalEnabledExperiments;
-  let flags;
+  let originalEnabledExperiments: string[];
+  let flags: FlagsServiceImplementation;
 
   suiteSetup(() => {
     originalEnabledExperiments = window.ENABLED_EXPERIMENTS;
@@ -41,4 +41,3 @@
     assert.deepEqual(flags.enabledExperiments, ['a']);
   });
 });
-
diff --git a/polygerrit-ui/app/services/gr-auth/gr-auth_impl.ts b/polygerrit-ui/app/services/gr-auth/gr-auth_impl.ts
index 6fadfde..08f2e25 100644
--- a/polygerrit-ui/app/services/gr-auth/gr-auth_impl.ts
+++ b/polygerrit-ui/app/services/gr-auth/gr-auth_impl.ts
@@ -26,7 +26,7 @@
   Token,
 } from './gr-auth';
 
-const MAX_AUTH_CHECK_WAIT_TIME_MS = 1000 * 30; // 30s
+export const MAX_AUTH_CHECK_WAIT_TIME_MS = 1000 * 30; // 30s
 const MAX_GET_TOKEN_RETRIES = 2;
 
 interface ValidToken extends Token {
diff --git a/polygerrit-ui/app/services/gr-auth/gr-auth_mock.ts b/polygerrit-ui/app/services/gr-auth/gr-auth_mock.ts
new file mode 100644
index 0000000..3dbb4c3
--- /dev/null
+++ b/polygerrit-ui/app/services/gr-auth/gr-auth_mock.ts
@@ -0,0 +1,77 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import {EventEmitterService} from '../gr-event-interface/gr-event-interface';
+import {
+  AuthRequestInit,
+  AuthService,
+  AuthStatus,
+  DefaultAuthOptions,
+  GetTokenCallback,
+} from './gr-auth';
+import {Auth} from './gr-auth_impl';
+
+export class GrAuthMock implements AuthService {
+  baseUrl = '';
+
+  private _status = AuthStatus.UNDETERMINED;
+
+  public eventEmitter: EventEmitterService;
+
+  constructor(eventEmitter: EventEmitterService) {
+    this.eventEmitter = eventEmitter;
+  }
+
+  get isAuthed() {
+    return this._status === Auth.STATUS.AUTHED;
+  }
+
+  private _setStatus(status: AuthStatus) {
+    if (this._status === status) return;
+    if (this._status === AuthStatus.AUTHED) {
+      this.eventEmitter.emit('auth-error', {
+        message: Auth.CREDS_EXPIRED_MSG,
+        action: 'Refresh credentials',
+      });
+    }
+    this._status = status;
+  }
+
+  get status() {
+    return this._status;
+  }
+
+  authCheck() {
+    return this.fetch(`${this.baseUrl}/auth-check`).then(res => {
+      if (res.status === 204) {
+        this._setStatus(Auth.STATUS.AUTHED);
+        return true;
+      } else {
+        this._setStatus(Auth.STATUS.NOT_AUTHED);
+        return false;
+      }
+    });
+  }
+
+  clearCache() {}
+
+  setup(_getToken: GetTokenCallback, _defaultOptions: DefaultAuthOptions) {}
+
+  fetch(_url: string, _opt_options?: AuthRequestInit): Promise<Response> {
+    return Promise.resolve(new Response());
+  }
+}
diff --git a/polygerrit-ui/app/services/gr-auth/gr-auth_test.js b/polygerrit-ui/app/services/gr-auth/gr-auth_test.js
index 80938ad..ac93a7d 100644
--- a/polygerrit-ui/app/services/gr-auth/gr-auth_test.js
+++ b/polygerrit-ui/app/services/gr-auth/gr-auth_test.js
@@ -24,7 +24,7 @@
   let auth;
 
   setup(() => {
-    auth = appContext.authService;
+    auth = new Auth(appContext.eventEmitter);
   });
 
   suite('Auth class methods', () => {
diff --git a/polygerrit-ui/app/services/router/router-model.ts b/polygerrit-ui/app/services/router/router-model.ts
index 201cb3f..b3cdf9e 100644
--- a/polygerrit-ui/app/services/router/router-model.ts
+++ b/polygerrit-ui/app/services/router/router-model.ts
@@ -65,6 +65,11 @@
   });
 }
 
+export const routerView$ = routerState$.pipe(
+  map(state => state.view),
+  distinctUntilChanged()
+);
+
 export const routerChangeNum$ = routerState$.pipe(
   map(state => state.changeNum),
   distinctUntilChanged()
diff --git a/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts b/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts
index a3df94c..30989d6 100644
--- a/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts
+++ b/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts
@@ -266,7 +266,7 @@
     return Promise.resolve(createServerInfo());
   },
   getDashboard(): Promise<DashboardInfo | undefined> {
-    throw new Error('getDashboard() not implemented by RestApiMock.');
+    return Promise.resolve(undefined);
   },
   getDefaultPreferences(): Promise<PreferencesInfo | undefined> {
     throw new Error('getDefaultPreferences() not implemented by RestApiMock.');
diff --git a/polygerrit-ui/app/test/test-app-context-init.ts b/polygerrit-ui/app/test/test-app-context-init.ts
index d74a9c1..483baa6 100644
--- a/polygerrit-ui/app/test/test-app-context-init.ts
+++ b/polygerrit-ui/app/test/test-app-context-init.ts
@@ -21,6 +21,7 @@
 import {AppContext, appContext} from '../services/app-context';
 import {grRestApiMock} from './mocks/gr-rest-api_mock';
 import {grStorageMock} from '../services/storage/gr-storage_mock';
+import {GrAuthMock} from '../services/gr-auth/gr-auth_mock';
 
 export function _testOnlyInitAppContext() {
   initAppContext();
@@ -38,4 +39,5 @@
   setMock('reportingService', grReportingMock);
   setMock('restApiService', grRestApiMock);
   setMock('storageService', grStorageMock);
+  setMock('authService', new GrAuthMock(appContext.eventEmitter));
 }
diff --git a/polygerrit-ui/app/test/test-data-generators.ts b/polygerrit-ui/app/test/test-data-generators.ts
index fa40529..4f06f7e 100644
--- a/polygerrit-ui/app/test/test-data-generators.ts
+++ b/polygerrit-ui/app/test/test-data-generators.ts
@@ -67,6 +67,8 @@
   RelatedChangesInfo,
   FixSuggestionInfo,
   FixId,
+  GroupInfo,
+  GroupId,
 } from '../types/common';
 import {
   AccountsVisibility,
@@ -623,3 +625,9 @@
     replacements: [],
   };
 }
+
+export function createGroupInfo(id = 'id'): GroupInfo {
+  return {
+    id: id as GroupId,
+  };
+}
diff --git a/polygerrit-ui/app/test/test-utils.ts b/polygerrit-ui/app/test/test-utils.ts
index 34f7fe4..4d8e8ae 100644
--- a/polygerrit-ui/app/test/test-utils.ts
+++ b/polygerrit-ui/app/test/test-utils.ts
@@ -25,6 +25,8 @@
 import {RestApiService} from '../services/gr-rest-api/gr-rest-api';
 import {SinonSpy} from 'sinon/pkg/sinon-esm';
 import {StorageService} from '../services/storage/gr-storage';
+import {AuthService} from '../services/gr-auth/gr-auth';
+import {ReportingService} from '../services/gr-reporting/gr-reporting';
 
 export interface MockPromise extends Promise<unknown> {
   resolve: (value?: unknown) => void;
@@ -179,6 +181,14 @@
   return sinon.spy(appContext.storageService, method);
 }
 
+export function stubAuth<K extends keyof AuthService>(method: K) {
+  return sinon.stub(appContext.authService, method);
+}
+
+export function stubReporting<K extends keyof ReportingService>(method: K) {
+  return sinon.stub(appContext.reportingService, method);
+}
+
 export type SinonSpyMember<F extends (...args: any) => any> = SinonSpy<
   Parameters<F>,
   ReturnType<F>
diff --git a/polygerrit-ui/app/utils/account-util_test.js b/polygerrit-ui/app/utils/account-util_test.ts
similarity index 75%
rename from polygerrit-ui/app/utils/account-util_test.js
rename to polygerrit-ui/app/utils/account-util_test.ts
index 0628f2d..835cd6d 100644
--- a/polygerrit-ui/app/utils/account-util_test.js
+++ b/polygerrit-ui/app/utils/account-util_test.ts
@@ -15,13 +15,12 @@
  * limitations under the License.
  */
 
-import '../test/common-test-setup-karma.js';
-import {isServiceUser, removeServiceUsers} from './account-util.js';
-import {AccountTag} from '../constants/constants.js';
+import '../test/common-test-setup-karma';
+import {isServiceUser, removeServiceUsers} from './account-util';
+import {AccountTag} from '../constants/constants';
 
 const EMPTY = {};
 const ERNIE = {name: 'Ernie'};
-const KERMIT = {name: 'Kermit', tags: ['FROG']};
 const SERVY = {name: 'Servy', tags: [AccountTag.SERVICE_USER]};
 const BOTTY = {name: 'Botty', tags: [AccountTag.SERVICE_USER]};
 
@@ -30,17 +29,17 @@
     assert.isFalse(isServiceUser());
     assert.isFalse(isServiceUser(EMPTY));
     assert.isFalse(isServiceUser(ERNIE));
-    assert.isFalse(isServiceUser(KERMIT));
     assert.isTrue(isServiceUser(SERVY));
     assert.isTrue(isServiceUser(BOTTY));
   });
 
   test('removeServiceUsers', () => {
     assert.sameMembers(removeServiceUsers([]), []);
-    assert.sameMembers(removeServiceUsers([EMPTY, ERNIE, KERMIT]),
-        [EMPTY, ERNIE, KERMIT]);
+    assert.sameMembers(removeServiceUsers([EMPTY, ERNIE]), [EMPTY, ERNIE]);
     assert.sameMembers(removeServiceUsers([SERVY, BOTTY]), []);
-    assert.sameMembers(removeServiceUsers([EMPTY, SERVY, ERNIE, BOTTY, KERMIT]),
-        [EMPTY, ERNIE, KERMIT]);
+    assert.sameMembers(removeServiceUsers([EMPTY, SERVY, ERNIE, BOTTY]), [
+      EMPTY,
+      ERNIE,
+    ]);
   });
 });
diff --git a/polygerrit-ui/app/utils/async-util.ts b/polygerrit-ui/app/utils/async-util.ts
index 2b36fee..c82f5e4 100644
--- a/polygerrit-ui/app/utils/async-util.ts
+++ b/polygerrit-ui/app/utils/async-util.ts
@@ -110,3 +110,23 @@
   existingTask?.cancel();
   return new DelayedTask(callback, waitMs);
 }
+
+const THROTTLE_INTERVAL_MS = 500;
+
+/**
+ * Ensure only one call is made within THROTTLE_INTERVAL_MS and any call within
+ * this interval is ignored
+ */
+export function throttleWrap(fn: (e: Event) => void) {
+  let lastCall: number | undefined;
+  return (e: Event) => {
+    if (
+      lastCall !== undefined &&
+      Date.now() - lastCall < THROTTLE_INTERVAL_MS
+    ) {
+      return;
+    }
+    lastCall = Date.now();
+    fn(e);
+  };
+}
diff --git a/polygerrit-ui/app/utils/attention-set-util_test.js b/polygerrit-ui/app/utils/attention-set-util_test.ts
similarity index 63%
rename from polygerrit-ui/app/utils/attention-set-util_test.js
rename to polygerrit-ui/app/utils/attention-set-util_test.ts
index 9d8c086..0e95817 100644
--- a/polygerrit-ui/app/utils/attention-set-util_test.js
+++ b/polygerrit-ui/app/utils/attention-set-util_test.ts
@@ -15,40 +15,38 @@
  * limitations under the License.
  */
 
-import '../test/common-test-setup-karma.js';
+import '../test/common-test-setup-karma';
+import {createChange} from '../test/test-data-generators';
 import {
-  hasAttention, getReason,
-} from './attention-set-util.js';
+  AccountId,
+  AccountInfo,
+  ChangeInfo,
+  EmailAddress,
+} from '../types/common';
+import {hasAttention, getReason} from './attention-set-util';
 
-const KERMIT = {
-  email: 'kermit@gmail.com',
+const KERMIT: AccountInfo = {
+  email: 'kermit@gmail.com' as EmailAddress,
   username: 'kermit',
   name: 'Kermit The Frog',
-  _account_id: '31415926535',
+  _account_id: 31415926535 as AccountId,
+};
+const change: ChangeInfo = {
+  ...createChange(),
+  attention_set: {
+    '31415926535': {
+      account: KERMIT,
+      reason: 'a good reason',
+    },
+  },
 };
 
 suite('attention-set-util', () => {
   test('hasAttention', () => {
-    const change = {
-      attention_set: {
-        31415926535: {
-          reason: 'a good reason',
-        },
-      },
-    };
-
     assert.isTrue(hasAttention(KERMIT, change));
   });
 
   test('getReason', () => {
-    const change = {
-      attention_set: {
-        31415926535: {
-          reason: 'a good reason',
-        },
-      },
-    };
-
     assert.equal(getReason(KERMIT, change), 'a good reason');
   });
 });
diff --git a/polygerrit-ui/app/utils/change-util.ts b/polygerrit-ui/app/utils/change-util.ts
index 380d06c..3dac8d3 100644
--- a/polygerrit-ui/app/utils/change-util.ts
+++ b/polygerrit-ui/app/utils/change-util.ts
@@ -187,11 +187,15 @@
   account?: AccountInfo
 ): boolean {
   if (!change || !account) return false;
+  if (isOwner(change, account)) return false;
   const reviewers = change.reviewers.REVIEWER ?? [];
   return reviewers.some(r => r._account_id === account._account_id);
 }
 
-export function isCc(change?: ChangeInfo, account?: AccountInfo): boolean {
+export function isCc(
+  change?: ChangeInfo | ParsedChangeInfo,
+  account?: AccountInfo
+): boolean {
   if (!change || !account) return false;
   const ccs = change.reviewers.CC ?? [];
   return ccs.some(r => r._account_id === account._account_id);
diff --git a/polygerrit-ui/app/utils/comment-util.ts b/polygerrit-ui/app/utils/comment-util.ts
index 2d5f66e..fd276c3 100644
--- a/polygerrit-ui/app/utils/comment-util.ts
+++ b/polygerrit-ui/app/utils/comment-util.ts
@@ -27,6 +27,7 @@
   ContextLine,
   BasePatchSetNum,
   RevisionPatchSetNum,
+  AccountInfo,
 } from '../types/common';
 import {CommentSide, Side, SpecialFilePath} from '../constants/constants';
 import {parseDate} from './date-util';
@@ -330,3 +331,18 @@
   };
   return diff;
 }
+
+export function getCommentAuthors(threads?: CommentThread[]) {
+  if (!threads) return [];
+  const ids = new Set();
+  const authors: AccountInfo[] = [];
+  threads.forEach(t =>
+    t.comments.forEach(c => {
+      if (c.author && !ids.has(c.author._account_id)) {
+        ids.add(c.author._account_id);
+        authors.push(c.author);
+      }
+    })
+  );
+  return authors;
+}
diff --git a/polygerrit-ui/app/utils/common-util_test.js b/polygerrit-ui/app/utils/common-util_test.ts
similarity index 90%
rename from polygerrit-ui/app/utils/common-util_test.js
rename to polygerrit-ui/app/utils/common-util_test.ts
index d6d66d7..4156729 100644
--- a/polygerrit-ui/app/utils/common-util_test.js
+++ b/polygerrit-ui/app/utils/common-util_test.ts
@@ -15,14 +15,14 @@
  * limitations under the License.
  */
 
-import '../test/common-test-setup-karma.js';
-import {hasOwnProperty, areSetsEqual, containsAll} from './common-util.js';
+import '../test/common-test-setup-karma';
+import {hasOwnProperty, areSetsEqual, containsAll} from './common-util';
 
 suite('common-util tests', () => {
   suite('hasOwnProperty', () => {
     test('object with the default prototype', () => {
       const obj = {
-        'abc': 3,
+        abc: 3,
         'name with spaces': 5,
       };
       assert.isTrue(hasOwnProperty(obj, 'abc'));
@@ -30,13 +30,15 @@
       assert.isFalse(hasOwnProperty(obj, 'def'));
     });
     test('object prototype has overridden hasOwnProperty', () => {
-      const F = function() {
-        this.abc = 23;
-      };
-      F.prototype.hasOwnProperty = function(key) {
-        return true;
-      };
-      const obj = new F();
+      class MyObject {
+        abc = 123;
+
+        hasOwnProperty(_key: PropertyKey) {
+          return true;
+        }
+      }
+
+      const obj = new MyObject();
       assert.isTrue(hasOwnProperty(obj, 'abc'));
       assert.isFalse(hasOwnProperty(obj, 'def'));
     });
diff --git a/polygerrit-ui/app/utils/dom-util.ts b/polygerrit-ui/app/utils/dom-util.ts
index 4f83881..e7cc956 100644
--- a/polygerrit-ui/app/utils/dom-util.ts
+++ b/polygerrit-ui/app/utils/dom-util.ts
@@ -294,6 +294,11 @@
   }
 }
 
+export function modifierPressed(e: KeyboardEvent) {
+  return e.altKey || e.ctrlKey || e.metaKey || e.shiftKey;
+}
+
+// Deprecated. Try using "normal" KeyboardEvent and modifierPressed() above.
 export function isModifierPressed(event: CustomKeyboardEvent) {
   const e = getKeyboardEvent(event);
   return e.altKey || e.ctrlKey || e.metaKey || e.shiftKey;
diff --git a/polygerrit-ui/app/utils/safe-types-util_test.js b/polygerrit-ui/app/utils/safe-types-util_test.ts
similarity index 83%
rename from polygerrit-ui/app/utils/safe-types-util_test.js
rename to polygerrit-ui/app/utils/safe-types-util_test.ts
index e3968d0..03253e0 100644
--- a/polygerrit-ui/app/utils/safe-types-util_test.js
+++ b/polygerrit-ui/app/utils/safe-types-util_test.ts
@@ -15,12 +15,12 @@
  * limitations under the License.
  */
 
-import '../test/common-test-setup-karma.js';
-import {safeTypesBridge, _testOnly_SafeUrl} from './safe-types-util.js';
+import '../test/common-test-setup-karma';
+import {safeTypesBridge, _testOnly_SafeUrl} from './safe-types-util';
 
 suite('safe-types-util tests', () => {
   test('SafeUrl accepts valid urls', () => {
-    function accepts(url) {
+    function accepts(url: string) {
       const safeUrl = new _testOnly_SafeUrl(url);
       assert.isOk(safeUrl);
       assert.equal(url, safeUrl.toString());
@@ -35,8 +35,10 @@
   });
 
   test('SafeUrl rejects invalid urls', () => {
-    function rejects(url) {
-      assert.throws(() => { new _testOnly_SafeUrl(url); });
+    function rejects(url: string) {
+      assert.throws(() => {
+        new _testOnly_SafeUrl(url);
+      });
     }
     rejects('javascript://alert("evil");');
     rejects('ftp:example.com');
@@ -44,13 +46,14 @@
   });
 
   suite('safeTypesBridge', () => {
-    function acceptsString(value, type) {
-      assert.equal(safeTypesBridge(value, type),
-          value);
+    function acceptsString(value: string, type: string) {
+      assert.equal(safeTypesBridge(value, type), value);
     }
 
-    function rejects(value, type) {
-      assert.throws(() => { safeTypesBridge(value, type); });
+    function rejects(value: unknown, type: string) {
+      assert.throws(() => {
+        safeTypesBridge(value, type);
+      });
     }
 
     test('accepts valid URL strings', () => {
diff --git a/polygerrit-ui/app/utils/string-util.ts b/polygerrit-ui/app/utils/string-util.ts
index 6aae67f..43c0765 100644
--- a/polygerrit-ui/app/utils/string-util.ts
+++ b/polygerrit-ui/app/utils/string-util.ts
@@ -30,3 +30,11 @@
 export function charsOnly(s: string): string {
   return s.replace(/[^a-zA-Z]+/g, '');
 }
+
+export function ordinal(n?: number): string {
+  if (n === undefined) return '';
+  if (n % 10 === 1 && n % 100 !== 11) return `${n}st`;
+  if (n % 10 === 2 && n % 100 !== 12) return `${n}nd`;
+  if (n % 10 === 3 && n % 100 !== 13) return `${n}rd`;
+  return `${n}th`;
+}
diff --git a/polygerrit-ui/app/utils/string-util_test.ts b/polygerrit-ui/app/utils/string-util_test.ts
index 2eef50f..8de6ac2 100644
--- a/polygerrit-ui/app/utils/string-util_test.ts
+++ b/polygerrit-ui/app/utils/string-util_test.ts
@@ -16,7 +16,7 @@
  */
 
 import '../test/common-test-setup-karma';
-import {pluralize} from './string-util';
+import {pluralize, ordinal} from './string-util';
 
 suite('formatter util tests', () => {
   test('pluralize', () => {
@@ -25,4 +25,18 @@
     assert.equal(pluralize(1, noun), '1 comment');
     assert.equal(pluralize(2, noun), '2 comments');
   });
+
+  test('ordinal', () => {
+    assert.equal(ordinal(0), '0th');
+    assert.equal(ordinal(1), '1st');
+    assert.equal(ordinal(2), '2nd');
+    assert.equal(ordinal(3), '3rd');
+    assert.equal(ordinal(4), '4th');
+    assert.equal(ordinal(10), '10th');
+    assert.equal(ordinal(11), '11th');
+    assert.equal(ordinal(12), '12th');
+    assert.equal(ordinal(13), '13th');
+    assert.equal(ordinal(44413), '44413th');
+    assert.equal(ordinal(44451), '44451st');
+  });
 });