Merge "Make navigating cursor on file list circular"
diff --git a/Documentation/config-gerrit.txt b/Documentation/config-gerrit.txt
index 1d787cc..2204c65 100644
--- a/Documentation/config-gerrit.txt
+++ b/Documentation/config-gerrit.txt
@@ -914,6 +914,12 @@
 +
 If direct updates are made to `All-Users`, this cache should be flushed.
 
+cache `"approvals"`::
++
+Cache entries contain approvals for a given patch set. This includes
+approvals granted on this patch set as well as approvals copied from
+earlier patch sets.
+
 cache `"adv_bases"`::
 +
 Used only for push over smart HTTP when branch level access controls
@@ -1426,21 +1432,6 @@
 +
 By default true.
 
-[[change.replyLabel]]change.replyLabel::
-+
-Label name for the reply button. In the user interface an ellipsis (…)
-is appended.
-+
-Default is "Reply". In the user interface it becomes "Reply…".
-
-[[change.replyTooltip]]change.replyTooltip::
-+
-Tooltip for the reply button. In the user interface a note about the
-keyboard shortcut is appended.
-+
-Default is "Reply and score". In the user interface it becomes "Reply
-and score (Shortcut: a)".
-
 [[change.robotCommentSizeLimit]]change.robotCommentSizeLimit::
 +
 Maximum allowed size in characters of a robot comment. Robot comments which
@@ -5108,6 +5099,16 @@
 +
 By default, all supported MACs are available.
 
+[[sshd.enableDeprecatedKexAlgorithms]]sshd.enableDeprecatedKexAlgorithms::
++
+Enable deprecated kex algorithms:
++
+* `diffie-hellman-group1-sha1`
+* `diffie-hellman-group14-sha1`
+* `diffie-hellman-group-exchange-sha1`
+
+By default, the deprecated kex algorithms are disabled.
+
 [[sshd.kex]]sshd.kex::
 +
 --
@@ -5118,24 +5119,20 @@
 algorithms, key exchange algorithm names starting with `-` are
 removed from the default key exchange algorithms.
 
-In the following example configuration, support for the 1024-bit
-`diffie-hellman-group1-sha1` key exchange is disabled while leaving
-all of the other default algorithms enabled:
-
-----
-[sshd]
-  kex = -diffie-hellman-group1-sha1
-----
-
 Supported key exchange algorithms:
 
 * `ecdh-sha2-nistp521`
 * `ecdh-sha2-nistp384`
 * `ecdh-sha2-nistp256`
 * `diffie-hellman-group-exchange-sha256`
-* `diffie-hellman-group-exchange-sha1`
-* `diffie-hellman-group14-sha1`
-* `diffie-hellman-group1-sha1`
+* `diffie-hellman-group18-sha512`
+* `diffie-hellman-group17-sha512`
+* `diffie-hellman-group16-sha512`
+* `diffie-hellman-group15-sha512`
+* `diffie-hellman-group14-sha256`
+
+See link:#sshd.enableDeprecatedKexAlgorithms[sshd.enableDeprecatedKexAlgorithms]
+for deprecated key algorithms and how to enable them.
 
 By default, all supported key exchange algorithms are available.
 
diff --git a/Documentation/config-labels.txt b/Documentation/config-labels.txt
index 9d3446e..5889c75 100644
--- a/Documentation/config-labels.txt
+++ b/Documentation/config-labels.txt
@@ -268,6 +268,58 @@
 If true, any score for the label is copied forward when a new patch
 set is uploaded. Defaults to false.
 
+[[label_copyCondition]]
+=== `label.Label-Name.copyCondition`
+
+If set, Gerrit matches patch set approvals against the provided query
+string. If the query matches, the approval is copied from one patch set
+to the next. The query language is the same as for
+link:user-search.html[other queries].
+
+This logic is triggered whenever a new patch set is uploaded.
+
+Gerrit currently supports the following predicates:
+
+==== 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.
+
+==== is:{MIN,MAX,ANY}
+
+Matches votes that are equal to the minimal or maximal voting range. Or any votes.
+
+==== approverin:link:rest-api-groups.html#group-id[\{group-id\}]
+
+Matches votes granted by a user who is 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).
+
+==== 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
+
+Matches when the new patch-set includes the same files as the old patch-set.
+
+Only 'unchanged-files' is supported for 'has'.
+
+==== Example
+
+----
+copyCondition = is:MIN OR -change-kind:REWORK OR uploaderin:dead...beef
+----
+
 [[label_copyMinScore]]
 === `label.Label-Name.copyMinScore`
 
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-bazel.txt b/Documentation/dev-bazel.txt
index 61565f8..c3237ed 100644
--- a/Documentation/dev-bazel.txt
+++ b/Documentation/dev-bazel.txt
@@ -365,6 +365,13 @@
   bazel test --test_tag_filters=api,git //...
 ----
 
+To run the tests against a specific index backend (LUCENE, FAKE):
+----
+  bazel test --test_env=GERRIT_INDEX_TYPE=LUCENE //...
+----
+
+Elastic search is not currently supported in integration tests.
+
 The following values are currently supported for the group name:
 
 * annotation
diff --git a/Documentation/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/glossary.txt b/Documentation/glossary.txt
index 2b40b5b..83362ab 100644
--- a/Documentation/glossary.txt
+++ b/Documentation/glossary.txt
@@ -1,6 +1,12 @@
 :linkattrs:
 = Glossary
 
+[[cluster]]
+== Cluster
+A Gerrit Cluster is a set of Gerrit processes sharing the same
+link:config-gerrit.html#gerrit.serverId[ServerId] and associated to the same
+set of repositories, accounts, and groups.
+
 [[event]]
 == Event
 
@@ -32,6 +38,45 @@
 API for listening to Gerrit events from plugins, without having any
 visibility restrictions.
 
+[[multi-primary]]
+== Multi-primary
+Multi-primary typically refers to configurations where multiple Gerrit primary
+processes are running in one or more xref:cluster[clusters] together.
+
+=== Single cluster multi-primary with shared storage
+A variation of multi-primary (a.k.a. HA or high-availability) that shares a file
+storage volume for the git repositories. These configurations can use the
+link:https://gerrit.googlesource.com/plugins/high-availability[high-availability plugin]
+to synchronize or share caches, indexes, events, and web sessions. The
+replication plugin also
+link:https://gerrit.googlesource.com/plugins/replication/+/refs/heads/master/src/main/resources/Documentation/config.md#configuring-cluster-replication[supports]
+synchronizing events using a shared file storage volume.
+
+[[multi-cluster-multi-primary]]
+=== Multiple clusters multi-primary
+Multi-cluster (aka multi-site) primaries typically refers to configurations
+where multiple Gerrit primary processes are running in different (likely
+geographically distributed) clusters (sites). This also typically makes use of
+a multi-primary configuration within each cluster. Synchronization across sites
+is necessary to detect and prevent split-brain scenarios. These configurations
+can use the link:https://gerrit.googlesource.com/plugins/multi-site[multi-site plugin]
+to facilitate synchronization.
+
+[[primary]]
+== Primary
+A Gerrit primary is the link:pgm-daemon.html[main Gerrit process] permitting
+write operations by clients. Most installations of Gerrit have only a single
+Gerrit primary running at a time for their service.
+
+[[replica]]
+== Replica
+A Gerrit process running with the link:pgm-daemon.html[--replica switch]
+provided. This permits read-only git operations by clients. There is no REST
+API, WebUI, or search operation available. Replicas can be run in
+the same cluster with primaries (likely sharing the storage volume) or in other
+clusters/sites (likely facilitated by the
+link:https://gerrit.googlesource.com/plugins/replication[replication plugin]).
+
 [[stream-events]]
 == Stream events
 
diff --git a/Documentation/images/gwt-user-review-ui-change-screen-change-info-cannot-merge.png b/Documentation/images/gwt-user-review-ui-change-screen-change-info-cannot-merge.png
deleted file mode 100644
index 69a28ec..0000000
--- a/Documentation/images/gwt-user-review-ui-change-screen-change-info-cannot-merge.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/gwt-user-review-ui-change-screen-change-info.png b/Documentation/images/gwt-user-review-ui-change-screen-change-info.png
deleted file mode 100644
index e92b49d..0000000
--- a/Documentation/images/gwt-user-review-ui-change-screen-change-info.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/gwt-user-review-ui-change-screen-change-update.png b/Documentation/images/gwt-user-review-ui-change-screen-change-update.png
deleted file mode 100644
index 227db40..0000000
--- a/Documentation/images/gwt-user-review-ui-change-screen-change-update.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/gwt-user-review-ui-change-screen-commit-info-merge-commit.png b/Documentation/images/gwt-user-review-ui-change-screen-commit-info-merge-commit.png
deleted file mode 100644
index 097637e..0000000
--- a/Documentation/images/gwt-user-review-ui-change-screen-commit-info-merge-commit.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/gwt-user-review-ui-change-screen-commit-info.png b/Documentation/images/gwt-user-review-ui-change-screen-commit-info.png
deleted file mode 100644
index fe0c1d1..0000000
--- a/Documentation/images/gwt-user-review-ui-change-screen-commit-info.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/gwt-user-review-ui-change-screen-history.png b/Documentation/images/gwt-user-review-ui-change-screen-history.png
deleted file mode 100644
index 3fe71d8..0000000
--- a/Documentation/images/gwt-user-review-ui-change-screen-history.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/gwt-user-review-ui-change-screen-included-in-list.png b/Documentation/images/gwt-user-review-ui-change-screen-included-in-list.png
deleted file mode 100644
index ad30fe2..0000000
--- a/Documentation/images/gwt-user-review-ui-change-screen-included-in-list.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/gwt-user-review-ui-change-screen-inline-comments.png b/Documentation/images/gwt-user-review-ui-change-screen-inline-comments.png
deleted file mode 100644
index a10f40a..0000000
--- a/Documentation/images/gwt-user-review-ui-change-screen-inline-comments.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/gwt-user-review-ui-change-screen-not-current.png b/Documentation/images/gwt-user-review-ui-change-screen-not-current.png
deleted file mode 100644
index 9a87c67..0000000
--- a/Documentation/images/gwt-user-review-ui-change-screen-not-current.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/gwt-user-review-ui-change-screen-permalink.png b/Documentation/images/gwt-user-review-ui-change-screen-permalink.png
deleted file mode 100644
index a1aede9..0000000
--- a/Documentation/images/gwt-user-review-ui-change-screen-permalink.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/gwt-user-review-ui-change-screen-plugin-extensions.png b/Documentation/images/gwt-user-review-ui-change-screen-plugin-extensions.png
deleted file mode 100644
index 120b99c..0000000
--- a/Documentation/images/gwt-user-review-ui-change-screen-plugin-extensions.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/gwt-user-review-ui-change-screen-reply-to-comment.png b/Documentation/images/gwt-user-review-ui-change-screen-reply-to-comment.png
deleted file mode 100644
index 07bd8a2..0000000
--- a/Documentation/images/gwt-user-review-ui-change-screen-reply-to-comment.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/gwt-user-review-ui-change-screen-reply.png b/Documentation/images/gwt-user-review-ui-change-screen-reply.png
deleted file mode 100644
index 20837ea..0000000
--- a/Documentation/images/gwt-user-review-ui-change-screen-reply.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/gwt-user-review-ui-change-screen-replying.png b/Documentation/images/gwt-user-review-ui-change-screen-replying.png
deleted file mode 100644
index 0ae85ab..0000000
--- a/Documentation/images/gwt-user-review-ui-change-screen-replying.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/gwt-user-review-ui-side-by-side-diff-screen-comment-box.png b/Documentation/images/gwt-user-review-ui-side-by-side-diff-screen-comment-box.png
deleted file mode 100644
index 6de9e75..0000000
--- a/Documentation/images/gwt-user-review-ui-side-by-side-diff-screen-comment-box.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/gwt-user-review-ui-side-by-side-diff-screen-comment-edit.png b/Documentation/images/gwt-user-review-ui-side-by-side-diff-screen-comment-edit.png
deleted file mode 100644
index b349d0d..0000000
--- a/Documentation/images/gwt-user-review-ui-side-by-side-diff-screen-comment-edit.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/gwt-user-review-ui-side-by-side-diff-screen-comment-reply.png b/Documentation/images/gwt-user-review-ui-side-by-side-diff-screen-comment-reply.png
deleted file mode 100644
index 011f986..0000000
--- a/Documentation/images/gwt-user-review-ui-side-by-side-diff-screen-comment-reply.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/gwt-user-review-ui-side-by-side-diff-screen-comment.png b/Documentation/images/gwt-user-review-ui-side-by-side-diff-screen-comment.png
deleted file mode 100644
index 2ecc47e..0000000
--- a/Documentation/images/gwt-user-review-ui-side-by-side-diff-screen-comment.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/gwt-user-review-ui-side-by-side-diff-screen-commented.png b/Documentation/images/gwt-user-review-ui-side-by-side-diff-screen-commented.png
deleted file mode 100644
index 598d18d..0000000
--- a/Documentation/images/gwt-user-review-ui-side-by-side-diff-screen-commented.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/gwt-user-review-ui-side-by-side-diff-screen-inline-comments.png b/Documentation/images/gwt-user-review-ui-side-by-side-diff-screen-inline-comments.png
deleted file mode 100644
index 36f1360..0000000
--- a/Documentation/images/gwt-user-review-ui-side-by-side-diff-screen-inline-comments.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/gwt-user-review-ui-side-by-side-diff-screen-keyboard-shortcuts.png b/Documentation/images/gwt-user-review-ui-side-by-side-diff-screen-keyboard-shortcuts.png
deleted file mode 100644
index 6f63f0e4..0000000
--- a/Documentation/images/gwt-user-review-ui-side-by-side-diff-screen-keyboard-shortcuts.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/gwt-user-review-ui-side-by-side-diff-screen-navigation.png b/Documentation/images/gwt-user-review-ui-side-by-side-diff-screen-navigation.png
deleted file mode 100644
index 8146b76..0000000
--- a/Documentation/images/gwt-user-review-ui-side-by-side-diff-screen-navigation.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/gwt-user-review-ui-side-by-side-diff-screen-no-differences.png b/Documentation/images/gwt-user-review-ui-side-by-side-diff-screen-no-differences.png
deleted file mode 100644
index 5d721a6..0000000
--- a/Documentation/images/gwt-user-review-ui-side-by-side-diff-screen-no-differences.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/gwt-user-review-ui-side-by-side-diff-screen-patch-sets.png b/Documentation/images/gwt-user-review-ui-side-by-side-diff-screen-patch-sets.png
deleted file mode 100644
index 9bdd4a9..0000000
--- a/Documentation/images/gwt-user-review-ui-side-by-side-diff-screen-patch-sets.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/gwt-user-review-ui-side-by-side-diff-screen-project-and-file.png b/Documentation/images/gwt-user-review-ui-side-by-side-diff-screen-project-and-file.png
deleted file mode 100644
index 836964b..0000000
--- a/Documentation/images/gwt-user-review-ui-side-by-side-diff-screen-project-and-file.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/gwt-user-review-ui-side-by-side-diff-screen-rename.png b/Documentation/images/gwt-user-review-ui-side-by-side-diff-screen-rename.png
deleted file mode 100644
index b4d83ba..0000000
--- a/Documentation/images/gwt-user-review-ui-side-by-side-diff-screen-rename.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/gwt-user-review-ui-side-by-side-diff-screen-replied-done.png b/Documentation/images/gwt-user-review-ui-side-by-side-diff-screen-replied-done.png
deleted file mode 100644
index 918cdee..0000000
--- a/Documentation/images/gwt-user-review-ui-side-by-side-diff-screen-replied-done.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/gwt-user-review-ui-side-by-side-diff-screen.png b/Documentation/images/gwt-user-review-ui-side-by-side-diff-screen.png
deleted file mode 100644
index d76ecef..0000000
--- a/Documentation/images/gwt-user-review-ui-side-by-side-diff-screen.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/user-review-ui-change-screen-change-update.png b/Documentation/images/user-review-ui-change-screen-change-update.png
new file mode 100644
index 0000000..fe07ef9
--- /dev/null
+++ b/Documentation/images/user-review-ui-change-screen-change-update.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-change-screen-plugin-extensions.png b/Documentation/images/user-review-ui-change-screen-plugin-extensions.png
new file mode 100644
index 0000000..5d6fee7
--- /dev/null
+++ b/Documentation/images/user-review-ui-change-screen-plugin-extensions.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-change-screen-reply.png b/Documentation/images/user-review-ui-change-screen-reply.png
new file mode 100644
index 0000000..1c50fc5
--- /dev/null
+++ b/Documentation/images/user-review-ui-change-screen-reply.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-side-by-side-diff-screen-inline-comments.png b/Documentation/images/user-review-ui-side-by-side-diff-screen-inline-comments.png
new file mode 100644
index 0000000..047034c
--- /dev/null
+++ b/Documentation/images/user-review-ui-side-by-side-diff-screen-inline-comments.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-side-by-side-diff-screen-patch-sets.png b/Documentation/images/user-review-ui-side-by-side-diff-screen-patch-sets.png
new file mode 100644
index 0000000..edbbccb
--- /dev/null
+++ b/Documentation/images/user-review-ui-side-by-side-diff-screen-patch-sets.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-side-by-side-diff-screen-rename.png b/Documentation/images/user-review-ui-side-by-side-diff-screen-rename.png
new file mode 100644
index 0000000..0281362e
--- /dev/null
+++ b/Documentation/images/user-review-ui-side-by-side-diff-screen-rename.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-side-by-side-diff-screen-replied-done.png b/Documentation/images/user-review-ui-side-by-side-diff-screen-replied-done.png
new file mode 100644
index 0000000..a72011b
--- /dev/null
+++ b/Documentation/images/user-review-ui-side-by-side-diff-screen-replied-done.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-side-by-side-diff-screen.png b/Documentation/images/user-review-ui-side-by-side-diff-screen.png
new file mode 100644
index 0000000..74d02e3
--- /dev/null
+++ b/Documentation/images/user-review-ui-side-by-side-diff-screen.png
Binary files differ
diff --git a/Documentation/intro-gerrit-walkthrough-github.txt b/Documentation/intro-gerrit-walkthrough-github.txt
index 8f3ff88..173f709 100644
--- a/Documentation/intro-gerrit-walkthrough-github.txt
+++ b/Documentation/intro-gerrit-walkthrough-github.txt
@@ -25,7 +25,7 @@
 Here’s how getting code reviewed and submitted with Gerrit is different from
 doing the same with GitHub:
 
-* You need the add a commit-msg hook script when you clone a repo for the first
+* You need to add a commit-msg hook script when you clone a repo for the first
 time using a snippet you can find e.g. https://gerrit-review.googlesource.com/admin/repos/gerrit[here,role=external,window=_blank];
 * Your review will be on a single commit instead of a branch. You use
 `git commit --amend` to modify a code change.
diff --git a/Documentation/js_licenses.txt b/Documentation/js_licenses.txt
index 9ac438a..813ff44 100644
--- a/Documentation/js_licenses.txt
+++ b/Documentation/js_licenses.txt
@@ -250,6 +250,7 @@
 [[DefinitelyTyped]]
 DefinitelyTyped
 
+* @types/resemblejs
 * @types/resize-observer-browser
 
 [[DefinitelyTyped_license]]
@@ -1076,6 +1077,38 @@
 ----
 
 
+[[immer]]
+immer
+
+* immer
+
+[[immer_license]]
+----
+MIT License
+
+Copyright (c) 2017 Michel Weststrate
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+----
+
+
 [[isarray]]
 isarray
 
@@ -1251,6 +1284,35 @@
 ----
 
 
+[[resemblejs]]
+resemblejs
+
+* resemblejs
+
+[[resemblejs_license]]
+----
+The MIT License (MIT) Copyright © 2013 Huddle

+

+Permission is hereby granted, free of charge, to any person obtaining a copy of

+this software and associated documentation files (the “Software”), to deal in

+the Software without restriction, including without limitation the rights to

+use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of

+the Software, and to permit persons to whom the Software is furnished to do so,

+subject to the following conditions:

+

+The above copyright notice and this permission notice shall be included in all

+copies or substantial portions of the Software.

+

+THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR

+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS

+FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR

+COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER

+IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN

+CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+
+----
+
+
 [[rxjs]]
 rxjs
 
diff --git a/Documentation/licenses.txt b/Documentation/licenses.txt
index 1bff25c..11f9ff3 100644
--- a/Documentation/licenses.txt
+++ b/Documentation/licenses.txt
@@ -3209,6 +3209,7 @@
 [[DefinitelyTyped]]
 DefinitelyTyped
 
+* @types/resemblejs
 * @types/resize-observer-browser
 
 [[DefinitelyTyped_license]]
@@ -4035,6 +4036,38 @@
 ----
 
 
+[[immer]]
+immer
+
+* immer
+
+[[immer_license]]
+----
+MIT License
+
+Copyright (c) 2017 Michel Weststrate
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+----
+
+
 [[isarray]]
 isarray
 
@@ -4210,6 +4243,35 @@
 ----
 
 
+[[resemblejs]]
+resemblejs
+
+* resemblejs
+
+[[resemblejs_license]]
+----
+The MIT License (MIT) Copyright © 2013 Huddle

+

+Permission is hereby granted, free of charge, to any person obtaining a copy of

+this software and associated documentation files (the “Software”), to deal in

+the Software without restriction, including without limitation the rights to

+use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of

+the Software, and to permit persons to whom the Software is furnished to do so,

+subject to the following conditions:

+

+The above copyright notice and this permission notice shall be included in all

+copies or substantial portions of the Software.

+

+THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR

+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS

+FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR

+COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER

+IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN

+CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+
+----
+
+
 [[rxjs]]
 rxjs
 
diff --git a/Documentation/linux-quickstart.txt b/Documentation/linux-quickstart.txt
index 29bb409..c45de05 100644
--- a/Documentation/linux-quickstart.txt
+++ b/Documentation/linux-quickstart.txt
@@ -19,8 +19,7 @@
 
 . A Unix-based server, including any Linux flavor, MacOS, or Berkeley Software
     Distribution (BSD).
-. Java SE Runtime Environment version 1.8. Gerrit is not compatible with Java
-    9 or newer yet.
+. Java SE Runtime Environment version 11 and up.
 
 == Download Gerrit
 
@@ -105,7 +104,7 @@
 Now that you have a simple version of Gerrit running, use the installation to
 explore the user interface and learn about Gerrit. For more detailed
 installation instructions, see
-link:[Standalone Daemon Installation Guide](install.html).
+link:install.html[Standalone Daemon Installation Guide].
 
 GERRIT
 ------
diff --git a/Documentation/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/repository-maintenance.txt b/Documentation/repository-maintenance.txt
index 1672436..4bf84b5 100644
--- a/Documentation/repository-maintenance.txt
+++ b/Documentation/repository-maintenance.txt
@@ -28,7 +28,7 @@
 
 Unlike a typical server database, access to Git repositories is not
 marshalled through a single process or a set of inter communicating
-processes. Unfortuntatlely the design of the on-disk layout of a Git
+processes. Unfortunately the design of the on-disk layout of a Git
 repository does not allow for 100% race free operations when accessed by
 multiple actors concurrently. These design shortcomings are more likely
 to impact the operations of busy repositories since racy conditions are
diff --git a/Documentation/rest-api-accounts.txt b/Documentation/rest-api-accounts.txt
index 8aa3173..410bf42 100644
--- a/Documentation/rest-api-accounts.txt
+++ b/Documentation/rest-api-accounts.txt
@@ -1760,7 +1760,7 @@
   [
     {
       "identity": "username:john",
-      "email": "john.doe@example.com",
+      "email_address": "john.doe@example.com",
       "trusted": true
     }
   ]
@@ -2250,7 +2250,7 @@
 |============================
 |Field Name        ||Description
 |`identity`        ||The account external id.
-|`email`           |optional|The email address for the external id.
+|`email_address`   |optional|The email address for the external id.
 |`trusted`         |not set if `false`|
 Whether the external id is trusted.
 |`can_delete`      |not set if `false`|
diff --git a/Documentation/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/rest-api-config.txt b/Documentation/rest-api-config.txt
index 41a8729..bd93b8b 100644
--- a/Documentation/rest-api-config.txt
+++ b/Documentation/rest-api-config.txt
@@ -1552,12 +1552,6 @@
 |`allow_blame`        |not set if `false`|
 link:config-gerrit.html#change.allowBlame[Whether blame on side by side diff is
 allowed].
-|`reply_label`        ||
-link:config-gerrit.html#change.replyTooltip[Label name for the reply
-button].
-|`reply_tooltip`      ||
-link:config-gerrit.html#change.replyTooltip[Tooltip for the reply
-button].
 |`update_delay`       ||
 link:config-gerrit.html#change.updateDelay[How often in seconds the web
 interface should poll for updates to the currently open change].
diff --git a/Documentation/rest-api-projects.txt b/Documentation/rest-api-projects.txt
index df83f1a..eb38434 100644
--- a/Documentation/rest-api-projects.txt
+++ b/Documentation/rest-api-projects.txt
@@ -3964,6 +3964,8 @@
 |`copy_any_score`|`false` if not set|
 Whether link:config-labels.html#label_copyAnyScore[copyAnyScore] is set on the
 label.
+|`copy_condition`|optional|
+See link:config-labels.html#label_copyCondition[copyCondition].
 |`copy_min_score`|`false` if not set|
 Whether link:config-labels.html#label_copyMinScore[copyMinScore] is set on the
 label.
@@ -4034,6 +4036,10 @@
 |`copy_any_score`|optional|
 Whether link:config-labels.html#label_copyAnyScore[copyAnyScore] is set on the
 label.
+|`copy_condition`|optional|
+See link:config-labels.html#label_copyCondition[copyCondition].
+|`unset_copy_condition`|optional|
+If true, clears the value stored in `copy_condition`.
 |`copy_min_score`|optional|
 Whether link:config-labels.html#label_copyMinScore[copyMinScore] is set on the
 label.
diff --git a/Documentation/rest-api.txt b/Documentation/rest-api.txt
index eabcaa9..ee5882a 100644
--- a/Documentation/rest-api.txt
+++ b/Documentation/rest-api.txt
@@ -244,6 +244,41 @@
 Given the trace ID an administrator can find the corresponding logs and
 investigate issues more easily.
 
+[[updated-refs]]
+=== X-Gerrit-UpdatedRef
+This is only enabled when "X-Gerrit-UpdatedRef-Enabled" is set to "true" in the
+request header.
+
+For each write REST request, we return X-Gerrit-UpdatedRef headers as the refs
+that were updated in the current request (involved in a ref transaction in the
+current request).
+
+The format of those headers is `PROJECT_NAME\~REF_NAME\~OLD_SHA-1\~NEW_SHA-1`.
+The project and ref names are URL-encoded, and must use %7E for '~'.
+
+A new SHA-1 of `0000000000000000000000000000000000000000` is treated as a
+deleted ref.
+If the new SHA-1 is not `0000000000000000000000000000000000000000`, the ref was
+either updated or created.
+If the old SHA-1 is `0000000000000000000000000000000000000000`, the ref was
+created.
+
+.Example Request
+----
+  DELETE /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940
+----
+
+.Example Response
+----
+HTTP/1.1 204 NO CONTENT
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+  X-Gerrit-UpdatedRef: myProject~refs%2Fchanges%2F01%2F1%2F1~deadbeefdeadbeefdeadbeefdeadbeefdeadbeef~0000000000000000000000000000000000000000
+  X-Gerrit-UpdatedRef: myProject~refs%2Fchanges%2F01%2F1%2Fmeta~deadbeefdeadbeefdeadbeefdeadbeefdeadbeef~0000000000000000000000000000000000000000
+
+  )]}'
+----
+
 GERRIT
 ------
 Part of link:index.html[Gerrit Code Review]
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-review-ui.txt b/Documentation/user-review-ui.txt
index c5feaee..6f5f729 100644
--- a/Documentation/user-review-ui.txt
+++ b/Documentation/user-review-ui.txt
@@ -218,7 +218,7 @@
 ** [[plugin-actions]]Further actions may be available if plugins are installed.
 
 +
-image::images/user-review-ui-change-screen-change-info-actions.png[width=600, link="images/user-review-ui-change-screen-change-info-actions.png"]
+image::images/user-review-ui-change-screen-change-info-actions.png[width=400, link="images/user-review-ui-change-screen-change-info-actions.png"]
 
 - [[labels]]Labels & Votes:
 +
@@ -259,7 +259,7 @@
 set is currently viewed can be seen from the `Patch Sets` drop-down
 panel in the change header.
 
-image::images/user-review-ui-change-screen-patch-sets.png[width=487, link="images/user-review-ui-change-screen-patch-sets.png"]
+image::images/user-review-ui-change-screen-patch-sets.png[width=300, link="images/user-review-ui-change-screen-patch-sets.png"]
 
 
 [[download]]
@@ -418,7 +418,7 @@
 currently viewed patch set; one can add a summary comment, publish
 inline draft comments, and vote on the labels.
 
-image::images/gwt-user-review-ui-change-screen-reply.png[width=800, link="images/gwt-user-review-ui-change-screen-reply.png"]
+image::images/user-review-ui-change-screen-reply.png[width=800, link="images/user-review-ui-change-screen-reply.png"]
 
 Clicking on the `Reply...` button opens a popup panel.
 
@@ -429,11 +429,8 @@
 items, and lines starting with "> " as block quotes (also see replying to
 link:#reply-to-message[messages] and link:#reply-inline-comment[inline comments]).
 
-Note that you can set the text and tooltip of the button in
-link:config-gerrit.html#change.replyLabel[gerrit.config].
-
 [[vote]]
-If the current patch set is viewed, radio buttons are displayed for
+If the current patch set is viewed, buttons are displayed for
 each label on which the user is allowed to vote. Voting on non-current
 patch sets is not possible.
 
@@ -444,8 +441,6 @@
 
 The `Post` button publishes the comments and the votes.
 
-image::images/gwt-user-review-ui-change-screen-replying.png[width=800, link="images/gwt-user-review-ui-change-screen-replying.png"]
-
 [[quick-approve]]
 If a user can approve a label that is still required, a quick approve
 button appears in the change header that allows to add this missing
@@ -463,7 +458,7 @@
 comments; a summary comment is only added if the reply popup panel is
 open when the quick approve button is clicked.
 
-image::images/user-review-ui-change-screen-quick-approve.png[width=800, link="images/gwt-user-review-ui-change-screen-quick-approve.png"]
+image::images/user-review-ui-change-screen-quick-approve.png[width=800, link="images/user-review-ui-change-screen-quick-approve.png"]
 
 [[history]]
 === History
@@ -474,32 +469,6 @@
 message is added when a new patch set is uploaded or when a review was
 done.
 
-Messages with new comments from other users, that were published after
-the current user last reviewed this change, are automatically expanded.
-
-image::images/gwt-user-review-ui-change-screen-history.png[width=800, link="images/gwt-user-review-ui-change-screen-history.png"]
-
-[[reply-to-message]]
-It is possible to directly reply to a change message by clicking on the
-reply icon in the right upper corner of a change message. This opens
-the reply popup panel and prefills the text box with the quoted comment.
-Then the reply can be written below the quoted comment or inserted
-inline. Lines starting with "> " will be rendered as a block quote.
-Please note that for a correct rendering it is important to leave a blank
-line between a quoted block and the reply to it.
-
-image::images/gwt-user-review-ui-change-screen-reply-to-comment.png[width=800, link="images/gwt-user-review-ui-change-screen-reply-to-comment.png"]
-
-[[inline-comments-in-history]]
-Inline comments are directly displayed in the change history and there
-are links to navigate to the inline comments.
-
-image::images/gwt-user-review-ui-change-screen-inline-comments.png[width=800, link="images/gwt-user-review-ui-change-screen-inline-comments.png"]
-
-[[expand-all]]
-The `Expand All` button expands all messages; the `Collapse All` button
-collapses all messages.
-
 [[update-notification]]
 === Update Notification
 
@@ -511,16 +480,16 @@
 it is 30 seconds. Polling may also be completely disabled by the
 administrator.
 
-image::images/gwt-user-review-ui-change-screen-change-update.png[width=800, link="images/gwt-user-review-ui-change-screen-change-update.png"]
+image::images/user-review-ui-change-screen-change-update.png[width=400, link="images/user-review-ui-change-screen-change-update.png"]
 
 [[plugin-extensions]]
 === Plugin Extensions
 
-Gerrit plugins may extend the change screen; they can add buttons for
-additional actions to the change info block and display arbitrary UI
-controls below the change info block.
+Gerrit plugins may extend the change screen. Java plugins in the
+backend can add additional actions to the triple-dot menu block.
+Frontend plugins can change the UI controls in arbitrary ways.
 
-image::images/gwt-user-review-ui-change-screen-plugin-extensions.png[width=800, link="images/gwt-user-review-ui-change-screen-plugin-extensions.png"]
+image::images/user-review-ui-change-screen-plugin-extensions.png[width=300, link="images/user-review-ui-change-screen-plugin-extensions.png"]
 
 [[side-by-side]]
 == Side-by-Side Diff Screen
@@ -531,17 +500,8 @@
 
 This screen allows to review a patch and to comment on it.
 
-image::images/gwt-user-review-ui-side-by-side-diff-screen.png[width=800, link="images/gwt-user-review-ui-side-by-side-diff-screen.png"]
+image::images/user-review-ui-side-by-side-diff-screen.png[width=800, link="images/user-review-ui-side-by-side-diff-screen.png"]
 
-[[side-by-side-header]]
-In the screen header the project name and the name of the viewed patch
-file are shown.
-
-If a Git web browser is configured on the server, the project name and
-the file path are displayed as links to the project and the folder in
-the Git web browser.
-
-image::images/gwt-user-review-ui-side-by-side-diff-screen-project-and-file.png[width=800, link="images/gwt-user-review-ui-side-by-side-diff-screen-project-and-file.png"]
 
 [[side-by-side-mark-reviewed]]
 The checkbox in front of the file name allows the
@@ -549,7 +509,7 @@
 diff preference allows to control whether the files should be
 automatically marked as reviewed when they are viewed.
 
-image::images/user-review-ui-side-by-side-diff-screen-reviewed.png[width=800, link="images/gwt-user-review-ui-side-by-side-diff-screen-reviewed.png"]
+image::images/user-review-ui-side-by-side-diff-screen-reviewed.png[width=800, link="images/user-review-ui-side-by-side-diff-screen-reviewed.png"]
 
 [[patch-set-selection]]
 In the header, on each side, the list of patch sets is shown. Clicking
@@ -569,34 +529,20 @@
 version before, may see what has changed since that version by
 comparing the old patch against the current patch.
 
-image::images/gwt-user-review-ui-side-by-side-diff-screen-patch-sets.png[width=800, link="images/gwt-user-review-ui-side-by-side-diff-screen-patch-sets.png"]
+image::images/user-review-ui-side-by-side-diff-screen-patch-sets.png[width=400, link="images/user-review-ui-side-by-side-diff-screen-patch-sets.png"]
 
 [[download-file]]
 The download icon next to the patch set list allows to download the
 patch. Unless the mime type of the file is configured as safe, the
 download file is a zip archive that contains the patch file.
 
-[[no-differences]]
-If the compared patches are identical, this is highlighted by a red
-`No Differences` label in the screen header.
-
-image::images/gwt-user-review-ui-side-by-side-diff-screen-no-differences.png[width=800, link="images/gwt-user-review-ui-side-by-side-diff-screen-no-differences.png"]
 
 [[side-by-side-rename]]
 If a file was renamed, the old and new file paths are shown in the
 header together with a similarity index that shows how much of the file
 content is unmodified.
 
-image::images/gwt-user-review-ui-side-by-side-diff-screen-rename.png[width=800, link="images/gwt-user-review-ui-side-by-side-diff-screen-rename.png"]
-
-[[navigation]]
-For navigating between the patches in a patch set there are navigation
-buttons on the right side of the screen header. The left arrow button
-navigates to the previous patch; the right arrow button navigates to
-the next patch. The arrow up button leads back to the change screen. In
-all cases the selection for the patch set comparison is kept.
-
-image::images/gwt-user-review-ui-side-by-side-diff-screen-navigation.png[width=800, link="images/gwt-user-review-ui-side-by-side-diff-screen-navigation.png"]
+image::images/user-review-ui-side-by-side-diff-screen-rename.png[width=400, link="images/user-review-ui-side-by-side-diff-screen-rename.png"]
 
 [[inline-comments]]
 === Inline Comments
@@ -612,26 +558,9 @@
 attach several comments to the same code.
 
 [[line-links]]
-The lines of the patch file are linkable. To link to a certain line in
-the patch file, '@<line-number>' must be appended to the patch link,
-e.g. `http://host:8080/#/c/56857/2/Documentation/user-review-ui.txt@665`.
-To link to a line in the old file version, '@a<line-number>' must be
-appended to the patch link. These links can be used to directly link to
-certain inline comments.
-
-If the diff preference link:#expand-all-comments[Expand All Comments]
-is set to `Expand`, all inline comments will be automatically expanded.
-
-image::images/gwt-user-review-ui-side-by-side-diff-screen-inline-comments.png[width=800, link="images/gwt-user-review-ui-side-by-side-diff-screen-inline-comments.png"]
-
-[[comment]]
-In the header of the comment box, the name of the comment author and
-the timestamp of the comment are shown. If avatars are configured on
-the server, the avatar image of the comment author is displayed in the
-top left corner. Below the actual comment there are buttons to reply to
-the comment.
-
-image::images/gwt-user-review-ui-side-by-side-diff-screen-comment-box.png[width=800, link="images/gwt-user-review-ui-side-by-side-diff-screen-comment-box.png"]
+The lines of the patch file are linkable: simply append
+'#<linenumber>' to the URL, or click on the line-number. This not only
+opens a draft comment box, but also sets the URL fragment.
 
 [[reply-inline-comment]]
 Clicking on the `Reply` button opens an editor to type the reply.
@@ -641,38 +570,25 @@
 note that for a correct rendering it is important to leave a blank line
 between a quoted block and the reply to it.
 
-Clicking on the `Save` button saves the comment as a draft. To make it
-visible to other users it must be published from the change screen by
-link:#reply[replying] to the change.
+image::images/user-review-ui-side-by-side-diff-screen-inline-comments.png[width=800, link="images/user-review-ui-side-by-side-diff-screen-inline-comments.png"]
 
-The `Cancel` button cancels the editing and discards any changes to the
-draft comment.
-
-Clicking on the `Discard` button deletes the inline draft comment.
-
-image::images/gwt-user-review-ui-side-by-side-diff-screen-comment-reply.png[width=800, link="images/gwt-user-review-ui-side-by-side-diff-screen-comment-reply.png"]
-
-[[draft-inline-comment]]
-Draft comments are marked by the text "Draft" in the header in the
-place of the comment author.
-
-A draft comment can be edited by clicking on the `Edit` button, or
-deleted by clicking on the `Discard` button.
-
-image::images/gwt-user-review-ui-side-by-side-diff-screen-comment-edit.png[width=800, link="images/gwt-user-review-ui-side-by-side-diff-screen-comment-edit.png"]
+Comments are first saved as drafts, and you can revisit the drafts as
+you read through code review. Finally, they should be published by
+clicking the "Reply".
 
 [[done]]
-Clicking on the `Done` button is a quick way to reply with "Done" to a
-comment. This is used to mark a comment as addressed by a follow-up
-patch set.
+Comments can be unresolved (something should be changed) or resolved
+(informational). If you have addressed an unresolved comment in a next
+patchset, you can quickly resolve the comment by clicking "Done" (if it was
+resolved in a next patchset) or "Ack" (if you acknowledge the comment,
+but don't want to make changes).
 
-image::images/gwt-user-review-ui-side-by-side-diff-screen-replied-done.png[width=800, link="images/gwt-user-review-ui-side-by-side-diff-screen-replied-done.png"]
+image::images/user-review-ui-side-by-side-diff-screen-replied-done.png[width=400, link="images/user-review-ui-side-by-side-diff-screen-replied-done.png"]
 
 [[add-inline-comment]]
 To add a new inline comment there are several possibilities:
 
 - select a code block and press 'c'
-- select a code block and click on the popup comment icon
 - go to a line, by clicking on it or by link:#key-navigation[key
   navigation], and press 'c'
 - click on a line number
@@ -687,23 +603,12 @@
 ** triple-click on a line to select it
 ** triple-click and drag with the mouse to select a code block line-wise
 
-- by keys (the same keys that are used for visual selection in Vim):
-** press 'v' + arrow keys (or 'h', 'j', 'k', 'l') to select a block
-** press 'V' + arrow keys (or 'j', 'k') to select a code block line-wise
-** type 'bvw' to select a word
-
-image::images/gwt-user-review-ui-side-by-side-diff-screen-comment.png[width=800, link="images/gwt-user-review-ui-side-by-side-diff-screen-comment.png"]
-
 For typing the new comment, a new comment box is shown under the code
 that is commented.
 
-Clicking on the `Save` button saves the new comment as a draft. To make
-it visible to other users it must be published from the change screen
-by link:#reply[replying] to the change.
-
-Clicking on the `Discard` button deletes the new comment.
-
-image::images/gwt-user-review-ui-side-by-side-diff-screen-commented.png[width=800, link="images/gwt-user-review-ui-side-by-side-diff-screen-commented.png"]
+Clicking on the `Save` button saves the new comment as a draft. To
+make it visible to other users it must be published from the change
+screen by link:#reply[replying] to the change.
 
 [[file-level-comments]]
 === File Level Comments
@@ -711,7 +616,7 @@
 File level comments are added by clicking the 'File' header at the top
 of the file.
 
-image::images/user-review-ui-side-by-side-diff-screen-file-level-comment.png[width=800, link="images/gwt-user-review-ui-side-by-side-diff-screen-file-level-comment.png"]
+image::images/user-review-ui-side-by-side-diff-screen-file-level-comment.png[width=400, link="images/user-review-ui-side-by-side-diff-screen-file-level-comment.png"]
 
 [[diff-preferences]]
 === Diff Preferences
@@ -721,7 +626,7 @@
 preferences. The diff preferences can be accessed by clicking on the
 settings icon in the screen header.
 
-image::images/user-review-ui-side-by-side-diff-screen-preferences.png[width=800, link="images/gwt-user-review-ui-side-by-side-diff-screen-preferences.png"]
+image::images/user-review-ui-side-by-side-diff-screen-preferences.png[width=800, link="images/user-review-ui-side-by-side-diff-screen-preferences.png"]
 
 The following diff preferences can be configured:
 
@@ -768,7 +673,7 @@
 If many lines are skipped there are additional links to expand the
 context by ten lines before and after the skipped block.
 +
-image::images/user-review-ui-side-by-side-diff-screen-expand-skipped-lines.png[width=800, link="images/gwt-user-review-ui-side-by-side-diff-screen-expand-skipped-lines.png"]
+image::images/user-review-ui-side-by-side-diff-screen-expand-skipped-lines.png[width=800, link="images/user-review-ui-side-by-side-diff-screen-expand-skipped-lines.png"]
 
 - [[syntax-highlighting]]`Syntax Highlighting`:
 +
@@ -798,7 +703,7 @@
 a popup that shows a list of available keyboard shortcuts.
 
 
-image::images/user-review-ui-change-screen-keyboard-shortcuts.png[width=800, link="images/gwt-user-review-ui-change-screen-keyboard-shortcuts.png"]
+image::images/user-review-ui-change-screen-keyboard-shortcuts.png[width=800, link="images/user-review-ui-change-screen-keyboard-shortcuts.png"]
 
 
 In addition, Vim-like commands can be used to link:#key-navigation[
diff --git a/Documentation/user-search.txt b/Documentation/user-search.txt
index a2dc31f..a9779b1 100644
--- a/Documentation/user-search.txt
+++ b/Documentation/user-search.txt
@@ -702,6 +702,18 @@
 to one of the fields in the
 link:rest-api-changes.html#submit-record[SubmitRecord] REST API entity.
 
+`label:Code-Review=MAX`::
++
+Matches changes with label voted with the highest possible score.
+
+`label:Code-Review=MIN`::
++
+Matches changes with label voted with the lowest possible score.
+
+`label:Code-Review=ANY`::
++
+Matches changes with label voted with any score.
+
 `label:Non-Author-Code-Review=need`::
 +
 Matches changes where the submit rules indicate that a label named
diff --git a/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/WORKSPACE b/WORKSPACE
index 0caf8c3..343eafd 100644
--- a/WORKSPACE
+++ b/WORKSPACE
@@ -70,6 +70,19 @@
     urls = ["https://github.com/bazelbuild/rules_nodejs/releases/download/3.5.0/rules_nodejs-3.5.0.tar.gz"],
 )
 
+http_archive(
+    name = "rules_pkg",
+    sha256 = "038f1caa773a7e35b3663865ffb003169c6a71dc995e39bf4815792f385d837d",
+    urls = [
+        "https://mirror.bazel.build/github.com/bazelbuild/rules_pkg/releases/download/0.4.0/rules_pkg-0.4.0.tar.gz",
+        "https://github.com/bazelbuild/rules_pkg/releases/download/0.4.0/rules_pkg-0.4.0.tar.gz",
+    ],
+)
+
+load("@rules_pkg//:deps.bzl", "rules_pkg_dependencies")
+
+rules_pkg_dependencies()
+
 # Golang support for PolyGerrit local dev server.
 http_archive(
     name = "io_bazel_rules_go",
@@ -941,6 +954,7 @@
 
 yarn_install(
     name = "npm",
+    data = ["//:twinkie.patch"],
     frozen_lockfile = False,
     package_json = "//:package.json",
     yarn_lock = "//:yarn.lock",
@@ -948,7 +962,17 @@
 
 yarn_install(
     name = "ui_npm",
-    args = ["--prod"],
+    args = [
+        "--prod",
+        # By default, yarn install all optional dependencies.
+        # In some cases, it installs a lot of additional dependencies which
+        # are not required (for example, "resemblejs" has one optional
+        # dependencies "canvas" that leads to tens of additional dependencies).
+        # Each additional dependency requires a license even if it is not used
+        # in our code.  We want to ensure that all optional dependencies are
+        # explicitly added to package.json.
+        "--ignore-optional",
+    ],
     frozen_lockfile = False,
     package_json = "//:polygerrit-ui/app/package.json",
     yarn_lock = "//:polygerrit-ui/app/yarn.lock",
@@ -991,5 +1015,5 @@
 maven_jar(
     name = "testcontainers-elasticsearch",
     artifact = "org.testcontainers:elasticsearch:" + TESTCONTAINERS_VERSION,
-    sha1 = "6b778a270b7529fcb9b7a6f62f3ae9d38544ce2f",
+    sha1 = "595e3a50f59cd3c1d281ca6c1bc4037e277a1353",
 )
diff --git a/e2e-tests/src/test/resources/hooks/commit-msg b/e2e-tests/src/test/resources/hooks/commit-msg
deleted file mode 100644
index b05a671..0000000
--- a/e2e-tests/src/test/resources/hooks/commit-msg
+++ /dev/null
@@ -1,43 +0,0 @@
-#!/bin/sh
-#
-# Part of Gerrit Code Review (https://www.gerritcodereview.com/)
-#
-# Copyright (C) 2009 The Android Open Source Project
-#
-# Licensed under the Apache License, Version 2.0 (the "License");
-# you may not use this file except in compliance with the License.
-# You may obtain a copy of the License at
-#
-# http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License is distributed on an "AS IS" BASIS,
-# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-# See the License for the specific language governing permissions and
-# limitations under the License.
-
-# avoid [[ which is not POSIX sh.
-if test "$#" != 1 ; then
-  echo "$0 requires an argument."
-  exit 1
-fi
-
-if test ! -f "$1" ; then
-  echo "file does not exist: $1"
-  exit 1
-fi
-
-if test ! -s "$1" ; then
-  echo "file is empty: $1"
-  exit 1
-fi
-
-# $RANDOM will be undefined if not using bash, so don't use set -u
-random=$( (whoami ; hostname ; date; cat $1 ; echo $RANDOM) | git hash-object --stdin)
-dest="$1.tmp.${random}"
-
-# Avoid the --in-place option which only appeared in Git 2.8
-# Avoid the --if-exists option which only appeared in Git 2.15
-cat "$1" \
-| git -c trailer.ifexists=doNothing interpret-trailers --trailer "Change-Id: I${random}" > "${dest}" \
-&& mv "${dest}" "$1"
diff --git a/e2e-tests/src/test/resources/hooks/commit-msg b/e2e-tests/src/test/resources/hooks/commit-msg
new file mode 120000
index 0000000..6066256
--- /dev/null
+++ b/e2e-tests/src/test/resources/hooks/commit-msg
@@ -0,0 +1 @@
+../../../../../resources/com/google/gerrit/server/tools/root/hooks/commit-msg
\ No newline at end of file
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/acceptance/BUILD b/java/com/google/gerrit/acceptance/BUILD
index 5ee1a08..fa62cd9 100644
--- a/java/com/google/gerrit/acceptance/BUILD
+++ b/java/com/google/gerrit/acceptance/BUILD
@@ -75,6 +75,7 @@
     "//java/com/google/gerrit/extensions/restapi/testing:restapi-test-util",
     "//java/com/google/gerrit/gpg/testing:gpg-test-util",
     "//java/com/google/gerrit/git/testing",
+    "//java/com/google/gerrit/index/testing",
 ]
 
 PGM_DEPLOY_ENV = [
diff --git a/java/com/google/gerrit/acceptance/GerritServer.java b/java/com/google/gerrit/acceptance/GerritServer.java
index 93c1237..085fef5 100644
--- a/java/com/google/gerrit/acceptance/GerritServer.java
+++ b/java/com/google/gerrit/acceptance/GerritServer.java
@@ -45,6 +45,8 @@
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.annotations.Exports;
 import com.google.gerrit.extensions.config.FactoryModule;
+import com.google.gerrit.index.IndexType;
+import com.google.gerrit.index.testing.FakeIndexModule;
 import com.google.gerrit.lucene.LuceneIndexModule;
 import com.google.gerrit.pgm.Daemon;
 import com.google.gerrit.pgm.Init;
@@ -449,9 +451,29 @@
     cfg.setString("gitweb", null, "cgi", "");
     cfg.setString(
         "accountPatchReviewDb", null, "url", JdbcAccountPatchReviewStore.TEST_IN_MEMORY_URL);
+
+    String configuredIndexBackend = cfg.getString("index", null, "type");
+    IndexType indexType;
+    if (configuredIndexBackend != null) {
+      // Explicitly configured index backend from gerrit.config trumps any other ways to configure
+      // index backends so that Reindex tests can be explicit about the backend they want to test
+      // against.
+      indexType = new IndexType(configuredIndexBackend);
+    } else {
+      // Allow configuring the index backend based on sys/env variables so that integration tests
+      // can be run against different index backends.
+      indexType = IndexType.fromEnvironment().orElse(new IndexType("fake"));
+    }
+    if (indexType.isLucene()) {
+      daemon.setIndexModule(
+          LuceneIndexModule.singleVersionAllLatest(0, ReplicaUtil.isReplica(baseConfig)));
+    } else {
+      daemon.setIndexModule(FakeIndexModule.latestVersion(false));
+    }
+    // Elastic search is not supported in integration tests yet.
+
     daemon.setEnableHttpd(desc.httpd());
-    daemon.setLuceneModule(
-        LuceneIndexModule.singleVersionAllLatest(0, ReplicaUtil.isReplica(baseConfig)));
+    daemon.setInMemory(true);
     daemon.setDatabaseForTesting(
         ImmutableList.of(
             new InMemoryTestingDatabaseModule(cfg, site, inMemoryRepoManager),
@@ -476,6 +498,8 @@
       String[] additionalArgs)
       throws Exception {
     requireNonNull(site);
+    daemon.addAdditionalSysModuleForTesting(
+        new ReindexProjectsAtStartup.Module(), new ReindexGroupsAtStartup.Module());
     ExecutorService daemonService = Executors.newSingleThreadExecutor();
     String[] args =
         Stream.concat(
diff --git a/java/com/google/gerrit/acceptance/InMemoryTestingDatabaseModule.java b/java/com/google/gerrit/acceptance/InMemoryTestingDatabaseModule.java
index de9a43d..e4b0eea 100644
--- a/java/com/google/gerrit/acceptance/InMemoryTestingDatabaseModule.java
+++ b/java/com/google/gerrit/acceptance/InMemoryTestingDatabaseModule.java
@@ -21,7 +21,9 @@
 import com.google.gerrit.extensions.events.LifecycleListener;
 import com.google.gerrit.lifecycle.LifecycleModule;
 import com.google.gerrit.metrics.MetricMaker;
+import com.google.gerrit.server.config.FileBasedGlobalPluginConfigProvider;
 import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.config.GlobalPluginConfigProvider;
 import com.google.gerrit.server.config.SitePath;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.config.TrackingFooters;
@@ -54,6 +56,7 @@
   @Override
   protected void configure() {
     bind(Config.class).annotatedWith(GerritServerConfig.class).toInstance(cfg);
+    bind(GlobalPluginConfigProvider.class).to(FileBasedGlobalPluginConfigProvider.class);
     bind(Path.class).annotatedWith(SitePath.class).toInstance(sitePath);
 
     if (repoManager != null) {
diff --git a/java/com/google/gerrit/acceptance/PushOneCommit.java b/java/com/google/gerrit/acceptance/PushOneCommit.java
index 67e26ec..d46fb78 100644
--- a/java/com/google/gerrit/acceptance/PushOneCommit.java
+++ b/java/com/google/gerrit/acceptance/PushOneCommit.java
@@ -30,7 +30,7 @@
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
-import com.google.gerrit.server.ApprovalsUtil;
+import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
 import com.google.gerrit.server.query.change.ChangeData;
@@ -282,6 +282,11 @@
     return this;
   }
 
+  public PushOneCommit noParent() throws Exception {
+    commitBuilder.noParents();
+    return this;
+  }
+
   public PushOneCommit addSymlink(String path, String target) throws Exception {
     RevBlob blobId = testRepo.blob(target);
     commitBuilder.edit(
diff --git a/java/com/google/gerrit/acceptance/RestSession.java b/java/com/google/gerrit/acceptance/RestSession.java
index 7ee1b26..342cbd0 100644
--- a/java/com/google/gerrit/acceptance/RestSession.java
+++ b/java/com/google/gerrit/acceptance/RestSession.java
@@ -37,17 +37,17 @@
   }
 
   public RestResponse get(String endPoint) throws IOException {
-    return getWithHeader(endPoint, null);
+    return getWithHeaders(endPoint);
   }
 
   public RestResponse getJsonAccept(String endPoint) throws IOException {
-    return getWithHeader(endPoint, new BasicHeader(ACCEPT, "application/json"));
+    return getWithHeaders(endPoint, new BasicHeader(ACCEPT, "application/json"));
   }
 
-  public RestResponse getWithHeader(String endPoint, Header header) throws IOException {
+  public RestResponse getWithHeaders(String endPoint, Header... headers) throws IOException {
     Request get = Request.Get(getUrl(endPoint));
-    if (header != null) {
-      get.addHeader(header);
+    if (headers != null) {
+      get.setHeaders(headers);
     }
     return execute(get);
   }
@@ -57,22 +57,22 @@
   }
 
   public RestResponse put(String endPoint) throws IOException {
-    return put(endPoint, null);
+    return put(endPoint, /* content = */ null);
   }
 
   public RestResponse put(String endPoint, Object content) throws IOException {
-    return putWithHeader(endPoint, null, content);
+    return putWithHeaders(endPoint, content);
   }
 
-  public RestResponse putWithHeader(String endPoint, Header header) throws IOException {
-    return putWithHeader(endPoint, header, null);
+  public RestResponse putWithHeaders(String endPoint, Header... headers) throws IOException {
+    return putWithHeaders(endPoint, /* content= */ null, headers);
   }
 
-  public RestResponse putWithHeader(String endPoint, Header header, Object content)
+  public RestResponse putWithHeaders(String endPoint, Object content, Header... headers)
       throws IOException {
     Request put = Request.Put(getUrl(endPoint));
-    if (header != null) {
-      put.addHeader(header);
+    if (headers != null) {
+      put.setHeaders(headers);
     }
     if (content != null) {
       addContentToRequest(put, content);
@@ -91,18 +91,18 @@
   }
 
   public RestResponse post(String endPoint) throws IOException {
-    return post(endPoint, null);
+    return post(endPoint, /* content = */ null);
   }
 
   public RestResponse post(String endPoint, Object content) throws IOException {
-    return postWithHeader(endPoint, null, content);
+    return postWithHeaders(endPoint, content);
   }
 
-  public RestResponse postWithHeader(String endPoint, Header header, Object content)
+  public RestResponse postWithHeaders(String endPoint, Object content, Header... headers)
       throws IOException {
     Request post = Request.Post(getUrl(endPoint));
-    if (header != null) {
-      post.addHeader(header);
+    if (headers != null) {
+      post.setHeaders(headers);
     }
     if (content != null) {
       addContentToRequest(post, content);
@@ -119,6 +119,14 @@
     return execute(Request.Delete(getUrl(endPoint)));
   }
 
+  public RestResponse deleteWithHeaders(String endPoint, Header... headers) throws IOException {
+    Request delete = Request.Delete(getUrl(endPoint));
+    if (headers != null) {
+      delete.setHeaders(headers);
+    }
+    return execute(delete);
+  }
+
   private String getUrl(String endPoint) {
     return url + (account != null ? "/a" : "") + endPoint;
   }
diff --git a/java/com/google/gerrit/acceptance/testsuite/change/ChangeKindCreator.java b/java/com/google/gerrit/acceptance/testsuite/change/ChangeKindCreator.java
new file mode 100644
index 0000000..1038a14
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/testsuite/change/ChangeKindCreator.java
@@ -0,0 +1,339 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.testsuite.change;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
+import static org.eclipse.jgit.lib.Constants.HEAD;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.acceptance.GitUtil;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.entities.LabelId;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.api.GerritApi;
+import com.google.gerrit.extensions.api.changes.CherryPickInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.changes.RevisionApi;
+import com.google.gerrit.extensions.client.ChangeKind;
+import com.google.gerrit.extensions.client.ListChangesOption;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.CommitInfo;
+import com.google.inject.Inject;
+import java.util.List;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.revwalk.RevCommit;
+
+/** Helper to create changes of a certain {@link ChangeKind}. */
+public class ChangeKindCreator {
+  private GerritApi gApi;
+  private PushOneCommit.Factory pushFactory;
+  private RequestScopeOperations requestScopeOperations;
+  private ProjectOperations projectOperations;
+
+  @Inject
+  private ChangeKindCreator(
+      GerritApi gApi,
+      PushOneCommit.Factory pushFactory,
+      RequestScopeOperations requestScopeOperations,
+      ProjectOperations projectOperations) {
+    this.gApi = gApi;
+    this.pushFactory = pushFactory;
+    this.requestScopeOperations = requestScopeOperations;
+    this.projectOperations = projectOperations;
+  }
+
+  /** Creates a change with the given {@link ChangeKind} and returns the change id. */
+  public String createChange(
+      ChangeKind kind, TestRepository<InMemoryRepository> testRepo, TestAccount user)
+      throws Exception {
+    switch (kind) {
+      case NO_CODE_CHANGE:
+      case REWORK:
+      case TRIVIAL_REBASE:
+      case NO_CHANGE:
+        return createChange(testRepo, user).getChangeId();
+      case MERGE_FIRST_PARENT_UPDATE:
+        return createChangeForMergeCommit(testRepo, user);
+      default:
+        throw new IllegalStateException("unexpected change kind: " + kind);
+    }
+  }
+
+  /** Updates a change with the given {@link ChangeKind}. */
+  public void updateChange(
+      String changeId,
+      ChangeKind changeKind,
+      TestRepository<InMemoryRepository> testRepo,
+      TestAccount user,
+      Project.NameKey project)
+      throws Exception {
+    switch (changeKind) {
+      case NO_CODE_CHANGE:
+        noCodeChange(changeId, testRepo, user);
+        return;
+      case REWORK:
+        rework(changeId, testRepo, user);
+        return;
+      case TRIVIAL_REBASE:
+        trivialRebase(changeId, testRepo, user, project);
+        return;
+      case MERGE_FIRST_PARENT_UPDATE:
+        updateFirstParent(changeId, testRepo, user);
+        return;
+      case NO_CHANGE:
+        noChange(changeId, testRepo, user);
+        return;
+      default:
+        assertWithMessage("unexpected change kind: " + changeKind).fail();
+    }
+  }
+
+  /**
+   * Creates a cherry pick of the provided change with the given {@link ChangeKind} and returns the
+   * change id.
+   */
+  public String cherryPick(
+      String changeId,
+      ChangeKind changeKind,
+      TestRepository<InMemoryRepository> testRepo,
+      TestAccount user,
+      Project.NameKey project)
+      throws Exception {
+    switch (changeKind) {
+      case REWORK:
+      case TRIVIAL_REBASE:
+        break;
+      case NO_CODE_CHANGE:
+      case NO_CHANGE:
+      case MERGE_FIRST_PARENT_UPDATE:
+      default:
+        assertWithMessage("unexpected change kind: " + changeKind).fail();
+    }
+
+    testRepo.reset(projectOperations.project(project).getHead("master"));
+    PushOneCommit.Result r =
+        pushFactory
+            .create(
+                user.newIdent(),
+                testRepo,
+                PushOneCommit.SUBJECT,
+                "other.txt",
+                "new content " + System.nanoTime())
+            .to("refs/for/master");
+    r.assertOkStatus();
+    vote(user, r.getChangeId(), 2, 1);
+    merge(r);
+
+    String subject =
+        ChangeKind.TRIVIAL_REBASE.equals(changeKind)
+            ? PushOneCommit.SUBJECT
+            : "Reworked change " + System.nanoTime();
+    CherryPickInput in = new CherryPickInput();
+    in.destination = "master";
+    in.message = String.format("%s\n\nChange-Id: %s", subject, changeId);
+    ChangeInfo c = gApi.changes().id(changeId).current().cherryPick(in).get();
+    return c.changeId;
+  }
+
+  /** Creates a change that is a merge {@link ChangeKind} and returns the change id. */
+  public String createChangeForMergeCommit(
+      TestRepository<InMemoryRepository> testRepo, TestAccount user) throws Exception {
+    ObjectId initial = testRepo.getRepository().exactRef(HEAD).getLeaf().getObjectId();
+
+    PushOneCommit.Result parent1 = createChange("parent 1", "p1.txt", "content 1", testRepo, user);
+
+    testRepo.reset(initial);
+    PushOneCommit.Result parent2 = createChange("parent 2", "p2.txt", "content 2", testRepo, user);
+
+    testRepo.reset(parent1.getCommit());
+
+    PushOneCommit merge = pushFactory.create(user.newIdent(), testRepo);
+    merge.setParents(ImmutableList.of(parent1.getCommit(), parent2.getCommit()));
+    PushOneCommit.Result result = merge.to("refs/for/master");
+    result.assertOkStatus();
+    return result.getChangeId();
+  }
+
+  /** Update the first parent of a merge. */
+  public void updateFirstParent(
+      String changeId, TestRepository<InMemoryRepository> testRepo, TestAccount user)
+      throws Exception {
+    ChangeInfo c = detailedChange(changeId);
+    List<CommitInfo> parents = c.revisions.get(c.currentRevision).commit.parents;
+    String parent1 = parents.get(0).commit;
+    String parent2 = parents.get(1).commit;
+    RevCommit commitParent2 = testRepo.getRevWalk().parseCommit(ObjectId.fromString(parent2));
+
+    testRepo.reset(parent1);
+    PushOneCommit.Result newParent1 =
+        createChange("new parent 1", "p1-1.txt", "content 1-1", testRepo, user);
+
+    PushOneCommit merge = pushFactory.create(user.newIdent(), testRepo, changeId);
+    merge.setParents(ImmutableList.of(newParent1.getCommit(), commitParent2));
+    PushOneCommit.Result result = merge.to("refs/for/master");
+    result.assertOkStatus();
+
+    assertThat(getChangeKind(changeId)).isEqualTo(ChangeKind.MERGE_FIRST_PARENT_UPDATE);
+  }
+
+  /** Update the second parent of a merge. */
+  public void updateSecondParent(
+      String changeId, TestRepository<InMemoryRepository> testRepo, TestAccount user)
+      throws Exception {
+    ChangeInfo c = detailedChange(changeId);
+    List<CommitInfo> parents = c.revisions.get(c.currentRevision).commit.parents;
+    String parent1 = parents.get(0).commit;
+    String parent2 = parents.get(1).commit;
+    RevCommit commitParent1 = testRepo.getRevWalk().parseCommit(ObjectId.fromString(parent1));
+
+    testRepo.reset(parent2);
+    PushOneCommit.Result newParent2 =
+        createChange("new parent 2", "p2-2.txt", "content 2-2", testRepo, user);
+
+    PushOneCommit merge = pushFactory.create(user.newIdent(), testRepo, changeId);
+    merge.setParents(ImmutableList.of(commitParent1, newParent2.getCommit()));
+    PushOneCommit.Result result = merge.to("refs/for/master");
+    result.assertOkStatus();
+
+    assertThat(getChangeKind(changeId)).isEqualTo(ChangeKind.REWORK);
+  }
+
+  private void noCodeChange(
+      String changeId, TestRepository<InMemoryRepository> testRepo, TestAccount user)
+      throws Exception {
+    TestRepository<?>.CommitBuilder commitBuilder =
+        testRepo.amendRef("HEAD").insertChangeId(changeId.substring(1));
+    commitBuilder
+        .message("New subject " + System.nanoTime())
+        .author(user.newIdent())
+        .committer(new PersonIdent(user.newIdent(), testRepo.getDate()));
+    commitBuilder.create();
+    GitUtil.pushHead(testRepo, "refs/for/master", false);
+    assertThat(getChangeKind(changeId)).isEqualTo(ChangeKind.NO_CODE_CHANGE);
+  }
+
+  private void noChange(
+      String changeId, TestRepository<InMemoryRepository> testRepo, TestAccount user)
+      throws Exception {
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    String commitMessage = change.revisions.get(change.currentRevision).commit.message;
+
+    TestRepository<?>.CommitBuilder commitBuilder =
+        testRepo.amendRef("HEAD").insertChangeId(changeId.substring(1));
+    commitBuilder
+        .message(commitMessage)
+        .author(user.newIdent())
+        .committer(new PersonIdent(user.newIdent(), testRepo.getDate()));
+    commitBuilder.create();
+    GitUtil.pushHead(testRepo, "refs/for/master", false);
+    assertThat(getChangeKind(changeId)).isEqualTo(ChangeKind.NO_CHANGE);
+  }
+
+  private void rework(
+      String changeId, TestRepository<InMemoryRepository> testRepo, TestAccount user)
+      throws Exception {
+    PushOneCommit push =
+        pushFactory.create(
+            user.newIdent(),
+            testRepo,
+            PushOneCommit.SUBJECT,
+            PushOneCommit.FILE_NAME,
+            "new content " + System.nanoTime(),
+            changeId);
+    push.to("refs/for/master").assertOkStatus();
+    assertThat(getChangeKind(changeId)).isEqualTo(ChangeKind.REWORK);
+  }
+
+  private void trivialRebase(
+      String changeId,
+      TestRepository<InMemoryRepository> testRepo,
+      TestAccount user,
+      Project.NameKey project)
+      throws Exception {
+    requestScopeOperations.setApiUser(user.id());
+    testRepo.reset(projectOperations.project(project).getHead("master"));
+    PushOneCommit push =
+        pushFactory.create(
+            user.newIdent(),
+            testRepo,
+            "Other Change",
+            "a" + System.nanoTime() + ".txt",
+            PushOneCommit.FILE_CONTENT);
+    PushOneCommit.Result r = push.to("refs/for/master");
+    r.assertOkStatus();
+    RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
+    ReviewInput in = new ReviewInput().label(LabelId.CODE_REVIEW, 2).label(LabelId.VERIFIED, 1);
+    revision.review(in);
+    revision.submit();
+
+    gApi.changes().id(changeId).current().rebase();
+    assertThat(getChangeKind(changeId)).isEqualTo(ChangeKind.TRIVIAL_REBASE);
+  }
+
+  private ChangeKind getChangeKind(String changeId) throws Exception {
+    ChangeInfo c = gApi.changes().id(changeId).get(ListChangesOption.CURRENT_REVISION);
+    return c.revisions.get(c.currentRevision).kind;
+  }
+
+  private PushOneCommit.Result createChange(
+      TestRepository<InMemoryRepository> testRepo, TestAccount user) throws Exception {
+    PushOneCommit push = pushFactory.create(user.newIdent(), testRepo);
+    PushOneCommit.Result result = push.to("refs/for/master");
+    result.assertOkStatus();
+    return result;
+  }
+
+  private ChangeInfo detailedChange(String changeId) throws Exception {
+    return gApi.changes()
+        .id(changeId)
+        .get(
+            ListChangesOption.DETAILED_LABELS,
+            ListChangesOption.CURRENT_REVISION,
+            ListChangesOption.CURRENT_COMMIT);
+  }
+
+  private PushOneCommit.Result createChange(
+      String subject,
+      String fileName,
+      String content,
+      TestRepository<InMemoryRepository> testRepo,
+      TestAccount user)
+      throws Exception {
+    PushOneCommit push = pushFactory.create(user.newIdent(), testRepo, subject, fileName, content);
+    return push.to("refs/for/master");
+  }
+
+  private void vote(TestAccount user, String changeId, int codeReviewVote, int verifiedVote)
+      throws Exception {
+    requestScopeOperations.setApiUser(user.id());
+    ReviewInput in =
+        new ReviewInput()
+            .label(LabelId.CODE_REVIEW, codeReviewVote)
+            .label(LabelId.VERIFIED, verifiedVote);
+    gApi.changes().id(changeId).current().review(in);
+  }
+
+  private void merge(PushOneCommit.Result r) throws Exception {
+    gApi.changes().id(r.getChangeId()).current().review(ReviewInput.approve());
+    gApi.changes().id(r.getChangeId()).current().submit();
+  }
+}
diff --git a/java/com/google/gerrit/elasticsearch/ElasticVersion.java b/java/com/google/gerrit/elasticsearch/ElasticVersion.java
index 6ca51fa..c6400df 100644
--- a/java/com/google/gerrit/elasticsearch/ElasticVersion.java
+++ b/java/com/google/gerrit/elasticsearch/ElasticVersion.java
@@ -18,7 +18,6 @@
 import java.util.regex.Pattern;
 
 public enum ElasticVersion {
-  V7_5("7.5.*"),
   V7_6("7.6.*"),
   V7_7("7.7.*"),
   V7_8("7.8.*");
diff --git a/java/com/google/gerrit/entities/ChangeMessage.java b/java/com/google/gerrit/entities/ChangeMessage.java
index e10d002..80a9042 100644
--- a/java/com/google/gerrit/entities/ChangeMessage.java
+++ b/java/com/google/gerrit/entities/ChangeMessage.java
@@ -41,8 +41,9 @@
   /** Template to identify an account in {@link ChangeMessage#message}. */
   public static final String ACCOUNT_TEMPLATE = "<GERRIT_ACCOUNT_%d>";
 
-  public static final Pattern ACCOUNT_TEMPLATE_PATTERN =
-      Pattern.compile("<GERRIT_ACCOUNT_([0-9]+)>");
+  public static final String ACCOUNT_TEMPLATE_REGEX = "<GERRIT_ACCOUNT_([0-9]+)>";
+
+  public static final Pattern ACCOUNT_TEMPLATE_PATTERN = Pattern.compile(ACCOUNT_TEMPLATE_REGEX);
 
   public static Key key(Change.Id changeId, String uuid) {
     return new AutoValue_ChangeMessage_Key(changeId, uuid);
diff --git a/java/com/google/gerrit/entities/LabelType.java b/java/com/google/gerrit/entities/LabelType.java
index 9649642..d254752 100644
--- a/java/com/google/gerrit/entities/LabelType.java
+++ b/java/com/google/gerrit/entities/LabelType.java
@@ -24,6 +24,7 @@
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.List;
+import java.util.Optional;
 
 @AutoValue
 public abstract class LabelType {
@@ -128,6 +129,8 @@
 
   public abstract boolean isCanOverride();
 
+  public abstract Optional<String> getCopyCondition();
+
   @Nullable
   public abstract ImmutableList<String> getRefPatterns();
 
@@ -239,6 +242,8 @@
 
     public abstract Builder setCopyAnyScore(boolean copyAnyScore);
 
+    public abstract Builder setCopyCondition(@Nullable String copyCondition);
+
     public abstract Builder setCopyMinScore(boolean copyMinScore);
 
     public abstract Builder setCopyMaxScore(boolean copyMaxScore);
diff --git a/java/com/google/gerrit/entities/SubmitRequirement.java b/java/com/google/gerrit/entities/SubmitRequirement.java
index 36f7b53..13e0b53 100644
--- a/java/com/google/gerrit/entities/SubmitRequirement.java
+++ b/java/com/google/gerrit/entities/SubmitRequirement.java
@@ -15,6 +15,8 @@
 package com.google.gerrit.entities;
 
 import com.google.auto.value.AutoValue;
+import com.google.gson.Gson;
+import com.google.gson.TypeAdapter;
 import java.util.Optional;
 
 /** Entity describing a requirement that should be met for a change to become submittable. */
@@ -29,23 +31,23 @@
   /**
    * Expression of the condition that makes the requirement applicable. The expression should be
    * evaluated for a specific {@link Change} and if it returns false, the requirement becomes
-   * irrelevant for the change (i.e. {@link #blockingExpression()} and {@link #overrideExpression()}
-   * become irrelevant).
+   * irrelevant for the change (i.e. {@link #submittabilityExpression()} and {@link
+   * #overrideExpression()} become irrelevant).
    *
    * <p>An empty {@link Optional} indicates that the requirement is applicable for any change.
    */
   public abstract Optional<SubmitRequirementExpression> applicabilityExpression();
 
   /**
-   * Expression of the condition that blocks the submission of a change. The expression should be
-   * evaluated for a specific {@link Change} and if it returns false, the requirement becomes
+   * Expression of the condition that allows the submission of a change. The expression should be
+   * evaluated for a specific {@link Change} and if it returns true, the requirement becomes
    * fulfilled for the change.
    */
-  public abstract SubmitRequirementExpression blockingExpression();
+  public abstract SubmitRequirementExpression submittabilityExpression();
 
   /**
    * Expression that, if evaluated to true, causes the submit requirement to be fulfilled,
-   * regardless of the blocking expression. This expression should be evaluated for a specific
+   * regardless of the submittability expression. This expression should be evaluated for a specific
    * {@link Change}.
    *
    * <p>An empty {@link Optional} indicates that the requirement is not overridable.
@@ -62,6 +64,10 @@
     return new AutoValue_SubmitRequirement.Builder();
   }
 
+  public static TypeAdapter<SubmitRequirement> typeAdapter(Gson gson) {
+    return new AutoValue_SubmitRequirement.GsonTypeAdapter(gson);
+  }
+
   @AutoValue.Builder
   public abstract static class Builder {
 
@@ -72,7 +78,8 @@
     public abstract Builder setApplicabilityExpression(
         Optional<SubmitRequirementExpression> applicabilityExpression);
 
-    public abstract Builder setBlockingExpression(SubmitRequirementExpression blockingExpression);
+    public abstract Builder setSubmittabilityExpression(
+        SubmitRequirementExpression submittabilityExpression);
 
     public abstract Builder setOverrideExpression(
         Optional<SubmitRequirementExpression> overrideExpression);
diff --git a/java/com/google/gerrit/entities/SubmitRequirementExpression.java b/java/com/google/gerrit/entities/SubmitRequirementExpression.java
index 7b31304..2af1379 100644
--- a/java/com/google/gerrit/entities/SubmitRequirementExpression.java
+++ b/java/com/google/gerrit/entities/SubmitRequirementExpression.java
@@ -17,14 +17,11 @@
 import com.google.auto.value.AutoValue;
 import com.google.common.base.Strings;
 import com.google.gerrit.common.Nullable;
+import com.google.gson.Gson;
+import com.google.gson.TypeAdapter;
 import java.util.Optional;
 
-/**
- * Describe a applicability, blocking or override expression of a {@link SubmitRequirement}.
- *
- * <p>TODO: Store the tree representation of the parsed expression internally and throw an exception
- * upon creation if the expression syntax is invalid.
- */
+/** Describe a applicability, blocking or override expression of a {@link SubmitRequirement}. */
 @AutoValue
 public abstract class SubmitRequirementExpression {
 
@@ -45,5 +42,9 @@
   }
 
   /** Returns the underlying String representing this {@link SubmitRequirementExpression}. */
-  public abstract String expression();
+  public abstract String expressionString();
+
+  public static TypeAdapter<SubmitRequirementExpression> typeAdapter(Gson gson) {
+    return new AutoValue_SubmitRequirementExpression.GsonTypeAdapter(gson);
+  }
 }
diff --git a/java/com/google/gerrit/entities/SubmitRequirementExpressionResult.java b/java/com/google/gerrit/entities/SubmitRequirementExpressionResult.java
new file mode 100644
index 0000000..58eb4ac
--- /dev/null
+++ b/java/com/google/gerrit/entities/SubmitRequirementExpressionResult.java
@@ -0,0 +1,181 @@
+// 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.entities;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableList;
+import com.google.gson.Gson;
+import com.google.gson.TypeAdapter;
+import java.util.Optional;
+
+/** Result of evaluating a submit requirement expression on a given Change. */
+@AutoValue
+public abstract class SubmitRequirementExpressionResult {
+
+  /** Submit requirement expression for which this result is evaluated. */
+  public abstract SubmitRequirementExpression expression();
+
+  /** Status of evaluation. */
+  public abstract Status status();
+
+  /**
+   * Optional error message. Populated if the evaluator fails to evaluate the expression for a
+   * certain change.
+   */
+  public abstract Optional<String> errorMessage();
+
+  /**
+   * List leaf predicates that are fulfilled, for example the expression
+   *
+   * <p><i>label:code-review=+2 and branch:refs/heads/master</i>
+   *
+   * <p>has two leaf predicates:
+   *
+   * <ul>
+   *   <li>label:code-review=+2
+   *   <li>branch:refs/heads/master
+   * </ul>
+   *
+   * This method will return the leaf predicates that were fulfilled, for example if only the first
+   * predicate was fulfilled, the returned list will be equal to ["label:code-review=+2"].
+   */
+  public abstract ImmutableList<String> passingAtoms();
+
+  /**
+   * List of leaf predicates that are not fulfilled. See {@link #passingAtoms()} for more details.
+   */
+  public abstract ImmutableList<String> failingAtoms();
+
+  public static SubmitRequirementExpressionResult create(
+      SubmitRequirementExpression expression, PredicateResult predicateResult) {
+    return create(
+        expression,
+        predicateResult.status() ? Status.PASS : Status.FAIL,
+        predicateResult.getPassingAtoms(),
+        predicateResult.getFailingAtoms());
+  }
+
+  public static SubmitRequirementExpressionResult create(
+      SubmitRequirementExpression expression,
+      Status status,
+      ImmutableList<String> passingAtoms,
+      ImmutableList<String> failingAtoms) {
+    return new AutoValue_SubmitRequirementExpressionResult(
+        expression, status, Optional.empty(), passingAtoms, failingAtoms);
+  }
+
+  public static SubmitRequirementExpressionResult error(
+      SubmitRequirementExpression expression, String errorMessage) {
+    return new AutoValue_SubmitRequirementExpressionResult(
+        expression,
+        Status.ERROR,
+        Optional.of(errorMessage),
+        ImmutableList.of(),
+        ImmutableList.of());
+  }
+
+  public static TypeAdapter<SubmitRequirementExpressionResult> typeAdapter(Gson gson) {
+    return new AutoValue_SubmitRequirementExpressionResult.GsonTypeAdapter(gson);
+  }
+
+  public enum Status {
+    /** Submit requirement expression is fulfilled for a given change. */
+    PASS,
+
+    /** Submit requirement expression is failing for a given change. */
+    FAIL,
+
+    /** Submit requirement expression contains invalid syntax and is not parsable. */
+    ERROR
+  }
+
+  /**
+   * Entity detailing the result of evaluating a predicate.
+   *
+   * <p>Example - branch:refs/heads/foo and has:unresolved
+   *
+   * <p>The above predicate is an "And" predicate having two child predicates:
+   *
+   * <ul>
+   *   <li>branch:refs/heads/foo
+   *   <li>has:unresolved
+   * </ul>
+   *
+   * <p>Each child predicate as well as the parent contains the result of its evaluation.
+   */
+  @AutoValue
+  public abstract static class PredicateResult {
+    abstract ImmutableList<PredicateResult> childPredicateResults();
+
+    public abstract String predicateString();
+
+    /** true if the predicate is passing for a given change. */
+    abstract boolean status();
+
+    /** Returns a list of leaf predicate results whose {@link PredicateResult#status()} is true. */
+    ImmutableList<String> getPassingAtoms() {
+      return getAtoms(/* status= */ true).stream()
+          .map(PredicateResult::predicateString)
+          .collect(ImmutableList.toImmutableList());
+    }
+
+    /** Returns a list of leaf predicate results whose {@link PredicateResult#status()} is false. */
+    ImmutableList<String> getFailingAtoms() {
+      return getAtoms(/* status= */ false).stream()
+          .map(PredicateResult::predicateString)
+          .collect(ImmutableList.toImmutableList());
+    }
+
+    /**
+     * Returns the list of leaf {@link PredicateResult} whose {@link #status()} is equal to the
+     * {@code status} parameter.
+     */
+    private ImmutableList<PredicateResult> getAtoms(boolean status) {
+      ImmutableList.Builder<PredicateResult> atomsList = ImmutableList.builder();
+      getAtomsRecursively(atomsList, status);
+      return atomsList.build();
+    }
+
+    private void getAtomsRecursively(ImmutableList.Builder<PredicateResult> list, boolean status) {
+      if (childPredicateResults().isEmpty() && status() == status) {
+        list.add(this);
+        return;
+      }
+      childPredicateResults().forEach(c -> c.getAtomsRecursively(list, status));
+    }
+
+    public static Builder builder() {
+      return new AutoValue_SubmitRequirementExpressionResult_PredicateResult.Builder();
+    }
+
+    @AutoValue.Builder
+    public abstract static class Builder {
+      public abstract Builder childPredicateResults(ImmutableList<PredicateResult> value);
+
+      protected abstract ImmutableList.Builder<PredicateResult> childPredicateResultsBuilder();
+
+      public abstract Builder predicateString(String value);
+
+      public abstract Builder status(boolean value);
+
+      public Builder addChildPredicateResult(PredicateResult result) {
+        childPredicateResultsBuilder().add(result);
+        return this;
+      }
+
+      public abstract PredicateResult build();
+    }
+  }
+}
diff --git a/java/com/google/gerrit/entities/SubmitRequirementResult.java b/java/com/google/gerrit/entities/SubmitRequirementResult.java
new file mode 100644
index 0000000..e1d5f39
--- /dev/null
+++ b/java/com/google/gerrit/entities/SubmitRequirementResult.java
@@ -0,0 +1,146 @@
+// 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.entities;
+
+import com.google.auto.value.AutoValue;
+import com.google.auto.value.extension.memoized.Memoized;
+import com.google.gson.Gson;
+import com.google.gson.TypeAdapter;
+import java.util.Optional;
+import org.eclipse.jgit.lib.ObjectId;
+
+/** Result of evaluating a {@link SubmitRequirement} on a given Change. */
+@AutoValue
+public abstract class SubmitRequirementResult {
+  /** Submit requirement for which this result is evaluated. */
+  public abstract SubmitRequirement submitRequirement();
+
+  /** Result of evaluating a {@link SubmitRequirement#applicabilityExpression()} on a change. */
+  public abstract Optional<SubmitRequirementExpressionResult> applicabilityExpressionResult();
+
+  /**
+   * Result of evaluating a {@link SubmitRequirement#submittabilityExpression()} ()} on a change.
+   */
+  public abstract SubmitRequirementExpressionResult submittabilityExpressionResult();
+
+  /** Result of evaluating a {@link SubmitRequirement#overrideExpression()} ()} on a change. */
+  public abstract Optional<SubmitRequirementExpressionResult> overrideExpressionResult();
+
+  /** SHA-1 of the patchset commit ID for which the submit requirement was evaluated. */
+  public abstract ObjectId patchSetCommitId();
+
+  @Memoized
+  public Status status() {
+    if (assertError(submittabilityExpressionResult())
+        || assertError(applicabilityExpressionResult())
+        || assertError(overrideExpressionResult())) {
+      return Status.ERROR;
+    } else if (assertFail(applicabilityExpressionResult())) {
+      return Status.NOT_APPLICABLE;
+    } else if (assertPass(overrideExpressionResult())) {
+      return Status.OVERRIDDEN;
+    } else if (assertPass(submittabilityExpressionResult())) {
+      return Status.SATISFIED;
+    } else {
+      return Status.UNSATISFIED;
+    }
+  }
+
+  public static Builder builder() {
+    return new AutoValue_SubmitRequirementResult.Builder();
+  }
+
+  public static TypeAdapter<SubmitRequirementResult> typeAdapter(Gson gson) {
+    return new AutoValue_SubmitRequirementResult.GsonTypeAdapter(gson);
+  }
+
+  public enum Status {
+    /** Submit requirement is fulfilled. */
+    SATISFIED,
+
+    /**
+     * Submit requirement is not satisfied. Happens when {@link
+     * SubmitRequirement#submittabilityExpression()} evaluates to false.
+     */
+    UNSATISFIED,
+
+    /**
+     * Submit requirement is overridden. Happens when {@link SubmitRequirement#overrideExpression()}
+     * evaluates to true.
+     */
+    OVERRIDDEN,
+
+    /**
+     * Submit requirement is not applicable for a given change. Happens when {@link
+     * SubmitRequirement#applicabilityExpression()} evaluates to false.
+     */
+    NOT_APPLICABLE,
+
+    /**
+     * Any of the applicability, blocking or override expressions contain invalid syntax and are not
+     * parsable.
+     */
+    ERROR
+  }
+
+  @AutoValue.Builder
+  public abstract static class Builder {
+    public abstract Builder submitRequirement(SubmitRequirement submitRequirement);
+
+    public abstract Builder applicabilityExpressionResult(
+        Optional<SubmitRequirementExpressionResult> value);
+
+    public abstract Builder submittabilityExpressionResult(SubmitRequirementExpressionResult value);
+
+    public abstract Builder overrideExpressionResult(
+        Optional<SubmitRequirementExpressionResult> value);
+
+    public abstract Builder patchSetCommitId(ObjectId value);
+
+    public abstract SubmitRequirementResult build();
+  }
+
+  private boolean assertPass(Optional<SubmitRequirementExpressionResult> expressionResult) {
+    return assertStatus(expressionResult, SubmitRequirementExpressionResult.Status.PASS);
+  }
+
+  private boolean assertPass(SubmitRequirementExpressionResult expressionResult) {
+    return assertStatus(expressionResult, SubmitRequirementExpressionResult.Status.PASS);
+  }
+
+  private boolean assertFail(Optional<SubmitRequirementExpressionResult> expressionResult) {
+    return assertStatus(expressionResult, SubmitRequirementExpressionResult.Status.FAIL);
+  }
+
+  private boolean assertError(Optional<SubmitRequirementExpressionResult> expressionResult) {
+    return assertStatus(expressionResult, SubmitRequirementExpressionResult.Status.ERROR);
+  }
+
+  private boolean assertError(SubmitRequirementExpressionResult expressionResult) {
+    return assertStatus(expressionResult, SubmitRequirementExpressionResult.Status.ERROR);
+  }
+
+  private boolean assertStatus(
+      SubmitRequirementExpressionResult expressionResult,
+      SubmitRequirementExpressionResult.Status status) {
+    return expressionResult.status() == status;
+  }
+
+  private boolean assertStatus(
+      Optional<SubmitRequirementExpressionResult> expressionResult,
+      SubmitRequirementExpressionResult.Status status) {
+    return expressionResult.isPresent() && assertStatus(expressionResult.get(), status);
+  }
+}
diff --git a/java/com/google/gerrit/entities/SubscribeSection.java b/java/com/google/gerrit/entities/SubscribeSection.java
index b95517c..574cae8 100644
--- a/java/com/google/gerrit/entities/SubscribeSection.java
+++ b/java/com/google/gerrit/entities/SubscribeSection.java
@@ -99,9 +99,10 @@
   public ImmutableSet<BranchNameKey> getDestinationBranches(
       BranchNameKey src, Collection<Ref> allRefsInRefsHeads) {
     Set<BranchNameKey> ret = new HashSet<>();
-    logger.atFine().log("Inspecting SubscribeSection %s", this);
-    for (RefSpec r : matchingRefSpecs()) {
-      logger.atFine().log("Inspecting [matching] ref %s", r);
+
+    ImmutableList<RefSpec> matching = matchingRefSpecs();
+    ImmutableList<RefSpec> multiMatch = multiMatchRefSpecs();
+    for (RefSpec r : matching) {
       if (!r.matchSource(src.branch())) {
         continue;
       }
@@ -118,8 +119,7 @@
       }
     }
 
-    for (RefSpec r : multiMatchRefSpecs()) {
-      logger.atFine().log("Inspecting [all] ref %s", r);
+    for (RefSpec r : multiMatch) {
       if (!r.matchSource(src.branch())) {
         continue;
       }
@@ -133,7 +133,9 @@
         }
       }
     }
-    logger.atFine().log("Returning possible branches: %s for project %s", ret, project());
+    logger.atFine().log(
+        "getDestinationBranches(%s): %s. matching refs: %s, multimatch refs: %s",
+        this, ret, matching, multiMatch);
     return ImmutableSet.copyOf(ret);
   }
 
diff --git a/java/com/google/gerrit/extensions/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/ChangeConfigInfo.java b/java/com/google/gerrit/extensions/common/ChangeConfigInfo.java
index b387017..fc09b49 100644
--- a/java/com/google/gerrit/extensions/common/ChangeConfigInfo.java
+++ b/java/com/google/gerrit/extensions/common/ChangeConfigInfo.java
@@ -19,8 +19,6 @@
   public Boolean allowBlame;
   public Boolean showAssigneeInChangesTable;
   public Boolean disablePrivateChanges;
-  public String replyLabel;
-  public String replyTooltip;
   public int updateDelay;
   public Boolean submitWholeTopic;
   public String mergeabilityComputationBehavior;
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/ChangeInfoDiffer.java b/java/com/google/gerrit/extensions/common/ChangeInfoDiffer.java
index 0fff0ba..0447e80 100644
--- a/java/com/google/gerrit/extensions/common/ChangeInfoDiffer.java
+++ b/java/com/google/gerrit/extensions/common/ChangeInfoDiffer.java
@@ -71,12 +71,12 @@
   @SuppressWarnings("unchecked") // reflection is used to construct instances of T
   private static <T> T getAdded(T oldValue, T newValue) {
     if (newValue instanceof Collection) {
-      List result = getAddedForCollection((Collection<?>) oldValue, (Collection<?>) newValue);
+      List<?> result = getAddedForCollection((Collection<?>) oldValue, (Collection<?>) newValue);
       return (T) result;
     }
 
     if (newValue instanceof Map) {
-      Map result = getAddedForMap((Map<?, ?>) oldValue, (Map<?, ?>) newValue);
+      Map<?, ?> result = getAddedForMap((Map<?, ?>) oldValue, (Map<?, ?>) newValue);
       return (T) result;
     }
 
diff --git a/java/com/google/gerrit/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/LabelDefinitionInfo.java b/java/com/google/gerrit/extensions/common/LabelDefinitionInfo.java
index 9a6d086..6f733d6 100644
--- a/java/com/google/gerrit/extensions/common/LabelDefinitionInfo.java
+++ b/java/com/google/gerrit/extensions/common/LabelDefinitionInfo.java
@@ -26,6 +26,7 @@
   public List<String> branches;
   public Boolean canOverride;
   public Boolean copyAnyScore;
+  public String copyCondition;
   public Boolean copyMinScore;
   public Boolean copyMaxScore;
   public Boolean copyAllScoresIfListOfFilesDidNotChange;
diff --git a/java/com/google/gerrit/extensions/common/LabelDefinitionInput.java b/java/com/google/gerrit/extensions/common/LabelDefinitionInput.java
index 87cae86..38b76c1 100644
--- a/java/com/google/gerrit/extensions/common/LabelDefinitionInput.java
+++ b/java/com/google/gerrit/extensions/common/LabelDefinitionInput.java
@@ -25,6 +25,8 @@
   public List<String> branches;
   public Boolean canOverride;
   public Boolean copyAnyScore;
+  public String copyCondition;
+  public Boolean unsetCopyCondition;
   public Boolean copyMinScore;
   public Boolean copyMaxScore;
   public Boolean copyAllScoresIfListOfFilesDidNotChange;
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/CacheBasedWebSession.java b/java/com/google/gerrit/httpd/CacheBasedWebSession.java
index 7212e3e..0a54448 100644
--- a/java/com/google/gerrit/httpd/CacheBasedWebSession.java
+++ b/java/com/google/gerrit/httpd/CacheBasedWebSession.java
@@ -42,7 +42,7 @@
 import org.eclipse.jgit.http.server.GitSmartHttpTools;
 
 @RequestScoped
-public abstract class CacheBasedWebSession implements WebSession {
+public abstract class CacheBasedWebSession extends WebSession {
   @VisibleForTesting public static final String ACCOUNT_COOKIE = "GerritAccount";
   protected static final long MAX_AGE_MINUTES = HOURS.toMinutes(12);
 
diff --git a/java/com/google/gerrit/httpd/GitReferenceUpdatedTracker.java b/java/com/google/gerrit/httpd/GitReferenceUpdatedTracker.java
new file mode 100644
index 0000000..ee28df9
--- /dev/null
+++ b/java/com/google/gerrit/httpd/GitReferenceUpdatedTracker.java
@@ -0,0 +1,55 @@
+// 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.httpd;
+
+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 {
+
+  private final DynamicItem<WebSession> webSession;
+
+  @Inject
+  GitReferenceUpdatedTracker(DynamicItem<WebSession> webSession) {
+    this.webSession = webSession;
+  }
+
+  @Override
+  public void onGitReferenceUpdated(GitReferenceUpdatedListener.Event event) {
+    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/HttpdModule.java b/java/com/google/gerrit/httpd/HttpdModule.java
new file mode 100644
index 0000000..1f1ec2f
--- /dev/null
+++ b/java/com/google/gerrit/httpd/HttpdModule.java
@@ -0,0 +1,14 @@
+package com.google.gerrit.httpd;
+
+import com.google.gerrit.extensions.annotations.Exports;
+import com.google.gerrit.extensions.config.FactoryModule;
+import com.google.gerrit.extensions.events.GitReferenceUpdatedListener;
+
+public class HttpdModule extends FactoryModule {
+  @Override
+  protected void configure() {
+    bind(GitReferenceUpdatedListener.class)
+        .annotatedWith(Exports.named(GitReferenceUpdatedTracker.class.getSimpleName()))
+        .to(GitReferenceUpdatedTracker.class);
+  }
+}
diff --git a/java/com/google/gerrit/httpd/WebSession.java b/java/com/google/gerrit/httpd/WebSession.java
index daf30ff..df8402e 100644
--- a/java/com/google/gerrit/httpd/WebSession.java
+++ b/java/com/google/gerrit/httpd/WebSession.java
@@ -16,30 +16,61 @@
 
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
+import com.google.gerrit.extensions.events.GitReferenceUpdatedListener;
 import com.google.gerrit.server.AccessPath;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.account.AuthResult;
+import com.google.inject.servlet.RequestScoped;
+import java.util.List;
+import java.util.concurrent.CopyOnWriteArrayList;
 
-public interface WebSession {
-  boolean isSignedIn();
+/**
+ * A thread safe class that contains details about a specific user web session.
+ *
+ * <p>WARNING: All implementors must have {@link RequestScoped} annotation to maintain thread
+ * safety.
+ */
+public abstract class WebSession {
+  public abstract boolean isSignedIn();
 
   @Nullable
-  String getXGerritAuth();
+  public abstract String getXGerritAuth();
 
-  boolean isValidXGerritAuth(String keyIn);
+  public abstract boolean isValidXGerritAuth(String keyIn);
 
-  CurrentUser getUser();
+  public abstract CurrentUser getUser();
 
-  void login(AuthResult res, boolean rememberMe);
+  public abstract void login(AuthResult res, boolean rememberMe);
 
   /** Set the user account for this current request only. */
-  void setUserAccountId(Account.Id id);
+  public abstract void setUserAccountId(Account.Id id);
 
-  boolean isAccessPathOk(AccessPath path);
+  public abstract boolean isAccessPathOk(AccessPath path);
 
-  void setAccessPathOk(AccessPath path, boolean ok);
+  public abstract void setAccessPathOk(AccessPath path, boolean ok);
 
-  void logout();
+  public abstract void logout();
 
-  String getSessionId();
+  public abstract String getSessionId();
+
+  /**
+   * Store and return the ref updates in this session. This class is {@link RequestScoped}, hence
+   * this is thread safe.
+   *
+   * <p>The same session could perform separate requests one after another, so resetting the ref
+   * updates is necessary between requests.
+   */
+  private List<GitReferenceUpdatedListener.Event> refUpdatedEvents = new CopyOnWriteArrayList<>();
+
+  public List<GitReferenceUpdatedListener.Event> getRefUpdatedEvents() {
+    return refUpdatedEvents;
+  }
+
+  public void addRefUpdatedEvents(GitReferenceUpdatedListener.Event event) {
+    refUpdatedEvents.add(event);
+  }
+
+  public void resetRefUpdatedEvents() {
+    refUpdatedEvents.clear();
+  }
 }
diff --git a/java/com/google/gerrit/httpd/init/WebAppInitializer.java b/java/com/google/gerrit/httpd/init/WebAppInitializer.java
index 3a77a8a..91d032e 100644
--- a/java/com/google/gerrit/httpd/init/WebAppInitializer.java
+++ b/java/com/google/gerrit/httpd/init/WebAppInitializer.java
@@ -28,6 +28,7 @@
 import com.google.gerrit.httpd.GitOverHttpModule;
 import com.google.gerrit.httpd.H2CacheBasedWebSession;
 import com.google.gerrit.httpd.HttpCanonicalWebUrlProvider;
+import com.google.gerrit.httpd.HttpdModule;
 import com.google.gerrit.httpd.RequestCleanupFilter;
 import com.google.gerrit.httpd.RequestContextFilter;
 import com.google.gerrit.httpd.RequestMetricsFilter;
@@ -121,6 +122,8 @@
 import com.google.inject.spi.Message;
 import com.google.inject.util.Providers;
 import java.io.IOException;
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
 import java.nio.file.Path;
 import java.nio.file.Paths;
 import java.util.ArrayList;
@@ -364,6 +367,19 @@
       return LuceneIndexModule.latestVersion(false);
     } else if (indexType.isElasticsearch()) {
       return ElasticIndexModule.latestVersion(false);
+    } else if (indexType.isFake()) {
+      // Use Reflection so that we can omit the fake index binary in production code. Test code does
+      // compile the component in.
+      try {
+        Class<?> clazz = Class.forName("com.google.gerrit.index.testing.FakeIndexModule");
+        Method m = clazz.getMethod("latestVersion", boolean.class);
+        return (Module) m.invoke(null, false);
+      } catch (NoSuchMethodException
+          | ClassNotFoundException
+          | IllegalAccessException
+          | InvocationTargetException e) {
+        throw new IllegalStateException("can't create index", e);
+      }
     } else {
       throw new IllegalStateException("unsupported index.type = " + indexType);
     }
@@ -393,6 +409,7 @@
     modules.add(RequestMetricsFilter.module());
     modules.add(sysInjector.getInstance(GerritAuthModule.class));
     modules.add(sysInjector.getInstance(GitOverHttpModule.class));
+    modules.add(sysInjector.getInstance(HttpdModule.class));
     modules.add(RequestCleanupFilter.module());
     modules.add(SetThreadNameFilter.module());
     modules.add(AllRequestFilter.module());
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/httpd/restapi/RestApiServlet.java b/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
index 269d1c4..7e6ab58 100644
--- a/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
+++ b/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
@@ -46,6 +46,7 @@
 import static javax.servlet.http.HttpServletResponse.SC_NOT_MODIFIED;
 import static javax.servlet.http.HttpServletResponse.SC_OK;
 import static javax.servlet.http.HttpServletResponse.SC_PRECONDITION_FAILED;
+import static javax.servlet.http.HttpServletResponse.SC_REQUEST_TIMEOUT;
 
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Joiner;
@@ -66,6 +67,7 @@
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.RawInputUtil;
 import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.events.GitReferenceUpdatedListener;
 import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.registration.DynamicSet;
@@ -97,6 +99,7 @@
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.extensions.restapi.Url;
 import com.google.gerrit.httpd.WebSession;
 import com.google.gerrit.httpd.restapi.ParameterParser.QueryParams;
 import com.google.gerrit.json.OutputFormat;
@@ -110,6 +113,7 @@
 import com.google.gerrit.server.RequestListener;
 import com.google.gerrit.server.audit.ExtendedHttpAuditEvent;
 import com.google.gerrit.server.cache.PerThreadCache;
+import com.google.gerrit.server.cancellation.RequestCancelledException;
 import com.google.gerrit.server.change.ChangeFinder;
 import com.google.gerrit.server.change.RevisionResource;
 import com.google.gerrit.server.config.GerritServerConfig;
@@ -204,6 +208,10 @@
   private static final String FORM_TYPE = "application/x-www-form-urlencoded";
 
   @VisibleForTesting public static final String X_GERRIT_TRACE = "X-Gerrit-Trace";
+  @VisibleForTesting public static final String X_GERRIT_UPDATED_REF = "X-Gerrit-UpdatedRef";
+
+  @VisibleForTesting
+  public static final String X_GERRIT_UPDATED_REF_ENABLED = "X-Gerrit-UpdatedRef-Enabled";
 
   private static final String X_REQUESTED_WITH = "X-Requested-With";
   private static final String X_GERRIT_AUTH = "X-Gerrit-Auth";
@@ -219,6 +227,7 @@
   public static final String XD_METHOD = "$m";
   public static final int SC_UNPROCESSABLE_ENTITY = 422;
   public static final int SC_TOO_MANY_REQUESTS = 429;
+  public static final int SC_CLIENT_CLOSED_REQUEST = 499;
 
   private static final int HEAP_EST_SIZE = 10 * 8 * 1024; // Presize 10 blocks.
   private static final String PLAIN_TEXT = "text/plain";
@@ -592,6 +601,11 @@
             } else {
               throw new ResourceNotFoundException();
             }
+            String isUpdatedRefEnabled = req.getHeader(X_GERRIT_UPDATED_REF_ENABLED);
+            if (!Strings.isNullOrEmpty(isUpdatedRefEnabled)
+                && Boolean.valueOf(isUpdatedRefEnabled)) {
+              setXGerritUpdatedRefResponseHeaders(req, res);
+            }
 
             if (response instanceof Response.Redirect) {
               CacheHeaders.setNotCacheable(res);
@@ -698,6 +712,25 @@
                 messageOr(e, "Quota limit reached"),
                 e.caching(),
                 e);
+      } catch (RequestCancelledException e) {
+        cause = Optional.of(e);
+        switch (e.getCancellationReason()) {
+          case CLIENT_CLOSED_REQUEST:
+            statusCode = SC_CLIENT_CLOSED_REQUEST;
+            break;
+          case CLIENT_PROVIDED_DEADLINE_EXCEEDED:
+          case SERVER_DEADLINE_EXCEEDED:
+            statusCode = SC_REQUEST_TIMEOUT;
+            break;
+        }
+
+        StringBuilder msg = new StringBuilder(e.formatCancellationReason());
+        if (e.getCancellationMessage().isPresent()) {
+          msg.append("\n\n");
+          msg.append(e.getCancellationMessage().get());
+        }
+
+        responseBytes = replyError(req, res, statusCode, msg.toString(), e);
       } catch (Exception e) {
         cause = Optional.of(e);
         statusCode = SC_INTERNAL_SERVER_ERROR;
@@ -753,6 +786,33 @@
     }
   }
 
+  /**
+   * Fill in the refs that were updated during this request in the response header. The updated refs
+   * will be in the form of "project~ref~updated_SHA-1".
+   */
+  private void setXGerritUpdatedRefResponseHeaders(
+      HttpServletRequest request, HttpServletResponse response) {
+    for (GitReferenceUpdatedListener.Event refUpdate :
+        globals.webSession.get().getRefUpdatedEvents()) {
+      String refUpdateFormat =
+          String.format(
+              "%s~%s~%s~%s",
+              // encode the project and ref names since they may contain `~`
+              Url.encode(refUpdate.getProjectName()),
+              Url.encode(refUpdate.getRefName()),
+              refUpdate.getOldObjectId(),
+              refUpdate.getNewObjectId());
+
+      if (isRead(request)) {
+        logger.atWarning().log(
+            "request %s performed a ref update %s although the request is a READ request",
+            request.getRequestURL().toString(), refUpdateFormat);
+      }
+      response.addHeader(X_GERRIT_UPDATED_REF, refUpdateFormat);
+    }
+    globals.webSession.get().resetRefUpdatedEvents();
+  }
+
   private String getEtagWithRetry(
       HttpServletRequest req,
       TraceContext traceContext,
diff --git a/java/com/google/gerrit/index/IndexType.java b/java/com/google/gerrit/index/IndexType.java
index cade439..0c3a76a 100644
--- a/java/com/google/gerrit/index/IndexType.java
+++ b/java/com/google/gerrit/index/IndexType.java
@@ -14,8 +14,12 @@
 
 package com.google.gerrit.index;
 
+import static com.google.common.base.Preconditions.checkArgument;
+
+import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.common.Nullable;
+import java.util.Optional;
 
 /**
  * Index types supported by the secondary index.
@@ -28,12 +32,42 @@
  * allows to not break that case upon core implementation changes.
  */
 public class IndexType {
+  public static final String SYS_PROP = "gerrit.index.type";
+  private static final String ENV_VAR = "GERRIT_INDEX_TYPE";
+
   private static final String LUCENE = "lucene";
   private static final String ELASTICSEARCH = "elasticsearch";
   private static final String FAKE = "fake";
 
   private final String type;
 
+  /**
+   * Returns the index type in case it was set by an environment variable. This is useful to run
+   * tests against a certain index backend.
+   */
+  public static Optional<IndexType> fromEnvironment() {
+    String value = System.getenv(ENV_VAR);
+    if (Strings.isNullOrEmpty(value)) {
+      value = System.getProperty(SYS_PROP);
+    }
+    if (Strings.isNullOrEmpty(value)) {
+      return Optional.empty();
+    }
+    value = value.toUpperCase().replace("-", "_");
+    IndexType type = new IndexType(value);
+    if (!Strings.isNullOrEmpty(System.getenv(ENV_VAR))) {
+      checkArgument(
+          type != null, "Invalid value for env variable %s: %s", ENV_VAR, System.getenv(ENV_VAR));
+    } else {
+      checkArgument(
+          type != null,
+          "Invalid value for system property %s: %s",
+          SYS_PROP,
+          System.getProperty(SYS_PROP));
+    }
+    return Optional.of(type);
+  }
+
   public IndexType(@Nullable String type) {
     this.type = type == null ? getDefault() : type.toLowerCase();
   }
diff --git a/java/com/google/gerrit/index/query/IndexPredicate.java b/java/com/google/gerrit/index/query/IndexPredicate.java
index aac6682..18d7fbc 100644
--- a/java/com/google/gerrit/index/query/IndexPredicate.java
+++ b/java/com/google/gerrit/index/query/IndexPredicate.java
@@ -14,11 +14,29 @@
 
 package com.google.gerrit.index.query;
 
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
+
+import com.google.common.base.CharMatcher;
+import com.google.common.base.Splitter;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.primitives.Ints;
+import com.google.common.primitives.Longs;
 import com.google.gerrit.index.FieldDef;
 import com.google.gerrit.index.FieldType;
+import java.util.Objects;
+import java.util.Set;
+import java.util.stream.StreamSupport;
 
 /** Predicate that is mapped to a field in the index. */
-public abstract class IndexPredicate<I> extends OperatorPredicate<I> {
+public abstract class IndexPredicate<I> extends OperatorPredicate<I> implements Matchable<I> {
+  /**
+   * Text segmentation to be applied to both the query string and the indexed field for full-text
+   * queries. This is inspired by http://unicode.org/reports/tr29/ which is what Lucene uses, but
+   * 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 final FieldDef<I, ?> def;
 
   protected IndexPredicate(FieldDef<I, ?> def, String value) {
@@ -38,4 +56,69 @@
   public FieldType<?> getType() {
     return def.getType();
   }
+
+  /**
+   * This method matches documents without calling an index subsystem. For primitive fields (e.g.
+   * integer, long) , the matching logic is consistent across this method and all known index
+   * implementations. For text fields (i.e. prefix and full-text) the semantics vary between this
+   * implementation and known index implementations:
+   * <li>Prefix: Lucene as well as {@link #match(Object)} matches terms as true prefixes (prefix:foo
+   *     -> `foo bar` matches, but `baz foo bar` does not match). The index implementation at Google
+   *     tokenizes both the query and the indexed text and matches tokens individually (prefix:fo ba
+   *     -> `baz foo bar` matches).
+   * <li>Full text: Lucene uses a {@code PhraseQuery} to search for terms in full text fields
+   *     in-order. The index implementation at Google as well as {@link #match(Object)} tokenizes
+   *     both the query and the indexed text and matches tokens individually.
+   *
+   * @return true if the predicate matches the provided {@code I}.
+   */
+  @Override
+  public boolean match(I doc) {
+    if (getField().isRepeatable()) {
+      Iterable<?> values = (Iterable<?>) getField().get(doc);
+      for (Object v : values) {
+        if (matchesSingleObject(v)) {
+          return true;
+        }
+      }
+      return false;
+    }
+    return matchesSingleObject(getField().get(doc));
+  }
+
+  @Override
+  public int getCost() {
+    return 1;
+  }
+
+  private boolean matchesSingleObject(Object fieldValueFromObject) {
+    String fieldTypeName = getField().getType().getName();
+    if (fieldTypeName.equals(FieldType.INTEGER.getName())) {
+      return Objects.equals(fieldValueFromObject, Ints.tryParse(value));
+    } else if (fieldTypeName.equals(FieldType.EXACT.getName())) {
+      return Objects.equals(fieldValueFromObject, value);
+    } else if (fieldTypeName.equals(FieldType.LONG.getName())) {
+      return Objects.equals(fieldValueFromObject, Longs.tryParse(value));
+    } else if (fieldTypeName.equals(FieldType.PREFIX.getName())) {
+      return String.valueOf(fieldValueFromObject).startsWith(value);
+    } else if (fieldTypeName.equals(FieldType.FULL_TEXT.getName())) {
+      Set<String> tokenizedField = tokenizeString(String.valueOf(fieldValueFromObject));
+      Set<String> tokenizedValue = tokenizeString(value);
+      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())) {
+      throw new IllegalStateException("timestamp queries must be handled in subclasses");
+    } else if (fieldTypeName.equals(FieldType.INTEGER_RANGE.getName())) {
+      throw new IllegalStateException("integer range queries must be handled in subclasses");
+    } else {
+      throw new IllegalStateException("unrecognized field " + fieldTypeName);
+    }
+  }
+
+  private static ImmutableSet<String> tokenizeString(String value) {
+    return StreamSupport.stream(FULL_TEXT_SPLITTER.split(value.toLowerCase()).spliterator(), false)
+        .filter(s -> !s.trim().isEmpty())
+        .collect(toImmutableSet());
+  }
 }
diff --git a/java/com/google/gerrit/index/query/IntegerRangePredicate.java b/java/com/google/gerrit/index/query/IntegerRangePredicate.java
index 6780867..850c4a5 100644
--- a/java/com/google/gerrit/index/query/IntegerRangePredicate.java
+++ b/java/com/google/gerrit/index/query/IntegerRangePredicate.java
@@ -31,6 +31,7 @@
 
   protected abstract Integer getValueInt(T object);
 
+  @Override
   public boolean match(T object) {
     Integer valueInt = getValueInt(object);
     if (valueInt == null) {
diff --git a/java/com/google/gerrit/index/testing/FakeIndexModuleOnInit.java b/java/com/google/gerrit/index/testing/FakeIndexModuleOnInit.java
new file mode 100644
index 0000000..75d8de2
--- /dev/null
+++ b/java/com/google/gerrit/index/testing/FakeIndexModuleOnInit.java
@@ -0,0 +1,37 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.index.testing;
+
+import com.google.gerrit.index.testing.AbstractFakeIndex.FakeAccountIndex;
+import com.google.gerrit.index.testing.AbstractFakeIndex.FakeGroupIndex;
+import com.google.gerrit.server.index.account.AccountIndex;
+import com.google.gerrit.server.index.group.GroupIndex;
+import com.google.inject.AbstractModule;
+import com.google.inject.assistedinject.FactoryModuleBuilder;
+
+public class FakeIndexModuleOnInit extends AbstractModule {
+  @Override
+  protected void configure() {
+    install(
+        new FactoryModuleBuilder()
+            .implement(AccountIndex.class, FakeAccountIndex.class)
+            .build(AccountIndex.Factory.class));
+
+    install(
+        new FactoryModuleBuilder()
+            .implement(GroupIndex.class, FakeGroupIndex.class)
+            .build(GroupIndex.Factory.class));
+  }
+}
diff --git a/java/com/google/gerrit/pgm/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/Daemon.java b/java/com/google/gerrit/pgm/Daemon.java
index 07bab24..2b4cfef 100644
--- a/java/com/google/gerrit/pgm/Daemon.java
+++ b/java/com/google/gerrit/pgm/Daemon.java
@@ -32,6 +32,7 @@
 import com.google.gerrit.httpd.GitOverHttpModule;
 import com.google.gerrit.httpd.H2CacheBasedWebSession;
 import com.google.gerrit.httpd.HttpCanonicalWebUrlProvider;
+import com.google.gerrit.httpd.HttpdModule;
 import com.google.gerrit.httpd.RequestCleanupFilter;
 import com.google.gerrit.httpd.RequestContextFilter;
 import com.google.gerrit.httpd.RequestMetricsFilter;
@@ -86,6 +87,7 @@
 import com.google.gerrit.server.git.SearchingChangeCacheImpl;
 import com.google.gerrit.server.git.WorkQueue;
 import com.google.gerrit.server.group.PeriodicGroupIndexer;
+import com.google.gerrit.server.index.AbstractIndexModule;
 import com.google.gerrit.server.index.IndexModule;
 import com.google.gerrit.server.index.OnlineUpgrader;
 import com.google.gerrit.server.index.VersionManager;
@@ -127,6 +129,8 @@
 import com.google.inject.Provider;
 import com.google.inject.Stage;
 import java.io.IOException;
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
 import java.nio.file.Files;
 import java.nio.file.Path;
 import java.util.ArrayList;
@@ -206,7 +210,7 @@
   private Injector httpdInjector;
   private Path runFile;
   private boolean inMemoryTest;
-  private AbstractModule luceneModule;
+  private AbstractModule indexModule;
   private Module emailModule;
   private List<Module> testSysModules = new ArrayList<>();
   private List<Module> testSshModules = new ArrayList<>();
@@ -335,9 +339,13 @@
   }
 
   @VisibleForTesting
-  public void setLuceneModule(LuceneIndexModule m) {
-    luceneModule = m;
-    inMemoryTest = true;
+  public void setIndexModule(AbstractIndexModule m) {
+    indexModule = m;
+  }
+
+  @VisibleForTesting
+  public void setInMemory(boolean inMemory) {
+    this.inMemoryTest = inMemory;
   }
 
   @VisibleForTesting
@@ -522,8 +530,8 @@
   }
 
   private Module createIndexModule() {
-    if (luceneModule != null) {
-      return luceneModule;
+    if (indexModule != null) {
+      return indexModule;
     }
     if (indexType.isLucene()) {
       return LuceneIndexModule.latestVersion(replica);
@@ -531,6 +539,20 @@
     if (indexType.isElasticsearch()) {
       return ElasticIndexModule.latestVersion(replica);
     }
+    if (indexType.isFake()) {
+      // Use Reflection so that we can omit the fake index binary in production code. Test code does
+      // compile the component in.
+      try {
+        Class<?> clazz = Class.forName("com.google.gerrit.index.testing.FakeIndexModule");
+        Method m = clazz.getMethod("latestVersion", boolean.class);
+        return (Module) m.invoke(null, replica);
+      } catch (NoSuchMethodException
+          | ClassNotFoundException
+          | IllegalAccessException
+          | InvocationTargetException e) {
+        throw new IllegalStateException("can't create index", e);
+      }
+    }
     throw new IllegalStateException("unsupported index.type = " + indexType);
   }
 
@@ -580,6 +602,7 @@
     modules.add(H2CacheBasedWebSession.module());
     modules.add(sysInjector.getInstance(GerritAuthModule.class));
     modules.add(sysInjector.getInstance(GitOverHttpModule.class));
+    modules.add(sysInjector.getInstance(HttpdModule.class));
     if (sshd) {
       modules.add(new ProjectQoSFilter.Module());
     }
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/Reindex.java b/java/com/google/gerrit/pgm/Reindex.java
index 3935268..6e99007 100644
--- a/java/com/google/gerrit/pgm/Reindex.java
+++ b/java/com/google/gerrit/pgm/Reindex.java
@@ -39,6 +39,8 @@
 import com.google.inject.Injector;
 import com.google.inject.Key;
 import com.google.inject.Module;
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.HashMap;
@@ -154,6 +156,21 @@
     } else if (indexType.isElasticsearch()) {
       indexModule =
           ElasticIndexModule.singleVersionWithExplicitVersions(versions, threads, replica);
+    } else if (indexType.isFake()) {
+      // Use Reflection so that we can omit the fake index binary in production code. Test code does
+      // compile the component in.
+      try {
+        Class<?> clazz = Class.forName("com.google.gerrit.index.testing.FakeIndexModule");
+        Method m =
+            clazz.getMethod(
+                "singleVersionWithExplicitVersions", Map.class, int.class, boolean.class);
+        indexModule = (Module) m.invoke(null, versions, threads, replica);
+      } catch (NoSuchMethodException
+          | ClassNotFoundException
+          | IllegalAccessException
+          | InvocationTargetException e) {
+        throw new IllegalStateException("can't create index", e);
+      }
     } else {
       throw new IllegalStateException("unsupported index.type = " + indexType);
     }
diff --git a/java/com/google/gerrit/pgm/init/BaseInit.java b/java/com/google/gerrit/pgm/init/BaseInit.java
index 62ff66a..c4b0040 100644
--- a/java/com/google/gerrit/pgm/init/BaseInit.java
+++ b/java/com/google/gerrit/pgm/init/BaseInit.java
@@ -32,6 +32,7 @@
 import com.google.gerrit.pgm.init.api.InstallPlugins;
 import com.google.gerrit.pgm.init.api.LibraryDownload;
 import com.google.gerrit.pgm.init.index.IndexManagerOnInit;
+import com.google.gerrit.pgm.init.index.IndexModuleOnInit;
 import com.google.gerrit.pgm.init.index.elasticsearch.ElasticIndexModuleOnInit;
 import com.google.gerrit.pgm.init.index.lucene.LuceneIndexModuleOnInit;
 import com.google.gerrit.pgm.util.SiteProgram;
@@ -57,6 +58,7 @@
 import com.google.inject.util.Providers;
 import java.io.FileNotFoundException;
 import java.io.IOException;
+import java.lang.reflect.InvocationTargetException;
 import java.nio.file.FileVisitResult;
 import java.nio.file.Files;
 import java.nio.file.Path;
@@ -418,6 +420,19 @@
         modules.add(new LuceneIndexModuleOnInit());
       } else if (indexType.isElasticsearch()) {
         modules.add(new ElasticIndexModuleOnInit());
+      } else if (indexType.isFake()) {
+        try {
+          Class<?> clazz = Class.forName("com.google.gerrit.index.testing.FakeIndexModuleOnInit");
+          Module indexOnInitModule = (Module) clazz.getDeclaredConstructor().newInstance();
+          modules.add(indexOnInitModule);
+        } catch (InstantiationException
+            | IllegalAccessException
+            | ClassNotFoundException
+            | NoSuchMethodException
+            | InvocationTargetException e) {
+          throw new IllegalStateException("unable to create fake index", e);
+        }
+        modules.add(new IndexModuleOnInit());
       } else {
         throw new IllegalStateException("unsupported index.type = " + indexType);
       }
diff --git a/java/com/google/gerrit/pgm/util/BatchProgramModule.java b/java/com/google/gerrit/pgm/util/BatchProgramModule.java
index 2cfc49f..4db657d 100644
--- a/java/com/google/gerrit/pgm/util/BatchProgramModule.java
+++ b/java/com/google/gerrit/pgm/util/BatchProgramModule.java
@@ -40,6 +40,7 @@
 import com.google.gerrit.server.account.Realm;
 import com.google.gerrit.server.account.ServiceUserClassifierImpl;
 import com.google.gerrit.server.account.externalids.ExternalIdModule;
+import com.google.gerrit.server.approval.ApprovalCacheImpl;
 import com.google.gerrit.server.cache.CacheRemovalListener;
 import com.google.gerrit.server.cache.h2.H2CacheModule;
 import com.google.gerrit.server.cache.mem.DefaultMemoryCacheModule;
@@ -79,8 +80,11 @@
 import com.google.gerrit.server.project.ProjectCacheImpl;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.project.SubmitRuleEvaluator;
+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;
@@ -171,6 +175,8 @@
     modules.add(new GroupModule());
     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());
@@ -181,11 +187,16 @@
     modules.add(ServiceUserClassifierImpl.module());
     modules.add(TagCache.module());
     modules.add(PureRevertCache.module());
+    modules.add(new ApprovalModule());
     factory(CapabilityCollection.Factory.class);
     factory(ChangeData.AssistedFactory.class);
     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/BUILD b/java/com/google/gerrit/server/BUILD
index 404906d..f9195c0 100644
--- a/java/com/google/gerrit/server/BUILD
+++ b/java/com/google/gerrit/server/BUILD
@@ -131,6 +131,7 @@
         "//lib/ow2:ow2-asm-util",
         "//lib/prolog:runtime",
         "//proto:cache_java_proto",
+        "//proto:entities_java_proto",
     ],
 )
 
diff --git a/java/com/google/gerrit/server/CommentsUtil.java b/java/com/google/gerrit/server/CommentsUtil.java
index b752791..b18f499 100644
--- a/java/com/google/gerrit/server/CommentsUtil.java
+++ b/java/com/google/gerrit/server/CommentsUtil.java
@@ -42,8 +42,9 @@
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ChangeUpdate;
-import com.google.gerrit.server.patch.PatchListCache;
-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.update.ChangeContext;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -52,6 +53,7 @@
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.List;
+import java.util.Map;
 import java.util.Optional;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Ref;
@@ -108,21 +110,21 @@
 
   private static final Ordering<Comparable<?>> NULLS_FIRST = Ordering.natural().nullsFirst();
 
+  private final DiffOperations diffOperations;
   private final GitRepositoryManager repoManager;
   private final AllUsersName allUsers;
   private final String serverId;
-  private final PatchListCache patchListCache;
 
   @Inject
   CommentsUtil(
+      DiffOperations diffOperations,
       GitRepositoryManager repoManager,
       AllUsersName allUsers,
-      @GerritServerId String serverId,
-      PatchListCache patchListCache) {
+      @GerritServerId String serverId) {
+    this.diffOperations = diffOperations;
     this.repoManager = repoManager;
     this.allUsers = allUsers;
     this.serverId = serverId;
-    this.patchListCache = patchListCache;
   }
 
   public HumanComment newHumanComment(
@@ -411,7 +413,7 @@
         int parentNumber = Math.abs(side);
         return resolveParentCommit(change.getProject(), patchset, parentNumber);
       }
-      return Optional.of(resolveAutoMergeCommit(change, patchset));
+      return Optional.ofNullable(resolveAutoMergeCommit(change, patchset));
     }
     return Optional.of(patchset.commitId());
   }
@@ -429,12 +431,18 @@
     }
   }
 
+  @Nullable
   private ObjectId resolveAutoMergeCommit(Change change, PatchSet patchset) {
     try {
       // TODO(ghareeb): Adjust after the auto-merge code was moved out of the diff caches. Also
       // unignore the test in PortedCommentsIT.
-      return patchListCache.getOldId(change, patchset, null);
-    } catch (PatchListNotAvailableException e) {
+      Map<String, FileDiffOutput> modifiedFiles =
+          diffOperations.listModifiedFilesAgainstParent(
+              change.getProject(), patchset.commitId(), /* parentNum= */ 0);
+      return modifiedFiles.isEmpty()
+          ? null
+          : modifiedFiles.values().iterator().next().oldCommitId();
+    } catch (DiffNotAvailableException e) {
       throw new StorageException(e);
     }
   }
diff --git a/java/com/google/gerrit/server/PatchSetUtil.java b/java/com/google/gerrit/server/PatchSetUtil.java
index 005ae3b..d60bc8f 100644
--- a/java/com/google/gerrit/server/PatchSetUtil.java
+++ b/java/com/google/gerrit/server/PatchSetUtil.java
@@ -29,6 +29,7 @@
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ChangeUpdate;
@@ -149,8 +150,7 @@
         projectCache.get(notes.getProjectName()).orElseThrow(illegalState(notes.getProjectName()));
 
     ApprovalsUtil approvalsUtil = approvalsUtilProvider.get();
-    for (PatchSetApproval ap :
-        approvalsUtil.byPatchSet(notes, change.currentPatchSetId(), null, null)) {
+    for (PatchSetApproval ap : approvalsUtil.byPatchSet(notes, change.currentPatchSetId())) {
       LabelType type = projectState.getLabelTypes(notes).byLabel(ap.label());
       if (type != null && ap.value() == 1 && type.getFunction() == LabelFunction.PATCH_SET_LOCK) {
         return true;
diff --git a/java/com/google/gerrit/server/account/AccountResolver.java b/java/com/google/gerrit/server/account/AccountResolver.java
index 75a2b38..103013c 100644
--- a/java/com/google/gerrit/server/account/AccountResolver.java
+++ b/java/com/google/gerrit/server/account/AccountResolver.java
@@ -535,10 +535,10 @@
    * As opposed to {@link #resolve}, the returned result includes all inactive accounts for the
    * input search.
    *
-   * <p>This can be used to resolve Gerrit Account from email to its {@link Account.Id}, to make
-   * sure that if {@link Account} with such email exists in Gerrit (even inactive), user data (email
-   * address) won't be recorded as it is, but instead will be stored as a link to the corresponding
-   * Gerrit Account.
+   * <p>This can be used to resolve Gerrit Account from email to its {@link
+   * com.google.gerrit.entities.Account.Id}, to make sure that if {@link Account} with such email
+   * exists in Gerrit (even inactive), user data (email address) won't be recorded as it is, but
+   * instead will be stored as a link to the corresponding Gerrit Account.
    */
   public Result resolveIncludeInactive(String input) throws ConfigInvalidException, IOException {
     return searchImpl(input, searchers, visibilitySupplierCanSee(), all());
diff --git a/java/com/google/gerrit/server/account/AccountsUpdate.java b/java/com/google/gerrit/server/account/AccountsUpdate.java
index 5a74047..93738b0 100644
--- a/java/com/google/gerrit/server/account/AccountsUpdate.java
+++ b/java/com/google/gerrit/server/account/AccountsUpdate.java
@@ -512,6 +512,10 @@
 
     BatchRefUpdate batchRefUpdate = allUsersRepo.getRefDatabase().newBatchUpdate();
 
+    String externalIdUpdateMessage =
+        updatedAccounts.size() == 1
+            ? Iterables.getOnlyElement(updatedAccounts).message
+            : "Batch update for " + updatedAccounts.size() + " accounts";
     for (UpdatedAccount updatedAccount : updatedAccounts) {
       // These updates are all for different refs (because batches never update the same account
       // more than once), so there can be multiple commits in the same batch, all with the same base
@@ -528,7 +532,7 @@
 
       // These update the same ref, so they need to be stacked on top of one another using the same
       // ExternalIdNotes instance.
-      commitExternalIdUpdates(updatedAccount.message, allUsersRepo, batchRefUpdate);
+      commitExternalIdUpdates(externalIdUpdateMessage, allUsersRepo, batchRefUpdate);
     }
 
     RefUpdateUtil.executeChecked(batchRefUpdate, allUsersRepo);
diff --git a/java/com/google/gerrit/server/account/GroupCache.java b/java/com/google/gerrit/server/account/GroupCache.java
index aaae95a..d8cac71 100644
--- a/java/com/google/gerrit/server/account/GroupCache.java
+++ b/java/com/google/gerrit/server/account/GroupCache.java
@@ -103,6 +103,6 @@
    */
   void evict(AccountGroup.UUID groupUuid);
 
-  /** @see #evict(AccountGroup.UUID); */
+  /** @see #evict(AccountGroup.UUID) */
   void evict(Collection<AccountGroup.UUID> groupUuid);
 }
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalId.java b/java/com/google/gerrit/server/account/externalids/ExternalId.java
index 2d501ad..ac4017a 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalId.java
+++ b/java/com/google/gerrit/server/account/externalids/ExternalId.java
@@ -321,6 +321,8 @@
    */
   public static ExternalId parse(String noteId, byte[] raw, ObjectId blobId)
       throws ConfigInvalidException {
+    requireNonNull(blobId);
+
     Config externalIdConfig = new Config();
     try {
       externalIdConfig.fromText(new String(raw, UTF_8));
@@ -328,13 +330,6 @@
       throw invalidConfig(noteId, e.getMessage());
     }
 
-    return parse(noteId, externalIdConfig, blobId);
-  }
-
-  public static ExternalId parse(String noteId, Config externalIdConfig, ObjectId blobId)
-      throws ConfigInvalidException {
-    requireNonNull(blobId);
-
     Set<String> externalIdKeys = externalIdConfig.getSubsections(EXTERNAL_ID_SECTION);
     if (externalIdKeys.size() != 1) {
       throw invalidConfig(
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalIdNotes.java b/java/com/google/gerrit/server/account/externalids/ExternalIdNotes.java
index e403a5b..50a2f69 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalIdNotes.java
+++ b/java/com/google/gerrit/server/account/externalids/ExternalIdNotes.java
@@ -17,7 +17,6 @@
 import static com.google.common.base.Preconditions.checkState;
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static java.util.Objects.requireNonNull;
-import static java.util.stream.Collectors.joining;
 import static java.util.stream.Collectors.toSet;
 import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
 
@@ -86,7 +85,8 @@
  *
  * <p>After committing the external IDs a cache update can be requested which also reindexes the
  * accounts for which external IDs have been updated (see {@link
- * ExternalIdNotesLoader#updateExternalIdCacheAndMaybeReindexAccounts)}).
+ * ExternalIdNotesLoader#updateExternalIdCacheAndMaybeReindexAccounts(ExternalIdNotes,
+ * Collection)}).
  */
 public class ExternalIdNotes extends VersionedMetaData {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
@@ -487,9 +487,9 @@
 
     Set<ExternalId> newExtIds = new HashSet<>();
     noteMapUpdates.add(
-        (rw, n, f) -> {
+        (rw, n) -> {
           for (ExternalId extId : extIds) {
-            ExternalId insertedExtId = upsert(rw, inserter, noteMap, f, extId);
+            ExternalId insertedExtId = upsert(rw, inserter, noteMap, extId);
             newExtIds.add(insertedExtId);
           }
         });
@@ -516,9 +516,9 @@
     Set<ExternalId> removedExtIds = get(ExternalId.Key.from(extIds));
     Set<ExternalId> updatedExtIds = new HashSet<>();
     noteMapUpdates.add(
-        (rw, n, f) -> {
+        (rw, n) -> {
           for (ExternalId extId : extIds) {
-            ExternalId updatedExtId = upsert(rw, inserter, noteMap, f, extId);
+            ExternalId updatedExtId = upsert(rw, inserter, noteMap, extId);
             updatedExtIds.add(updatedExtId);
           }
         });
@@ -547,9 +547,9 @@
     checkLoaded();
     Set<ExternalId> removedExtIds = new HashSet<>();
     noteMapUpdates.add(
-        (rw, n, f) -> {
+        (rw, n) -> {
           for (ExternalId extId : extIds) {
-            remove(rw, noteMap, f, extId);
+            remove(rw, noteMap, extId);
             removedExtIds.add(extId);
           }
         });
@@ -576,9 +576,9 @@
     checkLoaded();
     Set<ExternalId> removedExtIds = new HashSet<>();
     noteMapUpdates.add(
-        (rw, n, f) -> {
+        (rw, n) -> {
           for (ExternalId.Key extIdKey : extIdKeys) {
-            ExternalId removedExtId = remove(rw, noteMap, f, extIdKey, accountId);
+            ExternalId removedExtId = remove(rw, noteMap, extIdKey, accountId);
             removedExtIds.add(removedExtId);
           }
         });
@@ -594,9 +594,9 @@
     checkLoaded();
     Set<ExternalId> removedExtIds = new HashSet<>();
     noteMapUpdates.add(
-        (rw, n, f) -> {
+        (rw, n) -> {
           for (ExternalId.Key extIdKey : extIdKeys) {
-            ExternalId extId = remove(rw, noteMap, f, extIdKey, null);
+            ExternalId extId = remove(rw, noteMap, extIdKey, null);
             removedExtIds.add(extId);
           }
         });
@@ -624,16 +624,16 @@
     Set<ExternalId> removedExtIds = new HashSet<>();
     Set<ExternalId> updatedExtIds = new HashSet<>();
     noteMapUpdates.add(
-        (rw, n, f) -> {
+        (rw, n) -> {
           for (ExternalId.Key extIdKey : toDelete) {
-            ExternalId removedExtId = remove(rw, noteMap, f, extIdKey, accountId);
+            ExternalId removedExtId = remove(rw, noteMap, extIdKey, accountId);
             if (removedExtId != null) {
               removedExtIds.add(removedExtId);
             }
           }
 
           for (ExternalId extId : toAdd) {
-            ExternalId insertedExtId = upsert(rw, inserter, noteMap, f, extId);
+            ExternalId insertedExtId = upsert(rw, inserter, noteMap, extId);
             updatedExtIds.add(insertedExtId);
           }
         });
@@ -659,14 +659,14 @@
     Set<ExternalId> removedExtIds = new HashSet<>();
     Set<ExternalId> updatedExtIds = new HashSet<>();
     noteMapUpdates.add(
-        (rw, n, f) -> {
+        (rw, n) -> {
           for (ExternalId.Key extIdKey : toDelete) {
-            ExternalId removedExtId = remove(rw, noteMap, f, extIdKey, null);
+            ExternalId removedExtId = remove(rw, noteMap, extIdKey, null);
             removedExtIds.add(removedExtId);
           }
 
           for (ExternalId extId : toAdd) {
-            ExternalId insertedExtId = upsert(rw, inserter, noteMap, f, extId);
+            ExternalId insertedExtId = upsert(rw, inserter, noteMap, extId);
             updatedExtIds.add(insertedExtId);
           }
         });
@@ -745,21 +745,14 @@
     }
 
     try (RevWalk rw = new RevWalk(reader)) {
-      Set<String> footers = new HashSet<>();
       for (NoteMapUpdate noteMapUpdate : noteMapUpdates) {
         try {
-          noteMapUpdate.execute(rw, noteMap, footers);
+          noteMapUpdate.execute(rw, noteMap);
         } catch (DuplicateExternalIdKeyException e) {
           throw new IOException(e);
         }
       }
       noteMapUpdates.clear();
-      if (!footers.isEmpty()) {
-        commit.setMessage(
-            footers.stream()
-                .sorted()
-                .collect(joining("\n", commit.getMessage().trim() + "\n\n", "")));
-      }
 
       RevTree oldTree = revision != null ? rw.parseTree(revision) : null;
       ObjectId newTreeId = noteMap.writeTree(inserter);
@@ -821,17 +814,15 @@
    * <p>If the external ID already exists it is overwritten.
    */
   private static ExternalId upsert(
-      RevWalk rw, ObjectInserter ins, NoteMap noteMap, Set<String> footers, ExternalId extId)
+      RevWalk rw, ObjectInserter ins, NoteMap noteMap, ExternalId extId)
       throws IOException, ConfigInvalidException {
     ObjectId noteId = extId.key().sha1();
     Config c = new Config();
-    if (noteMap.contains(extId.key().sha1())) {
+    if (noteMap.contains(noteId)) {
       ObjectId noteDataId = noteMap.get(noteId);
       byte[] raw = readNoteData(rw, noteDataId);
       try {
         c = new BlobBasedConfig(null, raw);
-        ExternalId oldExtId = ExternalId.parse(noteId.name(), c, noteDataId);
-        addFooters(footers, oldExtId);
       } catch (ConfigInvalidException e) {
         throw new ConfigInvalidException(
             String.format("Invalid external id config for note %s: %s", noteId, e.getMessage()));
@@ -841,9 +832,7 @@
     byte[] raw = c.toText().getBytes(UTF_8);
     ObjectId noteData = ins.insert(OBJ_BLOB, raw);
     noteMap.set(noteId, noteData);
-    ExternalId newExtId = ExternalId.create(extId, noteData);
-    addFooters(footers, newExtId);
-    return newExtId;
+    return ExternalId.create(extId, noteData);
   }
 
   /**
@@ -852,7 +841,7 @@
    * @throws IllegalStateException is thrown if there is an existing external ID that has the same
    *     key, but otherwise doesn't match the specified external ID.
    */
-  private static void remove(RevWalk rw, NoteMap noteMap, Set<String> footers, ExternalId extId)
+  private static void remove(RevWalk rw, NoteMap noteMap, ExternalId extId)
       throws IOException, ConfigInvalidException {
     ObjectId noteId = extId.key().sha1();
     if (!noteMap.contains(noteId)) {
@@ -868,7 +857,6 @@
         extId.toString(),
         actualExtId.toString());
     noteMap.remove(noteId);
-    addFooters(footers, actualExtId);
   }
 
   /**
@@ -880,11 +868,7 @@
    *     exists
    */
   private static ExternalId remove(
-      RevWalk rw,
-      NoteMap noteMap,
-      Set<String> footers,
-      ExternalId.Key extIdKey,
-      Account.Id expectedAccountId)
+      RevWalk rw, NoteMap noteMap, ExternalId.Key extIdKey, Account.Id expectedAccountId)
       throws IOException, ConfigInvalidException {
     ObjectId noteId = extIdKey.sha1();
     if (!noteMap.contains(noteId)) {
@@ -904,17 +888,9 @@
           extId.accountId().get());
     }
     noteMap.remove(noteId);
-    addFooters(footers, extId);
     return extId;
   }
 
-  private static void addFooters(Set<String> footers, ExternalId extId) {
-    footers.add("Account: " + extId.accountId().get());
-    if (extId.email() != null) {
-      footers.add("Email: " + extId.email());
-    }
-  }
-
   private void checkExternalIdsDontExist(Collection<ExternalId> extIds)
       throws DuplicateExternalIdKeyException, IOException {
     checkExternalIdKeysDontExist(ExternalId.Key.from(extIds));
@@ -943,7 +919,7 @@
 
   @FunctionalInterface
   private interface NoteMapUpdate {
-    void execute(RevWalk rw, NoteMap noteMap, Set<String> footers)
+    void execute(RevWalk rw, NoteMap noteMap)
         throws IOException, ConfigInvalidException, DuplicateExternalIdKeyException;
   }
 
diff --git a/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java b/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java
index ab96c6b..764c46d 100644
--- a/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java
+++ b/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java
@@ -57,9 +57,9 @@
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.account.AccountDirectory.FillOptions;
 import com.google.gerrit.server.account.AccountLoader;
+import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.change.FileResource;
 import com.google.gerrit.server.change.RebaseUtil;
 import com.google.gerrit.server.change.RevisionResource;
@@ -642,7 +642,7 @@
         ListMultimapBuilder.treeKeys().arrayListValues().build();
     try {
       Iterable<PatchSetApproval> approvals =
-          approvalsUtil.byPatchSet(revision.getNotes(), revision.getPatchSet().id(), null, null);
+          approvalsUtil.byPatchSet(revision.getNotes(), revision.getPatchSet().id());
       AccountLoader accountLoader =
           accountLoaderFactory.create(
               EnumSet.of(
diff --git a/java/com/google/gerrit/server/approval/ApprovalCache.java b/java/com/google/gerrit/server/approval/ApprovalCache.java
new file mode 100644
index 0000000..5637249
--- /dev/null
+++ b/java/com/google/gerrit/server/approval/ApprovalCache.java
@@ -0,0 +1,28 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.approval;
+
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.PatchSetApproval;
+import com.google.gerrit.server.notedb.ChangeNotes;
+
+/**
+ * Cache that holds approvals per patch set and NoteDb state. This includes approvals copied forward
+ * from older patch sets.
+ */
+public interface ApprovalCache {
+  /** Returns {@link PatchSetApproval}s for the given patch set. */
+  Iterable<PatchSetApproval> get(ChangeNotes notes, PatchSet.Id psId);
+}
diff --git a/java/com/google/gerrit/server/approval/ApprovalCacheImpl.java b/java/com/google/gerrit/server/approval/ApprovalCacheImpl.java
new file mode 100644
index 0000000..93099eb
--- /dev/null
+++ b/java/com/google/gerrit/server/approval/ApprovalCacheImpl.java
@@ -0,0 +1,133 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.approval;
+
+import com.google.common.cache.CacheLoader;
+import com.google.common.cache.LoadingCache;
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.PatchSetApproval;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.converter.PatchSetApprovalProtoConverter;
+import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.proto.Entities;
+import com.google.gerrit.server.cache.CacheModule;
+import com.google.gerrit.server.cache.proto.Cache;
+import com.google.gerrit.server.cache.serialize.ObjectIdCacheSerializer;
+import com.google.gerrit.server.cache.serialize.ProtobufSerializer;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.inject.Inject;
+import com.google.inject.Module;
+import com.google.inject.Singleton;
+import com.google.inject.name.Named;
+import com.google.protobuf.ByteString;
+import java.util.concurrent.ExecutionException;
+
+/** @see ApprovalCache */
+public class ApprovalCacheImpl implements ApprovalCache {
+  private static final String CACHE_NAME = "approvals";
+
+  public static Module module() {
+    return new CacheModule() {
+      @Override
+      protected void configure() {
+        bind(ApprovalCache.class).to(ApprovalCacheImpl.class);
+        persist(
+                CACHE_NAME,
+                Cache.PatchSetApprovalsKeyProto.class,
+                Cache.AllPatchSetApprovalsProto.class)
+            .version(1)
+            .loader(Loader.class)
+            .keySerializer(new ProtobufSerializer<>(Cache.PatchSetApprovalsKeyProto.parser()))
+            .valueSerializer(new ProtobufSerializer<>(Cache.AllPatchSetApprovalsProto.parser()));
+      }
+    };
+  }
+
+  private final LoadingCache<Cache.PatchSetApprovalsKeyProto, Cache.AllPatchSetApprovalsProto>
+      cache;
+
+  @Inject
+  ApprovalCacheImpl(
+      @Named(CACHE_NAME)
+          LoadingCache<Cache.PatchSetApprovalsKeyProto, Cache.AllPatchSetApprovalsProto> cache) {
+    this.cache = cache;
+  }
+
+  @Override
+  public Iterable<PatchSetApproval> get(ChangeNotes notes, PatchSet.Id psId) {
+    try {
+      return fromProto(
+          cache.get(
+              Cache.PatchSetApprovalsKeyProto.newBuilder()
+                  .setChangeId(notes.getChangeId().get())
+                  .setPatchSetId(psId.get())
+                  .setProject(notes.getProjectName().get())
+                  .setId(
+                      ByteString.copyFrom(
+                          ObjectIdCacheSerializer.INSTANCE.serialize(notes.getMetaId())))
+                  .build()));
+    } catch (ExecutionException e) {
+      throw new StorageException(e);
+    }
+  }
+
+  @Singleton
+  static class Loader
+      extends CacheLoader<Cache.PatchSetApprovalsKeyProto, Cache.AllPatchSetApprovalsProto> {
+    private final ApprovalInference approvalInference;
+    private final ChangeNotes.Factory changeNotesFactory;
+
+    @Inject
+    Loader(ApprovalInference approvalInference, ChangeNotes.Factory changeNotesFactory) {
+      this.approvalInference = approvalInference;
+      this.changeNotesFactory = changeNotesFactory;
+    }
+
+    @Override
+    public Cache.AllPatchSetApprovalsProto load(Cache.PatchSetApprovalsKeyProto key)
+        throws Exception {
+      Change.Id changeId = Change.id(key.getChangeId());
+      return toProto(
+          approvalInference.forPatchSet(
+              changeNotesFactory.createChecked(
+                  Project.nameKey(key.getProject()),
+                  changeId,
+                  ObjectIdCacheSerializer.INSTANCE.deserialize(key.getId().toByteArray())),
+              PatchSet.id(changeId, key.getPatchSetId()),
+              null
+              /* revWalk= */ ,
+              null
+              /* repoConfig= */ ));
+    }
+  }
+
+  private static Iterable<PatchSetApproval> fromProto(Cache.AllPatchSetApprovalsProto proto) {
+    ImmutableList.Builder<PatchSetApproval> builder = ImmutableList.builder();
+    for (Entities.PatchSetApproval psa : proto.getApprovalList()) {
+      builder.add(PatchSetApprovalProtoConverter.INSTANCE.fromProto(psa));
+    }
+    return builder.build();
+  }
+
+  private static Cache.AllPatchSetApprovalsProto toProto(Iterable<PatchSetApproval> autoValue) {
+    Cache.AllPatchSetApprovalsProto.Builder builder = Cache.AllPatchSetApprovalsProto.newBuilder();
+    for (PatchSetApproval psa : autoValue) {
+      builder.addApproval(PatchSetApprovalProtoConverter.INSTANCE.toProto(psa));
+    }
+    return builder.build();
+  }
+}
diff --git a/java/com/google/gerrit/server/ApprovalInference.java b/java/com/google/gerrit/server/approval/ApprovalInference.java
similarity index 78%
rename from java/com/google/gerrit/server/ApprovalInference.java
rename to java/com/google/gerrit/server/approval/ApprovalInference.java
index 675c470..1efbd37 100644
--- a/java/com/google/gerrit/server/ApprovalInference.java
+++ b/java/com/google/gerrit/server/approval/ApprovalInference.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+package com.google.gerrit.server.approval;
 
 import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.common.base.Preconditions.checkState;
@@ -26,24 +26,27 @@
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.LabelTypes;
-import com.google.gerrit.entities.Patch.ChangeType;
 import com.google.gerrit.entities.PatchSet;
 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;
 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.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;
+import com.google.gerrit.server.query.approval.ApprovalQueryBuilder;
+import com.google.gerrit.server.query.approval.ListOfFilesUnchangedPredicate;
+import com.google.gerrit.server.util.ManualRequestContext;
+import com.google.gerrit.server.util.OneOffRequestContext;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.util.Collection;
@@ -58,27 +61,37 @@
  * 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
-public class ApprovalInference {
+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;
   }
 
   /**
@@ -105,28 +118,17 @@
     }
   }
 
-  private static boolean canCopy(
+  private boolean canCopyBasedOnBooleanLabelConfigs(
       ProjectState project,
       PatchSetApproval psa,
       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());
 
-    if (type == null) {
-      logger.atFine().log(
-          "approval %d on label %s of patch set %d of change %d cannot be copied"
-              + " to patch set %d because the label no longer exists on project %s",
-          psa.value(),
-          psa.label(),
-          n,
-          psa.key().patchSetId().changeId().get(),
-          psId.get(),
-          project.getName());
-      return false;
-    } else if (type.isCopyMinScore() && type.isMaxNegative(psa)) {
+    if (type.isCopyMinScore() && type.isMaxNegative(psa)) {
       logger.atFine().log(
           "veto approval %s on label %s of patch set %d of change %d can be copied"
               + " to patch set %d because the label has set copyMinScore = true on project %s",
@@ -172,12 +174,7 @@
           project.getName());
       return true;
     } else if (type.isCopyAllScoresIfListOfFilesDidNotChange()
-        && patchList.getPatches().stream()
-            .noneMatch(
-                p ->
-                    p.getChangeType() == ChangeType.ADDED
-                        || p.getChangeType() == ChangeType.DELETED
-                        || p.getChangeType() == ChangeType.RENAMED)) {
+        && 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 "
@@ -309,6 +306,31 @@
     }
   }
 
+  private boolean canCopyBasedOnCopyCondition(
+      ChangeNotes changeNotes,
+      PatchSetApproval psa,
+      PatchSet.Id psId,
+      LabelType type,
+      ChangeKind changeKind) {
+    if (!type.getCopyCondition().isPresent()) {
+      return false;
+    }
+    ApprovalContext ctx = ApprovalContext.create(changeNotes, psa, psId, changeKind);
+    try {
+      // Use a request context to run checks as an internal user with expanded visibility. This is
+      // so that the output of the copy condition does not depend on who is running the current
+      // request (e.g. a group used in this query might not be visible to the person sending this
+      // request).
+      try (ManualRequestContext ignored = requestContext.open()) {
+        return approvalQueryBuilder.parse(type.getCopyCondition().get()).asMatchable().match(ctx);
+      }
+    } catch (QueryParseException e) {
+      logger.atWarning().withCause(e).log(
+          "Unable to copy label because config is invalid. This should have been caught before.");
+      return false;
+    }
+  }
+
   private Collection<PatchSetApproval> getForPatchSetWithoutNormalization(
       ChangeNotes notes,
       ProjectState project,
@@ -369,18 +391,33 @@
     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 (!canCopy(project, psa, ps.id(), kind, type, patchList)) {
+      if (type == null) {
+        logger.atFine().log(
+            "approval %d on label %s of patch set %d of change %d cannot be copied"
+                + " to patch set %d because the label no longer exists on project %s",
+            psa.value(),
+            psa.label(),
+            psa.key().patchSetId().get(),
+            psa.key().patchSetId().changeId().get(),
+            psId.get(),
+            project.getName());
+        continue;
+      }
+      if (!canCopyBasedOnBooleanLabelConfigs(project, psa, ps.id(), kind, type, modifiedFiles)
+          && !canCopyBasedOnCopyCondition(notes, psa, ps.id(), type, kind)) {
         continue;
       }
       resultByUser.put(psa.label(), psa.accountId(), psa.copyWithPatchSet(ps.id()));
@@ -389,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/ApprovalsUtil.java b/java/com/google/gerrit/server/approval/ApprovalsUtil.java
similarity index 95%
rename from java/com/google/gerrit/server/ApprovalsUtil.java
rename to java/com/google/gerrit/server/approval/ApprovalsUtil.java
index 411768d..b1e85e9 100644
--- a/java/com/google/gerrit/server/ApprovalsUtil.java
+++ b/java/com/google/gerrit/server/approval/ApprovalsUtil.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+package com.google.gerrit.server.approval;
 
 import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.gerrit.server.notedb.ReviewerStateInternal.CC;
@@ -39,6 +39,9 @@
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.ReviewerSet;
+import com.google.gerrit.server.ReviewerStatusUpdate;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
@@ -94,16 +97,19 @@
   private final ApprovalInference approvalInference;
   private final PermissionBackend permissionBackend;
   private final ProjectCache projectCache;
+  private final ApprovalCache approvalCache;
 
   @VisibleForTesting
   @Inject
   public ApprovalsUtil(
       ApprovalInference approvalInference,
       PermissionBackend permissionBackend,
-      ProjectCache projectCache) {
+      ProjectCache projectCache,
+      ApprovalCache approvalCache) {
     this.approvalInference = approvalInference;
     this.permissionBackend = permissionBackend;
     this.projectCache = projectCache;
+    this.approvalCache = approvalCache;
   }
 
   /**
@@ -338,6 +344,10 @@
     return approvalInference.forPatchSet(notes, psId, rw, repoConfig);
   }
 
+  public Iterable<PatchSetApproval> byPatchSet(ChangeNotes notes, PatchSet.Id psId) {
+    return approvalCache.get(notes, psId);
+  }
+
   public Iterable<PatchSetApproval> byPatchSetUser(
       ChangeNotes notes,
       PatchSet.Id psId,
@@ -347,6 +357,11 @@
     return filterApprovals(byPatchSet(notes, psId, rw, repoConfig), accountId);
   }
 
+  public Iterable<PatchSetApproval> byPatchSetUser(
+      ChangeNotes notes, PatchSet.Id psId, Account.Id accountId) {
+    return filterApprovals(byPatchSet(notes, psId), accountId);
+  }
+
   public PatchSetApproval getSubmitter(ChangeNotes notes, PatchSet.Id c) {
     if (c == null) {
       return null;
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/cache/serialize/entities/LabelTypeSerializer.java b/java/com/google/gerrit/server/cache/serialize/entities/LabelTypeSerializer.java
index 4627cdb..c00961f 100644
--- a/java/com/google/gerrit/server/cache/serialize/entities/LabelTypeSerializer.java
+++ b/java/com/google/gerrit/server/cache/serialize/entities/LabelTypeSerializer.java
@@ -18,6 +18,7 @@
 
 import com.google.common.base.Converter;
 import com.google.common.base.Enums;
+import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
 import com.google.common.primitives.Shorts;
 import com.google.gerrit.entities.LabelFunction;
@@ -39,6 +40,7 @@
         .setAllowPostSubmit(proto.getAllowPostSubmit())
         .setIgnoreSelfApproval(proto.getIgnoreSelfApproval())
         .setDefaultValue(Shorts.saturatedCast(proto.getDefaultValue()))
+        .setCopyCondition(Strings.emptyToNull(proto.getCopyCondition()))
         .setCopyAnyScore(proto.getCopyAnyScore())
         .setCopyMinScore(proto.getCopyMinScore())
         .setCopyMaxScore(proto.getCopyMaxScore())
@@ -67,6 +69,7 @@
                 .map(LabelValueSerializer::serialize)
                 .collect(toImmutableList()))
         .setFunction(FUNCTION_CONVERTER.reverse().convert(autoValue.getFunction()))
+        .setCopyCondition(autoValue.getCopyCondition().orElse(""))
         .setCopyAnyScore(autoValue.isCopyAnyScore())
         .setCopyMinScore(autoValue.isCopyMinScore())
         .setCopyMaxScore(autoValue.isCopyMaxScore())
diff --git a/java/com/google/gerrit/server/cache/serialize/entities/SubmitRequirementExpressionResultSerializer.java b/java/com/google/gerrit/server/cache/serialize/entities/SubmitRequirementExpressionResultSerializer.java
new file mode 100644
index 0000000..4e997b4
--- /dev/null
+++ b/java/com/google/gerrit/server/cache/serialize/entities/SubmitRequirementExpressionResultSerializer.java
@@ -0,0 +1,45 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.cache.serialize.entities;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.entities.SubmitRequirementExpression;
+import com.google.gerrit.entities.SubmitRequirementExpressionResult;
+import com.google.gerrit.server.cache.proto.Cache.SubmitRequirementExpressionResultProto;
+
+/**
+ * Serializer of a {@link SubmitRequirementExpressionResult} to {@link
+ * SubmitRequirementExpressionResultProto}.
+ */
+public class SubmitRequirementExpressionResultSerializer {
+  public static SubmitRequirementExpressionResult deserialize(
+      SubmitRequirementExpressionResultProto proto) {
+    return SubmitRequirementExpressionResult.create(
+        SubmitRequirementExpression.create(proto.getExpression()),
+        SubmitRequirementExpressionResult.Status.valueOf(proto.getStatus()),
+        proto.getPassingAtomsList().stream().collect(ImmutableList.toImmutableList()),
+        proto.getFailingAtomsList().stream().collect(ImmutableList.toImmutableList()));
+  }
+
+  public static SubmitRequirementExpressionResultProto serialize(
+      SubmitRequirementExpressionResult r) {
+    return SubmitRequirementExpressionResultProto.newBuilder()
+        .setExpression(r.expression().expressionString())
+        .setStatus(r.status().name())
+        .addAllPassingAtoms(r.passingAtoms())
+        .addAllFailingAtoms(r.failingAtoms())
+        .build();
+  }
+}
diff --git a/java/com/google/gerrit/server/cache/serialize/entities/SubmitRequirementSerializer.java b/java/com/google/gerrit/server/cache/serialize/entities/SubmitRequirementSerializer.java
index ad015d1..47a377f 100644
--- a/java/com/google/gerrit/server/cache/serialize/entities/SubmitRequirementSerializer.java
+++ b/java/com/google/gerrit/server/cache/serialize/entities/SubmitRequirementSerializer.java
@@ -28,7 +28,8 @@
         .setDescription(Optional.ofNullable(Strings.emptyToNull(proto.getDescription())))
         .setApplicabilityExpression(
             SubmitRequirementExpression.of(proto.getApplicabilityExpression()))
-        .setBlockingExpression(SubmitRequirementExpression.create(proto.getBlockingExpression()))
+        .setSubmittabilityExpression(
+            SubmitRequirementExpression.create(proto.getSubmittabilityExpression()))
         .setOverrideExpression(SubmitRequirementExpression.of(proto.getOverrideExpression()))
         .setAllowOverrideInChildProjects(proto.getAllowOverrideInChildProjects())
         .build();
@@ -40,10 +41,11 @@
         .setName(submitRequirement.name())
         .setDescription(submitRequirement.description().orElse(""))
         .setApplicabilityExpression(
-            submitRequirement.applicabilityExpression().orElse(emptyExpression).expression())
-        .setBlockingExpression(submitRequirement.blockingExpression().expression())
+            submitRequirement.applicabilityExpression().orElse(emptyExpression).expressionString())
+        .setSubmittabilityExpression(
+            submitRequirement.submittabilityExpression().expressionString())
         .setOverrideExpression(
-            submitRequirement.overrideExpression().orElse(emptyExpression).expression())
+            submitRequirement.overrideExpression().orElse(emptyExpression).expressionString())
         .setAllowOverrideInChildProjects(submitRequirement.allowOverrideInChildProjects())
         .build();
   }
diff --git a/java/com/google/gerrit/server/cancellation/RequestCancelledException.java b/java/com/google/gerrit/server/cancellation/RequestCancelledException.java
new file mode 100644
index 0000000..3c668fb
--- /dev/null
+++ b/java/com/google/gerrit/server/cancellation/RequestCancelledException.java
@@ -0,0 +1,68 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.cancellation;
+
+import com.google.gerrit.common.Nullable;
+import java.util.Optional;
+import org.apache.commons.lang.WordUtils;
+
+/** Exception to signal that the current request is cancelled and should be aborted. */
+public class RequestCancelledException extends RuntimeException {
+  private static final long serialVersionUID = 1L;
+
+  private final RequestStateProvider.Reason cancellationReason;
+  private final Optional<String> cancellationMessage;
+
+  /**
+   * Create a {@code RequestCancelledException}.
+   *
+   * @param cancellationReason the reason why the request is cancelled
+   * @param cancellationMessage an optional message providing details about the cancellation
+   */
+  public RequestCancelledException(
+      RequestStateProvider.Reason cancellationReason, @Nullable String cancellationMessage) {
+    super(createMessage(cancellationReason, cancellationMessage));
+    this.cancellationReason = cancellationReason;
+    this.cancellationMessage = Optional.ofNullable(cancellationMessage);
+  }
+
+  private static String createMessage(
+      RequestStateProvider.Reason cancellationReason, @Nullable String message) {
+    StringBuilder messageBuilder = new StringBuilder();
+    messageBuilder.append(String.format("Request cancelled: %s", cancellationReason.name()));
+    if (message != null) {
+      messageBuilder.append(String.format(" (%s)", message));
+    }
+    return messageBuilder.toString();
+  }
+
+  /** Returns the reason why the request is cancelled. */
+  public RequestStateProvider.Reason getCancellationReason() {
+    return cancellationReason;
+  }
+
+  /** Returns the cancellation reason as a user-readable string. */
+  public String formatCancellationReason() {
+    return WordUtils.capitalizeFully(cancellationReason.name().replaceAll("_", " "));
+  }
+
+  /**
+   * Returns a message providing details about the cancellation, or {@link Optional#empty()} if none
+   * is available.
+   */
+  public Optional<String> getCancellationMessage() {
+    return cancellationMessage;
+  }
+}
diff --git a/java/com/google/gerrit/server/cancellation/RequestStateProvider.java b/java/com/google/gerrit/server/cancellation/RequestStateProvider.java
new file mode 100644
index 0000000..e1716eb
--- /dev/null
+++ b/java/com/google/gerrit/server/cancellation/RequestStateProvider.java
@@ -0,0 +1,59 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.cancellation;
+
+import com.google.gerrit.common.Nullable;
+
+/** Interface that provides information about the state of the current request. */
+public interface RequestStateProvider {
+  /**
+   * Checks whether the current request is cancelled.
+   *
+   * <p>Invoked by Gerrit to check whether the current request is cancelled and should be aborted.
+   *
+   * <p>If the current request is cancelled {@link OnCancelled#onCancel(Reason, String)} is invoked
+   * on the provided callback.
+   *
+   * @param onCancelled callback that should be invoked if the request is cancelled
+   */
+  void checkIfCancelled(OnCancelled onCancelled);
+
+  /** Callback interface to be invoked if a request is cancelled. */
+  interface OnCancelled {
+    /**
+     * Callback that is invoked if the request is cancelled.
+     *
+     * @param reason the reason for the cancellation of the request
+     * @param message an optional message providing details about the cancellation
+     */
+    void onCancel(Reason reason, @Nullable String message);
+  }
+
+  /** Reason why a request is cancelled. */
+  enum Reason {
+    /** The client got disconnected or has cancelled the request. */
+    CLIENT_CLOSED_REQUEST,
+
+    /** The deadline that the client provided for the request exceeded. */
+    CLIENT_PROVIDED_DEADLINE_EXCEEDED,
+
+    /**
+     * A server-side deadline for the request exceeded.
+     *
+     * <p>Server-side deadlines are usually configurable, but may also be hard-coded.
+     */
+    SERVER_DEADLINE_EXCEEDED;
+  }
+}
diff --git a/java/com/google/gerrit/server/change/AddReviewersOp.java b/java/com/google/gerrit/server/change/AddReviewersOp.java
index 1d6fb3c..a333ce5 100644
--- a/java/com/google/gerrit/server/change/AddReviewersOp.java
+++ b/java/com/google/gerrit/server/change/AddReviewersOp.java
@@ -33,10 +33,10 @@
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.extensions.client.ReviewerState;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.extensions.events.ReviewerAdded;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
 import com.google.gerrit.server.project.ProjectCache;
diff --git a/java/com/google/gerrit/server/change/ChangeInserter.java b/java/com/google/gerrit/server/change/ChangeInserter.java
index c067fcb..85482e4 100644
--- a/java/com/google/gerrit/server/change/ChangeInserter.java
+++ b/java/com/google/gerrit/server/change/ChangeInserter.java
@@ -45,10 +45,10 @@
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
-import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.change.ReviewerModifier.InternalReviewerInput;
 import com.google.gerrit.server.change.ReviewerModifier.ReviewerModification;
 import com.google.gerrit.server.change.ReviewerModifier.ReviewerModificationList;
@@ -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..ff8d8cb 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,10 @@
 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.SubmitRequirementResult;
 import com.google.gerrit.entities.SubmitTypeRecord;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.FixInput;
@@ -75,6 +80,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 +369,52 @@
     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.passingAtoms();
+    info.failingAtoms = result.failingAtoms();
+    return info;
+  }
+
   private static void finish(ChangeInfo info) {
     info.id =
         Joiner.on('~')
@@ -612,6 +660,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/ChangeResource.java b/java/com/google/gerrit/server/change/ChangeResource.java
index 3729b59..27b71d6 100644
--- a/java/com/google/gerrit/server/change/ChangeResource.java
+++ b/java/com/google/gerrit/server/change/ChangeResource.java
@@ -30,12 +30,12 @@
 import com.google.gerrit.extensions.restapi.RestResource;
 import com.google.gerrit.extensions.restapi.RestResource.HasETag;
 import com.google.gerrit.extensions.restapi.RestView;
-import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.StarredChangesUtil;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.logging.Metadata;
 import com.google.gerrit.server.logging.TraceContext;
 import com.google.gerrit.server.logging.TraceContext.TraceTimer;
diff --git a/java/com/google/gerrit/server/change/DeleteReviewerOp.java b/java/com/google/gerrit/server/change/DeleteReviewerOp.java
index 64472ea..3a12ad4 100644
--- a/java/com/google/gerrit/server/change/DeleteReviewerOp.java
+++ b/java/com/google/gerrit/server/change/DeleteReviewerOp.java
@@ -29,12 +29,12 @@
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.extensions.events.ReviewerDeleted;
 import com.google.gerrit.server.mail.send.DeleteReviewerSender;
 import com.google.gerrit.server.mail.send.MessageIdGenerator;
diff --git a/java/com/google/gerrit/server/change/DeleteReviewersUtil.java b/java/com/google/gerrit/server/change/DeleteReviewersUtil.java
index a4f306b..79ed043 100644
--- a/java/com/google/gerrit/server/change/DeleteReviewersUtil.java
+++ b/java/com/google/gerrit/server/change/DeleteReviewersUtil.java
@@ -20,8 +20,8 @@
 import com.google.gerrit.extensions.api.changes.ReviewerInput;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.account.AccountResolver;
+import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.inject.Inject;
@@ -62,9 +62,8 @@
             changeNotes.getChangeId(),
             deleteReviewerOpFactory.create(result.asUnique().account(), deleteReviewerInput));
         return;
-      } else {
-        return;
       }
+      return;
     } catch (AccountResolver.UnresolvableAccountException e) {
       if (e.isSelf()) {
         throw new AuthException(e.getMessage(), e);
diff --git a/java/com/google/gerrit/server/change/FileInfoJson.java b/java/com/google/gerrit/server/change/FileInfoJson.java
index ad6f9c7..ab557dc 100644
--- a/java/com/google/gerrit/server/change/FileInfoJson.java
+++ b/java/com/google/gerrit/server/change/FileInfoJson.java
@@ -46,7 +46,8 @@
    *
    * @param change a Gerrit change.
    * @param objectId a commit SHA-1 identifying a patchset commit.
-   * @param parentNum an integer identifying the parent number used for comparison.
+   * @param parentNum 1-based integer identifying the parent number used for comparison. If zero,
+   *     the only parent will be used or the auto-merge if {@code newCommit} is a merge commit.
    * @return a mapping of the file paths to their related diff information.
    */
   default Map<String, FileInfo> getFileInfoMap(Change change, ObjectId objectId, int parentNum)
@@ -74,7 +75,8 @@
    *
    * @param project a project identifying a repository.
    * @param objectId a commit SHA-1 identifying a patchset commit.
-   * @param parentNum an integer identifying the parent number used for comparison.
+   * @param parentNum 1-based integer identifying the parent number used for comparison. If zero,
+   *     the only parent will be used or the auto-merge if {@code newCommit} is a merge commit.
    * @return a mapping of the file paths to their related diff information.
    */
   Map<String, FileInfo> getFileInfoMap(Project.NameKey project, ObjectId objectId, int parentNum)
diff --git a/java/com/google/gerrit/server/change/FileInfoJsonExperimentImpl.java b/java/com/google/gerrit/server/change/FileInfoJsonExperimentImpl.java
new file mode 100644
index 0000000..3f7ce68
--- /dev/null
+++ b/java/com/google/gerrit/server/change/FileInfoJsonExperimentImpl.java
@@ -0,0 +1,69 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.common.FileInfo;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.server.experiments.ExperimentFeatures;
+import com.google.gerrit.server.patch.PatchListNotAvailableException;
+import java.util.Map;
+import javax.inject.Inject;
+import org.eclipse.jgit.lib.ObjectId;
+
+/**
+ * An experimental implementation of FileInfoJson that uses {@link FileInfoJsonNewImpl} if the
+ * experiment flag "GerritBackendRequestFeature__use_new_diff_cache" is enabled, or {@link
+ * FileInfoJsonOldImpl} otherwise. This would enable a gradual rollout of {@link
+ * FileInfoJsonNewImpl}.
+ */
+public class FileInfoJsonExperimentImpl implements FileInfoJson {
+  private final String NEW_DIFF_CACHE_FEATURE = "GerritBackendRequestFeature__use_new_diff_cache";
+
+  private final FileInfoJsonOldImpl oldImpl;
+  private final FileInfoJsonNewImpl newImpl;
+  private final ExperimentFeatures experimentFeatures;
+
+  @Inject
+  public FileInfoJsonExperimentImpl(
+      FileInfoJsonOldImpl oldImpl,
+      FileInfoJsonNewImpl newImpl,
+      ExperimentFeatures experimentFeatures) {
+    this.oldImpl = oldImpl;
+    this.newImpl = newImpl;
+    this.experimentFeatures = experimentFeatures;
+  }
+
+  @Override
+  public Map<String, FileInfo> getFileInfoMap(
+      Change change, ObjectId objectId, @Nullable PatchSet base)
+      throws ResourceConflictException, PatchListNotAvailableException {
+    return experimentFeatures.isFeatureEnabled(NEW_DIFF_CACHE_FEATURE)
+        ? newImpl.getFileInfoMap(change, objectId, base)
+        : oldImpl.getFileInfoMap(change, objectId, base);
+  }
+
+  @Override
+  public Map<String, FileInfo> getFileInfoMap(
+      Project.NameKey project, ObjectId objectId, int parentNum)
+      throws ResourceConflictException, PatchListNotAvailableException {
+    return experimentFeatures.isFeatureEnabled(NEW_DIFF_CACHE_FEATURE)
+        ? newImpl.getFileInfoMap(project, objectId, parentNum)
+        : oldImpl.getFileInfoMap(project, objectId, parentNum);
+  }
+}
diff --git a/java/com/google/gerrit/server/change/FileInfoJsonModule.java b/java/com/google/gerrit/server/change/FileInfoJsonModule.java
index de116bb..952b503 100644
--- a/java/com/google/gerrit/server/change/FileInfoJsonModule.java
+++ b/java/com/google/gerrit/server/change/FileInfoJsonModule.java
@@ -14,31 +14,13 @@
 
 package com.google.gerrit.server.change;
 
-import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.inject.AbstractModule;
-import org.eclipse.jgit.lib.Config;
 
 public class FileInfoJsonModule extends AbstractModule {
-  /** Use the new diff cache implementation {@link FileInfoJsonNewImpl}. */
-  private final boolean useNewDiffCache;
-
-  /** Used to dark launch the new diff cache with the list files endpoint. */
-  private final boolean runNewDiffCacheAsync;
-
-  public FileInfoJsonModule(@GerritServerConfig Config cfg) {
-    this.useNewDiffCache =
-        cfg.getBoolean("cache", "diff_cache", "runNewDiffCache_ListFiles", false);
-    this.runNewDiffCacheAsync =
-        cfg.getBoolean("cache", "diff_cache", "runNewDiffCacheAsync_listFiles", false);
-  }
 
   @Override
   public void configure() {
-    if (runNewDiffCacheAsync) {
-      bind(FileInfoJson.class).to(FileInfoJsonComparingImpl.class);
-      return;
-    }
-    bind(FileInfoJson.class)
-        .to(useNewDiffCache ? FileInfoJsonNewImpl.class : FileInfoJsonOldImpl.class);
+    // Binding to the experimental implementation to enable gradual rollout of the new diff cache.
+    bind(FileInfoJson.class).to(FileInfoJsonExperimentImpl.class);
   }
 }
diff --git a/java/com/google/gerrit/server/change/FileInfoJsonNewImpl.java b/java/com/google/gerrit/server/change/FileInfoJsonNewImpl.java
index 1ca2c93..7277404 100644
--- a/java/com/google/gerrit/server/change/FileInfoJsonNewImpl.java
+++ b/java/com/google/gerrit/server/change/FileInfoJsonNewImpl.java
@@ -47,8 +47,11 @@
       throws ResourceConflictException, PatchListNotAvailableException {
     try {
       if (base == null) {
+        // Setting parentNum=0 requests the default parent, which is the only parent for
+        // single-parent commits, or the auto-merge otherwise
         return asFileInfo(
-            diffs.listModifiedFilesAgainstParent(change.getProject(), objectId, null));
+            diffs.listModifiedFilesAgainstParent(
+                change.getProject(), objectId, /* parentNum= */ 0));
       }
       return asFileInfo(diffs.listModifiedFiles(change.getProject(), base.commitId(), objectId));
     } catch (DiffNotAvailableException e) {
@@ -63,7 +66,7 @@
       throws ResourceConflictException, PatchListNotAvailableException {
     try {
       Map<String, FileDiffOutput> modifiedFiles =
-          diffs.listModifiedFilesAgainstParent(project, objectId, parent + 1);
+          diffs.listModifiedFilesAgainstParent(project, objectId, parent);
       return asFileInfo(modifiedFiles);
     } catch (DiffNotAvailableException e) {
       convertException(e);
diff --git a/java/com/google/gerrit/server/change/FileInfoJsonOldImpl.java b/java/com/google/gerrit/server/change/FileInfoJsonOldImpl.java
index 55d162a..0570296 100644
--- a/java/com/google/gerrit/server/change/FileInfoJsonOldImpl.java
+++ b/java/com/google/gerrit/server/change/FileInfoJsonOldImpl.java
@@ -60,10 +60,10 @@
       Project.NameKey project, ObjectId objectId, int parentNum)
       throws ResourceConflictException, PatchListNotAvailableException {
     PatchListKey key =
-        parentNum == -1
+        parentNum == 0
             ? PatchListKey.againstDefaultBase(objectId, Whitespace.IGNORE_NONE)
             : PatchListKey.againstParentNum(
-                parentNum + 1, objectId, DiffPreferencesInfo.Whitespace.IGNORE_NONE);
+                parentNum, objectId, DiffPreferencesInfo.Whitespace.IGNORE_NONE);
     return toFileInfoMap(project, key);
   }
 
diff --git a/java/com/google/gerrit/server/change/IncludedInResolver.java b/java/com/google/gerrit/server/change/IncludedInResolver.java
index 09ca258..3e1b69b 100644
--- a/java/com/google/gerrit/server/change/IncludedInResolver.java
+++ b/java/com/google/gerrit/server/change/IncludedInResolver.java
@@ -26,9 +26,7 @@
 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;
 import java.util.List;
 import java.util.Set;
@@ -55,20 +53,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 +88,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 {
@@ -141,33 +114,6 @@
   }
 
   /**
-   * Partition the reference tips into two sets:
-   *
-   * <ul>
-   *   <li>before = commits with time < target.getCommitTime()
-   *   <li>after = commits with time >= target.getCommitTime()
-   * </ul>
-   *
-   * Each of the before/after lists is sorted by the commit time.
-   *
-   * @param before
-   * @param after
-   */
-  private void partition(List<RevCommit> before, List<RevCommit> after) {
-    int insertionPoint =
-        Collections.binarySearch(tipsByCommitTime, target, comparing(RevCommit::getCommitTime));
-    if (insertionPoint < 0) {
-      insertionPoint = -(insertionPoint + 1);
-    }
-    if (0 < insertionPoint) {
-      before.addAll(tipsByCommitTime.subList(0, insertionPoint));
-    }
-    if (insertionPoint < tipsByCommitTime.size()) {
-      after.addAll(tipsByCommitTime.subList(insertionPoint, tipsByCommitTime.size()));
-    }
-  }
-
-  /**
    * Returns the short names of refs which are as well in the matchingRefs list as well as in the
    * allRef list.
    */
diff --git a/java/com/google/gerrit/server/change/PatchSetInserter.java b/java/com/google/gerrit/server/change/PatchSetInserter.java
index 647fdf0..d25dba0 100644
--- a/java/com/google/gerrit/server/change/PatchSetInserter.java
+++ b/java/com/google/gerrit/server/change/PatchSetInserter.java
@@ -29,11 +29,11 @@
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.ReviewerSet;
+import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.events.CommitReceivedEvent;
 import com.google.gerrit.server.extensions.events.RevisionCreated;
 import com.google.gerrit.server.extensions.events.WorkInProgressStateChanged;
diff --git a/java/com/google/gerrit/server/change/ReviewerJson.java b/java/com/google/gerrit/server/change/ReviewerJson.java
index 761b57d..d5b74a8 100644
--- a/java/com/google/gerrit/server/change/ReviewerJson.java
+++ b/java/com/google/gerrit/server/change/ReviewerJson.java
@@ -27,8 +27,8 @@
 import com.google.gerrit.entities.SubmitRecord;
 import com.google.gerrit.extensions.api.changes.ReviewerInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.account.AccountLoader;
+import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.permissions.LabelPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -94,7 +94,7 @@
         out,
         reviewerAccountId,
         cd,
-        approvalsUtil.byPatchSetUser(cd.notes(), psId, reviewerAccountId, null, null));
+        approvalsUtil.byPatchSetUser(cd.notes(), psId, reviewerAccountId));
   }
 
   public ReviewerInfo format(
diff --git a/java/com/google/gerrit/server/comment/CommentContextCacheImpl.java b/java/com/google/gerrit/server/comment/CommentContextCacheImpl.java
index e12b538..5be41d4 100644
--- a/java/com/google/gerrit/server/comment/CommentContextCacheImpl.java
+++ b/java/com/google/gerrit/server/comment/CommentContextCacheImpl.java
@@ -99,25 +99,26 @@
       Iterable<CommentContextKey> inputKeys) {
     ImmutableMap.Builder<CommentContextKey, CommentContext> result = ImmutableMap.builder();
 
-    List<CommentContextKey> adjustedKeys =
+    // We do two transformations to the input keys: first we adjust the max context padding, and
+    // second we hash the file path. The transformed keys are used to request context from the
+    // cache. Keeping a map of the original inputKeys to the transformed keys
+    Map<CommentContextKey, CommentContextKey> inputKeysToCacheKeys =
         Streams.stream(inputKeys)
-            .map(CommentContextCacheImpl::adjustMaxContextPadding)
-            .collect(ImmutableList.toImmutableList());
-
-    // Convert the input keys to the same keys but with their file paths hashed
-    Map<CommentContextKey, CommentContextKey> keysToCacheKeys =
-        adjustedKeys.stream()
             .collect(
                 Collectors.toMap(
                     Function.identity(),
-                    k -> k.toBuilder().path(Loader.hashPath(k.path())).build()));
+                    k ->
+                        adjustMaxContextPadding(k)
+                            .toBuilder()
+                            .path(Loader.hashPath(k.path()))
+                            .build()));
 
     try {
       ImmutableMap<CommentContextKey, CommentContext> allContext =
-          contextCache.getAll(keysToCacheKeys.values());
+          contextCache.getAll(inputKeysToCacheKeys.values());
 
       for (CommentContextKey inputKey : inputKeys) {
-        CommentContextKey cacheKey = keysToCacheKeys.get(adjustMaxContextPadding(inputKey));
+        CommentContextKey cacheKey = inputKeysToCacheKeys.get(inputKey);
         result.put(inputKey, allContext.get(cacheKey));
       }
       return result.build();
@@ -255,14 +256,17 @@
       List<HumanComment> allComments =
           Streams.concat(humanComments.stream(), drafts.stream()).collect(Collectors.toList());
       CommentContextLoader loader = factory.create(project);
-      Map<ContextInput, CommentContextKey> commentsToKeys = new HashMap<>();
+      Map<CommentContextKey, ContextInput> keysToComments = new HashMap<>();
       for (CommentContextKey key : keys) {
         Comment comment = getCommentForKey(allComments, key);
-        commentsToKeys.put(ContextInput.fromComment(comment, key.contextPadding()), key);
+        keysToComments.put(key, ContextInput.fromComment(comment, key.contextPadding()));
       }
-      Map<ContextInput, CommentContext> allContext = loader.getContext(commentsToKeys.keySet());
-      return allContext.entrySet().stream()
-          .collect(Collectors.toMap(e -> commentsToKeys.get(e.getKey()), Map.Entry::getValue));
+      Map<ContextInput, CommentContext> allContext =
+          loader.getContext(
+              keysToComments.values().stream().distinct().collect(Collectors.toList()));
+      return keys.stream()
+          .collect(
+              Collectors.toMap(Function.identity(), k -> allContext.get(keysToComments.get(k))));
     }
 
     /**
diff --git a/java/com/google/gerrit/server/config/FileBasedGlobalPluginConfigProvider.java b/java/com/google/gerrit/server/config/FileBasedGlobalPluginConfigProvider.java
new file mode 100644
index 0000000..098d2c2
--- /dev/null
+++ b/java/com/google/gerrit/server/config/FileBasedGlobalPluginConfigProvider.java
@@ -0,0 +1,57 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.config;
+
+import com.google.common.flogger.FluentLogger;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.nio.file.Path;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.storage.file.FileBasedConfig;
+import org.eclipse.jgit.util.FS;
+
+@Singleton
+public class FileBasedGlobalPluginConfigProvider implements GlobalPluginConfigProvider {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+  private final SitePaths site;
+
+  @Inject
+  FileBasedGlobalPluginConfigProvider(SitePaths site) {
+    this.site = site;
+  }
+
+  @Override
+  public Config get(String pluginName) {
+    Path pluginConfigFile = site.etc_dir.resolve(pluginName + ".config");
+    FileBasedConfig cfg = new FileBasedConfig(pluginConfigFile.toFile(), FS.DETECTED);
+    if (!cfg.getFile().exists()) {
+      logger.atInfo().log("No %s; assuming defaults", pluginConfigFile.toAbsolutePath());
+      return cfg;
+    }
+
+    try {
+      cfg.load();
+    } catch (ConfigInvalidException e) {
+      // This is an error in user input, don't spam logs with a stack trace.
+      logger.atWarning().log(
+          "Failed to load %s: %s", pluginConfigFile.toAbsolutePath(), e.getMessage());
+    } catch (IOException e) {
+      logger.atWarning().withCause(e).log("Failed to load %s", pluginConfigFile.toAbsolutePath());
+    }
+    return cfg;
+  }
+}
diff --git a/java/com/google/gerrit/server/config/GerritGlobalModule.java b/java/com/google/gerrit/server/config/GerritGlobalModule.java
index 4794858..076ba46 100644
--- a/java/com/google/gerrit/server/config/GerritGlobalModule.java
+++ b/java/com/google/gerrit/server/config/GerritGlobalModule.java
@@ -77,7 +77,6 @@
 import com.google.gerrit.extensions.webui.TopMenu;
 import com.google.gerrit.extensions.webui.WebUiPlugin;
 import com.google.gerrit.server.AnonymousUser;
-import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.CmdLineParserModule;
 import com.google.gerrit.server.CreateGroupPermissionSyncer;
 import com.google.gerrit.server.DynamicOptions;
@@ -101,6 +100,8 @@
 import com.google.gerrit.server.account.ServiceUserClassifierImpl;
 import com.google.gerrit.server.account.VersionedAuthorizedKeys;
 import com.google.gerrit.server.account.externalids.ExternalIdModule;
+import com.google.gerrit.server.approval.ApprovalCacheImpl;
+import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.auth.AuthBackend;
 import com.google.gerrit.server.auth.UniversalAuthBackend;
 import com.google.gerrit.server.avatar.AvatarProvider;
@@ -179,6 +180,7 @@
 import com.google.gerrit.server.project.ProjectNameLockManager;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.project.SubmitRuleEvaluator;
+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;
@@ -237,6 +239,7 @@
     bind(RulesCache.class);
     bind(BlameCache.class).to(BlameCacheImpl.class);
     install(AccountCacheImpl.module());
+    install(ApprovalCacheImpl.module());
     install(BatchUpdate.module());
     install(ChangeKindCacheImpl.module());
     install(ChangeFinder.module());
@@ -268,8 +271,9 @@
     install(new IgnoreSelfApprovalRule.Module());
     install(new ReceiveCommitsModule());
     install(new SshAddressesModule());
-    install(new FileInfoJsonModule(cfg));
+    install(new FileInfoJsonModule());
     install(ThreadLocalRequestContext.module());
+    install(new ApprovalModule());
 
     factory(CapabilityCollection.Factory.class);
     factory(ChangeData.AssistedFactory.class);
diff --git a/java/com/google/gerrit/server/config/GerritServerConfigModule.java b/java/com/google/gerrit/server/config/GerritServerConfigModule.java
index 27d1d58..da85834 100644
--- a/java/com/google/gerrit/server/config/GerritServerConfigModule.java
+++ b/java/com/google/gerrit/server/config/GerritServerConfigModule.java
@@ -66,6 +66,7 @@
     bind(Config.class)
         .annotatedWith(GerritServerConfig.class)
         .toProvider(GerritServerConfigProvider.class);
+    bind(GlobalPluginConfigProvider.class).to(FileBasedGlobalPluginConfigProvider.class);
     bind(SecureStore.class).toProvider(SecureStoreProvider.class).in(SINGLETON);
     bind(Boolean.class)
         .annotatedWith(GerritIsReplica.class)
diff --git a/javatests/com/google/gerrit/acceptance/pgm/ReindexIT.java b/java/com/google/gerrit/server/config/GlobalPluginConfigProvider.java
similarity index 68%
copy from javatests/com/google/gerrit/acceptance/pgm/ReindexIT.java
copy to java/com/google/gerrit/server/config/GlobalPluginConfigProvider.java
index 223851e..847708a 100644
--- a/javatests/com/google/gerrit/acceptance/pgm/ReindexIT.java
+++ b/java/com/google/gerrit/server/config/GlobalPluginConfigProvider.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2014 The Android Open Source Project
+// Copyright (C) 2021 The Android Open Source Project
 //
 // Licensed under the Apache License, Version 2.0 (the "License");
 // you may not use this file except in compliance with the License.
@@ -12,11 +12,10 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.acceptance.pgm;
+package com.google.gerrit.server.config;
 
-import com.google.inject.Injector;
+import org.eclipse.jgit.lib.Config;
 
-public class ReindexIT extends AbstractReindexTests {
-  @Override
-  public void configureIndex(Injector injector) {}
+public interface GlobalPluginConfigProvider {
+  Config get(String pluginName);
 }
diff --git a/java/com/google/gerrit/server/config/PluginConfigFactory.java b/java/com/google/gerrit/server/config/PluginConfigFactory.java
index 2d0f9a5..bd4b661 100644
--- a/java/com/google/gerrit/server/config/PluginConfigFactory.java
+++ b/java/com/google/gerrit/server/config/PluginConfigFactory.java
@@ -16,7 +16,6 @@
 
 import static com.google.gerrit.server.project.ProjectCache.noSuchProject;
 
-import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.plugins.Plugin;
 import com.google.gerrit.server.plugins.ReloadPluginListener;
@@ -28,48 +27,37 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-import java.io.File;
-import java.io.IOException;
-import java.nio.file.Path;
 import java.util.HashMap;
 import java.util.Map;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.internal.storage.file.FileSnapshot;
 import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.storage.file.FileBasedConfig;
-import org.eclipse.jgit.util.FS;
 
 @Singleton
 public class PluginConfigFactory implements ReloadPluginListener {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
   private static final String EXTENSION = ".config";
 
-  private final SitePaths site;
+  private final GlobalPluginConfigProvider globalPluginConfigProvider;
   private final Provider<Config> cfgProvider;
   private final ProjectCache projectCache;
   private final ProjectState.Factory projectStateFactory;
   private final SecureStore secureStore;
   private final Map<String, Config> pluginConfigs;
 
-  private volatile FileSnapshot cfgSnapshot;
   private volatile Config cfg;
 
   @Inject
   PluginConfigFactory(
-      SitePaths site,
       @GerritServerConfig Provider<Config> cfgProvider,
+      GlobalPluginConfigProvider globalPluginConfigProvider,
       ProjectCache projectCache,
       ProjectState.Factory projectStateFactory,
       SecureStore secureStore) {
-    this.site = site;
+    this.globalPluginConfigProvider = globalPluginConfigProvider;
     this.cfgProvider = cfgProvider;
     this.projectCache = projectCache;
     this.projectStateFactory = projectStateFactory;
     this.secureStore = secureStore;
 
     this.pluginConfigs = new HashMap<>();
-    this.cfgSnapshot = FileSnapshot.save(site.gerrit_config.toFile());
     this.cfg = cfgProvider.get();
   }
 
@@ -103,12 +91,10 @@
    * @return the plugin configuration from the 'gerrit.config' file
    */
   public PluginConfig getFromGerritConfig(String pluginName, boolean refresh) {
-    if (refresh && secureStore.isOutdated()) {
-      secureStore.reload();
-    }
-    File configFile = site.gerrit_config.toFile();
-    if (refresh && cfgSnapshot.isModified(configFile)) {
-      cfgSnapshot = FileSnapshot.save(configFile);
+    if (refresh) {
+      if (secureStore.isOutdated()) {
+        secureStore.reload();
+      }
       cfg = cfgProvider.get();
     }
     return PluginConfig.createFromGerritConfig(pluginName, cfg);
@@ -217,25 +203,9 @@
       return pluginConfigs.get(pluginName);
     }
 
-    Path pluginConfigFile = site.etc_dir.resolve(pluginName + ".config");
-    FileBasedConfig cfg = new FileBasedConfig(pluginConfigFile.toFile(), FS.DETECTED);
+    Config cfg = globalPluginConfigProvider.get(pluginName);
     GlobalPluginConfig pluginConfig = new GlobalPluginConfig(pluginName, cfg, secureStore);
     pluginConfigs.put(pluginName, pluginConfig);
-    if (!cfg.getFile().exists()) {
-      logger.atInfo().log("No %s; assuming defaults", pluginConfigFile.toAbsolutePath());
-      return pluginConfig;
-    }
-
-    try {
-      cfg.load();
-    } catch (ConfigInvalidException e) {
-      // This is an error in user input, don't spam logs with a stack trace.
-      logger.atWarning().log(
-          "Failed to load %s: %s", pluginConfigFile.toAbsolutePath(), e.getMessage());
-    } catch (IOException e) {
-      logger.atWarning().withCause(e).log("Failed to load %s", pluginConfigFile.toAbsolutePath());
-    }
-
     return pluginConfig;
   }
 
diff --git a/java/com/google/gerrit/server/events/EventFactory.java b/java/com/google/gerrit/server/events/EventFactory.java
index caf495f..3f988a3 100644
--- a/java/com/google/gerrit/server/events/EventFactory.java
+++ b/java/com/google/gerrit/server/events/EventFactory.java
@@ -35,12 +35,12 @@
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.index.IndexConfig;
-import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.account.Emails;
+import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.change.ChangeKindCache;
 import com.google.gerrit.server.config.UrlFormatter;
 import com.google.gerrit.server.data.AccountAttribute;
@@ -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= */ 0);
+
+      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= */ 0);
+      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/CommitUtil.java b/java/com/google/gerrit/server/git/CommitUtil.java
index 2dbafd2..0e0185a 100644
--- a/java/com/google/gerrit/server/git/CommitUtil.java
+++ b/java/com/google/gerrit/server/git/CommitUtil.java
@@ -29,12 +29,12 @@
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.CommonConverters;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.ReviewerSet;
+import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.change.ChangeInserter;
 import com.google.gerrit.server.change.ChangeMessages;
 import com.google.gerrit.server.change.NotifyResolver;
diff --git a/java/com/google/gerrit/server/git/MergeUtil.java b/java/com/google/gerrit/server/git/MergeUtil.java
index 58df343..1da14f8 100644
--- a/java/com/google/gerrit/server/git/MergeUtil.java
+++ b/java/com/google/gerrit/server/git/MergeUtil.java
@@ -47,9 +47,9 @@
 import com.google.gerrit.extensions.restapi.MergeConflictException;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.UrlFormatter;
 import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk;
@@ -650,7 +650,7 @@
 
   private Iterable<PatchSetApproval> safeGetApprovals(ChangeNotes notes, PatchSet.Id psId) {
     try {
-      return approvalsUtil.byPatchSet(notes, psId, null, null);
+      return approvalsUtil.byPatchSet(notes, psId);
     } catch (StorageException e) {
       logger.atSevere().withCause(e).log("Can't read approval records for %s", psId);
       return Collections.emptyList();
diff --git a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
index 15bc603..d074f1e 100644
--- a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
+++ b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
@@ -100,7 +100,9 @@
 import com.google.gerrit.extensions.validators.CommentValidationContext;
 import com.google.gerrit.extensions.validators.CommentValidationFailure;
 import com.google.gerrit.extensions.validators.CommentValidator;
-import com.google.gerrit.server.ApprovalsUtil;
+import com.google.gerrit.metrics.Counter0;
+import com.google.gerrit.metrics.Description;
+import com.google.gerrit.metrics.MetricMaker;
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.CreateGroupPermissionSyncer;
@@ -111,6 +113,9 @@
 import com.google.gerrit.server.RequestInfo;
 import com.google.gerrit.server.RequestListener;
 import com.google.gerrit.server.account.AccountResolver;
+import com.google.gerrit.server.approval.ApprovalsUtil;
+import com.google.gerrit.server.cancellation.RequestCancelledException;
+import com.google.gerrit.server.change.AttentionSetUnchangedOp;
 import com.google.gerrit.server.change.ChangeInserter;
 import com.google.gerrit.server.change.NotifyResolver;
 import com.google.gerrit.server.change.SetHashtagsOp;
@@ -187,6 +192,7 @@
 import com.google.gerrit.util.cli.CmdLineParser;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
+import com.google.inject.Singleton;
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.util.Providers;
 import java.io.IOException;
@@ -309,6 +315,19 @@
     return RestApiException.wrap("Error inserting change/patchset", e);
   }
 
+  @Singleton
+  private static class Metrics {
+    private final Counter0 psRevisionMissing;
+
+    @Inject
+    Metrics(MetricMaker metricMaker) {
+      psRevisionMissing =
+          metricMaker.newCounter(
+              "receivecommits/ps_revision_missing",
+              new Description("errors due to patch set revision missing"));
+    }
+  }
+
   // ReceiveCommits has a lot of fields, sorry. Here and in the constructor they are split up
   // somewhat, and kept sorted lexicographically within sections, except where later assignments
   // depend on previous ones.
@@ -333,6 +352,7 @@
   private final DynamicSet<PluginPushOption> pluginPushOptions;
   private final PluginSetContext<ReceivePackInitializer> initializers;
   private final MergedByPushOp.Factory mergedByPushOpFactory;
+  private final Metrics metrics;
   private final PatchSetInfoFactory patchSetInfoFactory;
   private final PatchSetUtil psUtil;
   private final DynamicSet<PerformanceLogger> performanceLoggers;
@@ -418,6 +438,7 @@
       PluginSetContext<ReceivePackInitializer> initializers,
       PluginSetContext<CommentValidator> commentValidators,
       MergedByPushOp.Factory mergedByPushOpFactory,
+      Metrics metrics,
       PatchSetInfoFactory patchSetInfoFactory,
       PatchSetUtil psUtil,
       DynamicSet<PerformanceLogger> performanceLoggers,
@@ -472,6 +493,7 @@
     this.notesFactory = notesFactory;
     this.optionParserFactory = optionParserFactory;
     this.ormProvider = ormProvider;
+    this.metrics = metrics;
     this.patchSetInfoFactory = patchSetInfoFactory;
     this.permissionBackend = permissionBackend;
     this.pluginConfigEntries = pluginConfigEntries;
@@ -619,8 +641,17 @@
       Task commandProgress = progress.beginSubTask("refs", UNKNOWN);
       commands =
           commands.stream().map(c -> wrapReceiveCommand(c, commandProgress)).collect(toList());
-      processCommandsUnsafe(commands, progress);
-      rejectRemaining(commands, INTERNAL_SERVER_ERROR);
+
+      try {
+        processCommandsUnsafe(commands, progress);
+        rejectRemaining(commands, INTERNAL_SERVER_ERROR);
+      } catch (RequestCancelledException e) {
+        StringBuilder msg = new StringBuilder(e.formatCancellationReason());
+        if (e.getCancellationMessage().isPresent()) {
+          msg.append(String.format(" (%s)", e.getCancellationMessage().get()));
+        }
+        rejectRemaining(commands, msg.toString());
+      }
 
       // This sends error messages before the 'done' string of the progress monitor is sent.
       // Currently, the test framework relies on this ordering to understand if pushes completed
@@ -638,7 +669,9 @@
   // Process as many commands as possible, but may leave some commands in state NOT_ATTEMPTED.
   private void processCommandsUnsafe(
       Collection<ReceiveCommand> commands, MultiProgressMonitor progress) {
-    logger.atFine().log("Calling user: %s", user.getLoggableName());
+    logger.atFine().log("Calling user: %s, commands: %d", user.getLoggableName(), commands.size());
+
+    // If the list of groups is large, the log entry may get dropped, so separate out.
     logger.atFine().log("Groups: %s", lazy(() -> user.getEffectiveGroups().getKnownGroups()));
 
     if (!projectState.getProject().getState().permitsWrite()) {
@@ -648,8 +681,6 @@
       return;
     }
 
-    logger.atFine().log("Parsing %d commands", commands.size());
-
     List<ReceiveCommand> magicCommands = new ArrayList<>();
     List<ReceiveCommand> regularCommands = new ArrayList<>();
 
@@ -1662,6 +1693,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 +2689,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() {
@@ -2852,6 +2892,7 @@
         Change change = notes.getChange();
         priorPatchSet = change.currentPatchSetId();
         if (!revisions.containsValue(priorPatchSet)) {
+          metrics.psRevisionMissing.increment();
           logger.atWarning().log(
               "Change %d is missing revision for patch set %s"
                   + " (it has revisions for these patch sets: %s)",
diff --git a/java/com/google/gerrit/server/git/receive/ReplaceOp.java b/java/com/google/gerrit/server/git/receive/ReplaceOp.java
index cc908e4..f00b48eb 100644
--- a/java/com/google/gerrit/server/git/receive/ReplaceOp.java
+++ b/java/com/google/gerrit/server/git/receive/ReplaceOp.java
@@ -43,11 +43,11 @@
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
-import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.account.AccountResolver;
+import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.change.AddReviewersOp;
 import com.google.gerrit.server.change.ChangeKindCache;
 import com.google.gerrit.server.change.NotifyResolver;
@@ -305,6 +305,9 @@
         change.setWorkInProgress(true);
         update.setWorkInProgress(true);
       }
+      if (magicBranch.ignoreAttentionSet) {
+        update.ignoreFurtherAttentionSetUpdates();
+      }
     }
 
     newPatchSet =
diff --git a/java/com/google/gerrit/server/index/change/ChangeField.java b/java/com/google/gerrit/server/index/change/ChangeField.java
index 8aab647a..810cd4d 100644
--- a/java/com/google/gerrit/server/index/change/ChangeField.java
+++ b/java/com/google/gerrit/server/index/change/ChangeField.java
@@ -43,11 +43,13 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.common.io.Files;
 import com.google.common.primitives.Longs;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Address;
 import com.google.gerrit.entities.AttentionSetUpdate;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.ChangeMessage;
+import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.LegacySubmitRequirement;
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.entities.Project;
@@ -72,6 +74,7 @@
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.ChangeQueryBuilder;
 import com.google.gerrit.server.query.change.ChangeStatusPredicate;
+import com.google.gerrit.server.query.change.MagicLabelValue;
 import com.google.gson.Gson;
 import com.google.protobuf.MessageLite;
 import java.sql.Timestamp;
@@ -263,7 +266,7 @@
       StringBuilder directory = new StringBuilder();
       r.add(directory.toString());
       String nextPart = null;
-      for (String part : s.split(path)) {
+      for (String part : s.split(path.toLowerCase(Locale.US))) {
         if (nextPart != null) {
           r.add(nextPart);
 
@@ -602,16 +605,35 @@
     for (PatchSetApproval a : cd.currentApprovals()) {
       if (a.value() != 0 && !a.isLegacySubmit()) {
         allApprovals.add(formatLabel(a.label(), a.value(), a.accountId()));
+        LabelType labelType = cd.getLabelTypes().byLabel(a.labelId());
+        allApprovals.addAll(getMaxMinAnyLabels(a.label(), a.value(), labelType, a.accountId()));
         if (owners && cd.change().getOwner().equals(a.accountId())) {
           allApprovals.add(formatLabel(a.label(), a.value(), ChangeQueryBuilder.OWNER_ACCOUNT_ID));
+          allApprovals.addAll(
+              getMaxMinAnyLabels(
+                  a.label(), a.value(), labelType, ChangeQueryBuilder.OWNER_ACCOUNT_ID));
         }
         distinctApprovals.add(formatLabel(a.label(), a.value()));
+        distinctApprovals.addAll(getMaxMinAnyLabels(a.label(), a.value(), labelType, null));
       }
     }
     allApprovals.addAll(distinctApprovals);
     return allApprovals;
   }
 
+  private static List<String> getMaxMinAnyLabels(
+      String label, short labelVal, LabelType labelType, @Nullable Account.Id accountId) {
+    List<String> labels = new ArrayList<>();
+    if (labelVal == labelType.getMaxPositive()) {
+      labels.add(formatLabel(label, MagicLabelValue.MAX.name(), accountId));
+    }
+    if (labelVal == labelType.getMaxNegative()) {
+      labels.add(formatLabel(label, MagicLabelValue.MIN.name(), accountId));
+    }
+    labels.add(formatLabel(label, MagicLabelValue.ANY.name(), accountId));
+    return labels;
+  }
+
   public static Set<String> getAuthorParts(ChangeData cd) {
     return SchemaUtil.getPersonParts(cd.getAuthor());
   }
@@ -696,6 +718,17 @@
         + (accountId != null ? "," + formatAccount(accountId) : "");
   }
 
+  public static String formatLabel(String label, String value) {
+    return formatLabel(label, value, null);
+  }
+
+  public static String formatLabel(String label, String value, @Nullable Account.Id accountId) {
+    return label.toLowerCase()
+        + "="
+        + value
+        + (accountId != null ? "," + formatAccount(accountId) : "");
+  }
+
   private static String formatAccount(Account.Id accountId) {
     if (ChangeQueryBuilder.OWNER_ACCOUNT_ID.equals(accountId)) {
       return ChangeQueryBuilder.ARG_ID_OWNER;
diff --git a/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java b/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
index ffccb51..879da4f 100644
--- a/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
+++ b/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
@@ -140,10 +140,20 @@
       new Schema.Builder<ChangeData>().add(V60).add(ChangeField.MERGED_ON).build();
 
   /** Added new field {@link ChangeField#FUZZY_HASHTAG} */
+  @Deprecated
   static final Schema<ChangeData> V62 =
       new Schema.Builder<ChangeData>().add(V61).add(ChangeField.FUZZY_HASHTAG).build();
 
   /**
+   * The computation of the {@link ChangeField#DIRECTORY} field is changed, hence reindexing is
+   * required.
+   */
+  static final Schema<ChangeData> V63 = schema(V62, false);
+
+  /** Added support for MIN/MAX/ANY for {@link ChangeField#LABEL} */
+  static final Schema<ChangeData> V64 = schema(V63, false);
+
+  /**
    * Name of the change index to be used when contacting index backends or loading configurations.
    */
   public static final String NAME = "changes";
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/mail/receive/MailProcessor.java b/java/com/google/gerrit/server/mail/receive/MailProcessor.java
index 3a35d80..d805e39 100644
--- a/java/com/google/gerrit/server/mail/receive/MailProcessor.java
+++ b/java/com/google/gerrit/server/mail/receive/MailProcessor.java
@@ -43,7 +43,6 @@
 import com.google.gerrit.mail.MailMessage;
 import com.google.gerrit.mail.MailMetadata;
 import com.google.gerrit.mail.TextParser;
-import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.PatchSetUtil;
@@ -51,6 +50,7 @@
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.account.Emails;
+import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.change.EmailReviewComments;
 import com.google.gerrit.server.config.UrlFormatter;
 import com.google.gerrit.server.extensions.events.CommentAdded;
diff --git a/java/com/google/gerrit/server/mail/send/EmailArguments.java b/java/com/google/gerrit/server/mail/send/EmailArguments.java
index 808d6a4..258c9af 100644
--- a/java/com/google/gerrit/server/mail/send/EmailArguments.java
+++ b/java/com/google/gerrit/server/mail/send/EmailArguments.java
@@ -18,13 +18,14 @@
 import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.server.AnonymousUser;
-import com.google.gerrit.server.ApprovalsUtil;
+import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.GerritPersonIdentProvider;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.IdentifiedUser.GenericFactory;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.GroupBackend;
+import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.config.AnonymousCowardName;
 import com.google.gerrit.server.config.GerritInstanceName;
@@ -95,6 +96,7 @@
   final OutgoingEmailValidator validator;
   final boolean addInstanceNameInSubject;
   final Provider<String> instanceNameProvider;
+  final Provider<CurrentUser> currentUserProvider;
 
   @Inject
   EmailArguments(
@@ -126,7 +128,8 @@
       Provider<InternalAccountQuery> accountQueryProvider,
       OutgoingEmailValidator validator,
       @GerritInstanceName Provider<String> instanceNameProvider,
-      @GerritServerConfig Config cfg) {
+      @GerritServerConfig Config cfg,
+      Provider<CurrentUser> currentUserProvider) {
     this.server = server;
     this.projectCache = projectCache;
     this.permissionBackend = permissionBackend;
@@ -157,5 +160,6 @@
     this.instanceNameProvider = instanceNameProvider;
 
     this.addInstanceNameInSubject = cfg.getBoolean("sendemail", "addInstanceNameInSubject", false);
+    this.currentUserProvider = currentUserProvider;
   }
 }
diff --git a/java/com/google/gerrit/server/mail/send/MergedSender.java b/java/com/google/gerrit/server/mail/send/MergedSender.java
index ea76ab8..6af2345 100644
--- a/java/com/google/gerrit/server/mail/send/MergedSender.java
+++ b/java/com/google/gerrit/server/mail/send/MergedSender.java
@@ -78,8 +78,7 @@
     try {
       Table<Account.Id, String, PatchSetApproval> pos = HashBasedTable.create();
       Table<Account.Id, String, PatchSetApproval> neg = HashBasedTable.create();
-      for (PatchSetApproval ca :
-          args.approvalsUtil.byPatchSet(changeData.notes(), patchSet.id(), null, null)) {
+      for (PatchSetApproval ca : args.approvalsUtil.byPatchSet(changeData.notes(), patchSet.id())) {
         LabelType lt = labelTypes.byLabel(ca.labelId());
         if (lt == null) {
           continue;
diff --git a/java/com/google/gerrit/server/mail/send/OutgoingEmail.java b/java/com/google/gerrit/server/mail/send/OutgoingEmail.java
index f5fb6b0..ddcc0cf 100644
--- a/java/com/google/gerrit/server/mail/send/OutgoingEmail.java
+++ b/java/com/google/gerrit/server/mail/send/OutgoingEmail.java
@@ -30,6 +30,7 @@
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo.EmailFormat;
 import com.google.gerrit.mail.MailHeader;
+import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.change.NotifyResolver;
 import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -127,18 +128,40 @@
         Optional<AccountState> fromUser = args.accountCache.get(fromId);
         if (fromUser.isPresent()) {
           GeneralPreferencesInfo senderPrefs = fromUser.get().generalPreferences();
+          CurrentUser user = args.currentUserProvider.get();
+          boolean isImpersonating = user.isIdentifiedUser() && user.isImpersonating();
+          if (isImpersonating && user.getAccountId() != fromId) {
+            // This should not be possible, if this is the case it means the RequestContext is not
+            // set up correctly.
+            throw new EmailException(
+                String.format(
+                    "User %s is sending email from %s, while acting on behalf of %s",
+                    user.asIdentifiedUser().getRealUser().getAccountId(),
+                    fromId,
+                    user.getAccountId()));
+          }
           if (senderPrefs != null && senderPrefs.getEmailStrategy() == CC_ON_OWN_COMMENTS) {
-            // If we are impersonating a user, make sure they receive a CC of
-            // this message so they can always review and audit what we sent
-            // on their behalf to others.
+            // Include the sender in email if they enabled email notifications on their own
+            // comments.
             //
             logger.atFine().log(
                 "CC email sender %s because the email strategy of this user is %s",
                 fromUser.get().account().id(), CC_ON_OWN_COMMENTS);
             add(RecipientType.CC, fromId);
+          } else if (isImpersonating) {
+            // If we are impersonating a user, make sure they receive a CC of
+            // this message regardless of email strategy, unless email notifications are explicitly
+            // disabled for this user. This way they can always review and audit what we sent
+            // on their behalf to others.
+            logger.atFine().log(
+                "CC email sender %s because the email is sent on behalf of and email notifications"
+                    + " are enabled for this user.",
+                fromUser.get().account().id());
+            add(RecipientType.CC, fromId);
+
           } else if (!notify.accounts().containsValue(fromId) && rcptTo.remove(fromId)) {
             // If they don't want a copy, but we queued one up anyway,
-            // drop them from the recipient lists.
+            // drop them from the recipient lists, but only if the user is not being impersonated.
             //
             logger.atFine().log(
                 "Not CCing email sender %s because the email strategy of this user is not %s but %s",
diff --git a/java/com/google/gerrit/server/notedb/ChangeNoteJson.java b/java/com/google/gerrit/server/notedb/ChangeNoteJson.java
index 483b2e9..4c41a12 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNoteJson.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNoteJson.java
@@ -14,9 +14,17 @@
 
 package com.google.gerrit.server.notedb;
 
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.entities.EntitiesAdapterFactory;
+import com.google.gerrit.json.EnumTypeAdapterFactory;
 import com.google.gson.Gson;
 import com.google.gson.GsonBuilder;
+import com.google.gson.TypeAdapter;
+import com.google.gson.stream.JsonReader;
+import com.google.gson.stream.JsonWriter;
 import com.google.inject.Singleton;
+import com.google.inject.TypeLiteral;
+import java.io.IOException;
 import java.sql.Timestamp;
 
 @Singleton
@@ -26,6 +34,11 @@
   static Gson newGson() {
     return new GsonBuilder()
         .registerTypeAdapter(Timestamp.class, new CommentTimestampAdapter().nullSafe())
+        .registerTypeAdapterFactory(new EnumTypeAdapterFactory())
+        .registerTypeAdapterFactory(EntitiesAdapterFactory.create())
+        .registerTypeAdapter(
+            new TypeLiteral<ImmutableList<String>>() {}.getType(),
+            new ImmutableListAdapter().nullSafe())
         .setPrettyPrinting()
         .create();
   }
@@ -33,4 +46,27 @@
   public Gson getGson() {
     return gson;
   }
+
+  static class ImmutableListAdapter extends TypeAdapter<ImmutableList<String>> {
+
+    @Override
+    public void write(JsonWriter out, ImmutableList<String> value) throws IOException {
+      out.beginArray();
+      for (String v : value) {
+        out.value(v);
+      }
+      out.endArray();
+    }
+
+    @Override
+    public ImmutableList<String> read(JsonReader in) throws IOException {
+      ImmutableList.Builder<String> builder = ImmutableList.builder();
+      in.beginArray();
+      while (in.hasNext()) {
+        builder.add(in.nextString());
+      }
+      in.endArray();
+      return builder.build();
+    }
+  }
 }
diff --git a/java/com/google/gerrit/server/notedb/ChangeNoteUtil.java b/java/com/google/gerrit/server/notedb/ChangeNoteUtil.java
index 15f187a..7711289 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNoteUtil.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNoteUtil.java
@@ -54,6 +54,8 @@
   static final FooterKey FOOTER_REVERT_OF = new FooterKey("Revert-of");
   static final FooterKey FOOTER_CHERRY_PICK_OF = new FooterKey("Cherry-pick-of");
 
+  static final String GERRIT_USER_TEMPLATE = "Gerrit User %d";
+
   private static final Gson gson = OutputFormat.JSON_COMPACT.newGson();
 
   private final ChangeNoteJson changeNoteJson;
@@ -83,6 +85,11 @@
         .append('>');
   }
 
+  public static String formatAccountIdentString(Account.Id account, String accountIdAsEmail) {
+    return String.format(
+        "%s <%s>", ChangeNoteUtil.getAccountIdAsUsername(account), accountIdAsEmail);
+  }
+
   /**
    * Returns a {@link PersonIdent} that contains the account ID, but not the user's name or email
    * address.
@@ -97,10 +104,10 @@
 
   /** Returns the string {@code "Gerrit User " + accountId}, to pseudonymize user names. */
   public static String getAccountIdAsUsername(Account.Id accountId) {
-    return "Gerrit User " + accountId.toString();
+    return String.format(GERRIT_USER_TEMPLATE, accountId.get());
   }
 
-  private String getAccountIdAsEmailAddress(Account.Id accountId) {
+  public String getAccountIdAsEmailAddress(Account.Id accountId) {
     return accountId.get() + "@" + serverId;
   }
 
@@ -145,7 +152,14 @@
     }
 
     if (ptr <= changeMessageStart) {
-      return Optional.empty();
+      // Return with subject, ChangeMessage is empty
+      return Optional.of(
+          CommitMessageRange.builder()
+              .subjectStart(subjectStart)
+              .subjectEnd(subjectEnd)
+              .changeMessageStart(changeMessageStart)
+              .changeMessageEnd(changeMessageStart)
+              .build());
     }
 
     CommitMessageRange range =
@@ -170,6 +184,10 @@
 
     public abstract int changeMessageEnd();
 
+    public boolean hasChangeMessage() {
+      return changeMessageStart() < changeMessageEnd();
+    }
+
     public static Builder builder() {
       return new AutoValue_ChangeNoteUtil_CommitMessageRange.Builder();
     }
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotes.java b/java/com/google/gerrit/server/notedb/ChangeNotes.java
index 77d7bc0..6684493 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotes.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotes.java
@@ -19,7 +19,6 @@
 import static com.google.common.collect.ImmutableSet.toImmutableSet;
 import static com.google.gerrit.entities.RefNames.changeMetaRef;
 import static java.util.Comparator.comparing;
-import static java.util.Objects.requireNonNull;
 
 import com.google.auto.value.AutoValue;
 import com.google.common.annotations.VisibleForTesting;
@@ -51,6 +50,7 @@
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.entities.RobotComment;
 import com.google.gerrit.entities.SubmitRecord;
+import com.google.gerrit.entities.SubmitRequirementResult;
 import com.google.gerrit.server.AssigneeStatusUpdate;
 import com.google.gerrit.server.ReviewerByEmailSet;
 import com.google.gerrit.server.ReviewerSet;
@@ -414,6 +414,16 @@
   }
 
   /**
+   * Returns the evaluated submit requirements for the change. We only intend to store submit
+   * requirements in NoteDb for closed changes, hence the result will be an empty list for active
+   * changes, or a list of submit requirements results otherwise. For closed changes, the results
+   * represent the state of evaluating submit requirements for this change when it was merged.
+   */
+  public ImmutableList<SubmitRequirementResult> getSubmitRequirementsResult() {
+    return state.submitRequirementsResult();
+  }
+
+  /**
    * @return an ImmutableSet of Account.Ids of all users that have been assigned to this change. The
    *     order of the set is the order in which they were assigned.
    */
@@ -554,8 +564,20 @@
 
   public PatchSet getCurrentPatchSet() {
     PatchSet.Id psId = change.currentPatchSetId();
-    return requireNonNull(
-        getPatchSets().get(psId), () -> String.format("missing current patch set %s", psId.get()));
+    if (psId == null || getPatchSets().get(psId) == null) {
+      // In some cases, the current patch-set doesn't exist yet as it's being created during the
+      // operation (e.g rebase).
+      PatchSet currentPatchset =
+          getPatchSets().values().stream()
+              .max((p1, p2) -> p1.id().get() - p2.id().get())
+              .orElseThrow(
+                  () ->
+                      new IllegalStateException(
+                          String.format(
+                              "change %s can't load any patchset", getChangeId().toString())));
+      return currentPatchset;
+    }
+    return getPatchSets().get(psId);
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotesCommit.java b/java/com/google/gerrit/server/notedb/ChangeNotesCommit.java
index 71cb8c9..76573f6 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotesCommit.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotesCommit.java
@@ -15,9 +15,12 @@
 package com.google.gerrit.server.notedb;
 
 import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_ATTENTION;
+import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_PATCH_SET;
 
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.MultimapBuilder;
+import com.google.common.collect.Sets;
 import com.google.gerrit.server.git.InMemoryInserter;
 import com.google.gerrit.server.git.InsertedObject;
 import java.io.IOException;
@@ -127,4 +130,14 @@
     }
     return footerLines.get(key.getName().toLowerCase());
   }
+
+  public boolean isAttentionSetCommitOnly(boolean hasChangeMessage) {
+    return !hasChangeMessage
+        && footerLines
+            .keySet()
+            .equals(
+                Sets.newHashSet(
+                    FOOTER_PATCH_SET.getName().toLowerCase(),
+                    FOOTER_ATTENTION.getName().toLowerCase()));
+  }
 }
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotesParser.java b/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
index f4d6cd3..2a53c29 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
@@ -68,6 +68,7 @@
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.entities.SubmitRecord;
+import com.google.gerrit.entities.SubmitRequirementResult;
 import com.google.gerrit.metrics.Timer0;
 import com.google.gerrit.server.AssigneeStatusUpdate;
 import com.google.gerrit.server.ReviewerByEmailSet;
@@ -125,6 +126,7 @@
   private final List<AssigneeStatusUpdate> assigneeUpdates;
   private final List<SubmitRecord> submitRecords;
   private final ListMultimap<ObjectId, HumanComment> humanComments;
+  private final List<SubmitRequirementResult> submitRequirementResults;
   private final Map<PatchSet.Id, PatchSet.Builder> patchSets;
   private final Set<PatchSet.Id> deletedPatchSets;
   private final Map<PatchSet.Id, PatchSetState> patchSetStates;
@@ -187,6 +189,7 @@
     submitRecords = Lists.newArrayListWithExpectedSize(1);
     allChangeMessages = new ArrayList<>();
     humanComments = MultimapBuilder.hashKeys().arrayListValues().build();
+    submitRequirementResults = new ArrayList<>();
     patchSets = new HashMap<>();
     deletedPatchSets = new HashSet<>();
     patchSetStates = new HashMap<>();
@@ -259,6 +262,7 @@
         submitRecords,
         buildAllMessages(),
         humanComments,
+        submitRequirementResults,
         firstNonNull(isPrivate, false),
         firstNonNull(workInProgress, false),
         firstNonNull(hasReviewStarted, true),
@@ -327,7 +331,6 @@
   }
 
   private void parse(ChangeNotesCommit commit) throws ConfigInvalidException {
-    updateCount++;
     Timestamp commitTimestamp = getCommitTimestamp(commit);
 
     createdOn = commitTimestamp;
@@ -370,7 +373,8 @@
       originalSubject = currSubject;
     }
 
-    parseChangeMessage(psId, accountId, realAccountId, commit, commitTimestamp);
+    boolean hasChangeMessage =
+        parseChangeMessage(psId, accountId, realAccountId, commit, commitTimestamp);
     if (topic == null) {
       topic = parseTopic(commit);
     }
@@ -435,6 +439,9 @@
 
     previousWorkInProgressFooter = null;
     parseWorkInProgress(commit);
+    if (countTowardsMaxUpdatesLimit(commit, hasChangeMessage)) {
+      updateCount++;
+    }
   }
 
   private void parseSubmission(ChangeNotesCommit commit, Timestamp commitTimestamp)
@@ -720,7 +727,7 @@
     }
   }
 
-  private void parseChangeMessage(
+  private boolean parseChangeMessage(
       PatchSet.Id psId,
       Account.Id accountId,
       Account.Id realAccountId,
@@ -728,7 +735,7 @@
       Timestamp ts) {
     Optional<String> changeMsgString = getChangeMessageString(commit);
     if (!changeMsgString.isPresent()) {
-      return;
+      return false;
     }
 
     ChangeMessage changeMessage =
@@ -740,7 +747,7 @@
             changeMsgString.get(),
             realAccountId,
             tag);
-    allChangeMessages.add(changeMessage);
+    return allChangeMessages.add(changeMessage);
   }
 
   public static Optional<String> getChangeMessageString(ChangeNotesCommit commit) {
@@ -750,11 +757,13 @@
     Optional<ChangeNoteUtil.CommitMessageRange> range = parseCommitMessageRange(commit);
     return range.map(
         commitMessageRange ->
-            RawParseUtils.decode(
-                enc,
-                raw,
-                commitMessageRange.changeMessageStart(),
-                commitMessageRange.changeMessageEnd() + 1));
+            commitMessageRange.hasChangeMessage()
+                ? RawParseUtils.decode(
+                    enc,
+                    raw,
+                    commitMessageRange.changeMessageStart(),
+                    commitMessageRange.changeMessageEnd() + 1)
+                : null);
   }
 
   private void parseNotes() throws IOException, ConfigInvalidException {
@@ -769,6 +778,9 @@
       for (HumanComment c : e.getValue().getEntities()) {
         humanComments.put(e.getKey(), c);
       }
+      for (SubmitRequirementResult sr : e.getValue().getSubmitRequirementsResult()) {
+        submitRequirementResults.add(sr);
+      }
     }
 
     for (PatchSet.Builder b : patchSets.values()) {
@@ -1197,4 +1209,9 @@
         .orElseThrow(
             () -> parseException("cannot retrieve account id: %s", ident.getEmailAddress()));
   }
+
+  protected boolean countTowardsMaxUpdatesLimit(
+      ChangeNotesCommit commit, boolean hasChangeMessage) {
+    return !commit.isAttentionSetCommitOnly(hasChangeMessage);
+  }
 }
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotesState.java b/java/com/google/gerrit/server/notedb/ChangeNotesState.java
index 33bc039..e7da025 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotesState.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotesState.java
@@ -44,6 +44,7 @@
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.SubmitRecord;
+import com.google.gerrit.entities.SubmitRequirementResult;
 import com.google.gerrit.entities.converter.ChangeMessageProtoConverter;
 import com.google.gerrit.entities.converter.PatchSetApprovalProtoConverter;
 import com.google.gerrit.entities.converter.PatchSetProtoConverter;
@@ -60,10 +61,14 @@
 import com.google.gerrit.server.cache.proto.Cache.ChangeNotesStateProto.ReviewerByEmailSetEntryProto;
 import com.google.gerrit.server.cache.proto.Cache.ChangeNotesStateProto.ReviewerSetEntryProto;
 import com.google.gerrit.server.cache.proto.Cache.ChangeNotesStateProto.ReviewerStatusUpdateProto;
+import com.google.gerrit.server.cache.proto.Cache.SubmitRequirementResultProto;
 import com.google.gerrit.server.cache.serialize.CacheSerializer;
 import com.google.gerrit.server.cache.serialize.ObjectIdConverter;
+import com.google.gerrit.server.cache.serialize.entities.SubmitRequirementExpressionResultSerializer;
+import com.google.gerrit.server.cache.serialize.entities.SubmitRequirementSerializer;
 import com.google.gerrit.server.index.change.ChangeField.StoredSubmitRecord;
 import com.google.gson.Gson;
+import com.google.protobuf.Descriptors.FieldDescriptor;
 import java.sql.Timestamp;
 import java.time.Instant;
 import java.util.List;
@@ -128,6 +133,7 @@
       List<SubmitRecord> submitRecords,
       List<ChangeMessage> changeMessages,
       ListMultimap<ObjectId, HumanComment> publishedComments,
+      List<SubmitRequirementResult> submitRequirementResults,
       boolean isPrivate,
       boolean workInProgress,
       boolean reviewStarted,
@@ -181,6 +187,7 @@
         .submitRecords(submitRecords)
         .changeMessages(changeMessages)
         .publishedComments(publishedComments)
+        .submitRequirementsResult(submitRequirementResults)
         .updateCount(updateCount)
         .mergedOn(mergedOn)
         .build();
@@ -326,6 +333,8 @@
 
   abstract ImmutableListMultimap<ObjectId, HumanComment> publishedComments();
 
+  abstract ImmutableList<SubmitRequirementResult> submitRequirementsResult();
+
   abstract int updateCount();
 
   @Nullable
@@ -404,6 +413,7 @@
           .submitRecords(ImmutableList.of())
           .changeMessages(ImmutableList.of())
           .publishedComments(ImmutableListMultimap.of())
+          .submitRequirementsResult(ImmutableList.of())
           .updateCount(0);
     }
 
@@ -445,6 +455,9 @@
 
     abstract Builder publishedComments(ListMultimap<ObjectId, HumanComment> publishedComments);
 
+    abstract Builder submitRequirementsResult(
+        List<SubmitRequirementResult> submitRequirementsResult);
+
     abstract Builder updateCount(int updateCount);
 
     abstract Builder mergedOn(Timestamp mergedOn);
@@ -465,6 +478,11 @@
     private static final Converter<String, ReviewerStateInternal> REVIEWER_STATE_CONVERTER =
         Enums.stringConverter(ReviewerStateInternal.class);
 
+    private static final FieldDescriptor SR_APPLICABILITY_EXPR_RESULT_FIELD =
+        SubmitRequirementResultProto.getDescriptor().findFieldByNumber(2);
+    private static final FieldDescriptor SR_OVERRIDE_EXPR_RESULT_FIELD =
+        SubmitRequirementResultProto.getDescriptor().findFieldByNumber(4);
+
     @Override
     public byte[] serialize(ChangeNotesState object) {
       checkArgument(object.metaId() != null, "meta ID is required in: %s", object);
@@ -519,6 +537,9 @@
           .changeMessages()
           .forEach(m -> b.addChangeMessage(ChangeMessageProtoConverter.INSTANCE.toProto(m)));
       object.publishedComments().values().forEach(c -> b.addPublishedComment(GSON.toJson(c)));
+      object
+          .submitRequirementsResult()
+          .forEach(sr -> b.addSubmitRequirementResult(toSubmitRequirementResultProto(sr)));
       b.setUpdateCount(object.updateCount());
       if (object.mergedOn() != null) {
         b.setMergedOnMillis(object.mergedOn().getTime());
@@ -613,6 +634,53 @@
       return builder.build();
     }
 
+    private static SubmitRequirementResultProto toSubmitRequirementResultProto(
+        SubmitRequirementResult r) {
+      SubmitRequirementResultProto.Builder builder = SubmitRequirementResultProto.newBuilder();
+      builder
+          .setSubmitRequirement(SubmitRequirementSerializer.serialize(r.submitRequirement()))
+          .setCommit(ObjectIdConverter.create().toByteString(r.patchSetCommitId()));
+      if (r.applicabilityExpressionResult().isPresent()) {
+        builder.setApplicabilityExpressionResult(
+            SubmitRequirementExpressionResultSerializer.serialize(
+                r.applicabilityExpressionResult().get()));
+      }
+      builder.setSubmittabilityExpressionResult(
+          SubmitRequirementExpressionResultSerializer.serialize(
+              r.submittabilityExpressionResult()));
+      if (r.overrideExpressionResult().isPresent()) {
+        builder.setOverrideExpressionResult(
+            SubmitRequirementExpressionResultSerializer.serialize(
+                r.overrideExpressionResult().get()));
+      }
+      return builder.build();
+    }
+
+    private static SubmitRequirementResult toSubmitRequirementResult(
+        SubmitRequirementResultProto proto) {
+      SubmitRequirementResult.Builder builder =
+          SubmitRequirementResult.builder()
+              .patchSetCommitId(ObjectIdConverter.create().fromByteString(proto.getCommit()))
+              .submitRequirement(
+                  SubmitRequirementSerializer.deserialize(proto.getSubmitRequirement()));
+      if (proto.hasField(SR_APPLICABILITY_EXPR_RESULT_FIELD)) {
+        builder.applicabilityExpressionResult(
+            Optional.of(
+                SubmitRequirementExpressionResultSerializer.deserialize(
+                    proto.getApplicabilityExpressionResult())));
+      }
+      builder.submittabilityExpressionResult(
+          SubmitRequirementExpressionResultSerializer.deserialize(
+              proto.getSubmittabilityExpressionResult()));
+      if (proto.hasField(SR_OVERRIDE_EXPR_RESULT_FIELD)) {
+        builder.overrideExpressionResult(
+            Optional.of(
+                SubmitRequirementExpressionResultSerializer.deserialize(
+                    proto.getOverrideExpressionResult())));
+      }
+      return builder.build();
+    }
+
     @Override
     public ChangeNotesState deserialize(byte[] in) {
       ChangeNotesStateProto proto = Protos.parseUnchecked(ChangeNotesStateProto.parser(), in);
@@ -658,6 +726,10 @@
                   proto.getPublishedCommentList().stream()
                       .map(r -> GSON.fromJson(r, HumanComment.class))
                       .collect(toImmutableListMultimap(HumanComment::getCommitId, c -> c)))
+              .submitRequirementsResult(
+                  proto.getSubmitRequirementResultList().stream()
+                      .map(sr -> toSubmitRequirementResult(sr))
+                      .collect(toImmutableList()))
               .updateCount(proto.getUpdateCount())
               .mergedOn(proto.getHasMergedOn() ? new Timestamp(proto.getMergedOnMillis()) : null);
       return b.build();
diff --git a/java/com/google/gerrit/server/notedb/ChangeRevisionNote.java b/java/com/google/gerrit/server/notedb/ChangeRevisionNote.java
index bf2cf07..44475db 100644
--- a/java/com/google/gerrit/server/notedb/ChangeRevisionNote.java
+++ b/java/com/google/gerrit/server/notedb/ChangeRevisionNote.java
@@ -16,7 +16,9 @@
 
 import static java.nio.charset.StandardCharsets.UTF_8;
 
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.entities.HumanComment;
+import com.google.gerrit.entities.SubmitRequirementResult;
 import java.io.ByteArrayInputStream;
 import java.io.IOException;
 import java.io.InputStream;
@@ -34,6 +36,8 @@
   private final HumanComment.Status status;
   private String pushCert;
 
+  private ImmutableList<SubmitRequirementResult> submitRequirementsResult;
+
   ChangeRevisionNote(
       ChangeNoteJson noteJson, ObjectReader reader, ObjectId noteId, HumanComment.Status status) {
     super(reader, noteId);
@@ -41,6 +45,11 @@
     this.status = status;
   }
 
+  public ImmutableList<SubmitRequirementResult> getSubmitRequirementsResult() {
+    checkParsed();
+    return submitRequirementsResult;
+  }
+
   public String getPushCert() {
     checkParsed();
     return pushCert;
@@ -52,20 +61,24 @@
     MutableInteger p = new MutableInteger();
     p.value = offset;
 
-    HumanCommentsRevisionNoteData data = parseJson(noteJson, raw, p.value);
+    ChangeRevisionNoteData data = parseJson(noteJson, raw, p.value);
     if (status == HumanComment.Status.PUBLISHED) {
       pushCert = data.pushCert;
     } else {
       pushCert = null;
     }
+    this.submitRequirementsResult =
+        data.submitRequirementResults == null
+            ? ImmutableList.of()
+            : ImmutableList.copyOf(data.submitRequirementResults);
     return data.comments;
   }
 
-  private HumanCommentsRevisionNoteData parseJson(ChangeNoteJson noteUtil, byte[] raw, int offset)
+  private ChangeRevisionNoteData parseJson(ChangeNoteJson noteUtil, byte[] raw, int offset)
       throws IOException {
     try (InputStream is = new ByteArrayInputStream(raw, offset, raw.length - offset);
         Reader r = new InputStreamReader(is, UTF_8)) {
-      return noteUtil.getGson().fromJson(r, HumanCommentsRevisionNoteData.class);
+      return noteUtil.getGson().fromJson(r, ChangeRevisionNoteData.class);
     }
   }
 }
diff --git a/java/com/google/gerrit/server/notedb/HumanCommentsRevisionNoteData.java b/java/com/google/gerrit/server/notedb/ChangeRevisionNoteData.java
similarity index 78%
rename from java/com/google/gerrit/server/notedb/HumanCommentsRevisionNoteData.java
rename to java/com/google/gerrit/server/notedb/ChangeRevisionNoteData.java
index e570412..8e33023 100644
--- a/java/com/google/gerrit/server/notedb/HumanCommentsRevisionNoteData.java
+++ b/java/com/google/gerrit/server/notedb/ChangeRevisionNoteData.java
@@ -15,14 +15,17 @@
 package com.google.gerrit.server.notedb;
 
 import com.google.gerrit.entities.HumanComment;
+import com.google.gerrit.entities.SubmitRequirementResult;
 import java.util.List;
 
 /**
  * Holds the raw data of a RevisionNote.
  *
- * <p>It is intended for deserialization from JSON only. It is used for human comments only.
+ * <p>It is intended for deserialization from JSON only. It is used for human comments. Submit
+ * requirements are also stored but only for closed changes.
  */
-class HumanCommentsRevisionNoteData {
+class ChangeRevisionNoteData {
   String pushCert;
   List<HumanComment> comments;
+  List<SubmitRequirementResult> submitRequirementResults;
 }
diff --git a/java/com/google/gerrit/server/notedb/ChangeUpdate.java b/java/com/google/gerrit/server/notedb/ChangeUpdate.java
index 9d23137..971e0a8 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;
@@ -64,6 +65,7 @@
 import com.google.gerrit.entities.RobotComment;
 import com.google.gerrit.entities.SubmissionId;
 import com.google.gerrit.entities.SubmitRecord;
+import com.google.gerrit.entities.SubmitRequirementResult;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.client.ReviewerState;
 import com.google.gerrit.server.CurrentUser;
@@ -77,6 +79,7 @@
 import com.google.inject.assistedinject.AssistedInject;
 import java.io.IOException;
 import java.util.ArrayList;
+import java.util.Collection;
 import java.util.Comparator;
 import java.util.Date;
 import java.util.HashMap;
@@ -114,7 +117,6 @@
   public interface Factory {
     ChangeUpdate create(ChangeNotes notes, CurrentUser user, Date when);
 
-    @VisibleForTesting
     ChangeUpdate create(
         ChangeNotes notes, CurrentUser user, Date when, Comparator<String> labelNameComparator);
   }
@@ -129,6 +131,7 @@
   private final Map<Account.Id, ReviewerStateInternal> reviewers = new LinkedHashMap<>();
   private final Map<Address, ReviewerStateInternal> reviewersByEmail = new LinkedHashMap<>();
   private final List<HumanComment> comments = new ArrayList<>();
+  private final List<SubmitRequirementResult> submitRequirementResults = new ArrayList<>();
 
   private String commitSubject;
   private String subject;
@@ -302,6 +305,10 @@
     this.psDescription = psDescription;
   }
 
+  public void putSubmitRequirementResults(Collection<SubmitRequirementResult> rs) {
+    submitRequirementResults.addAll(rs);
+  }
+
   public void putComment(HumanComment.Status status, HumanComment c) {
     verifyComment(c);
     createDraftUpdateIfNull();
@@ -488,7 +495,7 @@
   /** @return the tree id for the updated tree */
   private ObjectId storeRevisionNotes(RevWalk rw, ObjectInserter inserter, ObjectId curr)
       throws ConfigInvalidException, IOException {
-    if (comments.isEmpty() && pushCert == null) {
+    if (submitRequirementResults.isEmpty() && comments.isEmpty() && pushCert == null) {
       return null;
     }
     RevisionNoteMap<ChangeRevisionNote> rnm = getRevisionNoteMap(rw, curr);
@@ -498,6 +505,9 @@
       c.tag = tag;
       cache.get(c.getCommitId()).putComment(c);
     }
+    for (SubmitRequirementResult sr : submitRequirementResults) {
+      cache.get(sr.patchSetCommitId()).putSubmitRequirementResult(sr);
+    }
     if (pushCert != null) {
       checkState(commit != null);
       cache.get(ObjectId.fromString(commit)).setPushCertificate(pushCert);
@@ -579,10 +589,27 @@
 
   @Override
   protected boolean bypassMaxUpdates() {
-    // Allow abandoning or submitting a change even if it would exceed the max update count.
+    return isAbandonChange() || isAttentionSetChangeOnly();
+  }
+
+  private boolean isAbandonChange() {
     return status != null && status.isClosed();
   }
 
+  private boolean isAttentionSetChangeOnly() {
+    return (plannedAttentionSetUpdates != null
+        && plannedAttentionSetUpdates.size() > 0
+        && doesNotHaveChangesAffectingAttentionSet());
+  }
+
+  private boolean doesNotHaveChangesAffectingAttentionSet() {
+    return comments.isEmpty()
+        && reviewers.isEmpty()
+        && reviewersByEmail.isEmpty()
+        && approvals.isEmpty()
+        && workInProgress == null;
+  }
+
   @Override
   protected CommitBuilder applyImpl(RevWalk rw, ObjectInserter ins, ObjectId curr)
       throws IOException {
@@ -753,9 +780,7 @@
       }
     }
 
-    if (plannedAttentionSetUpdates != null) {
-      updateAttentionSet(msg);
-    }
+    updateAttentionSet(msg);
 
     CommitBuilder cb = new CommitBuilder();
     cb.setMessage(msg.toString());
@@ -830,10 +855,14 @@
   /**
    * Any updates to the attention set must be done in {@link #addToPlannedAttentionSetUpdates}. This
    * method is called after all the updates are finished to do the updates once and for real.
+   *
+   * <p>Changing the behaviour of this method might affect the way a ChangeUpdate is considered to
+   * be an "Attention Set Change Only". Make sure the {@link #isAttentionSetChangeOnly} logic is
+   * amended as well if needed.
    */
   private void updateAttentionSet(StringBuilder msg) {
     if (plannedAttentionSetUpdates == null) {
-      return;
+      plannedAttentionSetUpdates = new HashMap<>();
     }
     Set<Account.Id> currentUsersInAttentionSet =
         AttentionSetUtil.additionsOnly(getNotes().getAttentionSet()).stream()
@@ -855,6 +884,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())) {
@@ -892,6 +923,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/notedb/CommitRewriter.java b/java/com/google/gerrit/server/notedb/CommitRewriter.java
new file mode 100644
index 0000000..0bad34d
--- /dev/null
+++ b/java/com/google/gerrit/server/notedb/CommitRewriter.java
@@ -0,0 +1,762 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+package com.google.gerrit.server.notedb;
+
+import static com.google.common.base.Preconditions.checkState;
+import static com.google.gerrit.entities.ChangeMessage.ACCOUNT_TEMPLATE_REGEX;
+import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_ASSIGNEE;
+import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_ATTENTION;
+import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_LABEL;
+import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_REAL_USER;
+import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_SUBMITTED_WITH;
+import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_TAG;
+
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.common.UsedAt;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.HumanComment;
+import com.google.gerrit.entities.PatchSetApproval;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.entities.SubmitRecord;
+import com.google.gerrit.git.RefUpdateUtil;
+import com.google.gerrit.server.ChangeMessagesUtil;
+import com.google.gerrit.server.account.AccountCache;
+import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.notedb.ChangeNoteUtil.CommitMessageRange;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.nio.charset.Charset;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+import org.eclipse.jgit.diff.DiffAlgorithm;
+import org.eclipse.jgit.diff.DiffFormatter;
+import org.eclipse.jgit.diff.EditList;
+import org.eclipse.jgit.diff.HistogramDiff;
+import org.eclipse.jgit.diff.RawText;
+import org.eclipse.jgit.diff.RawTextComparator;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.internal.storage.file.FileRepository;
+import org.eclipse.jgit.internal.storage.file.PackInserter;
+import org.eclipse.jgit.lib.BatchRefUpdate;
+import org.eclipse.jgit.lib.CommitBuilder;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.FooterLine;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevSort;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.transport.ReceiveCommand;
+import org.eclipse.jgit.util.RawParseUtils;
+
+/**
+ * Rewrites ('backfills') commit history of change in NoteDb to not contain user data. Only fixes
+ * known cases, rewriting commits case by case.
+ *
+ * <p>The cases where we used to put user data in NoteDb can be found by
+ * https://gerrit-review.googlesource.com/q/hashtag:user-data-cleanup
+ *
+ * <p>As opposed to {@link NoteDbRewriter} implementations, which target a specific change and are
+ * used by REST endpoints, this rewriter is used as standalone tool, that bulk backfills changes by
+ * project.
+ */
+@UsedAt(UsedAt.Project.GOOGLE)
+@Singleton
+public class CommitRewriter {
+  /** Options to run {@link #backfillProject}. */
+  public static class RunOptions {
+    /** Whether to rewrite the commit history or only find refs that need to be fixed. */
+    public boolean dryRun = true;
+    /**
+     * Whether to verify that resulting commits contain user data for the accounts that are linked
+     * to a change, see {@link #verifyCommit}, {@link #collectAccounts}.
+     */
+    public boolean verifyCommits = true;
+    /** Whether to compute and output the diff of the commit history for the backfilled refs. */
+    public boolean outputDiff = true;
+  }
+
+  /** Result of the backfill run for a project. */
+  public static class BackfillResult {
+
+    /** If the run for the project was successful. */
+    public boolean ok;
+
+    /**
+     * Refs that were fixed by the run/ would be fixed if in --dry-run, together with their commit
+     * history diff. Diff is empty if --output-diff is false.
+     */
+    public Map<String, List<String>> fixedRefDiff = new HashMap<>();
+
+    /**
+     * Refs that still contain user data after the backfill run. Only filled if --verify-commits,
+     * see {@link #verifyCommit}
+     */
+    public List<String> refsStillInvalidAfterFix = new ArrayList<>();
+
+    /** Refs, failed to backfill by the run. */
+    public List<String> refsFailedToFix = new ArrayList<>();
+  }
+
+  private final ChangeNotes.Factory changeNotesFactory;
+  private final AccountCache accountCache;
+  private DiffAlgorithm diffAlgorithm = new HistogramDiff();
+
+  @Inject
+  public CommitRewriter(ChangeNotes.Factory changeNotesFactory, AccountCache accountCache) {
+    this.changeNotesFactory = changeNotesFactory;
+    this.accountCache = accountCache;
+  }
+
+  /**
+   * Rewrites commit history of {@link RefNames#changeMetaRef}s in single {@code repo}. Only
+   * rewrites branch if necessary, i.e. if there were any commits that contained user data.
+   *
+   * <p>See {@link RunOptions} for the execution and output options.
+   *
+   * @param project project to backfill
+   * @param repo repo to backfill
+   * @param options {@link RunOptions} to control how the run is executed.
+   * @return BackfillResult
+   */
+  public BackfillResult backfillProject(
+      Project.NameKey project, Repository repo, RunOptions options) {
+    BackfillResult result = new BackfillResult();
+    result.ok = true;
+    try (RevWalk revWalk = new RevWalk(repo);
+        ObjectInserter ins = newPackInserter(repo)) {
+      BatchRefUpdate bru = repo.getRefDatabase().newBatchUpdate();
+      bru.setAllowNonFastForwards(true);
+      for (Ref ref : repo.getRefDatabase().getRefsByPrefix(RefNames.REFS_CHANGES)) {
+        Change.Id changeId = Change.Id.fromRef(ref.getName());
+        if (changeId == null || !ref.getName().equals(RefNames.changeMetaRef(changeId))) {
+          continue;
+        }
+
+        ChangeNotes changeNotes = changeNotesFactory.create(project, changeId);
+        ImmutableSet<AccountState> accountsInChange =
+            options.verifyCommits ? collectAccounts(changeNotes) : ImmutableSet.of();
+        try {
+          ChangeFixProgress changeFixProgress =
+              backfillChange(revWalk, ins, ref, accountsInChange, options);
+          if (changeFixProgress.anyFixesApplied) {
+            bru.addCommand(
+                new ReceiveCommand(ref.getObjectId(), changeFixProgress.newTipId, ref.getName()));
+            result.fixedRefDiff.put(ref.getName(), changeFixProgress.commitDiffs);
+          }
+
+          if (!changeFixProgress.isValidAfterFix) {
+            result.refsStillInvalidAfterFix.add(ref.getName());
+          }
+        } catch (ConfigInvalidException | IOException e) {
+          result.refsFailedToFix.add(ref.getName());
+        }
+      }
+
+      if (!bru.getCommands().isEmpty()) {
+        if (!options.dryRun) {
+          ins.flush();
+          RefUpdateUtil.executeChecked(bru, revWalk);
+        }
+      }
+    } catch (IOException e) {
+      result.ok = false;
+    }
+
+    return result;
+  }
+
+  /**
+   * Retrieves accounts, that are associated with a change (e.g. reviewers, commenters, etc.). These
+   * accounts are used to verify that commits do not contain user data. See {@link #verifyCommit}
+   *
+   * @param changeNotes {@link ChangeNotes} of the change to retrieve associated accounts from.
+   * @return {@link AccountState} of accounts, that are associated with the change.
+   */
+  private ImmutableSet<AccountState> collectAccounts(ChangeNotes changeNotes) {
+    Set<Account.Id> accounts = new HashSet<>();
+    accounts.add(changeNotes.getChange().getOwner());
+    for (PatchSetApproval patchSetApproval : changeNotes.getApprovals().values()) {
+      accounts.add(patchSetApproval.accountId());
+      accounts.add(patchSetApproval.realAccountId());
+    }
+    accounts.addAll(changeNotes.getAllPastReviewers());
+    accounts.addAll(changeNotes.getPastAssignees());
+    changeNotes
+        .getAttentionSetUpdates()
+        .forEach(attentionSetUpdate -> accounts.add(attentionSetUpdate.account()));
+    for (SubmitRecord submitRecord : changeNotes.getSubmitRecords()) {
+      accounts.addAll(
+          submitRecord.labels.stream()
+              .map(label -> label.appliedBy)
+              .filter(Objects::nonNull)
+              .collect(Collectors.toSet()));
+    }
+    for (HumanComment comment : changeNotes.getHumanComments().values()) {
+      accounts.add(comment.author.getId());
+      accounts.add(comment.getRealAuthor().getId());
+    }
+    return ImmutableSet.copyOf(accountCache.get(accounts).values());
+  }
+
+  /** Verifies that the commit does not contain user data of accounts in {@code accounts}. */
+  private boolean verifyCommit(
+      String commitMessage, PersonIdent author, Collection<AccountState> accounts) {
+    for (AccountState accountState : accounts) {
+      Account account = accountState.account();
+      if (commitMessage.contains(account.getName())) {
+        return false;
+      }
+      if (account.fullName() != null && commitMessage.contains(account.fullName())) {
+        return false;
+      }
+      if (account.displayName() != null && commitMessage.contains(account.displayName())) {
+        return false;
+      }
+      if (account.preferredEmail() != null && commitMessage.contains(account.preferredEmail())) {
+        return false;
+      }
+      if (accountState.userName().isPresent()
+          && commitMessage.contains(accountState.userName().get())) {
+        return false;
+      }
+      Stream<String> allEmails =
+          accountState.externalIds().stream().map(ExternalId::email).filter(Objects::nonNull);
+      if (allEmails.anyMatch(email -> commitMessage.contains(email))) {
+        return false;
+      }
+      if (author.toString().contains(account.getName())) {
+        return false;
+      }
+    }
+    return true;
+  }
+
+  /**
+   * Walks the ref history from oldest update to the most recent update, fixing the commits that
+   * contain user data case by case. Commit history is rewritten from the first commit, that needs
+   * to be updated, for all subsequent updates. The new ref tip is returned in {@link
+   * ChangeFixProgress#newTipId}.
+   */
+  public ChangeFixProgress backfillChange(
+      RevWalk revWalk,
+      ObjectInserter inserter,
+      Ref ref,
+      ImmutableSet<AccountState> accountsInChange,
+      RunOptions options)
+      throws IOException, ConfigInvalidException {
+
+    ObjectId oldTip = ref.getObjectId();
+    // Walk from the first commit of the branch.
+    revWalk.reset();
+    revWalk.markStart(revWalk.parseCommit(oldTip));
+    revWalk.sort(RevSort.TOPO);
+
+    revWalk.sort(RevSort.REVERSE);
+
+    RevCommit originalCommit;
+
+    boolean rewriteStarted = false;
+    ChangeFixProgress changeFixProgress = new ChangeFixProgress();
+    while ((originalCommit = revWalk.next()) != null) {
+
+      changeFixProgress.updateAuthorId = parseIdent(originalCommit.getAuthorIdent());
+      PersonIdent fixedAuthorIdent =
+          getFixedIdent(originalCommit.getAuthorIdent(), changeFixProgress.updateAuthorId);
+      Optional<String> fixedCommitMessage = fixedCommitMessage(originalCommit, changeFixProgress);
+      String commitMessage =
+          fixedCommitMessage.isPresent()
+              ? fixedCommitMessage.get()
+              : originalCommit.getFullMessage();
+      if (options.verifyCommits) {
+        changeFixProgress.isValidAfterFix &=
+            verifyCommit(commitMessage, fixedAuthorIdent, accountsInChange);
+      }
+      boolean needsFix =
+          !fixedAuthorIdent.equals(originalCommit.getAuthorIdent())
+              || fixedCommitMessage.isPresent();
+
+      if (!rewriteStarted && !needsFix) {
+        changeFixProgress.newTipId = originalCommit;
+        continue;
+      }
+      rewriteStarted = true;
+      changeFixProgress.anyFixesApplied = true;
+      CommitBuilder cb = new CommitBuilder();
+      if (changeFixProgress.newTipId != null) {
+        cb.setParentId(changeFixProgress.newTipId);
+      }
+      cb.setTreeId(originalCommit.getTree());
+      cb.setMessage(commitMessage);
+      cb.setAuthor(fixedAuthorIdent);
+      cb.setCommitter(originalCommit.getCommitterIdent());
+      cb.setEncoding(originalCommit.getEncoding());
+      byte[] newCommitContent = cb.build();
+      checkCommitModification(originalCommit, newCommitContent);
+      changeFixProgress.newTipId = inserter.insert(Constants.OBJ_COMMIT, newCommitContent);
+      // Only compute diff if the content of the commit was actually changed.
+      if (options.outputDiff && needsFix) {
+        String diff = computeDiff(originalCommit.getRawBuffer(), newCommitContent);
+        checkState(
+            !Strings.isNullOrEmpty(diff),
+            "Expected diff for commit %s of ref %s",
+            originalCommit.getId(),
+            ref.getName());
+        changeFixProgress.commitDiffs.add(diff);
+      }
+    }
+    return changeFixProgress;
+  }
+
+  /**
+   * In NoteDb, all the meta information is stored in footer lines. If we accidentally drop some of
+   * the footer lines, the original meta information will be lost, and the change might become
+   * unparsable.
+   *
+   * <p>While we can not verify the entire commit content, we at least make sure that the resulting
+   * commit has the same author, committer and footer lines are in the same order and contain same
+   * footer keys as the original commit.
+   *
+   * <p>Commit message and footer values might have been rewritten.
+   */
+  private void checkCommitModification(RevCommit originalCommit, byte[] newCommitContent)
+      throws IOException {
+    RevCommit newCommit = RevCommit.parse(newCommitContent);
+    PersonIdent newAuthorIdent = newCommit.getAuthorIdent();
+    PersonIdent originalAuthorIdent = originalCommit.getAuthorIdent();
+    // The new commit must have same author and committer ident as the original commit.
+    if (!verifyPersonIdent(newAuthorIdent, originalAuthorIdent)) {
+      throw new IllegalStateException(
+          String.format(
+              "New author %s does not match original author %s",
+              newAuthorIdent.toExternalString(), originalAuthorIdent.toExternalString()));
+    }
+    PersonIdent newCommitterIdent = newCommit.getCommitterIdent();
+    PersonIdent originalCommitterIdent = originalCommit.getCommitterIdent();
+    if (!verifyPersonIdent(newCommitterIdent, originalCommitterIdent)) {
+      throw new IllegalStateException(
+          String.format(
+              "New committer %s does not match original committer %s",
+              newCommitterIdent.toExternalString(), originalCommitterIdent.toExternalString()));
+    }
+
+    List<FooterLine> newFooterLines = newCommit.getFooterLines();
+    List<FooterLine> originalFooterLines = originalCommit.getFooterLines();
+    // Number and order of footer lines must remain the same, the value may have changed.
+    if (newFooterLines.size() != originalFooterLines.size()) {
+      String diff = computeDiff(originalCommit.getRawBuffer(), newCommitContent);
+      throw new IllegalStateException(
+          String.format(
+              "Expected footer lines in new commit to match original footer lines. Diff %s", diff));
+    }
+    for (int i = 0; i < newFooterLines.size(); i++) {
+      FooterLine newFooterLine = newFooterLines.get(i);
+      FooterLine originalFooterLine = originalFooterLines.get(i);
+      if (!newFooterLine.getKey().equals(originalFooterLine.getKey())) {
+        String diff = computeDiff(originalCommit.getRawBuffer(), newCommitContent);
+        throw new IllegalStateException(
+            String.format(
+                "Expected footer lines in new commit to match original footer lines. Diff %s",
+                diff));
+      }
+    }
+  }
+
+  private boolean verifyPersonIdent(PersonIdent newIdent, PersonIdent originalIdent) {
+    return newIdent.getTimeZoneOffset() == originalIdent.getTimeZoneOffset()
+        && newIdent.getWhen().equals(originalIdent.getWhen())
+        && newIdent.getEmailAddress().equals(originalIdent.getEmailAddress());
+  }
+
+  private Optional<String> fixAssigneeChangeMessage(
+      Account.Id oldAssignee, Account.Id newAssignee, String originalChangeMessage) {
+    if (Strings.isNullOrEmpty(originalChangeMessage)) {
+      return Optional.empty();
+    }
+    Pattern assigneeDeletedPattern = Pattern.compile("Assignee deleted: (.*)");
+    Matcher assigneeDeletedMatcher = assigneeDeletedPattern.matcher(originalChangeMessage);
+    if (assigneeDeletedMatcher.matches()) {
+      if (!assigneeDeletedMatcher.group(1).matches(ACCOUNT_TEMPLATE_REGEX)) {
+        return Optional.of(
+            "Assignee deleted: " + ChangeMessagesUtil.getAccountTemplate(oldAssignee));
+      }
+      return Optional.empty();
+    }
+    Pattern assigneeAddedPattern = Pattern.compile("Assignee added: (.*)");
+    Matcher assigneeAddedMatcher = assigneeAddedPattern.matcher(originalChangeMessage);
+    if (assigneeAddedMatcher.matches()) {
+      if (!assigneeAddedMatcher.group(1).matches(ACCOUNT_TEMPLATE_REGEX)) {
+        return Optional.of("Assignee added: " + ChangeMessagesUtil.getAccountTemplate(newAssignee));
+      }
+      return Optional.empty();
+    }
+    Pattern assigneeChangedPattern = Pattern.compile("Assignee changed from: (.*) to: (.*)");
+    Matcher assigneeChangedMatcher = assigneeChangedPattern.matcher(originalChangeMessage);
+    if (assigneeChangedMatcher.matches()) {
+      if (!assigneeChangedMatcher.group(1).matches(ACCOUNT_TEMPLATE_REGEX)) {
+        return Optional.of(
+            String.format(
+                "Assignee changed from: %s to: %s",
+                ChangeMessagesUtil.getAccountTemplate(oldAssignee),
+                ChangeMessagesUtil.getAccountTemplate(newAssignee)));
+      }
+      return Optional.empty();
+    }
+    return Optional.empty();
+  }
+
+  private Optional<String> fixReviewerChangeMessage(String originalChangeMessage) {
+    if (Strings.isNullOrEmpty(originalChangeMessage)) {
+      return Optional.empty();
+    }
+    Pattern removedReviewer = Pattern.compile("Removed (cc|reviewer) (.*) .*");
+    Matcher matcher = removedReviewer.matcher(originalChangeMessage);
+    if (matcher.matches() && !matcher.group(2).matches(ACCOUNT_TEMPLATE_REGEX)) {
+      // Since we do not use change messages for reviewer updates on UI, it does not matter what we
+      // rewrite it to.
+      return Optional.of(originalChangeMessage.substring(0, matcher.end(1)));
+    }
+    return Optional.empty();
+  }
+
+  private Optional<String> fixRemoveVoteChangeMessage(
+      Account.Id reviewer, String originalChangeMessage) {
+    if (Strings.isNullOrEmpty(originalChangeMessage)) {
+      return Optional.empty();
+    }
+    Pattern removedVotePattern = Pattern.compile("Removed (.*) by (.*)");
+    Matcher matcher = removedVotePattern.matcher(originalChangeMessage);
+    if (matcher.matches() && !matcher.group(2).matches(ACCOUNT_TEMPLATE_REGEX)) {
+      return Optional.of(
+          String.format(
+              "Removed %s by %s",
+              matcher.group(1), ChangeMessagesUtil.getAccountTemplate(reviewer)));
+    }
+    return Optional.empty();
+  }
+
+  private Optional<String> fixDeleteChangeMessageCommitMessage(String originalChangeMessage) {
+    if (Strings.isNullOrEmpty(originalChangeMessage)) {
+      return Optional.empty();
+    }
+    Pattern removedChangeMessage =
+        Pattern.compile("Change message removed by: (.*)(\nReason: .*)?");
+    Matcher matcher = removedChangeMessage.matcher(originalChangeMessage);
+    if (matcher.matches() && !matcher.group(1).matches(ACCOUNT_TEMPLATE_REGEX)) {
+      String fixedMessage = "Change message removed";
+      if (matcher.group(2) != null) {
+        fixedMessage += matcher.group(2);
+      }
+      return Optional.of(fixedMessage);
+    }
+    return Optional.empty();
+  }
+
+  private Optional<String> fixSubmitChangeMessage(String originalChangeMessage) {
+    if (Strings.isNullOrEmpty(originalChangeMessage)) {
+      return Optional.empty();
+    }
+    Pattern submittedPattern = Pattern.compile("Change has been successfully (.*) by (.*)");
+    Matcher matcher = submittedPattern.matcher(originalChangeMessage);
+    if (matcher.matches()) {
+      // See https://gerrit-review.googlesource.com/c/gerrit/+/272654
+      return Optional.of(originalChangeMessage.substring(0, matcher.end(1)));
+    }
+    return Optional.empty();
+  }
+
+  /**
+   * Rewrites a code owners change message.
+   *
+   * @param originalMessage the original change message
+   * @return the updated change message
+   */
+  private Optional<String> fixCodeOwnersChangeMessage(String originalMessage) {
+    // TODO(mariasavtchouk): backfill this case
+    return Optional.empty();
+  }
+
+  /**
+   * Fixes commit body case by case, so it does not contain user data. Returns fixed commit message,
+   * or {@link Optional#empty} if no fixes were applied.
+   */
+  private Optional<String> fixedCommitMessage(RevCommit revCommit, ChangeFixProgress fixProgress)
+      throws ConfigInvalidException {
+    byte[] raw = revCommit.getRawBuffer();
+    Charset enc = RawParseUtils.parseEncoding(raw);
+    Optional<CommitMessageRange> commitMessageRange =
+        ChangeNoteUtil.parseCommitMessageRange(revCommit);
+    if (!commitMessageRange.isPresent()) {
+      throw new ConfigInvalidException("Failed to parse commit message " + revCommit.getName());
+    }
+    String changeSubject =
+        RawParseUtils.decode(
+            enc,
+            raw,
+            commitMessageRange.get().subjectStart(),
+            commitMessageRange.get().subjectEnd());
+    Optional<String> fixedChangeMessage = Optional.empty();
+    String originalChangeMessage = null;
+    if (commitMessageRange.isPresent() && commitMessageRange.get().hasChangeMessage()) {
+      originalChangeMessage =
+          RawParseUtils.decode(
+                  enc,
+                  raw,
+                  commitMessageRange.get().changeMessageStart(),
+                  commitMessageRange.get().changeMessageEnd() + 1)
+              .trim();
+    }
+    List<FooterLine> footerLines = revCommit.getFooterLines();
+    StringBuilder footerLinesBuilder = new StringBuilder();
+    boolean anyFootersFixed = false;
+    for (FooterLine fl : footerLines) {
+      String footerKey = fl.getKey();
+      String footerValue = fl.getValue();
+      if (footerKey.equals(FOOTER_TAG.getName())) {
+        if (footerValue.equals(ChangeMessagesUtil.TAG_MERGED)) {
+          fixedChangeMessage = fixSubmitChangeMessage(originalChangeMessage);
+        }
+      } else if (footerKey.equalsIgnoreCase(FOOTER_ASSIGNEE.getName())) {
+        Account.Id oldAssignee = fixProgress.assigneeId;
+        FixIdentResult fixedAssignee = null;
+        if (footerValue.equals("")) {
+          fixProgress.assigneeId = null;
+        } else {
+          fixedAssignee = getFixedIdentString(footerValue);
+          fixProgress.assigneeId = fixedAssignee.accountId;
+        }
+        fixedChangeMessage =
+            fixAssigneeChangeMessage(oldAssignee, fixProgress.assigneeId, originalChangeMessage);
+        if (fixedAssignee != null && fixedAssignee.fixedIdentString.isPresent()) {
+          addFooter(footerLinesBuilder, footerKey, fixedAssignee.fixedIdentString.get());
+          anyFootersFixed = true;
+          continue;
+        }
+      } else if (Arrays.stream(ReviewerStateInternal.values())
+          .filter(state -> footerKey.equalsIgnoreCase(state.getFooterKey().getName()))
+          .findAny()
+          .isPresent()) {
+        fixedChangeMessage = fixReviewerChangeMessage(originalChangeMessage);
+        FixIdentResult fixedReviewer = getFixedIdentString(footerValue);
+        if (fixedReviewer.fixedIdentString.isPresent()) {
+          addFooter(footerLinesBuilder, footerKey, fixedReviewer.fixedIdentString.get());
+          anyFootersFixed = true;
+          continue;
+        }
+      } else if (footerKey.equalsIgnoreCase(FOOTER_REAL_USER.getName())) {
+        FixIdentResult fixedRealUser = getFixedIdentString(footerValue);
+        if (fixedRealUser.fixedIdentString.isPresent()) {
+          addFooter(footerLinesBuilder, footerKey, fixedRealUser.fixedIdentString.get());
+          anyFootersFixed = true;
+          continue;
+        }
+      } else if (footerKey.equalsIgnoreCase(FOOTER_LABEL.getName())) {
+        int voterIdentStart = footerValue.indexOf(' ');
+        FixIdentResult fixedVoter = null;
+        if (voterIdentStart > 0) {
+          String originalIdentString = footerValue.substring(voterIdentStart + 1);
+          fixedVoter = getFixedIdentString(originalIdentString);
+        }
+        fixedChangeMessage =
+            fixRemoveVoteChangeMessage(
+                fixedVoter == null ? fixProgress.updateAuthorId : fixedVoter.accountId,
+                originalChangeMessage);
+        if (fixedVoter != null && fixedVoter.fixedIdentString.isPresent()) {
+          String fixedLabelVote =
+              footerValue.substring(0, voterIdentStart) + " " + fixedVoter.fixedIdentString.get();
+          addFooter(footerLinesBuilder, footerKey, fixedLabelVote);
+          anyFootersFixed = true;
+          continue;
+        }
+      } else if (footerKey.equalsIgnoreCase(FOOTER_SUBMITTED_WITH.getName())) {
+        // TODO(mariasavtchouk): backfill this case
+
+      } else if (footerKey.equalsIgnoreCase(FOOTER_ATTENTION.getName())) {
+        // TODO(mariasavtchouk): backfill this case
+      }
+      addFooter(footerLinesBuilder, footerKey, footerValue);
+    }
+
+    if (!fixedChangeMessage.isPresent()) {
+      fixedChangeMessage = fixDeleteChangeMessageCommitMessage(originalChangeMessage);
+    }
+    if (!fixedChangeMessage.isPresent()) {
+      fixedChangeMessage = fixCodeOwnersChangeMessage(originalChangeMessage);
+    }
+    if (!anyFootersFixed && !fixedChangeMessage.isPresent()) {
+      return Optional.empty();
+    }
+    StringBuilder fixedCommitBuilder = new StringBuilder();
+    fixedCommitBuilder.append(changeSubject);
+    fixedCommitBuilder.append("\n\n");
+    if (commitMessageRange.get().hasChangeMessage()) {
+      fixedCommitBuilder.append(
+          fixedChangeMessage.isPresent() ? fixedChangeMessage.get() : originalChangeMessage);
+      fixedCommitBuilder.append("\n\n");
+    }
+    fixedCommitBuilder.append(footerLinesBuilder);
+    return Optional.of(fixedCommitBuilder.toString());
+  }
+
+  private static StringBuilder addFooter(StringBuilder sb, String footer, String value) {
+    sb.append(footer).append(":");
+    if (!Strings.isNullOrEmpty(value)) {
+      sb.append(" ").append(value);
+    }
+    sb.append('\n');
+    return sb;
+  }
+
+  private Account.Id parseIdent(PersonIdent ident) throws ConfigInvalidException {
+    return NoteDbUtil.parseIdent(ident)
+        .orElseThrow(
+            () -> new ConfigInvalidException("field to parse id: " + ident.getEmailAddress()));
+  }
+
+  /**
+   * Fixes {@code originalIdent} so it does not contain user data, see {@link
+   * ChangeNoteUtil#getAccountIdAsUsername}.
+   */
+  private PersonIdent getFixedIdent(PersonIdent originalIdent, Account.Id identAccount) {
+    return new PersonIdent(
+        ChangeNoteUtil.getAccountIdAsUsername(identAccount),
+        originalIdent.getEmailAddress(),
+        originalIdent.getWhen(),
+        originalIdent.getTimeZone());
+  }
+
+  /**
+   * Parses {@code originalIdentString} and applies the fix, so it does not contain user data, see
+   * {@link ChangeNoteUtil#appendAccountIdIdentString}.
+   *
+   * @param originalIdentString ident to apply the fix to.
+   * @return {@link FixIdentResult}, with {@link FixIdentResult#accountId} parsed from {@code
+   *     originalIdentString} and {@link FixIdentResult#fixedIdentString} if the fix was applied.
+   * @throws ConfigInvalidException if could not parse {@link FixIdentResult#accountId} from {@code
+   *     originalIdentString}
+   */
+  private FixIdentResult getFixedIdentString(String originalIdentString)
+      throws ConfigInvalidException {
+    FixIdentResult fixIdentResult = new FixIdentResult();
+    PersonIdent originalIdent = RawParseUtils.parsePersonIdent(originalIdentString);
+    fixIdentResult.accountId = parseIdent(originalIdent);
+    String fixedIdentString =
+        ChangeNoteUtil.formatAccountIdentString(
+            fixIdentResult.accountId, originalIdent.getEmailAddress());
+    fixIdentResult.fixedIdentString =
+        fixedIdentString.equals(originalIdentString)
+            ? Optional.empty()
+            : Optional.of(fixedIdentString);
+    return fixIdentResult;
+  }
+
+  /**
+   * Cuts tree and parent lines from raw unparsed commit body, so they are not included in diff
+   * comparison.
+   *
+   * @param b raw unparsed commit body, see {@link RevCommit#getRawBuffer()}.
+   *     <p>For parsing, see {@link RawParseUtils#author}, {@link RawParseUtils#commitMessage}, etc.
+   * @return raw unparsed commit body, without tree and parent lines.
+   */
+  public static byte[] cutTreeAndParents(byte[] b) {
+    final int sz = b.length;
+    int ptr = 46; // skip the "tree ..." line.
+    while (ptr < sz && b[ptr] == 'p') ptr += 48; // skip this parent.
+    return Arrays.copyOfRange(b, ptr, b.length + 1);
+  }
+
+  private String computeDiff(byte[] oldCommit, byte[] newCommit) throws IOException {
+    RawText oldBody = new RawText(cutTreeAndParents(oldCommit));
+    RawText newBody = new RawText(cutTreeAndParents(newCommit));
+    ByteArrayOutputStream out = new ByteArrayOutputStream();
+    EditList diff = diffAlgorithm.diff(RawTextComparator.DEFAULT, oldBody, newBody);
+    try (DiffFormatter fmt = new DiffFormatter(out)) {
+      // Do not show any unchanged lines, since it is not interesting
+      fmt.setContext(0);
+      fmt.format(diff, oldBody, newBody);
+      fmt.flush();
+      return out.toString();
+    }
+  }
+
+  private static ObjectInserter newPackInserter(Repository repo) {
+    if (!(repo instanceof FileRepository)) {
+      return repo.newObjectInserter();
+    }
+    PackInserter ins = ((FileRepository) repo).getObjectDatabase().newPackInserter();
+    ins.checkExisting(false);
+    return ins;
+  }
+
+  /**
+   * Parsed and fixed {@link PersonIdent} string, formatted as {@link
+   * ChangeNoteUtil#appendAccountIdIdentString}
+   */
+  private static class FixIdentResult {
+
+    /** {@link com.google.gerrit.entities.Account.Id} parsed from PersonIdent string. */
+    Account.Id accountId;
+    /**
+     * Fixed ident string, that does not contain user data, or {@link Optional#empty} if fix was not
+     * required.
+     */
+    Optional<String> fixedIdentString;
+  }
+
+  /**
+   * Holds the state of change rewrite progress. Rewrite goes from the oldest commit to the most
+   * recent update.
+   */
+  private static class ChangeFixProgress {
+    /** Assignee at current commit update. */
+    Account.Id assigneeId = null;
+
+    /** Author of the current commit update. */
+    Account.Id updateAuthorId = null;
+
+    /** Id of the current commit in rewriter walk. */
+    ObjectId newTipId = null;
+    /** If any commits were rewritten by the rewriter. */
+    boolean anyFixesApplied = false;
+
+    /**
+     * Whether all commits seen by the rewriter with the fixes applied passed the verification, see
+     * {@link #verifyCommit}.
+     */
+    boolean isValidAfterFix = true;
+
+    List<String> commitDiffs = new ArrayList<>();
+  }
+}
diff --git a/java/com/google/gerrit/server/notedb/DeleteChangeMessageRewriter.java b/java/com/google/gerrit/server/notedb/DeleteChangeMessageRewriter.java
index b555fdb..e07c793 100644
--- a/java/com/google/gerrit/server/notedb/DeleteChangeMessageRewriter.java
+++ b/java/com/google/gerrit/server/notedb/DeleteChangeMessageRewriter.java
@@ -88,7 +88,8 @@
     byte[] raw = commit.getRawBuffer();
 
     Optional<ChangeNoteUtil.CommitMessageRange> range = parseCommitMessageRange(commit);
-    checkState(range.isPresent(), "failed to parse commit message");
+    checkState(
+        range.isPresent() && range.get().hasChangeMessage(), "failed to parse commit message");
 
     // Only replace the commit message body, which is the user-provided message. The subject and
     // footers are NoteDb metadata.
diff --git a/java/com/google/gerrit/server/notedb/OpenRepo.java b/java/com/google/gerrit/server/notedb/OpenRepo.java
index 351f31d..d02ec87 100644
--- a/java/com/google/gerrit/server/notedb/OpenRepo.java
+++ b/java/com/google/gerrit/server/notedb/OpenRepo.java
@@ -178,9 +178,9 @@
             && !update.bypassMaxUpdates()) {
           throw new LimitExceededException(
               String.format(
-                  "Change %s may not exceed %d updates. It may still be abandoned or submitted. To"
-                      + " continue working on this change, recreate it with a new Change-Id, then"
-                      + " abandon this one.",
+                  "Change %s may not exceed %d updates. It may still be abandoned, submitted and you can add/remove"
+                      + " reviewers to/from the attention-set. To continue working on this change, recreate it with a new"
+                      + " Change-Id, then abandon this one.",
                   update.getId(), maxUpdates.get()));
         }
         curr = next;
diff --git a/java/com/google/gerrit/server/notedb/RevisionNoteBuilder.java b/java/com/google/gerrit/server/notedb/RevisionNoteBuilder.java
index 3c1d359..7998476 100644
--- a/java/com/google/gerrit/server/notedb/RevisionNoteBuilder.java
+++ b/java/com/google/gerrit/server/notedb/RevisionNoteBuilder.java
@@ -22,16 +22,20 @@
 import com.google.common.collect.Maps;
 import com.google.common.collect.MultimapBuilder;
 import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.SubmitRequirementResult;
 import java.io.ByteArrayOutputStream;
 import java.io.IOException;
 import java.io.OutputStream;
 import java.io.OutputStreamWriter;
+import java.util.ArrayList;
 import java.util.Collections;
+import java.util.Comparator;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
+import java.util.stream.Collectors;
 import org.eclipse.jgit.lib.AnyObjectId;
 import org.eclipse.jgit.lib.ObjectId;
 
@@ -60,11 +64,16 @@
     }
   }
 
+  /** Submit requirements are sorted w.r.t. their names before storing in NoteDb. */
+  private final Comparator<SubmitRequirementResult> SUBMIT_REQUIREMENT_RESULT_COMPARATOR =
+      Comparator.comparing(sr -> sr.submitRequirement().name());
+
   final byte[] baseRaw;
   private final List<? extends Comment> baseComments;
   final Map<Comment.Key, Comment> put;
   private final Set<Comment.Key> delete;
 
+  private List<SubmitRequirementResult> submitRequirementResults;
   private String pushCert;
 
   private RevisionNoteBuilder(RevisionNote<? extends Comment> base) {
@@ -81,6 +90,7 @@
       put = new HashMap<>();
       pushCert = null;
     }
+    submitRequirementResults = new ArrayList<>();
     delete = new HashSet<>();
   }
 
@@ -99,6 +109,10 @@
     put.put(comment.key, comment);
   }
 
+  void putSubmitRequirementResult(SubmitRequirementResult result) {
+    submitRequirementResults.add(result);
+  }
+
   void deleteComment(Comment.Key key) {
     checkArgument(!put.containsKey(key), "cannot both delete and put %s", key);
     delete.add(key);
@@ -126,13 +140,19 @@
 
   private void buildNoteJson(ChangeNoteJson noteUtil, OutputStream out) throws IOException {
     ListMultimap<Integer, Comment> comments = buildCommentMap();
-    if (comments.isEmpty() && pushCert == null) {
+    if (submitRequirementResults.isEmpty() && comments.isEmpty() && pushCert == null) {
       return;
     }
 
     RevisionNoteData data = new RevisionNoteData();
     data.comments = COMMENT_ORDER.sortedCopy(comments.values());
     data.pushCert = pushCert;
+    if (!submitRequirementResults.isEmpty()) {
+      data.submitRequirementResults =
+          submitRequirementResults.stream()
+              .sorted(SUBMIT_REQUIREMENT_RESULT_COMPARATOR)
+              .collect(Collectors.toList());
+    }
 
     try (OutputStreamWriter osw = new OutputStreamWriter(out, UTF_8)) {
       noteUtil.getGson().toJson(data, osw);
diff --git a/java/com/google/gerrit/server/notedb/RevisionNoteData.java b/java/com/google/gerrit/server/notedb/RevisionNoteData.java
index da15b34..c8770f1 100644
--- a/java/com/google/gerrit/server/notedb/RevisionNoteData.java
+++ b/java/com/google/gerrit/server/notedb/RevisionNoteData.java
@@ -15,15 +15,17 @@
 package com.google.gerrit.server.notedb;
 
 import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.SubmitRequirementResult;
 import java.util.List;
 
 /**
  * Holds the raw data of a RevisionNote.
  *
  * <p>It is intended for serialization to JSON only. It is used for human comments and robot
- * comments.
+ * comments, as well as for storing submit requirements.
  */
 class RevisionNoteData {
   String pushCert;
   List<Comment> comments;
+  List<SubmitRequirementResult> submitRequirementResults;
 }
diff --git a/java/com/google/gerrit/server/notedb/StoreSubmitRequirementsOp.java b/java/com/google/gerrit/server/notedb/StoreSubmitRequirementsOp.java
new file mode 100644
index 0000000..47948d7
--- /dev/null
+++ b/java/com/google/gerrit/server/notedb/StoreSubmitRequirementsOp.java
@@ -0,0 +1,38 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.notedb;
+
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.ChangeContext;
+
+/** A {@link BatchUpdateOp} that stores the evaluated submit requirements of a change in NoteDb. */
+public class StoreSubmitRequirementsOp implements BatchUpdateOp {
+  private final ChangeData.Factory changeDataFactory;
+
+  public StoreSubmitRequirementsOp(ChangeData.Factory changeDataFactory) {
+    this.changeDataFactory = changeDataFactory;
+  }
+
+  @Override
+  public boolean updateChange(ChangeContext ctx) throws Exception {
+    Change change = ctx.getChange();
+    ChangeData changeData = changeDataFactory.create(change);
+    ChangeUpdate update = ctx.getUpdate(change.currentPatchSetId());
+    update.putSubmitRequirementResults(changeData.submitRequirements().values());
+    return !changeData.submitRequirements().isEmpty();
+  }
+}
diff --git a/java/com/google/gerrit/server/patch/AutoMerger.java b/java/com/google/gerrit/server/patch/AutoMerger.java
index b99e2d2..97910400 100644
--- a/java/com/google/gerrit/server/patch/AutoMerger.java
+++ b/java/com/google/gerrit/server/patch/AutoMerger.java
@@ -31,6 +31,8 @@
 import com.google.gerrit.server.logging.Metadata;
 import com.google.gerrit.server.update.RepoView;
 import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
 import java.io.IOException;
 import java.util.Optional;
 import org.eclipse.jgit.dircache.DirCache;
@@ -70,6 +72,7 @@
  * <p>The second point means that these commits are referenced from NoteDb. The consequence of this
  * is that these refs should never be deleted.
  */
+@Singleton
 public class AutoMerger {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
@@ -88,7 +91,7 @@
 
   private final Counter1<OperationType> counter;
   private final Timer1<OperationType> latency;
-  private final PersonIdent gerritIdent;
+  private final Provider<PersonIdent> gerritIdentProvider;
   private final boolean save;
   private final ThreeWayMergeStrategy configuredMergeStrategy;
 
@@ -96,7 +99,7 @@
   AutoMerger(
       MetricMaker metricMaker,
       @GerritServerConfig Config cfg,
-      @GerritPersonIdent PersonIdent gerritIdent) {
+      @GerritPersonIdent Provider<PersonIdent> gerritIdentProvider) {
     this.counter =
         metricMaker.newCounter(
             "git/auto-merge/num_operations",
@@ -110,7 +113,7 @@
                 .setUnit("milliseconds"),
             Field.ofEnum(OperationType.class, "type", Metadata.Builder::operationName).build());
     this.save = cacheAutomerge(cfg);
-    this.gerritIdent = gerritIdent;
+    this.gerritIdentProvider = gerritIdentProvider;
     this.configuredMergeStrategy = MergeUtil.getMergeStrategy(cfg);
   }
 
@@ -139,7 +142,7 @@
     }
     counter.increment(OperationType.IN_MEMORY_WRITE);
     logger.atInfo().log("Computing in-memory AutoMerge for " + merge.name());
-    try (Timer1.Context ignored = latency.start(OperationType.IN_MEMORY_WRITE)) {
+    try (Timer1.Context<OperationType> ignored = latency.start(OperationType.IN_MEMORY_WRITE)) {
       return rw.parseCommit(createAutoMergeCommit(repo.getConfig(), rw, ins, merge, mergeStrategy));
     }
   }
@@ -168,7 +171,7 @@
     }
 
     ObjectId autoMerge;
-    try (Timer1.Context ignored = latency.start(OperationType.ON_DISK_WRITE)) {
+    try (Timer1.Context<OperationType> ignored = latency.start(OperationType.ON_DISK_WRITE)) {
       autoMerge =
           createAutoMergeCommit(
               repoView.getConfig(), rw, ins, maybeMergeCommit, configuredMergeStrategy);
@@ -224,7 +227,9 @@
     // the input commit, using the server name and timezone.
     PersonIdent ident =
         new PersonIdent(
-            gerritIdent, merge.getCommitterIdent().getWhen(), gerritIdent.getTimeZone());
+            gerritIdentProvider.get(),
+            merge.getCommitterIdent().getWhen(),
+            gerritIdentProvider.get().getTimeZone());
     CommitBuilder cb = new CommitBuilder();
     cb.setAuthor(ident);
     cb.setCommitter(ident);
diff --git a/java/com/google/gerrit/server/patch/BaseCommitUtil.java b/java/com/google/gerrit/server/patch/BaseCommitUtil.java
index 7c06a62..dd930378 100644
--- a/java/com/google/gerrit/server/patch/BaseCommitUtil.java
+++ b/java/com/google/gerrit/server/patch/BaseCommitUtil.java
@@ -21,6 +21,7 @@
 import com.google.gerrit.server.git.InMemoryInserter;
 import com.google.gerrit.server.git.MergeUtil;
 import com.google.inject.Inject;
+import com.google.inject.Singleton;
 import java.io.IOException;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.Constants;
@@ -34,6 +35,7 @@
 import org.eclipse.jgit.revwalk.RevWalk;
 
 /** A utility class for computing the base commit / parent for a specific patchset commit. */
+@Singleton
 class BaseCommitUtil {
   private final AutoMerger autoMerger;
   private final ThreeWayMergeStrategy mergeStrategy;
diff --git a/java/com/google/gerrit/server/patch/ComparisonType.java b/java/com/google/gerrit/server/patch/ComparisonType.java
index eca2658..e450779 100644
--- a/java/com/google/gerrit/server/patch/ComparisonType.java
+++ b/java/com/google/gerrit/server/patch/ComparisonType.java
@@ -30,7 +30,7 @@
 
   /**
    * 1-based parent. Available if the old commit is the parent of the new commit and old commit is
-   * not the auto-merge.
+   * not the auto-merge. If set to 0, then comparison is for a root commit.
    */
   abstract Optional<Integer> parentNum();
 
@@ -48,6 +48,10 @@
     return new AutoValue_ComparisonType(Optional.empty(), true);
   }
 
+  public static ComparisonType againstRoot() {
+    return new AutoValue_ComparisonType(Optional.of(0), false);
+  }
+
   private static ComparisonType create(Optional<Integer> parent, boolean automerge) {
     return new AutoValue_ComparisonType(parent, automerge);
   }
diff --git a/java/com/google/gerrit/server/patch/DiffOperations.java b/java/com/google/gerrit/server/patch/DiffOperations.java
index 7213581..d2da736 100644
--- a/java/com/google/gerrit/server/patch/DiffOperations.java
+++ b/java/com/google/gerrit/server/patch/DiffOperations.java
@@ -46,8 +46,8 @@
    *
    * @param project a project name representing a git repository.
    * @param newCommit 20 bytes SHA-1 of the new commit used in the diff.
-   * @param parentNum integer specifying which parent to use as base. If null, the only parent will
-   *     be used or the auto-merge if {@code newCommit} is a merge commit.
+   * @param parentNum 1-based integer specifying which parent to use as base. If zero, the only
+   *     parent will be used or the auto-merge if {@code newCommit} is a merge commit.
    * @return map of file paths to the file diffs. The map key is the new file path for all {@link
    *     ChangeType} file diffs except {@link ChangeType#DELETED} entries where the map key contains
    *     the old file path. The map entries are not sorted by key.
@@ -56,8 +56,7 @@
    *     an internal error occurred in Git while evaluating the diff.
    */
   Map<String, FileDiffOutput> listModifiedFilesAgainstParent(
-      Project.NameKey project, ObjectId newCommit, @Nullable Integer parentNum)
-      throws DiffNotAvailableException;
+      Project.NameKey project, ObjectId newCommit, int parentNum) throws DiffNotAvailableException;
 
   /**
    * Returns the list of added, deleted or modified files between two commits (patchsets). The
@@ -85,8 +84,8 @@
    *
    * @param project a project name representing a git repository.
    * @param newCommit 20 bytes SHA-1 of the new commit used in the diff.
-   * @param parentNum integer specifying which parent to use as base. If null, the only parent will
-   *     be used or the auto-merge if {@code newCommit} is a merge commit.
+   * @param parentNum 1-based integer specifying which parent to use as base. If zero, the only
+   *     parent will be used or the auto-merge if {@code newCommit} is a merge commit.
    * @param fileName the file name for which the diff should be evaluated.
    * @param whitespace preference controlling whitespace effect in diff computation.
    * @return the diff for the single file between the two commits.
@@ -96,7 +95,7 @@
   FileDiffOutput getModifiedFileAgainstParent(
       Project.NameKey project,
       ObjectId newCommit,
-      @Nullable Integer parentNum,
+      int parentNum,
       String fileName,
       @Nullable DiffPreferencesInfo.Whitespace whitespace)
       throws DiffNotAvailableException;
diff --git a/java/com/google/gerrit/server/patch/DiffOperationsImpl.java b/java/com/google/gerrit/server/patch/DiffOperationsImpl.java
index 6217239..3423b32 100644
--- a/java/com/google/gerrit/server/patch/DiffOperationsImpl.java
+++ b/java/com/google/gerrit/server/patch/DiffOperationsImpl.java
@@ -18,6 +18,7 @@
 import static com.google.gerrit.entities.Patch.MERGE_LIST;
 
 import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableCollection;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.flogger.FluentLogger;
@@ -28,8 +29,6 @@
 import com.google.gerrit.extensions.client.DiffPreferencesInfo;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
 import com.google.gerrit.server.cache.CacheModule;
-import com.google.gerrit.server.config.ConfigUtil;
-import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.patch.diff.ModifiedFilesCache;
 import com.google.gerrit.server.patch.diff.ModifiedFilesCacheImpl;
 import com.google.gerrit.server.patch.diff.ModifiedFilesCacheKey;
@@ -43,34 +42,29 @@
 import com.google.gerrit.server.patch.gitfilediff.GitFileDiffCacheImpl.DiffAlgorithm;
 import com.google.inject.Inject;
 import com.google.inject.Module;
+import com.google.inject.Singleton;
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Map;
-import java.util.concurrent.ExecutionException;
-import java.util.concurrent.ExecutorService;
-import java.util.concurrent.Future;
-import java.util.concurrent.TimeUnit;
-import java.util.concurrent.TimeoutException;
-import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
 
 /**
  * Provides different file diff operations. Uses the underlying Git/Gerrit caches to speed up the
  * diff computation.
  */
+@Singleton
 public class DiffOperationsImpl implements DiffOperations {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   private static final int RENAME_SCORE = 60;
-  private static final DiffAlgorithm DEFAULT_DIFF_ALGORITHM = DiffAlgorithm.HISTOGRAM;
+  private static final DiffAlgorithm DEFAULT_DIFF_ALGORITHM =
+      DiffAlgorithm.HISTOGRAM_WITH_FALLBACK_MYERS;
   private static final Whitespace DEFAULT_WHITESPACE = Whitespace.IGNORE_NONE;
 
   private final ModifiedFilesCache modifiedFilesCache;
   private final FileDiffCache fileDiffCache;
   private final BaseCommitUtil baseCommitUtil;
-  private final long timeoutMillis;
-  private final ExecutorService diffExecutor;
 
   public static Module module() {
     return new CacheModule() {
@@ -89,30 +83,18 @@
   public DiffOperationsImpl(
       ModifiedFilesCache modifiedFilesCache,
       FileDiffCache fileDiffCache,
-      BaseCommitUtil baseCommit,
-      @DiffExecutor ExecutorService executor,
-      @GerritServerConfig Config cfg) {
+      BaseCommitUtil baseCommit) {
     this.modifiedFilesCache = modifiedFilesCache;
     this.fileDiffCache = fileDiffCache;
     this.baseCommitUtil = baseCommit;
-    this.diffExecutor = executor;
-    this.timeoutMillis =
-        ConfigUtil.getTimeUnit(
-            cfg,
-            "cache",
-            "diff",
-            "timeout",
-            TimeUnit.MILLISECONDS.convert(5, TimeUnit.SECONDS),
-            TimeUnit.MILLISECONDS);
   }
 
   @Override
   public Map<String, FileDiffOutput> listModifiedFilesAgainstParent(
-      Project.NameKey project, ObjectId newCommit, @Nullable Integer parent)
-      throws DiffNotAvailableException {
+      Project.NameKey project, ObjectId newCommit, int parent) throws DiffNotAvailableException {
     try {
       DiffParameters diffParams = computeDiffParameters(project, newCommit, parent);
-      return listModifiedFilesWithTimeout(diffParams);
+      return getModifiedFiles(diffParams);
     } catch (IOException e) {
       throw new DiffNotAvailableException(
           "Failed to evaluate the parent/base commit for commit " + newCommit, e);
@@ -130,22 +112,29 @@
             .baseCommit(oldCommit)
             .comparisonType(ComparisonType.againstOtherPatchSet())
             .build();
-    return listModifiedFilesWithTimeout(params);
+    return getModifiedFiles(params);
   }
 
   @Override
   public FileDiffOutput getModifiedFileAgainstParent(
       Project.NameKey project,
       ObjectId newCommit,
-      @Nullable Integer parent,
+      int parent,
       String fileName,
       @Nullable DiffPreferencesInfo.Whitespace whitespace)
       throws DiffNotAvailableException {
     try {
       DiffParameters diffParams = computeDiffParameters(project, newCommit, parent);
       FileDiffCacheKey key =
-          createFileDiffCacheKey(project, diffParams.baseCommit(), newCommit, fileName, whitespace);
-      return getModifiedFileWithTimeout(key, diffParams);
+          createFileDiffCacheKey(
+              project,
+              diffParams.baseCommit(),
+              newCommit,
+              fileName,
+              DEFAULT_DIFF_ALGORITHM,
+              /* useTimeout= */ true,
+              whitespace);
+      return getModifiedFileForKey(key);
     } catch (IOException e) {
       throw new DiffNotAvailableException(
           "Failed to evaluate the parent/base commit for commit " + newCommit, e);
@@ -160,60 +149,16 @@
       String fileName,
       @Nullable DiffPreferencesInfo.Whitespace whitespace)
       throws DiffNotAvailableException {
-    DiffParameters params = // used for logging only
-        DiffParameters.builder()
-            .project(project)
-            .baseCommit(oldCommit)
-            .newCommit(newCommit)
-            .comparisonType(ComparisonType.againstOtherPatchSet())
-            .build();
     FileDiffCacheKey key =
-        createFileDiffCacheKey(project, oldCommit, newCommit, fileName, whitespace);
-    return getModifiedFileWithTimeout(key, params);
-  }
-
-  private Map<String, FileDiffOutput> listModifiedFilesWithTimeout(DiffParameters params)
-      throws DiffNotAvailableException {
-    Future<DiffResult> task =
-        diffExecutor.submit(
-            () -> {
-              ImmutableMap<String, FileDiffOutput> modifiedFiles = getModifiedFiles(params);
-              return DiffResult.create(null, modifiedFiles);
-            });
-    DiffResult diffResult = execDiffWithTimeout(task, params);
-    return diffResult.modifiedFiles();
-  }
-
-  private FileDiffOutput getModifiedFileWithTimeout(FileDiffCacheKey key, DiffParameters params)
-      throws DiffNotAvailableException {
-    Future<DiffResult> task =
-        diffExecutor.submit(
-            () -> {
-              Map<String, FileDiffOutput> diffList = getModifiedFilesForKeys(ImmutableList.of(key));
-              FileDiffOutput fileDiffOutput =
-                  diffList.containsKey(key.newFilePath())
-                      ? diffList.get(key.newFilePath())
-                      : FileDiffOutput.empty(key.newFilePath(), key.oldCommit(), key.newCommit());
-              return DiffResult.create(fileDiffOutput, null);
-            });
-    DiffResult result = execDiffWithTimeout(task, params);
-    return result.fileDiff();
-  }
-
-  /** Executes a diff task by employing a timeout. */
-  private DiffResult execDiffWithTimeout(Future<DiffResult> task, DiffParameters params)
-      throws DiffNotAvailableException {
-    try {
-      return task.get(timeoutMillis, TimeUnit.MILLISECONDS);
-    } catch (InterruptedException | TimeoutException e) {
-      throw new DiffNotAvailableException(
-          String.format(
-              "Timeout reached while computing diff for project %s, old commit %s, new commit %s",
-              params.project(), params.baseCommit().name(), params.newCommit().name()),
-          e);
-    } catch (ExecutionException e) {
-      throw new DiffNotAvailableException(e);
-    }
+        createFileDiffCacheKey(
+            project,
+            oldCommit,
+            newCommit,
+            fileName,
+            DEFAULT_DIFF_ALGORITHM,
+            /* useTimeout= */ true,
+            whitespace);
+    return getModifiedFileForKey(key);
   }
 
   private ImmutableMap<String, FileDiffOutput> getModifiedFiles(DiffParameters diffParams)
@@ -230,12 +175,24 @@
       List<FileDiffCacheKey> fileCacheKeys = new ArrayList<>();
       fileCacheKeys.add(
           createFileDiffCacheKey(
-              project, oldCommit, newCommit, COMMIT_MSG, /* whitespace= */ null));
+              project,
+              oldCommit,
+              newCommit,
+              COMMIT_MSG,
+              DEFAULT_DIFF_ALGORITHM,
+              /* useTimeout= */ true,
+              /* whitespace= */ null));
 
       if (cmp.isAgainstAutoMerge() || isMergeAgainstParent(cmp, project, newCommit)) {
         fileCacheKeys.add(
             createFileDiffCacheKey(
-                project, oldCommit, newCommit, MERGE_LIST, /*whitespace = */ null));
+                project,
+                oldCommit,
+                newCommit,
+                MERGE_LIST,
+                DEFAULT_DIFF_ALGORITHM,
+                /* useTimeout= */ true,
+                /*whitespace = */ null));
       }
 
       if (diffParams.skipFiles() == null) {
@@ -249,6 +206,8 @@
                         entity.newPath().isPresent()
                             ? entity.newPath().get()
                             : entity.oldPath().get(),
+                        DEFAULT_DIFF_ALGORITHM,
+                        /* useTimeout= */ true,
                         /* whitespace= */ null))
             .forEach(fileCacheKeys::add);
       }
@@ -258,22 +217,71 @@
     }
   }
 
+  private FileDiffOutput getModifiedFileForKey(FileDiffCacheKey key)
+      throws DiffNotAvailableException {
+    Map<String, FileDiffOutput> diffList = getModifiedFilesForKeys(ImmutableList.of(key));
+    return diffList.containsKey(key.newFilePath())
+        ? diffList.get(key.newFilePath())
+        : FileDiffOutput.empty(key.newFilePath(), key.oldCommit(), key.newCommit());
+  }
+
+  /**
+   * Lookup the file diffs for the input {@code keys}. For results where the cache reports negative
+   * results, e.g. due to timeouts in the cache loader, this method requests the diff again using
+   * the fallback algorithm {@link DiffAlgorithm#HISTOGRAM_NO_FALLBACK}.
+   */
   private ImmutableMap<String, FileDiffOutput> getModifiedFilesForKeys(List<FileDiffCacheKey> keys)
       throws DiffNotAvailableException {
-    ImmutableMap.Builder<String, FileDiffOutput> files = ImmutableMap.builder();
     ImmutableMap<FileDiffCacheKey, FileDiffOutput> fileDiffs = fileDiffCache.getAll(keys);
+    List<FileDiffCacheKey> fallbackKeys = new ArrayList<>();
 
-    for (FileDiffOutput fileDiffOutput : fileDiffs.values()) {
+    ImmutableList.Builder<FileDiffOutput> result = ImmutableList.builder();
+
+    // Use the fallback diff algorithm for negative results
+    for (FileDiffCacheKey key : fileDiffs.keySet()) {
+      FileDiffOutput diff = fileDiffs.get(key);
+      if (diff.isNegative()) {
+        FileDiffCacheKey fallbackKey =
+            createFileDiffCacheKey(
+                key.project(),
+                key.oldCommit(),
+                key.newCommit(),
+                key.newFilePath(),
+                // Use the fallback diff algorithm
+                DiffAlgorithm.HISTOGRAM_NO_FALLBACK,
+                // We don't enforce timeouts with the fallback algorithm. Timeouts were introduced
+                // because of a bug in JGit that happens only when the histogram algorithm uses
+                // Myers as fallback. See https://bugs.chromium.org/p/gerrit/issues/detail?id=487
+                /* useTimeout= */ false,
+                key.whitespace());
+        fallbackKeys.add(fallbackKey);
+      } else {
+        result.add(diff);
+      }
+    }
+    result.addAll(fileDiffCache.getAll(fallbackKeys).values());
+    return mapByFilePath(result.build());
+  }
+
+  /**
+   * Map a collection of {@link FileDiffOutput} based on their file paths. The result map keys
+   * represent the old file path for deleted files, or the new path otherwise.
+   */
+  private ImmutableMap<String, FileDiffOutput> mapByFilePath(
+      ImmutableCollection<FileDiffOutput> fileDiffOutputs) {
+    ImmutableMap.Builder<String, FileDiffOutput> diffs = ImmutableMap.builder();
+
+    for (FileDiffOutput fileDiffOutput : fileDiffOutputs) {
       if (fileDiffOutput.isEmpty() || allDueToRebase(fileDiffOutput)) {
         continue;
       }
       if (fileDiffOutput.changeType() == ChangeType.DELETED) {
-        files.put(fileDiffOutput.oldPath().get(), fileDiffOutput);
+        diffs.put(fileDiffOutput.oldPath().get(), fileDiffOutput);
       } else {
-        files.put(fileDiffOutput.newPath().get(), fileDiffOutput);
+        diffs.put(fileDiffOutput.newPath().get(), fileDiffOutput);
       }
     }
-    return files.build();
+    return diffs.build();
   }
 
   private static boolean allDueToRebase(FileDiffOutput fileDiffOutput) {
@@ -302,6 +310,8 @@
       ObjectId aCommit,
       ObjectId bCommit,
       String newPath,
+      DiffAlgorithm diffAlgorithm,
+      boolean useTimeout,
       @Nullable Whitespace whitespace) {
     whitespace = whitespace == null ? DEFAULT_WHITESPACE : whitespace;
     return FileDiffCacheKey.builder()
@@ -310,37 +320,22 @@
         .newCommit(bCommit)
         .newFilePath(newPath)
         .renameScore(RENAME_SCORE)
-        .diffAlgorithm(DEFAULT_DIFF_ALGORITHM)
+        .diffAlgorithm(diffAlgorithm)
         .whitespace(whitespace)
+        .useTimeout(useTimeout)
         .build();
   }
 
-  /**
-   * All interface methods create their results using this class. This is used so that the timeout
-   * method {@link #execDiffWithTimeout(Future, DiffParameters)} could be reused by all interface
-   * methods.
-   */
-  @AutoValue
-  abstract static class DiffResult {
-    static DiffResult create(
-        @Nullable FileDiffOutput fileDiff,
-        @Nullable ImmutableMap<String, FileDiffOutput> modifiedFiles) {
-      return new AutoValue_DiffOperationsImpl_DiffResult(fileDiff, modifiedFiles);
-    }
-
-    @Nullable
-    abstract FileDiffOutput fileDiff();
-
-    @Nullable
-    abstract ImmutableMap<String, FileDiffOutput> modifiedFiles();
-  }
-
   @AutoValue
   abstract static class DiffParameters {
     abstract Project.NameKey project();
 
     abstract ObjectId newCommit();
 
+    /**
+     * Base commit represents the old commit of the diff. For diffs against the root commit, this
+     * should be set to {@link ObjectId#zeroId()}.
+     */
     abstract ObjectId baseCommit();
 
     abstract ComparisonType comparisonType();
@@ -380,12 +375,17 @@
       Project.NameKey project, ObjectId newCommit, Integer parent) throws IOException {
     DiffParameters.Builder result =
         DiffParameters.builder().project(project).newCommit(newCommit).parent(parent);
-    if (parent != null) {
+    if (parent > 0) {
       result.baseCommit(baseCommitUtil.getBaseCommit(project, newCommit, parent));
       result.comparisonType(ComparisonType.againstParent(parent));
       return result.build();
     }
     int numParents = baseCommitUtil.getNumParents(project, newCommit);
+    if (numParents == 0) {
+      result.baseCommit(ObjectId.zeroId());
+      result.comparisonType(ComparisonType.againstRoot());
+      return result.build();
+    }
     if (numParents == 1) {
       result.baseCommit(baseCommitUtil.getBaseCommit(project, newCommit, parent));
       result.comparisonType(ComparisonType.againstParent(1));
@@ -394,11 +394,9 @@
     if (numParents > 2) {
       logger.atFine().log(
           "Diff against auto-merge for merge commits "
-              + "with more than two parents is not supported. Commit "
-              + newCommit
-              + " has "
-              + numParents
-              + " parents. Falling back to the diff against the first parent.");
+              + "with more than two parents is not supported. Commit %s has %d parents."
+              + " Falling back to the diff against the first parent.",
+          newCommit, numParents);
       result.baseCommit(baseCommitUtil.getBaseCommit(project, newCommit, 1).getId());
       result.comparisonType(ComparisonType.againstParent(1));
       result.skipFiles(true);
diff --git a/java/com/google/gerrit/server/patch/PatchScriptFactory.java b/java/com/google/gerrit/server/patch/PatchScriptFactory.java
index 885459a..fbb6559 100644
--- a/java/com/google/gerrit/server/patch/PatchScriptFactory.java
+++ b/java/com/google/gerrit/server/patch/PatchScriptFactory.java
@@ -172,7 +172,7 @@
 
     this.fileName = fileName;
     this.psa = patchSetA;
-    this.parentNum = -1;
+    this.parentNum = 0;
     this.psb = patchSetB;
     this.diffPrefs = diffPrefs;
     this.currentUser = currentUser;
@@ -223,7 +223,7 @@
     this.runNewDiffCache = cfg.getBoolean("cache", "diff_cache", "runNewDiffCache_GetDiff", false);
 
     changeId = patchSetB.changeId();
-    checkArgument(parentNum >= 0, "parentNum must be >= 0");
+    checkArgument(parentNum > 0, "parentNum must be > 0");
   }
 
   @Override
@@ -265,9 +265,8 @@
           // the results while rolling out the new diff cache.
           runOldDiffCacheAsyncAndExportMetrics(git, aId, bId, patchScript);
           return patchScript;
-        } else {
-          return getPatchScriptWithOldDiffCache(git, aId, bId);
         }
+        return getPatchScriptWithOldDiffCache(git, aId, bId);
       } catch (PatchListNotAvailableException e) {
         throw new NoSuchChangeException(changeId, e);
       } catch (DiffNotAvailableException e) {
@@ -327,11 +326,7 @@
     FileDiffOutput fileDiffOutput =
         aId == null
             ? diffOperations.getModifiedFileAgainstParent(
-                notes.getProjectName(),
-                bId,
-                parentNum == -1 ? null : parentNum + 1,
-                fileName,
-                diffPrefs.ignoreWhitespace)
+                notes.getProjectName(), bId, parentNum, fileName, diffPrefs.ignoreWhitespace)
             : diffOperations.getModifiedFile(
                 notes.getProjectName(), aId, bId, fileName, diffPrefs.ignoreWhitespace);
     return newBuilder().toPatchScriptNew(git, fileDiffOutput);
@@ -396,7 +391,7 @@
     if (psa == null) {
       return Optional.empty();
     }
-    checkState(parentNum < 0, "expected no parentNum when psa is present");
+    checkState(parentNum == 0, "expected no parentNum when psa is present");
     checkArgument(psa.get() != 0, "edit not supported for left side");
     return Optional.of(getCommitId(psa));
   }
@@ -410,10 +405,10 @@
   }
 
   private PatchListKey keyFor(ObjectId aId, ObjectId bId, Whitespace whitespace) {
-    if (parentNum < 0) {
+    if (parentNum == 0) {
       return PatchListKey.againstCommit(aId, bId, whitespace);
     }
-    return PatchListKey.againstParentNum(parentNum + 1, bId, whitespace);
+    return PatchListKey.againstParentNum(parentNum, bId, whitespace);
   }
 
   private PatchList listFor(PatchListKey key) throws PatchListNotAvailableException {
diff --git a/java/com/google/gerrit/server/patch/SubmitWithStickyApprovalDiff.java b/java/com/google/gerrit/server/patch/SubmitWithStickyApprovalDiff.java
index 18d532b..e33b261 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",
@@ -82,16 +84,7 @@
   public String apply(ChangeNotes notes, CurrentUser currentUser)
       throws AuthException, IOException, PermissionBackendException,
           InvalidChangeOperationException {
-    // In some submit strategies, the current patch-set doesn't exist yet as it's being created
-    // during the submit. Hence, we assign the current patch-set to be the last existing patch-set.
-    PatchSet currentPatchset =
-        notes.getPatchSets().values().stream()
-            .max((p1, p2) -> p1.id().get() - p2.id().get())
-            .orElseThrow(
-                () ->
-                    new IllegalStateException(
-                        String.format(
-                            "change %s can't load any patchset", notes.getChangeId().toString())));
+    PatchSet currentPatchset = notes.getCurrentPatchSet();
 
     PatchSet.Id latestApprovedPatchsetId = getLatestApprovedPatchsetId(notes);
     if (latestApprovedPatchsetId.get() == currentPatchset.id().get()) {
@@ -102,19 +95,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 +115,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 +137,7 @@
       ChangeNotes notes,
       PatchSet.Id currentPatchsetId,
       PatchSet.Id latestApprovedPatchsetId,
-      PatchListEntry patchListEntry,
+      FileDiffOutput fileDiffOutput,
       CurrentUser currentUser)
       throws AuthException, InvalidChangeOperationException, IOException,
           PermissionBackendException {
@@ -152,14 +145,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 +172,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 +256,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/diff/ModifiedFilesCache.java b/java/com/google/gerrit/server/patch/diff/ModifiedFilesCache.java
index bcae238..56f49c9 100644
--- a/java/com/google/gerrit/server/patch/diff/ModifiedFilesCache.java
+++ b/java/com/google/gerrit/server/patch/diff/ModifiedFilesCache.java
@@ -28,8 +28,8 @@
  * files.
  *
  * <p>If the {@link ModifiedFilesCacheKey#aCommit()} is equal to {@link
- * org.eclipse.jgit.lib.Constants#EMPTY_TREE_ID}, the diff will be evaluated against the empty tree,
- * and the result will be exactly the same as the caller can get from {@link
+ * org.eclipse.jgit.lib.ObjectId#zeroId()}, the diff will be evaluated against the empty tree, and
+ * the result will be exactly the same as the caller can get from {@link
  * GitModifiedFilesCache#get(GitModifiedFilesCacheKey)}
  */
 public interface ModifiedFilesCache {
diff --git a/java/com/google/gerrit/server/patch/diff/ModifiedFilesCacheImpl.java b/java/com/google/gerrit/server/patch/diff/ModifiedFilesCacheImpl.java
index 6023c0e..b779bf7 100644
--- a/java/com/google/gerrit/server/patch/diff/ModifiedFilesCacheImpl.java
+++ b/java/com/google/gerrit/server/patch/diff/ModifiedFilesCacheImpl.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server.patch.diff;
 
 import static com.google.common.collect.ImmutableList.toImmutableList;
-import static org.eclipse.jgit.lib.Constants.EMPTY_TREE_ID;
 
 import com.google.common.cache.CacheLoader;
 import com.google.common.cache.LoadingCache;
@@ -34,6 +33,7 @@
 import com.google.gerrit.server.patch.gitdiff.ModifiedFile;
 import com.google.inject.Inject;
 import com.google.inject.Module;
+import com.google.inject.Singleton;
 import com.google.inject.TypeLiteral;
 import com.google.inject.name.Named;
 import java.io.IOException;
@@ -51,11 +51,11 @@
  * <p>The loader of this cache wraps a {@link GitModifiedFilesCache} to retrieve the git modified
  * files.
  *
- * <p>If the {@link ModifiedFilesCacheKey#aCommit()} is equal to {@link
- * org.eclipse.jgit.lib.Constants#EMPTY_TREE_ID}, the diff will be evaluated against the empty tree,
- * and the result will be exactly the same as the caller can get from {@link
- * GitModifiedFilesCache#get(GitModifiedFilesCacheKey)}
+ * <p>If the {@link ModifiedFilesCacheKey#aCommit()} is equal to {@link ObjectId#zeroId()}, the diff
+ * will be evaluated against the empty tree, and the result will be exactly the same as the caller
+ * can get from {@link GitModifiedFilesCache#get(GitModifiedFilesCacheKey)}
  */
+@Singleton
 public class ModifiedFilesCacheImpl implements ModifiedFilesCache {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
@@ -128,7 +128,7 @@
     private ImmutableList<ModifiedFile> loadModifiedFiles(ModifiedFilesCacheKey key, RevWalk rw)
         throws IOException, DiffNotAvailableException {
       ObjectId aTree =
-          key.aCommit().equals(EMPTY_TREE_ID)
+          key.aCommit().equals(ObjectId.zeroId())
               ? key.aCommit()
               : DiffUtil.getTreeId(rw, key.aCommit());
       ObjectId bTree = DiffUtil.getTreeId(rw, key.bCommit());
@@ -140,7 +140,7 @@
               .renameScore(key.renameScore())
               .build();
       List<ModifiedFile> modifiedFiles = gitCache.get(gitKey);
-      if (key.aCommit().equals(EMPTY_TREE_ID)) {
+      if (key.aCommit().equals(ObjectId.zeroId())) {
         return ImmutableList.copyOf(modifiedFiles);
       }
       RevCommit revCommitA = DiffUtil.getRevCommit(rw, key.aCommit());
diff --git a/java/com/google/gerrit/server/patch/filediff/AllDiffsEvaluator.java b/java/com/google/gerrit/server/patch/filediff/AllDiffsEvaluator.java
index 12decc3..0923252 100644
--- a/java/com/google/gerrit/server/patch/filediff/AllDiffsEvaluator.java
+++ b/java/com/google/gerrit/server/patch/filediff/AllDiffsEvaluator.java
@@ -14,8 +14,6 @@
 
 package com.google.gerrit.server.patch.filediff;
 
-import static org.eclipse.jgit.lib.Constants.EMPTY_TREE_ID;
-
 import com.google.common.collect.ImmutableMap;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.server.patch.DiffNotAvailableException;
@@ -203,7 +201,7 @@
       FileDiffCacheKey key, ObjectId aCommit, ObjectId bCommit, String pathNew, RevWalk rw)
       throws IOException {
     ObjectId oldTreeId =
-        aCommit.equals(EMPTY_TREE_ID) ? EMPTY_TREE_ID : DiffUtil.getTreeId(rw, aCommit);
+        aCommit.equals(ObjectId.zeroId()) ? ObjectId.zeroId() : DiffUtil.getTreeId(rw, aCommit);
     ObjectId newTreeId = DiffUtil.getTreeId(rw, bCommit);
     return GitFileDiffCacheKey.builder()
         .project(key.project())
@@ -213,6 +211,7 @@
         .renameScore(key.renameScore())
         .diffAlgorithm(key.diffAlgorithm())
         .whitespace(key.whitespace())
+        .useTimeout(key.useTimeout())
         .build();
   }
 }
diff --git a/java/com/google/gerrit/server/patch/filediff/FileDiffCacheImpl.java b/java/com/google/gerrit/server/patch/filediff/FileDiffCacheImpl.java
index 395312f..a67f221 100644
--- a/java/com/google/gerrit/server/patch/filediff/FileDiffCacheImpl.java
+++ b/java/com/google/gerrit/server/patch/filediff/FileDiffCacheImpl.java
@@ -16,7 +16,6 @@
 
 import static com.google.common.collect.ImmutableList.toImmutableList;
 import static java.nio.charset.StandardCharsets.UTF_8;
-import static org.eclipse.jgit.lib.Constants.EMPTY_TREE_ID;
 
 import com.google.common.cache.CacheLoader;
 import com.google.common.cache.LoadingCache;
@@ -26,11 +25,15 @@
 import com.google.common.collect.Multimap;
 import com.google.common.collect.Streams;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Patch;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
 import com.google.gerrit.server.cache.CacheModule;
 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;
@@ -43,6 +46,7 @@
 import com.google.gerrit.server.patch.gitfilediff.GitFileDiffCacheImpl.DiffAlgorithmFactory;
 import com.google.inject.Inject;
 import com.google.inject.Module;
+import com.google.inject.Singleton;
 import com.google.inject.name.Named;
 import java.io.IOException;
 import java.util.ArrayList;
@@ -71,10 +75,10 @@
  * Cache for the single file diff between two commits for a single file path. This cache adds extra
  * Gerrit logic such as identifying edits due to rebase.
  *
- * <p>If the {@link FileDiffCacheKey#oldCommit()} is equal to {@link
- * org.eclipse.jgit.lib.Constants#EMPTY_TREE_ID}, the git diff will be evaluated against the empty
- * tree.
+ * <p>If the {@link FileDiffCacheKey#oldCommit()} is equal to {@link ObjectId#zeroId()}, the git
+ * diff will be evaluated against the empty tree.
  */
+@Singleton
 public class FileDiffCacheImpl implements FileDiffCache {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
@@ -93,7 +97,7 @@
         persist(DIFF, FileDiffCacheKey.class, FileDiffOutput.class)
             .maximumWeight(10 << 20)
             .weigher(FileDiffWeigher.class)
-            .version(4)
+            .version(7)
             .keySerializer(FileDiffCacheKey.Serializer.INSTANCE)
             .valueSerializer(FileDiffOutput.Serializer.INSTANCE)
             .loader(FileDiffLoader.class);
@@ -151,44 +155,56 @@
 
     @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(
         RevWalk rw, ObjectReader reader, ObjectId oldCommitId, ObjectId newCommitId)
         throws IOException {
+      if (oldCommitId.equals(ObjectId.zeroId())) {
+        return ComparisonType.againstRoot();
+      }
       RevCommit oldCommit = DiffUtil.getRevCommit(rw, oldCommitId);
       RevCommit newCommit = DiffUtil.getRevCommit(rw, newCommitId);
       for (int i = 0; i < newCommit.getParentCount(); i++) {
@@ -211,7 +227,7 @@
     }
 
     /**
-     * Creates a {@link FileDiffOutput} entry for the "Commit message" and "Merge list" file paths.
+     * Creates a {@link FileDiffOutput} entry for the "Commit message" or "Merge list" magic paths.
      */
     private FileDiffOutput createMagicPathEntry(
         FileDiffCacheKey key, ObjectReader reader, RevWalk rw, MagicPath magicPath) {
@@ -219,7 +235,10 @@
         RawTextComparator cmp = comparatorFor(key.whitespace());
         ComparisonType comparisonType =
             getComparisonType(rw, reader, key.oldCommit(), key.newCommit());
-        RevCommit aCommit = DiffUtil.getRevCommit(rw, key.oldCommit());
+        RevCommit aCommit =
+            key.oldCommit().equals(ObjectId.zeroId())
+                ? null
+                : DiffUtil.getRevCommit(rw, key.oldCommit());
         RevCommit bCommit = DiffUtil.getRevCommit(rw, key.newCommit());
         return magicPath == MagicPath.COMMIT
             ? createCommitEntry(reader, aCommit, bCommit, comparisonType, cmp, key.diffAlgorithm())
@@ -248,16 +267,19 @@
       }
     }
 
+    /**
+     * Creates a commit entry. {@code oldCommit} is null if the comparison is against a root commit.
+     */
     private FileDiffOutput createCommitEntry(
         ObjectReader reader,
-        RevCommit oldCommit,
+        @Nullable RevCommit oldCommit,
         RevCommit newCommit,
         ComparisonType comparisonType,
         RawTextComparator rawTextComparator,
         GitFileDiffCacheImpl.DiffAlgorithm diffAlgorithm)
         throws IOException {
       Text aText =
-          comparisonType.isAgainstParentOrAutoMerge()
+          oldCommit == null || comparisonType.isAgainstParentOrAutoMerge()
               ? Text.EMPTY
               : Text.forCommit(reader, oldCommit);
       Text bText = Text.forCommit(reader, newCommit);
@@ -272,16 +294,20 @@
           diffAlgorithm);
     }
 
+    /**
+     * Creates a merge list entry. {@code oldCommit} is null if the comparison is against a root
+     * commit.
+     */
     private FileDiffOutput createMergeListEntry(
         ObjectReader reader,
-        RevCommit oldCommit,
+        @Nullable RevCommit oldCommit,
         RevCommit newCommit,
         ComparisonType comparisonType,
         RawTextComparator rawTextComparator,
         GitFileDiffCacheImpl.DiffAlgorithm diffAlgorithm)
         throws IOException {
       Text aText =
-          comparisonType.isAgainstParentOrAutoMerge()
+          oldCommit == null || comparisonType.isAgainstParentOrAutoMerge()
               ? Text.EMPTY
               : Text.forMergeList(comparisonType, reader, oldCommit);
       Text bText = Text.forMergeList(comparisonType, reader, newCommit);
@@ -297,7 +323,7 @@
     }
 
     private static FileDiffOutput createMagicFileDiffOutput(
-        ObjectId oldCommit,
+        @Nullable ObjectId oldCommit,
         ObjectId newCommit,
         ComparisonType comparisonType,
         RawTextComparator rawTextComparator,
@@ -317,7 +343,7 @@
       FileHeader fileHeader = new FileHeader(rawHdr, edits, PatchType.UNIFIED);
       Patch.ChangeType changeType = FileHeaderUtil.getChangeType(fileHeader);
       return FileDiffOutput.builder()
-          .oldCommitId(oldCommit)
+          .oldCommitId(oldCommit == null ? ObjectId.zeroId() : oldCommit)
           .newCommitId(newCommit)
           .comparisonType(comparisonType)
           .oldPath(FileHeaderUtil.getOldPath(fileHeader))
@@ -364,6 +390,19 @@
 
       for (AugmentedFileDiffCacheKey augmentedKey : allFileDiffs.keySet()) {
         AllFileGitDiffs allDiffs = allFileDiffs.get(augmentedKey);
+        GitFileDiff mainGitDiff = allDiffs.mainDiff().gitDiff();
+
+        if (mainGitDiff.isNegative()) {
+          // If the result of the git diff computation was negative, i.e. due to timeout, cache a
+          // negative result.
+          result.put(
+              augmentedKey.key(),
+              FileDiffOutput.createNegative(
+                  mainGitDiff.newPath().orElse(""),
+                  augmentedKey.key().oldCommit(),
+                  augmentedKey.key().newCommit()));
+          continue;
+        }
 
         FileEdits rebaseFileEdits = FileEdits.empty();
         if (!augmentedKey.ignoreRebase()) {
@@ -371,12 +410,13 @@
         }
         List<Edit> rebaseEdits = rebaseFileEdits.edits();
 
-        RevTree aTree = rw.parseTree(allDiffs.mainDiff().gitKey().oldTree());
+        ObjectId oldTreeId = allDiffs.mainDiff().gitKey().oldTree();
+
+        RevTree aTree = oldTreeId.equals(ObjectId.zeroId()) ? null : rw.parseTree(oldTreeId);
         RevTree bTree = rw.parseTree(allDiffs.mainDiff().gitKey().newTree());
-        GitFileDiff mainGitDiff = allDiffs.mainDiff().gitDiff();
 
         Long oldSize =
-            mainGitDiff.oldMode().isPresent() && mainGitDiff.oldPath().isPresent()
+            aTree != null && mainGitDiff.oldMode().isPresent() && mainGitDiff.oldPath().isPresent()
                 ? new FileSizeEvaluator(reader, aTree)
                     .compute(
                         mainGitDiff.oldId(),
@@ -425,7 +465,7 @@
     private List<AugmentedFileDiffCacheKey> wrapKeys(List<FileDiffCacheKey> keys, RevWalk rw) {
       List<AugmentedFileDiffCacheKey> result = new ArrayList<>();
       for (FileDiffCacheKey key : keys) {
-        if (key.oldCommit().equals(EMPTY_TREE_ID)) {
+        if (key.oldCommit().equals(ObjectId.zeroId())) {
           result.add(AugmentedFileDiffCacheKey.builder().key(key).ignoreRebase(true).build());
           continue;
         }
diff --git a/java/com/google/gerrit/server/patch/filediff/FileDiffCacheKey.java b/java/com/google/gerrit/server/patch/filediff/FileDiffCacheKey.java
index a478fcf..5880b65 100644
--- a/java/com/google/gerrit/server/patch/filediff/FileDiffCacheKey.java
+++ b/java/com/google/gerrit/server/patch/filediff/FileDiffCacheKey.java
@@ -35,7 +35,10 @@
   /** A specific git project / repository. */
   public abstract Project.NameKey project();
 
-  /** The 20 bytes SHA-1 commit ID of the old commit used in the diff. */
+  /**
+   * The 20 bytes SHA-1 commit ID of the old commit used in the diff. If set to {@link
+   * ObjectId#zeroId()}, an empty tree is used for the diff.
+   */
   public abstract ObjectId oldCommit();
 
   /** The 20 bytes SHA-1 commit ID of the new commit used in the diff. */
@@ -55,6 +58,9 @@
 
   public abstract DiffPreferencesInfo.Whitespace whitespace();
 
+  /** Employ a timeout on the git computation while formatting the file header. */
+  public abstract boolean useTimeout();
+
   /** Number of bytes that this entity occupies. */
   public int weight() {
     return stringSize(project().get())
@@ -62,13 +68,16 @@
         + stringSize(newFilePath())
         + 4 // renameScore
         + 4 // diffAlgorithm
-        + 4; // whitespace
+        + 4 // whitespace
+        + 1; // useTimeout
   }
 
   public static FileDiffCacheKey.Builder builder() {
     return new AutoValue_FileDiffCacheKey.Builder();
   }
 
+  public abstract Builder toBuilder();
+
   @AutoValue.Builder
   public abstract static class Builder {
 
@@ -91,6 +100,8 @@
 
     public abstract FileDiffCacheKey.Builder whitespace(Whitespace value);
 
+    public abstract FileDiffCacheKey.Builder useTimeout(boolean value);
+
     public abstract FileDiffCacheKey build();
   }
 
@@ -109,6 +120,7 @@
               .setRenameScore(key.renameScore())
               .setDiffAlgorithm(key.diffAlgorithm().name())
               .setWhitespace(key.whitespace().name())
+              .setUseTimeout(key.useTimeout())
               .build());
     }
 
@@ -124,6 +136,7 @@
           .renameScore(proto.getRenameScore())
           .diffAlgorithm(DiffAlgorithm.valueOf(proto.getDiffAlgorithm()))
           .whitespace(Whitespace.valueOf(proto.getWhitespace()))
+          .useTimeout(proto.getUseTimeout())
           .build();
     }
   }
diff --git a/java/com/google/gerrit/server/patch/filediff/FileDiffOutput.java b/java/com/google/gerrit/server/patch/filediff/FileDiffOutput.java
index e7f47ef..3c6d746 100644
--- a/java/com/google/gerrit/server/patch/filediff/FileDiffOutput.java
+++ b/java/com/google/gerrit/server/patch/filediff/FileDiffOutput.java
@@ -79,6 +79,14 @@
   /** Difference in file size between the old and new commits. */
   public abstract long sizeDelta();
 
+  /**
+   * Returns {@code true} if the diff computation was not able to compute a diff, i.e. for diffs
+   * taking a very long time to compute. We cache negative result in this case.
+   */
+  public abstract Optional<Boolean> negative();
+
+  public abstract Builder toBuilder();
+
   /** A boolean indicating if all underlying edits of the file diff are due to rebase. */
   public boolean allEditsDueToRebase() {
     return !edits().isEmpty() && edits().stream().allMatch(TaggedEdit::dueToRebase);
@@ -122,11 +130,24 @@
         .build();
   }
 
+  public static FileDiffOutput createNegative(
+      String filePath, ObjectId oldCommitId, ObjectId newCommitId) {
+    return empty(filePath, oldCommitId, newCommitId).toBuilder().build();
+  }
+
   /** Returns true if this entity represents an unchanged file between two commits. */
   public boolean isEmpty() {
     return headerLines().isEmpty() && edits().isEmpty();
   }
 
+  /**
+   * Returns {@code true} if the diff computation was not able to compute a diff. We cache negative
+   * result in this case.
+   */
+  public boolean isNegative() {
+    return negative().isPresent() && negative().get();
+  }
+
   public static Builder builder() {
     return new AutoValue_FileDiffOutput.Builder();
   }
@@ -151,6 +172,9 @@
     for (String s : headerLines()) {
       s += stringSize(s);
     }
+    if (negative().isPresent()) {
+      result += 1;
+    }
     return result;
   }
 
@@ -179,6 +203,8 @@
 
     public abstract Builder sizeDelta(long value);
 
+    public abstract Builder negative(Optional<Boolean> value);
+
     public abstract FileDiffOutput build();
   }
 
@@ -194,6 +220,9 @@
     private static final FieldDescriptor PATCH_TYPE_DESCRIPTOR =
         FileDiffOutputProto.getDescriptor().findFieldByNumber(4);
 
+    private static final FieldDescriptor NEGATIVE_DESCRIPTOR =
+        FileDiffOutputProto.getDescriptor().findFieldByNumber(12);
+
     @Override
     public byte[] serialize(FileDiffOutput fileDiff) {
       ObjectIdConverter idConverter = ObjectIdConverter.create();
@@ -234,6 +263,10 @@
         builder.setPatchType(fileDiff.patchType().get().name());
       }
 
+      if (fileDiff.negative().isPresent()) {
+        builder.setNegative(fileDiff.negative().get());
+      }
+
       return Protos.toByteArray(builder.build());
     }
 
@@ -272,6 +305,9 @@
       if (proto.hasField(PATCH_TYPE_DESCRIPTOR)) {
         builder.patchType(Optional.of(Patch.PatchType.valueOf(proto.getPatchType())));
       }
+      if (proto.hasField(NEGATIVE_DESCRIPTOR)) {
+        builder.negative(Optional.of(proto.getNegative()));
+      }
       return builder.build();
     }
   }
diff --git a/java/com/google/gerrit/server/patch/gitdiff/GitModifiedFilesCacheImpl.java b/java/com/google/gerrit/server/patch/gitdiff/GitModifiedFilesCacheImpl.java
index b3b82bb..b7144d7 100644
--- a/java/com/google/gerrit/server/patch/gitdiff/GitModifiedFilesCacheImpl.java
+++ b/java/com/google/gerrit/server/patch/gitdiff/GitModifiedFilesCacheImpl.java
@@ -29,6 +29,7 @@
 import com.google.gerrit.server.patch.DiffNotAvailableException;
 import com.google.inject.Inject;
 import com.google.inject.Module;
+import com.google.inject.Singleton;
 import com.google.inject.TypeLiteral;
 import com.google.inject.name.Named;
 import java.io.IOException;
@@ -38,11 +39,13 @@
 import org.eclipse.jgit.diff.DiffEntry;
 import org.eclipse.jgit.diff.DiffEntry.ChangeType;
 import org.eclipse.jgit.diff.DiffFormatter;
+import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectReader;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.util.io.DisabledOutputStream;
 
 /** Implementation of the {@link GitModifiedFilesCache} */
+@Singleton
 public class GitModifiedFilesCacheImpl implements GitModifiedFilesCache {
   private static final String GIT_MODIFIED_FILES = "git_modified_files";
   private static final ImmutableMap<ChangeType, Patch.ChangeType> changeTypeMap =
@@ -130,7 +133,7 @@
         }
         // The scan method only returns the file paths that are different. Callers may choose to
         // format these paths themselves.
-        return df.scan(key.aTree(), key.bTree());
+        return df.scan(key.aTree().equals(ObjectId.zeroId()) ? null : key.aTree(), key.bTree());
       }
     }
 
diff --git a/java/com/google/gerrit/server/patch/gitdiff/GitModifiedFilesCacheKey.java b/java/com/google/gerrit/server/patch/gitdiff/GitModifiedFilesCacheKey.java
index fb8fce1..16b0e65 100644
--- a/java/com/google/gerrit/server/patch/gitdiff/GitModifiedFilesCacheKey.java
+++ b/java/com/google/gerrit/server/patch/gitdiff/GitModifiedFilesCacheKey.java
@@ -37,7 +37,8 @@
 
   /**
    * The git SHA-1 {@link ObjectId} of the first git tree object for which the diff should be
-   * computed.
+   * computed. If equals to {@link ObjectId#zeroId()}, a null tree is used for the diff scan, and
+   * {@link #bTree()} is treated as an added tree.
    */
   public abstract ObjectId aTree();
 
diff --git a/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiff.java b/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiff.java
index e1af81d..2f2d29b 100644
--- a/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiff.java
+++ b/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiff.java
@@ -29,11 +29,9 @@
 import com.google.gerrit.server.cache.serialize.ObjectIdConverter;
 import com.google.gerrit.server.patch.filediff.Edit;
 import com.google.protobuf.Descriptors.FieldDescriptor;
-import java.io.IOException;
 import java.util.Map;
 import java.util.Optional;
 import org.eclipse.jgit.diff.DiffEntry;
-import org.eclipse.jgit.diff.DiffFormatter;
 import org.eclipse.jgit.lib.AbbreviatedObjectId;
 import org.eclipse.jgit.lib.FileMode;
 import org.eclipse.jgit.patch.FileHeader;
@@ -65,8 +63,7 @@
    * Creates a {@link GitFileDiff} using the {@code diffEntry} and the {@code diffFormatter}
    * parameters.
    */
-  static GitFileDiff create(DiffEntry diffEntry, DiffFormatter diffFormatter) throws IOException {
-    FileHeader fileHeader = diffFormatter.toFileHeader(diffEntry);
+  static GitFileDiff create(DiffEntry diffEntry, FileHeader fileHeader) {
     ImmutableList<Edit> edits =
         fileHeader.toEditList().stream().map(Edit::fromJGitEdit).collect(toImmutableList());
 
@@ -102,6 +99,15 @@
         .build();
   }
 
+  /**
+   * Create a negative result to be cached, i.e. if the diff computation did not finish in a
+   * reasonable amount of time.
+   */
+  static GitFileDiff createNegative(
+      AbbreviatedObjectId oldId, AbbreviatedObjectId newId, String newFilePath) {
+    return empty(oldId, newId, newFilePath).toBuilder().negative(Optional.of(true)).build();
+  }
+
   /** An {@link ImmutableList} of the modified regions in the file. */
   public abstract ImmutableList<Edit> edits();
 
@@ -133,6 +139,12 @@
   public abstract Optional<PatchType> patchType();
 
   /**
+   * Returns {@code true} if the diff computation was not able to compute a diff. We cache negative
+   * result in this case.
+   */
+  public abstract Optional<Boolean> negative();
+
+  /**
    * Returns true if the object was created using the {@link #empty(AbbreviatedObjectId,
    * AbbreviatedObjectId, String)} method.
    */
@@ -140,6 +152,14 @@
     return edits().isEmpty();
   }
 
+  /**
+   * Returns {@code true} if the diff computation was not able to compute a diff. We cache negative
+   * result in this case.
+   */
+  public boolean isNegative() {
+    return negative().isPresent() && negative().get();
+  }
+
   /** Returns the size of the object in bytes. */
   public int weight() {
     int result = 20 * 2; // oldId and newId
@@ -161,6 +181,9 @@
     if (newMode().isPresent()) {
       result += 4;
     }
+    if (negative().isPresent()) {
+      result += 1;
+    }
     return result;
   }
 
@@ -168,6 +191,8 @@
     return new AutoValue_GitFileDiff.Builder();
   }
 
+  public abstract Builder toBuilder();
+
   @AutoValue.Builder
   public abstract static class Builder {
 
@@ -191,6 +216,8 @@
 
     public abstract Builder patchType(Optional<PatchType> value);
 
+    public abstract Builder negative(Optional<Boolean> value);
+
     public abstract GitFileDiff build();
   }
 
@@ -212,6 +239,9 @@
     private static final FieldDescriptor PATCH_TYPE_DESCRIPTOR =
         GitFileDiffProto.getDescriptor().findFieldByNumber(10);
 
+    private static final FieldDescriptor NEGATIVE_DESCRIPTOR =
+        GitFileDiffProto.getDescriptor().findFieldByNumber(11);
+
     @Override
     public byte[] serialize(GitFileDiff gitFileDiff) {
       ObjectIdConverter idConverter = ObjectIdConverter.create();
@@ -246,6 +276,9 @@
       if (gitFileDiff.patchType().isPresent()) {
         builder.setPatchType(gitFileDiff.patchType().get().name());
       }
+      if (gitFileDiff.negative().isPresent()) {
+        builder.setNegative(gitFileDiff.negative().get());
+      }
       return Protos.toByteArray(builder.build());
     }
 
@@ -279,6 +312,9 @@
       if (proto.hasField(PATCH_TYPE_DESCRIPTOR)) {
         builder.patchType(Optional.of(Patch.PatchType.valueOf(proto.getPatchType())));
       }
+      if (proto.hasField(NEGATIVE_DESCRIPTOR)) {
+        builder.negative(Optional.of(proto.getNegative()));
+      }
       return builder.build();
     }
   }
diff --git a/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiffCacheImpl.java b/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiffCacheImpl.java
index 97cf37d32..2ce6925 100644
--- a/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiffCacheImpl.java
+++ b/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiffCacheImpl.java
@@ -17,6 +17,7 @@
 import static java.util.function.Function.identity;
 
 import com.google.auto.value.AutoValue;
+import com.google.common.base.Throwables;
 import com.google.common.cache.CacheLoader;
 import com.google.common.cache.LoadingCache;
 import com.google.common.collect.ImmutableList;
@@ -27,10 +28,17 @@
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
 import com.google.gerrit.server.cache.CacheModule;
+import com.google.gerrit.server.config.ConfigUtil;
+import com.google.gerrit.server.config.GerritServerConfig;
 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.DiffExecutor;
 import com.google.gerrit.server.patch.DiffNotAvailableException;
 import com.google.inject.Inject;
 import com.google.inject.Module;
+import com.google.inject.Singleton;
 import com.google.inject.name.Named;
 import java.io.IOException;
 import java.util.Collection;
@@ -38,6 +46,10 @@
 import java.util.Map;
 import java.util.Set;
 import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
 import java.util.function.Function;
 import java.util.stream.Collectors;
 import org.eclipse.jgit.diff.DiffEntry;
@@ -46,12 +58,15 @@
 import org.eclipse.jgit.diff.HistogramDiff;
 import org.eclipse.jgit.diff.RawTextComparator;
 import org.eclipse.jgit.lib.AbbreviatedObjectId;
+import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectReader;
 import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.patch.FileHeader;
 import org.eclipse.jgit.util.io.DisabledOutputStream;
 
 /** Implementation of the {@link GitFileDiffCache} */
+@Singleton
 public class GitFileDiffCacheImpl implements GitFileDiffCache {
   private static final String GIT_DIFF = "git_file_diff";
 
@@ -65,6 +80,7 @@
             .weigher(GitFileDiffWeigher.class)
             .keySerializer(GitFileDiffCacheKey.Serializer.INSTANCE)
             .valueSerializer(GitFileDiff.Serializer.INSTANCE)
+            .version(3)
             .loader(GitFileDiffCacheImpl.Loader.class);
       }
     };
@@ -72,15 +88,15 @@
 
   /** Enum for the supported diff algorithms for the file diff computation. */
   public enum DiffAlgorithm {
-    HISTOGRAM,
-    HISTOGRAM_WITHOUT_MYERS_FALLBACK
+    HISTOGRAM_WITH_FALLBACK_MYERS,
+    HISTOGRAM_NO_FALLBACK
   }
 
   /** Creates a new JGit diff algorithm instance using the Gerrit's {@link DiffAlgorithm} enum. */
   public static class DiffAlgorithmFactory {
     public static org.eclipse.jgit.diff.DiffAlgorithm create(DiffAlgorithm diffAlgorithm) {
       HistogramDiff result = new HistogramDiff();
-      if (diffAlgorithm.equals(DiffAlgorithm.HISTOGRAM_WITHOUT_MYERS_FALLBACK)) {
+      if (diffAlgorithm.equals(DiffAlgorithm.HISTOGRAM_NO_FALLBACK)) {
         result.setFallbackAlgorithm(null);
       }
       return result;
@@ -126,43 +142,69 @@
                 : entry.getNewPath();
 
     private final GitRepositoryManager repoManager;
+    private final ExecutorService diffExecutor;
+    private final long timeoutMillis;
 
     @Inject
-    public Loader(GitRepositoryManager repoManager) {
+    public Loader(
+        @GerritServerConfig Config cfg,
+        GitRepositoryManager repoManager,
+        @DiffExecutor ExecutorService de) {
       this.repoManager = repoManager;
+      this.diffExecutor = de;
+      this.timeoutMillis =
+          ConfigUtil.getTimeUnit(
+              cfg,
+              "cache",
+              "diff",
+              "timeout",
+              TimeUnit.MILLISECONDS.convert(5, TimeUnit.SECONDS),
+              TimeUnit.MILLISECONDS);
     }
 
     @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();
     }
 
     /**
@@ -182,16 +224,18 @@
       Map<String, DiffEntry> diffEntries = loadDiffEntries(formatter, options, filePaths.values());
       for (GitFileDiffCacheKey key : filePaths.keySet()) {
         String newFilePath = filePaths.get(key);
-        if (diffEntries.containsKey(newFilePath)) {
-          result.put(key, GitFileDiff.create(diffEntries.get(newFilePath), formatter));
+        if (!diffEntries.containsKey(newFilePath)) {
+          result.put(
+              key,
+              GitFileDiff.empty(
+                  AbbreviatedObjectId.fromObjectId(key.oldTree()),
+                  AbbreviatedObjectId.fromObjectId(key.newTree()),
+                  newFilePath));
           continue;
         }
-        result.put(
-            key,
-            GitFileDiff.empty(
-                AbbreviatedObjectId.fromObjectId(key.oldTree()),
-                AbbreviatedObjectId.fromObjectId(key.newTree()),
-                newFilePath));
+        DiffEntry diffEntry = diffEntries.get(newFilePath);
+        GitFileDiff gitFileDiff = createGitFileDiff(diffEntry, formatter, key);
+        result.put(key, gitFileDiff);
       }
       return result.build();
     }
@@ -201,7 +245,9 @@
         throws IOException {
       Set<String> filePathsSet = ImmutableSet.copyOf(filePaths);
       List<DiffEntry> diffEntries =
-          diffFormatter.scan(diffOptions.oldTree(), diffOptions.newTree());
+          diffFormatter.scan(
+              diffOptions.oldTree().equals(ObjectId.zeroId()) ? null : diffOptions.oldTree(),
+              diffOptions.newTree());
 
       return diffEntries.stream()
           .filter(d -> filePathsSet.contains(pathExtractor.apply(d)))
@@ -219,6 +265,7 @@
           diffFormatter.getRenameDetector().setRenameScore(diffOptions.renameScore());
         }
         diffFormatter.setDiffAlgorithm(DiffAlgorithmFactory.create(diffOptions.diffAlgorithm()));
+        diffFormatter.getRenameDetector().setSkipContentRenamesForBinaryFiles(true);
         return diffFormatter;
       }
     }
@@ -239,6 +286,52 @@
           return RawTextComparator.DEFAULT;
       }
     }
+
+    /**
+     * Create a {@link GitFileDiff}. The result depends on the value of the {@code useTimeout} field
+     * of the {@code key} parameter.
+     *
+     * <ul>
+     *   <li>If {@code useTimeout} is true, the computation is performed with timeout enforcement
+     *       (identified by {@link #timeoutMillis}). If the timeout is exceeded, this method returns
+     *       a negative result using {@link GitFileDiff#createNegative(AbbreviatedObjectId,
+     *       AbbreviatedObjectId, String)}.
+     *   <li>If {@code useTimeouts} is false, the computation is performed synchronously without
+     *       timeout enforcement.
+     */
+    private GitFileDiff createGitFileDiff(
+        DiffEntry diffEntry, DiffFormatter formatter, GitFileDiffCacheKey key) throws IOException {
+      if (!key.useTimeout()) {
+        FileHeader fileHeader = formatter.toFileHeader(diffEntry);
+        return GitFileDiff.create(diffEntry, fileHeader);
+      }
+      Future<FileHeader> fileHeaderFuture =
+          diffExecutor.submit(
+              () -> {
+                synchronized (diffEntry) {
+                  return formatter.toFileHeader(diffEntry);
+                }
+              });
+      try {
+        // We employ the timeout because of a bug in Myers diff in JGit. See
+        // bugs.chromium.org/p/gerrit/issues/detail?id=487 for more details. The bug may happen
+        // if the algorithm used in diffs is HISTOGRAM_WITH_FALLBACK_MYERS.
+        fileHeaderFuture.get(timeoutMillis, TimeUnit.MILLISECONDS);
+        FileHeader fileHeader = formatter.toFileHeader(diffEntry);
+        return GitFileDiff.create(diffEntry, fileHeader);
+      } catch (InterruptedException | TimeoutException e) {
+        // If timeout happens, create a negative result
+        return GitFileDiff.createNegative(
+            AbbreviatedObjectId.fromObjectId(key.oldTree()),
+            AbbreviatedObjectId.fromObjectId(key.newTree()),
+            key.newFilePath());
+      } catch (ExecutionException e) {
+        // If there was an error computing the result, carry it
+        // up to the caller so the cache knows this key is invalid.
+        Throwables.throwIfInstanceOf(e.getCause(), IOException.class);
+        throw new IOException(e.getMessage(), e.getCause());
+      }
+    }
   }
 
   /** An entity representing the options affecting the diff computation. */
diff --git a/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiffCacheKey.java b/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiffCacheKey.java
index f104388..2d80614 100644
--- a/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiffCacheKey.java
+++ b/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiffCacheKey.java
@@ -34,7 +34,11 @@
   /** A specific git project / repository. */
   public abstract Project.NameKey project();
 
-  /** The old 20 bytes SHA-1 git tree ID used in the git tree diff */
+  /**
+   * The old 20 bytes SHA-1 git tree ID used in the git tree diff. If equals to {@link
+   * ObjectId#zeroId()}, a null tree is used for the diff scan, and {@link #newTree()} ()} is
+   * treated as an added tree.
+   */
   public abstract ObjectId oldTree();
 
   /** The new 20 bytes SHA-1 git tree ID used in the git tree diff */
@@ -53,13 +57,17 @@
 
   public abstract DiffPreferencesInfo.Whitespace whitespace();
 
+  /** Employ a timeout on the git computation while formatting the file header. */
+  public abstract boolean useTimeout();
+
   public int weight() {
     return stringSize(project().get())
         + 20 * 2 // oldTree and newTree
         + stringSize(newFilePath())
         + 4 // renameScore
         + 4 // diffAlgorithm
-        + 4; // whitespace
+        + 4 // whitespace
+        + 1; // useTimeout
   }
 
   public static Builder builder() {
@@ -88,6 +96,8 @@
 
     public abstract Builder whitespace(Whitespace value);
 
+    public abstract Builder useTimeout(boolean value);
+
     public abstract GitFileDiffCacheKey build();
   }
 
@@ -106,6 +116,7 @@
               .setRenameScore(key.renameScore())
               .setDiffAlgorithm(key.diffAlgorithm().name())
               .setWhitepsace(key.whitespace().name())
+              .setUseTimeout(key.useTimeout())
               .build());
     }
 
@@ -121,6 +132,7 @@
           .renameScore(proto.getRenameScore())
           .diffAlgorithm(DiffAlgorithm.valueOf(proto.getDiffAlgorithm()))
           .whitespace(Whitespace.valueOf(proto.getWhitepsace()))
+          .useTimeout(proto.getUseTimeout())
           .build();
     }
   }
diff --git a/java/com/google/gerrit/server/project/LabelDefinitionJson.java b/java/com/google/gerrit/server/project/LabelDefinitionJson.java
index 730162f..63c9d22 100644
--- a/java/com/google/gerrit/server/project/LabelDefinitionJson.java
+++ b/java/com/google/gerrit/server/project/LabelDefinitionJson.java
@@ -32,6 +32,7 @@
     label.defaultValue = labelType.getDefaultValue();
     label.branches = labelType.getRefPatterns() != null ? labelType.getRefPatterns() : null;
     label.canOverride = toBoolean(labelType.isCanOverride());
+    label.copyCondition = labelType.getCopyCondition().orElse(null);
     label.copyAnyScore = toBoolean(labelType.isCopyAnyScore());
     label.copyMinScore = toBoolean(labelType.isCopyMinScore());
     label.copyMaxScore = toBoolean(labelType.isCopyMaxScore());
diff --git a/java/com/google/gerrit/server/project/NullProjectCache.java b/java/com/google/gerrit/server/project/NullProjectCache.java
new file mode 100644
index 0000000..1d5f5b7
--- /dev/null
+++ b/java/com/google/gerrit/server/project/NullProjectCache.java
@@ -0,0 +1,83 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.project;
+
+import com.google.common.collect.ImmutableSortedSet;
+import com.google.gerrit.entities.AccountGroup.UUID;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.Project.NameKey;
+import com.google.gerrit.exceptions.StorageException;
+import java.io.IOException;
+import java.util.Optional;
+import java.util.Set;
+
+/** An implementation of {@link ProjectCache} with no operations implemented. */
+public class NullProjectCache implements ProjectCache {
+
+  @Override
+  public ProjectState getAllProjects() {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public ProjectState getAllUsers() {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public Optional<ProjectState> get(NameKey projectName) throws StorageException {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public void evict(Project p) {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public void evict(NameKey p) {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public void remove(Project p) {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public void remove(NameKey name) {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public ImmutableSortedSet<NameKey> all() {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public Set<UUID> guessRelevantGroupUUIDs() {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public ImmutableSortedSet<NameKey> byName(String prefix) {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public void onCreateProject(NameKey newProjectName) throws IOException {
+    throw new UnsupportedOperationException();
+  }
+}
diff --git a/java/com/google/gerrit/server/project/ProjectCacheImpl.java b/java/com/google/gerrit/server/project/ProjectCacheImpl.java
index 1b11ba2..e69967c 100644
--- a/java/com/google/gerrit/server/project/ProjectCacheImpl.java
+++ b/java/com/google/gerrit/server/project/ProjectCacheImpl.java
@@ -81,7 +81,13 @@
 import org.eclipse.jgit.storage.file.FileBasedConfig;
 import org.eclipse.jgit.util.FS;
 
-/** Cache of project information, including access rights. */
+/**
+ * Cache of project information, including access rights.
+ *
+ * <p>The data of a project is the project's project.config in refs/meta/config parsed out as an
+ * immutable value. It's keyed purely by the refs/meta/config SHA-1. We also cache the same value
+ * keyed by name. The latter mapping can become outdated, so data must be evicted explicitly.
+ */
 @Singleton
 public class ProjectCacheImpl implements ProjectCache {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
diff --git a/java/com/google/gerrit/server/project/ProjectConfig.java b/java/com/google/gerrit/server/project/ProjectConfig.java
index 5ac5ac7..0d710b9 100644
--- a/java/com/google/gerrit/server/project/ProjectConfig.java
+++ b/java/com/google/gerrit/server/project/ProjectConfig.java
@@ -112,6 +112,7 @@
   public static final String KEY_ALLOW_POST_SUBMIT = "allowPostSubmit";
   public static final String KEY_IGNORE_SELF_APPROVAL = "ignoreSelfApproval";
   public static final String KEY_COPY_ANY_SCORE = "copyAnyScore";
+  public static final String KEY_COPY_CONDITION = "copyCondition";
   public static final String KEY_COPY_MAX_SCORE = "copyMaxScore";
   public static final String KEY_COPY_ALL_SCORES_IF_LIST_OF_FILES_DID_NOT_CHANGE =
       "copyAllScoresIfListOfFilesDidNotChange";
@@ -125,12 +126,12 @@
   public static final String KEY_CAN_OVERRIDE = "canOverride";
   public static final String KEY_BRANCH = "branch";
 
-  public static final String SUBMIT_REQUIREMENT = "submitRequirement";
+  public static final String SUBMIT_REQUIREMENT = "submit-requirement";
   public static final String KEY_SR_NAME = "name";
   public static final String KEY_SR_DESCRIPTION = "description";
-  public static final String KEY_SR_APPLICABILITY_EXPRESSION = "applicabilityExpression";
-  public static final String KEY_SR_BLOCKING_EXPRESSION = "blockingExpression";
-  public static final String KEY_SR_OVERRIDE_EXPRESSION = "overrideExpression";
+  public static final String KEY_SR_APPLICABILITY_EXPRESSION = "applicableIf";
+  public static final String KEY_SR_SUBMITTABILITY_EXPRESSION = "submittableIf";
+  public static final String KEY_SR_OVERRIDE_EXPRESSION = "overrideIf";
   public static final String KEY_SR_OVERRIDE_IN_CHILD_PROJECTS = "canOverrideInChildProjects";
 
   public static final String KEY_MATCH = "match";
@@ -528,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);
@@ -984,7 +989,7 @@
       lowerNames.put(lower, name);
       String description = rc.getString(SUBMIT_REQUIREMENT, name, KEY_SR_DESCRIPTION);
       String appExpr = rc.getString(SUBMIT_REQUIREMENT, name, KEY_SR_APPLICABILITY_EXPRESSION);
-      String blockExpr = rc.getString(SUBMIT_REQUIREMENT, name, KEY_SR_BLOCKING_EXPRESSION);
+      String blockExpr = rc.getString(SUBMIT_REQUIREMENT, name, KEY_SR_SUBMITTABILITY_EXPRESSION);
       String overrideExpr = rc.getString(SUBMIT_REQUIREMENT, name, KEY_SR_OVERRIDE_EXPRESSION);
       boolean canInherit =
           rc.getBoolean(SUBMIT_REQUIREMENT, name, KEY_SR_OVERRIDE_IN_CHILD_PROJECTS, false);
@@ -994,7 +999,7 @@
             ValidationError.create(
                 PROJECT_CONFIG,
                 (String.format(
-                    "Submit requirement \"%s\" does not define a blocking expression."
+                    "Submit requirement \"%s\" does not define a submittability expression."
                         + " Skipping this requirement.",
                     name))));
         continue;
@@ -1008,7 +1013,7 @@
               .setName(name)
               .setDescription(Optional.ofNullable(description))
               .setApplicabilityExpression(SubmitRequirementExpression.of(appExpr))
-              .setBlockingExpression(SubmitRequirementExpression.create(blockExpr))
+              .setSubmittabilityExpression(SubmitRequirementExpression.create(blockExpr))
               .setOverrideExpression(SubmitRequirementExpression.of(overrideExpr))
               .setAllowOverrideInChildProjects(canInherit)
               .build();
@@ -1075,6 +1080,7 @@
                     KEY_FUNCTION, name, Joiner.on(", ").join(LabelFunction.ALL.keySet()))));
       }
       label.setFunction(function.orElse(null));
+      label.setCopyCondition(rc.getString(LABEL, name, KEY_COPY_CONDITION));
 
       if (!values.isEmpty()) {
         short dv = (short) rc.getInt(LABEL, name, KEY_DEFAULT_VALUE, 0);
@@ -1664,6 +1670,11 @@
         values.add(value.format().trim());
       }
       rc.setStringList(LABEL, name, KEY_VALUE, values);
+      if (label.getCopyCondition().isPresent()) {
+        rc.setString(LABEL, name, KEY_COPY_CONDITION, label.getCopyCondition().get());
+      } else {
+        rc.unset(LABEL, name, KEY_COPY_CONDITION);
+      }
 
       List<String> refPatterns = label.getRefPatterns();
       if (refPatterns != null && !refPatterns.isEmpty()) {
@@ -1694,19 +1705,19 @@
               SUBMIT_REQUIREMENT,
               name,
               KEY_SR_APPLICABILITY_EXPRESSION,
-              sr.applicabilityExpression().get().expression());
+              sr.applicabilityExpression().get().expressionString());
         }
         rc.setString(
             SUBMIT_REQUIREMENT,
             name,
-            KEY_SR_BLOCKING_EXPRESSION,
-            sr.blockingExpression().expression());
+            KEY_SR_SUBMITTABILITY_EXPRESSION,
+            sr.submittabilityExpression().expressionString());
         if (sr.overrideExpression().isPresent()) {
           rc.setString(
               SUBMIT_REQUIREMENT,
               name,
               KEY_SR_OVERRIDE_EXPRESSION,
-              sr.overrideExpression().get().expression());
+              sr.overrideExpression().get().expressionString());
         }
         rc.setBoolean(
             SUBMIT_REQUIREMENT,
diff --git a/java/com/google/gerrit/server/project/ProjectState.java b/java/com/google/gerrit/server/project/ProjectState.java
index 249eb35..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;
@@ -63,8 +65,8 @@
 import org.eclipse.jgit.lib.Config;
 
 /**
- * Cached information on a project. Must not contain any data derived from parents other than it's
- * immediate parent's {@link com.google.gerrit.entities.Project.NameKey}.
+ * State of a project, aggregated from the project and its parents. This is obtained from the {@link
+ * ProjectCache}. It should not be persisted across requests
  */
 public class ProjectState {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
@@ -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/ProjectsConsistencyChecker.java b/java/com/google/gerrit/server/project/ProjectsConsistencyChecker.java
index 780a24f..fca1b36 100644
--- a/java/com/google/gerrit/server/project/ProjectsConsistencyChecker.java
+++ b/java/com/google/gerrit/server/project/ProjectsConsistencyChecker.java
@@ -48,9 +48,7 @@
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gerrit.server.query.change.ChangeIdPredicate;
 import com.google.gerrit.server.query.change.ChangePredicates;
-import com.google.gerrit.server.query.change.CommitPredicate;
 import com.google.gerrit.server.update.RetryHelper;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -230,11 +228,11 @@
               }
 
               // Find changes that have a matching Change-Id.
-              predicates.add(new ChangeIdPredicate(changeId));
+              predicates.add(ChangePredicates.idPrefix(changeId));
             });
 
         // Find changes that have a matching commit.
-        predicates.add(new CommitPredicate(commit.name()));
+        predicates.add(ChangePredicates.commitPrefix(commit.name()));
       }
 
       if (!predicates.isEmpty()) {
diff --git a/java/com/google/gerrit/server/project/Reachable.java b/java/com/google/gerrit/server/project/Reachable.java
index 331b7da..2adebe7 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,15 @@
 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.errors.IncorrectObjectTypeException;
+import org.eclipse.jgit.errors.MissingObjectException;
 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 +77,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 (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 (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/project/SubmitRequirementsEvaluator.java b/java/com/google/gerrit/server/project/SubmitRequirementsEvaluator.java
new file mode 100644
index 0000000..65d9d9e
--- /dev/null
+++ b/java/com/google/gerrit/server/project/SubmitRequirementsEvaluator.java
@@ -0,0 +1,103 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.project;
+
+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.index.query.Predicate;
+import com.google.gerrit.index.query.QueryParseException;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.ChangeQueryBuilder;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.util.Optional;
+
+/** Evaluates submit requirements for different change data. */
+@Singleton
+public class SubmitRequirementsEvaluator {
+  private final Provider<ChangeQueryBuilder> changeQueryBuilderProvider;
+
+  @Inject
+  private SubmitRequirementsEvaluator(Provider<ChangeQueryBuilder> changeQueryBuilderProvider) {
+    this.changeQueryBuilderProvider = changeQueryBuilderProvider;
+  }
+
+  /**
+   * Validate a {@link SubmitRequirementExpression}. Callers who wish to validate submit
+   * requirements upon creation or update should use this method.
+   *
+   * @param expression entity containing the expression string.
+   * @throws QueryParseException the expression string contains invalid syntax and can't be parsed.
+   */
+  public void validateExpression(SubmitRequirementExpression expression)
+      throws QueryParseException {
+    changeQueryBuilderProvider.get().parse(expression.expressionString());
+  }
+
+  /** Evaluate a {@link SubmitRequirement} on a given {@link ChangeData}. */
+  public SubmitRequirementResult evaluate(SubmitRequirement sr, ChangeData cd) {
+    SubmitRequirementExpressionResult blockingResult =
+        evaluateExpression(sr.submittabilityExpression(), cd);
+
+    Optional<SubmitRequirementExpressionResult> applicabilityResult =
+        sr.applicabilityExpression().isPresent()
+            ? Optional.of(evaluateExpression(sr.applicabilityExpression().get(), cd))
+            : Optional.empty();
+
+    Optional<SubmitRequirementExpressionResult> overrideResult =
+        sr.overrideExpression().isPresent()
+            ? Optional.of(evaluateExpression(sr.overrideExpression().get(), cd))
+            : Optional.empty();
+
+    return SubmitRequirementResult.builder()
+        .submitRequirement(sr)
+        .patchSetCommitId(cd.currentPatchSet().commitId())
+        .submittabilityExpressionResult(blockingResult)
+        .applicabilityExpressionResult(applicabilityResult)
+        .overrideExpressionResult(overrideResult)
+        .build();
+  }
+
+  /** Evaluate a {@link SubmitRequirementExpression} using change data. */
+  public SubmitRequirementExpressionResult evaluateExpression(
+      SubmitRequirementExpression expression, ChangeData changeData) {
+    try {
+      Predicate<ChangeData> predicate =
+          changeQueryBuilderProvider.get().parse(expression.expressionString());
+      PredicateResult predicateResult = evaluatePredicateTree(predicate, changeData);
+      return SubmitRequirementExpressionResult.create(expression, predicateResult);
+    } catch (QueryParseException e) {
+      return SubmitRequirementExpressionResult.error(expression, e.getMessage());
+    }
+  }
+
+  /** Evaluate the predicate recursively using change data. */
+  private PredicateResult evaluatePredicateTree(
+      Predicate<ChangeData> predicate, ChangeData changeData) {
+    PredicateResult.Builder predicateResult =
+        PredicateResult.builder()
+            .predicateString(predicate.toString())
+            .status(predicate.asMatchable().match(changeData));
+    predicate
+        .getChildren()
+        .forEach(
+            c -> predicateResult.addChildPredicateResult(evaluatePredicateTree(c, changeData)));
+    return predicateResult.build();
+  }
+}
diff --git a/java/com/google/gerrit/server/query/account/AccountPredicates.java b/java/com/google/gerrit/server/query/account/AccountPredicates.java
index e4da946..8f94089 100644
--- a/java/com/google/gerrit/server/query/account/AccountPredicates.java
+++ b/java/com/google/gerrit/server/query/account/AccountPredicates.java
@@ -21,7 +21,6 @@
 import com.google.gerrit.index.FieldDef;
 import com.google.gerrit.index.Schema;
 import com.google.gerrit.index.query.IndexPredicate;
-import com.google.gerrit.index.query.Matchable;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.QueryBuilder;
 import com.google.gerrit.server.account.AccountState;
@@ -132,8 +131,7 @@
   }
 
   /** Predicate that is mapped to a field in the account index. */
-  static class AccountPredicate extends IndexPredicate<AccountState>
-      implements Matchable<AccountState> {
+  static class AccountPredicate extends IndexPredicate<AccountState> {
     AccountPredicate(FieldDef<AccountState, ?> def, String value) {
       super(def, value);
     }
@@ -141,16 +139,6 @@
     AccountPredicate(FieldDef<AccountState, ?> def, String name, String value) {
       super(def, name, value);
     }
-
-    @Override
-    public boolean match(AccountState object) {
-      return true;
-    }
-
-    @Override
-    public int getCost() {
-      return 1;
-    }
   }
 
   private AccountPredicates() {}
diff --git a/java/com/google/gerrit/server/query/approval/ApprovalContext.java b/java/com/google/gerrit/server/query/approval/ApprovalContext.java
new file mode 100644
index 0000000..3bf072a
--- /dev/null
+++ b/java/com/google/gerrit/server/query/approval/ApprovalContext.java
@@ -0,0 +1,55 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.query.approval;
+
+import static com.google.common.base.Preconditions.checkState;
+
+import com.google.auto.value.AutoValue;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.PatchSetApproval;
+import com.google.gerrit.extensions.client.ChangeKind;
+import com.google.gerrit.server.notedb.ChangeNotes;
+
+/** Entity representing all required information to match predicates for copying approvals. */
+@AutoValue
+public abstract class ApprovalContext {
+  /** Approval on the source patch set to be copied. */
+  public abstract PatchSetApproval patchSetApproval();
+
+  /** Target change and patch set for the approval. */
+  public abstract PatchSet.Id target();
+
+  /** {@link ChangeNotes} of the change in question. */
+  public abstract ChangeNotes changeNotes();
+
+  /** {@link ChangeKind} of the delta between the origin and target patch set. */
+  public abstract ChangeKind changeKind();
+
+  public static ApprovalContext create(
+      ChangeNotes changeNotes, PatchSetApproval psa, PatchSet.Id id, ChangeKind changeKind) {
+    checkState(
+        psa.patchSetId().changeId().equals(id.changeId()),
+        "approval and target must be the same change. got: %s, %s",
+        psa.patchSetId(),
+        id);
+    // TODO(ekempin): Use checkState to verify that psa.patchSetId().get() + 1 == id.get() so that
+    // it's ensured that approvals are only copied to the next consecutive patch set. To add back
+    // this verification https://gerrit-review.googlesource.com/c/gerrit/+/312633 can be reverted.
+    // As explained in the commit message of this change doing this check is only possible if there
+    // are no changes with gaps in patch set numbers. Since it's planned to fix-up old changes with
+    // gaps in patch set numbers, this todo is a reminder to add back the check once this is done.
+    return new AutoValue_ApprovalContext(psa, id, changeNotes, changeKind);
+  }
+}
diff --git a/java/com/google/gerrit/server/query/approval/ApprovalModule.java b/java/com/google/gerrit/server/query/approval/ApprovalModule.java
new file mode 100644
index 0000000..ff4d5ad
--- /dev/null
+++ b/java/com/google/gerrit/server/query/approval/ApprovalModule.java
@@ -0,0 +1,27 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.query.approval;
+
+import com.google.gerrit.extensions.config.FactoryModule;
+
+/** Module to bind logic related to approval copying. */
+public class ApprovalModule extends FactoryModule {
+
+  @Override
+  protected void configure() {
+    factory(MagicValuePredicate.Factory.class);
+    factory(UserInPredicate.Factory.class);
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/pgm/ReindexIT.java b/java/com/google/gerrit/server/query/approval/ApprovalPredicate.java
similarity index 60%
copy from javatests/com/google/gerrit/acceptance/pgm/ReindexIT.java
copy to java/com/google/gerrit/server/query/approval/ApprovalPredicate.java
index 223851e..a6f8153 100644
--- a/javatests/com/google/gerrit/acceptance/pgm/ReindexIT.java
+++ b/java/com/google/gerrit/server/query/approval/ApprovalPredicate.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2014 The Android Open Source Project
+// Copyright (C) 2021 The Android Open Source Project
 //
 // Licensed under the Apache License, Version 2.0 (the "License");
 // you may not use this file except in compliance with the License.
@@ -12,11 +12,15 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.acceptance.pgm;
+package com.google.gerrit.server.query.approval;
 
-import com.google.inject.Injector;
+import com.google.gerrit.index.query.Matchable;
+import com.google.gerrit.index.query.Predicate;
 
-public class ReindexIT extends AbstractReindexTests {
+public abstract class ApprovalPredicate extends Predicate<ApprovalContext>
+    implements Matchable<ApprovalContext> {
   @Override
-  public void configureIndex(Injector injector) {}
+  public int getCost() {
+    return 1;
+  }
 }
diff --git a/java/com/google/gerrit/server/query/approval/ApprovalQueryBuilder.java b/java/com/google/gerrit/server/query/approval/ApprovalQueryBuilder.java
new file mode 100644
index 0000000..819f319
--- /dev/null
+++ b/java/com/google/gerrit/server/query/approval/ApprovalQueryBuilder.java
@@ -0,0 +1,104 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.query.approval;
+
+import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.GroupDescription;
+import com.google.gerrit.extensions.client.ChangeKind;
+import com.google.gerrit.index.query.Predicate;
+import com.google.gerrit.index.query.QueryBuilder;
+import com.google.gerrit.index.query.QueryParseException;
+import com.google.gerrit.server.account.GroupControl;
+import com.google.gerrit.server.group.GroupResolver;
+import com.google.inject.Inject;
+import java.util.Arrays;
+
+public class ApprovalQueryBuilder extends QueryBuilder<ApprovalContext, ApprovalQueryBuilder> {
+  private static final QueryBuilder.Definition<ApprovalContext, ApprovalQueryBuilder> mydef =
+      new QueryBuilder.Definition<>(ApprovalQueryBuilder.class);
+
+  private final MagicValuePredicate.Factory magicValuePredicate;
+  private final UserInPredicate.Factory userInPredicate;
+  private final GroupResolver groupResolver;
+  private final GroupControl.Factory groupControl;
+  private final ListOfFilesUnchangedPredicate listOfFilesUnchangedPredicate;
+
+  @Inject
+  protected ApprovalQueryBuilder(
+      MagicValuePredicate.Factory magicValuePredicate,
+      UserInPredicate.Factory userInPredicate,
+      GroupResolver groupResolver,
+      GroupControl.Factory groupControl,
+      ListOfFilesUnchangedPredicate listOfFilesUnchangedPredicate) {
+    super(mydef, null);
+    this.magicValuePredicate = magicValuePredicate;
+    this.userInPredicate = userInPredicate;
+    this.groupResolver = groupResolver;
+    this.groupControl = groupControl;
+    this.listOfFilesUnchangedPredicate = listOfFilesUnchangedPredicate;
+  }
+
+  @Operator
+  public Predicate<ApprovalContext> changekind(String term) throws QueryParseException {
+    return new ChangeKindPredicate(toEnumValue(ChangeKind.class, term));
+  }
+
+  @Operator
+  public Predicate<ApprovalContext> is(String term) throws QueryParseException {
+    return magicValuePredicate.create(toEnumValue(MagicValuePredicate.MagicValue.class, term));
+  }
+
+  @Operator
+  public Predicate<ApprovalContext> approverin(String group) throws QueryParseException {
+    return userInPredicate.create(UserInPredicate.Field.APPROVER, parseGroupOrThrow(group));
+  }
+
+  @Operator
+  public Predicate<ApprovalContext> uploaderin(String group) throws QueryParseException {
+    return userInPredicate.create(UserInPredicate.Field.UPLOADER, parseGroupOrThrow(group));
+  }
+
+  @Operator
+  public Predicate<ApprovalContext> has(String value) throws QueryParseException {
+    if (value.equals("unchanged-files")) {
+      return listOfFilesUnchangedPredicate;
+    }
+    throw error(
+        String.format(
+            "'%s' is not a supported argument for has. only 'unchanged-files' is supported",
+            value));
+  }
+
+  private static <T extends Enum<T>> T toEnumValue(Class<T> clazz, String term)
+      throws QueryParseException {
+    try {
+      return Enum.valueOf(clazz, term.toUpperCase().replace('-', '_'));
+    } catch (IllegalArgumentException e) {
+      throw new QueryParseException(
+          String.format(
+              "%s is not a valid term. valid options: %s",
+              term, Arrays.asList(clazz.getEnumConstants())),
+          e);
+    }
+  }
+
+  private AccountGroup.UUID parseGroupOrThrow(String maybeUUID) throws QueryParseException {
+    GroupDescription.Basic g = groupResolver.parseId(maybeUUID);
+    if (g == null || !groupControl.controlFor(g).isVisible()) {
+      throw error("Group " + maybeUUID + " not found");
+    }
+    return g.getGroupUUID();
+  }
+}
diff --git a/java/com/google/gerrit/server/query/approval/ChangeKindPredicate.java b/java/com/google/gerrit/server/query/approval/ChangeKindPredicate.java
new file mode 100644
index 0000000..78711fd
--- /dev/null
+++ b/java/com/google/gerrit/server/query/approval/ChangeKindPredicate.java
@@ -0,0 +1,56 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.query.approval;
+
+import com.google.gerrit.extensions.client.ChangeKind;
+import com.google.gerrit.index.query.Predicate;
+import java.util.Collection;
+import java.util.Objects;
+
+/**
+ * Predicate that matches patch set approvals we want to copy if the diff between the old and new
+ * patch set is of a certain kind.
+ */
+public class ChangeKindPredicate extends ApprovalPredicate {
+  private final ChangeKind changeKind;
+
+  ChangeKindPredicate(ChangeKind changeKind) {
+    this.changeKind = changeKind;
+  }
+
+  @Override
+  public boolean match(ApprovalContext ctx) {
+    return ctx.changeKind().equals(changeKind);
+  }
+
+  @Override
+  public Predicate<ApprovalContext> copy(
+      Collection<? extends Predicate<ApprovalContext>> children) {
+    return new ChangeKindPredicate(changeKind);
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(changeKind);
+  }
+
+  @Override
+  public boolean equals(Object other) {
+    if (!(other instanceof ChangeKindPredicate)) {
+      return false;
+    }
+    return ((ChangeKindPredicate) other).changeKind.equals(changeKind);
+  }
+}
diff --git a/java/com/google/gerrit/server/query/approval/ListOfFilesUnchangedPredicate.java b/java/com/google/gerrit/server/query/approval/ListOfFilesUnchangedPredicate.java
new file mode 100644
index 0000000..459a8b0
--- /dev/null
+++ b/java/com/google/gerrit/server/query/approval/ListOfFilesUnchangedPredicate.java
@@ -0,0 +1,88 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.query.approval;
+
+import com.google.gerrit.entities.Patch.ChangeType;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.index.query.Predicate;
+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;
+import java.util.Map;
+import java.util.Objects;
+
+/** 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 DiffOperations diffOperations;
+
+  @Inject
+  public ListOfFilesUnchangedPredicate(DiffOperations diffOperations) {
+    this.diffOperations = diffOperations;
+  }
+
+  @Override
+  public boolean match(ApprovalContext ctx) {
+    PatchSet currentPatchset = ctx.changeNotes().getCurrentPatchSet();
+    Map.Entry<PatchSet.Id, PatchSet> priorPatchSet =
+        ctx.changeNotes().getPatchSets().lowerEntry(currentPatchset.id());
+    try {
+      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 "
+              + "copyAllIfListOfFilesDidNotChange",
+          ex);
+    }
+  }
+
+  public boolean match(Map<String, FileDiffOutput> modifiedFiles) {
+    return modifiedFiles.values().stream()
+        .noneMatch(
+            p ->
+                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(diffOperations);
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(diffOperations);
+  }
+
+  @Override
+  public boolean equals(Object other) {
+    if (!(other instanceof ListOfFilesUnchangedPredicate)) {
+      return false;
+    }
+    ListOfFilesUnchangedPredicate o = (ListOfFilesUnchangedPredicate) other;
+    return Objects.equals(o.diffOperations, diffOperations);
+  }
+}
diff --git a/java/com/google/gerrit/server/query/approval/MagicValuePredicate.java b/java/com/google/gerrit/server/query/approval/MagicValuePredicate.java
new file mode 100644
index 0000000..2924e6e
--- /dev/null
+++ b/java/com/google/gerrit/server/query/approval/MagicValuePredicate.java
@@ -0,0 +1,96 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.query.approval;
+
+import com.google.gerrit.entities.LabelId;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.index.query.Predicate;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+import java.util.Collection;
+import java.util.Objects;
+
+/** Predicate that matches patch set approvals we want to copy based on the value. */
+public class MagicValuePredicate extends ApprovalPredicate {
+  enum MagicValue {
+    MIN,
+    MAX,
+    ANY
+  }
+
+  public interface Factory {
+    MagicValuePredicate create(MagicValue value);
+  }
+
+  private final MagicValue value;
+  private final ProjectCache projectCache;
+
+  @Inject
+  MagicValuePredicate(ProjectCache projectCache, @Assisted MagicValue value) {
+    this.projectCache = projectCache;
+    this.value = value;
+  }
+
+  @Override
+  public boolean match(ApprovalContext ctx) {
+    short pValue;
+    switch (value) {
+      case ANY:
+        return true;
+      case MIN:
+        pValue =
+            getLabelType(ctx.changeNotes().getProjectName(), ctx.patchSetApproval().labelId())
+                .getMaxNegative();
+        break;
+      case MAX:
+        pValue =
+            getLabelType(ctx.changeNotes().getProjectName(), ctx.patchSetApproval().labelId())
+                .getMaxPositive();
+        break;
+      default:
+        throw new IllegalArgumentException("unrecognized label value: " + value);
+    }
+    return pValue == ctx.patchSetApproval().value();
+  }
+
+  private LabelType getLabelType(Project.NameKey project, LabelId labelId) {
+    return projectCache
+        .get(project)
+        .orElseThrow(() -> new IllegalStateException(project + " absent"))
+        .getLabelTypes()
+        .byLabel(labelId);
+  }
+
+  @Override
+  public Predicate<ApprovalContext> copy(
+      Collection<? extends Predicate<ApprovalContext>> children) {
+    return new MagicValuePredicate(projectCache, value);
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(value);
+  }
+
+  @Override
+  public boolean equals(Object other) {
+    if (!(other instanceof MagicValuePredicate)) {
+      return false;
+    }
+    return ((MagicValuePredicate) other).value.equals(value);
+  }
+}
diff --git a/java/com/google/gerrit/server/query/approval/UserInPredicate.java b/java/com/google/gerrit/server/query/approval/UserInPredicate.java
new file mode 100644
index 0000000..7e16fcb
--- /dev/null
+++ b/java/com/google/gerrit/server/query/approval/UserInPredicate.java
@@ -0,0 +1,71 @@
+package com.google.gerrit.server.query.approval;
+
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.index.query.Predicate;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+import java.util.Collection;
+import java.util.Objects;
+
+/** Predicate that matches group memberships of users such as uploader or approver. */
+public class UserInPredicate extends ApprovalPredicate {
+  interface Factory {
+    UserInPredicate create(Field field, AccountGroup.UUID group);
+  }
+
+  enum Field {
+    UPLOADER,
+    APPROVER
+  }
+
+  private final IdentifiedUser.GenericFactory identifiedUserFactory;
+  private final Field field;
+  private final AccountGroup.UUID group;
+
+  @Inject
+  UserInPredicate(
+      IdentifiedUser.GenericFactory identifiedUserFactory,
+      @Assisted Field field,
+      @Assisted AccountGroup.UUID group) {
+    this.identifiedUserFactory = identifiedUserFactory;
+    this.field = field;
+    this.group = group;
+  }
+
+  @Override
+  public boolean match(ApprovalContext ctx) {
+    Account.Id accountId;
+    if (field == Field.UPLOADER) {
+      PatchSet patchSet = ctx.changeNotes().getPatchSets().get(ctx.target());
+      accountId = patchSet.uploader();
+    } else if (field == Field.APPROVER) {
+      accountId = ctx.patchSetApproval().accountId();
+    } else {
+      throw new IllegalStateException("unknown field in group membership check: " + field);
+    }
+    return identifiedUserFactory.create(accountId).getEffectiveGroups().contains(group);
+  }
+
+  @Override
+  public Predicate<ApprovalContext> copy(
+      Collection<? extends Predicate<ApprovalContext>> children) {
+    return new UserInPredicate(identifiedUserFactory, field, group);
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(field, group);
+  }
+
+  @Override
+  public boolean equals(Object other) {
+    if (!(other instanceof UserInPredicate)) {
+      return false;
+    }
+    UserInPredicate o = (UserInPredicate) other;
+    return Objects.equals(o.field, field) && Objects.equals(o.group, group);
+  }
+}
diff --git a/java/com/google/gerrit/server/query/change/AuthorPredicate.java b/java/com/google/gerrit/server/query/change/AuthorPredicate.java
deleted file mode 100644
index 6e362ad..0000000
--- a/java/com/google/gerrit/server/query/change/AuthorPredicate.java
+++ /dev/null
@@ -1,31 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.query.change;
-
-import static com.google.gerrit.server.index.change.ChangeField.AUTHOR;
-import static com.google.gerrit.server.query.change.ChangeQueryBuilder.FIELD_AUTHOR;
-
-import com.google.gerrit.server.index.change.ChangeField;
-
-public class AuthorPredicate extends ChangeIndexPredicate {
-  public AuthorPredicate(String value) {
-    super(AUTHOR, FIELD_AUTHOR, value.toLowerCase());
-  }
-
-  @Override
-  public boolean match(ChangeData object) {
-    return ChangeField.getAuthorParts(object).contains(getValue().toLowerCase());
-  }
-}
diff --git a/java/com/google/gerrit/server/query/change/ChangeData.java b/java/com/google/gerrit/server/query/change/ChangeData.java
index 84c6de0..f912250 100644
--- a/java/com/google/gerrit/server/query/change/ChangeData.java
+++ b/java/com/google/gerrit/server/query/change/ChangeData.java
@@ -53,12 +53,13 @@
 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;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.index.RefState;
-import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.CurrentUser;
@@ -68,6 +69,7 @@
 import com.google.gerrit.server.ReviewerStatusUpdate;
 import com.google.gerrit.server.StarredChangesUtil;
 import com.google.gerrit.server.StarredChangesUtil.StarRef;
+import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.change.CommentThread;
 import com.google.gerrit.server.change.CommentThreads;
 import com.google.gerrit.server.change.MergeabilityCache;
@@ -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;
@@ -321,12 +327,16 @@
   private SubmitTypeRecord submitTypeRecord;
   private Boolean mergeable;
   private Set<String> hashtags;
-  /** Map from {@link Account.Id} to the tip of the edit ref for this change and a given user. */
+  /**
+   * Map from {@link com.google.gerrit.entities.Account.Id} to the tip of the edit ref for this
+   * change and a given user.
+   */
   private Table<Account.Id, PatchSet.Id, ObjectId> editsByUser;
 
   private Set<Account.Id> reviewedBy;
   /**
-   * Map from {@link Account.Id} to the tip of the draft comments ref for this change and the user.
+   * Map from {@link com.google.gerrit.entities.Account.Id} to the tip of the draft comments ref for
+   * this change and the user.
    */
   private Map<Account.Id, ObjectId> draftsByUser;
 
@@ -365,6 +375,7 @@
       ProjectCache projectCache,
       TrackingFooters trackingFooters,
       PureRevert pureRevert,
+      SubmitRequirementsEvaluator submitRequirementsEvaluator,
       SubmitRuleEvaluator.Factory submitRuleEvaluatorFactory,
       @Assisted Project.NameKey project,
       @Assisted Change.Id id,
@@ -384,6 +395,7 @@
     this.starredChangesUtil = starredChangesUtil;
     this.trackingFooters = trackingFooters;
     this.pureRevert = pureRevert;
+    this.submitRequirementsEvaluator = submitRequirementsEvaluator;
     this.submitRuleEvaluatorFactory = submitRuleEvaluatorFactory;
 
     this.project = project;
@@ -586,8 +598,7 @@
       } else {
         try {
           currentApprovals =
-              ImmutableList.copyOf(
-                  approvalsUtil.byPatchSet(notes(), c.currentPatchSetId(), null, null));
+              ImmutableList.copyOf(approvalsUtil.byPatchSet(notes(), c.currentPatchSetId()));
         } catch (StorageException e) {
           if (e.getCause() instanceof NoSuchChangeException) {
             currentApprovals = Collections.emptyList();
@@ -921,6 +932,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
@@ -1323,7 +1349,7 @@
       }
 
       draftsByUser = new HashMap<>();
-      for (Ref ref : commentsUtil.getDraftRefs(notes.getChangeId())) {
+      for (Ref ref : commentsUtil.getDraftRefs(notes().getChangeId())) {
         Account.Id account = Account.Id.fromRefSuffix(ref.getName());
         if (account != null
             // Double-check that any drafts exist for this user after
diff --git a/java/com/google/gerrit/server/query/change/ChangeIdPredicate.java b/java/com/google/gerrit/server/query/change/ChangeIdPredicate.java
deleted file mode 100644
index f06d1f2..0000000
--- a/java/com/google/gerrit/server/query/change/ChangeIdPredicate.java
+++ /dev/null
@@ -1,39 +0,0 @@
-// Copyright (C) 2010 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.query.change;
-
-import com.google.gerrit.entities.Change;
-import com.google.gerrit.server.index.change.ChangeField;
-
-/** Predicate over Change-Id strings (aka Change.Key). */
-public class ChangeIdPredicate extends ChangeIndexPredicate {
-  public ChangeIdPredicate(String id) {
-    super(ChangeField.ID, ChangeQueryBuilder.FIELD_CHANGE, id);
-  }
-
-  @Override
-  public boolean match(ChangeData cd) {
-    Change change = cd.change();
-    if (change == null) {
-      return false;
-    }
-
-    String key = change.getKey().get();
-    if (key.equals(getValue()) || key.startsWith(getValue())) {
-      return true;
-    }
-    return false;
-  }
-}
diff --git a/java/com/google/gerrit/server/query/change/ChangeIndexPredicate.java b/java/com/google/gerrit/server/query/change/ChangeIndexPredicate.java
index c6bba88..ccd4109 100644
--- a/java/com/google/gerrit/server/query/change/ChangeIndexPredicate.java
+++ b/java/com/google/gerrit/server/query/change/ChangeIndexPredicate.java
@@ -14,17 +14,12 @@
 
 package com.google.gerrit.server.query.change;
 
-import com.google.common.primitives.Ints;
 import com.google.gerrit.index.FieldDef;
-import com.google.gerrit.index.FieldType;
 import com.google.gerrit.index.query.IndexPredicate;
-import com.google.gerrit.index.query.Matchable;
 import com.google.gerrit.index.query.Predicate;
-import java.util.Objects;
 
 /** Predicate that is mapped to a field in the change index. */
-public class ChangeIndexPredicate extends IndexPredicate<ChangeData>
-    implements Matchable<ChangeData> {
+public class ChangeIndexPredicate extends IndexPredicate<ChangeData> {
   /**
    * Returns an index predicate that matches no changes in the index.
    *
@@ -44,34 +39,4 @@
   protected ChangeIndexPredicate(FieldDef<ChangeData, ?> def, String name, String value) {
     super(def, name, value);
   }
-
-  @Override
-  public boolean match(ChangeData cd) {
-    if (getField().isRepeatable()) {
-      Iterable<Object> values = (Iterable<Object>) getField().get(cd);
-      for (Object v : values) {
-        if (matchesSingleObject(v)) {
-          return true;
-        }
-      }
-      return false;
-    } else {
-      return matchesSingleObject(getField().get(cd));
-    }
-  }
-
-  @Override
-  public int getCost() {
-    return 1;
-  }
-
-  private boolean matchesSingleObject(Object fieldValueFromObject) {
-    String fieldTypeName = getField().getType().getName();
-    if (fieldTypeName.equals(FieldType.INTEGER.getName())) {
-      return Objects.equals(fieldValueFromObject, Ints.tryParse(value));
-    } else if (fieldTypeName.equals(FieldType.EXACT.getName())) {
-      return Objects.equals(fieldValueFromObject, value);
-    }
-    throw new UnsupportedOperationException("match function must be provided in subclass");
-  }
 }
diff --git a/java/com/google/gerrit/server/query/change/ChangePredicates.java b/java/com/google/gerrit/server/query/change/ChangePredicates.java
index 7ccc7b6..044d276 100644
--- a/java/com/google/gerrit/server/query/change/ChangePredicates.java
+++ b/java/com/google/gerrit/server/query/change/ChangePredicates.java
@@ -19,6 +19,7 @@
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
+import com.google.gerrit.git.ObjectIds;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.server.change.HashtagsUtil;
 import com.google.gerrit.server.index.change.ChangeField;
@@ -32,22 +33,24 @@
   private ChangePredicates() {}
 
   /**
-   * Returns a predicate that matches changes where the provided {@link Account.Id} is in the
-   * attention set.
+   * Returns a predicate that matches changes where the provided {@link
+   * com.google.gerrit.entities.Account.Id} is in the attention set.
    */
   public static Predicate<ChangeData> attentionSet(Account.Id id) {
     return new ChangeIndexPredicate(ChangeField.ATTENTION_SET_USERS, id.toString());
   }
 
   /**
-   * Returns a predicate that matches changes that are assigned to the provided {@link Account.Id}.
+   * Returns a predicate that matches changes that are assigned to the provided {@link
+   * com.google.gerrit.entities.Account.Id}.
    */
   public static Predicate<ChangeData> assignee(Account.Id id) {
     return new ChangeIndexPredicate(ChangeField.ASSIGNEE, id.toString());
   }
 
   /**
-   * Returns a predicate that matches changes that are a revert of the provided {@link Change.Id}.
+   * Returns a predicate that matches changes that are a revert of the provided {@link
+   * com.google.gerrit.entities.Change.Id}.
    */
   public static Predicate<ChangeData> revertOf(Change.Id revertOf) {
     return new ChangeIndexPredicate(ChangeField.REVERT_OF, revertOf.toString());
@@ -55,23 +58,23 @@
 
   /**
    * Returns a predicate that matches changes that have a comment authored by the provided {@link
-   * Account.Id}.
+   * com.google.gerrit.entities.Account.Id}.
    */
   public static Predicate<ChangeData> commentBy(Account.Id id) {
     return new ChangeIndexPredicate(ChangeField.COMMENTBY, id.toString());
   }
 
   /**
-   * Returns a predicate that matches changes where the provided {@link Account.Id} has a pending
-   * change edit.
+   * Returns a predicate that matches changes where the provided {@link
+   * com.google.gerrit.entities.Account.Id} has a pending change edit.
    */
   public static Predicate<ChangeData> editBy(Account.Id id) {
     return new ChangeIndexPredicate(ChangeField.EDITBY, id.toString());
   }
 
   /**
-   * Returns a predicate that matches changes where the provided {@link Account.Id} has a pending
-   * draft comment.
+   * Returns a predicate that matches changes where the provided {@link
+   * com.google.gerrit.entities.Account.Id} has a pending draft comment.
    */
   public static Predicate<ChangeData> draftBy(Account.Id id) {
     return new ChangeIndexPredicate(ChangeField.DRAFTBY, id.toString());
@@ -79,7 +82,7 @@
 
   /**
    * Returns a predicate that matches changes that were reviewed by any of the provided {@link
-   * Account.Id}.
+   * com.google.gerrit.entities.Account.Id}.
    */
   public static Predicate<ChangeData> reviewedBy(Collection<Account.Id> ids) {
     List<Predicate<ChangeData>> predicates = new ArrayList<>(ids.size());
@@ -95,26 +98,35 @@
         new ChangeIndexPredicate(ChangeField.REVIEWEDBY, ChangeField.NOT_REVIEWED.toString()));
   }
 
-  /** Returns a predicate that matches the change with the provided {@link Change.Id}. */
+  /**
+   * Returns a predicate that matches the change with the provided {@link
+   * com.google.gerrit.entities.Change.Id}.
+   */
   public static Predicate<ChangeData> id(Change.Id id) {
     return new ChangeIndexPredicate(
         ChangeField.LEGACY_ID, ChangeQueryBuilder.FIELD_CHANGE, id.toString());
   }
 
-  /** Returns a predicate that matches the change with the provided {@link Change.Id}. */
+  /**
+   * Returns a predicate that matches the change with the provided {@link
+   * com.google.gerrit.entities.Change.Id}.
+   */
   public static Predicate<ChangeData> idStr(Change.Id id) {
     return new ChangeIndexPredicate(
         ChangeField.LEGACY_ID_STR, ChangeQueryBuilder.FIELD_CHANGE, id.toString());
   }
 
-  /** Returns a predicate that matches changes owned by the provided {@link Account.Id}. */
+  /**
+   * Returns a predicate that matches changes owned by the provided {@link
+   * com.google.gerrit.entities.Account.Id}.
+   */
   public static Predicate<ChangeData> owner(Account.Id id) {
     return new ChangeIndexPredicate(ChangeField.OWNER, id.toString());
   }
 
   /**
    * Returns a predicate that matches changes that are a cherry pick of the provided {@link
-   * Change.Id}.
+   * com.google.gerrit.entities.Change.Id}.
    */
   public static Predicate<ChangeData> cherryPickOf(Change.Id id) {
     return new ChangeIndexPredicate(ChangeField.CHERRY_PICK_OF_CHANGE, id.toString());
@@ -122,7 +134,7 @@
 
   /**
    * Returns a predicate that matches changes that are a cherry pick of the provided {@link
-   * PatchSet.Id}.
+   * com.google.gerrit.entities.PatchSet.Id}.
    */
   public static Predicate<ChangeData> cherryPickOf(PatchSet.Id psId) {
     return Predicate.and(
@@ -130,7 +142,10 @@
         new ChangeIndexPredicate(ChangeField.CHERRY_PICK_OF_PATCHSET, String.valueOf(psId.get())));
   }
 
-  /** Returns a predicate that matches changes in the provided {@link Project.NameKey}. */
+  /**
+   * Returns a predicate that matches changes in the provided {@link
+   * com.google.gerrit.entities.Project.NameKey}.
+   */
   public static Predicate<ChangeData> project(Project.NameKey id) {
     return new ChangeIndexPredicate(ChangeField.PROJECT, id.get());
   }
@@ -145,6 +160,11 @@
     return new ChangeIndexPredicate(ChangeField.EXACT_TOPIC, topic);
   }
 
+  /** Returns a predicate that matches changes in the provided {@code topic}. */
+  public static Predicate<ChangeData> fuzzyTopic(String topic) {
+    return new ChangeIndexPredicate(ChangeField.FUZZY_TOPIC, topic);
+  }
+
   /** Returns a predicate that matches changes submitted in the provided {@code changeSet}. */
   public static Predicate<ChangeData> submissionId(String changeSet) {
     return new ChangeIndexPredicate(ChangeField.SUBMISSIONID, changeSet);
@@ -162,6 +182,13 @@
         ChangeField.HASHTAG, HashtagsUtil.cleanupHashtag(hashtag).toLowerCase());
   }
 
+  /** Returns a predicate that matches changes tagged with the provided {@code hashtag}. */
+  public static Predicate<ChangeData> fuzzyHashtag(String hashtag) {
+    // Use toLowerCase without locale to match behavior in ChangeField.
+    return new ChangeIndexPredicate(
+        ChangeField.FUZZY_HASHTAG, HashtagsUtil.cleanupHashtag(hashtag).toLowerCase());
+  }
+
   /** Returns a predicate that matches changes that modified the provided {@code file}. */
   public static Predicate<ChangeData> file(ChangeQueryBuilder.Arguments args, String file) {
     Predicate<ChangeData> eqPath = path(file);
@@ -204,6 +231,11 @@
     return new ChangeIndexPredicate(ChangeField.EXACT_AUTHOR, exactAuthor.toLowerCase(Locale.US));
   }
 
+  /** Returns a predicate that matches changes authored by the provided {@code author}. */
+  public static Predicate<ChangeData> author(String author) {
+    return new ChangeIndexPredicate(ChangeField.AUTHOR, author);
+  }
+
   /**
    * Returns a predicate that matches changes where the patch set was committed by {@code
    * exactCommitter}.
@@ -212,4 +244,52 @@
     return new ChangeIndexPredicate(
         ChangeField.EXACT_COMMITTER, exactCommitter.toLowerCase(Locale.US));
   }
+
+  /**
+   * Returns a predicate that matches changes where the patch set was committed by {@code
+   * committer}.
+   */
+  public static Predicate<ChangeData> committer(String comitter) {
+    return new ChangeIndexPredicate(ChangeField.COMMITTER, comitter.toLowerCase(Locale.US));
+  }
+
+  /** Returns a predicate that matches changes whose ID starts with the provided {@code id}. */
+  public static Predicate<ChangeData> idPrefix(String id) {
+    return new ChangeIndexPredicate(ChangeField.ID, id);
+  }
+
+  /**
+   * Returns a predicate that matches changes in a project that has the provided {@code prefix} in
+   * its name.
+   */
+  public static Predicate<ChangeData> projectPrefix(String prefix) {
+    return new ChangeIndexPredicate(ChangeField.PROJECTS, prefix);
+  }
+
+  /**
+   * Returns a predicate that matches changes where a patch set has the provided {@code commitId}
+   * either as prefix or as full {@link org.eclipse.jgit.lib.ObjectId}.
+   */
+  public static Predicate<ChangeData> commitPrefix(String commitId) {
+    if (commitId.length() == ObjectIds.STR_LEN) {
+      return new ChangeIndexPredicate(ChangeField.EXACT_COMMIT, commitId);
+    }
+    return new ChangeIndexPredicate(ChangeField.COMMIT, commitId);
+  }
+
+  /**
+   * Returns a predicate that matches changes where the provided {@code message} appears in the
+   * commit message. Uses full-text search semantics.
+   */
+  public static Predicate<ChangeData> message(String message) {
+    return new ChangeIndexPredicate(ChangeField.COMMIT_MESSAGE, message);
+  }
+
+  /**
+   * Returns a predicate that matches changes where the provided {@code comment} appears in any
+   * comment on any patch set of the change. Uses full-text search semantics.
+   */
+  public static Predicate<ChangeData> comment(String comment) {
+    return new ChangeIndexPredicate(ChangeField.COMMENT, comment);
+  }
 }
diff --git a/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java b/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
index 1963ca9..131de74 100644
--- a/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
+++ b/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
@@ -528,7 +528,7 @@
       return Predicate.and(
           project(triplet.get().project().get()),
           branch(triplet.get().branch().branch()),
-          new ChangeIdPredicate(parseChangeId(triplet.get().id().get())));
+          ChangePredicates.idPrefix(parseChangeId(triplet.get().id().get())));
     }
     if (PAT_LEGACY_ID.matcher(query).matches()) {
       Integer id = Ints.tryParse(query);
@@ -538,7 +538,7 @@
             : ChangePredicates.idStr(Change.id(id));
       }
     } else if (PAT_CHANGE_ID.matcher(query).matches()) {
-      return new ChangeIdPredicate(parseChangeId(query));
+      return ChangePredicates.idPrefix(parseChangeId(query));
     }
 
     throw new QueryParseException("Invalid change format");
@@ -546,7 +546,7 @@
 
   @Operator
   public Predicate<ChangeData> comment(String value) {
-    return new CommentPredicate(args.index, value);
+    return ChangePredicates.comment(value);
   }
 
   @Operator
@@ -705,7 +705,7 @@
 
   @Operator
   public Predicate<ChangeData> commit(String id) {
-    return new CommitPredicate(id);
+    return ChangePredicates.commitPrefix(id);
   }
 
   @Operator
@@ -733,7 +733,7 @@
 
   @Operator
   public Predicate<ChangeData> projects(String name) {
-    return new ProjectPrefixPredicate(name);
+    return ChangePredicates.projectPrefix(name);
   }
 
   @Operator
@@ -807,7 +807,7 @@
       throw new QueryParseException(
           "'inhashtag' operator is not supported by change index version");
     }
-    return new FuzzyHashtagPredicate(hashtag, args.index);
+    return ChangePredicates.fuzzyHashtag(hashtag);
   }
 
   @Operator
@@ -823,7 +823,7 @@
     if (name.isEmpty()) {
       return ChangePredicates.exactTopic(name);
     }
-    return new FuzzyTopicPredicate(name, args.index);
+    return ChangePredicates.fuzzyTopic(name);
   }
 
   @Operator
@@ -973,7 +973,7 @@
     int eq = name.indexOf('=');
     if (args.getSchema().hasField(ChangeField.SUBMIT_RECORD) && eq > 0) {
       String statusName = name.substring(eq + 1).toUpperCase();
-      if (!isInt(statusName)) {
+      if (!isInt(statusName) && !MagicLabelValue.tryParse(statusName).isPresent()) {
         SubmitRecord.Label.Status status =
             Enums.getIfPresent(SubmitRecord.Label.Status.class, statusName).orNull();
         if (status == null) {
@@ -998,7 +998,7 @@
 
   @Operator
   public Predicate<ChangeData> message(String text) {
-    return new MessagePredicate(args.index, text);
+    return ChangePredicates.message(text);
   }
 
   @Operator
@@ -1390,18 +1390,18 @@
   public Predicate<ChangeData> author(String who) throws QueryParseException {
     if (args.getSchema().hasField(ChangeField.EXACT_AUTHOR)) {
       return getAuthorOrCommitterPredicate(
-          who.trim(), ChangePredicates::exactAuthor, AuthorPredicate::new);
+          who.trim(), ChangePredicates::exactAuthor, ChangePredicates::author);
     }
-    return getAuthorOrCommitterFullTextPredicate(who.trim(), AuthorPredicate::new);
+    return getAuthorOrCommitterFullTextPredicate(who.trim(), ChangePredicates::author);
   }
 
   @Operator
   public Predicate<ChangeData> committer(String who) throws QueryParseException {
     if (args.getSchema().hasField(ChangeField.EXACT_COMMITTER)) {
       return getAuthorOrCommitterPredicate(
-          who.trim(), ChangePredicates::exactCommitter, CommitterPredicate::new);
+          who.trim(), ChangePredicates::exactCommitter, ChangePredicates::committer);
     }
-    return getAuthorOrCommitterFullTextPredicate(who.trim(), CommitterPredicate::new);
+    return getAuthorOrCommitterFullTextPredicate(who.trim(), ChangePredicates::committer);
   }
 
   @Operator
diff --git a/java/com/google/gerrit/server/query/change/CommentPredicate.java b/java/com/google/gerrit/server/query/change/CommentPredicate.java
deleted file mode 100644
index 7463324..0000000
--- a/java/com/google/gerrit/server/query/change/CommentPredicate.java
+++ /dev/null
@@ -1,54 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.query.change;
-
-import com.google.gerrit.entities.Change;
-import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.index.query.Predicate;
-import com.google.gerrit.index.query.QueryParseException;
-import com.google.gerrit.server.index.change.ChangeField;
-import com.google.gerrit.server.index.change.ChangeIndex;
-import com.google.gerrit.server.index.change.IndexedChangeQuery;
-
-public class CommentPredicate extends ChangeIndexPredicate {
-  protected final ChangeIndex index;
-
-  public CommentPredicate(ChangeIndex index, String value) {
-    super(ChangeField.COMMENT, value);
-    this.index = index;
-  }
-
-  @Override
-  public boolean match(ChangeData object) {
-    try {
-      Change.Id id = object.getId();
-      Predicate<ChangeData> p =
-          Predicate.and(
-              index.getSchema().useLegacyNumericFields()
-                  ? ChangePredicates.id(id)
-                  : ChangePredicates.idStr(id),
-              this);
-      for (ChangeData cData : index.getSource(p, IndexedChangeQuery.oneResult()).read()) {
-        if (cData.getId().equals(id)) {
-          return true;
-        }
-      }
-    } catch (QueryParseException e) {
-      throw new StorageException(e);
-    }
-
-    return false;
-  }
-}
diff --git a/java/com/google/gerrit/server/query/change/CommitPredicate.java b/java/com/google/gerrit/server/query/change/CommitPredicate.java
deleted file mode 100644
index 2b3b345..0000000
--- a/java/com/google/gerrit/server/query/change/CommitPredicate.java
+++ /dev/null
@@ -1,54 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.query.change;
-
-import static com.google.gerrit.git.ObjectIds.matchesAbbreviation;
-import static com.google.gerrit.server.index.change.ChangeField.COMMIT;
-import static com.google.gerrit.server.index.change.ChangeField.EXACT_COMMIT;
-
-import com.google.gerrit.entities.PatchSet;
-import com.google.gerrit.git.ObjectIds;
-import com.google.gerrit.index.FieldDef;
-
-public class CommitPredicate extends ChangeIndexPredicate {
-  static FieldDef<ChangeData, ?> commitField(String id) {
-    if (id.length() == ObjectIds.STR_LEN) {
-      return EXACT_COMMIT;
-    }
-    return COMMIT;
-  }
-
-  public CommitPredicate(String id) {
-    super(commitField(id), id);
-  }
-
-  @Override
-  public boolean match(ChangeData object) {
-    String id = getValue().toLowerCase();
-    for (PatchSet p : object.patchSets()) {
-      if (equals(p, id)) {
-        return true;
-      }
-    }
-    return false;
-  }
-
-  protected boolean equals(PatchSet p, String id) {
-    if (getField() == EXACT_COMMIT) {
-      return p.commitId().name().equals(id);
-    }
-    return matchesAbbreviation(p.commitId(), id);
-  }
-}
diff --git a/java/com/google/gerrit/server/query/change/CommitterPredicate.java b/java/com/google/gerrit/server/query/change/CommitterPredicate.java
deleted file mode 100644
index 65034a2..0000000
--- a/java/com/google/gerrit/server/query/change/CommitterPredicate.java
+++ /dev/null
@@ -1,31 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.query.change;
-
-import static com.google.gerrit.server.index.change.ChangeField.COMMITTER;
-import static com.google.gerrit.server.query.change.ChangeQueryBuilder.FIELD_COMMITTER;
-
-import com.google.gerrit.server.index.change.ChangeField;
-
-public class CommitterPredicate extends ChangeIndexPredicate {
-  public CommitterPredicate(String value) {
-    super(COMMITTER, FIELD_COMMITTER, value.toLowerCase());
-  }
-
-  @Override
-  public boolean match(ChangeData object) {
-    return ChangeField.getCommitterParts(object).contains(getValue().toLowerCase());
-  }
-}
diff --git a/java/com/google/gerrit/server/query/change/FuzzyHashtagPredicate.java b/java/com/google/gerrit/server/query/change/FuzzyHashtagPredicate.java
deleted file mode 100644
index b4d6b5f..0000000
--- a/java/com/google/gerrit/server/query/change/FuzzyHashtagPredicate.java
+++ /dev/null
@@ -1,33 +0,0 @@
-// Copyright (C) 2021 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.query.change;
-
-import static com.google.gerrit.server.index.change.ChangeField.FUZZY_HASHTAG;
-
-import com.google.gerrit.server.index.change.ChangeIndex;
-
-public class FuzzyHashtagPredicate extends ChangeIndexPredicate {
-  protected final ChangeIndex index;
-
-  public FuzzyHashtagPredicate(String hashtag, ChangeIndex index) {
-    super(FUZZY_HASHTAG, hashtag.toLowerCase());
-    this.index = index;
-  }
-
-  @Override
-  public boolean match(ChangeData cd) {
-    return cd.hashtags().stream().anyMatch(ht -> ht.toLowerCase().contains(getValue()));
-  }
-}
diff --git a/java/com/google/gerrit/server/query/change/FuzzyTopicPredicate.java b/java/com/google/gerrit/server/query/change/FuzzyTopicPredicate.java
deleted file mode 100644
index 414f85b..0000000
--- a/java/com/google/gerrit/server/query/change/FuzzyTopicPredicate.java
+++ /dev/null
@@ -1,57 +0,0 @@
-// Copyright (C) 2010 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.query.change;
-
-import static com.google.gerrit.server.index.change.ChangeField.FUZZY_TOPIC;
-
-import com.google.common.collect.Iterables;
-import com.google.gerrit.entities.Change;
-import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.index.query.Predicate;
-import com.google.gerrit.index.query.QueryParseException;
-import com.google.gerrit.server.index.change.ChangeIndex;
-import com.google.gerrit.server.index.change.IndexedChangeQuery;
-
-public class FuzzyTopicPredicate extends ChangeIndexPredicate {
-  protected final ChangeIndex index;
-
-  public FuzzyTopicPredicate(String topic, ChangeIndex index) {
-    super(FUZZY_TOPIC, topic);
-    this.index = index;
-  }
-
-  @Override
-  public boolean match(ChangeData cd) {
-    Change change = cd.change();
-    if (change == null) {
-      return false;
-    }
-    String t = change.getTopic();
-    if (t == null) {
-      return false;
-    }
-    try {
-      Predicate<ChangeData> thisId =
-          index.getSchema().useLegacyNumericFields()
-              ? ChangePredicates.id(cd.getId())
-              : ChangePredicates.idStr(cd.getId());
-      Iterable<ChangeData> results =
-          index.getSource(and(thisId, this), IndexedChangeQuery.oneResult()).read();
-      return !Iterables.isEmpty(results);
-    } catch (QueryParseException e) {
-      throw new StorageException(e);
-    }
-  }
-}
diff --git a/java/com/google/gerrit/server/query/change/InternalChangeQuery.java b/java/com/google/gerrit/server/query/change/InternalChangeQuery.java
index 340b327..76ebd81 100644
--- a/java/com/google/gerrit/server/query/change/InternalChangeQuery.java
+++ b/java/com/google/gerrit/server/query/change/InternalChangeQuery.java
@@ -65,7 +65,7 @@
   }
 
   private static Predicate<ChangeData> change(Change.Key key) {
-    return new ChangeIdPredicate(key.get());
+    return ChangePredicates.idPrefix(key.get());
   }
 
   private static Predicate<ChangeData> project(Project.NameKey project) {
@@ -77,7 +77,7 @@
   }
 
   private static Predicate<ChangeData> commit(String id) {
-    return new CommitPredicate(id);
+    return ChangePredicates.commitPrefix(id);
   }
 
   private final ChangeData.Factory changeDataFactory;
@@ -108,7 +108,7 @@
   }
 
   public List<ChangeData> byKeyPrefix(String prefix) {
-    return query(new ChangeIdPredicate(prefix));
+    return query(ChangePredicates.idPrefix(prefix));
   }
 
   public List<ChangeData> byLegacyChangeId(Change.Id id) {
diff --git a/java/com/google/gerrit/server/query/change/LabelPredicate.java b/java/com/google/gerrit/server/query/change/LabelPredicate.java
index 38d1dbe..989b4bb 100644
--- a/java/com/google/gerrit/server/query/change/LabelPredicate.java
+++ b/java/com/google/gerrit/server/query/change/LabelPredicate.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.query.change;
 
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Lists;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
@@ -59,12 +60,12 @@
   protected static class Parsed {
     protected final String label;
     protected final String test;
-    protected final int expVal;
+    protected final int numericValue;
 
-    protected Parsed(String label, String test, int expVal) {
+    protected Parsed(String label, String test, int numericValue) {
       this.label = label;
       this.test = test;
-      this.expVal = expVal;
+      this.numericValue = numericValue;
     }
   }
 
@@ -83,6 +84,14 @@
 
   protected static List<Predicate<ChangeData>> predicates(Args args) {
     String v = args.value;
+
+    try {
+      MagicLabelVote mlv = MagicLabelVote.parseWithEquals(v);
+      return ImmutableList.of(new MagicLabelPredicate(args, mlv));
+    } catch (IllegalArgumentException e) {
+      // Try next format.
+    }
+
     Parsed parsed = null;
 
     try {
@@ -108,7 +117,7 @@
     } else {
       range =
           RangeUtil.getRange(
-              parsed.label, parsed.test, parsed.expVal, -MAX_LABEL_VALUE, MAX_LABEL_VALUE);
+              parsed.label, parsed.test, parsed.numericValue, -MAX_LABEL_VALUE, MAX_LABEL_VALUE);
     }
     String prefix = range.prefix;
     int min = range.min;
diff --git a/java/com/google/gerrit/server/query/change/MagicLabelPredicate.java b/java/com/google/gerrit/server/query/change/MagicLabelPredicate.java
new file mode 100644
index 0000000..e3c58e47
--- /dev/null
+++ b/java/com/google/gerrit/server/query/change/MagicLabelPredicate.java
@@ -0,0 +1,101 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.query.change;
+
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.LabelTypes;
+import com.google.gerrit.entities.LabelValue;
+import com.google.gerrit.index.query.Predicate;
+import com.google.gerrit.server.index.change.ChangeField;
+import com.google.gerrit.server.project.ProjectState;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
+
+public class MagicLabelPredicate extends ChangeIndexPredicate {
+  protected final LabelPredicate.Args args;
+  private final MagicLabelVote magicLabelVote;
+
+  public MagicLabelPredicate(LabelPredicate.Args args, MagicLabelVote magicLabelVote) {
+    super(ChangeField.LABEL, magicLabelVote.formatLabel());
+    this.args = args;
+    this.magicLabelVote = magicLabelVote;
+  }
+
+  @Override
+  public boolean match(ChangeData changeData) {
+    Change change = changeData.change();
+    if (change == null) {
+      // The change has disappeared.
+      //
+      return false;
+    }
+
+    Optional<ProjectState> project = args.projectCache.get(change.getDest().project());
+    if (!project.isPresent()) {
+      // The project has disappeared.
+      //
+      return false;
+    }
+
+    LabelType labelType = type(project.get().getLabelTypes(), magicLabelVote.label());
+    if (labelType == null) {
+      return false; // Label is not defined by this project.
+    }
+
+    switch (magicLabelVote.value()) {
+      case ANY:
+        return matchAny(changeData, labelType);
+      case MIN:
+        return matchNumeric(changeData, magicLabelVote.label(), labelType.getMin().getValue());
+      case MAX:
+        return matchNumeric(changeData, magicLabelVote.label(), labelType.getMax().getValue());
+    }
+
+    throw new IllegalStateException("Unsupported magic label value: " + magicLabelVote.value());
+  }
+
+  private boolean matchAny(ChangeData changeData, LabelType labelType) {
+    List<Predicate<ChangeData>> predicates = new ArrayList<>();
+    for (LabelValue labelValue : labelType.getValues()) {
+      if (labelValue.getValue() != 0) {
+        predicates.add(numericPredicate(labelType.getName(), labelValue.getValue()));
+      }
+    }
+    return or(predicates).asMatchable().match(changeData);
+  }
+
+  private boolean matchNumeric(ChangeData changeData, String label, short value) {
+    return numericPredicate(label, value).match(changeData);
+  }
+
+  private EqualsLabelPredicate numericPredicate(String label, short value) {
+    return new EqualsLabelPredicate(args, label, value, /* account= */ null);
+  }
+
+  protected static LabelType type(LabelTypes types, String toFind) {
+    if (types.byLabel(toFind) != null) {
+      return types.byLabel(toFind);
+    }
+
+    for (LabelType lt : types.getLabelTypes()) {
+      if (toFind.equalsIgnoreCase(lt.getName())) {
+        return lt;
+      }
+    }
+    return null;
+  }
+}
diff --git a/java/com/google/gerrit/server/query/change/MagicLabelValue.java b/java/com/google/gerrit/server/query/change/MagicLabelValue.java
new file mode 100644
index 0000000..c4bcbe3
--- /dev/null
+++ b/java/com/google/gerrit/server/query/change/MagicLabelValue.java
@@ -0,0 +1,31 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.query.change;
+
+import java.util.Optional;
+
+public enum MagicLabelValue {
+  ANY,
+  MIN,
+  MAX;
+
+  public static Optional<MagicLabelValue> tryParse(String value) {
+    try {
+      return Optional.of(MagicLabelValue.valueOf(value));
+    } catch (IllegalArgumentException e) {
+      return Optional.empty();
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/query/change/MagicLabelVote.java b/java/com/google/gerrit/server/query/change/MagicLabelVote.java
new file mode 100644
index 0000000..c29ac72
--- /dev/null
+++ b/java/com/google/gerrit/server/query/change/MagicLabelVote.java
@@ -0,0 +1,47 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.query.change;
+
+import static com.google.common.base.Preconditions.checkArgument;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.base.Strings;
+import com.google.gerrit.entities.LabelType;
+import java.util.Locale;
+
+/** An entity representing a special label vote that's not numeric, e.g. MAX, MIN, etc... */
+@AutoValue
+public abstract class MagicLabelVote {
+  public static MagicLabelVote parseWithEquals(String text) {
+    checkArgument(!Strings.isNullOrEmpty(text), "Empty label vote");
+    int e = text.lastIndexOf('=');
+    checkArgument(e >= 0, "Label vote missing '=': %s", text);
+    String label = text.substring(0, e);
+    String voteValue = text.substring(e + 1);
+    return create(label, MagicLabelValue.valueOf(voteValue));
+  }
+
+  public static MagicLabelVote create(String label, MagicLabelValue value) {
+    return new AutoValue_MagicLabelVote(LabelType.checkNameInternal(label), value);
+  }
+
+  public abstract String label();
+
+  public abstract MagicLabelValue value();
+
+  public String formatLabel() {
+    return label().toLowerCase(Locale.US) + "=" + value().name();
+  }
+}
diff --git a/java/com/google/gerrit/server/query/change/MessagePredicate.java b/java/com/google/gerrit/server/query/change/MessagePredicate.java
deleted file mode 100644
index 3228f96..0000000
--- a/java/com/google/gerrit/server/query/change/MessagePredicate.java
+++ /dev/null
@@ -1,53 +0,0 @@
-// Copyright (C) 2010 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.query.change;
-
-import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.index.query.Predicate;
-import com.google.gerrit.index.query.QueryParseException;
-import com.google.gerrit.server.index.change.ChangeField;
-import com.google.gerrit.server.index.change.ChangeIndex;
-import com.google.gerrit.server.index.change.IndexedChangeQuery;
-
-/** Predicate to match changes that contains specified text in commit messages body. */
-public class MessagePredicate extends ChangeIndexPredicate {
-  protected final ChangeIndex index;
-
-  public MessagePredicate(ChangeIndex index, String value) {
-    super(ChangeField.COMMIT_MESSAGE, value);
-    this.index = index;
-  }
-
-  @Override
-  public boolean match(ChangeData object) {
-    try {
-      Predicate<ChangeData> p =
-          Predicate.and(
-              index.getSchema().useLegacyNumericFields()
-                  ? ChangePredicates.id(object.getId())
-                  : ChangePredicates.idStr(object.getId()),
-              this);
-      for (ChangeData cData : index.getSource(p, IndexedChangeQuery.oneResult()).read()) {
-        if (cData.getId().equals(object.getId())) {
-          return true;
-        }
-      }
-    } catch (QueryParseException e) {
-      throw new StorageException(e);
-    }
-
-    return false;
-  }
-}
diff --git a/java/com/google/gerrit/server/query/change/ProjectPrefixPredicate.java b/java/com/google/gerrit/server/query/change/ProjectPrefixPredicate.java
deleted file mode 100644
index b89fffe..0000000
--- a/java/com/google/gerrit/server/query/change/ProjectPrefixPredicate.java
+++ /dev/null
@@ -1,30 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.query.change;
-
-import com.google.gerrit.entities.Change;
-import com.google.gerrit.server.index.change.ChangeField;
-
-public class ProjectPrefixPredicate extends ChangeIndexPredicate {
-  public ProjectPrefixPredicate(String prefix) {
-    super(ChangeField.PROJECTS, prefix);
-  }
-
-  @Override
-  public boolean match(ChangeData object) {
-    Change c = object.change();
-    return c != null && c.getDest().project().get().startsWith(getValue());
-  }
-}
diff --git a/java/com/google/gerrit/server/query/group/GroupPredicates.java b/java/com/google/gerrit/server/query/group/GroupPredicates.java
index 9d8171c..8a2dc8d 100644
--- a/java/com/google/gerrit/server/query/group/GroupPredicates.java
+++ b/java/com/google/gerrit/server/query/group/GroupPredicates.java
@@ -19,7 +19,6 @@
 import com.google.gerrit.entities.InternalGroup;
 import com.google.gerrit.index.FieldDef;
 import com.google.gerrit.index.query.IndexPredicate;
-import com.google.gerrit.index.query.Matchable;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.server.index.group.GroupField;
 import java.util.Locale;
@@ -45,7 +44,7 @@
   }
 
   public static Predicate<InternalGroup> name(String name) {
-    return new NameGroupPredicate(name);
+    return new GroupPredicate(GroupField.NAME, name);
   }
 
   public static Predicate<InternalGroup> owner(AccountGroup.UUID ownerUuid) {
@@ -76,25 +75,5 @@
     }
   }
 
-  // TODO(hiesel): This is just a one-off to make index tests work. Remove in favor of a more
-  // generic solution.
-  // This is required because Gerrit needs to look up groups by name on every request.
-  static class NameGroupPredicate extends IndexPredicate<InternalGroup>
-      implements Matchable<InternalGroup> {
-    NameGroupPredicate(String value) {
-      super(GroupField.NAME, value);
-    }
-
-    @Override
-    public boolean match(InternalGroup group) {
-      return group.getName().equals(getValue());
-    }
-
-    @Override
-    public int getCost() {
-      return 1;
-    }
-  }
-
   private GroupPredicates() {}
 }
diff --git a/java/com/google/gerrit/server/restapi/change/Abandon.java b/java/com/google/gerrit/server/restapi/change/Abandon.java
index 826c89d..2cfc3f5 100644
--- a/java/com/google/gerrit/server/restapi/change/Abandon.java
+++ b/java/com/google/gerrit/server/restapi/change/Abandon.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.server.restapi.change;
 
-import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.extensions.api.changes.AbandonInput;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
@@ -44,8 +43,6 @@
 @Singleton
 public class Abandon
     implements RestModifyView<ChangeResource, AbandonInput>, UiAction<ChangeResource> {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
   private final BatchUpdate.Factory updateFactory;
   private final ChangeJson.Factory json;
   private final AbandonOp.Factory abandonOpFactory;
diff --git a/java/com/google/gerrit/server/restapi/change/CherryPickChange.java b/java/com/google/gerrit/server/restapi/change/CherryPickChange.java
index dc1cd10..5375936 100644
--- a/java/com/google/gerrit/server/restapi/change/CherryPickChange.java
+++ b/java/com/google/gerrit/server/restapi/change/CherryPickChange.java
@@ -33,11 +33,11 @@
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
-import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.ReviewerSet;
+import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.change.ChangeInserter;
 import com.google.gerrit.server.change.NotifyResolver;
 import com.google.gerrit.server.change.PatchSetInserter;
diff --git a/java/com/google/gerrit/server/restapi/change/CommentPorter.java b/java/com/google/gerrit/server/restapi/change/CommentPorter.java
index d1d4544..35d512a 100644
--- a/java/com/google/gerrit/server/restapi/change/CommentPorter.java
+++ b/java/com/google/gerrit/server/restapi/change/CommentPorter.java
@@ -29,7 +29,6 @@
 import com.google.gerrit.entities.Patch;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
-import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
 import com.google.gerrit.metrics.Counter0;
 import com.google.gerrit.metrics.Description;
 import com.google.gerrit.metrics.MetricMaker;
@@ -39,16 +38,17 @@
 import com.google.gerrit.server.logging.TraceContext.TraceTimer;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.patch.DiffMappings;
+import com.google.gerrit.server.patch.DiffNotAvailableException;
+import com.google.gerrit.server.patch.DiffOperations;
 import com.google.gerrit.server.patch.GitPositionTransformer;
 import com.google.gerrit.server.patch.GitPositionTransformer.BestPositionOnConflict;
 import com.google.gerrit.server.patch.GitPositionTransformer.FileMapping;
 import com.google.gerrit.server.patch.GitPositionTransformer.Mapping;
 import com.google.gerrit.server.patch.GitPositionTransformer.Position;
 import com.google.gerrit.server.patch.GitPositionTransformer.PositionedEntity;
-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.filediff.FileDiffOutput;
+import com.google.gerrit.server.patch.filediff.FileEdits;
+import com.google.gerrit.server.patch.filediff.TaggedEdit;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.util.List;
@@ -102,15 +102,15 @@
     }
   }
 
+  private final DiffOperations diffOperations;
   private final GitPositionTransformer positionTransformer =
       new GitPositionTransformer(BestPositionOnConflict.INSTANCE);
-  private final PatchListCache patchListCache;
   private final CommentsUtil commentsUtil;
   private final Metrics metrics;
 
   @Inject
-  public CommentPorter(PatchListCache patchListCache, CommentsUtil commentsUtil, Metrics metrics) {
-    this.patchListCache = patchListCache;
+  public CommentPorter(DiffOperations diffOperations, CommentsUtil commentsUtil, Metrics metrics) {
+    this.diffOperations = diffOperations;
     this.commentsUtil = commentsUtil;
     this.metrics = metrics;
   }
@@ -283,7 +283,7 @@
       PatchSet originalPatchset,
       PatchSet targetPatchset,
       short side)
-      throws PatchListNotAvailableException {
+      throws DiffNotAvailableException {
     try (TraceTimer ignored =
         TraceContext.newTimer(
             "Loading commit mappings",
@@ -311,18 +311,26 @@
 
   private ImmutableSet<Mapping> loadCommitMappings(
       Project.NameKey project, ObjectId originalCommit, ObjectId targetCommit)
-      throws PatchListNotAvailableException {
+      throws DiffNotAvailableException {
     try (TraceTimer ignored =
         TraceContext.newTimer(
             "Computing diffs", Metadata.builder().commit(originalCommit.name()).build())) {
-      PatchList patchList =
-          patchListCache.get(
-              PatchListKey.againstCommit(originalCommit, targetCommit, Whitespace.IGNORE_NONE),
-              project);
-      return patchList.getPatches().stream().map(DiffMappings::toMapping).collect(toImmutableSet());
+      Map<String, FileDiffOutput> modifiedFiles =
+          diffOperations.listModifiedFiles(project, originalCommit, targetCommit);
+      return modifiedFiles.values().stream()
+          .map(CommentPorter::getFileEdits)
+          .map(DiffMappings::toMapping)
+          .collect(toImmutableSet());
     }
   }
 
+  private static FileEdits getFileEdits(FileDiffOutput fileDiffOutput) {
+    return FileEdits.create(
+        fileDiffOutput.edits().stream().map(TaggedEdit::edit).collect(toImmutableList()),
+        fileDiffOutput.oldPath(),
+        fileDiffOutput.newPath());
+  }
+
   private ImmutableSet<Mapping> getFallbackMappings(List<HumanComment> comments) {
     // Consider all files as deleted. -> Comments will be ported to the fallback destination, which
     // currently are patchset-level comments.
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/DeleteVote.java b/java/com/google/gerrit/server/restapi/change/DeleteVote.java
index 8ae902d..84424a8 100644
--- a/java/com/google/gerrit/server/restapi/change/DeleteVote.java
+++ b/java/com/google/gerrit/server/restapi/change/DeleteVote.java
@@ -33,12 +33,12 @@
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.change.AddToAttentionSetOp;
 import com.google.gerrit.server.change.NotifyResolver;
 import com.google.gerrit.server.change.ReviewerResource;
@@ -89,7 +89,6 @@
       ApprovalsUtil approvalsUtil,
       PatchSetUtil psUtil,
       ChangeMessagesUtil cmUtil,
-      IdentifiedUser.GenericFactory userFactory,
       VoteDeleted voteDeleted,
       DeleteVoteSender.Factory deleteVoteSenderFactory,
       NotifyResolver notifyResolver,
diff --git a/java/com/google/gerrit/server/restapi/change/Files.java b/java/com/google/gerrit/server/restapi/change/Files.java
index f82284e..320e57d 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;
@@ -181,7 +183,7 @@
         r =
             Response.ok(
                 fileInfoJson.getFileInfoMap(
-                    resource.getChange(), resource.getPatchSet().commitId(), parentNum - 1));
+                    resource.getChange(), resource.getPatchSet().commitId(), parentNum));
       } else {
         r = Response.ok(fileInfoJson.getFileInfoMap(resource.getChange(), resource.getPatchSet()));
       }
@@ -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= */ 0);
 
-        PatchList curList = patchListCache.get(change, resource.getPatchSet());
+        Map<String, FileDiffOutput> curList =
+            diffOperations.listModifiedFilesAgainstParent(
+                project, resource.getPatchSet().commitId(), /* parentNum= */ 0);
 
         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/GetDiff.java b/java/com/google/gerrit/server/restapi/change/GetDiff.java
index 2169d57..dd951a8 100644
--- a/java/com/google/gerrit/server/restapi/change/GetDiff.java
+++ b/java/com/google/gerrit/server/restapi/change/GetDiff.java
@@ -74,6 +74,7 @@
   @Option(name = "--base", metaVar = "REVISION")
   String base;
 
+  /** 1-based index of the parent's position in the commit object. */
   @Option(name = "--parent", metaVar = "parent-number")
   int parentNum;
 
@@ -143,7 +144,7 @@
     } else if (parentNum > 0) {
       psf =
           patchScriptFactoryFactory.create(
-              notes, fileName, parentNum - 1, pId, prefs, currentUser.get());
+              notes, fileName, parentNum, pId, prefs, currentUser.get());
     } else {
       psf = patchScriptFactoryFactory.create(notes, fileName, null, pId, prefs, currentUser.get());
     }
diff --git a/java/com/google/gerrit/server/restapi/change/ListReviewers.java b/java/com/google/gerrit/server/restapi/change/ListReviewers.java
index 3d07d43..12dbf4e 100644
--- a/java/com/google/gerrit/server/restapi/change/ListReviewers.java
+++ b/java/com/google/gerrit/server/restapi/change/ListReviewers.java
@@ -19,7 +19,7 @@
 import com.google.gerrit.extensions.api.changes.ReviewerInfo;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.server.ApprovalsUtil;
+import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.ReviewerJson;
 import com.google.gerrit.server.change.ReviewerResource;
diff --git a/java/com/google/gerrit/server/restapi/change/ListRevisionReviewers.java b/java/com/google/gerrit/server/restapi/change/ListRevisionReviewers.java
index b44f637..2df2d29 100644
--- a/java/com/google/gerrit/server/restapi/change/ListRevisionReviewers.java
+++ b/java/com/google/gerrit/server/restapi/change/ListRevisionReviewers.java
@@ -20,7 +20,7 @@
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.server.ApprovalsUtil;
+import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.change.ReviewerJson;
 import com.google.gerrit.server.change.ReviewerResource;
 import com.google.gerrit.server.change.RevisionResource;
diff --git a/java/com/google/gerrit/server/restapi/change/Move.java b/java/com/google/gerrit/server/restapi/change/Move.java
index 22fcbc7..9263971 100644
--- a/java/com/google/gerrit/server/restapi/change/Move.java
+++ b/java/com/google/gerrit/server/restapi/change/Move.java
@@ -21,7 +21,6 @@
 import static com.google.gerrit.server.query.change.ChangeData.asChanges;
 
 import com.google.common.base.Strings;
-import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
@@ -40,11 +39,11 @@
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.webui.UiAction;
-import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.change.ChangeJson;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.config.GerritServerConfig;
@@ -73,8 +72,6 @@
 
 @Singleton
 public class Move implements RestModifyView<ChangeResource, MoveInput>, UiAction<ChangeResource> {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
   private final PermissionBackend permissionBackend;
   private final BatchUpdate.Factory updateFactory;
   private final ChangeJson.Factory json;
diff --git a/java/com/google/gerrit/server/restapi/change/PostReview.java b/java/com/google/gerrit/server/restapi/change/PostReview.java
index 8d413fa..6816361 100644
--- a/java/com/google/gerrit/server/restapi/change/PostReview.java
+++ b/java/com/google/gerrit/server/restapi/change/PostReview.java
@@ -81,7 +81,6 @@
 import com.google.gerrit.extensions.validators.CommentValidationContext;
 import com.google.gerrit.extensions.validators.CommentValidationFailure;
 import com.google.gerrit.extensions.validators.CommentValidator;
-import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.CommentsUtil;
@@ -91,6 +90,7 @@
 import com.google.gerrit.server.PublishCommentUtil;
 import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.account.AccountResolver;
+import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.EmailReviewComments;
 import com.google.gerrit.server.change.ModifyReviewersEmail;
@@ -1418,11 +1418,7 @@
       return !del.isEmpty() || !ups.isEmpty();
     }
 
-    /**
-     * Approval is copied over if it doesn't exist in the approvals of the current patch-set
-     * according to change notes (which means it was computed in {@link
-     * com.google.gerrit.server.ApprovalInference})
-     */
+    /** Approval is copied over if it doesn't exist in the approvals of the current patch-set. */
     private boolean isApprovalCopiedOver(
         PatchSetApproval patchSetApproval, ChangeNotes changeNotes) {
       return !changeNotes.getApprovals().get(changeNotes.getChange().currentPatchSetId()).stream()
diff --git a/java/com/google/gerrit/server/restapi/change/PutAssignee.java b/java/com/google/gerrit/server/restapi/change/PutAssignee.java
index 17ee92e..dcf616c 100644
--- a/java/com/google/gerrit/server/restapi/change/PutAssignee.java
+++ b/java/com/google/gerrit/server/restapi/change/PutAssignee.java
@@ -26,11 +26,11 @@
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.webui.UiAction;
-import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.account.AccountLoader;
 import com.google.gerrit.server.account.AccountResolver;
+import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.ReviewerModifier;
 import com.google.gerrit.server.change.ReviewerModifier.ReviewerModification;
diff --git a/java/com/google/gerrit/server/restapi/change/Rebase.java b/java/com/google/gerrit/server/restapi/change/Rebase.java
index 3e1f033..2077fb8 100644
--- a/java/com/google/gerrit/server/restapi/change/Rebase.java
+++ b/java/com/google/gerrit/server/restapi/change/Rebase.java
@@ -18,7 +18,6 @@
 
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Sets;
-import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
@@ -65,8 +64,6 @@
 @Singleton
 public class Rebase
     implements RestModifyView<RevisionResource, RebaseInput>, UiAction<RevisionResource> {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
   private static final ImmutableSet<ListChangesOption> OPTIONS =
       Sets.immutableEnumSet(ListChangesOption.CURRENT_REVISION, ListChangesOption.CURRENT_COMMIT);
 
diff --git a/java/com/google/gerrit/server/restapi/change/ReplyAttentionSetUpdates.java b/java/com/google/gerrit/server/restapi/change/ReplyAttentionSetUpdates.java
index 4723d70..53d0f18 100644
--- a/java/com/google/gerrit/server/restapi/change/ReplyAttentionSetUpdates.java
+++ b/java/com/google/gerrit/server/restapi/change/ReplyAttentionSetUpdates.java
@@ -19,7 +19,9 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Sets;
 import com.google.common.collect.Sets.SetView;
+import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.AttentionSetUpdate;
 import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.extensions.api.changes.AttentionSetInput;
@@ -27,11 +29,11 @@
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
-import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.account.AccountResolver;
 import com.google.gerrit.server.account.ServiceUserClassifier;
+import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.change.AddToAttentionSetOp;
 import com.google.gerrit.server.change.AttentionSetUnchangedOp;
 import com.google.gerrit.server.change.CommentThread;
@@ -60,6 +62,7 @@
  * This class is used to update the attention set when performing a review or replying on a change.
  */
 public class ReplyAttentionSetUpdates {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   private final PermissionBackend permissionBackend;
   private final AddToAttentionSetOp.Factory addToAttentionSetOpFactory;
@@ -316,11 +319,15 @@
     AttentionSetUtil.validateInput(add);
     try {
       Account.Id attentionUserId =
-          getAccountIdAndValidateUser(changeNotes, add.user, accountsChangedInCommit);
+          getAccountIdAndValidateUser(
+              changeNotes, add.user, accountsChangedInCommit, AttentionSetUpdate.Operation.ADD);
       addToAttentionSet(bu, changeNotes, attentionUserId, add.reason, false);
     } catch (AccountResolver.UnresolvableAccountException ex) {
       // This happens only when the account doesn't exist. Silently ignore it. If we threw an error
       // message here, then it would be possible to probe whether an account exists.
+    } catch (AuthException ex) {
+      // adding users without permission to the attention set should fail silently.
+      logger.atFine().log(ex.getMessage());
     }
   }
 
@@ -334,17 +341,25 @@
     AttentionSetUtil.validateInput(remove);
     try {
       Account.Id attentionUserId =
-          getAccountIdAndValidateUser(changeNotes, remove.user, accountsChangedInCommit);
+          getAccountIdAndValidateUser(
+              changeNotes,
+              remove.user,
+              accountsChangedInCommit,
+              AttentionSetUpdate.Operation.REMOVE);
       removeFromAttentionSet(bu, changeNotes, attentionUserId, remove.reason, false);
     } catch (AccountResolver.UnresolvableAccountException ex) {
       // This happens only when the account doesn't exist. Silently ignore it. If we threw an error
       // message here, then it would be possible to probe whether an account exists.
+    } catch (AuthException ex) {
+      // this should never happen since removing users with permissions should work.
+      logger.atSevere().log(ex.getMessage());
     }
   }
 
-  private Account.Id getAccountId(ChangeNotes changeNotes, String user)
+  private Account.Id getAccountId(
+      ChangeNotes changeNotes, String user, AttentionSetUpdate.Operation operation)
       throws ConfigInvalidException, IOException, UnprocessableEntityException,
-          PermissionBackendException {
+          PermissionBackendException, AuthException {
     Account.Id attentionUserId = accountResolver.resolve(user).asUnique().account().id();
     try {
       permissionBackend
@@ -352,22 +367,29 @@
           .change(changeNotes)
           .check(ChangePermission.READ);
     } catch (AuthException e) {
+      // If the change is private, it is okay to add the user to the attention set since that
+      // person will be granted visibility when a reviewer.
       if (!changeNotes.getChange().isPrivate()) {
-        // If the change is private, it is okay to add the user to the attention set since that
-        // person will be granted visibility when a reviewer.
-        throw new UnprocessableEntityException(
-            "Can't add to attention set: Read not permitted for " + attentionUserId, e);
+
+        // Removing users without access is allowed, adding is not allowed
+        if (operation == AttentionSetUpdate.Operation.ADD) {
+          throw new AuthException(
+              "Can't modify attention set: Read not permitted for " + attentionUserId, e);
+        }
       }
     }
     return attentionUserId;
   }
 
   private Account.Id getAccountIdAndValidateUser(
-      ChangeNotes changeNotes, String user, Set<Account.Id> accountsChangedInCommit)
+      ChangeNotes changeNotes,
+      String user,
+      Set<Account.Id> accountsChangedInCommit,
+      AttentionSetUpdate.Operation operation)
       throws ConfigInvalidException, IOException, PermissionBackendException,
-          UnprocessableEntityException, BadRequestException {
+          UnprocessableEntityException, BadRequestException, AuthException {
     try {
-      Account.Id attentionUserId = getAccountId(changeNotes, user);
+      Account.Id attentionUserId = getAccountId(changeNotes, user, operation);
       if (accountsChangedInCommit.contains(attentionUserId)) {
         throw new BadRequestException(
             String.format(
diff --git a/java/com/google/gerrit/server/restapi/change/Revert.java b/java/com/google/gerrit/server/restapi/change/Revert.java
index 7bb43d2..8d48c88 100644
--- a/java/com/google/gerrit/server/restapi/change/Revert.java
+++ b/java/com/google/gerrit/server/restapi/change/Revert.java
@@ -19,7 +19,6 @@
 import static com.google.gerrit.server.permissions.RefPermission.CREATE_CHANGE;
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
 
-import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.extensions.api.changes.RevertInput;
@@ -54,8 +53,6 @@
 @Singleton
 public class Revert
     implements RestModifyView<ChangeResource, RevertInput>, UiAction<ChangeResource> {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
   private final PermissionBackend permissionBackend;
   private final PatchSetUtil psUtil;
   private final ChangeJson.Factory json;
diff --git a/java/com/google/gerrit/server/restapi/change/ReviewerRecommender.java b/java/com/google/gerrit/server/restapi/change/ReviewerRecommender.java
index d80ab696..7f7c1ad 100644
--- a/java/com/google/gerrit/server/restapi/change/ReviewerRecommender.java
+++ b/java/com/google/gerrit/server/restapi/change/ReviewerRecommender.java
@@ -24,10 +24,10 @@
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.extensions.client.ReviewerState;
 import com.google.gerrit.index.query.QueryParseException;
-import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.FanOutExecutor;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.change.ReviewerSuggestion;
 import com.google.gerrit.server.change.SuggestedReviewer;
 import com.google.gerrit.server.config.GerritServerConfig;
@@ -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/server/restapi/change/Reviewers.java b/java/com/google/gerrit/server/restapi/change/Reviewers.java
index 4bfcf14..9da7c88 100644
--- a/java/com/google/gerrit/server/restapi/change/Reviewers.java
+++ b/java/com/google/gerrit/server/restapi/change/Reviewers.java
@@ -22,8 +22,8 @@
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestView;
-import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.account.AccountResolver;
+import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.ReviewerResource;
 import com.google.inject.Inject;
diff --git a/java/com/google/gerrit/server/restapi/change/RevisionReviewers.java b/java/com/google/gerrit/server/restapi/change/RevisionReviewers.java
index 2651ab5..97383cda 100644
--- a/java/com/google/gerrit/server/restapi/change/RevisionReviewers.java
+++ b/java/com/google/gerrit/server/restapi/change/RevisionReviewers.java
@@ -24,7 +24,7 @@
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
-import com.google.gerrit.server.ApprovalsUtil;
+import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.change.ReviewerResource;
 import com.google.gerrit.server.change.RevisionResource;
 import com.google.gerrit.server.restapi.account.AccountsCollection;
diff --git a/java/com/google/gerrit/server/restapi/change/Votes.java b/java/com/google/gerrit/server/restapi/change/Votes.java
index d899002..0f4b960 100644
--- a/java/com/google/gerrit/server/restapi/change/Votes.java
+++ b/java/com/google/gerrit/server/restapi/change/Votes.java
@@ -24,7 +24,7 @@
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.extensions.restapi.RestView;
-import com.google.gerrit.server.ApprovalsUtil;
+import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.change.ReviewerResource;
 import com.google.gerrit.server.change.VoteResource;
 import com.google.inject.Inject;
@@ -83,9 +83,7 @@
           approvalsUtil.byPatchSetUser(
               rsrc.getChangeResource().getNotes(),
               rsrc.getChange().currentPatchSetId(),
-              rsrc.getReviewerUser().getAccountId(),
-              null,
-              null);
+              rsrc.getReviewerUser().getAccountId());
       for (PatchSetApproval psa : byPatchSetUser) {
         votes.put(psa.label(), psa.value());
       }
diff --git a/java/com/google/gerrit/server/restapi/config/GetServerInfo.java b/java/com/google/gerrit/server/restapi/config/GetServerInfo.java
index c3dd1b5..ae11d71 100644
--- a/java/com/google/gerrit/server/restapi/config/GetServerInfo.java
+++ b/java/com/google/gerrit/server/restapi/config/GetServerInfo.java
@@ -68,7 +68,6 @@
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.HashMap;
-import java.util.Optional;
 import java.util.concurrent.TimeUnit;
 import org.eclipse.jgit.lib.Config;
 
@@ -222,13 +221,6 @@
     info.showAssigneeInChangesTable =
         toBoolean(
             config.getBoolean("change", "showAssigneeInChangesTable", false) && hasAssigneeInIndex);
-    info.replyTooltip =
-        Optional.ofNullable(config.getString("change", null, "replyTooltip"))
-                .orElse("Reply and score")
-            + " (Shortcut: a)";
-    info.replyLabel =
-        Optional.ofNullable(config.getString("change", null, "replyLabel")).orElse("Reply")
-            + "\u2026";
     info.updateDelay =
         (int) ConfigUtil.getTimeUnit(config, "change", null, "updateDelay", 300, TimeUnit.SECONDS);
     info.submitWholeTopic = toBoolean(MergeSuperSet.wholeTopicEnabled(config));
diff --git a/java/com/google/gerrit/server/restapi/project/CommitsCollection.java b/java/com/google/gerrit/server/restapi/project/CommitsCollection.java
index 9ba7fa3..ae7f540 100644
--- a/java/com/google/gerrit/server/restapi/project/CommitsCollection.java
+++ b/java/com/google/gerrit/server/restapi/project/CommitsCollection.java
@@ -35,7 +35,6 @@
 import com.google.gerrit.server.project.Reachable;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.ChangePredicates;
-import com.google.gerrit.server.query.change.CommitPredicate;
 import com.google.gerrit.server.update.RetryHelper;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -152,7 +151,7 @@
             ChangePredicates.project(project),
             Predicate.or(
                 Arrays.stream(commit.getParents())
-                    .map(parent -> new CommitPredicate(parent.getId().getName()))
+                    .map(parent -> ChangePredicates.commitPrefix(parent.getId().getName()))
                     .collect(toImmutableList())));
     changes =
         retryHelper
diff --git a/java/com/google/gerrit/server/restapi/project/CreateAccessChange.java b/java/com/google/gerrit/server/restapi/project/CreateAccessChange.java
index eceab43..92038b0 100644
--- a/java/com/google/gerrit/server/restapi/project/CreateAccessChange.java
+++ b/java/com/google/gerrit/server/restapi/project/CreateAccessChange.java
@@ -30,7 +30,7 @@
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.server.ApprovalsUtil;
+import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.change.ChangeInserter;
 import com.google.gerrit.server.change.ChangeJson;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
diff --git a/java/com/google/gerrit/server/restapi/project/CreateLabel.java b/java/com/google/gerrit/server/restapi/project/CreateLabel.java
index 2ae1b05..d2f4161 100644
--- a/java/com/google/gerrit/server/restapi/project/CreateLabel.java
+++ b/java/com/google/gerrit/server/restapi/project/CreateLabel.java
@@ -26,6 +26,7 @@
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestCollectionCreateView;
+import com.google.gerrit.index.query.QueryParseException;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
 import com.google.gerrit.server.permissions.PermissionBackend;
@@ -36,6 +37,7 @@
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectConfig;
 import com.google.gerrit.server.project.ProjectResource;
+import com.google.gerrit.server.query.approval.ApprovalQueryBuilder;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -51,6 +53,7 @@
   private final MetaDataUpdate.User updateFactory;
   private final ProjectConfig.Factory projectConfigFactory;
   private final ProjectCache projectCache;
+  private final ApprovalQueryBuilder approvalQueryBuilder;
 
   @Inject
   public CreateLabel(
@@ -58,12 +61,14 @@
       PermissionBackend permissionBackend,
       MetaDataUpdate.User updateFactory,
       ProjectConfig.Factory projectConfigFactory,
-      ProjectCache projectCache) {
+      ProjectCache projectCache,
+      ApprovalQueryBuilder approvalQueryBuilder) {
     this.user = user;
     this.permissionBackend = permissionBackend;
     this.updateFactory = updateFactory;
     this.projectConfigFactory = projectConfigFactory;
     this.projectCache = projectCache;
+    this.approvalQueryBuilder = approvalQueryBuilder;
   }
 
   @Override
@@ -166,6 +171,23 @@
       labelType.setCopyAnyScore(input.copyAnyScore);
     }
 
+    if (input.copyCondition != null) {
+      try {
+        approvalQueryBuilder.parse(input.copyCondition);
+      } catch (QueryParseException e) {
+        throw new BadRequestException(
+            "unable to parse copy condition. got: " + input.copyCondition + ". " + e.getMessage(),
+            e);
+      }
+      if (Boolean.TRUE.equals(input.unsetCopyCondition)) {
+        throw new BadRequestException("can't set and unset copyCondition in the same request");
+      }
+      labelType.setCopyCondition(Strings.emptyToNull(input.copyCondition));
+    }
+    if (Boolean.TRUE.equals(input.unsetCopyCondition)) {
+      labelType.setCopyCondition(null);
+    }
+
     if (input.copyMinScore != null) {
       labelType.setCopyMinScore(input.copyMinScore);
     }
diff --git a/java/com/google/gerrit/server/restapi/project/FilesInCommitCollection.java b/java/com/google/gerrit/server/restapi/project/FilesInCommitCollection.java
index 7bee2f2..6d054bd 100644
--- a/java/com/google/gerrit/server/restapi/project/FilesInCommitCollection.java
+++ b/java/com/google/gerrit/server/restapi/project/FilesInCommitCollection.java
@@ -77,6 +77,10 @@
   }
 
   public static final class ListFiles implements RestReadView<CommitResource> {
+    /**
+     * The 1-based parent number. If zero, the default base commit will be used, which is the only
+     * parent for commits having one parent or the auto-merge commit otherwise.
+     */
     @Option(name = "--parent", metaVar = "parent-number")
     int parentNum;
 
@@ -97,8 +101,7 @@
         throws ResourceConflictException, PatchListNotAvailableException {
       RevCommit commit = resource.getCommit();
       return Response.ok(
-          fileInfoJson.getFileInfoMap(
-              resource.getProjectState().getNameKey(), commit, parentNum - 1));
+          fileInfoJson.getFileInfoMap(resource.getProjectState().getNameKey(), commit, parentNum));
     }
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/project/SetLabel.java b/java/com/google/gerrit/server/restapi/project/SetLabel.java
index b1bcb15..d69abef 100644
--- a/java/com/google/gerrit/server/restapi/project/SetLabel.java
+++ b/java/com/google/gerrit/server/restapi/project/SetLabel.java
@@ -23,6 +23,7 @@
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.index.query.QueryParseException;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
 import com.google.gerrit.server.permissions.PermissionBackend;
@@ -32,6 +33,7 @@
 import com.google.gerrit.server.project.LabelResource;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectConfig;
+import com.google.gerrit.server.query.approval.ApprovalQueryBuilder;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -45,6 +47,7 @@
   private final MetaDataUpdate.User updateFactory;
   private final ProjectConfig.Factory projectConfigFactory;
   private final ProjectCache projectCache;
+  private final ApprovalQueryBuilder approvalQueryBuilder;
 
   @Inject
   public SetLabel(
@@ -52,12 +55,14 @@
       PermissionBackend permissionBackend,
       MetaDataUpdate.User updateFactory,
       ProjectConfig.Factory projectConfigFactory,
-      ProjectCache projectCache) {
+      ProjectCache projectCache,
+      ApprovalQueryBuilder approvalQueryBuilder) {
     this.user = user;
     this.permissionBackend = permissionBackend;
     this.updateFactory = updateFactory;
     this.projectConfigFactory = projectConfigFactory;
     this.projectCache = projectCache;
+    this.approvalQueryBuilder = approvalQueryBuilder;
   }
 
   @Override
@@ -174,6 +179,26 @@
       dirty = true;
     }
 
+    input.copyCondition = Strings.emptyToNull(input.copyCondition);
+    if (input.copyCondition != null) {
+      try {
+        approvalQueryBuilder.parse(input.copyCondition);
+      } catch (QueryParseException e) {
+        throw new BadRequestException(
+            "unable to parse copy condition. got: " + input.copyCondition + ". " + e.getMessage(),
+            e);
+      }
+      labelTypeBuilder.setCopyCondition(input.copyCondition);
+      dirty = true;
+      if (Boolean.TRUE.equals(input.unsetCopyCondition)) {
+        throw new BadRequestException("can't set and unset copyCondition in the same request");
+      }
+    }
+    if (Boolean.TRUE.equals(input.unsetCopyCondition)) {
+      labelTypeBuilder.setCopyCondition(null);
+      dirty = true;
+    }
+
     if (input.copyAnyScore != null) {
       labelTypeBuilder.setCopyAnyScore(input.copyAnyScore);
       dirty = true;
diff --git a/java/com/google/gerrit/server/rules/DefaultSubmitRule.java b/java/com/google/gerrit/server/rules/DefaultSubmitRule.java
index f5709e4..fe429dd 100644
--- a/java/com/google/gerrit/server/rules/DefaultSubmitRule.java
+++ b/java/com/google/gerrit/server/rules/DefaultSubmitRule.java
@@ -17,16 +17,13 @@
 import static com.google.common.base.Preconditions.checkState;
 import static com.google.common.collect.ImmutableList.toImmutableList;
 
-import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.LabelFunction;
 import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.entities.SubmitRecord;
 import com.google.gerrit.extensions.annotations.Exports;
 import com.google.gerrit.extensions.config.FactoryModule;
-import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.query.change.ChangeData;
-import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.util.ArrayList;
 import java.util.Collection;
@@ -42,8 +39,6 @@
  */
 @Singleton
 public final class DefaultSubmitRule implements SubmitRule {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
   public static class Module extends FactoryModule {
     @Override
     public void configure() {
@@ -53,13 +48,6 @@
     }
   }
 
-  private final ProjectCache projectCache;
-
-  @Inject
-  DefaultSubmitRule(ProjectCache projectCache) {
-    this.projectCache = projectCache;
-  }
-
   @Override
   public Optional<SubmitRecord> evaluate(ChangeData cd) {
     SubmitRecord submitRecord = new SubmitRecord();
diff --git a/java/com/google/gerrit/server/rules/PrologRuleEvaluator.java b/java/com/google/gerrit/server/rules/PrologRuleEvaluator.java
index 3dc5be0..179a3d0 100644
--- a/java/com/google/gerrit/server/rules/PrologRuleEvaluator.java
+++ b/java/com/google/gerrit/server/rules/PrologRuleEvaluator.java
@@ -158,6 +158,8 @@
       return ruleError("Error looking up change " + cd.getId(), e);
     }
 
+    logger.atFine().log("input approvals: %s", cd.approvals());
+
     List<Term> results;
     try {
       results =
@@ -178,7 +180,9 @@
               getSubmitRuleName(), cd.getId(), projectState.getName()));
     }
 
-    return resultsToSubmitRecord(getSubmitRule(), results);
+    SubmitRecord submitRecord = resultsToSubmitRecord(getSubmitRule(), results);
+    logger.atFine().log("submit record: %s", submitRecord);
+    return submitRecord;
   }
 
   private String getSubmitRuleName() {
@@ -320,6 +324,7 @@
       logger.atSevere().withCause(e).log(err);
       return createRuleError(DEFAULT_MSG);
     }
+    logger.atFine().log("rule error: %s", err);
     return createRuleError(err);
   }
 
diff --git a/java/com/google/gerrit/server/submit/CommitMergeStatus.java b/java/com/google/gerrit/server/submit/CommitMergeStatus.java
index bf8b840..4638bfa 100644
--- a/java/com/google/gerrit/server/submit/CommitMergeStatus.java
+++ b/java/com/google/gerrit/server/submit/CommitMergeStatus.java
@@ -77,7 +77,14 @@
   EMPTY_COMMIT(
       "Change could not be merged because the commit is empty.\n"
           + "\n"
-          + "Project policy requires all commits to contain modifications to at least one file.");
+          + "Project policy requires all commits to contain modifications to at least one file."),
+
+  FAST_FORWARD_INDEPENDENT_CHANGES(
+      "Change could not be merged because the submission has two independent changes "
+          + "with the same destination branch.\n"
+          + "\n"
+          + "Independent changes can't be submitted to the same destination branch with "
+          + "FAST_FORWARD_ONLY submit strategy");
 
   private final String description;
 
diff --git a/java/com/google/gerrit/server/submit/EmailMerge.java b/java/com/google/gerrit/server/submit/EmailMerge.java
index 109c9c3..3d38f6c 100644
--- a/java/com/google/gerrit/server/submit/EmailMerge.java
+++ b/java/com/google/gerrit/server/submit/EmailMerge.java
@@ -16,7 +16,6 @@
 
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.CurrentUser;
@@ -42,7 +41,7 @@
     EmailMerge create(
         Project.NameKey project,
         Change change,
-        Account.Id submitter,
+        IdentifiedUser submitter,
         NotifyResolver.Result notify,
         RepoView repoView,
         String stickyApprovalDiff);
@@ -51,12 +50,11 @@
   private final ExecutorService sendEmailsExecutor;
   private final MergedSender.Factory mergedSenderFactory;
   private final ThreadLocalRequestContext requestContext;
-  private final IdentifiedUser.GenericFactory identifiedUserFactory;
   private final MessageIdGenerator messageIdGenerator;
 
   private final Project.NameKey project;
   private final Change change;
-  private final Account.Id submitter;
+  private final IdentifiedUser submitter;
   private final NotifyResolver.Result notify;
   private final RepoView repoView;
   private final String stickyApprovalDiff;
@@ -66,18 +64,16 @@
       @SendEmailExecutor ExecutorService executor,
       MergedSender.Factory mergedSenderFactory,
       ThreadLocalRequestContext requestContext,
-      IdentifiedUser.GenericFactory identifiedUserFactory,
       MessageIdGenerator messageIdGenerator,
       @Assisted Project.NameKey project,
       @Assisted Change change,
-      @Assisted @Nullable Account.Id submitter,
+      @Assisted @Nullable IdentifiedUser submitter,
       @Assisted NotifyResolver.Result notify,
       @Assisted RepoView repoView,
       @Assisted String stickyApprovalDiff) {
     this.sendEmailsExecutor = executor;
     this.mergedSenderFactory = mergedSenderFactory;
     this.requestContext = requestContext;
-    this.identifiedUserFactory = identifiedUserFactory;
     this.messageIdGenerator = messageIdGenerator;
     this.project = project;
     this.change = change;
@@ -99,7 +95,7 @@
       MergedSender emailSender =
           mergedSenderFactory.create(project, change.getId(), Optional.of(stickyApprovalDiff));
       if (submitter != null) {
-        emailSender.setFrom(submitter);
+        emailSender.setFrom(submitter.getAccountId());
       }
       emailSender.setNotify(notify);
       emailSender.setMessageId(
@@ -120,7 +116,7 @@
   @Override
   public CurrentUser getUser() {
     if (submitter != null) {
-      return identifiedUserFactory.create(submitter).getRealUser();
+      return submitter;
     }
     throw new OutOfScopeException("No user on email thread");
   }
diff --git a/java/com/google/gerrit/server/submit/FastForwardOnly.java b/java/com/google/gerrit/server/submit/FastForwardOnly.java
index 176b063..8a30898 100644
--- a/java/com/google/gerrit/server/submit/FastForwardOnly.java
+++ b/java/com/google/gerrit/server/submit/FastForwardOnly.java
@@ -14,11 +14,15 @@
 
 package com.google.gerrit.server.submit;
 
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.server.git.CodeReviewCommit;
 import com.google.gerrit.server.update.RepoContext;
 import java.util.ArrayList;
 import java.util.Collection;
+import java.util.HashMap;
 import java.util.List;
+import java.util.Map;
 
 public class FastForwardOnly extends SubmitStrategy {
   FastForwardOnly(SubmitStrategy.Arguments args) {
@@ -28,6 +32,21 @@
   @Override
   public List<SubmitStrategyOp> buildOps(Collection<CodeReviewCommit> toMerge) {
     List<CodeReviewCommit> sorted = args.mergeUtil.reduceToMinimalMerge(args.mergeSorter, toMerge);
+
+    Map<BranchNameKey, CodeReviewCommit> branchToCommit = new HashMap<>();
+    for (CodeReviewCommit codeReviewCommit : sorted) {
+      BranchNameKey branchNameKey = codeReviewCommit.change().getDest();
+      CodeReviewCommit otherCommitInBranch = branchToCommit.get(branchNameKey);
+      if (otherCommitInBranch == null) {
+        branchToCommit.put(branchNameKey, codeReviewCommit);
+      } else {
+        // we found another change with the same destination branch.
+        codeReviewCommit.setStatusCode(CommitMergeStatus.FAST_FORWARD_INDEPENDENT_CHANGES);
+        otherCommitInBranch.setStatusCode(CommitMergeStatus.FAST_FORWARD_INDEPENDENT_CHANGES);
+        return ImmutableList.of();
+      }
+    }
+
     List<SubmitStrategyOp> ops = new ArrayList<>(sorted.size());
     CodeReviewCommit newTipCommit =
         args.mergeUtil.getFirstFastForward(args.mergeTip.getInitialTip(), args.rw, sorted);
diff --git a/java/com/google/gerrit/server/submit/MergeOp.java b/java/com/google/gerrit/server/submit/MergeOp.java
index 2b4fb3b..363cdca 100644
--- a/java/com/google/gerrit/server/submit/MergeOp.java
+++ b/java/com/google/gerrit/server/submit/MergeOp.java
@@ -67,6 +67,7 @@
 import com.google.gerrit.server.logging.RequestId;
 import com.google.gerrit.server.logging.TraceContext;
 import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.notedb.StoreSubmitRequirementsOp;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.NoSuchProjectException;
 import com.google.gerrit.server.project.SubmitRuleOptions;
@@ -96,6 +97,8 @@
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
+import java.util.function.Function;
+import java.util.stream.Collectors;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.errors.IncorrectObjectTypeException;
 import org.eclipse.jgit.lib.Constants;
@@ -654,6 +657,16 @@
               toSubmit, updateOrderCalculator, submoduleCommits, subscriptionGraph, dryrun);
       this.allProjects = updateOrderCalculator.getProjectsInOrder();
       List<BatchUpdate> batchUpdates = orm.batchUpdates(allProjects);
+      // Group batch updates by project
+      Map<Project.NameKey, BatchUpdate> batchUpdatesByProject =
+          batchUpdates.stream().collect(Collectors.toMap(b -> b.getProject(), Function.identity()));
+      for (Map.Entry<Change.Id, ChangeData> entry : cs.changesById().entrySet()) {
+        Project.NameKey project = entry.getValue().project();
+        Change.Id changeId = entry.getKey();
+        batchUpdatesByProject
+            .get(project)
+            .addOp(changeId, new StoreSubmitRequirementsOp(changeDataFactory));
+      }
       try {
         submissionExecutor.setAdditionalBatchUpdateListeners(
             ImmutableList.of(new SubmitStrategyListener(submitInput, strategies, commitStatus)));
diff --git a/java/com/google/gerrit/server/submit/SubmitStrategy.java b/java/com/google/gerrit/server/submit/SubmitStrategy.java
index 530c53f..6291e6c 100644
--- a/java/com/google/gerrit/server/submit/SubmitStrategy.java
+++ b/java/com/google/gerrit/server/submit/SubmitStrategy.java
@@ -26,12 +26,12 @@
 import com.google.gerrit.extensions.api.changes.SubmitInput;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.config.FactoryModule;
-import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.account.AccountCache;
+import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.change.LabelNormalizer;
 import com.google.gerrit.server.change.RebaseChangeOp;
 import com.google.gerrit.server.change.SetPrivateOp;
diff --git a/java/com/google/gerrit/server/submit/SubmitStrategyListener.java b/java/com/google/gerrit/server/submit/SubmitStrategyListener.java
index b533bebc..59c6b81 100644
--- a/java/com/google/gerrit/server/submit/SubmitStrategyListener.java
+++ b/java/com/google/gerrit/server/submit/SubmitStrategyListener.java
@@ -134,6 +134,7 @@
         case NOT_FAST_FORWARD:
         case EMPTY_COMMIT:
         case MISSING_DEPENDENCY:
+        case FAST_FORWARD_INDEPENDENT_CHANGES:
           // TODO(dborowitz): Reformat these messages to be more appropriate for
           // short problem descriptions.
           String message = s.getDescription();
diff --git a/java/com/google/gerrit/server/submit/SubmitStrategyOp.java b/java/com/google/gerrit/server/submit/SubmitStrategyOp.java
index a63c7dc..f181c36 100644
--- a/java/com/google/gerrit/server/submit/SubmitStrategyOp.java
+++ b/java/com/google/gerrit/server/submit/SubmitStrategyOp.java
@@ -32,9 +32,9 @@
 import com.google.gerrit.entities.SubmitRecord;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.change.LabelNormalizer;
 import com.google.gerrit.server.git.CodeReviewCommit;
 import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk;
@@ -503,7 +503,7 @@
           .create(
               ctx.getProject(),
               toMerge.change(),
-              submitter.accountId(),
+              args.caller,
               ctx.getNotify(getId()),
               ctx.getRepoView(),
               stickyApprovalDiff)
diff --git a/java/com/google/gerrit/server/submit/SubscriptionGraph.java b/java/com/google/gerrit/server/submit/SubscriptionGraph.java
index ad16cb0..dfbbf81 100644
--- a/java/com/google/gerrit/server/submit/SubscriptionGraph.java
+++ b/java/com/google/gerrit/server/submit/SubscriptionGraph.java
@@ -231,7 +231,6 @@
         Map<BranchNameKey, GitModules> branchGitModules,
         MergeOpRepoManager orm)
         throws SubmoduleConflictException {
-      logger.atFine().log("Calculating superprojects - submodules map");
       LinkedHashSet<BranchNameKey> allVisited = new LinkedHashSet<>();
       for (BranchNameKey updatedBranch : updatedBranches) {
         if (allVisited.contains(updatedBranch)) {
@@ -332,15 +331,15 @@
         Map<BranchNameKey, GitModules> branchGitModules,
         MergeOpRepoManager orm)
         throws IOException {
-      logger.atFine().log("Calculating possible superprojects for %s", srcBranch);
       Collection<SubmoduleSubscription> ret = new ArrayList<>();
+      if (RefNames.isGerritRef(srcBranch.branch())) return ret;
+
       Project.NameKey srcProject = srcBranch.project();
       for (SubscribeSection s :
           projectCache
               .get(srcProject)
               .orElseThrow(illegalState(srcProject))
               .getSubscribeSections(srcBranch)) {
-        logger.atFine().log("Checking subscribe section %s", s);
         Collection<BranchNameKey> branches = getDestinationBranches(srcBranch, s, orm);
         for (BranchNameKey targetBranch : branches) {
           Project.NameKey targetProject = targetBranch.project();
@@ -348,11 +347,11 @@
             OpenRepo or = orm.getRepo(targetProject);
             ObjectId id = or.repo.resolve(targetBranch.branch());
             if (id == null) {
-              logger.atFine().log("The branch %s doesn't exist.", targetBranch);
+              logger.atFine().log("SubscribeSection %s: branch %s doesn't exist.", s, targetBranch);
               continue;
             }
           } catch (NoSuchProjectException e) {
-            logger.atFine().log("The project %s doesn't exist", targetProject);
+            logger.atFine().log("SubscribeSection %s: project %s doesn't exist", s, targetProject);
             continue;
           }
 
diff --git a/java/com/google/gerrit/server/update/BatchUpdate.java b/java/com/google/gerrit/server/update/BatchUpdate.java
index 3b0cd9a..f558d30 100644
--- a/java/com/google/gerrit/server/update/BatchUpdate.java
+++ b/java/com/google/gerrit/server/update/BatchUpdate.java
@@ -75,6 +75,7 @@
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.Map.Entry;
 import java.util.Objects;
 import java.util.Optional;
 import java.util.TimeZone;
@@ -553,11 +554,15 @@
     try {
       logDebug("Executing updateRepo on %d ops", ops.size());
       RepoContextImpl ctx = new RepoContextImpl();
-      for (BatchUpdateOp op : ops.values()) {
+      for (Entry<Change.Id, BatchUpdateOp> op : ops.entries()) {
         try (TraceContext.TraceTimer ignored =
             TraceContext.newTimer(
-                op.getClass().getSimpleName() + "#updateRepo", Metadata.empty())) {
-          op.updateRepo(ctx);
+                op.getClass().getSimpleName() + "#updateRepo",
+                Metadata.builder()
+                    .projectName(project.get())
+                    .changeId(op.getKey().get())
+                    .build())) {
+          op.getValue().updateRepo(ctx);
         }
       }
 
@@ -672,7 +677,8 @@
       for (BatchUpdateOp op : e.getValue()) {
         try (TraceContext.TraceTimer ignored =
             TraceContext.newTimer(
-                op.getClass().getSimpleName() + "#updateChange", Metadata.empty())) {
+                op.getClass().getSimpleName() + "#updateChange",
+                Metadata.builder().projectName(project.get()).changeId(id.get()).build())) {
           dirty |= op.updateChange(ctx);
         }
       }
diff --git a/java/com/google/gerrit/sshd/HostKeyProvider.java b/java/com/google/gerrit/sshd/HostKeyProvider.java
index 3578fb9..0e9b46b 100644
--- a/java/com/google/gerrit/sshd/HostKeyProvider.java
+++ b/java/com/google/gerrit/sshd/HostKeyProvider.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.sshd;
 
+import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -22,11 +23,13 @@
 import java.nio.file.Path;
 import java.util.ArrayList;
 import java.util.List;
+import org.apache.sshd.common.config.keys.KeyUtils;
 import org.apache.sshd.common.keyprovider.FileKeyPairProvider;
 import org.apache.sshd.common.keyprovider.KeyPairProvider;
 import org.apache.sshd.server.keyprovider.SimpleGeneratorHostKeyProvider;
 
 class HostKeyProvider implements Provider<KeyPairProvider> {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
   private final SitePaths site;
 
   @Inject
@@ -63,7 +66,11 @@
     if (Files.exists(objKey)) {
       if (stdKeys.isEmpty()) {
         SimpleGeneratorHostKeyProvider p = new SimpleGeneratorHostKeyProvider();
+        p.setAlgorithm(KeyUtils.RSA_ALGORITHM);
         p.setPath(objKey.toAbsolutePath());
+        logger.atWarning().log(
+            "Defaulting to RSA algorithm for SSH key exchange."
+                + "This is a weak security setting, consider changing it (see 'sshd.kex' documentation section).");
         return p;
       }
       // Both formats of host key exist, we don't know which format
diff --git a/java/com/google/gerrit/sshd/SshCommand.java b/java/com/google/gerrit/sshd/SshCommand.java
index c94b25c..93c6c2c 100644
--- a/java/com/google/gerrit/sshd/SshCommand.java
+++ b/java/com/google/gerrit/sshd/SshCommand.java
@@ -19,6 +19,7 @@
 import com.google.gerrit.server.DynamicOptions;
 import com.google.gerrit.server.RequestInfo;
 import com.google.gerrit.server.RequestListener;
+import com.google.gerrit.server.cancellation.RequestCancelledException;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.logging.PerformanceLogContext;
 import com.google.gerrit.server.logging.PerformanceLogger;
@@ -61,6 +62,12 @@
                   RequestInfo.builder(RequestInfo.RequestType.SSH, user, traceContext).build();
               requestListeners.runEach(l -> l.onRequest(requestInfo));
               SshCommand.this.run();
+            } catch (RequestCancelledException e) {
+              StringBuilder msg = new StringBuilder(e.formatCancellationReason());
+              if (e.getCancellationMessage().isPresent()) {
+                msg.append(String.format(" (%s)", e.getCancellationMessage().get()));
+              }
+              stderr.println(msg.toString());
             } finally {
               stdout.flush();
               stderr.flush();
diff --git a/java/com/google/gerrit/sshd/SshDaemon.java b/java/com/google/gerrit/sshd/SshDaemon.java
index fa20b9c..553287ec 100644
--- a/java/com/google/gerrit/sshd/SshDaemon.java
+++ b/java/com/google/gerrit/sshd/SshDaemon.java
@@ -83,6 +83,7 @@
 import org.apache.sshd.common.io.IoServiceFactoryFactory;
 import org.apache.sshd.common.io.IoSession;
 import org.apache.sshd.common.io.nio2.Nio2ServiceFactoryFactory;
+import org.apache.sshd.common.kex.BuiltinDHFactories;
 import org.apache.sshd.common.kex.KeyExchangeFactory;
 import org.apache.sshd.common.keyprovider.KeyPairProvider;
 import org.apache.sshd.common.mac.Mac;
@@ -479,7 +480,13 @@
   }
 
   private void initKeyExchanges(Config cfg) {
-    List<KeyExchangeFactory> a = ServerBuilder.setUpDefaultKeyExchanges(true);
+    List<KeyExchangeFactory> a =
+        NamedFactory.setUpTransformedFactories(
+            true,
+            cfg.getBoolean("sshd", null, "enableDeprecatedKexAlgorithms", false)
+                ? BuiltinDHFactories.VALUES
+                : BaseBuilder.DEFAULT_KEX_PREFERENCE,
+            ServerBuilder.DH2KEX);
     setKeyExchangeFactories(filter(cfg, "kex", a.toArray(new KeyExchangeFactory[a.size()])));
   }
 
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/java/com/google/gerrit/testing/InMemoryModule.java b/java/com/google/gerrit/testing/InMemoryModule.java
index 7ca763a1..06d9453 100644
--- a/java/com/google/gerrit/testing/InMemoryModule.java
+++ b/java/com/google/gerrit/testing/InMemoryModule.java
@@ -54,6 +54,7 @@
 import com.google.gerrit.server.config.CanonicalWebUrlModule;
 import com.google.gerrit.server.config.CanonicalWebUrlProvider;
 import com.google.gerrit.server.config.DefaultUrlFormatter;
+import com.google.gerrit.server.config.FileBasedGlobalPluginConfigProvider;
 import com.google.gerrit.server.config.GerritGlobalModule;
 import com.google.gerrit.server.config.GerritInstanceIdModule;
 import com.google.gerrit.server.config.GerritInstanceNameModule;
@@ -62,10 +63,12 @@
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.GerritServerId;
 import com.google.gerrit.server.config.GerritServerIdProvider;
+import com.google.gerrit.server.config.GlobalPluginConfigProvider;
 import com.google.gerrit.server.config.SendEmailExecutor;
 import com.google.gerrit.server.config.SitePath;
 import com.google.gerrit.server.config.TrackingFooters;
 import com.google.gerrit.server.config.TrackingFootersProvider;
+import com.google.gerrit.server.experiments.ConfigExperimentFeatures;
 import com.google.gerrit.server.git.GarbageCollection;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.PerThreadRequestScope;
@@ -194,6 +197,7 @@
     bind(Path.class).annotatedWith(SitePath.class).toInstance(Paths.get("."));
     bind(Config.class).annotatedWith(GerritServerConfig.class).toInstance(cfg);
     bind(GerritOptions.class).toInstance(new GerritOptions(false, false));
+    bind(GlobalPluginConfigProvider.class).to(FileBasedGlobalPluginConfigProvider.class);
 
     bind(GitRepositoryManager.class).to(InMemoryRepositoryManager.class);
     bind(InMemoryRepositoryManager.class).in(SINGLETON);
@@ -250,7 +254,8 @@
     install(new RestApiModule());
     install(new OAuthRestModule());
     install(new DefaultProjectNameLockManager.Module());
-    install(new FileInfoJsonModule(cfg));
+    install(new FileInfoJsonModule());
+    install(new ConfigExperimentFeatures.Module());
 
     bind(ProjectOperations.class).to(ProjectOperationsImpl.class);
   }
diff --git a/java/com/google/gerrit/truth/NullAwareCorrespondence.java b/java/com/google/gerrit/truth/NullAwareCorrespondence.java
index 687ad94..5b107a6 100644
--- a/java/com/google/gerrit/truth/NullAwareCorrespondence.java
+++ b/java/com/google/gerrit/truth/NullAwareCorrespondence.java
@@ -7,15 +7,6 @@
 // http://www.apache.org/licenses/LICENSE-2.0
 //
 // Unless required by applicable law or agreed to in writing, software
-// Copyright (C) 2020 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
 // distributed under the License is distributed on an "AS IS" BASIS,
 // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 // See the License for the specific language governing permissions and
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
index 1d50e82..d54574a 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
@@ -47,8 +47,8 @@
 import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
 import static org.mockito.Mockito.verifyNoMoreInteractions;
-import static org.mockito.Mockito.verifyZeroInteractions;
 
 import com.github.rholder.retry.StopStrategies;
 import com.google.common.collect.FluentIterable;
@@ -686,7 +686,7 @@
               () -> gApi.accounts().id(activatableAccountId.get()).setActive(false));
       assertThat(thrown).hasMessageThat().isEqualTo("account not active");
       assertThat(accountOperations.account(activatableAccountId).get().active()).isFalse();
-      verifyZeroInteractions(listener);
+      verifyNoInteractions(listener);
 
       // Activate account that can be activated
       gApi.accounts().id(activatableAccountId.get()).setActive(true);
@@ -697,7 +697,7 @@
       // Activate account that is already active
       gApi.accounts().id(activatableAccountId.get()).setActive(true);
       assertThat(accountOperations.account(activatableAccountId).get().active()).isTrue();
-      verifyZeroInteractions(listener);
+      verifyNoMoreInteractions(listener);
 
       // Try deactivating account that cannot be deactivated
       thrown =
@@ -706,13 +706,13 @@
               () -> gApi.accounts().id(activatableAccountId.get()).setActive(false));
       assertThat(thrown).hasMessageThat().isEqualTo("not allowed to deactive account");
       assertThat(accountOperations.account(activatableAccountId).get().active()).isTrue();
-      verifyZeroInteractions(listener);
+      verifyNoMoreInteractions(listener);
 
       /* Test account that can be deactivated, but not activated */
       // Activate account that is already inactive
       gApi.accounts().id(deactivatableAccountId.get()).setActive(true);
       assertThat(accountOperations.account(deactivatableAccountId).get().active()).isTrue();
-      verifyZeroInteractions(listener);
+      verifyNoMoreInteractions(listener);
 
       // Deactivate account that can be deactivated
       gApi.accounts().id(deactivatableAccountId.get()).setActive(false);
@@ -727,7 +727,7 @@
               () -> gApi.accounts().id(deactivatableAccountId.get()).setActive(false));
       assertThat(thrown).hasMessageThat().isEqualTo("account not active");
       assertThat(accountOperations.account(deactivatableAccountId).get().active()).isFalse();
-      verifyZeroInteractions(listener);
+      verifyNoMoreInteractions(listener);
 
       // Try activating account that cannot be activated
       thrown =
@@ -736,7 +736,7 @@
               () -> gApi.accounts().id(deactivatableAccountId.get()).setActive(true));
       assertThat(thrown).hasMessageThat().isEqualTo("not allowed to active account");
       assertThat(accountOperations.account(deactivatableAccountId).get().active()).isFalse();
-      verifyZeroInteractions(listener);
+      verifyNoMoreInteractions(listener);
     }
   }
 
@@ -2974,6 +2974,46 @@
     assertThat(e).hasMessageThat().contains("foo:bar");
   }
 
+  @Test
+  public void externalIdBatchUpdates_commitMsg_multipleAccounts() throws Exception {
+    ExternalId extId1 =
+        ExternalId.createWithEmail(ExternalId.Key.parse("foo:bar"), admin.id(), "1@foo.com");
+    ExternalId extId2 =
+        ExternalId.createWithEmail(ExternalId.Key.parse("foo:baz"), user.id(), "2@foo.com");
+
+    AccountsUpdate.UpdateArguments ua1 =
+        new AccountsUpdate.UpdateArguments(
+            "first message", admin.id(), (a, u) -> u.addExternalId(extId1));
+    AccountsUpdate.UpdateArguments ua2 =
+        new AccountsUpdate.UpdateArguments(
+            "second message", user.id(), (a, u) -> u.addExternalId(extId2));
+    accountsUpdateProvider.get().updateBatch(ImmutableList.of(ua1, ua2));
+
+    try (Repository allUsersRepo = repoManager.openRepository(allUsers);
+        RevWalk rw = new RevWalk(allUsersRepo)) {
+      RevCommit commit =
+          rw.parseCommit(allUsersRepo.exactRef(RefNames.REFS_EXTERNAL_IDS).getObjectId());
+
+      assertThat(commit.getFullMessage()).isEqualTo("Batch update for 2 accounts\n");
+    }
+  }
+
+  @Test
+  public void externalIdBatchUpdates_commitMsg_singleAccount() throws Exception {
+    ExternalId extId =
+        ExternalId.createWithEmail(ExternalId.Key.parse("foo:bar"), admin.id(), "1@foo.com");
+
+    accountsUpdateProvider.get().update("foobar", admin.id(), (a, u) -> u.addExternalId(extId));
+
+    try (Repository allUsersRepo = repoManager.openRepository(allUsers);
+        RevWalk rw = new RevWalk(allUsersRepo)) {
+      RevCommit commit =
+          rw.parseCommit(allUsersRepo.exactRef(RefNames.REFS_EXTERNAL_IDS).getObjectId());
+
+      assertThat(commit.getFullMessage()).isEqualTo("foobar\n");
+    }
+  }
+
   private void createDraft(PushOneCommit.Result r, String path, String message) throws Exception {
     DraftInput in = new DraftInput();
     in.path = path;
diff --git a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
index 529ce73..c08aa7f 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;
@@ -68,6 +69,7 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Lists;
+import com.google.common.collect.MoreCollectors;
 import com.google.common.truth.ThrowableSubject;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.ChangeIndexedCounter;
@@ -76,6 +78,7 @@
 import com.google.gerrit.acceptance.GitUtil;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.acceptance.TestProjectInput;
 import com.google.gerrit.acceptance.UseClockStep;
 import com.google.gerrit.acceptance.UseTimezone;
@@ -100,9 +103,14 @@
 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.entities.SubmitRequirementExpressionResult;
+import com.google.gerrit.entities.SubmitRequirementResult;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.annotations.Exports;
 import com.google.gerrit.extensions.api.accounts.DeleteDraftCommentsInput;
+import com.google.gerrit.extensions.api.changes.AttentionSetInput;
 import com.google.gerrit.extensions.api.changes.DeleteReviewerInput;
 import com.google.gerrit.extensions.api.changes.DeleteVoteInput;
 import com.google.gerrit.extensions.api.changes.DraftApi;
@@ -142,6 +150,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;
@@ -165,6 +175,7 @@
 import com.google.gerrit.server.index.change.ChangeIndex;
 import com.google.gerrit.server.index.change.ChangeIndexCollection;
 import com.google.gerrit.server.index.change.IndexedChangeQuery;
+import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.patch.DiffSummary;
 import com.google.gerrit.server.patch.DiffSummaryKey;
 import com.google.gerrit.server.patch.IntraLineDiff;
@@ -209,6 +220,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
@@ -802,6 +814,28 @@
   }
 
   @Test
+  public void rebaseAsUploaderInAttentionSet() throws Exception {
+    // Create two changes both with the same parent
+    PushOneCommit.Result r = createChange();
+    testRepo.reset("HEAD~1");
+    PushOneCommit.Result r2 = createChange();
+
+    // Approve and submit the first change
+    RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
+    revision.review(ReviewInput.approve());
+    revision.submit();
+
+    TestAccount admin2 = accountCreator.admin2();
+    requestScopeOperations.setApiUser(admin2.id());
+    amendChangeWithUploader(r2, project, admin2);
+    gApi.changes()
+        .id(r2.getChangeId())
+        .addToAttentionSet(new AttentionSetInput(admin2.id().toString(), "manual update"));
+
+    gApi.changes().id(r2.getChangeId()).rebase();
+  }
+
+  @Test
   public void rebaseOnChangeNumber() throws Exception {
     String branchTip = testRepo.getRepository().exactRef("HEAD").getObjectId().name();
     PushOneCommit.Result r1 = createChange();
@@ -3990,6 +4024,307 @@
   }
 
   @Test
+  public void submitRequirement_withLabelEqualsMax() throws Exception {
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("code-review")
+            .setSubmittabilityExpression(
+                SubmitRequirementExpression.create("label:code-review=MAX"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(change.submitRequirements, "code-review", Status.UNSATISFIED);
+
+    voteLabel(changeId, "code-review", 2);
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(change.submitRequirements, "code-review", Status.SATISFIED);
+  }
+
+  @Test
+  public void submitRequirement_withLabelEqualsMinBlockingSubmission() throws Exception {
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("code-review")
+            .setSubmittabilityExpression(
+                SubmitRequirementExpression.create("-label:code-review=MIN"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    // Requirement is satisfied because there are no votes
+    assertSubmitRequirementStatus(change.submitRequirements, "code-review", Status.SATISFIED);
+
+    voteLabel(changeId, "code-review", -1);
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    // Requirement is still satisfied because -1 is not the max negative value
+    assertSubmitRequirementStatus(change.submitRequirements, "code-review", Status.SATISFIED);
+
+    voteLabel(changeId, "code-review", -2);
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    // Requirement is now unsatisfied because -2 is the max negative value
+    assertSubmitRequirementStatus(change.submitRequirements, "code-review", Status.UNSATISFIED);
+  }
+
+  @Test
+  public void submitRequirement_withLabelEqualsAny() throws Exception {
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("code-review")
+            .setSubmittabilityExpression(
+                SubmitRequirementExpression.create("label:code-review=ANY"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(change.submitRequirements, "code-review", Status.UNSATISFIED);
+
+    voteLabel(changeId, "code-review", 1);
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(change.submitRequirements, "code-review", Status.SATISFIED);
+  }
+
+  @Test
+  public void submitRequirementIsSatisfied_whenSubmittabilityExpressionIsFulfilled()
+      throws Exception {
+    configSubmitRequirement(
+        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 submitRequirement_storedForClosedChanges() throws Exception {
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("code-review")
+            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:code-review=+2"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    PushOneCommit.Result r = createChange("Add a file", "foo", "content");
+    String changeId = r.getChangeId();
+
+    voteLabel(changeId, "code-review", 2);
+
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(change.submitRequirements, "code-review", Status.SATISFIED);
+
+    RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
+    revision.review(ReviewInput.approve());
+    revision.submit();
+
+    ChangeNotes notes = notesFactory.create(project, r.getChange().getId());
+
+    SubmitRequirementResult result =
+        notes.getSubmitRequirementsResult().stream().collect(MoreCollectors.onlyElement());
+    assertThat(result.status()).isEqualTo(SubmitRequirementResult.Status.SATISFIED);
+    assertThat(result.submittabilityExpressionResult().status())
+        .isEqualTo(SubmitRequirementExpressionResult.Status.PASS);
+    assertThat(result.submittabilityExpressionResult().expression().expressionString())
+        .isEqualTo("label:code-review=+2");
+  }
+
+  @Test
   public void fourByteEmoji() throws Exception {
     // U+1F601 GRINNING FACE WITH SMILING EYES
     String smile = new String(Character.toChars(0x1f601));
@@ -4630,4 +4965,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/api/change/StickyApprovalsIT.java b/javatests/com/google/gerrit/acceptance/api/change/StickyApprovalsIT.java
index 503ab11..53a9364 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/StickyApprovalsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/StickyApprovalsIT.java
@@ -28,15 +28,14 @@
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static com.google.gerrit.server.project.testing.TestLabels.labelBuilder;
 import static com.google.gerrit.server.project.testing.TestLabels.value;
-import static org.eclipse.jgit.lib.Constants.HEAD;
 
 import com.google.common.cache.Cache;
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.GitUtil;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.acceptance.testsuite.change.ChangeKindCreator;
 import com.google.gerrit.acceptance.testsuite.change.ChangeOperations;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
@@ -45,26 +44,19 @@
 import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.RefNames;
-import com.google.gerrit.extensions.api.changes.CherryPickInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
-import com.google.gerrit.extensions.api.changes.RevisionApi;
 import com.google.gerrit.extensions.client.ChangeKind;
 import com.google.gerrit.extensions.common.ApprovalInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
-import com.google.gerrit.extensions.common.CommitInfo;
 import com.google.gerrit.server.change.ChangeKindCacheImpl;
 import com.google.gerrit.server.project.testing.TestLabels;
 import com.google.inject.Inject;
 import com.google.inject.name.Named;
 import java.util.EnumSet;
 import java.util.HashMap;
-import java.util.List;
 import java.util.Map;
 import java.util.Set;
-import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.revwalk.RevCommit;
 import org.junit.Before;
 import org.junit.Test;
 
@@ -73,6 +65,7 @@
   @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
   @Inject private ChangeOperations changeOperations;
+  @Inject private ChangeKindCreator changeKindCreator;
 
   @Inject
   @Named("change_kind")
@@ -135,11 +128,11 @@
         EnumSet.of(REWORK, TRIVIAL_REBASE, NO_CODE_CHANGE, MERGE_FIRST_PARENT_UPDATE, NO_CHANGE)) {
       testRepo.reset(projectOperations.project(project).getHead("master"));
 
-      String changeId = createChange(changeKind);
+      String changeId = changeKindCreator.createChange(changeKind, testRepo, admin);
       vote(admin, changeId, 2, 1);
       vote(user, changeId, 1, -1);
 
-      updateChange(changeId, changeKind);
+      changeKindCreator.updateChange(changeId, changeKind, testRepo, admin, project);
       ChangeInfo c = detailedChange(changeId);
       assertVotes(c, admin, 2, 0, changeKind);
       assertVotes(c, user, 1, 0, changeKind);
@@ -147,6 +140,49 @@
   }
 
   @Test
+  public void stickyWhenCopyConditionIsTrue() throws Exception {
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      u.getConfig().updateLabelType(LabelId.CODE_REVIEW, b -> b.setCopyCondition("is:ANY"));
+      u.save();
+    }
+
+    for (ChangeKind changeKind :
+        EnumSet.of(REWORK, TRIVIAL_REBASE, NO_CODE_CHANGE, MERGE_FIRST_PARENT_UPDATE, NO_CHANGE)) {
+      testRepo.reset(projectOperations.project(project).getHead("master"));
+
+      String changeId = changeKindCreator.createChange(changeKind, testRepo, admin);
+      vote(admin, changeId, 2, 1);
+      vote(user, changeId, 1, -1);
+
+      changeKindCreator.updateChange(changeId, changeKind, testRepo, admin, project);
+      ChangeInfo c = detailedChange(changeId);
+      assertVotes(c, admin, 2, 0, changeKind);
+      assertVotes(c, user, 1, 0, changeKind);
+    }
+  }
+
+  @Test
+  public void stickyEvenWhenUserCantSeeUploaderInGroup() throws Exception {
+    // user can't see admin group
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      String administratorsUUID = gApi.groups().query("name:Administrators").get().get(0).id;
+      u.getConfig()
+          .updateLabelType(
+              LabelId.CODE_REVIEW, b -> b.setCopyCondition("approverin:" + administratorsUUID));
+      u.save();
+    }
+
+    String changeId = createChange().getChangeId();
+    approve(changeId);
+    amendChange(changeId);
+    vote(user, changeId, 1, -1); // Invalidate cache
+    requestScopeOperations.setApiUser(user.id());
+    ChangeInfo c = detailedChange(changeId);
+    assertVotes(c, admin, 2, 0);
+    assertVotes(c, user, 1, -1);
+  }
+
+  @Test
   public void stickyOnMinScore() throws Exception {
     try (ProjectConfigUpdate u = updateProject(project)) {
       u.getConfig().updateLabelType(LabelId.CODE_REVIEW, b -> b.setCopyMinScore(true));
@@ -157,11 +193,11 @@
         EnumSet.of(REWORK, TRIVIAL_REBASE, NO_CODE_CHANGE, MERGE_FIRST_PARENT_UPDATE, NO_CHANGE)) {
       testRepo.reset(projectOperations.project(project).getHead("master"));
 
-      String changeId = createChange(changeKind);
+      String changeId = changeKindCreator.createChange(changeKind, testRepo, admin);
       vote(admin, changeId, -1, 1);
       vote(user, changeId, -2, -1);
 
-      updateChange(changeId, changeKind);
+      changeKindCreator.updateChange(changeId, changeKind, testRepo, admin, project);
       ChangeInfo c = detailedChange(changeId);
       assertVotes(c, admin, 0, 0, changeKind);
       assertVotes(c, user, -2, 0, changeKind);
@@ -169,6 +205,30 @@
   }
 
   @Test
+  public void stickyWhenEitherBooleanConfigsOrCopyConditionAreTrue() throws Exception {
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      u.getConfig()
+          .updateLabelType(
+              LabelId.CODE_REVIEW, b -> b.setCopyCondition("is:MAX").setCopyMinScore(true));
+      u.save();
+    }
+
+    for (ChangeKind changeKind :
+        EnumSet.of(REWORK, TRIVIAL_REBASE, NO_CODE_CHANGE, MERGE_FIRST_PARENT_UPDATE, NO_CHANGE)) {
+      testRepo.reset(projectOperations.project(project).getHead("master"));
+
+      String changeId = changeKindCreator.createChange(changeKind, testRepo, admin);
+      vote(admin, changeId, 2, 1);
+      vote(user, changeId, -2, -1);
+
+      changeKindCreator.updateChange(changeId, changeKind, testRepo, admin, project);
+      ChangeInfo c = detailedChange(changeId);
+      assertVotes(c, admin, 2, 0, changeKind);
+      assertVotes(c, user, -2, 0, changeKind);
+    }
+  }
+
+  @Test
   public void stickyOnMaxScore() throws Exception {
     try (ProjectConfigUpdate u = updateProject(project)) {
       u.getConfig().updateLabelType(LabelId.CODE_REVIEW, b -> b.setCopyMaxScore(true));
@@ -179,11 +239,11 @@
         EnumSet.of(REWORK, TRIVIAL_REBASE, NO_CODE_CHANGE, MERGE_FIRST_PARENT_UPDATE, NO_CHANGE)) {
       testRepo.reset(projectOperations.project(project).getHead("master"));
 
-      String changeId = createChange(changeKind);
+      String changeId = changeKindCreator.createChange(changeKind, testRepo, admin);
       vote(admin, changeId, 2, 1);
       vote(user, changeId, 1, -1);
 
-      updateChange(changeId, changeKind);
+      changeKindCreator.updateChange(changeId, changeKind, testRepo, admin, project);
       ChangeInfo c = detailedChange(changeId);
       assertVotes(c, admin, 2, 0, changeKind);
       assertVotes(c, user, 0, 0, changeKind);
@@ -205,12 +265,12 @@
         EnumSet.of(REWORK, TRIVIAL_REBASE, NO_CODE_CHANGE, MERGE_FIRST_PARENT_UPDATE, NO_CHANGE)) {
       testRepo.reset(projectOperations.project(project).getHead("master"));
 
-      String changeId = createChange(changeKind);
+      String changeId = changeKindCreator.createChange(changeKind, testRepo, admin);
       vote(admin, changeId, -1, 1);
       vote(user, changeId, -2, -1);
       vote(user2, changeId, 1, -1);
 
-      updateChange(changeId, changeKind);
+      changeKindCreator.updateChange(changeId, changeKind, testRepo, admin, project);
       ChangeInfo c = detailedChange(changeId);
       assertVotes(c, admin, -1, 0, changeKind);
       assertVotes(c, user, 0, 0, changeKind);
@@ -226,16 +286,16 @@
       u.save();
     }
 
-    String changeId = createChange(TRIVIAL_REBASE);
+    String changeId = changeKindCreator.createChange(TRIVIAL_REBASE, testRepo, admin);
     vote(admin, changeId, 2, 1);
     vote(user, changeId, -2, -1);
 
-    updateChange(changeId, NO_CHANGE);
+    changeKindCreator.updateChange(changeId, NO_CHANGE, testRepo, admin, project);
     ChangeInfo c = detailedChange(changeId);
     assertVotes(c, admin, 2, 0, NO_CHANGE);
     assertVotes(c, user, -2, 0, NO_CHANGE);
 
-    updateChange(changeId, TRIVIAL_REBASE);
+    changeKindCreator.updateChange(changeId, TRIVIAL_REBASE, testRepo, admin, project);
     c = detailedChange(changeId);
     assertVotes(c, admin, 2, 0, TRIVIAL_REBASE);
     assertVotes(c, user, -2, 0, TRIVIAL_REBASE);
@@ -248,7 +308,8 @@
     vote(admin, changeId, 2, 1);
     vote(user, changeId, -2, -1);
 
-    String cherryPickChangeId = cherryPick(changeId, TRIVIAL_REBASE);
+    String cherryPickChangeId =
+        changeKindCreator.cherryPick(changeId, TRIVIAL_REBASE, testRepo, admin, project);
     c = detailedChange(cherryPickChangeId);
     assertVotes(c, admin, 2, 0);
     assertVotes(c, user, -2, 0);
@@ -259,7 +320,7 @@
     vote(admin, changeId, 2, 1);
     vote(user, changeId, -2, -1);
 
-    cherryPickChangeId = cherryPick(changeId, REWORK);
+    cherryPickChangeId = changeKindCreator.cherryPick(changeId, REWORK, testRepo, admin, project);
     c = detailedChange(cherryPickChangeId);
     assertVotes(c, admin, 0, 0);
     assertVotes(c, user, 0, 0);
@@ -272,16 +333,16 @@
       u.save();
     }
 
-    String changeId = createChange(NO_CODE_CHANGE);
+    String changeId = changeKindCreator.createChange(NO_CODE_CHANGE, testRepo, admin);
     vote(admin, changeId, 2, 1);
     vote(user, changeId, -2, -1);
 
-    updateChange(changeId, NO_CHANGE);
+    changeKindCreator.updateChange(changeId, NO_CHANGE, testRepo, admin, project);
     ChangeInfo c = detailedChange(changeId);
     assertVotes(c, admin, 0, 1, NO_CHANGE);
     assertVotes(c, user, 0, -1, NO_CHANGE);
 
-    updateChange(changeId, NO_CODE_CHANGE);
+    changeKindCreator.updateChange(changeId, NO_CODE_CHANGE, testRepo, admin, project);
     c = detailedChange(changeId);
     assertVotes(c, admin, 0, 1, NO_CODE_CHANGE);
     assertVotes(c, user, 0, -1, NO_CODE_CHANGE);
@@ -298,16 +359,16 @@
       u.save();
     }
 
-    String changeId = createChange(MERGE_FIRST_PARENT_UPDATE);
+    String changeId = changeKindCreator.createChange(MERGE_FIRST_PARENT_UPDATE, testRepo, admin);
     vote(admin, changeId, 2, 1);
     vote(user, changeId, -2, -1);
 
-    updateChange(changeId, NO_CHANGE);
+    changeKindCreator.updateChange(changeId, NO_CHANGE, testRepo, admin, project);
     ChangeInfo c = detailedChange(changeId);
     assertVotes(c, admin, 2, 0, NO_CHANGE);
     assertVotes(c, user, -2, 0, NO_CHANGE);
 
-    updateChange(changeId, MERGE_FIRST_PARENT_UPDATE);
+    changeKindCreator.updateChange(changeId, MERGE_FIRST_PARENT_UPDATE, testRepo, admin, project);
     c = detailedChange(changeId);
     assertVotes(c, admin, 2, 0, MERGE_FIRST_PARENT_UPDATE);
     assertVotes(c, user, -2, 0, MERGE_FIRST_PARENT_UPDATE);
@@ -322,11 +383,11 @@
       u.save();
     }
 
-    String changeId = createChangeForMergeCommit();
+    String changeId = changeKindCreator.createChangeForMergeCommit(testRepo, admin);
     vote(admin, changeId, 2, 1);
     vote(user, changeId, -2, -1);
 
-    updateSecondParent(changeId);
+    changeKindCreator.updateSecondParent(changeId, testRepo, admin);
     ChangeInfo c = detailedChange(changeId);
     assertVotes(c, admin, 0, 0, null);
     assertVotes(c, user, 0, 0, null);
@@ -439,7 +500,7 @@
         EnumSet.of(REWORK, TRIVIAL_REBASE, NO_CODE_CHANGE, MERGE_FIRST_PARENT_UPDATE, NO_CHANGE)) {
       testRepo.reset(projectOperations.project(project).getHead("master"));
 
-      String changeId = createChange(changeKind);
+      String changeId = changeKindCreator.createChange(changeKind, testRepo, admin);
       vote(admin, changeId, 2, 1);
       vote(user, changeId, -2, -1);
 
@@ -450,7 +511,7 @@
       assertVotes(c, admin, 0, 0, null);
       assertVotes(c, user, 0, 0, null);
 
-      updateChange(changeId, changeKind);
+      changeKindCreator.updateChange(changeId, changeKind, testRepo, admin, project);
       c = detailedChange(changeId);
       assertVotes(c, admin, 0, 0, changeKind);
       assertVotes(c, user, 0, 0, changeKind);
@@ -465,16 +526,16 @@
       u.save();
     }
 
-    String changeId = createChange(REWORK);
+    String changeId = changeKindCreator.createChange(REWORK, testRepo, admin);
     vote(admin, changeId, 2, 1);
 
     for (int i = 0; i < 5; i++) {
-      updateChange(changeId, NO_CODE_CHANGE);
+      changeKindCreator.updateChange(changeId, NO_CODE_CHANGE, testRepo, admin, project);
       ChangeInfo c = detailedChange(changeId);
       assertVotes(c, admin, 2, 1, NO_CODE_CHANGE);
     }
 
-    updateChange(changeId, REWORK);
+    changeKindCreator.updateChange(changeId, REWORK, testRepo, admin, project);
     ChangeInfo c = detailedChange(changeId);
     assertVotes(c, admin, 2, 0, REWORK);
   }
@@ -491,11 +552,11 @@
       u.save();
     }
 
-    String changeId = createChange(REWORK);
+    String changeId = changeKindCreator.createChange(REWORK, testRepo, admin);
     vote(admin, changeId, 2, 1);
-    updateChange(changeId, NO_CODE_CHANGE);
-    updateChange(changeId, NO_CODE_CHANGE);
-    updateChange(changeId, NO_CODE_CHANGE);
+    changeKindCreator.updateChange(changeId, NO_CODE_CHANGE, testRepo, admin, project);
+    changeKindCreator.updateChange(changeId, NO_CODE_CHANGE, testRepo, admin, project);
+    changeKindCreator.updateChange(changeId, NO_CODE_CHANGE, testRepo, admin, project);
 
     Map<Integer, ObjectId> revisions = new HashMap<>();
     gApi.changes()
@@ -524,24 +585,24 @@
     }
 
     // Vote max score on PS1
-    String changeId = createChange(REWORK);
+    String changeId = changeKindCreator.createChange(REWORK, testRepo, admin);
     vote(admin, changeId, 2, 1);
 
     // Have someone else vote min score on PS2
-    updateChange(changeId, REWORK);
+    changeKindCreator.updateChange(changeId, REWORK, testRepo, admin, project);
     vote(user, changeId, -2, 0);
     ChangeInfo c = detailedChange(changeId);
     assertVotes(c, admin, 2, 0, REWORK);
     assertVotes(c, user, -2, 0, REWORK);
 
     // No vote changes on PS3
-    updateChange(changeId, REWORK);
+    changeKindCreator.updateChange(changeId, REWORK, testRepo, admin, project);
     c = detailedChange(changeId);
     assertVotes(c, admin, 2, 0, REWORK);
     assertVotes(c, user, -2, 0, REWORK);
 
     // Both users revote on PS4
-    updateChange(changeId, REWORK);
+    changeKindCreator.updateChange(changeId, REWORK, testRepo, admin, project);
     vote(admin, changeId, 1, 1);
     vote(user, changeId, 1, 1);
     c = detailedChange(changeId);
@@ -549,7 +610,7 @@
     assertVotes(c, user, 1, 1, REWORK);
 
     // New approvals shouldn't carry through to PS5
-    updateChange(changeId, REWORK);
+    changeKindCreator.updateChange(changeId, REWORK, testRepo, admin, project);
     c = detailedChange(changeId);
     assertVotes(c, admin, 0, 0, REWORK);
     assertVotes(c, user, 0, 0, REWORK);
@@ -564,10 +625,10 @@
     }
 
     // Vote max score on PS1
-    String changeId = createChange(REWORK);
+    String changeId = changeKindCreator.createChange(REWORK, testRepo, admin);
     vote(admin, changeId, label, 2);
     assertVotes(detailedChange(changeId), admin, label, 2, null);
-    updateChange(changeId, REWORK);
+    changeKindCreator.updateChange(changeId, REWORK, testRepo, admin, project);
     assertVotes(detailedChange(changeId), admin, label, 2, REWORK);
 
     // Delete vote that was copied via sticky approval
@@ -622,209 +683,17 @@
     for (ChangeKind changeKind : changeKinds) {
       testRepo.reset(projectOperations.project(project).getHead("master"));
 
-      String changeId = createChange(changeKind);
+      String changeId = changeKindCreator.createChange(changeKind, testRepo, admin);
       vote(admin, changeId, +2, 1);
       vote(user, changeId, -2, -1);
 
-      updateChange(changeId, changeKind);
+      changeKindCreator.updateChange(changeId, changeKind, testRepo, admin, project);
       ChangeInfo c = detailedChange(changeId);
       assertVotes(c, admin, 0, 0, changeKind);
       assertVotes(c, user, 0, 0, changeKind);
     }
   }
 
-  private String createChange(ChangeKind kind) throws Exception {
-    switch (kind) {
-      case NO_CODE_CHANGE:
-      case REWORK:
-      case TRIVIAL_REBASE:
-      case NO_CHANGE:
-        return createChange().getChangeId();
-      case MERGE_FIRST_PARENT_UPDATE:
-        return createChangeForMergeCommit();
-      default:
-        throw new IllegalStateException("unexpected change kind: " + kind);
-    }
-  }
-
-  private void updateChange(String changeId, ChangeKind changeKind) throws Exception {
-    switch (changeKind) {
-      case NO_CODE_CHANGE:
-        noCodeChange(changeId);
-        return;
-      case REWORK:
-        rework(changeId);
-        return;
-      case TRIVIAL_REBASE:
-        trivialRebase(changeId);
-        return;
-      case MERGE_FIRST_PARENT_UPDATE:
-        updateFirstParent(changeId);
-        return;
-      case NO_CHANGE:
-        noChange(changeId);
-        return;
-      default:
-        assertWithMessage("unexpected change kind: " + changeKind).fail();
-    }
-  }
-
-  private void noCodeChange(String changeId) throws Exception {
-    TestRepository<?>.CommitBuilder commitBuilder =
-        testRepo.amendRef("HEAD").insertChangeId(changeId.substring(1));
-    commitBuilder
-        .message("New subject " + System.nanoTime())
-        .author(admin.newIdent())
-        .committer(new PersonIdent(admin.newIdent(), testRepo.getDate()));
-    commitBuilder.create();
-    GitUtil.pushHead(testRepo, "refs/for/master", false);
-    assertThat(getChangeKind(changeId)).isEqualTo(NO_CODE_CHANGE);
-  }
-
-  private void noChange(String changeId) throws Exception {
-    ChangeInfo change = gApi.changes().id(changeId).get();
-    String commitMessage = change.revisions.get(change.currentRevision).commit.message;
-
-    TestRepository<?>.CommitBuilder commitBuilder =
-        testRepo.amendRef("HEAD").insertChangeId(changeId.substring(1));
-    commitBuilder
-        .message(commitMessage)
-        .author(admin.newIdent())
-        .committer(new PersonIdent(admin.newIdent(), testRepo.getDate()));
-    commitBuilder.create();
-    GitUtil.pushHead(testRepo, "refs/for/master", false);
-    assertThat(getChangeKind(changeId)).isEqualTo(NO_CHANGE);
-  }
-
-  private void rework(String changeId) throws Exception {
-    PushOneCommit push =
-        pushFactory.create(
-            admin.newIdent(),
-            testRepo,
-            PushOneCommit.SUBJECT,
-            PushOneCommit.FILE_NAME,
-            "new content " + System.nanoTime(),
-            changeId);
-    push.to("refs/for/master").assertOkStatus();
-    assertThat(getChangeKind(changeId)).isEqualTo(REWORK);
-  }
-
-  private void trivialRebase(String changeId) throws Exception {
-    requestScopeOperations.setApiUser(admin.id());
-    testRepo.reset(projectOperations.project(project).getHead("master"));
-    PushOneCommit push =
-        pushFactory.create(
-            admin.newIdent(),
-            testRepo,
-            "Other Change",
-            "a" + System.nanoTime() + ".txt",
-            PushOneCommit.FILE_CONTENT);
-    PushOneCommit.Result r = push.to("refs/for/master");
-    r.assertOkStatus();
-    RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
-    ReviewInput in = new ReviewInput().label(LabelId.CODE_REVIEW, 2).label(LabelId.VERIFIED, 1);
-    revision.review(in);
-    revision.submit();
-
-    gApi.changes().id(changeId).current().rebase();
-    assertThat(getChangeKind(changeId)).isEqualTo(TRIVIAL_REBASE);
-  }
-
-  private String createChangeForMergeCommit() throws Exception {
-    ObjectId initial = repo().exactRef(HEAD).getLeaf().getObjectId();
-
-    PushOneCommit.Result parent1 = createChange("parent 1", "p1.txt", "content 1");
-
-    testRepo.reset(initial);
-    PushOneCommit.Result parent2 = createChange("parent 2", "p2.txt", "content 2");
-
-    testRepo.reset(parent1.getCommit());
-
-    PushOneCommit merge = pushFactory.create(admin.newIdent(), testRepo);
-    merge.setParents(ImmutableList.of(parent1.getCommit(), parent2.getCommit()));
-    PushOneCommit.Result result = merge.to("refs/for/master");
-    result.assertOkStatus();
-    return result.getChangeId();
-  }
-
-  private void updateFirstParent(String changeId) throws Exception {
-    ChangeInfo c = detailedChange(changeId);
-    List<CommitInfo> parents = c.revisions.get(c.currentRevision).commit.parents;
-    String parent1 = parents.get(0).commit;
-    String parent2 = parents.get(1).commit;
-    RevCommit commitParent2 = testRepo.getRevWalk().parseCommit(ObjectId.fromString(parent2));
-
-    testRepo.reset(parent1);
-    PushOneCommit.Result newParent1 = createChange("new parent 1", "p1-1.txt", "content 1-1");
-
-    PushOneCommit merge = pushFactory.create(admin.newIdent(), testRepo, changeId);
-    merge.setParents(ImmutableList.of(newParent1.getCommit(), commitParent2));
-    PushOneCommit.Result result = merge.to("refs/for/master");
-    result.assertOkStatus();
-
-    assertThat(getChangeKind(changeId)).isEqualTo(MERGE_FIRST_PARENT_UPDATE);
-  }
-
-  private void updateSecondParent(String changeId) throws Exception {
-    ChangeInfo c = detailedChange(changeId);
-    List<CommitInfo> parents = c.revisions.get(c.currentRevision).commit.parents;
-    String parent1 = parents.get(0).commit;
-    String parent2 = parents.get(1).commit;
-    RevCommit commitParent1 = testRepo.getRevWalk().parseCommit(ObjectId.fromString(parent1));
-
-    testRepo.reset(parent2);
-    PushOneCommit.Result newParent2 = createChange("new parent 2", "p2-2.txt", "content 2-2");
-
-    PushOneCommit merge = pushFactory.create(admin.newIdent(), testRepo, changeId);
-    merge.setParents(ImmutableList.of(commitParent1, newParent2.getCommit()));
-    PushOneCommit.Result result = merge.to("refs/for/master");
-    result.assertOkStatus();
-
-    assertThat(getChangeKind(changeId)).isEqualTo(REWORK);
-  }
-
-  private String cherryPick(String changeId, ChangeKind changeKind) throws Exception {
-    switch (changeKind) {
-      case REWORK:
-      case TRIVIAL_REBASE:
-        break;
-      case NO_CODE_CHANGE:
-      case NO_CHANGE:
-      case MERGE_FIRST_PARENT_UPDATE:
-      default:
-        assertWithMessage("unexpected change kind: " + changeKind).fail();
-    }
-
-    testRepo.reset(projectOperations.project(project).getHead("master"));
-    PushOneCommit.Result r =
-        pushFactory
-            .create(
-                admin.newIdent(),
-                testRepo,
-                PushOneCommit.SUBJECT,
-                "other.txt",
-                "new content " + System.nanoTime())
-            .to("refs/for/master");
-    r.assertOkStatus();
-    vote(admin, r.getChangeId(), 2, 1);
-    merge(r);
-
-    String subject =
-        TRIVIAL_REBASE.equals(changeKind)
-            ? PushOneCommit.SUBJECT
-            : "Reworked change " + System.nanoTime();
-    CherryPickInput in = new CherryPickInput();
-    in.destination = "master";
-    in.message = String.format("%s\n\nChange-Id: %s", subject, changeId);
-    ChangeInfo c = gApi.changes().id(changeId).current().cherryPick(in).get();
-    return c.changeId;
-  }
-
-  private ChangeKind getChangeKind(String changeId) throws Exception {
-    ChangeInfo c = gApi.changes().id(changeId).get(CURRENT_REVISION);
-    return c.revisions.get(c.currentRevision).kind;
-  }
-
   private void vote(TestAccount user, String changeId, String label, int vote) throws Exception {
     requestScopeOperations.setApiUser(user.id());
     gApi.changes().id(changeId).current().review(new ReviewInput().label(label, vote));
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java b/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java
index 9bdc420..a01b340 100644
--- a/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java
@@ -16,6 +16,7 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.TruthJUnit.assume;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
 import static com.google.gerrit.entities.Patch.COMMIT_MSG;
 import static com.google.gerrit.entities.Patch.MERGE_LIST;
 import static com.google.gerrit.entities.Patch.PATCHSET_LEVEL;
@@ -35,7 +36,10 @@
 import com.google.gerrit.acceptance.GitUtil;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.PushOneCommit.Result;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.common.RawInputUtil;
+import com.google.gerrit.entities.Patch;
+import com.google.gerrit.entities.Permission;
 import com.google.gerrit.extensions.api.changes.FileApi;
 import com.google.gerrit.extensions.api.changes.RebaseInput;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo;
@@ -46,6 +50,8 @@
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.webui.EditWebLink;
+import com.google.gerrit.server.patch.DiffOperations;
+import com.google.gerrit.server.patch.filediff.FileDiffOutput;
 import com.google.inject.Inject;
 import java.awt.image.BufferedImage;
 import java.io.ByteArrayOutputStream;
@@ -57,6 +63,7 @@
 import java.util.Locale;
 import java.util.Map;
 import java.util.function.Function;
+import java.util.stream.Collectors;
 import java.util.stream.IntStream;
 import javax.imageio.ImageIO;
 import org.eclipse.jgit.lib.ObjectId;
@@ -81,11 +88,14 @@
   private static final String FILE_CONTENT2 = "1st line\n2nd line\n3rd line\n";
 
   @Inject private ExtensionRegistry extensionRegistry;
+  @Inject private DiffOperations diffOperations;
+  @Inject private ProjectOperations projectOperations;
 
   private boolean intraline;
   private boolean useNewDiffCacheListFiles;
   private boolean useNewDiffCacheGetDiff;
 
+  private ObjectId initialCommit;
   private ObjectId commit1;
   private String changeId;
   private String initialPatchSetId;
@@ -104,6 +114,8 @@
         baseConfig.getBoolean("cache", "diff_cache", "runNewDiffCache_GetDiff", false);
 
     ObjectId headCommit = testRepo.getRepository().resolve("HEAD");
+    initialCommit = headCommit;
+
     commit1 =
         addCommit(headCommit, ImmutableMap.of(FILE_NAME, FILE_CONTENT, FILE_NAME2, FILE_CONTENT2));
 
@@ -124,10 +136,34 @@
     assertDiffForNewFile(result, COMMIT_MSG, result.getCommit().getFullMessage());
   }
 
-  @Ignore
   @Test
   public void diffWithRootCommit() throws Exception {
-    // TODO(ghareeb): Implement this test
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.PUSH).ref("refs/*").group(adminGroupUuid()).force(true))
+        .update();
+
+    testRepo.reset(initialCommit);
+    PushOneCommit push =
+        pushFactory
+            .create(admin.newIdent(), testRepo, "subject", ImmutableMap.of("f.txt", "content"))
+            .noParent();
+    push.setForce(true);
+    PushOneCommit.Result result = push.to("refs/heads/master");
+
+    Map<String, FileDiffOutput> modifiedFiles =
+        diffOperations.listModifiedFilesAgainstParent(
+            project, result.getCommit(), /* parentNum= */ 0);
+
+    assertThat(modifiedFiles.keySet()).containsExactly("/COMMIT_MSG", "f.txt");
+    assertThat(
+            modifiedFiles.values().stream()
+                .map(FileDiffOutput::oldCommitId)
+                .collect(Collectors.toSet()))
+        .containsExactly(ObjectId.zeroId());
+    assertThat(modifiedFiles.get("/COMMIT_MSG").changeType()).isEqualTo(Patch.ChangeType.ADDED);
+    assertThat(modifiedFiles.get("f.txt").changeType()).isEqualTo(Patch.ChangeType.ADDED);
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java b/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
index 9d0b1f4..4590d34 100644
--- a/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
@@ -1207,10 +1207,15 @@
     ChangeApi originalChange = gApi.changes().id(project.get() + "~master~" + result.getChangeId());
 
     ChangeApi cherryPick = originalChange.revision(result.getCommit().name()).cherryPick(input);
+    String firstCherryPickChangeId = cherryPick.id();
     cherryPick.setWorkInProgress();
-    cherryPick = originalChange.revision(result.getCommit().name()).cherryPick(input);
+    gApi.changes()
+        .id(project.get() + "~master~" + result.getChangeId())
+        .revision(result.getCommit().name())
+        .cherryPick(input);
 
-    ChangeInfo secondCherryPickResult = cherryPick.get(ALL_REVISIONS);
+    ChangeInfo secondCherryPickResult =
+        gApi.changes().id(firstCherryPickChangeId).get(ALL_REVISIONS);
     assertThat(secondCherryPickResult.revisions).hasSize(2);
     assertThat(secondCherryPickResult.workInProgress).isNull();
   }
@@ -1416,22 +1421,12 @@
     BadRequestException thrown =
         assertThrows(
             BadRequestException.class,
-            () ->
-                gApi.changes()
-                    .id(r.getChangeId())
-                    .revision(r.getCommit().name())
-                    .files(3)
-                    .keySet());
+            () -> gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).files(3));
     assertThat(thrown).hasMessageThat().isEqualTo("invalid parent number: 3");
     thrown =
         assertThrows(
             BadRequestException.class,
-            () ->
-                gApi.changes()
-                    .id(r.getChangeId())
-                    .revision(r.getCommit().name())
-                    .files(-1)
-                    .keySet());
+            () -> gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).files(-1));
     assertThat(thrown).hasMessageThat().isEqualTo("invalid parent number: -1");
   }
 
@@ -1444,14 +1439,13 @@
 
     BadRequestException thrown =
         assertThrows(
-            BadRequestException.class,
-            () -> gApi.changes().id(changeId).revision(revId2).files(2).keySet());
+            BadRequestException.class, () -> gApi.changes().id(changeId).revision(revId2).files(2));
     assertThat(thrown).hasMessageThat().isEqualTo("invalid parent number: 2");
 
     thrown =
         assertThrows(
             BadRequestException.class,
-            () -> gApi.changes().id(changeId).revision(revId2).files(-1).keySet());
+            () -> gApi.changes().id(changeId).revision(revId2).files(-1));
     assertThat(thrown).hasMessageThat().isEqualTo("invalid parent number: -1");
   }
 
diff --git a/javatests/com/google/gerrit/acceptance/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/git/AbstractSubmitOnPush.java b/javatests/com/google/gerrit/acceptance/git/AbstractSubmitOnPush.java
index fbb0b1a..31292d5 100644
--- a/javatests/com/google/gerrit/acceptance/git/AbstractSubmitOnPush.java
+++ b/javatests/com/google/gerrit/acceptance/git/AbstractSubmitOnPush.java
@@ -36,7 +36,7 @@
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.api.changes.RecipientType;
-import com.google.gerrit.server.ApprovalsUtil;
+import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.events.ChangeMergedEvent;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.query.change.ChangeData;
diff --git a/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java b/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java
index b7acbe2..92770ba 100644
--- a/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java
@@ -38,7 +38,6 @@
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.entities.AccessSection;
 import com.google.gerrit.entities.AccountGroup;
-import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Patch;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Permission;
@@ -52,7 +51,6 @@
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.git.receive.ReceiveCommitsAdvertiseRefsHookChain;
 import com.google.gerrit.server.git.receive.testing.TestRefAdvertiser;
-import com.google.gerrit.server.notedb.ChangeNoteUtil;
 import com.google.gerrit.server.notedb.Sequences;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackend.RefFilterOptions;
@@ -69,7 +67,6 @@
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.RefUpdate;
 import org.eclipse.jgit.lib.Repository;
@@ -82,7 +79,6 @@
 @NoHttpd
 public class RefAdvertisementIT extends AbstractDaemonTest {
   @Inject private AllUsersName allUsersName;
-  @Inject private ChangeNoteUtil noteUtil;
   @Inject private PermissionBackend permissionBackend;
   @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
@@ -1106,37 +1102,10 @@
 
   @Test
   public void receivePackOmitsMissingObject() throws Exception {
-    String rev = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
     try (Repository repo = repoManager.openRepository(project);
         TestRepository<Repository> tr = new TestRepository<>(repo)) {
-      String subject = "Subject for missing commit";
-      Change c = new Change(cd3.change());
-      PatchSet.Id psId = PatchSet.id(cd3.getId(), 2);
-      c.setCurrentPatchSet(psId, subject, c.getOriginalSubject());
-
-      PersonIdent committer = serverIdent.get();
-      PersonIdent author =
-          noteUtil.newAccountIdIdent(getAccount(admin.id()).id(), committer.getWhen(), committer);
-      tr.branch(RefNames.changeMetaRef(cd3.getId()))
-          .commit()
-          .author(author)
-          .committer(committer)
-          .message(
-              "Update patch set "
-                  + psId.get()
-                  + "\n"
-                  + "\n"
-                  + "Patch-set: "
-                  + psId.get()
-                  + "\n"
-                  + "Commit: "
-                  + rev
-                  + "\n"
-                  + "Subject: "
-                  + subject
-                  + "\n")
-          .create();
-      indexer.index(c.getProject(), c.getId());
+      PatchSet.Id psId = PatchSet.id(cd3.getId(), 1);
+      tr.delete(psId.toRefName());
     }
 
     assertThat(getReceivePackRefs().additionalHaves()).containsExactly(obj(cd4, 1));
diff --git a/javatests/com/google/gerrit/acceptance/pgm/AbstractReindexTests.java b/javatests/com/google/gerrit/acceptance/pgm/AbstractReindexTests.java
index cad0b83..cac376f 100644
--- a/javatests/com/google/gerrit/acceptance/pgm/AbstractReindexTests.java
+++ b/javatests/com/google/gerrit/acceptance/pgm/AbstractReindexTests.java
@@ -31,14 +31,18 @@
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.GerritApi;
 import com.google.gerrit.extensions.common.ChangeInput;
+import com.google.gerrit.index.IndexDefinition;
 import com.google.gerrit.launcher.GerritLauncher;
 import com.google.gerrit.server.index.GerritIndexStatus;
 import com.google.gerrit.server.index.change.ChangeIndexCollection;
 import com.google.gerrit.server.index.change.ChangeSchemaDefinitions;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
 import com.google.inject.Injector;
+import com.google.inject.Key;
 import com.google.inject.Provider;
+import com.google.inject.TypeLiteral;
 import java.nio.file.Files;
+import java.util.Collection;
 import java.util.Set;
 import java.util.function.Consumer;
 import org.eclipse.jgit.lib.Config;
@@ -48,9 +52,6 @@
 
 @NoHttpd
 public abstract class AbstractReindexTests extends StandaloneSiteTest {
-  /** @param injector injector */
-  public abstract void configureIndex(Injector injector) throws Exception;
-
   private static final String CHANGES = ChangeSchemaDefinitions.NAME;
 
   private Project.NameKey project;
@@ -223,10 +224,18 @@
     }
   }
 
+  protected static void createAllIndexes(Injector injector) {
+    Collection<IndexDefinition<?, ?, ?>> indexDefs =
+        injector.getInstance(Key.get(new TypeLiteral<Collection<IndexDefinition<?, ?, ?>>>() {}));
+    for (IndexDefinition<?, ?, ?> indexDef : indexDefs) {
+      indexDef.getIndexCollection().getSearchIndex().deleteAll();
+    }
+  }
+
   private void setUpChange() throws Exception {
     project = Project.nameKey("reindex-project-test");
     try (ServerContext ctx = startServer()) {
-      configureIndex(ctx.getInjector());
+      createAllIndexes(ctx.getInjector());
       GerritApi gApi = ctx.getInjector().getInstance(GerritApi.class);
       gApi.projects().create(project.get());
 
diff --git a/javatests/com/google/gerrit/acceptance/pgm/ElasticReindexIT.java b/javatests/com/google/gerrit/acceptance/pgm/ElasticReindexIT.java
index f23cc10..0632241 100644
--- a/javatests/com/google/gerrit/acceptance/pgm/ElasticReindexIT.java
+++ b/javatests/com/google/gerrit/acceptance/pgm/ElasticReindexIT.java
@@ -14,23 +14,15 @@
 
 package com.google.gerrit.acceptance.pgm;
 
-import static com.google.gerrit.elasticsearch.ElasticTestUtils.createAllIndexes;
 import static com.google.gerrit.elasticsearch.ElasticTestUtils.getConfig;
 
 import com.google.gerrit.elasticsearch.ElasticVersion;
 import com.google.gerrit.testing.ConfigSuite;
-import com.google.inject.Injector;
 import org.eclipse.jgit.lib.Config;
 
 public class ElasticReindexIT extends AbstractReindexTests {
-
   @ConfigSuite.Default
   public static Config elasticsearchV7() {
     return getConfig(ElasticVersion.V7_8);
   }
-
-  @Override
-  public void configureIndex(Injector injector) {
-    createAllIndexes(injector);
-  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/pgm/ReindexIT.java b/javatests/com/google/gerrit/acceptance/pgm/LuceneReindexIT.java
similarity index 68%
rename from javatests/com/google/gerrit/acceptance/pgm/ReindexIT.java
rename to javatests/com/google/gerrit/acceptance/pgm/LuceneReindexIT.java
index 223851e..e630bca 100644
--- a/javatests/com/google/gerrit/acceptance/pgm/ReindexIT.java
+++ b/javatests/com/google/gerrit/acceptance/pgm/LuceneReindexIT.java
@@ -14,9 +14,14 @@
 
 package com.google.gerrit.acceptance.pgm;
 
-import com.google.inject.Injector;
+import com.google.gerrit.testing.ConfigSuite;
+import org.eclipse.jgit.lib.Config;
 
-public class ReindexIT extends AbstractReindexTests {
-  @Override
-  public void configureIndex(Injector injector) {}
+public class LuceneReindexIT extends AbstractReindexTests {
+  @ConfigSuite.Default
+  public static Config luceneConfig() {
+    Config cfg = new Config();
+    cfg.setString("index", null, "type", "lucene");
+    return cfg;
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/CancellationIT.java b/javatests/com/google/gerrit/acceptance/rest/CancellationIT.java
new file mode 100644
index 0000000..29d54cc
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/CancellationIT.java
@@ -0,0 +1,206 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.rest;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.httpd.restapi.RestApiServlet.SC_CLIENT_CLOSED_REQUEST;
+import static org.apache.http.HttpStatus.SC_REQUEST_TIMEOUT;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.ExtensionRegistry;
+import com.google.gerrit.acceptance.ExtensionRegistry.Registration;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.server.cancellation.RequestCancelledException;
+import com.google.gerrit.server.cancellation.RequestStateProvider;
+import com.google.gerrit.server.events.CommitReceivedEvent;
+import com.google.gerrit.server.git.validators.CommitValidationException;
+import com.google.gerrit.server.git.validators.CommitValidationListener;
+import com.google.gerrit.server.git.validators.CommitValidationMessage;
+import com.google.gerrit.server.project.CreateProjectArgs;
+import com.google.gerrit.server.validators.ProjectCreationValidationListener;
+import com.google.gerrit.server.validators.ValidationException;
+import com.google.inject.Inject;
+import java.util.List;
+import org.junit.Test;
+
+public class CancellationIT extends AbstractDaemonTest {
+  @Inject private ExtensionRegistry extensionRegistry;
+
+  @Test
+  public void handleClientDisconnected() throws Exception {
+    ProjectCreationValidationListener projectCreationListener =
+        new ProjectCreationValidationListener() {
+          @Override
+          public void validateNewProject(CreateProjectArgs args) throws ValidationException {
+            // Simulate a request cancellation by throwing RequestCancelledException. In contrast to
+            // an actual request cancellation this allows us to verify the HTTP status code that is
+            // set when a request is cancelled.
+            throw new RequestCancelledException(
+                RequestStateProvider.Reason.CLIENT_CLOSED_REQUEST, /* cancellationMessage= */ null);
+          }
+        };
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(projectCreationListener)) {
+      RestResponse response = adminRestSession.put("/projects/" + name("new"));
+      assertThat(response.getStatusCode()).isEqualTo(SC_CLIENT_CLOSED_REQUEST);
+      assertThat(response.getEntityContent()).isEqualTo("Client Closed Request");
+    }
+  }
+
+  @Test
+  public void handleClientDeadlineExceeded() throws Exception {
+    ProjectCreationValidationListener projectCreationListener =
+        new ProjectCreationValidationListener() {
+          @Override
+          public void validateNewProject(CreateProjectArgs args) throws ValidationException {
+            // Simulate an exceeded deadline by throwing RequestCancelledException.
+            throw new RequestCancelledException(
+                RequestStateProvider.Reason.CLIENT_PROVIDED_DEADLINE_EXCEEDED,
+                /* cancellationMessage= */ null);
+          }
+        };
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(projectCreationListener)) {
+      RestResponse response = adminRestSession.put("/projects/" + name("new"));
+      assertThat(response.getStatusCode()).isEqualTo(SC_REQUEST_TIMEOUT);
+      assertThat(response.getEntityContent()).isEqualTo("Client Provided Deadline Exceeded");
+    }
+  }
+
+  @Test
+  public void handleServerDeadlineExceeded() throws Exception {
+    ProjectCreationValidationListener projectCreationListener =
+        new ProjectCreationValidationListener() {
+          @Override
+          public void validateNewProject(CreateProjectArgs args) throws ValidationException {
+            // Simulate an exceeded deadline by throwing RequestCancelledException.
+            throw new RequestCancelledException(
+                RequestStateProvider.Reason.SERVER_DEADLINE_EXCEEDED,
+                /* cancellationMessage= */ null);
+          }
+        };
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(projectCreationListener)) {
+      RestResponse response = adminRestSession.put("/projects/" + name("new"));
+      assertThat(response.getStatusCode()).isEqualTo(SC_REQUEST_TIMEOUT);
+      assertThat(response.getEntityContent()).isEqualTo("Server Deadline Exceeded");
+    }
+  }
+
+  @Test
+  public void handleRequestCancellationWithMessage() throws Exception {
+    ProjectCreationValidationListener projectCreationListener =
+        new ProjectCreationValidationListener() {
+          @Override
+          public void validateNewProject(CreateProjectArgs args) throws ValidationException {
+            // Simulate an exceeded deadline by throwing RequestCancelledException.
+            throw new RequestCancelledException(
+                RequestStateProvider.Reason.SERVER_DEADLINE_EXCEEDED, "deadline = 10m");
+          }
+        };
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(projectCreationListener)) {
+      RestResponse response = adminRestSession.put("/projects/" + name("new"));
+      assertThat(response.getStatusCode()).isEqualTo(SC_REQUEST_TIMEOUT);
+      assertThat(response.getEntityContent())
+          .isEqualTo("Server Deadline Exceeded\n\ndeadline = 10m");
+    }
+  }
+
+  @Test
+  public void handleClientDisconnectedForPush() throws Exception {
+    CommitValidationListener commitValidationListener =
+        new CommitValidationListener() {
+          @Override
+          public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent)
+              throws CommitValidationException {
+            // Simulate a request cancellation by throwing RequestCancelledException. In contrast to
+            // an actual request cancellation this allows us verify the error message that is sent
+            // to the client.
+            throw new RequestCancelledException(
+                RequestStateProvider.Reason.CLIENT_CLOSED_REQUEST, /* cancellationMessage= */ null);
+          }
+        };
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(commitValidationListener)) {
+      PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo);
+      PushOneCommit.Result r = push.to("refs/heads/master");
+      r.assertErrorStatus("Client Closed Request");
+    }
+  }
+
+  @Test
+  public void handleClientDeadlineExceededForPush() throws Exception {
+    CommitValidationListener commitValidationListener =
+        new CommitValidationListener() {
+          @Override
+          public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent)
+              throws CommitValidationException {
+            // Simulate an exceeded deadline by throwing RequestCancelledException.
+            throw new RequestCancelledException(
+                RequestStateProvider.Reason.CLIENT_PROVIDED_DEADLINE_EXCEEDED,
+                /* cancellationMessage= */ null);
+          }
+        };
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(commitValidationListener)) {
+      PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo);
+      PushOneCommit.Result r = push.to("refs/heads/master");
+      r.assertErrorStatus("Client Provided Deadline Exceeded");
+    }
+  }
+
+  @Test
+  public void handleServerDeadlineExceededForPush() throws Exception {
+    CommitValidationListener commitValidationListener =
+        new CommitValidationListener() {
+          @Override
+          public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent)
+              throws CommitValidationException {
+            // Simulate an exceeded deadline by throwing RequestCancelledException.
+            throw new RequestCancelledException(
+                RequestStateProvider.Reason.SERVER_DEADLINE_EXCEEDED,
+                /* cancellationMessage= */ null);
+          }
+        };
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(commitValidationListener)) {
+      PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo);
+      PushOneCommit.Result r = push.to("refs/heads/master");
+      r.assertErrorStatus("Server Deadline Exceeded");
+    }
+  }
+
+  @Test
+  public void handleRequestCancellationWithMessageForPush() throws Exception {
+    CommitValidationListener commitValidationListener =
+        new CommitValidationListener() {
+          @Override
+          public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent)
+              throws CommitValidationException {
+            // Simulate an exceeded deadline by throwing RequestCancelledException.
+            throw new RequestCancelledException(
+                RequestStateProvider.Reason.SERVER_DEADLINE_EXCEEDED, "deadline = 10m");
+          }
+        };
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(commitValidationListener)) {
+      PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo);
+      PushOneCommit.Result r = push.to("refs/heads/master");
+      r.assertErrorStatus("Server Deadline Exceeded (deadline = 10m)");
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/rest/RestApiServletIT.java b/javatests/com/google/gerrit/acceptance/rest/RestApiServletIT.java
index bc45460..fd079b2 100644
--- a/javatests/com/google/gerrit/acceptance/rest/RestApiServletIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/RestApiServletIT.java
@@ -14,25 +14,38 @@
 
 package com.google.gerrit.acceptance.rest;
 
+import static com.google.common.net.HttpHeaders.ORIGIN;
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.httpd.restapi.RestApiServlet.X_GERRIT_UPDATED_REF;
+import static com.google.gerrit.httpd.restapi.RestApiServlet.X_GERRIT_UPDATED_REF_ENABLED;
 import static org.apache.http.HttpStatus.SC_OK;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.PushOneCommit.Result;
 import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.restapi.Url;
 import com.google.gerrit.httpd.restapi.RestApiServlet;
 import java.io.IOException;
+import java.util.List;
 import java.util.regex.Pattern;
 import org.apache.http.message.BasicHeader;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
 import org.junit.Test;
 
 public class RestApiServletIT extends AbstractDaemonTest {
   private static String ANY_REST_API = "/accounts/self/capabilities";
   private static BasicHeader ACCEPT_STAR_HEADER = new BasicHeader("Accept", "*/*");
+  private static BasicHeader X_GERRIT_UPDATED_REF_ENABLED_HEADER =
+      new BasicHeader(X_GERRIT_UPDATED_REF_ENABLED, "true");
   private static Pattern ANY_SPACE = Pattern.compile("\\s");
 
   @Test
   public void restResponseBodyShouldBeCompactWithoutSpaces() throws Exception {
-    RestResponse response = adminRestSession.getWithHeader(ANY_REST_API, ACCEPT_STAR_HEADER);
+    RestResponse response = adminRestSession.getWithHeaders(ANY_REST_API, ACCEPT_STAR_HEADER);
     assertThat(response.getStatusCode()).isEqualTo(SC_OK);
 
     assertThat(contentWithoutMagicJson(response)).doesNotContainMatch(ANY_SPACE);
@@ -61,9 +74,253 @@
         .containsMatch(ANY_SPACE);
   }
 
+  @Test
+  public void xGerritUpdatedRefNotSetByDefault() throws Exception {
+    Result change = createChange();
+    String origin = adminRestSession.url();
+
+    RestResponse response =
+        adminRestSession.putWithHeaders(
+            "/changes/" + change.getChangeId() + "/topic",
+            /* content= */ "A",
+            new BasicHeader(ORIGIN, origin));
+    response.assertOK();
+    assertThat(gApi.changes().id(change.getChangeId()).topic()).isEqualTo("A");
+
+    // Meta ref updated because of topic update, but updated refs are not set by default.
+    assertThat(response.getHeader(X_GERRIT_UPDATED_REF)).isNull();
+  }
+
+  @Test
+  public void xGerritUpdatedRefNotSetWhenUpdatedRefNotEnabled() throws Exception {
+    Result change = createChange();
+    String origin = adminRestSession.url();
+
+    RestResponse response =
+        adminRestSession.putWithHeaders(
+            "/changes/" + change.getChangeId() + "/topic",
+            /* content= */ "A",
+            new BasicHeader(ORIGIN, origin),
+            new BasicHeader(X_GERRIT_UPDATED_REF_ENABLED, "false"));
+    response.assertOK();
+    assertThat(gApi.changes().id(change.getChangeId()).topic()).isEqualTo("A");
+
+    // Meta ref updated because of topic update, but updated refs are not enabled.
+    assertThat(response.getHeader(X_GERRIT_UPDATED_REF)).isNull();
+  }
+
+  @Test
+  public void xGerritUpdatedRefNotSetForReadRequests() throws Exception {
+    RestResponse response =
+        adminRestSession.getWithHeaders(
+            ANY_REST_API, ACCEPT_STAR_HEADER, X_GERRIT_UPDATED_REF_ENABLED_HEADER);
+    assertThat(response.getStatusCode()).isEqualTo(SC_OK);
+    assertThat(response.getHeader(X_GERRIT_UPDATED_REF)).isNull();
+  }
+
+  @Test
+  public void xGerritUpdatedRefSetForDifferentWriteRequests() throws Exception {
+    Result change = createChange();
+    String origin = adminRestSession.url();
+    String project = change.getChange().project().get();
+    String metaRef = RefNames.changeMetaRef(change.getChange().getId());
+
+    ObjectId originalMetaRefSha1 = getMetaRefSha1(change);
+
+    RestResponse response =
+        adminRestSession.putWithHeaders(
+            "/changes/" + change.getChangeId() + "/topic",
+            /* content= */ "A",
+            new BasicHeader(ORIGIN, origin),
+            X_GERRIT_UPDATED_REF_ENABLED_HEADER);
+    response.assertOK();
+    assertThat(gApi.changes().id(change.getChangeId()).topic()).isEqualTo("A");
+    ObjectId firstMetaRefSha1 = getMetaRefSha1(change);
+
+    // Meta ref updated because of topic update.
+    assertThat(response.getHeader(X_GERRIT_UPDATED_REF))
+        .isEqualTo(
+            String.format(
+                "%s~%s~%s~%s",
+                Url.encode(project),
+                Url.encode(metaRef),
+                originalMetaRefSha1.getName(),
+                firstMetaRefSha1.getName()));
+
+    response =
+        adminRestSession.putWithHeaders(
+            "/changes/" + change.getChangeId() + "/topic",
+            /* content= */ "B",
+            new BasicHeader(ORIGIN, origin),
+            X_GERRIT_UPDATED_REF_ENABLED_HEADER);
+    response.assertOK();
+    assertThat(gApi.changes().id(change.getChangeId()).topic()).isEqualTo("B");
+
+    ObjectId secondMetaRefSha1 = getMetaRefSha1(change);
+
+    // Meta ref updated again because of another topic update.
+    assertThat(response.getHeader(X_GERRIT_UPDATED_REF))
+        .isEqualTo(
+            String.format(
+                "%s~%s~%s~%s",
+                Url.encode(project),
+                Url.encode(metaRef),
+                firstMetaRefSha1.getName(),
+                secondMetaRefSha1.getName()));
+
+    // Ensure the meta ref SHA-1 changed for the project~metaRef which means we return different
+    // X-Gerrit-UpdatedRef headers.
+    assertThat(secondMetaRefSha1).isNotEqualTo(firstMetaRefSha1);
+  }
+
+  @Test
+  public void xGerritUpdatedRefDeleted() throws Exception {
+    Result change = createChange();
+    String project = change.getChange().project().get();
+    String metaRef = RefNames.changeMetaRef(change.getChange().getId());
+    String patchSetRef = RefNames.patchSetRef(change.getPatchSetId());
+
+    ObjectId originalMetaRefSha1 = getMetaRefSha1(change);
+    ObjectId originalchangeRefSha1 = change.getCommit().getId();
+
+    RestResponse response =
+        adminRestSession.deleteWithHeaders(
+            "/changes/" + change.getChangeId(), X_GERRIT_UPDATED_REF_ENABLED_HEADER);
+    response.assertNoContent();
+
+    List<String> headers = response.getHeaders(X_GERRIT_UPDATED_REF);
+
+    // The change was deleted, so the refs were deleted which means they are ObjectId.zeroId().
+    assertThat(headers)
+        .containsExactly(
+            String.format(
+                "%s~%s~%s~%s",
+                Url.encode(project),
+                Url.encode(metaRef),
+                originalMetaRefSha1.getName(),
+                ObjectId.zeroId().getName()),
+            String.format(
+                "%s~%s~%s~%s",
+                Url.encode(project),
+                Url.encode(patchSetRef),
+                originalchangeRefSha1.getName(),
+                ObjectId.zeroId().getName()));
+  }
+
+  @Test
+  public void xGerritUpdatedRefWithProjectNameContainingTilde() throws Exception {
+    Project.NameKey project = createProjectOverAPI("~~pr~oje~ct~~~~", null, true, null);
+    Result change = createChange(cloneProject(project, admin));
+    String metaRef = RefNames.changeMetaRef(change.getChange().getId());
+    String patchSetRef = RefNames.patchSetRef(change.getPatchSetId());
+
+    ObjectId originalMetaRefSha1 = getMetaRefSha1(change);
+    ObjectId originalchangeRefSha1 = change.getCommit().getId();
+
+    RestResponse response =
+        adminRestSession.deleteWithHeaders(
+            "/changes/" + change.getChangeId(), X_GERRIT_UPDATED_REF_ENABLED_HEADER);
+    response.assertNoContent();
+
+    List<String> headers = response.getHeaders(X_GERRIT_UPDATED_REF);
+
+    // The change was deleted, so the refs were deleted which means they are ObjectId.zeroId().
+    assertThat(headers)
+        .containsExactly(
+            String.format(
+                "%s~%s~%s~%s",
+                Url.encode(project.get()),
+                Url.encode(metaRef),
+                originalMetaRefSha1.getName(),
+                ObjectId.zeroId().getName()),
+            String.format(
+                "%s~%s~%s~%s",
+                Url.encode(project.get()),
+                Url.encode(patchSetRef),
+                originalchangeRefSha1.getName(),
+                ObjectId.zeroId().getName()));
+
+    // Ensures ~ gets encoded to %7E.
+    assertThat(Url.encode(project.get())).endsWith("%7E%7Epr%7Eoje%7Ect%7E%7E%7E%7E");
+  }
+
+  @Test
+  public void xGerritUpdatedRefSetMultipleHeadersForSubmit() throws Exception {
+    Result change1 = createChange();
+    Result change2 = createChange();
+    String metaRef1 = RefNames.changeMetaRef(change1.getChange().getId());
+    String metaRef2 = RefNames.changeMetaRef(change2.getChange().getId());
+
+    gApi.changes().id(change1.getChangeId()).current().review(ReviewInput.approve());
+    gApi.changes().id(change2.getChangeId()).current().review(ReviewInput.approve());
+
+    Project.NameKey project = change1.getChange().project();
+
+    try (Repository repository = repoManager.openRepository(project)) {
+      ObjectId originalFirstMetaRefSha1 = getMetaRefSha1(change1);
+      ObjectId originalSecondMetaRefSha1 = getMetaRefSha1(change2);
+      ObjectId originalDestinationBranchSha1 =
+          repository.resolve(change1.getChange().change().getDest().branch());
+
+      RestResponse response =
+          adminRestSession.postWithHeaders(
+              "/changes/" + change2.getChangeId() + "/submit",
+              /* content = */ null,
+              X_GERRIT_UPDATED_REF_ENABLED_HEADER);
+      response.assertOK();
+
+      ObjectId firstMetaRefSha1 = getMetaRefSha1(change1);
+      ObjectId secondMetaRefSha1 = getMetaRefSha1(change2);
+
+      List<String> headers = response.getHeaders(X_GERRIT_UPDATED_REF);
+
+      String branch = change1.getChange().change().getDest().branch();
+      String branchSha1 =
+          repository
+              .getRefDatabase()
+              .exactRef(change1.getChange().change().getDest().branch())
+              .getObjectId()
+              .name();
+
+      // During submit, all relevant meta refs of the latest patchset are updated + the destination
+      // branch/es.
+      // TODO(paiking): This doesn't work well for torn submissions: If the changes were in
+      // different projects in the same topic, and we tried to submit those changes together, it's
+      // possible that the first submission only submitted one of the changes, and then the retry
+      // submitted the other change. If that happens, when the user retries, they will not get the
+      // meta ref updates for the change that got submitted on the previous submission attempt.
+      // Ideally, submit should be idempotent and always return all meta refs on all submission
+      // attempts.
+      assertThat(headers)
+          .containsExactly(
+              String.format(
+                  "%s~%s~%s~%s",
+                  Url.encode(project.get()),
+                  Url.encode(metaRef1),
+                  originalFirstMetaRefSha1.getName(),
+                  firstMetaRefSha1.getName()),
+              String.format(
+                  "%s~%s~%s~%s",
+                  Url.encode(project.get()),
+                  Url.encode(metaRef2),
+                  originalSecondMetaRefSha1.getName(),
+                  secondMetaRefSha1.getName()),
+              String.format(
+                  "%s~%s~%s~%s",
+                  Url.encode(project.get()),
+                  Url.encode(branch),
+                  originalDestinationBranchSha1.getName(),
+                  branchSha1));
+    }
+  }
+
+  private ObjectId getMetaRefSha1(Result change) {
+    return change.getChange().notes().getRevision();
+  }
+
   private RestResponse prettyJsonRestResponse(String ppArgument, int ppValue) throws Exception {
     RestResponse response =
-        adminRestSession.getWithHeader(
+        adminRestSession.getWithHeaders(
             ANY_REST_API + "?" + ppArgument + "=" + ppValue, ACCEPT_STAR_HEADER);
     assertThat(response.getStatusCode()).isEqualTo(SC_OK);
 
diff --git a/javatests/com/google/gerrit/acceptance/rest/TraceIT.java b/javatests/com/google/gerrit/acceptance/rest/TraceIT.java
index 02916c7..530f2ec 100644
--- a/javatests/com/google/gerrit/acceptance/rest/TraceIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/TraceIT.java
@@ -24,7 +24,7 @@
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.timeout;
 import static org.mockito.Mockito.verify;
-import static org.mockito.Mockito.verifyZeroInteractions;
+import static org.mockito.Mockito.verifyNoInteractions;
 
 import com.google.auto.value.AutoValue;
 import com.google.common.collect.ImmutableList;
@@ -160,7 +160,7 @@
     try (Registration registration =
         extensionRegistry.newRegistration().add(projectCreationListener)) {
       RestResponse response =
-          adminRestSession.putWithHeader(
+          adminRestSession.putWithHeaders(
               "/projects/new4", new BasicHeader(RestApiServlet.X_GERRIT_TRACE, null));
       assertThat(response.getStatusCode()).isEqualTo(SC_CREATED);
       assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).isNotNull();
@@ -177,7 +177,7 @@
     try (Registration registration =
         extensionRegistry.newRegistration().add(projectCreationListener)) {
       RestResponse response =
-          adminRestSession.putWithHeader(
+          adminRestSession.putWithHeaders(
               "/projects/new5", new BasicHeader(RestApiServlet.X_GERRIT_TRACE, "issue/123"));
       assertThat(response.getStatusCode()).isEqualTo(SC_CREATED);
       assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).isEqualTo("issue/123");
@@ -195,7 +195,7 @@
         extensionRegistry.newRegistration().add(projectCreationListener)) {
       // trace ID only specified by trace header
       RestResponse response =
-          adminRestSession.putWithHeader(
+          adminRestSession.putWithHeaders(
               "/projects/new6?trace", new BasicHeader(RestApiServlet.X_GERRIT_TRACE, "issue/123"));
       assertThat(response.getStatusCode()).isEqualTo(SC_CREATED);
       assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).isEqualTo("issue/123");
@@ -205,7 +205,7 @@
 
       // trace ID only specified by trace request parameter
       response =
-          adminRestSession.putWithHeader(
+          adminRestSession.putWithHeaders(
               "/projects/new7?trace=issue/123",
               new BasicHeader(RestApiServlet.X_GERRIT_TRACE, null));
       assertThat(response.getStatusCode()).isEqualTo(SC_CREATED);
@@ -216,7 +216,7 @@
 
       // same trace ID specified by trace header and trace request parameter
       response =
-          adminRestSession.putWithHeader(
+          adminRestSession.putWithHeaders(
               "/projects/new8?trace=issue/123",
               new BasicHeader(RestApiServlet.X_GERRIT_TRACE, "issue/123"));
       assertThat(response.getStatusCode()).isEqualTo(SC_CREATED);
@@ -227,7 +227,7 @@
 
       // different trace IDs specified by trace header and trace request parameter
       response =
-          adminRestSession.putWithHeader(
+          adminRestSession.putWithHeaders(
               "/projects/new9?trace=issue/123",
               new BasicHeader(RestApiServlet.X_GERRIT_TRACE, "issue/456"));
       assertThat(response.getStatusCode()).isEqualTo(SC_CREATED);
@@ -409,7 +409,7 @@
       PushOneCommit.Result r = push.to("refs/heads/master");
       r.assertOkStatus();
 
-      verifyZeroInteractions(testPerformanceLogger);
+      verifyNoInteractions(testPerformanceLogger);
     }
   }
 
diff --git a/javatests/com/google/gerrit/acceptance/rest/account/ExternalIdIT.java b/javatests/com/google/gerrit/acceptance/rest/account/ExternalIdIT.java
index b6033d4..20b378b 100644
--- a/javatests/com/google/gerrit/acceptance/rest/account/ExternalIdIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/account/ExternalIdIT.java
@@ -37,7 +37,6 @@
 import com.google.common.collect.Iterables;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.RestResponse;
-import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.data.GlobalCapability;
@@ -79,8 +78,6 @@
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.FooterLine;
-import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
 import org.eclipse.jgit.transport.PushResult;
 import org.eclipse.jgit.transport.RemoteRefUpdate;
@@ -774,97 +771,6 @@
     }
   }
 
-  @Test
-  public void footers() throws Exception {
-    // Insert external ID for different accounts
-    TestAccount user1 = accountCreator.create("user1");
-    TestAccount user2 = accountCreator.create("user2");
-    ExternalId extId1 = ExternalId.create("foo", "1", user1.id());
-    ExternalId extId2 = ExternalId.create("foo", "2", user1.id());
-    ExternalId extId3 = ExternalId.create("foo", "3", user2.id());
-    try (Repository allUsersRepo = repoManager.openRepository(allUsers);
-        MetaDataUpdate md = metaDataUpdateFactory.create(allUsers)) {
-      ExternalIdNotes extIdNotes = externalIdNotesFactory.load(allUsersRepo);
-      extIdNotes.insert(ImmutableSet.of(extId1, extId2, extId3));
-      RevCommit c = extIdNotes.commit(md);
-      assertThat(getFooters(c))
-          .containsExactly("Account: " + user1.id(), "Account: " + user2.id())
-          .inOrder();
-    }
-
-    // Insert external ID with different emails
-    ExternalId extId4 = ExternalId.createWithEmail("foo", "4", user1.id(), "foo4@example.com");
-    ExternalId extId5 = ExternalId.createWithEmail("foo", "5", user2.id(), "foo5@example.com");
-    try (Repository allUsersRepo = repoManager.openRepository(allUsers);
-        MetaDataUpdate md = metaDataUpdateFactory.create(allUsers)) {
-      ExternalIdNotes extIdNotes = externalIdNotesFactory.load(allUsersRepo);
-      extIdNotes.insert(ImmutableSet.of(extId4, extId5));
-      RevCommit c = extIdNotes.commit(md);
-      assertThat(getFooters(c))
-          .containsExactly(
-              "Account: " + user1.id(),
-              "Account: " + user2.id(),
-              "Email: foo4@example.com",
-              "Email: foo5@example.com")
-          .inOrder();
-    }
-
-    // Update external ID - Add Email
-    ExternalId extId1a = ExternalId.createWithEmail("foo", "1", user1.id(), "foo1@example.com");
-    try (Repository allUsersRepo = repoManager.openRepository(allUsers);
-        MetaDataUpdate md = metaDataUpdateFactory.create(allUsers)) {
-      ExternalIdNotes extIdNotes = externalIdNotesFactory.load(allUsersRepo);
-      extIdNotes.upsert(extId1a);
-      RevCommit c = extIdNotes.commit(md);
-      assertThat(getFooters(c))
-          .containsExactly("Account: " + user1.id(), "Email: foo1@example.com")
-          .inOrder();
-    }
-
-    // Update external ID - Remove Email
-    try (Repository allUsersRepo = repoManager.openRepository(allUsers);
-        MetaDataUpdate md = metaDataUpdateFactory.create(allUsers)) {
-      ExternalIdNotes extIdNotes = externalIdNotesFactory.load(allUsersRepo);
-      extIdNotes.upsert(extId1);
-      RevCommit c = extIdNotes.commit(md);
-      assertThat(getFooters(c))
-          .containsExactly("Account: " + user1.id(), "Email: foo1@example.com")
-          .inOrder();
-    }
-
-    // Delete external IDs
-    try (Repository allUsersRepo = repoManager.openRepository(allUsers);
-        MetaDataUpdate md = metaDataUpdateFactory.create(allUsers)) {
-      ExternalIdNotes extIdNotes = externalIdNotesFactory.load(allUsersRepo);
-      extIdNotes.delete(ImmutableSet.of(extId1, extId5));
-      RevCommit c = extIdNotes.commit(md);
-      assertThat(getFooters(c))
-          .containsExactly(
-              "Account: " + user1.id(), "Account: " + user2.id(), "Email: foo5@example.com")
-          .inOrder();
-    }
-
-    // Delete external ID by key without email
-    try (Repository allUsersRepo = repoManager.openRepository(allUsers);
-        MetaDataUpdate md = metaDataUpdateFactory.create(allUsers)) {
-      ExternalIdNotes extIdNotes = externalIdNotesFactory.load(allUsersRepo);
-      extIdNotes.delete(extId2.accountId(), extId2.key());
-      RevCommit c = extIdNotes.commit(md);
-      assertThat(getFooters(c)).containsExactly("Account: " + user1.id()).inOrder();
-    }
-
-    // Delete external ID by key with email
-    try (Repository allUsersRepo = repoManager.openRepository(allUsers);
-        MetaDataUpdate md = metaDataUpdateFactory.create(allUsers)) {
-      ExternalIdNotes extIdNotes = externalIdNotesFactory.load(allUsersRepo);
-      extIdNotes.delete(extId4.accountId(), extId4.key());
-      RevCommit c = extIdNotes.commit(md);
-      assertThat(getFooters(c))
-          .containsExactly("Account: " + user1.id(), "Email: foo4@example.com")
-          .inOrder();
-    }
-  }
-
   private boolean isPartialCacheReloadingEnabled() {
     return cfg.getBoolean("cache", "external_ids_map", "enablePartialReloads", true);
   }
@@ -915,10 +821,6 @@
     }
   }
 
-  private List<String> getFooters(RevCommit c) {
-    return c.getFooterLines().stream().map(FooterLine::toString).collect(toList());
-  }
-
   private List<AccountExternalIdInfo> toExternalIdInfos(Collection<ExternalId> extIds) {
     return extIds.stream().map(this::toExternalIdInfo).collect(toList());
   }
diff --git a/javatests/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java b/javatests/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java
index bf8de93..4da4da4 100644
--- a/javatests/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java
@@ -63,10 +63,10 @@
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
-import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.account.AccountControl;
+import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.project.testing.TestLabels;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
@@ -436,7 +436,7 @@
   @Test
   public void runAsValidUser() throws Exception {
     allowRunAs();
-    RestResponse res = adminRestSession.getWithHeader("/accounts/self", runAsHeader(user.id()));
+    RestResponse res = adminRestSession.getWithHeaders("/accounts/self", runAsHeader(user.id()));
     res.assertOK();
     AccountInfo account = newGson().fromJson(res.getEntityContent(), AccountInfo.class);
     assertThat(account._accountId).isEqualTo(user.id().get());
@@ -446,7 +446,7 @@
   @Test
   public void runAsDisabledByConfig() throws Exception {
     allowRunAs();
-    RestResponse res = adminRestSession.getWithHeader("/changes/", runAsHeader(user.id()));
+    RestResponse res = adminRestSession.getWithHeaders("/changes/", runAsHeader(user.id()));
     res.assertForbidden();
     assertThat(res.getEntityContent())
         .isEqualTo("X-Gerrit-RunAs disabled by auth.enableRunAs = false");
@@ -454,7 +454,7 @@
 
   @Test
   public void runAsNotPermitted() throws Exception {
-    RestResponse res = adminRestSession.getWithHeader("/changes/", runAsHeader(user.id()));
+    RestResponse res = adminRestSession.getWithHeaders("/changes/", runAsHeader(user.id()));
     res.assertForbidden();
     assertThat(res.getEntityContent()).isEqualTo("not permitted to use X-Gerrit-RunAs");
   }
@@ -462,7 +462,7 @@
   @Test
   public void runAsNeverPermittedForAnonymousUsers() throws Exception {
     allowRunAs();
-    RestResponse res = anonRestSession.getWithHeader("/changes/", runAsHeader(user.id()));
+    RestResponse res = anonRestSession.getWithHeaders("/changes/", runAsHeader(user.id()));
     res.assertForbidden();
     assertThat(res.getEntityContent()).isEqualTo("not permitted to use X-Gerrit-RunAs");
   }
@@ -470,7 +470,7 @@
   @Test
   public void runAsInvalidUser() throws Exception {
     allowRunAs();
-    RestResponse res = adminRestSession.getWithHeader("/changes/", runAsHeader("doesnotexist"));
+    RestResponse res = adminRestSession.getWithHeaders("/changes/", runAsHeader("doesnotexist"));
     res.assertForbidden();
     assertThat(res.getEntityContent()).isEqualTo("no account matches X-Gerrit-RunAs");
   }
@@ -496,10 +496,10 @@
     in.message = "message";
     in.drafts = DraftHandling.PUBLISH;
     RestResponse res =
-        adminRestSession.postWithHeader(
+        adminRestSession.postWithHeaders(
             "/changes/" + r.getChangeId() + "/revisions/current/review",
-            runAsHeader(user.id()),
-            in);
+            in,
+            runAsHeader(user.id()));
     res.assertOK();
 
     ChangeMessageInfo m = Iterables.getLast(gApi.changes().id(r.getChangeId()).get().messages);
@@ -532,13 +532,13 @@
     in.message = "Message on behalf of";
 
     String endpoint = "/changes/" + r.getChangeId() + "/revisions/current/review";
-    RestResponse res = adminRestSession.postWithHeader(endpoint, runAsHeader(user2.id()), in);
+    RestResponse res = adminRestSession.postWithHeaders(endpoint, in, runAsHeader(user2.id()));
     res.assertForbidden();
     assertThat(res.getEntityContent())
         .isEqualTo("label required to post review on behalf of \"" + in.onBehalfOf + '"');
 
     in.label("Code-Review", 1);
-    adminRestSession.postWithHeader(endpoint, runAsHeader(user2.id()), in).assertOK();
+    adminRestSession.postWithHeaders(endpoint, in, runAsHeader(user2.id())).assertOK();
 
     PatchSetApproval psa = Iterables.getOnlyElement(r.getChange().approvals().values());
     assertThat(psa.patchSetId().get()).isEqualTo(1);
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
index 796ce38..d967f48 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
@@ -85,8 +85,8 @@
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.webui.UiAction;
-import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.change.RevisionResource;
 import com.google.gerrit.server.change.TestSubmitInput;
 import com.google.gerrit.server.git.validators.OnSubmitValidationListener;
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java b/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java
index d480eb1..4b45476 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.acceptance.rest.change;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
 import static com.google.gerrit.extensions.restapi.testing.AttentionSetUpdateSubject.assertThat;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
@@ -32,23 +33,29 @@
 import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
 import com.google.gerrit.acceptance.testsuite.change.ChangeOperations;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.AttentionSetUpdate;
 import com.google.gerrit.entities.AttentionSetUpdate.Operation;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.entities.Patch;
+import com.google.gerrit.entities.Permission;
 import com.google.gerrit.extensions.api.changes.AttentionSetInput;
 import com.google.gerrit.extensions.api.changes.DeleteReviewerInput;
 import com.google.gerrit.extensions.api.changes.HashtagsInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.changes.ReviewerInput;
+import com.google.gerrit.extensions.api.groups.GroupInput;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo.EmailStrategy;
 import com.google.gerrit.extensions.client.ReviewerState;
 import com.google.gerrit.extensions.client.Side;
+import com.google.gerrit.extensions.common.GroupInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.server.account.ServiceUserClassifier;
 import com.google.gerrit.server.query.change.ChangeData;
@@ -82,6 +89,7 @@
   @Inject private FakeEmailSender email;
   @Inject private TestCommentHelper testCommentHelper;
   @Inject private Provider<InternalChangeQuery> changeQueryProvider;
+  @Inject private ProjectOperations projectOperations;
 
   /** Simulates a fake clock. Uses second granularity. */
   private static class FakeClock implements LongSupplier {
@@ -622,7 +630,7 @@
     assertThat(attentionSet).hasReasonThat().isEqualTo("reason");
 
     // No emails for adding to attention set were sent.
-    email.getMessages().isEmpty();
+    assertThat(email.getMessages()).isEmpty();
   }
 
   @Test
@@ -631,6 +639,7 @@
     // implictly adds the user to the attention set when adding as reviewer
     change(r).addReviewer(user.email());
     requestScopeOperations.setApiUser(user.id());
+    email.clear();
 
     ReviewInput reviewInput =
         ReviewInput.create().removeUserFromAttentionSet(user.email(), "reason");
@@ -643,7 +652,7 @@
     assertThat(attentionSet).hasReasonThat().isEqualTo("reason");
 
     // No emails for removing from attention set were sent.
-    email.getMessages().isEmpty();
+    assertThat(email.getMessages()).isEmpty();
   }
 
   @Test
@@ -1758,6 +1767,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();
@@ -1810,6 +1843,44 @@
     assertThat(attentionSet).hasReasonThat().isEqualTo("Their vote was deleted");
   }
 
+  @Test
+  public void accountsWithNoReadPermissionIgnoredOnReply() throws Exception {
+    // Create a group with user.
+    GroupInput groupInput = new GroupInput();
+    groupInput.name = name("User");
+    groupInput.members = ImmutableList.of(String.valueOf(user.id()));
+    GroupInfo group = gApi.groups().create(groupInput).get();
+
+    PushOneCommit.Result r = createChange();
+    gApi.changes().id(r.getChangeId()).addReviewer(user.email());
+
+    // remove read permission for user.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(Permission.READ).ref("refs/*").group(AccountGroup.uuid(group.id)))
+        .update();
+
+    // removing user without permissions from attention set is allowed on reply.
+    gApi.changes()
+        .id(r.getChangeId())
+        .current()
+        .review(new ReviewInput().removeUserFromAttentionSet(user.email(), "reason"));
+
+    // Add user to attention throws an exception.
+    assertThrows(
+        AuthException.class,
+        () -> change(r).addToAttentionSet(new AttentionSetInput(user.email(), "reason")));
+
+    // Add user to attention set is ignored on reply.
+    gApi.changes()
+        .id(r.getChangeId())
+        .current()
+        .review(new ReviewInput().addUserToAttentionSet(user.email(), "reason"));
+    assertThat(Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r, user)).operation())
+        .isEqualTo(Operation.REMOVE);
+  }
+
   private List<AttentionSetUpdate> getAttentionSetUpdatesForUser(
       PushOneCommit.Result r, TestAccount account) {
     return getAttentionSetUpdates(r.getChange().getId()).stream()
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/ChangeMessagesIT.java b/javatests/com/google/gerrit/acceptance/rest/change/ChangeMessagesIT.java
index d93d3f7..e1a6f99 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/ChangeMessagesIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/ChangeMessagesIT.java
@@ -312,11 +312,7 @@
     assertThat(info.message).isEqualTo(expectedMessage);
     List<ChangeMessageInfo> messagesAfterDeletion = gApi.changes().id(changeNum).messages();
     assertMessagesAfterDeletion(
-        messagesBeforeDeletion,
-        messagesAfterDeletion,
-        deletedMessageIndex,
-        deletedBy,
-        expectedMessage);
+        messagesBeforeDeletion, messagesAfterDeletion, deletedMessageIndex, expectedMessage);
     assertCommentsAfterDeletion(changeNum, commentsBefore);
 
     // Verify change index is updated after deletion.
@@ -331,7 +327,6 @@
       List<ChangeMessageInfo> messagesBeforeDeletion,
       List<ChangeMessageInfo> messagesAfterDeletion,
       int deletedMessageIndex,
-      TestAccount deletedBy,
       String expectedDeleteMessage) {
     assertWithMessage("after: %s; before: %s", messagesAfterDeletion, messagesBeforeDeletion)
         .that(messagesAfterDeletion)
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/CorsIT.java b/javatests/com/google/gerrit/acceptance/rest/change/CorsIT.java
index d3dd801..61e5a2e 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/CorsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/CorsIT.java
@@ -130,8 +130,10 @@
     Result change = createChange();
     String origin = adminRestSession.url();
     RestResponse r =
-        adminRestSession.putWithHeader(
-            "/changes/" + change.getChangeId() + "/topic", new BasicHeader(ORIGIN, origin), "A");
+        adminRestSession.putWithHeaders(
+            "/changes/" + change.getChangeId() + "/topic",
+            /* content = */ "A",
+            new BasicHeader(ORIGIN, origin));
     r.assertOK();
     checkCors(r, false, origin);
     checkTopic(change, "A");
@@ -142,8 +144,10 @@
     Result change = createChange();
     String origin = "http://example.com";
     RestResponse r =
-        adminRestSession.putWithHeader(
-            "/changes/" + change.getChangeId() + "/topic", new BasicHeader(ORIGIN, origin), "A");
+        adminRestSession.putWithHeaders(
+            "/changes/" + change.getChangeId() + "/topic",
+            /* content = */ "A",
+            new BasicHeader(ORIGIN, origin));
     r.assertOK();
     checkCors(r, true, origin);
   }
@@ -283,7 +287,7 @@
       String url, boolean accept, String origin, RestSession restSession, int httpStatusCode)
       throws Exception {
     Header hdr = new BasicHeader(ORIGIN, origin);
-    RestResponse r = restSession.getWithHeader(url, hdr);
+    RestResponse r = restSession.getWithHeaders(url, hdr);
     r.assertStatus(httpStatusCode);
     checkCors(r, accept, origin);
   }
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java b/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
index 129b546..ad06226 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;
@@ -76,18 +83,37 @@
 import java.util.concurrent.Executors;
 import java.util.concurrent.Future;
 import java.util.concurrent.TimeUnit;
+import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
 import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.RefUpdate;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
 import org.eclipse.jgit.transport.RefSpec;
+import org.junit.Before;
 import org.junit.Test;
 
 @UseClockStep
 public class CreateChangeIT extends AbstractDaemonTest {
   @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
+  @Inject private ExtensionRegistry extensionRegistry;
+
+  @Before
+  public void addNonCommitHead() throws Exception {
+    try (Repository repo = repoManager.openRepository(project);
+        ObjectInserter ins = repo.newObjectInserter()) {
+      ObjectId answer = ins.insert(Constants.OBJ_BLOB, new byte[] {42});
+      ins.flush();
+      ins.close();
+
+      RefUpdate update = repo.getRefDatabase().newUpdate("refs/heads/answer", false);
+      update.setNewObjectId(answer);
+      assertThat(update.forceUpdate()).isEqualTo(RefUpdate.Result.NEW);
+    }
+  }
 
   @Test
   public void createEmptyChange_MissingBranch() throws Exception {
@@ -963,6 +989,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 +1176,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/SubmitByFastForwardIT.java b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByFastForwardIT.java
index 66eb48c..58e48e9 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByFastForwardIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByFastForwardIT.java
@@ -20,6 +20,7 @@
 
 import com.google.gerrit.acceptance.GitUtil;
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
@@ -107,6 +108,39 @@
   }
 
   @Test
+  @GerritConfig(name = "change.submitWholeTopic", value = "true")
+  public void submitTwoIndependentChangesWithFastForwardFail() throws Throwable {
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
+    PushOneCommit.Result change1 = createChange("subject1", "file1.txt", "content", "topic");
+
+    testRepo.reset(initialHead);
+    PushOneCommit.Result change2 = createChange("subject2", "file2.txt", "content", "topic");
+
+    approve(change1.getChangeId());
+    approve(change2.getChangeId());
+
+    String fastForwardIndependentChangesError =
+        "Change could not be merged because the submission"
+            + " has two independent changes with the same destination branch. Independent changes can't "
+            + "be submitted to the same destination branch with FAST_FORWARD_ONLY submit strategy";
+
+    submitWithConflict(
+        change2.getChangeId(),
+        String.format(
+            "Failed to submit 2 changes due to the following problems:\n"
+                + "Change %d: %s\nChange %d: %s",
+            change1.getChange().getId().get(),
+            fastForwardIndependentChangesError,
+            change2.getChange().getId().get(),
+            fastForwardIndependentChangesError));
+
+    RevCommit updatedHead = projectOperations.project(project).getHead("master");
+    assertThat(updatedHead.getId()).isEqualTo(initialHead.getId());
+    assertRefUpdatedEvents();
+    assertChangeMergedEvents();
+  }
+
+  @Test
   public void submitFastForwardNotPossible_Conflict() throws Throwable {
     RevCommit initialHead = projectOperations.project(project).getHead("master");
     PushOneCommit.Result change = createChange("Change 1", "a.txt", "content");
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/acceptance/rest/config/ServerInfoIT.java b/javatests/com/google/gerrit/acceptance/rest/config/ServerInfoIT.java
index 4fe86b3..97288a8 100644
--- a/javatests/com/google/gerrit/acceptance/rest/config/ServerInfoIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/config/ServerInfoIT.java
@@ -58,8 +58,6 @@
   @GerritConfig(name = "auth.httpPasswordUrl", value = "https://example.com/password")
 
   // change
-  @GerritConfig(name = "change.replyTooltip", value = "Publish votes and draft comments")
-  @GerritConfig(name = "change.replyLabel", value = "Vote")
   @GerritConfig(name = "change.updateDelay", value = "50s")
   @GerritConfig(name = "change.disablePrivateChanges", value = "true")
   @GerritConfig(name = "change.enableAttentionSet", value = "true")
@@ -102,8 +100,6 @@
     assertThat(i.auth.httpPasswordUrl).isNull();
 
     // change
-    assertThat(i.change.replyTooltip).startsWith("Publish votes and draft comments");
-    assertThat(i.change.replyLabel).isEqualTo("Vote\u2026");
     assertThat(i.change.updateDelay).isEqualTo(50);
     assertThat(i.change.disablePrivateChanges).isTrue();
     assertThat(i.change.enableAttentionSet).isTrue();
@@ -170,8 +166,6 @@
     assertThat(i.auth.httpPasswordUrl).isNull();
 
     // change
-    assertThat(i.change.replyTooltip).startsWith("Reply and score");
-    assertThat(i.change.replyLabel).isEqualTo("Reply\u2026");
     assertThat(i.change.updateDelay).isEqualTo(300);
     assertThat(i.change.disablePrivateChanges).isNull();
     assertThat(i.change.submitWholeTopic).isNull();
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/CreateLabelIT.java b/javatests/com/google/gerrit/acceptance/rest/project/CreateLabelIT.java
index 6a98b8b..dfe69f9 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/CreateLabelIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/CreateLabelIT.java
@@ -430,6 +430,57 @@
   }
 
   @Test
+  public void createWithCopyCondition() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
+    input.copyCondition = "is:MAX";
+
+    LabelDefinitionInfo createdLabel =
+        gApi.projects().name(project.get()).label("foo").create(input).get();
+    assertThat(createdLabel.copyCondition).isEqualTo("is:MAX");
+  }
+
+  @Test
+  public void createCopyConditionPerformsGroupVisibilityCheckWhenUserInPredicateIsUsed()
+      throws Exception {
+    String administratorsUUID = gApi.groups().query("name:Administrators").get().get(0).id;
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
+    input.copyCondition = "uploaderin:" + administratorsUUID;
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.OWNER).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+    // User can't see admin group
+    requestScopeOperations.setApiUser(user.id());
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.projects().name(project.get()).label("foo").create(input));
+    assertThat(thrown).hasMessageThat().contains("Group " + administratorsUUID + " not found");
+
+    // Admin can see admin group
+    requestScopeOperations.setApiUser(admin.id());
+    LabelDefinitionInfo updatedLabel =
+        gApi.projects().name(project.get()).label("foo").create(input).get();
+    assertThat(updatedLabel.copyCondition).isEqualTo(input.copyCondition);
+  }
+
+  @Test
+  public void createWithInvalidCopyCondition() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
+    input.copyCondition = "blarg::asd";
+
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.projects().name(project.get()).label("Bar").create(input));
+    assertThat(thrown).hasMessageThat().contains("unable to parse copy condition");
+  }
+
+  @Test
   public void createWithCopyMaxScore() throws Exception {
     LabelDefinitionInput input = new LabelDefinitionInput();
     input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/CreateProjectIT.java b/javatests/com/google/gerrit/acceptance/rest/project/CreateProjectIT.java
index 7090074..02db412 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/CreateProjectIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/CreateProjectIT.java
@@ -109,7 +109,7 @@
   @Test
   public void createProjectHttpWhenProjectAlreadyExists_PreconditionFailed() throws Exception {
     adminRestSession
-        .putWithHeader(
+        .putWithHeaders(
             "/projects/" + allProjects.get(), new BasicHeader(HttpHeaders.IF_NONE_MATCH, "*"))
         .assertPreconditionFailed();
   }
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/DeleteLabelIT.java b/javatests/com/google/gerrit/acceptance/rest/project/DeleteLabelIT.java
index c2db9f1..491a0d5 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/DeleteLabelIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/DeleteLabelIT.java
@@ -16,6 +16,7 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.extensions.client.ListChangesOption.DETAILED_LABELS;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
@@ -109,4 +110,16 @@
             projectOperations.project(allProjects).getHead(RefNames.REFS_CONFIG).getShortMessage())
         .isEqualTo("Delete Code-Review label");
   }
+
+  @Test
+  public void deletedLabelDoesNotThrowExceptions_approvalInference() throws Exception {
+    String changeId = createChange().getChangeId();
+    approve(changeId);
+    gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).delete();
+
+    amendChange(changeId);
+
+    // Assert no throws.
+    gApi.changes().id(changeId).get(DETAILED_LABELS);
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/SetLabelIT.java b/javatests/com/google/gerrit/acceptance/rest/project/SetLabelIT.java
index 2e68b54..b4938c1 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/SetLabelIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/SetLabelIT.java
@@ -516,6 +516,83 @@
   }
 
   @Test
+  public void setCopyCondition() throws Exception {
+    configLabel("foo", LabelFunction.NO_OP);
+    assertThat(gApi.projects().name(project.get()).label("foo").get().copyCondition).isNull();
+
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.copyCondition = "is:MAX";
+
+    LabelDefinitionInfo updatedLabel =
+        gApi.projects().name(project.get()).label("foo").update(input);
+    assertThat(updatedLabel.copyCondition).isEqualTo("is:MAX");
+  }
+
+  @Test
+  public void setCopyConditionPerformsGroupVisibilityCheckWhenUserInPredicateIsUsed()
+      throws Exception {
+    String administratorsUUID = gApi.groups().query("name:Administrators").get().get(0).id;
+    configLabel("foo", LabelFunction.NO_OP);
+    assertThat(gApi.projects().name(project.get()).label("foo").get().copyCondition).isNull();
+
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.copyCondition = "uploaderin:" + administratorsUUID;
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.OWNER).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+    // User can't see admin group
+    requestScopeOperations.setApiUser(user.id());
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.projects().name(project.get()).label("foo").update(input));
+    assertThat(thrown).hasMessageThat().contains("Group " + administratorsUUID + " not found");
+
+    // Admin can see admin group
+    requestScopeOperations.setApiUser(admin.id());
+    LabelDefinitionInfo updatedLabel =
+        gApi.projects().name(project.get()).label("foo").update(input);
+    assertThat(updatedLabel.copyCondition).isEqualTo(input.copyCondition);
+  }
+
+  @Test
+  public void setInvalidCopyCondition() throws Exception {
+    configLabel("foo", LabelFunction.NO_OP);
+    assertThat(gApi.projects().name(project.get()).label("foo").get().copyCondition).isNull();
+
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.copyCondition = "foo:::bar";
+
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.projects().name(project.get()).label("foo").update(input));
+    assertThat(thrown).hasMessageThat().contains("unable to parse copy condition");
+  }
+
+  @Test
+  public void unsetCopyCondition() throws Exception {
+    configLabel("foo", LabelFunction.NO_OP);
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      u.getConfig().updateLabelType("foo", lt -> lt.setCopyCondition("is:MAX"));
+      u.save();
+    }
+    assertThat(gApi.projects().name(project.get()).label("foo").get().copyCondition)
+        .isEqualTo("is:MAX");
+
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.unsetCopyCondition = true;
+
+    LabelDefinitionInfo updatedLabel =
+        gApi.projects().name(project.get()).label("foo").update(input);
+    assertThat(updatedLabel.copyCondition).isNull();
+
+    assertThat(gApi.projects().name(project.get()).label("foo").get().copyCondition).isNull();
+  }
+
+  @Test
   public void setCopyMinScore() throws Exception {
     configLabel("foo", LabelFunction.NO_OP);
     assertThat(gApi.projects().name(project.get()).label("foo").get().copyMinScore).isNull();
diff --git a/javatests/com/google/gerrit/acceptance/server/change/CommentContextIT.java b/javatests/com/google/gerrit/acceptance/server/change/CommentContextIT.java
index 9bd8e9c..29058ef 100644
--- a/javatests/com/google/gerrit/acceptance/server/change/CommentContextIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/change/CommentContextIT.java
@@ -445,6 +445,52 @@
     assertThat(comments.get(0).sourceContentType).isEqualTo("text/x-c++src");
   }
 
+  @Test
+  public void listChangeCommentsWithContextEnabled_twoRangeCommentsWithTheSameContext()
+      throws Exception {
+    PushOneCommit.Result r1 = createChange();
+
+    ImmutableList.Builder<String> content = ImmutableList.builder();
+    for (int i = 1; i <= 10; i++) {
+      content.add("line_" + i);
+    }
+
+    PushOneCommit.Result r2 =
+        pushFactory
+            .create(
+                admin.newIdent(),
+                testRepo,
+                PushOneCommit.SUBJECT,
+                FILE_NAME,
+                content.build().stream().collect(Collectors.joining("\n")),
+                r1.getChangeId())
+            .to("refs/for/master");
+
+    CommentsUtil.addCommentOnRange(gApi, r2, "looks good", createCommentRange(2, 5));
+    CommentsUtil.addCommentOnRange(gApi, r2, "are you sure?", createCommentRange(2, 5));
+
+    List<CommentInfo> comments =
+        gApi.changes().id(r2.getChangeId()).commentsRequest().withContext(true).getAsList();
+
+    assertThat(comments).hasSize(2);
+
+    assertThat(
+            comments.stream()
+                .filter(c -> c.message.equals("looks good"))
+                .collect(MoreCollectors.onlyElement())
+                .contextLines)
+        .containsExactlyElementsIn(
+            createContextLines("2", "line_2", "3", "line_3", "4", "line_4", "5", "line_5"));
+
+    assertThat(
+            comments.stream()
+                .filter(c -> c.message.equals("are you sure?"))
+                .collect(MoreCollectors.onlyElement())
+                .contextLines)
+        .containsExactlyElementsIn(
+            createContextLines("2", "line_2", "3", "line_3", "4", "line_4", "5", "line_5"));
+  }
+
   private String createChangeWithContent(String fileName, String fileContent, int line)
       throws Exception {
     PushOneCommit.Result result =
diff --git a/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java b/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
index cbf8438..89074b7 100644
--- a/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
@@ -1386,11 +1386,10 @@
             r1.getCommit().getName(),
             CommentsUtil.newDraft(FILE_NAME, Side.REVISION, createLineRange(4, 10), "comment 1"));
 
-    CommentInfo draftPs2 =
-        addDraft(
-            r1.getChangeId(),
-            r2.getCommit().getName(),
-            CommentsUtil.newDraft(FILE_NAME, Side.REVISION, createLineRange(3, 12), "comment 3"));
+    addDraft(
+        r1.getChangeId(),
+        r2.getCommit().getName(),
+        CommentsUtil.newDraft(FILE_NAME, Side.REVISION, createLineRange(3, 12), "comment 3"));
 
     ReviewInput reviewInput =
         createReviewInput(
diff --git a/javatests/com/google/gerrit/acceptance/server/change/ConsistencyCheckerIT.java b/javatests/com/google/gerrit/acceptance/server/change/ConsistencyCheckerIT.java
index e39f967..9d821b7 100644
--- a/javatests/com/google/gerrit/acceptance/server/change/ConsistencyCheckerIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/change/ConsistencyCheckerIT.java
@@ -257,7 +257,6 @@
             + "Groups: "
             + rev
             + "\n");
-    indexer.index(c.getProject(), c.getId());
     ChangeNotes notes = changeNotesFactory.create(c.getProject(), c.getId());
 
     FixInput fix = new FixInput();
@@ -817,8 +816,6 @@
             + "Subject: "
             + subject
             + "\n");
-    indexer.index(c.getProject(), c.getId());
-
     return ps;
   }
 
diff --git a/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java b/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java
index 4a8c376..65cb97a 100644
--- a/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java
@@ -89,6 +89,7 @@
         .forUpdate()
         .add(allow(Permission.FORGE_COMMITTER).ref("refs/*").group(REGISTERED_USERS))
         .add(allow(Permission.SUBMIT).ref("refs/*").group(REGISTERED_USERS))
+        .add(allow(Permission.SUBMIT_AS).ref("refs/*").group(REGISTERED_USERS))
         .add(allow(Permission.ABANDON).ref("refs/*").group(REGISTERED_USERS))
         .add(allowLabel(LabelId.CODE_REVIEW).ref("refs/*").group(REGISTERED_USERS).range(-2, +2))
         .update();
@@ -1623,6 +1624,75 @@
     assertThat(sender).didNotSend();
   }
 
+  @Test
+  public void mergeOnBehalfOfEmailEnabled_impersonatedOwnerNotified() throws Exception {
+    StagedChange sc = stageChangeReadyForMerge();
+    // If notification is enabled, onBehalfOfUser is always notified.
+    setEmailStrategy(sc.owner, ENABLED);
+    merge(sc.changeId, other, sc.owner, ALL);
+    assertThat(sender)
+        .sent("merged", sc)
+        .to(sc.owner)
+        .cc(sc.reviewer, sc.ccer)
+        .cc(StagedUsers.REVIEWER_BY_EMAIL, StagedUsers.CC_BY_EMAIL)
+        .bcc(sc.starrer)
+        .bcc(ALL_COMMENTS, SUBMITTED_CHANGES)
+        .noOneElse();
+    assertThat(sender).didNotSend();
+  }
+
+  @Test
+  public void mergeOnBehalfOfEmailEnabled_impersonatedReviewerNotified() throws Exception {
+    StagedChange sc = stageChangeReadyForMerge();
+    // If notification is enabled, onBehalfOfUser is always notified.
+    setEmailStrategy(sc.reviewer, ENABLED);
+    merge(sc.changeId, other, sc.reviewer, ALL);
+    assertThat(sender)
+        .sent("merged", sc)
+        .to(sc.owner)
+        .cc(sc.reviewer, sc.ccer)
+        .cc(StagedUsers.REVIEWER_BY_EMAIL, StagedUsers.CC_BY_EMAIL)
+        .bcc(sc.starrer)
+        .bcc(ALL_COMMENTS, SUBMITTED_CHANGES)
+        .noOneElse();
+    assertThat(sender).didNotSend();
+  }
+
+  @Test
+  public void mergeOnBehalfOfReviewerNotifyOwner_impersonatedReviewerInCC() throws Exception {
+    StagedChange sc = stageChangeReadyForMerge();
+    setEmailStrategy(sc.reviewer, ENABLED);
+    // Even though Submit strategy is OWNER, impersonated reviewer is added to CC.
+    merge(sc.changeId, other, sc.reviewer, OWNER);
+    assertThat(sender).sent("merged", sc).to(sc.owner).cc(sc.reviewer).noOneElse();
+    assertThat(sender).didNotSend();
+  }
+
+  @Test
+  public void mergeOnBehalfOfOtherNotifyOwner_impersonatedOtherInCC() throws Exception {
+    StagedChange sc = stageChangeReadyForMerge();
+    // Unrelated impersonated user is added to CC.
+    merge(sc.changeId, sc.reviewer, other, OWNER);
+    assertThat(sender).sent("merged", sc).to(sc.owner).cc(other).noOneElse();
+    assertThat(sender).didNotSend();
+  }
+
+  @Test
+  public void mergeOnBehalfOfEmailDisabled_doesNotNotify() throws Exception {
+    StagedChange sc = stageChangeReadyForMerge();
+    setEmailStrategy(other, EmailStrategy.DISABLED);
+    merge(sc.changeId, sc.reviewer, other, OWNER);
+    assertThat(sender).sent("merged", sc).to(sc.owner).noOneElse();
+    assertThat(sender).didNotSend();
+  }
+
+  @Test
+  public void mergeOnBehalfOfNotifyNone() throws Exception {
+    StagedChange sc = stageChangeReadyForMerge();
+    merge(sc.changeId, other, sc.owner, NONE);
+    assertThat(sender).didNotSend();
+  }
+
   private void merge(String changeId, TestAccount by) throws Exception {
     merge(changeId, by, ENABLED);
   }
@@ -1648,6 +1718,16 @@
     gApi.changes().id(changeId).current().submit(in);
   }
 
+  private void merge(
+      String changeId, TestAccount by, TestAccount onBehalfOf, @Nullable NotifyHandling notify)
+      throws Exception {
+    requestScopeOperations.setApiUser(by.id());
+    SubmitInput in = new SubmitInput();
+    in.notify = notify;
+    in.onBehalfOf = onBehalfOf.id().toString();
+    gApi.changes().id(changeId).current().submit(in);
+  }
+
   private StagedChange stageChangeReadyForMerge() throws Exception {
     StagedChange sc = stageReviewableChange();
     requestScopeOperations.setApiUser(sc.reviewer.id());
diff --git a/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsEvaluatorIT.java b/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsEvaluatorIT.java
new file mode 100644
index 0000000..e848cef
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsEvaluatorIT.java
@@ -0,0 +1,253 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.server.project;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabel;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.server.project.testing.TestLabels.value;
+
+import com.google.common.collect.MoreCollectors;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.LabelFunction;
+import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.entities.SubmitRequirementExpression;
+import com.google.gerrit.entities.SubmitRequirementExpressionResult;
+import com.google.gerrit.entities.SubmitRequirementExpressionResult.Status;
+import com.google.gerrit.entities.SubmitRequirementResult;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.server.project.SubmitRequirementsEvaluator;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.InternalChangeQuery;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import java.util.Optional;
+import org.junit.Before;
+import org.junit.Test;
+
+@NoHttpd
+public class SubmitRequirementsEvaluatorIT extends AbstractDaemonTest {
+  @Inject SubmitRequirementsEvaluator evaluator;
+  @Inject private ProjectOperations projectOperations;
+  @Inject private Provider<InternalChangeQuery> changeQueryProvider;
+
+  private ChangeData changeData;
+  private String changeId;
+
+  @Before
+  public void setUp() throws Exception {
+    PushOneCommit.Result pushResult =
+        createChange(testRepo, "refs/heads/master", "Fix a bug", "file.txt", "content", "topic");
+    changeData = pushResult.getChange();
+    changeId = pushResult.getChangeId();
+  }
+
+  @Test
+  public void invalidExpression() throws Exception {
+    SubmitRequirementExpression expression =
+        SubmitRequirementExpression.create("invalid_field:invalid_value");
+    SubmitRequirementExpressionResult result = evaluator.evaluateExpression(expression, changeData);
+
+    assertThat(result.status()).isEqualTo(Status.ERROR);
+    assertThat(result.errorMessage().get())
+        .isEqualTo("Unsupported operator invalid_field:invalid_value");
+  }
+
+  @Test
+  public void expressionWithPassingPredicate() throws Exception {
+    SubmitRequirementExpression expression =
+        SubmitRequirementExpression.create("branch:refs/heads/master");
+    SubmitRequirementExpressionResult result = evaluator.evaluateExpression(expression, changeData);
+
+    assertThat(result.status()).isEqualTo(Status.PASS);
+    assertThat(result.errorMessage()).isEqualTo(Optional.empty());
+  }
+
+  @Test
+  public void expressionWithFailingPredicate() throws Exception {
+    SubmitRequirementExpression expression =
+        SubmitRequirementExpression.create("branch:refs/heads/foo");
+    SubmitRequirementExpressionResult result = evaluator.evaluateExpression(expression, changeData);
+
+    assertThat(result.status()).isEqualTo(Status.FAIL);
+    assertThat(result.errorMessage()).isEqualTo(Optional.empty());
+  }
+
+  @Test
+  public void compositeExpression() throws Exception {
+    SubmitRequirementExpression expression =
+        SubmitRequirementExpression.create(
+            String.format(
+                "(project:%s AND branch:refs/heads/foo) OR message:\"Fix a bug\"", project.get()));
+
+    SubmitRequirementExpressionResult result = evaluator.evaluateExpression(expression, changeData);
+
+    assertThat(result.status()).isEqualTo(Status.PASS);
+
+    assertThat(result.passingAtoms())
+        .containsExactly(String.format("project:%s", project.get()), "message:\"Fix a bug\"");
+
+    assertThat(result.failingAtoms())
+        .containsExactly(
+            // TODO(ghareeb): querying "branch:" creates a RefPredicate. Fix names so that they
+            // match
+            String.format("ref:refs/heads/foo"));
+  }
+
+  @Test
+  public void submitRequirementIsNotApplicable_whenApplicabilityExpressionIsFalse()
+      throws Exception {
+    SubmitRequirement sr =
+        createSubmitRequirement(
+            /* applicabilityExpr= */ "project:non-existent-project",
+            /* submittabilityExpr= */ "message:\"Fix bug\"",
+            /* overrideExpr= */ "");
+
+    SubmitRequirementResult result = evaluator.evaluate(sr, changeData);
+    assertThat(result.status()).isEqualTo(SubmitRequirementResult.Status.NOT_APPLICABLE);
+  }
+
+  @Test
+  public void submitRequirementIsSatisfied_whenSubmittabilityExpressionIsTrue() throws Exception {
+    SubmitRequirement sr =
+        createSubmitRequirement(
+            /* applicabilityExpr= */ "project:" + project.get(),
+            /* submittabilityExpr= */ "message:\"Fix a bug\"",
+            /* overrideExpr= */ "");
+
+    SubmitRequirementResult result = evaluator.evaluate(sr, changeData);
+    assertThat(result.status()).isEqualTo(SubmitRequirementResult.Status.SATISFIED);
+  }
+
+  @Test
+  public void submitRequirementIsUnsatisfied_whenSubmittabilityExpressionIsFalse()
+      throws Exception {
+    SubmitRequirement sr =
+        createSubmitRequirement(
+            /* applicabilityExpr= */ "project:" + project.get(),
+            /* submittabilityExpr= */ "label:\"code-review=+2\"",
+            /* overrideExpr= */ "");
+
+    SubmitRequirementResult result = evaluator.evaluate(sr, changeData);
+    assertThat(result.status()).isEqualTo(SubmitRequirementResult.Status.UNSATISFIED);
+  }
+
+  @Test
+  public void submitRequirementIsOverridden_whenOverrideExpressionIsTrue() throws Exception {
+    addLabel("build-cop-override");
+    voteLabel(changeId, "build-cop-override", 1);
+
+    // Reload change data after applying the vote
+    changeData =
+        changeQueryProvider.get().byLegacyChangeId(changeData.getId()).stream()
+            .collect(MoreCollectors.onlyElement());
+
+    SubmitRequirement sr =
+        createSubmitRequirement(
+            /* applicabilityExpr= */ "project:" + project.get(),
+            /* submittabilityExpr= */ "label:\"code-review=+2\"",
+            /* overrideExpr= */ "label:\"build-cop-override=+1\"");
+
+    SubmitRequirementResult result = evaluator.evaluate(sr, changeData);
+    assertThat(result.status()).isEqualTo(SubmitRequirementResult.Status.OVERRIDDEN);
+  }
+
+  @Test
+  public void submitRequirementIsError_whenApplicabilityExpressionHasInvalidSyntax()
+      throws Exception {
+    addLabel("build-cop-override");
+
+    SubmitRequirement sr =
+        createSubmitRequirement(
+            /* applicabilityExpr= */ "invalid_field:invalid_value",
+            /* submittabilityExpr= */ "label:\"code-review=+2\"",
+            /* overrideExpr= */ "label:\"build-cop-override=+1\"");
+
+    SubmitRequirementResult result = evaluator.evaluate(sr, changeData);
+    assertThat(result.status()).isEqualTo(SubmitRequirementResult.Status.ERROR);
+    assertThat(result.applicabilityExpressionResult().get().errorMessage().get())
+        .isEqualTo("Unsupported operator invalid_field:invalid_value");
+  }
+
+  @Test
+  public void submitRequirementIsError_whenSubmittabilityExpressionHasInvalidSyntax()
+      throws Exception {
+    addLabel("build-cop-override");
+
+    SubmitRequirement sr =
+        createSubmitRequirement(
+            /* applicabilityExpr= */ "project:" + project.get(),
+            /* submittabilityExpr= */ "invalid_field:invalid_value",
+            /* overrideExpr= */ "label:\"build-cop-override=+1\"");
+
+    SubmitRequirementResult result = evaluator.evaluate(sr, changeData);
+    assertThat(result.status()).isEqualTo(SubmitRequirementResult.Status.ERROR);
+    assertThat(result.submittabilityExpressionResult().errorMessage().get())
+        .isEqualTo("Unsupported operator invalid_field:invalid_value");
+  }
+
+  @Test
+  public void submitRequirementIsError_whenOverrideExpressionHasInvalidSyntax() throws Exception {
+    SubmitRequirement sr =
+        createSubmitRequirement(
+            /* applicabilityExpr= */ "project:" + project.get(),
+            /* submittabilityExpr= */ "label:\"code-review=+2\"",
+            /* overrideExpr= */ "invalid_field:invalid_value");
+
+    SubmitRequirementResult result = evaluator.evaluate(sr, changeData);
+    assertThat(result.status()).isEqualTo(SubmitRequirementResult.Status.ERROR);
+    assertThat(result.overrideExpressionResult().get().errorMessage().get())
+        .isEqualTo("Unsupported operator invalid_field:invalid_value");
+  }
+
+  private void voteLabel(String changeId, String labelName, int score) throws RestApiException {
+    gApi.changes().id(changeId).current().review(new ReviewInput().label(labelName, score));
+  }
+
+  private void addLabel(String labelName) throws Exception {
+    configLabel(
+        project,
+        labelName,
+        LabelFunction.NO_OP,
+        value(1, "ok"),
+        value(0, "No score"),
+        value(-1, "Needs work"));
+
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allowLabel(labelName).ref("refs/heads/master").group(REGISTERED_USERS).range(-1, +1))
+        .update();
+  }
+
+  private SubmitRequirement createSubmitRequirement(
+      @Nullable String applicabilityExpr,
+      String submittabilityExpr,
+      @Nullable String overrideExpr) {
+    return SubmitRequirement.builder()
+        .setName("sr-name")
+        .setDescription(Optional.of("sr-description"))
+        .setApplicabilityExpression(SubmitRequirementExpression.of(applicabilityExpr))
+        .setSubmittabilityExpression(SubmitRequirementExpression.create(submittabilityExpr))
+        .setOverrideExpression(SubmitRequirementExpression.of(overrideExpr))
+        .setAllowOverrideInChildProjects(false)
+        .build();
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/server/query/ApprovalQueryIT.java b/javatests/com/google/gerrit/acceptance/server/query/ApprovalQueryIT.java
new file mode 100644
index 0000000..9392219
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/query/ApprovalQueryIT.java
@@ -0,0 +1,274 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.server.query;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.testsuite.change.ChangeKindCreator;
+import com.google.gerrit.acceptance.testsuite.change.ChangeOperations;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.LabelId;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.PatchSetApproval;
+import com.google.gerrit.extensions.client.ChangeKind;
+import com.google.gerrit.index.query.QueryParseException;
+import com.google.gerrit.server.change.ChangeKindCache;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.query.approval.ApprovalContext;
+import com.google.gerrit.server.query.approval.ApprovalQueryBuilder;
+import com.google.inject.Inject;
+import java.util.Date;
+import org.junit.Test;
+
+public class ApprovalQueryIT extends AbstractDaemonTest {
+  @Inject private ApprovalQueryBuilder queryBuilder;
+  @Inject private ChangeKindCreator changeKindCreator;
+  @Inject private ChangeNotes.Factory changeNotesFactory;
+  @Inject private ChangeKindCache changeKindCache;
+  @Inject private ChangeOperations changeOperations;
+
+  @Test
+  public void magicValuePredicate() throws Exception {
+    assertTrue(queryBuilder.parse("is:MAX").asMatchable().match(contextForCodeReviewLabel(2)));
+    assertTrue(queryBuilder.parse("is:mAx").asMatchable().match(contextForCodeReviewLabel(2)));
+    assertFalse(queryBuilder.parse("is:MAX").asMatchable().match(contextForCodeReviewLabel(-2)));
+    assertFalse(queryBuilder.parse("is:MAX").asMatchable().match(contextForCodeReviewLabel(1)));
+    assertFalse(queryBuilder.parse("is:MAX").asMatchable().match(contextForCodeReviewLabel(5000)));
+
+    assertTrue(queryBuilder.parse("is:MIN").asMatchable().match(contextForCodeReviewLabel(-2)));
+    assertTrue(queryBuilder.parse("is:mIn").asMatchable().match(contextForCodeReviewLabel(-2)));
+    assertFalse(queryBuilder.parse("is:MIN").asMatchable().match(contextForCodeReviewLabel(2)));
+    assertFalse(queryBuilder.parse("is:MIN").asMatchable().match(contextForCodeReviewLabel(-1)));
+    assertFalse(queryBuilder.parse("is:MIN").asMatchable().match(contextForCodeReviewLabel(5000)));
+
+    assertTrue(queryBuilder.parse("is:ANY").asMatchable().match(contextForCodeReviewLabel(-2)));
+    assertTrue(queryBuilder.parse("is:ANY").asMatchable().match(contextForCodeReviewLabel(2)));
+    assertTrue(queryBuilder.parse("is:aNy").asMatchable().match(contextForCodeReviewLabel(2)));
+  }
+
+  @Test
+  public void changeKindPredicate_noCodeChange() throws Exception {
+    String change = changeKindCreator.createChange(ChangeKind.NO_CODE_CHANGE, testRepo, admin);
+    changeKindCreator.updateChange(change, ChangeKind.NO_CODE_CHANGE, testRepo, admin, project);
+    PatchSet.Id ps1 =
+        PatchSet.id(Change.id(gApi.changes().id(change).get()._number), /* psId= */ 1);
+    assertTrue(
+        queryBuilder
+            .parse("changekind:no-code-change")
+            .asMatchable()
+            .match(contextForCodeReviewLabel(/* value= */ -2, ps1, admin.id())));
+
+    changeKindCreator.updateChange(change, ChangeKind.TRIVIAL_REBASE, testRepo, admin, project);
+    PatchSet.Id ps2 =
+        PatchSet.id(Change.id(gApi.changes().id(change).get()._number), /* psId= */ 2);
+    assertFalse(
+        queryBuilder
+            .parse("changekind:no-code-change")
+            .asMatchable()
+            .match(contextForCodeReviewLabel(/* value= */ -2, ps2, admin.id())));
+  }
+
+  @Test
+  public void changeKindPredicate_trivialRebase() throws Exception {
+    String change = changeKindCreator.createChange(ChangeKind.TRIVIAL_REBASE, testRepo, admin);
+    changeKindCreator.updateChange(change, ChangeKind.TRIVIAL_REBASE, testRepo, admin, project);
+    PatchSet.Id ps1 =
+        PatchSet.id(Change.id(gApi.changes().id(change).get()._number), /* psId= */ 1);
+    assertTrue(
+        queryBuilder
+            .parse("changekind:trivial-rebase")
+            .asMatchable()
+            .match(contextForCodeReviewLabel(/* value= */ -2, ps1, admin.id())));
+
+    changeKindCreator.updateChange(change, ChangeKind.REWORK, testRepo, admin, project);
+    PatchSet.Id ps2 =
+        PatchSet.id(Change.id(gApi.changes().id(change).get()._number), /* psId= */ 2);
+    assertFalse(
+        queryBuilder
+            .parse("changekind:trivial-rebase")
+            .asMatchable()
+            .match(contextForCodeReviewLabel(/* value= */ -2, ps2, admin.id())));
+  }
+
+  @Test
+  public void changeKindPredicate_reworkAndNotRework() throws Exception {
+    String change = changeKindCreator.createChange(ChangeKind.REWORK, testRepo, admin);
+    changeKindCreator.updateChange(change, ChangeKind.REWORK, testRepo, admin, project);
+    PatchSet.Id ps1 =
+        PatchSet.id(Change.id(gApi.changes().id(change).get()._number), /* psId= */ 1);
+    assertTrue(
+        queryBuilder
+            .parse("changekind:rework")
+            .asMatchable()
+            .match(contextForCodeReviewLabel(/* value= */ -2, ps1, admin.id())));
+
+    changeKindCreator.updateChange(change, ChangeKind.REWORK, testRepo, admin, project);
+    PatchSet.Id ps2 =
+        PatchSet.id(Change.id(gApi.changes().id(change).get()._number), /* psId= */ 2);
+    assertFalse(
+        queryBuilder
+            .parse("-changekind:rework")
+            .asMatchable()
+            .match(contextForCodeReviewLabel(/* value= */ -2, ps2, admin.id())));
+  }
+
+  @Test
+  public void uploaderInPredicate() throws Exception {
+    String administratorsUUID = gApi.groups().query("name:Administrators").get().get(0).id;
+
+    PushOneCommit.Result pushResult = createChange();
+    String changeCreatedByAdmin = pushResult.getChangeId();
+    approve(changeCreatedByAdmin);
+    // PS2 uploaded by admin
+    amendChange(changeCreatedByAdmin);
+    // PS3 uploaded by user
+    amendChangeWithUploader(pushResult, project, user).assertOkStatus();
+
+    // can copy approval from patchset 1 -> 2
+    assertTrue(
+        queryBuilder
+            .parse("uploaderin:" + administratorsUUID)
+            .asMatchable()
+            .match(
+                contextForCodeReviewLabel(
+                    /* value= */ 2,
+                    PatchSet.id(pushResult.getChange().getId(), /* psId= */ 1),
+                    admin.id())));
+    // can not copy approval from patchset 2 -> 3
+    assertFalse(
+        queryBuilder
+            .parse("uploaderin:" + administratorsUUID)
+            .asMatchable()
+            .match(
+                contextForCodeReviewLabel(
+                    /* value= */ 2,
+                    PatchSet.id(pushResult.getChange().getId(), /* psId= */ 2),
+                    admin.id())));
+  }
+
+  @Test
+  public void approverInPredicate() throws Exception {
+    String administratorsUUID = gApi.groups().query("name:Administrators").get().get(0).id;
+
+    PushOneCommit.Result pushResult = createChange();
+    amendChange(pushResult.getChangeId());
+    amendChange(pushResult.getChangeId());
+    // can copy approval from patchset 1 -> 2
+    assertTrue(
+        queryBuilder
+            .parse("approverin:" + administratorsUUID)
+            .asMatchable()
+            .match(
+                contextForCodeReviewLabel(
+                    /* value= */ 2,
+                    PatchSet.id(pushResult.getChange().getId(), /* psId= */ 1),
+                    admin.id())));
+    // can not copy approval from patchset 2 -> 3
+    assertFalse(
+        queryBuilder
+            .parse("approverin:" + administratorsUUID)
+            .asMatchable()
+            .match(
+                contextForCodeReviewLabel(
+                    /* value= */ 2,
+                    PatchSet.id(pushResult.getChange().getId(), /* psId= */ 2),
+                    user.id())));
+  }
+
+  @Test
+  public void userInPredicate_groupNotFound() {
+    QueryParseException thrown =
+        assertThrows(
+            QueryParseException.class,
+            () ->
+                queryBuilder
+                    .parse("uploaderin:foobar")
+                    .asMatchable()
+                    .match(contextForCodeReviewLabel(/* value= */ 2)));
+    assertThat(thrown).hasMessageThat().contains("Group foobar not found");
+  }
+
+  @Test
+  public void hasChangedFilesPredicate() throws Exception {
+    Change.Id changeId =
+        changeOperations.newChange().project(project).file("file").content("content").create();
+    changeOperations.change(changeId).newPatchset().file("file").content("new content").create();
+
+    // can copy approval from patch-set 1 -> 2
+    assertTrue(
+        queryBuilder
+            .parse("has:unchanged-files")
+            .asMatchable()
+            .match(
+                contextForCodeReviewLabel(
+                    /* value= */ 2, PatchSet.id(changeId, /* psId= */ 1), admin.id())));
+    changeOperations.change(changeId).newPatchset().file("file").delete().create();
+
+    // can not copy approval from patch-set 2 -> 3
+    assertFalse(
+        queryBuilder
+            .parse("has:unchanged-files")
+            .asMatchable()
+            .match(
+                contextForCodeReviewLabel(
+                    /* value= */ 2, PatchSet.id(changeId, /* psId= */ 2), admin.id())));
+  }
+
+  @Test
+  public void hasChangedFilesPredicate_unsupportedOperator() {
+    QueryParseException thrown =
+        assertThrows(
+            QueryParseException.class,
+            () ->
+                queryBuilder
+                    .parse("has:invalid")
+                    .asMatchable()
+                    .match(contextForCodeReviewLabel(/* value= */ 2)));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains(
+            "'invalid' is not a supported argument for has. only 'unchanged-files' is supported");
+  }
+
+  private ApprovalContext contextForCodeReviewLabel(int value) throws Exception {
+    PushOneCommit.Result result = createChange();
+    amendChange(result.getChangeId());
+    PatchSet.Id psId = PatchSet.id(result.getChange().getId(), 1);
+    return contextForCodeReviewLabel(value, psId, admin.id());
+  }
+
+  private ApprovalContext contextForCodeReviewLabel(
+      int value, PatchSet.Id psId, Account.Id approver) {
+    ChangeNotes changeNotes = changeNotesFactory.create(project, psId.changeId());
+    PatchSet.Id newPsId = PatchSet.id(psId.changeId(), psId.get() + 1);
+    ChangeKind changeKind =
+        changeKindCache.getChangeKind(
+            changeNotes.getChange(), changeNotes.getPatchSets().get(newPsId));
+    PatchSetApproval approval =
+        PatchSetApproval.builder()
+            .postSubmit(false)
+            .granted(new Date())
+            .key(PatchSetApproval.key(psId, approver, LabelId.create("Code-Review")))
+            .value(value)
+            .build();
+    return ApprovalContext.create(changeNotes, approval, newPsId, changeKind);
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/server/query/BUILD b/javatests/com/google/gerrit/acceptance/server/query/BUILD
new file mode 100644
index 0000000..f7d13a0
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/query/BUILD
@@ -0,0 +1,7 @@
+load("//javatests/com/google/gerrit/acceptance:tests.bzl", "acceptance_tests")
+
+acceptance_tests(
+    srcs = glob(["*IT.java"]),
+    group = "server_query",
+    labels = ["server"],
+)
diff --git a/javatests/com/google/gerrit/acceptance/ssh/SshCancellationIT.java b/javatests/com/google/gerrit/acceptance/ssh/SshCancellationIT.java
new file mode 100644
index 0000000..2cb9637
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/ssh/SshCancellationIT.java
@@ -0,0 +1,102 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.ssh;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.ExtensionRegistry;
+import com.google.gerrit.acceptance.ExtensionRegistry.Registration;
+import com.google.gerrit.acceptance.UseSsh;
+import com.google.gerrit.server.cancellation.RequestCancelledException;
+import com.google.gerrit.server.cancellation.RequestStateProvider;
+import com.google.gerrit.server.project.CreateProjectArgs;
+import com.google.gerrit.server.validators.ProjectCreationValidationListener;
+import com.google.gerrit.server.validators.ValidationException;
+import com.google.inject.Inject;
+import org.junit.Test;
+
+@UseSsh
+public class SshCancellationIT extends AbstractDaemonTest {
+  @Inject private ExtensionRegistry extensionRegistry;
+
+  @Test
+  public void handleClientDisconnected() throws Exception {
+    ProjectCreationValidationListener projectCreationListener =
+        new ProjectCreationValidationListener() {
+          @Override
+          public void validateNewProject(CreateProjectArgs args) throws ValidationException {
+            throw new RequestCancelledException(
+                RequestStateProvider.Reason.CLIENT_CLOSED_REQUEST, /* cancellationMessage= */ null);
+          }
+        };
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(projectCreationListener)) {
+      adminSshSession.exec("gerrit create-project " + name("new"));
+      adminSshSession.assertFailure("Client Closed Request");
+    }
+  }
+
+  @Test
+  public void handleClientDeadlineExceeded() throws Exception {
+    ProjectCreationValidationListener projectCreationListener =
+        new ProjectCreationValidationListener() {
+          @Override
+          public void validateNewProject(CreateProjectArgs args) throws ValidationException {
+            throw new RequestCancelledException(
+                RequestStateProvider.Reason.CLIENT_PROVIDED_DEADLINE_EXCEEDED,
+                /* cancellationMessage= */ null);
+          }
+        };
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(projectCreationListener)) {
+      adminSshSession.exec("gerrit create-project " + name("new"));
+      adminSshSession.assertFailure("Client Provided Deadline Exceeded");
+    }
+  }
+
+  @Test
+  public void handleServerDeadlineExceeded() throws Exception {
+    ProjectCreationValidationListener projectCreationListener =
+        new ProjectCreationValidationListener() {
+          @Override
+          public void validateNewProject(CreateProjectArgs args) throws ValidationException {
+            throw new RequestCancelledException(
+                RequestStateProvider.Reason.SERVER_DEADLINE_EXCEEDED,
+                /* cancellationMessage= */ null);
+          }
+        };
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(projectCreationListener)) {
+      adminSshSession.exec("gerrit create-project " + name("new"));
+      adminSshSession.assertFailure("Server Deadline Exceeded");
+    }
+  }
+
+  @Test
+  public void handleRequestCancellationWithMessage() throws Exception {
+    ProjectCreationValidationListener projectCreationListener =
+        new ProjectCreationValidationListener() {
+          @Override
+          public void validateNewProject(CreateProjectArgs args) throws ValidationException {
+            throw new RequestCancelledException(
+                RequestStateProvider.Reason.SERVER_DEADLINE_EXCEEDED, "deadline = 10m");
+          }
+        };
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(projectCreationListener)) {
+      adminSshSession.exec("gerrit create-project " + name("new"));
+      adminSshSession.assertFailure("Server Deadline Exceeded (deadline = 10m)");
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/testsuite/index/DefaultIndexBindingIT.java b/javatests/com/google/gerrit/acceptance/testsuite/index/DefaultIndexBindingIT.java
new file mode 100644
index 0000000..f5e4e09
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/testsuite/index/DefaultIndexBindingIT.java
@@ -0,0 +1,51 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.testsuite.index;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.index.IndexType;
+import com.google.gerrit.index.testing.AbstractFakeIndex;
+import com.google.gerrit.server.index.change.ChangeIndexCollection;
+import javax.inject.Inject;
+import org.junit.AfterClass;
+import org.junit.BeforeClass;
+import org.junit.Test;
+
+/** Test to check that the expected index backend was bound depending on sys/env properties. */
+public class DefaultIndexBindingIT extends AbstractDaemonTest {
+
+  @Inject private ChangeIndexCollection changeIndex;
+
+  private static String propertyBeforeTest;
+
+  @BeforeClass
+  public static void setup() {
+    propertyBeforeTest = System.getProperty(IndexType.SYS_PROP);
+    System.setProperty(IndexType.SYS_PROP, "");
+  }
+
+  @AfterClass
+  public static void teardown() {
+    System.setProperty(IndexType.SYS_PROP, propertyBeforeTest);
+  }
+
+  @Test
+  public void fakeIsBoundByDefault() throws Exception {
+    assertThat(System.getProperty(IndexType.SYS_PROP)).isEmpty();
+    assertThat(changeIndex.getSearchIndex()).isInstanceOf(AbstractFakeIndex.FakeChangeIndex.class);
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/testsuite/index/FakeIndexBindingIT.java b/javatests/com/google/gerrit/acceptance/testsuite/index/FakeIndexBindingIT.java
new file mode 100644
index 0000000..4122426
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/testsuite/index/FakeIndexBindingIT.java
@@ -0,0 +1,51 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.testsuite.index;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.index.IndexType;
+import com.google.gerrit.index.testing.AbstractFakeIndex;
+import com.google.gerrit.server.index.change.ChangeIndexCollection;
+import javax.inject.Inject;
+import org.junit.AfterClass;
+import org.junit.BeforeClass;
+import org.junit.Test;
+
+/** Test to check that the expected index backend was bound depending on sys/env properties. */
+public class FakeIndexBindingIT extends AbstractDaemonTest {
+
+  @Inject private ChangeIndexCollection changeIndex;
+
+  private static String propertyBeforeTest;
+
+  @BeforeClass
+  public static void setup() {
+    propertyBeforeTest = System.getProperty(IndexType.SYS_PROP);
+    System.setProperty(IndexType.SYS_PROP, "fake");
+  }
+
+  @AfterClass
+  public static void teardown() {
+    System.setProperty(IndexType.SYS_PROP, propertyBeforeTest);
+  }
+
+  @Test
+  public void fakeIsBoundWhenConfigured() throws Exception {
+    assertThat(System.getProperty(IndexType.SYS_PROP)).isEqualTo("fake");
+    assertThat(changeIndex.getSearchIndex()).isInstanceOf(AbstractFakeIndex.FakeChangeIndex.class);
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/testsuite/index/LuceneIndexBindingIT.java b/javatests/com/google/gerrit/acceptance/testsuite/index/LuceneIndexBindingIT.java
new file mode 100644
index 0000000..31e31fd
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/testsuite/index/LuceneIndexBindingIT.java
@@ -0,0 +1,51 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.testsuite.index;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.index.IndexType;
+import com.google.gerrit.lucene.LuceneChangeIndex;
+import com.google.gerrit.server.index.change.ChangeIndexCollection;
+import javax.inject.Inject;
+import org.junit.AfterClass;
+import org.junit.BeforeClass;
+import org.junit.Test;
+
+/** Test to check that the expected index backend was bound depending on sys/env properties. */
+public class LuceneIndexBindingIT extends AbstractDaemonTest {
+
+  @Inject private ChangeIndexCollection changeIndex;
+
+  private static String propertyBeforeTest;
+
+  @BeforeClass
+  public static void setup() {
+    propertyBeforeTest = System.getProperty(IndexType.SYS_PROP);
+    System.setProperty(IndexType.SYS_PROP, "lucene");
+  }
+
+  @AfterClass
+  public static void teardown() {
+    System.setProperty(IndexType.SYS_PROP, propertyBeforeTest);
+  }
+
+  @Test
+  public void luceneIsBoundWhenConfigured() throws Exception {
+    assertThat(System.getProperty(IndexType.SYS_PROP)).isEqualTo("lucene");
+    assertThat(changeIndex.getSearchIndex()).isInstanceOf(LuceneChangeIndex.class);
+  }
+}
diff --git a/javatests/com/google/gerrit/elasticsearch/ElasticContainer.java b/javatests/com/google/gerrit/elasticsearch/ElasticContainer.java
index 4d7468f..c330961 100644
--- a/javatests/com/google/gerrit/elasticsearch/ElasticContainer.java
+++ b/javatests/com/google/gerrit/elasticsearch/ElasticContainer.java
@@ -39,8 +39,6 @@
 
   private static String getImageName(ElasticVersion version) {
     switch (version) {
-      case V7_5:
-        return "blacktop/elasticsearch:7.5.2";
       case V7_6:
         return "blacktop/elasticsearch:7.6.2";
       case V7_7:
diff --git a/javatests/com/google/gerrit/elasticsearch/ElasticVersionTest.java b/javatests/com/google/gerrit/elasticsearch/ElasticVersionTest.java
index 75e9636..2ce3a2c 100644
--- a/javatests/com/google/gerrit/elasticsearch/ElasticVersionTest.java
+++ b/javatests/com/google/gerrit/elasticsearch/ElasticVersionTest.java
@@ -22,9 +22,6 @@
 public class ElasticVersionTest {
   @Test
   public void supportedVersion() throws Exception {
-    assertThat(ElasticVersion.forVersion("7.5.0")).isEqualTo(ElasticVersion.V7_5);
-    assertThat(ElasticVersion.forVersion("7.5.1")).isEqualTo(ElasticVersion.V7_5);
-
     assertThat(ElasticVersion.forVersion("7.6.0")).isEqualTo(ElasticVersion.V7_6);
     assertThat(ElasticVersion.forVersion("7.6.1")).isEqualTo(ElasticVersion.V7_6);
 
diff --git a/javatests/com/google/gerrit/server/account/externalids/ExternalIDCacheLoaderTest.java b/javatests/com/google/gerrit/server/account/externalids/ExternalIDCacheLoaderTest.java
index 2f64ed0..2b80601 100644
--- a/javatests/com/google/gerrit/server/account/externalids/ExternalIDCacheLoaderTest.java
+++ b/javatests/com/google/gerrit/server/account/externalids/ExternalIDCacheLoaderTest.java
@@ -17,7 +17,7 @@
 import static com.google.common.truth.Truth.assertThat;
 import static org.mockito.Mockito.times;
 import static org.mockito.Mockito.verify;
-import static org.mockito.Mockito.verifyZeroInteractions;
+import static org.mockito.Mockito.verifyNoInteractions;
 
 import com.google.common.cache.Cache;
 import com.google.common.cache.CacheBuilder;
@@ -80,7 +80,7 @@
     externalIdCache.put(firstState, allFromGit(firstState));
 
     assertThat(loader.load(head)).isEqualTo(allFromGit(head));
-    verifyZeroInteractions(externalIdReaderSpy);
+    verifyNoInteractions(externalIdReaderSpy);
   }
 
   @Test
@@ -92,7 +92,7 @@
     externalIdCache.put(firstState, allFromGit(firstState));
 
     assertThat(loader.load(head)).isEqualTo(allFromGit(head));
-    verifyZeroInteractions(externalIdReaderSpy);
+    verifyNoInteractions(externalIdReaderSpy);
   }
 
   @Test
@@ -142,7 +142,7 @@
     externalIdCache.put(firstState, allFromGit(firstState));
 
     assertThat(loader.load(head)).isEqualTo(allFromGit(head));
-    verifyZeroInteractions(externalIdReaderSpy);
+    verifyNoInteractions(externalIdReaderSpy);
   }
 
   @Test
@@ -156,7 +156,7 @@
     externalIdCache.put(firstState, allFromGit(firstState));
 
     assertThat(loader.load(head)).isEqualTo(allFromGit(head));
-    verifyZeroInteractions(externalIdReaderSpy);
+    verifyNoInteractions(externalIdReaderSpy);
   }
 
   @Test
@@ -173,7 +173,7 @@
     externalIdCache.put(firstState, allFromGit(firstState));
 
     assertThat(loader.load(head)).isEqualTo(allFromGit(head));
-    verifyZeroInteractions(externalIdReaderSpy);
+    verifyNoInteractions(externalIdReaderSpy);
   }
 
   @Test
@@ -186,7 +186,7 @@
     externalIdCache.put(oldState, allFromGit(oldState));
 
     assertThat(loader.load(head)).isEqualTo(allFromGit(head));
-    verifyZeroInteractions(externalIdReaderSpy);
+    verifyNoInteractions(externalIdReaderSpy);
   }
 
   @Test
@@ -200,7 +200,7 @@
     externalIdCache.put(oldState, allFromGit(oldState));
 
     assertThat(loader.load(head)).isEqualTo(allFromGit(head));
-    verifyZeroInteractions(externalIdReaderSpy);
+    verifyNoInteractions(externalIdReaderSpy);
   }
 
   private ExternalIdCacheLoader createLoader(boolean allowPartial) {
diff --git a/javatests/com/google/gerrit/server/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/cache/serialize/entities/FileDiffCacheKeySerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/entities/FileDiffCacheKeySerializerTest.java
index ecab07d..bb924ca 100644
--- a/javatests/com/google/gerrit/server/cache/serialize/entities/FileDiffCacheKeySerializerTest.java
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/FileDiffCacheKeySerializerTest.java
@@ -39,8 +39,9 @@
             .newCommit(COMMIT_ID_2)
             .newFilePath("some_file.txt")
             .renameScore(65)
-            .diffAlgorithm(DiffAlgorithm.HISTOGRAM)
+            .diffAlgorithm(DiffAlgorithm.HISTOGRAM_WITH_FALLBACK_MYERS)
             .whitespace(Whitespace.IGNORE_ALL)
+            .useTimeout(true)
             .build();
 
     byte[] serialized = FileDiffCacheKey.Serializer.INSTANCE.serialize(key);
diff --git a/javatests/com/google/gerrit/server/cache/serialize/entities/FileDiffOutputSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/entities/FileDiffOutputSerializerTest.java
index 17fd959..c5e8574 100644
--- a/javatests/com/google/gerrit/server/cache/serialize/entities/FileDiffOutputSerializerTest.java
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/FileDiffOutputSerializerTest.java
@@ -48,6 +48,7 @@
             .sizeDelta(10)
             .headerLines(ImmutableList.of("header line 1", "header line 2"))
             .edits(edits)
+            .negative(Optional.of(true))
             .build();
 
     byte[] serialized = FileDiffOutput.Serializer.INSTANCE.serialize(fileDiff);
diff --git a/javatests/com/google/gerrit/server/cache/serialize/entities/GitFileDiffKeySerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/entities/GitFileDiffKeySerializerTest.java
index 12d8d00..d7fdfe6 100644
--- a/javatests/com/google/gerrit/server/cache/serialize/entities/GitFileDiffKeySerializerTest.java
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/GitFileDiffKeySerializerTest.java
@@ -38,8 +38,9 @@
             .newTree(TREE_ID_2)
             .newFilePath("some_file.txt")
             .renameScore(65)
-            .diffAlgorithm(DiffAlgorithm.HISTOGRAM)
+            .diffAlgorithm(DiffAlgorithm.HISTOGRAM_WITH_FALLBACK_MYERS)
             .whitespace(Whitespace.IGNORE_ALL)
+            .useTimeout(true)
             .build();
 
     byte[] serialized = GitFileDiffCacheKey.Serializer.INSTANCE.serialize(key);
diff --git a/javatests/com/google/gerrit/server/cache/serialize/entities/GitFileDiffSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/entities/GitFileDiffSerializerTest.java
index 93441a4..e73c774 100644
--- a/javatests/com/google/gerrit/server/cache/serialize/entities/GitFileDiffSerializerTest.java
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/GitFileDiffSerializerTest.java
@@ -51,6 +51,7 @@
             .patchType(Optional.of(PatchType.UNIFIED))
             .oldMode(Optional.of(FileMode.REGULAR_FILE))
             .newMode(Optional.of(FileMode.REGULAR_FILE))
+            .negative(Optional.of(true))
             .build();
 
     byte[] serialized = Serializer.INSTANCE.serialize(gitFileDiff);
diff --git a/javatests/com/google/gerrit/server/cache/serialize/entities/LabelTypeSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/entities/LabelTypeSerializerTest.java
index ad460cd..614dcf0 100644
--- a/javatests/com/google/gerrit/server/cache/serialize/entities/LabelTypeSerializerTest.java
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/LabelTypeSerializerTest.java
@@ -35,6 +35,7 @@
           .setIgnoreSelfApproval(!LabelType.DEF_IGNORE_SELF_APPROVAL)
           .setRefPatterns(ImmutableList.of("refs/heads/*", "refs/tags/*"))
           .setDefaultValue((short) 1)
+          .setCopyCondition("is:ANY")
           .setCopyAnyScore(!LabelType.DEF_COPY_ANY_SCORE)
           .setCopyMaxScore(!LabelType.DEF_COPY_MAX_SCORE)
           .setCopyMinScore(!LabelType.DEF_COPY_MIN_SCORE)
@@ -57,7 +58,8 @@
 
   @Test
   public void roundTripWithMinimalValues() {
-    LabelType autoValue = ALL_VALUES_SET.toBuilder().setRefPatterns(null).build();
+    LabelType autoValue =
+        ALL_VALUES_SET.toBuilder().setRefPatterns(null).setCopyCondition(null).build();
     assertThat(deserialize(serialize(autoValue))).isEqualTo(autoValue);
   }
 }
diff --git a/javatests/com/google/gerrit/server/cache/serialize/entities/SubmitRequirementSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/entities/SubmitRequirementSerializerTest.java
index c2e8e0c..a1dee1a 100644
--- a/javatests/com/google/gerrit/server/cache/serialize/entities/SubmitRequirementSerializerTest.java
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/SubmitRequirementSerializerTest.java
@@ -29,7 +29,7 @@
           .setName("code-review")
           .setDescription(Optional.of("require code review +2"))
           .setApplicabilityExpression(SubmitRequirementExpression.of("branch(refs/heads/master)"))
-          .setBlockingExpression(SubmitRequirementExpression.create("label(code-review, 2+)"))
+          .setSubmittabilityExpression(SubmitRequirementExpression.create("label(code-review, 2+)"))
           .setOverrideExpression(Optional.empty())
           .setAllowOverrideInChildProjects(true)
           .build();
diff --git a/javatests/com/google/gerrit/server/mail/send/FromAddressGeneratorProviderTest.java b/javatests/com/google/gerrit/server/mail/send/FromAddressGeneratorProviderTest.java
index f10a281..5980071 100644
--- a/javatests/com/google/gerrit/server/mail/send/FromAddressGeneratorProviderTest.java
+++ b/javatests/com/google/gerrit/server/mail/send/FromAddressGeneratorProviderTest.java
@@ -18,7 +18,7 @@
 import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.verify;
-import static org.mockito.Mockito.verifyZeroInteractions;
+import static org.mockito.Mockito.verifyNoInteractions;
 import static org.mockito.Mockito.when;
 
 import com.google.gerrit.entities.Account;
@@ -125,7 +125,7 @@
     assertThat(r).isNotNull();
     assertThat(r.name()).isEqualTo(ident.getName());
     assertThat(r.email()).isEqualTo(ident.getEmailAddress());
-    verifyZeroInteractions(accountCache);
+    verifyNoInteractions(accountCache);
   }
 
   @Test
@@ -229,7 +229,7 @@
     assertThat(r).isNotNull();
     assertThat(r.name()).isEqualTo(ident.getName());
     assertThat(r.email()).isEqualTo(ident.getEmailAddress());
-    verifyZeroInteractions(accountCache);
+    verifyNoInteractions(accountCache);
   }
 
   @Test
@@ -239,7 +239,7 @@
     assertThat(r).isNotNull();
     assertThat(r.name()).isEqualTo(ident.getName());
     assertThat(r.email()).isEqualTo(ident.getEmailAddress());
-    verifyZeroInteractions(accountCache);
+    verifyNoInteractions(accountCache);
   }
 
   @Test
@@ -304,7 +304,7 @@
     assertThat(r).isNotNull();
     assertThat(r.name()).isEqualTo(ident.getName());
     assertThat(r.email()).isEqualTo(ident.getEmailAddress());
-    verifyZeroInteractions(accountCache);
+    verifyNoInteractions(accountCache);
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java b/javatests/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java
index a5cb456..c8ad84c 100644
--- a/javatests/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java
+++ b/javatests/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java
@@ -52,7 +52,9 @@
 import com.google.gerrit.server.git.GitModule;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.group.SystemGroupBackend;
+import com.google.gerrit.server.project.NullProjectCache;
 import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.query.change.InternalChangeQuery;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.gerrit.testing.AssertableExecutorService;
 import com.google.gerrit.testing.ConfigSuite;
@@ -63,7 +65,6 @@
 import com.google.inject.Guice;
 import com.google.inject.Inject;
 import com.google.inject.Injector;
-import com.google.inject.util.Providers;
 import java.sql.Timestamp;
 import java.util.TimeZone;
 import java.util.concurrent.ExecutorService;
@@ -106,7 +107,7 @@
 
   @Inject protected AbstractChangeNotes.Args args;
 
-  @Inject @GerritServerId private String serverId;
+  @Inject @GerritServerId protected String serverId;
 
   protected Injector injector;
   private String systemTimeZone;
@@ -144,7 +145,7 @@
                 bind(AllUsersName.class).toProvider(AllUsersNameProvider.class);
                 bind(String.class).annotatedWith(GerritServerId.class).toInstance("gerrit");
                 bind(GitRepositoryManager.class).toInstance(repoManager);
-                bind(ProjectCache.class).toProvider(Providers.of(null));
+                bind(ProjectCache.class).to(NullProjectCache.class);
                 bind(Config.class).annotatedWith(GerritServerConfig.class).toInstance(testConfig);
                 bind(String.class)
                     .annotatedWith(AnonymousCowardName.class)
@@ -167,6 +168,11 @@
                     .annotatedWith(FanOutExecutor.class)
                     .toInstance(assertableFanOutExecutor);
                 bind(ServiceUserClassifier.class).to(ServiceUserClassifier.NoOp.class);
+                bind(InternalChangeQuery.class)
+                    .toProvider(
+                        () -> {
+                          throw new UnsupportedOperationException();
+                        });
               }
             });
 
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeNotesCommitTest.java b/javatests/com/google/gerrit/server/notedb/ChangeNotesCommitTest.java
new file mode 100644
index 0000000..f105cf1
--- /dev/null
+++ b/javatests/com/google/gerrit/server/notedb/ChangeNotesCommitTest.java
@@ -0,0 +1,124 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.notedb;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.server.util.time.TimeUtil;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.CommitBuilder;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+public class ChangeNotesCommitTest extends AbstractChangeNotesTest {
+  private TestRepository<InMemoryRepository> testRepo;
+  private ChangeNotesCommit.ChangeNotesRevWalk walk;
+
+  @Before
+  public void setUpTestRepo() throws Exception {
+    testRepo = new TestRepository<>(repo);
+    walk = ChangeNotesCommit.newRevWalk(repo);
+  }
+
+  @After
+  public void tearDownTestRepo() throws Exception {
+    walk.close();
+  }
+
+  @Test
+  public void attentionSetCommitOnlyWhenNoChangeMessageIsPresentAndCorrectFooter()
+      throws Exception {
+    RevCommit commit =
+        writeCommit(
+            "Update patch set 1\n"
+                + "\n"
+                + "Patch-set: 1\n"
+                + "Attention: {\"person_ident\":\"Gerrit User 1000000 \\u003c1000000@adce0b11-8f2e-4ab6-ac69-e675f183d871\\u003e\",\"operation\":\"ADD\",\"reason\":\"Added by Administrator using the hovercard menu\"}");
+
+    newParser(commit).parseAll();
+    assertThat(((ChangeNotesCommit) commit).isAttentionSetCommitOnly(false)).isEqualTo(true);
+  }
+
+  @Test
+  public void noAttentionSetCommitOnlyWhenNoChangeMessageIsPresentAndFooterNotOnlyAS()
+      throws Exception {
+    RevCommit commit =
+        writeCommit(
+            "Update patch set 1\n"
+                + "\n"
+                + "Patch-set: 1\n"
+                + "Subject: Change subject\n"
+                + "Attention: {\"person_ident\":\"Gerrit User 1000000 \\u003c1000000@adce0b11-8f2e-4ab6-ac69-e675f183d871\\u003e\",\"operation\":\"ADD\",\"reason\":\"Added by Administrator using the hovercard menu\"}");
+
+    newParser(commit).parseAll();
+    assertThat(((ChangeNotesCommit) commit).isAttentionSetCommitOnly(false)).isEqualTo(false);
+  }
+
+  @Test
+  public void noAttentionSetCommitOnlyWhenNoChangeMessageIsPresentAndGenericFooter()
+      throws Exception {
+    RevCommit commit = writeCommit("Update patch set 1\n" + "\n" + "Patch-set: 1\n");
+
+    newParser(commit).parseAll();
+    assertThat(((ChangeNotesCommit) commit).isAttentionSetCommitOnly(false)).isEqualTo(false);
+  }
+
+  @Test
+  public void noAttentionSetCommitOnlyWhenChangeMessageIsPresent() throws Exception {
+    RevCommit commit =
+        writeCommit(
+            "Update patch set 1\n"
+                + "\n"
+                + "Patch-set: 1\n"
+                + "Attention: {\"person_ident\":\"Gerrit User 1000000 \\u003c1000000@adce0b11-8f2e-4ab6-ac69-e675f183d871\\u003e\",\"operation\":\"ADD\",\"reason\":\"Added by Administrator using the hovercard menu\"}");
+
+    newParser(commit).parseAll();
+    assertThat(((ChangeNotesCommit) commit).isAttentionSetCommitOnly(true)).isEqualTo(false);
+  }
+
+  private ChangeNotesParser newParser(ObjectId tip) throws Exception {
+    walk.reset();
+    ChangeNoteJson changeNoteJson = injector.getInstance(ChangeNoteJson.class);
+    return new ChangeNotesParser(newChange().getId(), tip, walk, changeNoteJson, args.metrics);
+  }
+
+  private RevCommit writeCommit(String body) throws Exception {
+    Change change = newChange(true);
+    ChangeNotes notes = newNotes(change).load();
+    ChangeNoteUtil noteUtil = injector.getInstance(ChangeNoteUtil.class);
+    PersonIdent author =
+        noteUtil.newAccountIdIdent(changeOwner.getAccount().id(), TimeUtil.nowTs(), serverIdent);
+    try (ObjectInserter ins = testRepo.getRepository().newObjectInserter()) {
+      CommitBuilder cb = new CommitBuilder();
+      cb.setParentId(notes.getRevision());
+      cb.setAuthor(author);
+      cb.setCommitter(new PersonIdent(serverIdent, author.getWhen()));
+      cb.setTreeId(testRepo.tree());
+      cb.setMessage(body);
+      ObjectId id = ins.insert(cb);
+      ins.flush();
+      RevCommit commit = walk.parseCommit(id);
+      walk.parseBody(commit);
+      return commit;
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeNotesParserTest.java b/javatests/com/google/gerrit/server/notedb/ChangeNotesParserTest.java
index 5bfe97c..6a32fa1 100644
--- a/javatests/com/google/gerrit/server/notedb/ChangeNotesParserTest.java
+++ b/javatests/com/google/gerrit/server/notedb/ChangeNotesParserTest.java
@@ -497,6 +497,73 @@
   }
 
   @Test
+  public void attentionSetOnlyShouldNotCountTowardsMaxUpdatesLimit() throws Exception {
+    RevCommit commit =
+        writeCommit(
+            "Update patch set 1\n"
+                + "\n"
+                + "Patch-set: 1\n"
+                + "Attention: {\"person_ident\":\"Gerrit User 1000000 \\u003c1000000@adce0b11-8f2e-4ab6-ac69-e675f183d871\\u003e\",\"operation\":\"ADD\",\"reason\":\"Added by Administrator using the hovercard menu\"}",
+            false);
+    ChangeNotesParser changeNotesParser = newParser(commit);
+    changeNotesParser.parseAll();
+    final boolean hasChangeMessage = false;
+    assertThat(
+            changeNotesParser.countTowardsMaxUpdatesLimit(
+                (ChangeNotesCommit) commit, hasChangeMessage))
+        .isEqualTo(false);
+  }
+
+  @Test
+  public void attentionSetWithExtraFooterShouldCountTowardsMaxUpdatesLimit() throws Exception {
+    RevCommit commit =
+        writeCommit(
+            "Update patch set 1\n"
+                + "\n"
+                + "Patch-set: 1\n"
+                + "Subject: Change subject\n"
+                + "Attention: {\"person_ident\":\"Gerrit User 1000000 \\u003c1000000@adce0b11-8f2e-4ab6-ac69-e675f183d871\\u003e\",\"operation\":\"ADD\",\"reason\":\"Added by Administrator using the hovercard menu\"}",
+            false);
+    ChangeNotesParser changeNotesParser = newParser(commit);
+    changeNotesParser.parseAll();
+    final boolean hasChangeMessage = false;
+    assertThat(
+            changeNotesParser.countTowardsMaxUpdatesLimit(
+                (ChangeNotesCommit) commit, hasChangeMessage))
+        .isEqualTo(true);
+  }
+
+  @Test
+  public void changeWithoutAttentionSetShouldCountTowardsMaxUpdatesLimit() throws Exception {
+    RevCommit commit = writeCommit("Update WIP change\n" + "\n" + "Patch-set: 1\n", true);
+    ChangeNotesParser changeNotesParser = newParser(commit);
+    changeNotesParser.parseAll();
+    final boolean hasChangeMessage = false;
+    assertThat(
+            changeNotesParser.countTowardsMaxUpdatesLimit(
+                (ChangeNotesCommit) commit, hasChangeMessage))
+        .isEqualTo(true);
+  }
+
+  @Test
+  public void attentionSetWithCommentShouldCountTowardsMaxUpdatesLimit() throws Exception {
+    RevCommit commit =
+        writeCommit(
+            "Update patch set 1\n"
+                + "\n"
+                + "Patch-set: 1\n"
+                + "Attention: {\"person_ident\":\"Gerrit User 1000000 \\u003c1000000@adce0b11-8f2e-4ab6-ac69-e675f183d871\\u003e\",\"operation\":\"ADD\",\"reason\":\"Added by Administrator using the hovercard menu\"}",
+            false);
+    ChangeNotesParser changeNotesParser = newParser(commit);
+    changeNotesParser.parseAll();
+    final boolean hasChangeMessage = true;
+    assertThat(
+            changeNotesParser.countTowardsMaxUpdatesLimit(
+                (ChangeNotesCommit) commit, hasChangeMessage))
+        .isEqualTo(true);
+  }
+
+  @Test
   public void caseInsensitiveFooters() throws Exception {
     assertParseSucceeds(
         "Update change\n"
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java b/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java
index feff89c..ecdb03d 100644
--- a/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java
+++ b/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java
@@ -36,6 +36,10 @@
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.entities.SubmitRecord;
+import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.entities.SubmitRequirementExpression;
+import com.google.gerrit.entities.SubmitRequirementExpressionResult;
+import com.google.gerrit.entities.SubmitRequirementResult;
 import com.google.gerrit.entities.converter.ChangeMessageProtoConverter;
 import com.google.gerrit.entities.converter.PatchSetApprovalProtoConverter;
 import com.google.gerrit.entities.converter.PatchSetProtoConverter;
@@ -52,6 +56,9 @@
 import com.google.gerrit.server.cache.proto.Cache.ChangeNotesStateProto.ReviewerByEmailSetEntryProto;
 import com.google.gerrit.server.cache.proto.Cache.ChangeNotesStateProto.ReviewerSetEntryProto;
 import com.google.gerrit.server.cache.proto.Cache.ChangeNotesStateProto.ReviewerStatusUpdateProto;
+import com.google.gerrit.server.cache.proto.Cache.SubmitRequirementExpressionResultProto;
+import com.google.gerrit.server.cache.proto.Cache.SubmitRequirementProto;
+import com.google.gerrit.server.cache.proto.Cache.SubmitRequirementResultProto;
 import com.google.gerrit.server.cache.serialize.ObjectIdConverter;
 import com.google.gerrit.server.notedb.ChangeNotesState.ChangeColumns;
 import com.google.gerrit.server.notedb.ChangeNotesState.Serializer;
@@ -671,6 +678,69 @@
   }
 
   @Test
+  public void serializeSubmitRequirementsResult() throws Exception {
+    assertRoundTrip(
+        newBuilder()
+            .submitRequirementsResult(
+                ImmutableList.of(
+                    SubmitRequirementResult.builder()
+                        .patchSetCommitId(
+                            ObjectId.fromString("26e50c7d315a33a13e5cc00902781fa876bc36cd"))
+                        .submitRequirement(
+                            SubmitRequirement.builder()
+                                .setName("Code-Review")
+                                .setApplicabilityExpression(
+                                    SubmitRequirementExpression.of("project:foo"))
+                                .setSubmittabilityExpression(
+                                    SubmitRequirementExpression.create("label:code-review=+2"))
+                                .setAllowOverrideInChildProjects(false)
+                                .build())
+                        .applicabilityExpressionResult(
+                            Optional.of(
+                                SubmitRequirementExpressionResult.create(
+                                    SubmitRequirementExpression.create("project:foo"),
+                                    SubmitRequirementExpressionResult.Status.PASS,
+                                    ImmutableList.of("project:foo"),
+                                    ImmutableList.of())))
+                        .submittabilityExpressionResult(
+                            SubmitRequirementExpressionResult.create(
+                                SubmitRequirementExpression.create("label:code-review=+2"),
+                                SubmitRequirementExpressionResult.Status.FAIL,
+                                ImmutableList.of(),
+                                ImmutableList.of("label:code-review=+2")))
+                        .build()))
+            .build(),
+        newProtoBuilder()
+            .addSubmitRequirementResult(
+                SubmitRequirementResultProto.newBuilder()
+                    .setCommit(
+                        ObjectIdConverter.create()
+                            .toByteString(
+                                ObjectId.fromString("26e50c7d315a33a13e5cc00902781fa876bc36cd")))
+                    .setSubmitRequirement(
+                        SubmitRequirementProto.newBuilder()
+                            .setName("Code-Review")
+                            .setApplicabilityExpression("project:foo")
+                            .setSubmittabilityExpression("label:code-review=+2")
+                            .setAllowOverrideInChildProjects(false)
+                            .build())
+                    .setApplicabilityExpressionResult(
+                        SubmitRequirementExpressionResultProto.newBuilder()
+                            .setExpression("project:foo")
+                            .setStatus("PASS")
+                            .addPassingAtoms("project:foo")
+                            .build())
+                    .setSubmittabilityExpressionResult(
+                        SubmitRequirementExpressionResultProto.newBuilder()
+                            .setExpression("label:code-review=+2")
+                            .setStatus("FAIL")
+                            .addFailingAtoms("label:code-review=+2")
+                            .build())
+                    .build())
+            .build());
+  }
+
+  @Test
   public void serializeAssigneeUpdates() throws Exception {
     assertRoundTrip(
         newBuilder()
@@ -842,6 +912,9 @@
                 .put(
                     "publishedComments",
                     new TypeLiteral<ImmutableListMultimap<ObjectId, HumanComment>>() {}.getType())
+                .put(
+                    "submitRequirementsResult",
+                    new TypeLiteral<ImmutableList<SubmitRequirementResult>>() {}.getType())
                 .put("updateCount", int.class)
                 .put("mergedOn", Timestamp.class)
                 .build());
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java b/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java
index fa37704..858a9bb 100644
--- a/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java
+++ b/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java
@@ -56,7 +56,6 @@
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.ReviewerSet;
-import com.google.gerrit.server.config.GerritServerId;
 import com.google.gerrit.server.notedb.ChangeNotesCommit.ChangeNotesRevWalk;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.gerrit.server.validators.ValidationException;
@@ -81,8 +80,6 @@
 
   @Inject private ChangeNoteJson changeNoteJson;
 
-  @Inject private @GerritServerId String serverId;
-
   @Test
   public void tagChangeMessage() throws Exception {
     String tag = "jenkins";
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeUpdateTest.java b/javatests/com/google/gerrit/server/notedb/ChangeUpdateTest.java
new file mode 100644
index 0000000..fa05adc
--- /dev/null
+++ b/javatests/com/google/gerrit/server/notedb/ChangeUpdateTest.java
@@ -0,0 +1,192 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.notedb;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.entities.Address;
+import com.google.gerrit.entities.AttentionSetUpdate;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.CommentRange;
+import com.google.gerrit.entities.HumanComment;
+import com.google.gerrit.server.util.time.TimeUtil;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.junit.Test;
+
+public class ChangeUpdateTest extends AbstractChangeNotesTest {
+
+  @Test
+  public void bypassMaxUpdatesShouldBeTrueWhenChangingAttentionSetOnly() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+
+    addToAttentionSet(update);
+    update.commit();
+
+    assertThat(update.bypassMaxUpdates()).isTrue();
+  }
+
+  @Test
+  public void bypassMaxUpdatesShouldBeTrueWhenClosingChange() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+
+    update.setStatus(Change.Status.ABANDONED);
+
+    update.commit();
+
+    assertThat(update.bypassMaxUpdates()).isTrue();
+  }
+
+  @Test
+  public void bypassMaxUpdatesShouldBeFalseWhenNotAbandoningChangeAndNotChangingAttentionSetOnly()
+      throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+
+    update.commit();
+
+    assertThat(update.bypassMaxUpdates()).isFalse();
+  }
+
+  @Test
+  public void bypassMaxUpdatesShouldBeFalseWhenCommentsAndChangesToAttentionSetCoexist()
+      throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+
+    addToAttentionSet(update);
+    // Add a comment
+    RevCommit commit = tr.commit().message("PS2").create();
+    update.putComment(
+        HumanComment.Status.PUBLISHED,
+        newComment(
+            c.currentPatchSetId(),
+            "a.txt",
+            "uuid1",
+            new CommentRange(1, 2, 3, 4),
+            1,
+            changeOwner,
+            null,
+            TimeUtil.nowTs(),
+            "Comment",
+            (short) 1,
+            commit,
+            false));
+    update.commit();
+
+    assertThat(update.bypassMaxUpdates()).isFalse();
+  }
+
+  @Test
+  public void bypassMaxUpdatesShouldBeFalseWhenReviewersAndChangesToAttentionSetCoexist()
+      throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+
+    addToAttentionSet(update);
+    update.putReviewer(otherUserId, ReviewerStateInternal.REVIEWER);
+    update.commit();
+
+    assertThat(update.bypassMaxUpdates()).isFalse();
+  }
+
+  @Test
+  public void bypassMaxUpdatesShouldBeFalseWhenReviewersByEmailAndChangesToAttentionSetCoexist()
+      throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+
+    addToAttentionSet(update);
+    update.putReviewerByEmail(Address.create("anyEmail@mail.com"), ReviewerStateInternal.REVIEWER);
+    update.commit();
+
+    assertThat(update.bypassMaxUpdates()).isFalse();
+  }
+
+  @Test
+  public void bypassMaxUpdatesShouldBeFalseWhenWIPAndChangesToAttentionSetCoexist()
+      throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+
+    addToAttentionSet(update);
+    update.setWorkInProgress(true);
+    update.commit();
+
+    assertThat(update.bypassMaxUpdates()).isFalse();
+  }
+
+  @Test
+  public void bypassMaxUpdatesShouldBeFalseWhenNonWIPAndChangesToAttentionSetCoexist()
+      throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+
+    addToAttentionSet(update);
+    update.setWorkInProgress(false);
+    update.commit();
+
+    assertThat(update.bypassMaxUpdates()).isFalse();
+  }
+
+  @Test
+  public void bypassMaxUpdatesShouldBeTrueWhenAbandoningAndChangesToAttentionSetCoexist()
+      throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+
+    update.setStatus(Change.Status.ABANDONED);
+    addToAttentionSet(update);
+    update.commit();
+
+    assertThat(update.bypassMaxUpdates()).isTrue();
+  }
+
+  @Test
+  public void bypassMaxUpdatesShouldBeFalseWhenVotingAndChangesToAttentionSetCoexist()
+      throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+
+    update.putApproval("Code-Review", (short) 1);
+    addToAttentionSet(update);
+    update.commit();
+
+    assertThat(update.bypassMaxUpdates()).isFalse();
+  }
+
+  @Test
+  public void bypassMaxUpdatesShouldBeFalseWhenDeletingVotesAndChangesToAttentionSetCoexist()
+      throws Exception {
+    Change c = newChange();
+    ChangeUpdate updateWithVote = newUpdate(c, changeOwner);
+    updateWithVote.putApproval("Code-Review", (short) 1);
+    updateWithVote.commit();
+
+    updateWithVote.removeApproval("Code-Review");
+    addToAttentionSet(updateWithVote);
+
+    assertThat(updateWithVote.bypassMaxUpdates()).isFalse();
+  }
+
+  private void addToAttentionSet(ChangeUpdate update) {
+    AttentionSetUpdate attentionSetUpdate =
+        AttentionSetUpdate.createForWrite(
+            otherUser.getAccountId(), AttentionSetUpdate.Operation.ADD, "test");
+    update.addToPlannedAttentionSetUpdates(ImmutableSet.of(attentionSetUpdate));
+  }
+}
diff --git a/javatests/com/google/gerrit/server/notedb/CommitRewriterTest.java b/javatests/com/google/gerrit/server/notedb/CommitRewriterTest.java
new file mode 100644
index 0000000..3aa5e9c
--- /dev/null
+++ b/javatests/com/google/gerrit/server/notedb/CommitRewriterTest.java
@@ -0,0 +1,1126 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.notedb;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.entities.LabelId.CODE_REVIEW;
+import static com.google.gerrit.entities.LabelId.VERIFIED;
+import static com.google.gerrit.server.notedb.ReviewerStateInternal.CC;
+import static com.google.gerrit.server.notedb.ReviewerStateInternal.REMOVED;
+import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
+import static java.util.Objects.requireNonNull;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.ChangeMessage;
+import com.google.gerrit.entities.LabelId;
+import com.google.gerrit.entities.PatchSetApproval;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.server.ChangeMessagesUtil;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.ReviewerStatusUpdate;
+import com.google.gerrit.server.notedb.CommitRewriter.BackfillResult;
+import com.google.gerrit.server.notedb.CommitRewriter.RunOptions;
+import com.google.gerrit.server.util.time.TimeUtil;
+import com.google.inject.Inject;
+import java.sql.Timestamp;
+import java.util.List;
+import java.util.stream.IntStream;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevSort;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.junit.Before;
+import org.junit.Test;
+
+/** Tests for {@link CommitRewriter} */
+public class CommitRewriterTest extends AbstractChangeNotesTest {
+
+  private @Inject CommitRewriter rewriter;
+  @Inject private ChangeNoteUtil changeNoteUtil;
+
+  @Before
+  public void setUp() throws Exception {}
+
+  @Test
+  public void validHistoryNoOp() throws Exception {
+    String tag = "jenkins";
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.setChangeMessage("verification from jenkins");
+    update.setTag(tag);
+    update.commit();
+
+    ChangeUpdate updateWithSubject = newUpdate(c, changeOwner);
+    updateWithSubject.setSubjectForCommit("Update with subject");
+    updateWithSubject.commit();
+
+    ChangeNotes notesBeforeRewrite = newNotes(c);
+    Ref metaRefBefore = repo.exactRef(RefNames.changeMetaRef(c.getId()));
+    RunOptions options = new RunOptions();
+    options.dryRun = false;
+    BackfillResult backfillResult = rewriter.backfillProject(project, repo, options);
+    ChangeNotes notesAfterRewrite = newNotes(c);
+    Ref metaRefAfter = repo.exactRef(RefNames.changeMetaRef(c.getId()));
+
+    assertThat(notesBeforeRewrite.getMetaId()).isEqualTo(notesAfterRewrite.getMetaId());
+    assertThat(metaRefBefore.getObjectId()).isEqualTo(metaRefAfter.getObjectId());
+    assertThat(backfillResult.fixedRefDiff).isEmpty();
+  }
+
+  @Test
+  public void failedVerification() throws Exception {
+    String tag = "jenkins";
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.setChangeMessage("Unknown commit " + changeOwner.getName());
+    update.setTag(tag);
+    update.commit();
+
+    ChangeUpdate updateWithSubject = newUpdate(c, changeOwner);
+    updateWithSubject.setSubjectForCommit("Update with subject");
+    updateWithSubject.commit();
+
+    ChangeNotes notesBeforeRewrite = newNotes(c);
+    Ref metaRefBefore = repo.exactRef(RefNames.changeMetaRef(c.getId()));
+    RunOptions options = new RunOptions();
+    options.dryRun = false;
+    BackfillResult backfillResult = rewriter.backfillProject(project, repo, options);
+    assertThat(backfillResult.fixedRefDiff).isEmpty();
+    assertThat(backfillResult.refsStillInvalidAfterFix)
+        .containsExactly(RefNames.changeMetaRef(c.getId()));
+    ChangeNotes notesAfterRewrite = newNotes(c);
+    Ref metaRefAfter = repo.exactRef(RefNames.changeMetaRef(c.getId()));
+
+    assertThat(notesBeforeRewrite.getMetaId()).isEqualTo(notesAfterRewrite.getMetaId());
+    assertThat(metaRefBefore.getObjectId()).isEqualTo(metaRefAfter.getObjectId());
+  }
+
+  @Test
+  public void fixAuthorIdent() throws Exception {
+    Change c = newChange();
+    Timestamp when = TimeUtil.nowTs();
+    PersonIdent invalidAuthorIdent =
+        new PersonIdent(
+            changeOwner.getName(),
+            changeNoteUtil.getAccountIdAsEmailAddress(changeOwner.getAccountId()),
+            when,
+            serverIdent.getTimeZone());
+    RevCommit invalidUpdateCommit =
+        writeUpdate(
+            RefNames.changeMetaRef(c.getId()),
+            getChangeUpdateBody(c, /*changeMessage=*/ null),
+            invalidAuthorIdent);
+    ChangeUpdate validUpdate = newUpdate(c, changeOwner);
+    validUpdate.setChangeMessage("verification from jenkins");
+    validUpdate.setTag("jenkins");
+    validUpdate.commit();
+
+    Ref metaRefBeforeRewrite = repo.exactRef(RefNames.changeMetaRef(c.getId()));
+
+    ImmutableList<RevCommit> commitsBeforeRewrite = logMetaRef(repo, metaRefBeforeRewrite);
+    ChangeNotes notesBeforeRewrite = newNotes(c);
+
+    RunOptions options = new RunOptions();
+    options.dryRun = false;
+    BackfillResult result = rewriter.backfillProject(project, repo, options);
+    assertThat(result.fixedRefDiff.keySet()).containsExactly(RefNames.changeMetaRef(c.getId()));
+
+    ChangeNotes notesAfterRewrite = newNotes(c);
+
+    assertThat(notesAfterRewrite.getChange().getOwner())
+        .isEqualTo(notesBeforeRewrite.getChange().getOwner());
+    Ref metaRefAfterRewrite = repo.exactRef(RefNames.changeMetaRef(c.getId()));
+    assertThat(metaRefAfterRewrite.getObjectId()).isNotEqualTo(metaRefBeforeRewrite.getObjectId());
+    ImmutableList<RevCommit> commitsAfterRewrite = logMetaRef(repo, metaRefAfterRewrite);
+    int invalidCommitIndex = commitsBeforeRewrite.indexOf(invalidUpdateCommit);
+
+    assertValidCommits(
+        commitsBeforeRewrite, commitsAfterRewrite, ImmutableList.of(invalidCommitIndex));
+    RevCommit fixedUpdateCommit = commitsAfterRewrite.get(invalidCommitIndex);
+    PersonIdent originalAuthorIdent = invalidUpdateCommit.getAuthorIdent();
+    PersonIdent fixedAuthorIdent = fixedUpdateCommit.getAuthorIdent();
+    assertThat(originalAuthorIdent).isNotEqualTo(fixedAuthorIdent);
+    assertThat(fixedUpdateCommit.getAuthorIdent().getName())
+        .isEqualTo("Gerrit User " + changeOwner.getAccountId());
+    assertThat(originalAuthorIdent.getEmailAddress()).isEqualTo(fixedAuthorIdent.getEmailAddress());
+    assertThat(originalAuthorIdent.getWhen()).isEqualTo(fixedAuthorIdent.getWhen());
+    assertThat(originalAuthorIdent.getTimeZone()).isEqualTo(fixedAuthorIdent.getTimeZone());
+    assertThat(invalidUpdateCommit.getFullMessage()).isEqualTo(fixedUpdateCommit.getFullMessage());
+    assertThat(invalidUpdateCommit.getCommitterIdent())
+        .isEqualTo(fixedUpdateCommit.getCommitterIdent());
+    assertThat(fixedUpdateCommit.getFullMessage()).doesNotContain(changeOwner.getName());
+    List<String> commitHistoryDiff = result.fixedRefDiff.get(RefNames.changeMetaRef(c.getId()));
+    assertThat(commitHistoryDiff).hasSize(1);
+    assertThat(commitHistoryDiff.get(0)).contains("-author Change Owner <1@gerrit>");
+    assertThat(commitHistoryDiff.get(0)).contains("+author Gerrit User 1 <1@gerrit>");
+  }
+
+  @Test
+  public void fixRealUserFooterIdent() throws Exception {
+    Change c = newChange();
+
+    String realUserIdentToFix = getAccountIdentToFix(otherUser.getAccount());
+    RevCommit invalidUpdateCommit =
+        writeUpdate(
+            RefNames.changeMetaRef(c.getId()),
+            getChangeUpdateBody(c, "Comment on behalf of user", "Real-user: " + realUserIdentToFix),
+            getAuthorIdent(changeOwner.getAccount()));
+
+    IdentifiedUser impersonatedChangeOwner =
+        this.userFactory.runAs(
+            null, changeOwner.getAccountId(), requireNonNull(otherUser).getRealUser());
+    ChangeUpdate impersonatedChangeMessageUpdate = newUpdate(c, impersonatedChangeOwner);
+    impersonatedChangeMessageUpdate.setChangeMessage("Other comment on behalf of");
+    impersonatedChangeMessageUpdate.commit();
+
+    Ref metaRefBeforeRewrite = repo.exactRef(RefNames.changeMetaRef(c.getId()));
+
+    ImmutableList<RevCommit> commitsBeforeRewrite = logMetaRef(repo, metaRefBeforeRewrite);
+
+    int invalidCommitIndex = commitsBeforeRewrite.indexOf(invalidUpdateCommit);
+    ChangeNotes notesBeforeRewrite = newNotes(c);
+
+    RunOptions options = new RunOptions();
+    options.dryRun = false;
+    BackfillResult result = rewriter.backfillProject(project, repo, options);
+    assertThat(result.fixedRefDiff.keySet()).containsExactly(RefNames.changeMetaRef(c.getId()));
+
+    ChangeNotes notesAfterRewrite = newNotes(c);
+    assertThat(changeMessages(notesBeforeRewrite))
+        .containsExactly("Comment on behalf of user", "Other comment on behalf of");
+    assertThat(notesBeforeRewrite.getChangeMessages().get(0).getAuthor())
+        .isEqualTo(changeOwner.getAccountId());
+    assertThat(notesBeforeRewrite.getChangeMessages().get(0).getRealAuthor())
+        .isEqualTo(otherUser.getAccountId());
+    assertThat(changeMessages(notesAfterRewrite))
+        .containsExactly("Comment on behalf of user", "Other comment on behalf of");
+    assertThat(notesBeforeRewrite.getChangeMessages().get(0).getAuthor())
+        .isEqualTo(changeOwner.getAccountId());
+    assertThat(notesBeforeRewrite.getChangeMessages().get(0).getRealAuthor())
+        .isEqualTo(otherUser.getAccountId());
+
+    Ref metaRefAfterRewrite = repo.exactRef(RefNames.changeMetaRef(c.getId()));
+    assertThat(metaRefAfterRewrite.getObjectId()).isNotEqualTo(metaRefBeforeRewrite.getObjectId());
+
+    ImmutableList<RevCommit> commitsAfterRewrite = logMetaRef(repo, metaRefAfterRewrite);
+    assertValidCommits(
+        commitsBeforeRewrite, commitsAfterRewrite, ImmutableList.of(invalidCommitIndex));
+
+    List<String> commitHistoryDiff = result.fixedRefDiff.get(RefNames.changeMetaRef(c.getId()));
+    assertThat(commitHistoryDiff).hasSize(1);
+    assertThat(commitHistoryDiff.get(0))
+        .isEqualTo(
+            "@@ -9 +9 @@\n"
+                + "-Real-user: Other Account <2@gerrit>\n"
+                + "+Real-user: Gerrit User 2 <2@gerrit>\n");
+  }
+
+  @Test
+  public void fixReviewerFooterIdent() throws Exception {
+    Change c = newChange();
+    String reviewerIdentToFix = getAccountIdentToFix(otherUser.getAccount());
+    ImmutableList<RevCommit> commitsToFix =
+        new ImmutableList.Builder<RevCommit>()
+            .add(
+                writeUpdate(
+                    RefNames.changeMetaRef(c.getId()),
+                    getChangeUpdateBody(
+                        c, /*changeMessage=*/ null, "Reviewer: " + reviewerIdentToFix),
+                    getAuthorIdent(changeOwner.getAccount())))
+            .add(
+                writeUpdate(
+                    RefNames.changeMetaRef(c.getId()),
+                    getChangeUpdateBody(c, /*changeMessage=*/ null, "CC: " + reviewerIdentToFix),
+                    getAuthorIdent(otherUser.getAccount())))
+            .add(
+                writeUpdate(
+                    RefNames.changeMetaRef(c.getId()),
+                    getChangeUpdateBody(c, "Removed cc", "Removed: " + reviewerIdentToFix),
+                    getAuthorIdent(changeOwner.getAccount())))
+            .build();
+
+    Ref metaRefBeforeRewrite = repo.exactRef(RefNames.changeMetaRef(c.getId()));
+
+    ImmutableList<RevCommit> commitsBeforeRewrite = logMetaRef(repo, metaRefBeforeRewrite);
+
+    ImmutableList<Integer> invalidCommits =
+        commitsToFix.stream()
+            .map(commit -> commitsBeforeRewrite.indexOf(commit))
+            .collect(toImmutableList());
+    ChangeNotes notesBeforeRewrite = newNotes(c);
+
+    RunOptions options = new RunOptions();
+    options.dryRun = false;
+    BackfillResult result = rewriter.backfillProject(project, repo, options);
+    assertThat(result.fixedRefDiff.keySet()).containsExactly(RefNames.changeMetaRef(c.getId()));
+
+    Timestamp updateTimestamp = new Timestamp(serverIdent.getWhen().getTime());
+    ImmutableList<ReviewerStatusUpdate> expectedReviewerUpdates =
+        ImmutableList.of(
+            ReviewerStatusUpdate.create(
+                updateTimestamp, changeOwner.getAccountId(), otherUserId, REVIEWER),
+            ReviewerStatusUpdate.create(updateTimestamp, otherUserId, otherUserId, CC),
+            ReviewerStatusUpdate.create(
+                updateTimestamp, changeOwner.getAccountId(), otherUserId, REMOVED));
+    ChangeNotes notesAfterRewrite = newNotes(c);
+
+    assertThat(notesBeforeRewrite.getReviewerUpdates()).isEqualTo(expectedReviewerUpdates);
+    assertThat(notesAfterRewrite.getReviewerUpdates()).isEqualTo(expectedReviewerUpdates);
+
+    Ref metaRefAfterRewrite = repo.exactRef(RefNames.changeMetaRef(c.getId()));
+    assertThat(metaRefAfterRewrite.getObjectId()).isNotEqualTo(metaRefBeforeRewrite.getObjectId());
+
+    ImmutableList<RevCommit> commitsAfterRewrite = logMetaRef(repo, metaRefAfterRewrite);
+    assertValidCommits(commitsBeforeRewrite, commitsAfterRewrite, invalidCommits);
+
+    List<String> commitHistoryDiff = result.fixedRefDiff.get(RefNames.changeMetaRef(c.getId()));
+    assertThat(commitHistoryDiff).hasSize(3);
+    assertThat(commitHistoryDiff.get(0))
+        .isEqualTo(
+            "@@ -7 +7 @@\n"
+                + "-Reviewer: Other Account <2@gerrit>\n"
+                + "+Reviewer: Gerrit User 2 <2@gerrit>\n");
+    assertThat(commitHistoryDiff.get(1))
+        .isEqualTo(
+            "@@ -7 +7 @@\n"
+                + "-CC: Other Account <2@gerrit>\n"
+                + "+CC: Gerrit User 2 <2@gerrit>\n");
+    assertThat(commitHistoryDiff.get(2))
+        .isEqualTo(
+            "@@ -9 +9 @@\n"
+                + "-Removed: Other Account <2@gerrit>\n"
+                + "+Removed: Gerrit User 2 <2@gerrit>\n");
+  }
+
+  @Test
+  public void fixReviewerMessage() throws Exception {
+    Change c = newChange();
+    ImmutableList.Builder<RevCommit> commitsToFix = new ImmutableList.Builder<>();
+    ChangeUpdate addReviewerUpdate = newUpdate(c, changeOwner);
+    addReviewerUpdate.putReviewer(otherUserId, REVIEWER);
+    addReviewerUpdate.commit();
+
+    commitsToFix.add(
+        writeUpdate(
+            RefNames.changeMetaRef(c.getId()),
+            getChangeUpdateBody(
+                c,
+                "Removed reviewer " + otherUser.getAccount().fullName(),
+                "Removed: " + getValidIdentAsString(otherUser.getAccount())),
+            getAuthorIdent(changeOwner.getAccount())));
+
+    ChangeUpdate addCcUpdate = newUpdate(c, changeOwner);
+    addCcUpdate.putReviewer(otherUserId, CC);
+    addCcUpdate.commit();
+
+    commitsToFix.add(
+        writeUpdate(
+            RefNames.changeMetaRef(c.getId()),
+            getChangeUpdateBody(
+                c,
+                "Removed cc " + otherUser.getAccount().fullName(),
+                "Removed: " + getValidIdentAsString(otherUser.getAccount())),
+            getAuthorIdent(changeOwner.getAccount())));
+
+    Ref metaRefBeforeRewrite = repo.exactRef(RefNames.changeMetaRef(c.getId()));
+
+    ImmutableList<RevCommit> commitsBeforeRewrite = logMetaRef(repo, metaRefBeforeRewrite);
+
+    ImmutableList<Integer> invalidCommits =
+        commitsToFix.build().stream()
+            .map(commit -> commitsBeforeRewrite.indexOf(commit))
+            .collect(toImmutableList());
+    ChangeNotes notesBeforeRewrite = newNotes(c);
+
+    RunOptions options = new RunOptions();
+    options.dryRun = false;
+    BackfillResult result = rewriter.backfillProject(project, repo, options);
+    assertThat(result.fixedRefDiff.keySet()).containsExactly(RefNames.changeMetaRef(c.getId()));
+
+    Timestamp updateTimestamp = new Timestamp(serverIdent.getWhen().getTime());
+    ImmutableList<ReviewerStatusUpdate> expectedReviewerUpdates =
+        ImmutableList.of(
+            ReviewerStatusUpdate.create(
+                new Timestamp(addReviewerUpdate.when.getTime()),
+                changeOwner.getAccountId(),
+                otherUserId,
+                REVIEWER),
+            ReviewerStatusUpdate.create(
+                updateTimestamp, changeOwner.getAccountId(), otherUserId, REMOVED),
+            ReviewerStatusUpdate.create(
+                new Timestamp(addCcUpdate.when.getTime()),
+                changeOwner.getAccountId(),
+                otherUserId,
+                CC),
+            ReviewerStatusUpdate.create(
+                updateTimestamp, changeOwner.getAccountId(), otherUserId, REMOVED));
+    ChangeNotes notesAfterRewrite = newNotes(c);
+
+    assertThat(notesBeforeRewrite.getReviewerUpdates()).isEqualTo(expectedReviewerUpdates);
+    assertThat(changeMessages(notesBeforeRewrite))
+        .containsExactly("Removed reviewer Other Account", "Removed cc Other Account");
+    assertThat(notesAfterRewrite.getReviewerUpdates()).isEqualTo(expectedReviewerUpdates);
+    assertThat(changeMessages(notesAfterRewrite)).containsExactly("Removed reviewer", "Removed cc");
+
+    Ref metaRefAfterRewrite = repo.exactRef(RefNames.changeMetaRef(c.getId()));
+    assertThat(metaRefAfterRewrite.getObjectId()).isNotEqualTo(metaRefBeforeRewrite.getObjectId());
+
+    ImmutableList<RevCommit> commitsAfterRewrite = logMetaRef(repo, metaRefAfterRewrite);
+    assertValidCommits(commitsBeforeRewrite, commitsAfterRewrite, invalidCommits);
+
+    List<String> commitHistoryDiff = result.fixedRefDiff.get(RefNames.changeMetaRef(c.getId()));
+    assertThat(commitHistoryDiff).hasSize(2);
+    assertThat(commitHistoryDiff.get(0))
+        .isEqualTo("@@ -6 +6 @@\n" + "-Removed reviewer Other Account\n" + "+Removed reviewer\n");
+    assertThat(commitHistoryDiff.get(1))
+        .isEqualTo("@@ -6 +6 @@\n" + "-Removed cc Other Account\n" + "+Removed cc\n");
+  }
+
+  @Test
+  public void fixLabelFooterIdent() throws Exception {
+    Change c = newChange();
+    String approverIdentToFix = getAccountIdentToFix(otherUser.getAccount());
+    String changeOwnerIdentToFix = getAccountIdentToFix(changeOwner.getAccount());
+    ChangeUpdate approvalUpdateByOtherUser = newUpdate(c, otherUser);
+    approvalUpdateByOtherUser.putApproval(VERIFIED, (short) -1);
+    approvalUpdateByOtherUser.commit();
+
+    ImmutableList<RevCommit> commitsToFix =
+        new ImmutableList.Builder<RevCommit>()
+            .add(
+                writeUpdate(
+                    RefNames.changeMetaRef(c.getId()),
+                    getChangeUpdateBody(
+                        c,
+                        /*changeMessage=*/ null,
+                        "Label: -Verified " + approverIdentToFix,
+                        "Label: Custom-Label-1=-1 " + approverIdentToFix,
+                        "Label: Verified=+1",
+                        "Label: Custom-Label-1=+1",
+                        "Label: Custom-Label-2=+2 " + approverIdentToFix,
+                        "Label: Custom-Label-3=0 " + approverIdentToFix),
+                    getAuthorIdent(changeOwner.getAccount())))
+            .add(
+                writeUpdate(
+                    RefNames.changeMetaRef(c.getId()),
+                    getChangeUpdateBody(
+                        c,
+                        /*changeMessage=*/ null,
+                        "Label: -Verified " + changeOwnerIdentToFix,
+                        "Label: Custom-Label-1=+1"),
+                    getAuthorIdent(otherUser.getAccount())))
+            .build();
+
+    Ref metaRefBeforeRewrite = repo.exactRef(RefNames.changeMetaRef(c.getId()));
+
+    ImmutableList<RevCommit> commitsBeforeRewrite = logMetaRef(repo, metaRefBeforeRewrite);
+
+    ImmutableList<Integer> invalidCommits =
+        commitsToFix.stream()
+            .map(commit -> commitsBeforeRewrite.indexOf(commit))
+            .collect(toImmutableList());
+    ChangeNotes notesBeforeRewrite = newNotes(c);
+
+    RunOptions options = new RunOptions();
+    options.dryRun = false;
+    BackfillResult result = rewriter.backfillProject(project, repo, options);
+    assertThat(result.fixedRefDiff.keySet()).containsExactly(RefNames.changeMetaRef(c.getId()));
+
+    Timestamp updateTimestamp = new Timestamp(serverIdent.getWhen().getTime());
+    ImmutableList<PatchSetApproval> expectedApprovals =
+        ImmutableList.of(
+            PatchSetApproval.builder()
+                .key(
+                    PatchSetApproval.key(
+                        c.currentPatchSetId(),
+                        changeOwner.getAccountId(),
+                        LabelId.create(VERIFIED)))
+                .value(0)
+                .granted(updateTimestamp)
+                .build(),
+            PatchSetApproval.builder()
+                .key(
+                    PatchSetApproval.key(
+                        c.currentPatchSetId(),
+                        changeOwner.getAccountId(),
+                        LabelId.create("Custom-Label-1")))
+                .value(+1)
+                .granted(updateTimestamp)
+                .build(),
+            PatchSetApproval.builder()
+                .key(
+                    PatchSetApproval.key(
+                        c.currentPatchSetId(), otherUserId, LabelId.create(VERIFIED)))
+                .value(0)
+                .granted(updateTimestamp)
+                .build(),
+            PatchSetApproval.builder()
+                .key(
+                    PatchSetApproval.key(
+                        c.currentPatchSetId(), otherUserId, LabelId.create("Custom-Label-1")))
+                .value(+1)
+                .granted(updateTimestamp)
+                .build(),
+            PatchSetApproval.builder()
+                .key(
+                    PatchSetApproval.key(
+                        c.currentPatchSetId(), otherUserId, LabelId.create("Custom-Label-2")))
+                .value(+2)
+                .granted(updateTimestamp)
+                .build(),
+            PatchSetApproval.builder()
+                .key(
+                    PatchSetApproval.key(
+                        c.currentPatchSetId(), otherUserId, LabelId.create("Custom-Label-3")))
+                .value(0)
+                .granted(updateTimestamp)
+                .build());
+    ChangeNotes notesAfterRewrite = newNotes(c);
+
+    assertThat(notesBeforeRewrite.getApprovals().get(c.currentPatchSetId()))
+        .containsExactlyElementsIn(expectedApprovals);
+    assertThat(notesAfterRewrite.getApprovals().get(c.currentPatchSetId()))
+        .containsExactlyElementsIn(expectedApprovals);
+
+    Ref metaRefAfterRewrite = repo.exactRef(RefNames.changeMetaRef(c.getId()));
+    assertThat(metaRefAfterRewrite.getObjectId()).isNotEqualTo(metaRefBeforeRewrite.getObjectId());
+
+    ImmutableList<RevCommit> commitsAfterRewrite = logMetaRef(repo, metaRefAfterRewrite);
+    assertValidCommits(commitsBeforeRewrite, commitsAfterRewrite, invalidCommits);
+
+    List<String> commitHistoryDiff = result.fixedRefDiff.get(RefNames.changeMetaRef(c.getId()));
+    assertThat(commitHistoryDiff).hasSize(2);
+    assertThat(commitHistoryDiff.get(0))
+        .isEqualTo(
+            "@@ -7,2 +7,2 @@\n"
+                + "-Label: -Verified Other Account <2@gerrit>\n"
+                + "-Label: Custom-Label-1=-1 Other Account <2@gerrit>\n"
+                + "+Label: -Verified Gerrit User 2 <2@gerrit>\n"
+                + "+Label: Custom-Label-1=-1 Gerrit User 2 <2@gerrit>\n"
+                + "@@ -11,2 +11,2 @@\n"
+                + "-Label: Custom-Label-2=+2 Other Account <2@gerrit>\n"
+                + "-Label: Custom-Label-3=0 Other Account <2@gerrit>\n"
+                + "+Label: Custom-Label-2=+2 Gerrit User 2 <2@gerrit>\n"
+                + "+Label: Custom-Label-3=0 Gerrit User 2 <2@gerrit>\n");
+    assertThat(commitHistoryDiff.get(1))
+        .isEqualTo(
+            "@@ -7 +7 @@\n"
+                + "-Label: -Verified Change Owner <1@gerrit>\n"
+                + "+Label: -Verified Gerrit User 1 <1@gerrit>\n");
+  }
+
+  @Test
+  public void fixRemoveVoteChangeMessage() throws Exception {
+    Change c = newChange();
+    String approverIdentToFix = getAccountIdentToFix(otherUser.getAccount());
+    ChangeUpdate approvalUpdateByOtherUser = newUpdate(c, otherUser);
+    approvalUpdateByOtherUser.putApproval(CODE_REVIEW, (short) +2);
+    approvalUpdateByOtherUser.putApproval("Custom-Label", (short) -1);
+    approvalUpdateByOtherUser.putApprovalFor(changeOwner.getAccountId(), VERIFIED, (short) -1);
+    approvalUpdateByOtherUser.commit();
+
+    ImmutableList<RevCommit> commitsToFix =
+        new ImmutableList.Builder<RevCommit>()
+            .add(
+                writeUpdate(
+                    RefNames.changeMetaRef(c.getId()),
+                    getChangeUpdateBody(
+                        c,
+                        /*changeMessage=*/ "Removed Code-Review+2 by " + otherUser.getNameEmail(),
+                        "Label: -Code-Review " + approverIdentToFix),
+                    getAuthorIdent(changeOwner.getAccount())))
+            .add(
+                writeUpdate(
+                    RefNames.changeMetaRef(c.getId()),
+                    getChangeUpdateBody(
+                        c,
+                        /*changeMessage=*/ "Removed Custom-Label-1 by " + otherUser.getNameEmail(),
+                        "Label: -Custom-Label " + getValidIdentAsString(otherUser.getAccount())),
+                    getAuthorIdent(changeOwner.getAccount())))
+            .add(
+                writeUpdate(
+                    RefNames.changeMetaRef(c.getId()),
+                    getChangeUpdateBody(
+                        c,
+                        /*changeMessage=*/ "Removed Verified+2 by " + changeOwner.getNameEmail(),
+                        "Label: -Verified"),
+                    getAuthorIdent(changeOwner.getAccount())))
+            .build();
+
+    Ref metaRefBeforeRewrite = repo.exactRef(RefNames.changeMetaRef(c.getId()));
+
+    ImmutableList<RevCommit> commitsBeforeRewrite = logMetaRef(repo, metaRefBeforeRewrite);
+
+    ImmutableList<Integer> invalidCommits =
+        commitsToFix.stream()
+            .map(commit -> commitsBeforeRewrite.indexOf(commit))
+            .collect(toImmutableList());
+    ChangeNotes notesBeforeRewrite = newNotes(c);
+
+    RunOptions options = new RunOptions();
+    options.dryRun = false;
+    BackfillResult result = rewriter.backfillProject(project, repo, options);
+    assertThat(result.fixedRefDiff.keySet()).containsExactly(RefNames.changeMetaRef(c.getId()));
+
+    Timestamp updateTimestamp = new Timestamp(serverIdent.getWhen().getTime());
+    ImmutableList<PatchSetApproval> expectedApprovals =
+        ImmutableList.of(
+            PatchSetApproval.builder()
+                .key(
+                    PatchSetApproval.key(
+                        c.currentPatchSetId(),
+                        changeOwner.getAccountId(),
+                        LabelId.create(VERIFIED)))
+                .value(0)
+                .granted(updateTimestamp)
+                .build(),
+            PatchSetApproval.builder()
+                .key(
+                    PatchSetApproval.key(
+                        c.currentPatchSetId(), otherUserId, LabelId.create("Custom-Label")))
+                .value(0)
+                .granted(updateTimestamp)
+                .build(),
+            PatchSetApproval.builder()
+                .key(
+                    PatchSetApproval.key(
+                        c.currentPatchSetId(), otherUserId, LabelId.create(CODE_REVIEW)))
+                .value(0)
+                .granted(updateTimestamp)
+                .build());
+    ChangeNotes notesAfterRewrite = newNotes(c);
+    assertThat(changeMessages(notesBeforeRewrite))
+        .containsExactly(
+            "Removed Code-Review+2 by Other Account <other@account.com>",
+            "Removed Custom-Label-1 by Other Account <other@account.com>",
+            "Removed Verified+2 by Change Owner <change@owner.com>");
+
+    assertThat(notesBeforeRewrite.getApprovals().get(c.currentPatchSetId()))
+        .containsExactlyElementsIn(expectedApprovals);
+    assertThat(changeMessages(notesAfterRewrite))
+        .containsExactly(
+            "Removed Code-Review+2 by <GERRIT_ACCOUNT_2>",
+            "Removed Custom-Label-1 by <GERRIT_ACCOUNT_2>",
+            "Removed Verified+2 by <GERRIT_ACCOUNT_1>");
+    assertThat(notesAfterRewrite.getApprovals().get(c.currentPatchSetId()))
+        .containsExactlyElementsIn(expectedApprovals);
+
+    Ref metaRefAfterRewrite = repo.exactRef(RefNames.changeMetaRef(c.getId()));
+    assertThat(metaRefAfterRewrite.getObjectId()).isNotEqualTo(metaRefBeforeRewrite.getObjectId());
+
+    ImmutableList<RevCommit> commitsAfterRewrite = logMetaRef(repo, metaRefAfterRewrite);
+    assertValidCommits(commitsBeforeRewrite, commitsAfterRewrite, invalidCommits);
+
+    List<String> commitHistoryDiff = result.fixedRefDiff.get(RefNames.changeMetaRef(c.getId()));
+    assertThat(commitHistoryDiff).hasSize(3);
+    assertThat(commitHistoryDiff.get(0))
+        .isEqualTo(
+            "@@ -6 +6 @@\n"
+                + "-Removed Code-Review+2 by Other Account <other@account.com>\n"
+                + "+Removed Code-Review+2 by <GERRIT_ACCOUNT_2>\n"
+                + "@@ -9 +9 @@\n"
+                + "-Label: -Code-Review Other Account <2@gerrit>\n"
+                + "+Label: -Code-Review Gerrit User 2 <2@gerrit>\n");
+    assertThat(commitHistoryDiff.get(1))
+        .isEqualTo(
+            "@@ -6 +6 @@\n"
+                + "-Removed Custom-Label-1 by Other Account <other@account.com>\n"
+                + "+Removed Custom-Label-1 by <GERRIT_ACCOUNT_2>\n");
+    assertThat(commitHistoryDiff.get(2))
+        .isEqualTo(
+            "@@ -6 +6 @@\n"
+                + "-Removed Verified+2 by Change Owner <change@owner.com>\n"
+                + "+Removed Verified+2 by <GERRIT_ACCOUNT_1>\n");
+  }
+
+  @Test
+  public void fixAttentionFooterIdent() throws Exception {
+    // TODO(mariasavtchouk): add once backfilling is implemented for this case.
+  }
+
+  @Test
+  public void fixSubmitChangeMessage() throws Exception {
+    Change c = newChange();
+    ImmutableList.Builder<ObjectId> commitsToFix = new ImmutableList.Builder<>();
+    ChangeUpdate invalidMergedMessageUpdate = newUpdate(c, changeOwner);
+    invalidMergedMessageUpdate.setChangeMessage(
+        "Change has been successfully merged by " + changeOwner.getName());
+    invalidMergedMessageUpdate.setTag(ChangeMessagesUtil.TAG_MERGED);
+    commitsToFix.add(invalidMergedMessageUpdate.commit());
+    ChangeUpdate invalidCherryPickedMessageUpdate = newUpdate(c, changeOwner);
+    invalidCherryPickedMessageUpdate.setChangeMessage(
+        "Change has been successfully cherry-picked as e40dc1a50dc7f457a37579e2755374f3e1a5413b by "
+            + changeOwner.getName());
+    invalidCherryPickedMessageUpdate.setTag(ChangeMessagesUtil.TAG_MERGED);
+    commitsToFix.add(invalidCherryPickedMessageUpdate.commit());
+    ChangeUpdate invalidRebasedMessageUpdate = newUpdate(c, changeOwner);
+    invalidRebasedMessageUpdate.setChangeMessage(
+        "Change has been successfully rebased and submitted as e40dc1a50dc7f457a37579e2755374f3e1a5413b by "
+            + changeOwner.getName());
+    invalidRebasedMessageUpdate.setTag(ChangeMessagesUtil.TAG_MERGED);
+    commitsToFix.add(invalidRebasedMessageUpdate.commit());
+    ChangeUpdate validSubmitMessageUpdate = newUpdate(c, changeOwner);
+    validSubmitMessageUpdate.setChangeMessage(
+        "Change has been successfully rebased and submitted as e40dc1a50dc7f457a37579e2755374f3e1a5413b");
+    validSubmitMessageUpdate.commit();
+
+    Ref metaRefBeforeRewrite = repo.exactRef(RefNames.changeMetaRef(c.getId()));
+
+    ImmutableList<RevCommit> commitsBeforeRewrite = logMetaRef(repo, metaRefBeforeRewrite);
+
+    ImmutableList<Integer> invalidCommits =
+        commitsToFix.build().stream()
+            .map(commit -> commitsBeforeRewrite.indexOf(commit))
+            .collect(toImmutableList());
+    ChangeNotes notesBeforeRewrite = newNotes(c);
+
+    RunOptions options = new RunOptions();
+    options.dryRun = false;
+    BackfillResult result = rewriter.backfillProject(project, repo, options);
+    assertThat(result.fixedRefDiff.keySet()).containsExactly(RefNames.changeMetaRef(c.getId()));
+
+    ChangeNotes notesAfterRewrite = newNotes(c);
+
+    assertThat(changeMessages(notesBeforeRewrite))
+        .containsExactly(
+            "Change has been successfully merged by Change Owner",
+            "Change has been successfully cherry-picked as e40dc1a50dc7f457a37579e2755374f3e1a5413b by Change Owner",
+            "Change has been successfully rebased and submitted as e40dc1a50dc7f457a37579e2755374f3e1a5413b by Change Owner",
+            "Change has been successfully rebased and submitted as e40dc1a50dc7f457a37579e2755374f3e1a5413b");
+    assertThat(changeMessages(notesAfterRewrite))
+        .containsExactly(
+            "Change has been successfully merged",
+            "Change has been successfully cherry-picked as e40dc1a50dc7f457a37579e2755374f3e1a5413b",
+            "Change has been successfully rebased and submitted as e40dc1a50dc7f457a37579e2755374f3e1a5413b",
+            "Change has been successfully rebased and submitted as e40dc1a50dc7f457a37579e2755374f3e1a5413b");
+
+    Ref metaRefAfterRewrite = repo.exactRef(RefNames.changeMetaRef(c.getId()));
+    assertThat(metaRefAfterRewrite.getObjectId()).isNotEqualTo(metaRefBeforeRewrite.getObjectId());
+
+    ImmutableList<RevCommit> commitsAfterRewrite = logMetaRef(repo, metaRefAfterRewrite);
+    assertValidCommits(commitsBeforeRewrite, commitsAfterRewrite, invalidCommits);
+
+    List<String> commitHistoryDiff = result.fixedRefDiff.get(RefNames.changeMetaRef(c.getId()));
+    assertThat(commitHistoryDiff).hasSize(3);
+    assertThat(commitHistoryDiff.get(0))
+        .isEqualTo(
+            "@@ -6 +6 @@\n"
+                + "-Change has been successfully merged by Change Owner\n"
+                + "+Change has been successfully merged\n");
+    assertThat(commitHistoryDiff.get(1))
+        .isEqualTo(
+            "@@ -6 +6 @@\n"
+                + "-Change has been successfully cherry-picked as e40dc1a50dc7f457a37579e2755374f3e1a5413b by Change Owner\n"
+                + "+Change has been successfully cherry-picked as e40dc1a50dc7f457a37579e2755374f3e1a5413b\n");
+    assertThat(commitHistoryDiff.get(2))
+        .isEqualTo(
+            "@@ -6 +6 @@\n"
+                + "-Change has been successfully rebased and submitted as e40dc1a50dc7f457a37579e2755374f3e1a5413b by Change Owner\n"
+                + "+Change has been successfully rebased and submitted as e40dc1a50dc7f457a37579e2755374f3e1a5413b\n");
+  }
+
+  @Test
+  public void fixSubmittedWithFooterIdent() throws Exception {
+    // TODO(mariasavtchouk): add once backfilling is implemented for this case.
+  }
+
+  @Test
+  public void fixDeleteChangeMessageCommitMessage() throws Exception {
+    Change c = newChange();
+    ImmutableList.Builder<ObjectId> commitsToFix = new ImmutableList.Builder<>();
+    ChangeUpdate invalidDeleteChangeMessageUpdate = newUpdate(c, changeOwner);
+    invalidDeleteChangeMessageUpdate.setChangeMessage(
+        "Change message removed by: " + changeOwner.getName());
+    commitsToFix.add(invalidDeleteChangeMessageUpdate.commit());
+    ChangeUpdate invalidDeleteChangeMessageUpdateWithReason = newUpdate(c, changeOwner);
+    invalidDeleteChangeMessageUpdateWithReason.setChangeMessage(
+        String.format(
+            "Change message removed by: %s\nReason: %s",
+            changeOwner.getName(), "contains confidential information"));
+    commitsToFix.add(invalidDeleteChangeMessageUpdateWithReason.commit());
+    ChangeUpdate validDeleteChangeMessageUpdate = newUpdate(c, changeOwner);
+    validDeleteChangeMessageUpdate.setChangeMessage(
+        "Change message removed by: <GERRIT_ACCOUNT_1>");
+    validDeleteChangeMessageUpdate.commit();
+    ChangeUpdate validDeleteChangeMessageUpdateWithReason = newUpdate(c, changeOwner);
+    validDeleteChangeMessageUpdateWithReason.setChangeMessage(
+        "Change message removed by: <GERRIT_ACCOUNT_1>\nReason: abusive language");
+    validDeleteChangeMessageUpdateWithReason.commit();
+
+    Ref metaRefBeforeRewrite = repo.exactRef(RefNames.changeMetaRef(c.getId()));
+
+    ImmutableList<RevCommit> commitsBeforeRewrite = logMetaRef(repo, metaRefBeforeRewrite);
+
+    ImmutableList<Integer> invalidCommits =
+        commitsToFix.build().stream()
+            .map(commit -> commitsBeforeRewrite.indexOf(commit))
+            .collect(toImmutableList());
+    ChangeNotes notesBeforeRewrite = newNotes(c);
+
+    RunOptions options = new RunOptions();
+    options.dryRun = false;
+    BackfillResult result = rewriter.backfillProject(project, repo, options);
+    assertThat(result.fixedRefDiff.keySet()).containsExactly(RefNames.changeMetaRef(c.getId()));
+
+    ChangeNotes notesAfterRewrite = newNotes(c);
+
+    assertThat(changeMessages(notesBeforeRewrite))
+        .containsExactly(
+            "Change message removed by: Change Owner",
+            "Change message removed by: Change Owner\n"
+                + "Reason: contains confidential information",
+            "Change message removed by: <GERRIT_ACCOUNT_1>",
+            "Change message removed by: <GERRIT_ACCOUNT_1>\n" + "Reason: abusive language");
+    assertThat(changeMessages(notesAfterRewrite))
+        .containsExactly(
+            "Change message removed",
+            "Change message removed\n" + "Reason: contains confidential information",
+            "Change message removed by: <GERRIT_ACCOUNT_1>",
+            "Change message removed by: <GERRIT_ACCOUNT_1>\n" + "Reason: abusive language");
+
+    Ref metaRefAfterRewrite = repo.exactRef(RefNames.changeMetaRef(c.getId()));
+    assertThat(metaRefAfterRewrite.getObjectId()).isNotEqualTo(metaRefBeforeRewrite.getObjectId());
+
+    ImmutableList<RevCommit> commitsAfterRewrite = logMetaRef(repo, metaRefAfterRewrite);
+    assertValidCommits(commitsBeforeRewrite, commitsAfterRewrite, invalidCommits);
+
+    List<String> commitHistoryDiff = result.fixedRefDiff.get(RefNames.changeMetaRef(c.getId()));
+    assertThat(commitHistoryDiff).hasSize(2);
+    assertThat(commitHistoryDiff.get(0))
+        .isEqualTo(
+            "@@ -6 +6 @@\n"
+                + "-Change message removed by: Change Owner\n"
+                + "+Change message removed\n");
+    assertThat(commitHistoryDiff.get(1))
+        .isEqualTo(
+            "@@ -6 +6 @@\n"
+                + "-Change message removed by: Change Owner\n"
+                + "+Change message removed\n");
+  }
+
+  @Test
+  public void fixCodeOwnersChangeMessage() throws Exception {
+    // TODO(mariasavtchouk): add once backfilling is implemented for this case.
+  }
+
+  @Test
+  public void fixAssigneeFooterIdent() throws Exception {
+    Change c = newChange();
+
+    String assigneeIdentToFix = getAccountIdentToFix(changeOwner.getAccount());
+    RevCommit invalidUpdateCommit =
+        writeUpdate(
+            RefNames.changeMetaRef(c.getId()),
+            getChangeUpdateBody(c, "Assignee added", "Assignee: " + assigneeIdentToFix),
+            getAuthorIdent(changeOwner.getAccount()));
+
+    ChangeUpdate changeAssigneeUpdate = newUpdate(c, changeOwner);
+    changeAssigneeUpdate.setAssignee(otherUserId);
+    changeAssigneeUpdate.commit();
+
+    ChangeUpdate removeAssigneeUpdate = newUpdate(c, changeOwner);
+    removeAssigneeUpdate.removeAssignee();
+    removeAssigneeUpdate.commit();
+
+    Ref metaRefBeforeRewrite = repo.exactRef(RefNames.changeMetaRef(c.getId()));
+
+    ImmutableList<RevCommit> commitsBeforeRewrite = logMetaRef(repo, metaRefBeforeRewrite);
+
+    int invalidCommitIndex = commitsBeforeRewrite.indexOf(invalidUpdateCommit);
+    ChangeNotes notesBeforeRewrite = newNotes(c);
+
+    RunOptions options = new RunOptions();
+    options.dryRun = false;
+    BackfillResult result = rewriter.backfillProject(project, repo, options);
+    assertThat(result.fixedRefDiff.keySet()).containsExactly(RefNames.changeMetaRef(c.getId()));
+
+    ChangeNotes notesAfterRewrite = newNotes(c);
+    assertThat(notesBeforeRewrite.getPastAssignees())
+        .containsExactly(changeOwner.getAccountId(), otherUser.getAccountId());
+    assertThat(notesBeforeRewrite.getChange().getAssignee()).isNull();
+    assertThat(notesAfterRewrite.getPastAssignees())
+        .containsExactly(changeOwner.getAccountId(), otherUser.getAccountId());
+    assertThat(notesAfterRewrite.getChange().getAssignee()).isNull();
+
+    Ref metaRefAfterRewrite = repo.exactRef(RefNames.changeMetaRef(c.getId()));
+    assertThat(metaRefAfterRewrite.getObjectId()).isNotEqualTo(metaRefBeforeRewrite.getObjectId());
+
+    ImmutableList<RevCommit> commitsAfterRewrite = logMetaRef(repo, metaRefAfterRewrite);
+    assertValidCommits(
+        commitsBeforeRewrite, commitsAfterRewrite, ImmutableList.of(invalidCommitIndex));
+
+    RevCommit fixedUpdateCommit = commitsAfterRewrite.get(invalidCommitIndex);
+    assertThat(invalidUpdateCommit.getAuthorIdent()).isEqualTo(fixedUpdateCommit.getAuthorIdent());
+    assertThat(invalidUpdateCommit.getCommitterIdent())
+        .isEqualTo(fixedUpdateCommit.getCommitterIdent());
+    assertThat(invalidUpdateCommit.getFullMessage())
+        .isNotEqualTo(fixedUpdateCommit.getFullMessage());
+    assertThat(fixedUpdateCommit.getFullMessage()).doesNotContain(changeOwner.getName());
+    assertThat(invalidUpdateCommit.getFullMessage()).contains(assigneeIdentToFix);
+    String expectedFixedIdent = getValidIdentAsString(changeOwner.getAccount());
+    assertThat(fixedUpdateCommit.getFullMessage()).contains(expectedFixedIdent);
+
+    List<String> commitHistoryDiff = result.fixedRefDiff.get(RefNames.changeMetaRef(c.getId()));
+    assertThat(commitHistoryDiff).hasSize(1);
+    assertThat(commitHistoryDiff.get(0))
+        .isEqualTo(
+            "@@ -9 +9 @@\n"
+                + "-Assignee: Change Owner <1@gerrit>\n"
+                + "+Assignee: Gerrit User 1 <1@gerrit>\n");
+  }
+
+  @Test
+  public void fixAssigneeChangeMessage() throws Exception {
+    Change c = newChange();
+
+    ImmutableList<RevCommit> commitsToFix =
+        new ImmutableList.Builder<RevCommit>()
+            .add(
+                writeUpdate(
+                    RefNames.changeMetaRef(c.getId()),
+                    getChangeUpdateBody(
+                        c,
+                        "Assignee added: " + changeOwner.getNameEmail(),
+                        "Assignee: " + getValidIdentAsString(changeOwner.getAccount())),
+                    getAuthorIdent(changeOwner.getAccount())))
+            .add(
+                writeUpdate(
+                    RefNames.changeMetaRef(c.getId()),
+                    getChangeUpdateBody(
+                        c,
+                        String.format(
+                            "Assignee changed from: %s to: %s",
+                            changeOwner.getNameEmail(), otherUser.getNameEmail()),
+                        "Assignee: " + getValidIdentAsString(otherUser.getAccount())),
+                    getAuthorIdent(changeOwner.getAccount())))
+            .add(
+                writeUpdate(
+                    RefNames.changeMetaRef(c.getId()),
+                    getChangeUpdateBody(
+                        c, "Assignee deleted: " + otherUser.getNameEmail(), "Assignee:"),
+                    getAuthorIdent(changeOwner.getAccount())))
+            .build();
+
+    Ref metaRefBeforeRewrite = repo.exactRef(RefNames.changeMetaRef(c.getId()));
+
+    ImmutableList<RevCommit> commitsBeforeRewrite = logMetaRef(repo, metaRefBeforeRewrite);
+
+    ImmutableList<Integer> invalidCommits =
+        commitsToFix.stream()
+            .map(commit -> commitsBeforeRewrite.indexOf(commit))
+            .collect(toImmutableList());
+    ChangeNotes notesBeforeRewrite = newNotes(c);
+
+    RunOptions options = new RunOptions();
+    options.dryRun = false;
+    BackfillResult result = rewriter.backfillProject(project, repo, options);
+    assertThat(result.fixedRefDiff.keySet()).containsExactly(RefNames.changeMetaRef(c.getId()));
+
+    ChangeNotes notesAfterRewrite = newNotes(c);
+    assertThat(notesBeforeRewrite.getPastAssignees())
+        .containsExactly(changeOwner.getAccountId(), otherUser.getAccountId());
+    assertThat(notesBeforeRewrite.getChange().getAssignee()).isNull();
+    assertThat(changeMessages(notesBeforeRewrite))
+        .containsExactly(
+            "Assignee added: Change Owner <change@owner.com>",
+            "Assignee changed from: Change Owner <change@owner.com> to: Other Account <other@account.com>",
+            "Assignee deleted: Other Account <other@account.com>");
+
+    assertThat(notesAfterRewrite.getPastAssignees())
+        .containsExactly(changeOwner.getAccountId(), otherUser.getAccountId());
+    assertThat(notesAfterRewrite.getChange().getAssignee()).isNull();
+    assertThat(changeMessages(notesAfterRewrite))
+        .containsExactly(
+            "Assignee added: " + ChangeMessagesUtil.getAccountTemplate(changeOwner.getAccountId()),
+            String.format(
+                "Assignee changed from: %s to: %s",
+                ChangeMessagesUtil.getAccountTemplate(changeOwner.getAccountId()),
+                ChangeMessagesUtil.getAccountTemplate(otherUser.getAccountId())),
+            "Assignee deleted: " + ChangeMessagesUtil.getAccountTemplate(otherUser.getAccountId()));
+
+    Ref metaRefAfterRewrite = repo.exactRef(RefNames.changeMetaRef(c.getId()));
+    assertThat(metaRefAfterRewrite.getObjectId()).isNotEqualTo(metaRefBeforeRewrite.getObjectId());
+
+    ImmutableList<RevCommit> commitsAfterRewrite = logMetaRef(repo, metaRefAfterRewrite);
+    assertValidCommits(commitsBeforeRewrite, commitsAfterRewrite, invalidCommits);
+    List<String> commitHistoryDiff = result.fixedRefDiff.get(RefNames.changeMetaRef(c.getId()));
+    assertThat(commitHistoryDiff).hasSize(3);
+    assertThat(commitHistoryDiff.get(0))
+        .isEqualTo(
+            "@@ -6 +6 @@\n"
+                + "-Assignee added: Change Owner <change@owner.com>\n"
+                + "+Assignee added: <GERRIT_ACCOUNT_1>\n");
+    assertThat(commitHistoryDiff.get(1))
+        .isEqualTo(
+            "@@ -6 +6 @@\n"
+                + "-Assignee changed from: Change Owner <change@owner.com> to: Other Account <other@account.com>\n"
+                + "+Assignee changed from: <GERRIT_ACCOUNT_1> to: <GERRIT_ACCOUNT_2>\n");
+    assertThat(commitHistoryDiff.get(2))
+        .isEqualTo(
+            "@@ -6 +6 @@\n"
+                + "-Assignee deleted: Other Account <other@account.com>\n"
+                + "+Assignee deleted: <GERRIT_ACCOUNT_2>\n");
+  }
+
+  @Test
+  public void singleRunFixesAll() throws Exception {
+    Change c = newChange();
+    Timestamp when = TimeUtil.nowTs();
+    String assigneeIdentToFix = getAccountIdentToFix(otherUser.getAccount());
+    PersonIdent authorIdentToFix =
+        new PersonIdent(
+            changeOwner.getName(),
+            changeNoteUtil.getAccountIdAsEmailAddress(changeOwner.getAccountId()),
+            when,
+            serverIdent.getTimeZone());
+
+    RevCommit invalidUpdateCommit =
+        writeUpdate(
+            RefNames.changeMetaRef(c.getId()),
+            getChangeUpdateBody(
+                c,
+                "Assignee added: Other Account <other@account.com>",
+                "Assignee: " + assigneeIdentToFix),
+            authorIdentToFix);
+    Ref metaRefBeforeRewrite = repo.exactRef(RefNames.changeMetaRef(c.getId()));
+
+    ImmutableList<RevCommit> commitsBeforeRewrite = logMetaRef(repo, metaRefBeforeRewrite);
+
+    int invalidCommitIndex = commitsBeforeRewrite.indexOf(invalidUpdateCommit);
+    ChangeNotes notesBeforeRewrite = newNotes(c);
+
+    RunOptions options = new RunOptions();
+    options.dryRun = false;
+    BackfillResult result = rewriter.backfillProject(project, repo, options);
+    assertThat(result.fixedRefDiff.keySet()).containsExactly(RefNames.changeMetaRef(c.getId()));
+
+    ChangeNotes notesAfterRewrite = newNotes(c);
+    assertThat(notesBeforeRewrite.getChange().getAssignee()).isEqualTo(otherUserId);
+    assertThat(notesAfterRewrite.getChange().getAssignee()).isEqualTo(otherUserId);
+
+    Ref metaRefAfterRewrite = repo.exactRef(RefNames.changeMetaRef(c.getId()));
+    assertThat(metaRefAfterRewrite.getObjectId()).isNotEqualTo(metaRefBeforeRewrite.getObjectId());
+
+    ImmutableList<RevCommit> commitsAfterRewrite = logMetaRef(repo, metaRefAfterRewrite);
+    assertValidCommits(
+        commitsBeforeRewrite, commitsAfterRewrite, ImmutableList.of(invalidCommitIndex));
+
+    RevCommit fixedUpdateCommit = commitsAfterRewrite.get(invalidCommitIndex);
+    assertThat(invalidUpdateCommit.getAuthorIdent())
+        .isNotEqualTo(fixedUpdateCommit.getAuthorIdent());
+    assertThat(invalidUpdateCommit.getFullMessage()).contains(otherUser.getName());
+    assertThat(fixedUpdateCommit.getFullMessage()).doesNotContain(changeOwner.getName());
+    assertThat(fixedUpdateCommit.getFullMessage()).doesNotContain(otherUser.getName());
+
+    List<String> commitHistoryDiff = result.fixedRefDiff.get(RefNames.changeMetaRef(c.getId()));
+    assertThat(commitHistoryDiff).hasSize(1);
+    assertThat(commitHistoryDiff.get(0)).contains("-author Change Owner <1@gerrit>");
+    assertThat(commitHistoryDiff.get(0)).contains("+author Gerrit User 1 <1@gerrit>");
+    assertThat(commitHistoryDiff.get(0))
+        .contains(
+            "@@ -6 +6 @@\n"
+                + "-Assignee added: Other Account <other@account.com>\n"
+                + "+Assignee added: <GERRIT_ACCOUNT_2>\n"
+                + "@@ -9 +9 @@\n"
+                + "-Assignee: Other Account <2@gerrit>\n"
+                + "+Assignee: Gerrit User 2 <2@gerrit>");
+  }
+
+  private RevCommit writeUpdate(String metaRef, String body, PersonIdent author) throws Exception {
+    return tr.branch(metaRef).commit().message(body).author(author).committer(serverIdent).create();
+  }
+
+  private String getChangeUpdateBody(Change change, String changeMessage, String... footers) {
+    StringBuilder commitBody = new StringBuilder();
+    commitBody.append("Update patch set " + change.currentPatchSetId().get());
+    commitBody.append("\n\n");
+    if (changeMessage != null) {
+      commitBody.append(changeMessage);
+      commitBody.append("\n\n");
+    }
+    commitBody.append("Patch-set: " + change.currentPatchSetId().get());
+    commitBody.append("\n");
+    for (String footer : footers) {
+      commitBody.append(footer);
+      commitBody.append("\n");
+    }
+    return commitBody.toString();
+  }
+
+  private ImmutableList<RevCommit> logMetaRef(Repository repo, Ref metaRef) throws Exception {
+    try (RevWalk rw = new RevWalk(repo)) {
+      rw.sort(RevSort.TOPO);
+      rw.sort(RevSort.REVERSE);
+      if (metaRef == null) {
+        return ImmutableList.of();
+      }
+      rw.markStart(rw.parseCommit(metaRef.getObjectId()));
+      return ImmutableList.copyOf(rw);
+    }
+  }
+
+  private void assertValidCommits(
+      ImmutableList<RevCommit> commitsBeforeRewrite,
+      ImmutableList<RevCommit> commitsAfterRewrite,
+      ImmutableList<Integer> invalidCommits) {
+    ImmutableList<RevCommit> validCommitsBeforeRewrite =
+        IntStream.range(0, commitsBeforeRewrite.size())
+            .filter(i -> !invalidCommits.contains(i))
+            .mapToObj(commitsBeforeRewrite::get)
+            .collect(ImmutableList.toImmutableList());
+
+    ImmutableList<RevCommit> validCommitsAfterRewrite =
+        IntStream.range(0, commitsAfterRewrite.size())
+            .filter(i -> !invalidCommits.contains(i))
+            .mapToObj(commitsAfterRewrite::get)
+            .collect(ImmutableList.toImmutableList());
+
+    assertThat(validCommitsBeforeRewrite).hasSize(validCommitsAfterRewrite.size());
+    for (int i = 0; i < validCommitsAfterRewrite.size(); i++) {
+      RevCommit actual = validCommitsAfterRewrite.get(i);
+      RevCommit expected = validCommitsBeforeRewrite.get(i);
+      assertThat(actual.getAuthorIdent()).isEqualTo(expected.getAuthorIdent());
+      assertThat(actual.getCommitterIdent()).isEqualTo(expected.getCommitterIdent());
+      assertThat(actual.getFullMessage()).isEqualTo(expected.getFullMessage());
+    }
+  }
+
+  private String getAccountIdentToFix(Account account) {
+    return String.format("%s <%s>", account.getName(), account.id().get() + "@" + serverId);
+  }
+
+  private String getValidIdentAsString(Account account) {
+    return String.format(
+        "%s <%s>",
+        ChangeNoteUtil.getAccountIdAsUsername(account.id()), account.id().get() + "@" + serverId);
+  }
+
+  private ImmutableList<String> changeMessages(ChangeNotes changeNotes) {
+    return changeNotes.getChangeMessages().stream()
+        .map(ChangeMessage::getMessage)
+        .collect(toImmutableList());
+  }
+
+  private PersonIdent getAuthorIdent(Account account) {
+    Timestamp when = TimeUtil.nowTs();
+    return changeNoteUtil.newAccountIdIdent(account.id(), when, serverIdent);
+  }
+}
diff --git a/javatests/com/google/gerrit/server/notedb/OpenRepoTest.java b/javatests/com/google/gerrit/server/notedb/OpenRepoTest.java
new file mode 100644
index 0000000..507b71f
--- /dev/null
+++ b/javatests/com/google/gerrit/server/notedb/OpenRepoTest.java
@@ -0,0 +1,270 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.notedb;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableListMultimap;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.ListMultimap;
+import com.google.gerrit.entities.Address;
+import com.google.gerrit.entities.AttentionSetUpdate;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.SubmissionId;
+import com.google.gerrit.server.update.ChainedReceiveCommands;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Optional;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.transport.ReceiveCommand;
+import org.junit.Test;
+
+public class OpenRepoTest extends AbstractChangeNotesTest {
+
+  private final Optional<Integer> NO_UPDATES_AT_ALL = Optional.of(0);
+  private final Optional<Integer> ONLY_ONE_UPDATE = Optional.of(1);
+  private final Optional<Integer> ONLY_TWO_UPDATES = Optional.of(2);
+  private final Optional<Integer> MAX_PATCH_SETS = Optional.empty();
+
+  private FakeChainedReceiveCommands fakeChainedReceiveCommands;
+
+  @Override
+  public void setUpTestEnvironment() throws Exception {
+    super.setUpTestEnvironment();
+    fakeChainedReceiveCommands = new FakeChainedReceiveCommands(repo);
+  }
+
+  @Test
+  public void throwExceptionWhenExceedingMaxUpdatesLimit() throws Exception {
+    try (OpenRepo openRepo = openRepo()) {
+      Change c = newChange();
+      ChangeUpdate update = newUpdate(c, changeOwner);
+      update.setStatus(Change.Status.NEW);
+
+      ListMultimap<String, ChangeUpdate> changeUpdates =
+          new ImmutableListMultimap.Builder<String, ChangeUpdate>().put("one", update).build();
+
+      assertThrows(
+          LimitExceededException.class,
+          () -> openRepo.addUpdates(changeUpdates, NO_UPDATES_AT_ALL, MAX_PATCH_SETS));
+    }
+  }
+
+  @Test
+  public void allowExceedingLimitWhenAttentionSetUpdateOnly() throws Exception {
+    try (OpenRepo openRepo = openRepo()) {
+      Change c = newChange();
+      ChangeUpdate update = newUpdate(c, changeOwner);
+      update.setStatus(Change.Status.NEW);
+
+      addToAttentionSet(update);
+
+      ListMultimap<String, ChangeUpdate> changeUpdates =
+          new ImmutableListMultimap.Builder<String, ChangeUpdate>().put("one", update).build();
+
+      openRepo.addUpdates(changeUpdates, NO_UPDATES_AT_ALL, MAX_PATCH_SETS);
+
+      assertThat(fakeChainedReceiveCommands.commands.size()).isEqualTo(1);
+    }
+  }
+
+  @Test
+  public void allowExceedingLimitWhenChangeIsSubmitted() throws Exception {
+    try (OpenRepo openRepo = openRepo()) {
+      Change c = newChange();
+      ChangeUpdate update = newUpdate(c, changeOwner);
+      update.merge(
+          new SubmissionId(c),
+          ImmutableList.of(
+              submitRecord(
+                  "NOT_READY",
+                  null,
+                  submitLabel("Verified", "OK", changeOwner.getAccountId()),
+                  submitLabel("Alternative-Code-Review", "NEED", null))));
+
+      ListMultimap<String, ChangeUpdate> changeUpdates =
+          new ImmutableListMultimap.Builder<String, ChangeUpdate>().put("one", update).build();
+
+      openRepo.addUpdates(changeUpdates, NO_UPDATES_AT_ALL, MAX_PATCH_SETS);
+
+      assertThat(fakeChainedReceiveCommands.commands.size()).isEqualTo(1);
+    }
+  }
+
+  @Test
+  public void allowExceedingLimitWhenChangeIsAbandoned() throws Exception {
+    try (OpenRepo openRepo = openRepo()) {
+      Change c = newChange();
+      ChangeUpdate update = newUpdate(c, changeOwner);
+      update.setStatus(Change.Status.ABANDONED);
+
+      ListMultimap<String, ChangeUpdate> changeUpdates =
+          new ImmutableListMultimap.Builder<String, ChangeUpdate>().put("one", update).build();
+
+      openRepo.addUpdates(changeUpdates, NO_UPDATES_AT_ALL, MAX_PATCH_SETS);
+
+      assertThat(fakeChainedReceiveCommands.commands.size()).isEqualTo(1);
+    }
+  }
+
+  @Test
+  public void attentionSetUpdateShouldNotContributeToOperationsCount() throws Exception {
+    try (OpenRepo openRepo = openRepo()) {
+      Change c1 = newChange();
+
+      ChangeUpdate update1 = newUpdateForNewChange(c1, changeOwner);
+      addToAttentionSet(update1);
+
+      ChangeUpdate update2 = newUpdateForNewChange(c1, changeOwner);
+      update2.setStatus(Change.Status.NEW);
+
+      ListMultimap<String, ChangeUpdate> changeUpdates =
+          new ImmutableListMultimap.Builder<String, ChangeUpdate>().put("two", update2).build();
+
+      openRepo.addUpdates(changeUpdates, ONLY_TWO_UPDATES, MAX_PATCH_SETS);
+
+      assertThat(fakeChainedReceiveCommands.commands.size()).isEqualTo(1);
+    }
+  }
+
+  @Test
+  public void attentionSetAndReviewerUpdateShouldContributeToOperationsCount() throws Exception {
+    try (OpenRepo openRepo = openRepo()) {
+      Change c1 = newChange();
+
+      ChangeUpdate update1 = newUpdateForNewChange(c1, changeOwner);
+      addToAttentionSet(update1);
+      update1.putReviewer(otherUserId, ReviewerStateInternal.REVIEWER);
+
+      ChangeUpdate update2 = newUpdateForNewChange(c1, changeOwner);
+      update2.setStatus(Change.Status.NEW);
+
+      ListMultimap<String, ChangeUpdate> changeUpdates =
+          new ImmutableListMultimap.Builder<String, ChangeUpdate>().put("two", update2).build();
+
+      assertThrows(
+          LimitExceededException.class,
+          () -> openRepo.addUpdates(changeUpdates, NO_UPDATES_AT_ALL, MAX_PATCH_SETS));
+    }
+  }
+
+  @Test
+  public void attentionSetAndReviewerByEmailUpdateShouldContributeToOperationsCount()
+      throws Exception {
+    try (OpenRepo openRepo = openRepo()) {
+      Change c1 = newChange();
+
+      ChangeUpdate update1 = newUpdateForNewChange(c1, changeOwner);
+      addToAttentionSet(update1);
+      update1.putReviewerByEmail(
+          Address.create("anyEmail@mail.com"), ReviewerStateInternal.REVIEWER);
+
+      ChangeUpdate update2 = newUpdateForNewChange(c1, changeOwner);
+      update2.setStatus(Change.Status.NEW);
+
+      ListMultimap<String, ChangeUpdate> changeUpdates =
+          new ImmutableListMultimap.Builder<String, ChangeUpdate>().put("two", update2).build();
+
+      assertThrows(
+          LimitExceededException.class,
+          () -> openRepo.addUpdates(changeUpdates, NO_UPDATES_AT_ALL, MAX_PATCH_SETS));
+    }
+  }
+
+  @Test
+  public void attentionSetAndWIPUpdateToTrueShouldContributeToOperationsCount() throws Exception {
+    try (OpenRepo openRepo = openRepo()) {
+      Change c1 = newChange();
+
+      ChangeUpdate update1 = newUpdateForNewChange(c1, changeOwner);
+      addToAttentionSet(update1);
+      update1.setWorkInProgress(true);
+
+      ChangeUpdate update2 = newUpdateForNewChange(c1, changeOwner);
+      update2.setStatus(Change.Status.NEW);
+
+      ListMultimap<String, ChangeUpdate> changeUpdates =
+          new ImmutableListMultimap.Builder<String, ChangeUpdate>().put("two", update2).build();
+
+      assertThrows(
+          LimitExceededException.class,
+          () -> openRepo.addUpdates(changeUpdates, NO_UPDATES_AT_ALL, MAX_PATCH_SETS));
+    }
+  }
+
+  @Test
+  public void attentionSetAndWIPUpdateToFalseShouldContributeToOperationsCount() throws Exception {
+    try (OpenRepo openRepo = openRepo()) {
+      Change c1 = newChange();
+
+      ChangeUpdate update1 = newUpdateForNewChange(c1, changeOwner);
+      addToAttentionSet(update1);
+      update1.setWorkInProgress(false);
+
+      ChangeUpdate update2 = newUpdateForNewChange(c1, changeOwner);
+      update2.setStatus(Change.Status.NEW);
+
+      ListMultimap<String, ChangeUpdate> changeUpdates =
+          new ImmutableListMultimap.Builder<String, ChangeUpdate>().put("two", update2).build();
+
+      assertThrows(
+          LimitExceededException.class,
+          () -> openRepo.addUpdates(changeUpdates, NO_UPDATES_AT_ALL, MAX_PATCH_SETS));
+    }
+  }
+
+  @Test
+  public void normalChangeShouldContributeToOperationsCount() throws Exception {
+    try (OpenRepo openRepo = openRepo()) {
+      Change c1 = newChange();
+
+      ChangeUpdate update2 = newUpdateForNewChange(c1, changeOwner);
+      update2.setStatus(Change.Status.NEW);
+
+      ListMultimap<String, ChangeUpdate> changeUpdates =
+          new ImmutableListMultimap.Builder<String, ChangeUpdate>().put("two", update2).build();
+
+      assertThrows(
+          LimitExceededException.class,
+          () -> openRepo.addUpdates(changeUpdates, ONLY_ONE_UPDATE, MAX_PATCH_SETS));
+    }
+  }
+
+  private void addToAttentionSet(ChangeUpdate update) {
+    AttentionSetUpdate attentionSetUpdate =
+        AttentionSetUpdate.createForWrite(
+            otherUser.getAccountId(), AttentionSetUpdate.Operation.ADD, "test");
+    update.addToPlannedAttentionSetUpdates(ImmutableSet.of(attentionSetUpdate));
+  }
+
+  private static class FakeChainedReceiveCommands extends ChainedReceiveCommands {
+    Map<String, ReceiveCommand> commands = new HashMap<>();
+
+    public FakeChainedReceiveCommands(Repository repo) {
+      super(repo);
+    }
+
+    @Override
+    public void add(ReceiveCommand cmd) {
+      commands.put(cmd.getRefName(), cmd);
+    }
+  }
+
+  private OpenRepo openRepo() {
+    return new OpenRepo(repo, rw, null, fakeChainedReceiveCommands, false);
+  }
+}
diff --git a/javatests/com/google/gerrit/server/patch/DiffOperationsTest.java b/javatests/com/google/gerrit/server/patch/DiffOperationsTest.java
index 5bf5154..aa313e3 100644
--- a/javatests/com/google/gerrit/server/patch/DiffOperationsTest.java
+++ b/javatests/com/google/gerrit/server/patch/DiffOperationsTest.java
@@ -72,7 +72,7 @@
 
     FileDiffOutput diffOutput =
         diffOperations.getModifiedFileAgainstParent(
-            testProjectName, newCommitId, /* parentNum=*/ null, fileName2, /* whitespace=*/ null);
+            testProjectName, newCommitId, /* parentNum=*/ 0, fileName2, /* whitespace=*/ null);
 
     assertThat(diffOutput.oldCommitId()).isEqualTo(oldCommitId);
     assertThat(diffOutput.newCommitId()).isEqualTo(newCommitId);
diff --git a/javatests/com/google/gerrit/server/project/ProjectConfigTest.java b/javatests/com/google/gerrit/server/project/ProjectConfigTest.java
index 9dad9ae..9130d3e 100644
--- a/javatests/com/google/gerrit/server/project/ProjectConfigTest.java
+++ b/javatests/com/google/gerrit/server/project/ProjectConfigTest.java
@@ -209,15 +209,15 @@
             .add("groups", group(developers))
             .add(
                 "project.config",
-                "[submitRequirement \"Code-review\"]\n"
+                "[submit-requirement \"Code-review\"]\n"
                     + "  description =  At least one Code Review +2\n"
-                    + "  applicabilityExpression = branch(refs/heads/master)\n"
-                    + "  blockingExpression = label(code-review, +2)\n"
-                    + "[submitRequirement \"api-review\"]\n"
+                    + "  applicableIf =branch(refs/heads/master)\n"
+                    + "  submittableIf =  label(code-review, +2)\n"
+                    + "[submit-requirement \"api-review\"]\n"
                     + "  description =  Additional review required for API modifications\n"
-                    + "  applicabilityExpression = commit_filepath_contains(\\\"/api/.*\\\")\n"
-                    + "  blockingExpression = label(api-review, +2)\n"
-                    + "  overrideExpression = label(build-cop-override, +1)\n"
+                    + "  applicableIf =commit_filepath_contains(\\\"/api/.*\\\")\n"
+                    + "  submittableIf =  label(api-review, +2)\n"
+                    + "  overrideIf =  label(build-cop-override, +1)\n"
                     + "  canOverrideInChildProjects = true\n")
             .create();
 
@@ -231,7 +231,8 @@
                 .setDescription(Optional.of("At least one Code Review +2"))
                 .setApplicabilityExpression(
                     SubmitRequirementExpression.of("branch(refs/heads/master)"))
-                .setBlockingExpression(SubmitRequirementExpression.create("label(code-review, +2)"))
+                .setSubmittabilityExpression(
+                    SubmitRequirementExpression.create("label(code-review, +2)"))
                 .setOverrideExpression(Optional.empty())
                 .setAllowOverrideInChildProjects(false)
                 .build(),
@@ -241,7 +242,8 @@
                 .setDescription(Optional.of("Additional review required for API modifications"))
                 .setApplicabilityExpression(
                     SubmitRequirementExpression.of("commit_filepath_contains(\"/api/.*\")"))
-                .setBlockingExpression(SubmitRequirementExpression.create("label(api-review, +2)"))
+                .setSubmittabilityExpression(
+                    SubmitRequirementExpression.create("label(api-review, +2)"))
                 .setOverrideExpression(
                     SubmitRequirementExpression.of("label(build-cop-override, +1)"))
                 .setAllowOverrideInChildProjects(true)
@@ -255,8 +257,8 @@
             .add("groups", group(developers))
             .add(
                 "project.config",
-                "[submitRequirement \"code-review\"]\n"
-                    + "  blockingExpression = label(code-review, +2)\n")
+                "[submit-requirement \"code-review\"]\n"
+                    + "  submittableIf =  label(code-review, +2)\n")
             .create();
 
     ProjectConfig cfg = read(rev);
@@ -266,7 +268,8 @@
             "code-review",
             SubmitRequirement.builder()
                 .setName("code-review")
-                .setBlockingExpression(SubmitRequirementExpression.create("label(code-review, +2)"))
+                .setSubmittabilityExpression(
+                    SubmitRequirementExpression.create("label(code-review, +2)"))
                 .setAllowOverrideInChildProjects(false)
                 .build());
   }
@@ -278,12 +281,12 @@
             .add("groups", group(developers))
             .add(
                 "project.config",
-                "[submitRequirement \"code-review\"]\n"
+                "[submit-requirement \"code-review\"]\n"
                     + "  description = At least one Code Review +2\n"
-                    + "  blockingExpression = label(code-review, +2)\n"
-                    + "[submitRequirement \"Code-Review\"]\n"
+                    + "  submittableIf =  label(code-review, +2)\n"
+                    + "[submit-requirement \"Code-Review\"]\n"
                     + "  description = Another code review label\n"
-                    + "  blockingExpression = label(code-review, +2)\n"
+                    + "  submittableIf =  label(code-review, +2)\n"
                     + "  canOverrideInChildProjects = true\n")
             .create();
 
@@ -295,7 +298,8 @@
             SubmitRequirement.builder()
                 .setName("code-review")
                 .setDescription(Optional.of("At least one Code Review +2"))
-                .setBlockingExpression(SubmitRequirementExpression.create("label(code-review, +2)"))
+                .setSubmittabilityExpression(
+                    SubmitRequirementExpression.create("label(code-review, +2)"))
                 .setAllowOverrideInChildProjects(false)
                 .build());
     assertThat(cfg.getValidationErrors()).hasSize(1);
@@ -307,14 +311,14 @@
   }
 
   @Test
-  public void readSubmitRequirementNoBlockingExpression() throws Exception {
+  public void readSubmitRequirementNoSubmittabilityExpression() throws Exception {
     RevCommit rev =
         tr.commit()
             .add("groups", group(developers))
             .add(
                 "project.config",
-                "[submitRequirement \"code-review\"]\n"
-                    + "  applicabilityExpression = label(code-review, +2)\n")
+                "[submit-requirement \"code-review\"]\n"
+                    + "  applicableIf =label(code-review, +2)\n")
             .create();
 
     ProjectConfig cfg = read(rev);
@@ -323,7 +327,7 @@
     assertThat(cfg.getValidationErrors()).hasSize(1);
     assertThat(Iterables.getOnlyElement(cfg.getValidationErrors()).getMessage())
         .isEqualTo(
-            "project.config: Submit requirement \"code-review\" does not define a blocking expression."
+            "project.config: Submit requirement \"code-review\" does not define a submittability expression."
                 + " Skipping this requirement.");
   }
 
@@ -939,10 +943,10 @@
         tr.commit()
             .add(
                 "project.config",
-                "[submitRequirement \"code-review\"]\n"
+                "[submit-requirement \"code-review\"]\n"
                     + "  description =  At least one Code Review +2\n"
-                    + "  applicabilityExpression = branch(refs/heads/master)\n"
-                    + "  blockingExpression = label(code-review, +2)\n"
+                    + "  applicableIf =branch(refs/heads/master)\n"
+                    + "  submittableIf =  label(code-review, +2)\n"
                     + "[notify \"name\"]\n"
                     + "  email = example@example.com\n")
             .create();
diff --git a/javatests/com/google/gerrit/server/query/account/AbstractQueryAccountsTest.java b/javatests/com/google/gerrit/server/query/account/AbstractQueryAccountsTest.java
index f6b3317..b7be40b 100644
--- a/javatests/com/google/gerrit/server/query/account/AbstractQueryAccountsTest.java
+++ b/javatests/com/google/gerrit/server/query/account/AbstractQueryAccountsTest.java
@@ -607,7 +607,6 @@
   @Test
   public void reindex() throws Exception {
     AccountInfo user1 = newAccountWithFullName("tester", "Test Usre");
-
     // update account without reindex so that account index is stale
     Account.Id accountId = Account.id(user1._accountId);
     String newName = "Test User";
@@ -621,10 +620,11 @@
           .setAccountDelta(AccountDelta.builder().setFullName(newName).build())
           .commit(md);
     }
-
-    assertQuery("name:" + quote(user1.name), user1);
-    assertQuery("name:" + quote(newName));
-
+    // Querying for the account here will not result in a stale document because
+    // we load AccountStates from the cache after reading documents from the index
+    // which means we always read fresh data when matching.
+    //
+    // Reindex document
     gApi.accounts().id(user1.username).index();
     assertQuery("name:" + quote(user1.name));
     assertQuery("name:" + quote(newName), user1);
diff --git a/javatests/com/google/gerrit/server/query/account/FakeQueryAccountsTest.java b/javatests/com/google/gerrit/server/query/account/FakeQueryAccountsTest.java
index b742bd8..31d256e 100644
--- a/javatests/com/google/gerrit/server/query/account/FakeQueryAccountsTest.java
+++ b/javatests/com/google/gerrit/server/query/account/FakeQueryAccountsTest.java
@@ -14,10 +14,6 @@
 
 package com.google.gerrit.server.query.account;
 
-import static org.junit.Assume.assumeFalse;
-
-import com.google.gerrit.entities.Account;
-import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.index.account.AccountSchemaDefinitions;
 import com.google.gerrit.testing.ConfigSuite;
 import com.google.gerrit.testing.InMemoryModule;
@@ -25,7 +21,6 @@
 import com.google.gerrit.testing.IndexVersions;
 import com.google.inject.Guice;
 import com.google.inject.Injector;
-import java.sql.Timestamp;
 import java.util.List;
 import java.util.Map;
 import org.eclipse.jgit.lib.Config;
@@ -52,16 +47,4 @@
     fakeConfig.setString("index", null, "type", "fake");
     return Guice.createInjector(new InMemoryModule(fakeConfig));
   }
-
-  @Override
-  protected void validateAssumptions() {
-    // TODO(hiesel): Account predicates are always matching (they return true on match), so we need
-    // to skip all tests here. We are doing this to document existing behavior. We want to remove
-    // this assume statement and make group predicates matchable.
-    assumeFalse(
-        AccountPredicates.equalsName("test")
-            .asMatchable()
-            .match(
-                AccountState.forAccount(Account.builder(Account.id(1), new Timestamp(0)).build())));
-  }
 }
diff --git a/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
index 6c8026b..1f29f45 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());
@@ -992,6 +1007,7 @@
     changes.put(-1, reviewMinus1Change);
     changes.put(-2, reviewMinus2Change);
 
+    assertQuery("label:Code-Review=MIN", reviewMinus2Change);
     assertQuery("label:Code-Review=-2", reviewMinus2Change);
     assertQuery("label:Code-Review-2", reviewMinus2Change);
     assertQuery("label:Code-Review=-1", reviewMinus1Change);
@@ -1003,6 +1019,13 @@
     assertQuery("label:Code-Review=+2", reviewPlus2Change);
     assertQuery("label:Code-Review=2", reviewPlus2Change);
     assertQuery("label:Code-Review+2", reviewPlus2Change);
+    assertQuery("label:Code-Review=MAX", reviewPlus2Change);
+    assertQuery(
+        "label:Code-Review=ANY",
+        reviewPlus2Change,
+        reviewPlus1Change,
+        reviewMinus1Change,
+        reviewMinus2Change);
 
     assertQuery("label:Code-Review>-3", codeReviewInRange(changes, -2, 2));
     assertQuery("label:Code-Review>=-2", codeReviewInRange(changes, -2, 2));
@@ -1496,6 +1519,7 @@
         insert(repo, newChangeWithFiles(repo, "documentation/training/slides/README.txt"));
     Change change4 = insert(repo, newChangeWithFiles(repo, "a.txt"));
     Change change5 = insert(repo, newChangeWithFiles(repo, "a/b/c/d/e/foo.txt"));
+    Change change6 = insert(repo, newChangeWithFiles(repo, "all/caps/DIRECTORY/file.txt"));
 
     // matching by directory prefix works
     assertQuery("directory:src", change2, change1);
@@ -1511,6 +1535,7 @@
 
     // case doesn't matter
     assertQuery("directory:Documentation/TrAiNiNg/SLIDES", change3);
+    assertQuery("directory:all/caps/directory", change6);
 
     // leading and trailing '/' doesn't matter
     assertQuery("directory:/documentation/training/slides", change3);
@@ -1522,8 +1547,8 @@
     assertQuery("directory:documentation/training/slides/README.txt");
 
     // root directory matches all changes
-    assertQuery("directory:/", change5, change4, change3, change2, change1);
-    assertQuery("directory:\"\"", change5, change4, change3, change2, change1);
+    assertQuery("directory:/", change6, change5, change4, change3, change2, change1);
+    assertQuery("directory:\"\"", change6, change5, change4, change3, change2, change1);
     assertFailingQuery("directory:");
 
     // matching single directory segments works
diff --git a/javatests/com/google/gerrit/server/query/change/FakeQueryChangesTest.java b/javatests/com/google/gerrit/server/query/change/FakeQueryChangesTest.java
index 385d4b2..1e23420 100644
--- a/javatests/com/google/gerrit/server/query/change/FakeQueryChangesTest.java
+++ b/javatests/com/google/gerrit/server/query/change/FakeQueryChangesTest.java
@@ -18,8 +18,6 @@
 import com.google.inject.Guice;
 import com.google.inject.Injector;
 import org.eclipse.jgit.lib.Config;
-import org.junit.Ignore;
-import org.junit.Test;
 
 /**
  * Test against {@link com.google.gerrit.index.testing.AbstractFakeIndex}. This test might seem
@@ -34,156 +32,4 @@
     fakeConfig.setString("index", null, "type", "fake");
     return Guice.createInjector(new InMemoryModule(fakeConfig));
   }
-
-  @Ignore
-  @Test
-  @Override
-  public void byDefault() throws Exception {
-    // TODO(hiesel): Fix bug in predicate and remove @Ignore.
-    super.byDefault();
-  }
-
-  @Ignore
-  @Test
-  @Override
-  public void byMergedBefore() throws Exception {
-    // TODO(hiesel): Used predicate is not a matchable. Fix.
-    super.byMergedBefore();
-  }
-
-  @Ignore
-  @Test
-  @Override
-  public void reviewerAndCcByEmail() throws Exception {
-    // TODO(hiesel): Fix bug in predicate and remove @Ignore.
-    super.reviewerAndCcByEmail();
-  }
-
-  @Ignore
-  @Test
-  @Override
-  public void byMessageExact() throws Exception {
-    // TODO(hiesel): Existing #match function uses the index causing a StackOverflowError. Fix.
-    super.byMessageExact();
-  }
-
-  @Ignore
-  @Test
-  @Override
-  public void fullTextWithNumbers() throws Exception {
-    // TODO(hiesel): Existing #match function uses the index causing a StackOverflowError. Fix.
-    super.fullTextWithNumbers();
-  }
-
-  @Ignore
-  @Test
-  @Override
-  public void byTriplet() throws Exception {
-    // TODO(hiesel): Fix bug in predicate and remove @Ignore.
-    super.byTriplet();
-  }
-
-  @Ignore
-  @Test
-  @Override
-  public void byAge() throws Exception {
-    // TODO(hiesel): Existing #match function uses the index causing a StackOverflowError. Fix.
-    super.byAge();
-  }
-
-  @Ignore
-  @Test
-  @Override
-  public void byMessageSubstring() throws Exception {
-    // TODO(hiesel): Existing #match function uses the index causing a StackOverflowError. Fix.
-    super.byMessageSubstring();
-  }
-
-  @Ignore
-  @Test
-  @Override
-  public void byBeforeUntil() throws Exception {
-    // TODO(hiesel): Used predicate is not a matchable. Fix.
-    super.byBeforeUntil();
-  }
-
-  @Ignore
-  @Test
-  @Override
-  public void byTopic() throws Exception {
-    // TODO(hiesel): Existing #match function uses the index causing a StackOverflowError. Fix.
-    super.byTopic();
-  }
-
-  @Ignore
-  @Test
-  @Override
-  public void userQuery() throws Exception {
-    // TODO(hiesel): Account name predicate is always returning true in #match. Fix.
-    super.userQuery();
-  }
-
-  @Ignore
-  @Test
-  @Override
-  public void visible() throws Exception {
-    // TODO(hiesel): Account name predicate is always returning true in #match. Fix.
-    super.visible();
-  }
-
-  @Ignore
-  @Test
-  @Override
-  public void userDestination() throws Exception {
-    // TODO(hiesel): Account name predicate is always returning true in #match. Fix.
-    super.userDestination();
-  }
-
-  @Ignore
-  @Test
-  @Override
-  public void byAfterSince() throws Exception {
-    // TODO(hiesel): Used predicate is not a matchable. Fix.
-    super.byAfterSince();
-  }
-
-  @Ignore
-  @Test
-  @Override
-  public void byMessageMixedCase() throws Exception {
-    // TODO(hiesel): Used predicate is not a matchable. Fix.
-    super.byMessageMixedCase();
-  }
-
-  @Ignore
-  @Test
-  @Override
-  public void byCommit() throws Exception {
-    // TODO(hiesel): Existing #match function uses the index causing a StackOverflowError. Fix.
-    super.byCommit();
-  }
-
-  @Ignore
-  @Test
-  @Override
-  public void byComment() throws Exception {
-    // TODO(hiesel): Existing #match function uses the index causing a StackOverflowError. Fix.
-    super.byComment();
-  }
-
-  @Ignore
-  @Test
-  @Override
-  public void byMergedAfter() throws Exception {
-    // TODO(hiesel): Used predicate is not a matchable. Fix.
-    super.byMergedAfter();
-  }
-
-  @Ignore
-  @Test
-  @Override
-  public void byOwnerInvalidQuery() throws Exception {
-    // TODO(hiesel): Account name predicate is always returning true in #match. Fix.
-    super.byMergedAfter();
-  }
 }
diff --git a/javatests/com/google/gerrit/server/query/group/FakeQueryGroupsTest.java b/javatests/com/google/gerrit/server/query/group/FakeQueryGroupsTest.java
index 8bc1c30..e4f228a 100644
--- a/javatests/com/google/gerrit/server/query/group/FakeQueryGroupsTest.java
+++ b/javatests/com/google/gerrit/server/query/group/FakeQueryGroupsTest.java
@@ -14,8 +14,6 @@
 
 package com.google.gerrit.server.query.group;
 
-import static org.junit.Assume.assumeTrue;
-
 import com.google.gerrit.server.index.group.GroupSchemaDefinitions;
 import com.google.gerrit.testing.ConfigSuite;
 import com.google.gerrit.testing.InMemoryModule;
@@ -48,12 +46,4 @@
     fakeConfig.setString("index", null, "type", "fake");
     return Guice.createInjector(new InMemoryModule(fakeConfig));
   }
-
-  @Override
-  protected void validateAssumptions() {
-    // TODO(hiesel): Group predicates are not matchable, so we need to skip all tests here.
-    // We are doing this to document existing behavior. We want to remove this assume statement and
-    // make group predicates matchable.
-    assumeTrue(GroupPredicates.inname("test").isMatchable());
-  }
 }
diff --git a/javatests/com/google/gerrit/server/query/project/FakeQueryProjectsTest.java b/javatests/com/google/gerrit/server/query/project/FakeQueryProjectsTest.java
index 8517ad2..6fc0568 100644
--- a/javatests/com/google/gerrit/server/query/project/FakeQueryProjectsTest.java
+++ b/javatests/com/google/gerrit/server/query/project/FakeQueryProjectsTest.java
@@ -14,8 +14,6 @@
 
 package com.google.gerrit.server.query.project;
 
-import static org.junit.Assume.assumeTrue;
-
 import com.google.gerrit.index.project.ProjectSchemaDefinitions;
 import com.google.gerrit.testing.ConfigSuite;
 import com.google.gerrit.testing.InMemoryModule;
@@ -50,12 +48,4 @@
     fakeConfig.setString("index", null, "type", "fake");
     return Guice.createInjector(new InMemoryModule(fakeConfig));
   }
-
-  @Override
-  protected void validateAssumptions() {
-    // TODO(hiesel): Project predicates are not matchable, so we need to skip all tests here.
-    // We are doing this to document existing behavior. We want to remove this assume statement and
-    // make group predicates matchable.
-    assumeTrue(ProjectPredicates.inname("test").isMatchable());
-  }
 }
diff --git a/javatests/com/google/gerrit/server/restapi/change/CommentPorterTest.java b/javatests/com/google/gerrit/server/restapi/change/CommentPorterTest.java
index ebb2f38..46f9c5a 100644
--- a/javatests/com/google/gerrit/server/restapi/change/CommentPorterTest.java
+++ b/javatests/com/google/gerrit/server/restapi/change/CommentPorterTest.java
@@ -22,6 +22,7 @@
 import static org.mockito.Mockito.when;
 
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSortedMap;
 import com.google.common.truth.Correspondence;
 import com.google.gerrit.entities.Account;
@@ -35,12 +36,8 @@
 import com.google.gerrit.metrics.DisabledMetricMaker;
 import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.patch.ComparisonType;
-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.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.restapi.change.CommentPorter.Metrics;
 import com.google.gerrit.truth.NullAwareCorrespondence;
 import java.sql.Timestamp;
@@ -60,7 +57,7 @@
 
   @Rule public final MockitoRule mockito = MockitoJUnit.rule();
 
-  @Mock private PatchListCache patchListCache;
+  @Mock private DiffOperations diffOperations;
   @Mock private CommentsUtil commentsUtil;
 
   private static final CommentPorter.Metrics metrics = new Metrics(new DisabledMetricMaker());
@@ -76,12 +73,13 @@
     PatchSet patchset2 = createPatchset(PatchSet.id(changeId, 2));
     ChangeNotes changeNotes = mockChangeNotes(project, change, patchset1, patchset2);
 
-    CommentPorter commentPorter = new CommentPorter(patchListCache, commentsUtil, metrics);
+    CommentPorter commentPorter = new CommentPorter(diffOperations, commentsUtil, metrics);
     HumanComment comment = createComment(patchset1.id(), "myFile");
     when(commentsUtil.determineCommitId(any(), any(), anyShort()))
         .thenReturn(Optional.of(dummyObjectId));
-    when(patchListCache.get(any(PatchListKey.class), any(Project.NameKey.class)))
-        .thenThrow(PatchListNotAvailableException.class);
+    when(diffOperations.listModifiedFiles(
+            any(Project.NameKey.class), any(ObjectId.class), any(ObjectId.class)))
+        .thenThrow(DiffNotAvailableException.class);
     ImmutableList<HumanComment> portedComments =
         commentPorter.portComments(
             changeNotes, patchset2, ImmutableList.of(comment), ImmutableList.of());
@@ -98,11 +96,12 @@
     PatchSet patchset2 = createPatchset(PatchSet.id(changeId, 2));
     ChangeNotes changeNotes = mockChangeNotes(project, change, patchset1, patchset2);
 
-    CommentPorter commentPorter = new CommentPorter(patchListCache, commentsUtil, metrics);
+    CommentPorter commentPorter = new CommentPorter(diffOperations, commentsUtil, metrics);
     HumanComment comment = createComment(patchset1.id(), "myFile");
     when(commentsUtil.determineCommitId(any(), any(), anyShort()))
         .thenReturn(Optional.of(dummyObjectId));
-    when(patchListCache.get(any(PatchListKey.class), any(Project.NameKey.class)))
+    when(diffOperations.listModifiedFiles(
+            any(Project.NameKey.class), any(ObjectId.class), any(ObjectId.class)))
         .thenThrow(IllegalStateException.class);
     ImmutableList<HumanComment> portedComments =
         commentPorter.portComments(
@@ -120,7 +119,7 @@
     PatchSet patchset2 = createPatchset(PatchSet.id(changeId, 2));
     ChangeNotes changeNotes = mockChangeNotes(project, change, patchset1, patchset2);
 
-    CommentPorter commentPorter = new CommentPorter(patchListCache, commentsUtil, metrics);
+    CommentPorter commentPorter = new CommentPorter(diffOperations, commentsUtil, metrics);
     HumanComment comment = createComment(patchset1.id(), "myFile");
     when(commentsUtil.determineCommitId(any(), any(), anyShort()))
         .thenThrow(IllegalStateException.class);
@@ -140,11 +139,12 @@
     PatchSet patchset2 = createPatchset(PatchSet.id(changeId, 2));
     ChangeNotes changeNotes = mockChangeNotes(project, change, patchset1, patchset2);
 
-    CommentPorter commentPorter = new CommentPorter(patchListCache, commentsUtil, metrics);
+    CommentPorter commentPorter = new CommentPorter(diffOperations, commentsUtil, metrics);
     HumanComment comment = createComment(patchset1.id(), "myFile");
     when(commentsUtil.determineCommitId(any(), any(), anyShort()))
         .thenReturn(Optional.of(dummyObjectId));
-    when(patchListCache.get(any(PatchListKey.class), any(Project.NameKey.class)))
+    when(diffOperations.listModifiedFiles(
+            any(Project.NameKey.class), any(ObjectId.class), any(ObjectId.class)))
         .thenThrow(IllegalStateException.class);
     ImmutableList<HumanComment> portedComments =
         commentPorter.portComments(
@@ -165,17 +165,17 @@
     PatchSet patchset3 = createPatchset(PatchSet.id(changeId, 3));
     ChangeNotes changeNotes = mockChangeNotes(project, change, patchset1, patchset2, patchset3);
 
-    CommentPorter commentPorter = new CommentPorter(patchListCache, commentsUtil, metrics);
+    CommentPorter commentPorter = new CommentPorter(diffOperations, commentsUtil, metrics);
     // Place the comments on different patchsets to have two different diff requests.
     HumanComment comment1 = createComment(patchset1.id(), "myFile");
     HumanComment comment2 = createComment(patchset2.id(), "myFile");
     when(commentsUtil.determineCommitId(any(), any(), anyShort()))
         .thenReturn(Optional.of(dummyObjectId));
-    PatchList emptyDiff = getEmptyDiff();
     // Throw an exception on the first diff request but return an actual value on the second.
-    when(patchListCache.get(any(PatchListKey.class), any(Project.NameKey.class)))
+    when(diffOperations.listModifiedFiles(
+            any(Project.NameKey.class), any(ObjectId.class), any(ObjectId.class)))
         .thenThrow(IllegalStateException.class)
-        .thenReturn(emptyDiff);
+        .thenReturn(ImmutableMap.of());
     ImmutableList<HumanComment> portedComments =
         commentPorter.portComments(
             changeNotes, patchset3, ImmutableList.of(comment1, comment2), ImmutableList.of());
@@ -195,13 +195,13 @@
     // Leave out patchset 1 (e.g. reserved for draft patchsets in the past).
     ChangeNotes changeNotes = mockChangeNotes(project, change, patchset2);
 
-    CommentPorter commentPorter = new CommentPorter(patchListCache, commentsUtil, metrics);
+    CommentPorter commentPorter = new CommentPorter(diffOperations, commentsUtil, metrics);
     HumanComment comment = createComment(patchset1.id(), "myFile");
     when(commentsUtil.determineCommitId(any(), any(), anyShort()))
         .thenReturn(Optional.of(dummyObjectId));
-    PatchList emptyDiff = getEmptyDiff();
-    when(patchListCache.get(any(PatchListKey.class), any(Project.NameKey.class)))
-        .thenReturn(emptyDiff);
+    when(diffOperations.listModifiedFiles(
+            any(Project.NameKey.class), any(ObjectId.class), any(ObjectId.class)))
+        .thenReturn(ImmutableMap.of());
     ImmutableList<HumanComment> portedComments =
         commentPorter.portComments(
             changeNotes, patchset2, ImmutableList.of(comment), ImmutableList.of());
@@ -260,13 +260,4 @@
   private Correspondence<HumanComment, String> hasFilePath() {
     return NullAwareCorrespondence.transforming(comment -> comment.key.filename, "hasFilePath");
   }
-
-  private PatchList getEmptyDiff() {
-    return new PatchList(
-        dummyObjectId,
-        dummyObjectId,
-        false,
-        ComparisonType.againstOtherPatchSet(),
-        new PatchListEntry[0]);
-  }
 }
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/package.json b/package.json
index a3329a1..151b784 100644
--- a/package.json
+++ b/package.json
@@ -5,7 +5,8 @@
   "dependencies": {
     "@bazel/rollup": "^3.5.0",
     "@bazel/terser": "^3.5.0",
-    "@bazel/typescript": "^3.5.0"
+    "@bazel/typescript": "^3.5.0",
+    "twinkie": "^1.1.2"
   },
   "devDependencies": {
     "@typescript-eslint/eslint-plugin": "^4.22.0",
@@ -34,7 +35,10 @@
     "eslintfix": "npm run safe_bazelisk run polygerrit-ui/app:lint_bin -- -- --fix $(pwd)/polygerrit-ui/app",
     "polylint": "npm run safe_bazelisk test polygerrit-ui/app:polylint_test",
     "test:debug": "npm run compile:local && npm run safe_bazelisk run //polygerrit-ui:karma_bin -- -- start $(pwd)/polygerrit-ui/karma.conf.js --browsers ChromeDev --no-single-run --testFiles",
-    "test:single": "npm run compile:local && npm run safe_bazelisk run //polygerrit-ui:karma_bin -- -- start $(pwd)/polygerrit-ui/karma.conf.js --testFiles"
+    "test:single": "npm run compile:local && npm run safe_bazelisk run //polygerrit-ui:karma_bin -- -- start $(pwd)/polygerrit-ui/karma.conf.js --testFiles",
+    "postinstall": "(git apply --reverse --ignore-whitespace twinkie.patch || true) && git apply --ignore-whitespace twinkie.patch",
+    "polytest": "npm run safe_bazelisk test //polygerrit-ui/app:validate_polymer_templates",
+    "polytest:dev": "rm -rf ./polygerrit-ui/app/tmpl_out && npm run safe_bazelisk build //polygerrit-ui/app:template_test_tar && mkdir ./polygerrit-ui/app/tmpl_out && tar -xf bazel-bin/polygerrit-ui/app/template_test_tar.tar -C ./polygerrit-ui/app/tmpl_out"
   },
   "repository": {
     "type": "git",
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/plugins/plugin-manager b/plugins/plugin-manager
index 00e5794..5b87f63 160000
--- a/plugins/plugin-manager
+++ b/plugins/plugin-manager
@@ -1 +1 @@
-Subproject commit 00e57948f4f112c226028bc5c8d8fe60f770038f
+Subproject commit 5b87f63f3e9c5817bcddf008c0b4005494059368
diff --git a/plugins/replication b/plugins/replication
index 13cefb7..dc9bb2e 160000
--- a/plugins/replication
+++ b/plugins/replication
@@ -1 +1 @@
-Subproject commit 13cefb724df786d254ecbc24261589ab473be267
+Subproject commit dc9bb2e946e4c6c31e8a4665f30eca6d00017523
diff --git a/plugins/reviewnotes b/plugins/reviewnotes
index fb0390a..35e6449 160000
--- a/plugins/reviewnotes
+++ b/plugins/reviewnotes
@@ -1 +1 @@
-Subproject commit fb0390a8b49f0d601e11f8a1ac0658c429727f21
+Subproject commit 35e6449a517691a880c94e7467bc07360f8e6666
diff --git a/polygerrit-ui/README.md b/polygerrit-ui/README.md
index c6e6cd9..0297324 100644
--- a/polygerrit-ui/README.md
+++ b/polygerrit-ui/README.md
@@ -228,6 +228,56 @@
 the "Before launch" section for IntelliJ. This is a temporary problem until
 typescript migration is complete.
 
+## Running Templates Test
+The templates test validates polymer templates. The test convert polymer
+templates into a plain typescript code and then run TS compiler. The test fails
+if TS compiler reports errors; in this case you will see TS errors in
+the log/output. Gerrit-CI automatically runs templates test.
+
+**Note**: Files defined in `ignore_templates_list` (`polygerrit-ui/app/BUILD`)
+are excluded from code generation and checking. If you don't know how to fix
+a problem, you can add a problematic template in the list.
+
+* To run test locally, use npm command:
+``` sh
+npm run polytest
+```
+
+* Often, the output from the previous command is not clear (cryptic TS errors).
+In this case, run the command
+```sh
+npm run polytest:dev
+```
+This command (re)creates the `polygerrit-ui/app/tmpl_out` directory and put
+generated files into it. For each polygerrit .ts file there is a generated file
+in the `tmpl_out` directory. If an original file doesn't contain a polymer
+template, the generated file is empty.
+
+You can open a problematic file in IDE and fix the problem. Ensure, that IDE
+uses `polygerrit-ui/app/tsconfig.json` as a project (usually, this is default).
+
+### Generated file overview
+
+A generated file starts with imports followed by a static content with
+different type definitions. You can skip this part - it doesn't contains
+anything usefule.
+
+After the static content there is a class definition. Example:
+```typescript
+export class GrCreateGroupDialogCheck extends GrCreateGroupDialog {
+  templateCheck() {
+    // Converted template
+    // Each HTML element from the template is wrapped into own block.
+  }
+}
+```
+
+The converted template usually quite straightforward, but in some cases
+additional functions are added. For example, `<element x=[[y.a]]>` converts into
+`el.x = y!.a` if y is a simple type. However, if y has a union type, like - `y:A|B`,
+then the generated code looks like `el.x=__f(y)!.a` (`y!.a` may result in a TS error
+if `a` is defined only in one type of a union). 
+
 ## Style guide
 
 We follow the [Google JavaScript Style Guide](https://google.github.io/styleguide/javascriptguide.xml)
diff --git a/polygerrit-ui/app/.eslintignore b/polygerrit-ui/app/.eslintignore
index 087a049..bb30f23 100644
--- a/polygerrit-ui/app/.eslintignore
+++ b/polygerrit-ui/app/.eslintignore
@@ -2,3 +2,4 @@
 **/rollup.config.js
 node_modules_licenses
 !.eslintrc-bazel.js
+tmpl_out
diff --git a/polygerrit-ui/app/.gitignore b/polygerrit-ui/app/.gitignore
index c235144..6b96e60 100644
--- a/polygerrit-ui/app/.gitignore
+++ b/polygerrit-ui/app/.gitignore
@@ -1,2 +1,3 @@
 /plugins/
 /node_modules/
+/tmpl_out/
diff --git a/polygerrit-ui/app/BUILD b/polygerrit-ui/app/BUILD
index 2f83182..fcf1cf4 100644
--- a/polygerrit-ui/app/BUILD
+++ b/polygerrit-ui/app/BUILD
@@ -1,5 +1,7 @@
 load(":rules.bzl", "compile_ts", "polygerrit_bundle")
 load("//tools/js:eslint.bzl", "eslint")
+load("//tools/js:template_checker.bzl", "transform_polymer_templates")
+load("@build_bazel_rules_nodejs//:index.bzl", "nodejs_binary", "nodejs_test", "npm_package_bin")
 
 package(default_visibility = ["//visibility:public"])
 
@@ -48,22 +50,161 @@
         exclude = [
             "node_modules/**",
             "node_modules_licenses/**",
-            "template_test_srcs/**",
+            "tmpl_out/**",  # This directory is created by template checker in dev-mode
             "rollup.config.js",
         ],
     ),
-    include_tests = True,
+    additional_deps = [
+        "@ui_dev_npm//:node_modules",
+        "tsconfig_bazel.json",
+    ],
     # The same outdir also appears in the following files:
     # wct_test.sh
     # karma.conf.js
     ts_outdir = "_pg_with_tests_out",
+    ts_project = "tsconfig_bazel_test.json",
+)
+
+# Template checker reports problems in the following files. Ignore the files,
+# so template tests pass.
+# TODO: fix problems reported by template checker in these files.
+ignore_templates_list = [
+    "elements/admin/gr-access-section/gr-access-section_html.ts",
+    "elements/admin/gr-admin-view/gr-admin-view_html.ts",
+    "elements/admin/gr-create-change-dialog/gr-create-change-dialog_html.ts",
+    "elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog_html.ts",
+    "elements/admin/gr-create-repo-dialog/gr-create-repo-dialog_html.ts",
+    "elements/admin/gr-group-members/gr-group-members_html.ts",
+    "elements/admin/gr-group/gr-group_html.ts",
+    "elements/admin/gr-permission/gr-permission_html.ts",
+    "elements/admin/gr-plugin-list/gr-plugin-list_html.ts",
+    "elements/admin/gr-repo-access/gr-repo-access_html.ts",
+    "elements/admin/gr-repo-commands/gr-repo-commands_html.ts",
+    "elements/admin/gr-repo-plugin-config/gr-repo-plugin-config_html.ts",
+    "elements/admin/gr-repo/gr-repo_html.ts",
+    "elements/admin/gr-rule-editor/gr-rule-editor_html.ts",
+    "elements/change-list/gr-change-list-item/gr-change-list-item_html.ts",
+    "elements/change-list/gr-change-list-view/gr-change-list-view_html.ts",
+    "elements/change-list/gr-change-list/gr-change-list_html.ts",
+    "elements/change-list/gr-dashboard-view/gr-dashboard-view_html.ts",
+    "elements/change-list/gr-user-header/gr-user-header_html.ts",
+    "elements/change/gr-change-actions/gr-change-actions_html.ts",
+    "elements/change/gr-change-metadata/gr-change-metadata_html.ts",
+    "elements/change/gr-change-requirements/gr-change-requirements_html.ts",
+    "elements/change/gr-change-view/gr-change-view_html.ts",
+    "elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog_html.ts",
+    "elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_html.ts",
+    "elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog_html.ts",
+    "elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_html.ts",
+    "elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_html.ts",
+    "elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog_html.ts",
+    "elements/change/gr-file-list-header/gr-file-list-header_html.ts",
+    "elements/change/gr-file-list/gr-file-list_html.ts",
+    "elements/change/gr-label-score-row/gr-label-score-row_html.ts",
+    "elements/change/gr-message/gr-message_html.ts",
+    "elements/change/gr-messages-list/gr-messages-list_html.ts",
+    "elements/change/gr-reply-dialog/gr-reply-dialog_html.ts",
+    "elements/change/gr-reviewer-list/gr-reviewer-list_html.ts",
+    "elements/change/gr-thread-list/gr-thread-list_html.ts",
+    "elements/checks/gr-hovercard-run_html.ts",
+    "elements/core/gr-main-header/gr-main-header_html.ts",
+    "elements/core/gr-search-bar/gr-search-bar_html.ts",
+    "elements/core/gr-smart-search/gr-smart-search_html.ts",
+    "elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog_html.ts",
+    "elements/diff/gr-diff-builder/gr-diff-builder-element_html.ts",
+    "elements/diff/gr-diff-host/gr-diff-host_html.ts",
+    "elements/diff/gr-diff-mode-selector/gr-diff-mode-selector_html.ts",
+    "elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog_html.ts",
+    "elements/diff/gr-diff-view/gr-diff-view_html.ts",
+    "elements/diff/gr-diff/gr-diff_html.ts",
+    "elements/diff/gr-patch-range-select/gr-patch-range-select_html.ts",
+    "elements/gr-app-element_html.ts",
+    "elements/settings/gr-settings-view/gr-settings-view_html.ts",
+    "elements/settings/gr-watched-projects-editor/gr-watched-projects-editor_html.ts",
+    "elements/shared/gr-account-list/gr-account-list_html.ts",
+    "elements/shared/gr-autocomplete/gr-autocomplete_html.ts",
+    "elements/shared/gr-change-status/gr-change-status_html.ts",
+    "elements/shared/gr-comment-thread/gr-comment-thread_html.ts",
+    "elements/shared/gr-comment/gr-comment_html.ts",
+    "elements/shared/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog_html.ts",
+    "elements/shared/gr-copy-clipboard/gr-copy-clipboard_html.ts",
+    "elements/shared/gr-dialog/gr-dialog_html.ts",
+    "elements/shared/gr-diff-preferences/gr-diff-preferences_html.ts",
+    "elements/shared/gr-download-commands/gr-download-commands_html.ts",
+    "elements/shared/gr-dropdown-list/gr-dropdown-list_html.ts",
+    "elements/shared/gr-dropdown/gr-dropdown_html.ts",
+    "elements/shared/gr-editable-content/gr-editable-content_html.ts",
+    "elements/shared/gr-hovercard-account/gr-hovercard-account_html.ts",
+    "elements/shared/gr-label-info/gr-label-info_html.ts",
+    "elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete_html.ts",
+    "elements/shared/gr-list-view/gr-list-view_html.ts",
+    "elements/shared/gr-repo-branch-picker/gr-repo-branch-picker_html.ts",
+    "elements/shared/gr-textarea/gr-textarea_html.ts",
+]
+
+# Transform templates into a .ts files.
+templates_srcs = transform_polymer_templates(
+    name = "template_test",
+    srcs = glob(
+        [src_dir + "/**/*" + ext for src_dir in src_dirs for ext in [
+            ".ts",
+        ]],
+        exclude = [
+            "**/*_test.ts",
+        ] + ignore_templates_list,
+    ),
+    out_tsconfig = "tsconfig_template_test.json",
+    tsconfig = "tsconfig_bazel.json",
+    deps = [
+        "tsconfig.json",
+        "tsconfig_bazel.json",
+        "@ui_npm//:node_modules",
+    ],
+)
+
+# Compile transformed templates together with the polygerrit source. If
+# templates don't have problem, then the compilation ends without error.
+# Otherwise, the typescript compiler reports the error.
+# Note, that the compile_ts macro creates build rules. If the build succeed,
+# the macro creates the file compile_template_test.success. The
+# 'validate_polymer_templates' rule tests existence of the file.
+compile_ts(
+    name = "compile_template_test",
+    srcs = templates_srcs + glob(
+        [src_dir + "/**/*" + ext for src_dir in src_dirs for ext in [
+            ".ts",
+        ]],
+        exclude = [
+            "**/*_test.ts",
+        ] + ignore_templates_list,
+    ),
+    additional_deps = [
+        "tsconfig_bazel.json",
+    ],
+    emitJS = False,
+    # Should not run sandboxed.
+    tags = [
+        "local",
+        "manual",
+    ],
+    ts_outdir = "_pg_template_test_out",
+    ts_project = "tsconfig_template_test.json",
+)
+
+# This rule allows to run polymer template checker with bazel test command.
+# For details - see compile_template_test rule.
+sh_test(
+    name = "validate_polymer_templates",
+    srcs = [":empty_test.sh"],
+    data = ["compile_template_test.success"],
 )
 
 polygerrit_bundle(
     name = "polygerrit_ui",
     srcs = compiled_pg_srcs,
     outs = ["polygerrit_ui.zip"],
-    entry_point = "_pg_ts_out/elements/gr-app.js",
+    app_name = "gr-app",
+    entry_point = "_pg_ts_out/elements/gr-app-entry-point.js",
 )
 
 filegroup(
diff --git a/polygerrit-ui/app/api/annotation.ts b/polygerrit-ui/app/api/annotation.ts
index ad0846d..5922e5e 100644
--- a/polygerrit-ui/app/api/annotation.ts
+++ b/polygerrit-ui/app/api/annotation.ts
@@ -15,6 +15,7 @@
  * limitations under the License.
  */
 import {CoverageRange, Side} from './diff';
+import {ChangeInfo} from './rest-api';
 
 /**
  * This is the callback object that Gerrit calls once for each diff. Gerrit
@@ -26,15 +27,8 @@
   path: string,
   basePatchNum?: number,
   patchNum?: number,
-  /**
-   * This is a ChangeInfo object as defined here:
-   * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#change-info
-   * At the moment we neither want to repeat it nor add a dependency on it here.
-   * TODO: Create a dedicated smaller object for exposing a change in the plugin
-   * API. Or allow the plugin API to depend on the entire rest API.
-   */
-  change?: unknown
-) => Promise<Array<CoverageRange>>;
+  change?: ChangeInfo
+) => Promise<Array<CoverageRange> | undefined>;
 
 export declare interface AnnotationPluginApi {
   /**
diff --git a/polygerrit-ui/app/api/checks.ts b/polygerrit-ui/app/api/checks.ts
index 454b3d5..b64cd91 100644
--- a/polygerrit-ui/app/api/checks.ts
+++ b/polygerrit-ui/app/api/checks.ts
@@ -14,6 +14,8 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
+import {CommentRange} from './core';
+import {ChangeInfo} from './rest-api';
 
 export declare interface ChecksPluginApi {
   /**
@@ -27,6 +29,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 {
@@ -43,9 +62,8 @@
   patchsetNumber: number;
   patchsetSha: string;
   repo: string;
-  commmitMessage?: string;
-  /* TODO(brohlfs): Add dep to Rest API types and replace type by ChangeInfo. */
-  changeInfo: unknown;
+  commitMessage?: string;
+  changeInfo: ChangeInfo;
 }
 
 export declare interface ChecksProvider {
@@ -382,6 +400,11 @@
   links?: Link[];
 
   /**
+   * Links to lines of code. The referenced path must be part of this patchset.
+   */
+  codePointers?: CodePointer[];
+
+  /**
    * Callbacks to the plugin. Must be implemented individually by each
    * plugin. Actions are rendered as buttons. If there are more than two actions
    * per result, then further actions are put into an overflow menu. Sort order
@@ -430,6 +453,11 @@
   icon: LinkIcon;
 }
 
+export declare interface CodePointer {
+  path: string;
+  range: CommentRange;
+}
+
 export enum LinkIcon {
   EXTERNAL = 'external',
   IMAGE = 'image',
@@ -438,4 +466,6 @@
   DOWNLOAD_MOBILE = 'download_mobile',
   HELP_PAGE = 'help_page',
   REPORT_BUG = 'report_bug',
+  CODE = 'code',
+  FILE_PRESENT = 'file_present',
 }
diff --git a/polygerrit-ui/app/api/core.ts b/polygerrit-ui/app/api/core.ts
index 5820139..af7fc40 100644
--- a/polygerrit-ui/app/api/core.ts
+++ b/polygerrit-ui/app/api/core.ts
@@ -45,3 +45,25 @@
   /** The character position in the end line. (0-based) */
   end_character: number;
 }
+
+/**
+ * Return type for cursor moves, that indicate whether a move was possible.
+ */
+export enum CursorMoveResult {
+  /** The cursor was successfully moved. */
+  MOVED,
+  /** There were no stops - the cursor was reset. */
+  NO_STOPS,
+  /**
+   * There was no more matching stop to move to - the cursor was clipped to the
+   * end.
+   */
+  CLIPPED,
+  /** The abort condition would have been fulfilled for the new target. */
+  ABORTED,
+}
+
+/** A sentinel that can be inserted to disallow moving across. */
+export class AbortStop {}
+
+export type Stop = HTMLElement | AbortStop;
diff --git a/polygerrit-ui/app/api/diff.ts b/polygerrit-ui/app/api/diff.ts
index 75b86c7..7400295 100644
--- a/polygerrit-ui/app/api/diff.ts
+++ b/polygerrit-ui/app/api/diff.ts
@@ -20,7 +20,7 @@
  * limitations under the License.
  */
 
-import {CommentRange} from './core';
+import {CommentRange, CursorMoveResult} from './core';
 
 /**
  * Diff type in preferences
@@ -206,6 +206,11 @@
   font_size: number;
   // TODO: Missing documentation
   show_file_comment_button?: boolean;
+  line_wrapping?: boolean;
+}
+
+export declare interface ImageDiffPreferences {
+  automatic_blink?: boolean;
 }
 
 export declare interface RenderPreferences {
@@ -214,6 +219,7 @@
   show_file_comment_button?: boolean;
   hide_line_length_indicator?: boolean;
   use_block_expansion?: boolean;
+  image_diff_prefs?: ImageDiffPreferences;
 }
 
 /**
@@ -322,6 +328,11 @@
   | {type: 'magnifier-clicked'}
   | {type: 'magnifier-dragged'}
   | {type: 'version-switcher-clicked'; button: 'base' | 'revision' | 'switch'}
+  | {
+      type: 'highlight-changes-changed';
+      value: boolean;
+      source: 'controls' | 'magnifier';
+    }
   | {type: 'zoom-level-changed'; scale: number | 'fit'}
   | {type: 'follow-mouse-changed'; value: boolean}
   | {type: 'background-color-changed'; value: string}
@@ -405,3 +416,47 @@
     className: string
   ): void;
 }
+
+/** 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 {
+  // 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;
+
+  moveLeft(): void;
+  moveRight(): void;
+
+  moveDown(): CursorMoveResult;
+  moveUp(): CursorMoveResult;
+
+  moveToFirstChunk(): void;
+  moveToLastChunk(): void;
+
+  moveToNextChunk(): CursorMoveResult;
+  moveToPreviousChunk(): CursorMoveResult;
+
+  moveToNextCommentThread(): CursorMoveResult;
+  moveToPreviousCommentThread(): CursorMoveResult;
+
+  createCommentInPlace(): void;
+  resetScrollMode(): void;
+  moveToLineNumber(lineNum: number, side: Side, path?: string): void;
+}
diff --git a/polygerrit-ui/app/api/embed.ts b/polygerrit-ui/app/api/embed.ts
index b9918d3..b1b7f34 100644
--- a/polygerrit-ui/app/api/embed.ts
+++ b/polygerrit-ui/app/api/embed.ts
@@ -20,12 +20,13 @@
  * limitations under the License.
  */
 
-import {DiffLayer, GrAnnotation} from './diff';
+import {DiffLayer, GrAnnotation, GrDiffCursor} from './diff';
 
 declare global {
   interface Window {
     grdiff: {
       GrAnnotation: GrAnnotation;
+      GrDiffCursor: {new (): GrDiffCursor};
       TokenHighlightLayer: {new (): DiffLayer};
     };
   }
diff --git a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar_html.ts b/polygerrit-ui/app/api/gerrit.ts
similarity index 61%
copy from polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar_html.ts
copy to polygerrit-ui/app/api/gerrit.ts
index a1e51df..b5a349f 100644
--- a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar_html.ts
+++ b/polygerrit-ui/app/api/gerrit.ts
@@ -1,6 +1,6 @@
 /**
  * @license
- * Copyright (C) 2020 The Android Open Source Project
+ * Copyright (C) 2021 The Android Open Source Project
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -14,15 +14,20 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
+import {PluginApi} from './plugin';
 
-export const htmlTemplate = html`
-  <style>
-    :host {
-      display: inline-block;
-      border-radius: 50%;
-      background-size: cover;
-      background-color: var(--avatar-background-color, var(--gray-background));
-    }
-  </style>
-`;
+declare global {
+  interface Window {
+    Gerrit: Gerrit;
+    VERSION_INFO?: string;
+    ENABLED_EXPERIMENTS?: string[];
+  }
+}
+
+export interface Gerrit {
+  install(
+    callback: (plugin: PluginApi) => void,
+    opt_version?: string,
+    src?: string
+  ): void;
+}
diff --git a/polygerrit-ui/app/api/hook.ts b/polygerrit-ui/app/api/hook.ts
index 0ac6468..f8a6cc1 100644
--- a/polygerrit-ui/app/api/hook.ts
+++ b/polygerrit-ui/app/api/hook.ts
@@ -14,31 +14,32 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-interface GerritElementExtensions {
+import {ChangeInfo, ConfigInfo, RevisionInfo} from './rest-api';
+
+export interface GerritElementExtensions {
   content?: HTMLElement & {hidden?: boolean};
-  change?: unknown;
-  revision?: unknown;
+  change?: ChangeInfo;
+  revision?: RevisionInfo;
   token?: string;
   repoName?: string;
-  /**
-   * This is a ConfigInfo object as defined here:
-   * https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#config-info
-   * We neither want to repeat it nor add a dependency on it here.
-   */
-  config?: unknown;
+  config?: ConfigInfo;
 }
 
-export type HookCallback = (el: HTMLElement & GerritElementExtensions) => void;
+export type PluginElement = HTMLElement & GerritElementExtensions;
+
+export type HookCallback<T extends PluginElement> = (el: T) => void;
 
 export declare interface RegisterOptions {
+  /** Defaults to empty string. */
   slot?: string;
-  replace: unknown;
+  /** Defaults to false. */
+  replace?: boolean;
 }
 
-export declare interface HookApi {
-  onAttached(callback: HookCallback): HookApi;
+export declare interface HookApi<T extends PluginElement> {
+  onAttached(callback: HookCallback<T>): HookApi<T>;
 
-  onDetached(callback: HookCallback): HookApi;
+  onDetached(callback: HookCallback<T>): HookApi<T>;
 
   getAllAttached(): HTMLElement[];
 
diff --git a/polygerrit-ui/app/api/plugin.ts b/polygerrit-ui/app/api/plugin.ts
index 0c91546..7a56ff7 100644
--- a/polygerrit-ui/app/api/plugin.ts
+++ b/polygerrit-ui/app/api/plugin.ts
@@ -58,22 +58,25 @@
   checks(): ChecksPluginApi;
   eventHelper(element: Node): EventHelperPluginApi;
   getPluginName(): string;
-  hook(endpointName: string, opt_options?: RegisterOptions): HookApi;
+  hook<T extends HTMLElement>(
+    endpointName: string,
+    opt_options?: RegisterOptions
+  ): HookApi<T>;
   // eslint-disable-next-line @typescript-eslint/no-explicit-any
   on(eventName: EventType, target: any): void;
   popup(): Promise<PopupPluginApi>;
   popup(moduleName: string): Promise<PopupPluginApi>;
   popup(moduleName?: string): Promise<PopupPluginApi | null>;
-  registerCustomComponent(
+  registerCustomComponent<T extends HTMLElement>(
     endpointName: string,
     moduleName?: string,
     options?: RegisterOptions
-  ): HookApi;
-  registerDynamicCustomComponent(
+  ): HookApi<T>;
+  registerDynamicCustomComponent<T extends HTMLElement>(
     endpointName: string,
     moduleName?: string,
     options?: RegisterOptions
-  ): HookApi;
+  ): HookApi<T>;
   registerStyleModule(endpoint: string, moduleName: string): void;
   reporting(): ReportingPluginApi;
   restApi(): RestPluginApi;
diff --git a/polygerrit-ui/app/api/rest-api.ts b/polygerrit-ui/app/api/rest-api.ts
new file mode 100644
index 0000000..fe9d00d
--- /dev/null
+++ b/polygerrit-ui/app/api/rest-api.ts
@@ -0,0 +1,1021 @@
+/**
+ * @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.
+ */
+
+/**
+ * rest-api.ts contains all entities from the Gerrit REST API that are also
+ * relevant to plugins and gr-diff users. These entities should be exactly what
+ * the backend defines and returns and should eventually be generated.
+ *
+ * Sorting order:
+ * - enums in alphabetical order
+ * - types and interfaces in alphabetical order
+ *   - type checking functions after their corresponding type
+ */
+
+/**
+ * enums =======================================================================
+ */
+
+export enum AccountTag {
+  SERVICE_USER = 'SERVICE_USER',
+}
+
+/**
+ * The authentication type that is configured on the server.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#auth-info
+ */
+export enum AuthType {
+  OPENID = 'OPENID',
+  OPENID_SSO = 'OPENID_SSO',
+  OAUTH = 'OAUTH',
+  HTTP = 'HTTP',
+  HTTP_LDAP = 'HTTP_LDAP',
+  CLIENT_SSL_CERT_LDAP = 'CLIENT_SSL_CERT_LDAP',
+  LDAP = 'LDAP',
+  LDAP_BIND = 'LDAP_BIND',
+  CUSTOM_EXTENSION = 'CUSTOM_EXTENSION',
+  DEVELOPMENT_BECOME_ANY_ACCOUNT = 'DEVELOPMENT_BECOME_ANY_ACCOUNT',
+}
+
+/**
+ * @desc Specifies status for a change
+ */
+export enum ChangeStatus {
+  ABANDONED = 'ABANDONED',
+  MERGED = 'MERGED',
+  NEW = 'NEW',
+}
+
+/**
+ * The type in ConfigParameterInfo entity.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#config-parameter-info
+ */
+export enum ConfigParameterInfoType {
+  // Should be kept in sync with
+  // gerrit/java/com/google/gerrit/extensions/api/projects/ProjectConfigEntryType.java.
+  STRING = 'STRING',
+  INT = 'INT',
+  LONG = 'LONG',
+  BOOLEAN = 'BOOLEAN',
+  LIST = 'LIST',
+  ARRAY = 'ARRAY',
+}
+
+/**
+ * @desc Used for server config of accounts
+ */
+export enum DefaultDisplayNameConfig {
+  USERNAME = 'USERNAME',
+  FIRST_NAME = 'FIRST_NAME',
+  FULL_NAME = 'FULL_NAME',
+}
+
+/**
+ * Account fields that are editable
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#auth-info
+ */
+export enum EditableAccountField {
+  FULL_NAME = 'FULL_NAME',
+  USER_NAME = 'USER_NAME',
+  REGISTER_NEW_EMAIL = 'REGISTER_NEW_EMAIL',
+}
+
+/**
+ * @desc The status of the file
+ */
+export enum FileInfoStatus {
+  ADDED = 'A',
+  DELETED = 'D',
+  RENAMED = 'R',
+  COPIED = 'C',
+  REWRITTEN = 'W',
+  // Modifed = 'M', // but API not set it if the file was modified
+  UNMODIFIED = 'U', // Not returned by BE, but added by UI for certain files
+}
+
+/**
+ * @desc The status of the file
+ */
+export enum GpgKeyInfoStatus {
+  BAD = 'BAD',
+  OK = 'OK',
+  TRUSTED = 'TRUSTED',
+}
+
+/**
+ * Enum for all http methods used in Gerrit.
+ */
+export enum HttpMethod {
+  HEAD = 'HEAD',
+  POST = 'POST',
+  GET = 'GET',
+  DELETE = 'DELETE',
+  PUT = 'PUT',
+}
+
+/**
+ * Enum for possible configured value in InheritedBooleanInfo.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#inherited-boolean-info
+ */
+export enum InheritedBooleanInfoConfiguredValue {
+  TRUE = 'TRUE',
+  FALSE = 'FALSE',
+  INHERITED = 'INHERITED',
+}
+
+/**
+ * This setting determines when Gerrit computes if a change is mergeable or not.
+ * https://gerrit-review.googlesource.com/Documentation/config-gerrit.html#change.mergeabilityComputationBehavior
+ */
+export enum MergeabilityComputationBehavior {
+  API_REF_UPDATED_AND_CHANGE_REINDEX = 'API_REF_UPDATED_AND_CHANGE_REINDEX',
+  REF_UPDATED_AND_CHANGE_REINDEX = 'REF_UPDATED_AND_CHANGE_REINDEX',
+  NEVER = 'NEVER',
+}
+
+/**
+ * @desc The status of fixing the problem
+ */
+export enum ProblemInfoStatus {
+  FIXED = 'FIXED',
+  FIX_FAILED = 'FIX_FAILED',
+}
+
+/**
+ * @desc The state of the projects
+ */
+export enum ProjectState {
+  ACTIVE = 'ACTIVE',
+  READ_ONLY = 'READ_ONLY',
+  HIDDEN = 'HIDDEN',
+}
+
+/**
+ * @desc The reviewer state
+ */
+export enum RequirementStatus {
+  OK = 'OK',
+  NOT_READY = 'NOT_READY',
+  RULE_ERROR = 'RULE_ERROR',
+}
+
+/**
+ * @desc The reviewer state
+ */
+export enum ReviewerState {
+  REVIEWER = 'REVIEWER',
+  CC = 'CC',
+  REMOVED = 'REMOVED',
+}
+
+/**
+ * @desc The patchset kind
+ */
+export enum RevisionKind {
+  REWORK = 'REWORK',
+  TRIVIAL_REBASE = 'TRIVIAL_REBASE',
+  MERGE_FIRST_PARENT_UPDATE = 'MERGE_FIRST_PARENT_UPDATE',
+  NO_CODE_CHANGE = 'NO_CODE_CHANGE',
+  NO_CHANGE = 'NO_CHANGE',
+}
+
+/**
+ * All supported submit types.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#submit-type-info
+ */
+export enum SubmitType {
+  MERGE_IF_NECESSARY = 'MERGE_IF_NECESSARY',
+  FAST_FORWARD_ONLY = 'FAST_FORWARD_ONLY',
+  REBASE_IF_NECESSARY = 'REBASE_IF_NECESSARY',
+  REBASE_ALWAYS = 'REBASE_ALWAYS',
+  MERGE_ALWAYS = 'MERGE_ALWAYS ',
+  CHERRY_PICK = 'CHERRY_PICK',
+  INHERIT = 'INHERIT',
+}
+
+/**
+ * types and interfaces ========================================================
+ */
+
+// This is a "meta type", so it comes first and is not sored alphabetically with
+// the other types.
+export type BrandType<T, BrandName extends string> = T &
+  {[__brand in BrandName]: never};
+
+export type AccountId = BrandType<number, '_accountId'>;
+
+/**
+ * The AccountInfo entity contains information about an account.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#account-info
+ */
+export declare interface AccountInfo {
+  // Normally _account_id is defined (for known Gerrit users), but users can
+  // also be CCed just with their email address. So you have to be prepared that
+  // _account_id is undefined, but then email must be set.
+  _account_id?: AccountId;
+  name?: string;
+  display_name?: string;
+  // Must be set, if _account_id is undefined.
+  email?: EmailAddress;
+  secondary_emails?: string[];
+  username?: string;
+  avatars?: AvatarInfo[];
+  _more_accounts?: boolean; // not set if false
+  status?: string; // status message of the account
+  inactive?: boolean; // not set if false
+  tags?: AccountTag[];
+}
+
+/**
+ * The AccountsConfigInfo entity contains information about Gerrit configuration
+ * from the accounts section.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#accounts-config-info
+ */
+export interface AccountsConfigInfo {
+  visibility: string;
+  default_display_name: DefaultDisplayNameConfig;
+}
+
+/**
+ * The ActionInfo entity describes a REST API call the client can make to
+ * manipulate a resource. These are frequently implemented by plugins and may
+ * be discovered at runtime.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#action-info
+ */
+export declare interface ActionInfo {
+  method?: HttpMethod; // Most actions use POST, PUT or DELETE to cause state changes.
+  label?: string; // Short title to display to a user describing the action
+  title?: string; // Longer text to display describing the action
+  enabled?: boolean; // not set if false
+}
+
+export declare interface ActionNameToActionInfoMap {
+  [actionType: string]: ActionInfo | undefined;
+  // List of actions explicitly used in code:
+  wip?: ActionInfo;
+  publishEdit?: ActionInfo;
+  rebaseEdit?: ActionInfo;
+  deleteEdit?: ActionInfo;
+  edit?: ActionInfo;
+  stopEdit?: ActionInfo;
+  download?: ActionInfo;
+  rebase?: ActionInfo;
+  cherrypick?: ActionInfo;
+  move?: ActionInfo;
+  revert?: ActionInfo;
+  revert_submission?: ActionInfo;
+  abandon?: ActionInfo;
+  submit?: ActionInfo;
+  topic?: ActionInfo;
+  hashtags?: ActionInfo;
+  assignee?: ActionInfo;
+  ready?: ActionInfo;
+  includedIn?: ActionInfo;
+}
+
+/**
+ * The ApprovalInfo entity contains information about an approval from auser
+ * for a label on a change.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#approval-info
+ */
+export declare interface ApprovalInfo extends AccountInfo {
+  value?: number;
+  permitted_voting_range?: VotingRangeInfo;
+  date?: Timestamp;
+  tag?: ReviewInputTag;
+  post_submit?: boolean; // not set if false
+}
+
+/**
+ * The AttentionSetInfo entity contains details of users that are in the attention set.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#attention-set-info
+ */
+export declare interface AttentionSetInfo {
+  account: AccountInfo;
+  last_update?: Timestamp;
+  reason?: string;
+}
+
+/**
+ * The AuthInfo entity contains information about the authentication
+ * configuration of the Gerrit server.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#auth-info
+ */
+export interface AuthInfo {
+  auth_type: AuthType; // docs incorrectly names it 'type'
+  use_contributor_agreements?: boolean;
+  contributor_agreements?: ContributorAgreementInfo[];
+  editable_account_fields: EditableAccountField[];
+  login_url?: string;
+  login_text?: string;
+  switch_account_url?: string;
+  register_url?: string;
+  register_text?: string;
+  edit_full_name_url?: string;
+  http_password_url?: string;
+  git_basic_auth_policy?: string;
+}
+
+/**
+ * The AvartarInfo entity contains information about an avatar image ofan
+ * account.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#avatar-info
+ */
+export declare interface AvatarInfo {
+  url: string;
+  height: number;
+  width: number;
+}
+
+export type BasePatchSetNum = BrandType<'PARENT' | number, '_patchSet'>;
+// The refs/heads/ prefix is omitted in Branch name
+
+export type BranchName = BrandType<string, '_branchName'>;
+
+/**
+ * The ChangeConfigInfo entity contains information about Gerrit configuration
+ * from the change section.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#change-config-info
+ */
+export interface ChangeConfigInfo {
+  allow_blame?: boolean;
+  large_change: number;
+  update_delay: number;
+  submit_whole_topic?: boolean;
+  disable_private_changes?: boolean;
+  mergeability_computation_behavior: MergeabilityComputationBehavior;
+  enable_assignee: boolean;
+}
+
+export type ChangeId = BrandType<string, '_changeId'>;
+
+/**
+ * The ChangeInfo entity contains information about a change.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#change-info
+ */
+export declare interface ChangeInfo {
+  id: ChangeInfoId;
+  project: RepoName;
+  branch: BranchName;
+  topic?: TopicName;
+  attention_set?: IdToAttentionSetMap;
+  assignee?: AccountInfo;
+  hashtags?: Hashtag[];
+  change_id: ChangeId;
+  subject: string;
+  status: ChangeStatus;
+  created: Timestamp;
+  updated: Timestamp;
+  submitted?: Timestamp;
+  submitter?: AccountInfo;
+  starred?: boolean; // not set if false
+  stars?: StarLabel[];
+  reviewed?: boolean; // not set if false
+  submit_type?: SubmitType;
+  mergeable?: boolean;
+  submittable?: boolean;
+  insertions: number; // Number of inserted lines
+  deletions: number; // Number of deleted lines
+  total_comment_count?: number;
+  unresolved_comment_count?: number;
+  _number: NumericChangeId;
+  owner: AccountInfo;
+  actions?: ActionNameToActionInfoMap;
+  requirements?: Requirement[];
+  labels?: LabelNameToInfoMap;
+  permitted_labels?: LabelNameToValueMap;
+  removable_reviewers?: AccountInfo[];
+  // This is documented as optional, but actually always set.
+  reviewers: Reviewers;
+  pending_reviewers?: AccountInfo[];
+  reviewer_updates?: ReviewerUpdateInfo[];
+  messages?: ChangeMessageInfo[];
+  current_revision?: CommitId;
+  revisions?: {[revisionId: string]: RevisionInfo};
+  tracking_ids?: TrackingIdInfo[];
+  _more_changes?: boolean; // not set if false
+  problems?: ProblemInfo[];
+  is_private?: boolean; // not set if false
+  work_in_progress?: boolean; // not set if false
+  has_review_started?: boolean; // not set if false
+  revert_of?: NumericChangeId;
+  submission_id?: ChangeSubmissionId;
+  cherry_pick_of_change?: NumericChangeId;
+  cherry_pick_of_patch_set?: PatchSetNum;
+  contains_git_conflicts?: boolean;
+  internalHost?: string; // TODO(TS): provide an explanation what is its
+}
+
+// The ID of the change in the format "'<project>~<branch>~<Change-Id>'"
+export type ChangeInfoId = BrandType<string, '_changeInfoId'>;
+
+export type ChangeMessageId = BrandType<string, '_changeMessageId'>;
+
+/**
+ * The ChangeMessageInfo entity contains information about a message attached
+ * to a change.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#change-message-info
+ */
+export declare interface ChangeMessageInfo {
+  id: ChangeMessageId;
+  author?: AccountInfo;
+  reviewer?: AccountInfo;
+  updated_by?: AccountInfo;
+  real_author?: AccountInfo;
+  date: Timestamp;
+  message: string;
+  accounts_in_message?: AccountInfo[];
+  tag?: ReviewInputTag;
+  _revision_number?: PatchSetNum;
+}
+
+// This ID is equal to the numeric ID of the change that triggered the
+// submission. If the change that triggered the submission also has a topic, it
+// will be "<id>-<topic>" of the change that triggered the submission
+// The callers must not rely on the format of the submission ID.
+export type ChangeSubmissionId = BrandType<
+  string | number,
+  '_changeSubmissionId'
+>;
+
+export type CloneCommandMap = {[name: string]: string};
+
+/**
+ * The CommentLinkInfo entity describes acommentlink.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#commentlink-info
+ */
+export interface CommentLinkInfo {
+  match: string;
+  link?: string;
+  enabled?: boolean;
+  html?: string;
+}
+
+export interface CommentLinks {
+  [name: string]: CommentLinkInfo;
+}
+
+export type CommitId = BrandType<string, '_commitId'>;
+
+/**
+ * The CommitInfo entity contains information about a commit.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#commit-info
+ */
+export declare interface CommitInfo {
+  commit?: CommitId;
+  parents: ParentCommitInfo[];
+  author: GitPersonInfo;
+  committer: GitPersonInfo;
+  subject: string;
+  message: string;
+  web_links?: WebLinkInfo[];
+  resolve_conflicts_web_links?: WebLinkInfo[];
+}
+
+export interface ConfigArrayParameterInfo extends ConfigParameterInfoBase {
+  type: ConfigParameterInfoType.ARRAY;
+  values: string[];
+}
+
+/**
+ * The ConfigInfo entity contains information about the effective
+ * project configuration.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#config-info
+ */
+export interface ConfigInfo {
+  description?: string;
+  use_contributor_agreements?: InheritedBooleanInfo;
+  use_content_merge?: InheritedBooleanInfo;
+  use_signed_off_by?: InheritedBooleanInfo;
+  create_new_change_for_all_not_in_target?: InheritedBooleanInfo;
+  require_change_id?: InheritedBooleanInfo;
+  enable_signed_push?: InheritedBooleanInfo;
+  require_signed_push?: InheritedBooleanInfo;
+  reject_implicit_merges?: InheritedBooleanInfo;
+  private_by_default: InheritedBooleanInfo;
+  work_in_progress_by_default: InheritedBooleanInfo;
+  max_object_size_limit: MaxObjectSizeLimitInfo;
+  default_submit_type: SubmitTypeInfo;
+  submit_type: SubmitType;
+  match_author_to_committer_date?: InheritedBooleanInfo;
+  state?: ProjectState;
+  commentlinks: CommentLinks;
+  plugin_config?: PluginNameToPluginParametersMap;
+  actions?: {[viewName: string]: ActionInfo};
+  reject_empty_commit?: InheritedBooleanInfo;
+}
+
+export interface ConfigListParameterInfo extends ConfigParameterInfoBase {
+  type: ConfigParameterInfoType.LIST;
+  permitted_values?: string[];
+}
+
+export type ConfigParameterInfo =
+  | ConfigParameterInfoBase
+  | ConfigArrayParameterInfo
+  | ConfigListParameterInfo;
+
+/**
+ * The ConfigParameterInfo entity describes a project configurationparameter.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#config-parameter-info
+ */
+export interface ConfigParameterInfoBase {
+  display_name?: string;
+  description?: string;
+  warning?: string;
+  type: ConfigParameterInfoType;
+  value?: string;
+  values?: string[];
+  editable?: boolean;
+  permitted_values?: string[];
+  inheritable?: boolean;
+  configured_value?: string;
+  inherited_value?: string;
+}
+
+// https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#contributor-agreement-info
+export interface ContributorAgreementInfo {
+  name: string;
+  description: string;
+  url: string;
+  auto_verify_group?: GroupInfo;
+}
+
+/**
+ * LabelInfo when DETAILED_LABELS are requested.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#_fields_set_by_code_detailed_labels_code
+ */
+export declare interface DetailedLabelInfo extends LabelCommonInfo {
+  // This is not set when the change has no reviewers.
+  all?: ApprovalInfo[];
+  // Docs claim that 'values' is optional, but it is actually always set.
+  values?: LabelValueToDescriptionMap; // A map of all values that are allowed for this label
+  default_value?: number;
+}
+
+export function isDetailedLabelInfo(
+  label: LabelInfo
+): label is DetailedLabelInfo | (QuickLabelInfo & DetailedLabelInfo) {
+  return !!(label as DetailedLabelInfo).values;
+}
+
+/**
+ * The DownloadInfo entity contains information about supported download
+ * options.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#download-info
+ */
+export interface DownloadInfo {
+  schemes: SchemesInfoMap;
+  archives: string[];
+}
+
+/**
+ * The DownloadSchemeInfo entity contains information about a supported download
+ * scheme and its commands.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html
+ */
+export interface DownloadSchemeInfo {
+  url: string;
+  is_auth_required: boolean;
+  is_auth_supported: boolean;
+  commands: string;
+  clone_commands: CloneCommandMap;
+}
+
+export type EmailAddress = BrandType<string, '_emailAddress'>;
+
+/**
+ * The FetchInfo entity contains information about how to fetch a patchset via
+ * a certain protocol.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#fetch-info
+ */
+export declare interface FetchInfo {
+  url: string;
+  ref: string;
+  commands?: {[commandName: string]: string};
+}
+
+/**
+ * The FileInfo entity contains information about a file in a patch set.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#file-info
+ */
+export declare interface FileInfo {
+  status?: FileInfoStatus;
+  binary?: boolean; // not set if false
+  old_path?: string;
+  lines_inserted?: number;
+  lines_deleted?: number;
+  size_delta: number; // in bytes
+  size: number; // in bytes
+}
+
+/**
+ * The GerritInfo entity contains information about Gerrit configuration from
+ * the gerrit section.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#gerrit-info
+ */
+export interface GerritInfo {
+  all_projects: string; // Doc contains incorrect name
+  all_users: string; // Doc contains incorrect name
+  doc_search: boolean;
+  doc_url?: string;
+  edit_gpg_keys?: boolean;
+  report_bug_url?: string;
+  // The following property is missed in doc
+  primary_weblink_name?: string;
+}
+
+export type GitRef = BrandType<string, '_gitRef'>;
+// The 40-char (plus spaces) hex GPG key fingerprint
+
+export type GpgKeyFingerprint = BrandType<string, '_gpgKeyFingerprint'>;
+// The 8-char hex GPG key ID.
+
+export type GpgKeyId = BrandType<string, '_gpgKeyId'>;
+
+/**
+ * The GpgKeyInfo entity contains information about a GPG public key.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#gpg-key-info
+ */
+export declare interface GpgKeyInfo {
+  id?: GpgKeyId;
+  fingerprint?: GpgKeyFingerprint;
+  user_ids?: OpenPgpUserIds[];
+  key?: string; // ASCII armored public key material
+  status?: GpgKeyInfoStatus;
+  problems?: string[];
+}
+
+/**
+ * The GitPersonInfo entity contains information about theauthor/committer of
+ * a commit.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#git-person-info
+ */
+export declare interface GitPersonInfo {
+  name: string;
+  email: EmailAddress;
+  date: Timestamp;
+  tz: TimezoneOffset;
+}
+
+export type GroupId = BrandType<string, '_groupId'>;
+
+/**
+ * The GroupInfo entity contains information about a group. This can be a
+ * Gerrit internal group, or an external group that is known to Gerrit.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-groups.html#group-info
+ */
+export interface GroupInfo {
+  id: GroupId;
+  name?: GroupName;
+  url?: string;
+  options?: GroupOptionsInfo;
+  description?: string;
+  group_id?: string;
+  owner?: string;
+  owner_id?: string;
+  created_on?: string;
+  _more_groups?: boolean;
+  members?: AccountInfo[];
+  includes?: GroupInfo[];
+}
+
+export type GroupName = BrandType<string, '_groupName'>;
+
+/**
+ * Options of the group.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-groups.html
+ */
+export interface GroupOptionsInfo {
+  visible_to_all: boolean;
+}
+
+export type Hashtag = BrandType<string, '_hashtag'>;
+
+export type IdToAttentionSetMap = {[accountId: string]: AttentionSetInfo};
+
+/**
+ * A boolean value that can also be inherited.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#inherited-boolean-info
+ */
+export interface InheritedBooleanInfo {
+  value: boolean;
+  configured_value: InheritedBooleanInfoConfiguredValue;
+  inherited_value?: boolean;
+}
+
+export declare interface LabelCommonInfo {
+  optional?: boolean; // not set if false
+}
+
+export type LabelNameToInfoMap = {[labelName: string]: LabelInfo};
+
+// {Verified: ["-1", " 0", "+1"]}
+export type LabelNameToValueMap = {[labelName: string]: string[]};
+
+/**
+ * The LabelInfo entity contains information about a label on a change, always
+ * corresponding to the current patch set.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#label-info
+ */
+export type LabelInfo =
+  | QuickLabelInfo
+  | DetailedLabelInfo
+  | (QuickLabelInfo & DetailedLabelInfo);
+
+// The map maps the values (“-2”, “-1”, " `0`", “+1”, “+2”) to the value descriptions.
+export type LabelValueToDescriptionMap = {[labelValue: string]: string};
+
+/**
+ * The MaxObjectSizeLimitInfo entity contains information about the max object
+ * size limit of a project.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#max-object-size-limit-info
+ */
+export interface MaxObjectSizeLimitInfo {
+  value?: string;
+  configured_value?: string;
+  summary?: string;
+}
+
+export type NumericChangeId = BrandType<number, '_numericChangeId'>;
+// OpenPGP User IDs (https://tools.ietf.org/html/rfc4880#section-5.11).
+
+export type OpenPgpUserIds = BrandType<string, '_openPgpUserIds'>;
+
+/**
+ * The parent commits of this commit as a list of CommitInfo entities.
+ * In each parent only the commit and subject fields are populated.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#commit-info
+ */
+export declare interface ParentCommitInfo {
+  commit: CommitId;
+  subject: string;
+}
+
+export type PatchSetNum = BrandType<'PARENT' | 'edit' | number, '_patchSet'>;
+
+/**
+ * The PluginConfigInfo entity contains information about Gerrit extensions by
+ * plugins.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#plugin-config-info
+ */
+export interface PluginConfigInfo {
+  has_avatars: boolean;
+  // Exists in Java class, but not mentioned in docs.
+  js_resource_paths: string[];
+}
+
+/**
+ * Plugin configuration values as map which maps the plugin name to a map of parameter names to values
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#config-input
+ */
+export type PluginNameToPluginParametersMap = {
+  [pluginName: string]: PluginParameterToConfigParameterInfoMap;
+};
+
+export type PluginParameterToConfigParameterInfoMap = {
+  [parameterName: string]: ConfigParameterInfo;
+};
+
+/**
+ * The ProblemInfo entity contains a description of a potential consistency
+ * problem with a change. These are not related to the code review process,
+ * but rather indicate some inconsistency in Gerrit’s database or repository
+ * metadata related to the enclosing change.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#problem-info
+ */
+export declare interface ProblemInfo {
+  message: string;
+  status?: ProblemInfoStatus; // Only set if a fix was attempted
+  outcome?: string;
+}
+
+/**
+ * The PushCertificateInfo entity contains information about a pushcertificate
+ * provided when the user pushed for review with git push
+ * --signed HEAD:refs/for/<branch>. Only used when signed push is
+ * enabled on the server.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#push-certificate-info
+ */
+export declare interface PushCertificateInfo {
+  certificate: string;
+  key: GpgKeyInfo;
+}
+
+export declare interface QuickLabelInfo extends LabelCommonInfo {
+  approved?: AccountInfo;
+  rejected?: AccountInfo;
+  recommended?: AccountInfo;
+  disliked?: AccountInfo;
+  blocking?: boolean; // not set if false
+  value?: number; // The voting value of the user who recommended/disliked this label on the change if it is not “+1”/“-1”.
+  default_value?: number;
+}
+
+export function isQuickLabelInfo(
+  l: LabelInfo
+): l is QuickLabelInfo | (QuickLabelInfo & DetailedLabelInfo) {
+  const quickLabelInfo = l as QuickLabelInfo;
+  return (
+    quickLabelInfo.approved !== undefined ||
+    quickLabelInfo.rejected !== undefined ||
+    quickLabelInfo.recommended !== undefined ||
+    quickLabelInfo.disliked !== undefined ||
+    quickLabelInfo.blocking !== undefined ||
+    quickLabelInfo.blocking !== undefined ||
+    quickLabelInfo.value !== undefined
+  );
+}
+
+/**
+ * The ReceiveInfo entity contains information about the configuration of
+ * git-receive-pack behavior on the server.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#receive-info
+ */
+export interface ReceiveInfo {
+  enable_signed_push?: string;
+}
+
+export type RepoName = BrandType<string, '_repoName'>;
+
+/**
+ * The Requirement entity contains information about a requirement relative to
+ * a change.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#requirement
+ */
+export declare interface Requirement {
+  status: RequirementStatus;
+  fallbackText: string; // A human readable reason
+  type: RequirementType;
+}
+
+export type RequirementType = BrandType<string, '_requirementType'>;
+
+/**
+ * The reviewers as a map that maps a reviewer state to a list of AccountInfo
+ * entities. Possible reviewer states are REVIEWER, CC and REMOVED.
+ * REVIEWER: Users with at least one non-zero vote on the change.
+ * CC: Users that were added to the change, but have not voted.
+ * REMOVED: Users that were previously reviewers on the change, but have been removed.
+ */
+export type Reviewers = Partial<Record<ReviewerState, AccountInfo[]>>;
+
+/**
+ * The ReviewerUpdateInfo entity contains information about updates to change’s
+ * reviewers set.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#review-update-info
+ */
+export declare interface ReviewerUpdateInfo {
+  updated: Timestamp;
+  updated_by: AccountInfo;
+  reviewer: AccountInfo;
+  state: ReviewerState;
+}
+
+export type ReviewInputTag = BrandType<string, '_reviewInputTag'>;
+
+/**
+ * The RevisionInfo entity contains information about a patch set.Not all
+ * fields are returned by default.  Additional fields can be obtained by
+ * adding o parameters as described in Query Changes.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#revision-info
+ * basePatchNum is present in case RevisionInfo is of type 'edit'
+ */
+export declare interface RevisionInfo {
+  kind: RevisionKind;
+  _number: PatchSetNum;
+  created: Timestamp;
+  uploader: AccountInfo;
+  ref: GitRef;
+  fetch?: {[protocol: string]: FetchInfo};
+  commit?: CommitInfo;
+  files?: {[filename: string]: FileInfo};
+  actions?: ActionNameToActionInfoMap;
+  reviewed?: boolean;
+  commit_with_footers?: boolean;
+  push_certificate?: PushCertificateInfo;
+  description?: string;
+  basePatchNum?: BasePatchSetNum;
+}
+
+export type SchemesInfoMap = {[name: string]: DownloadSchemeInfo};
+
+/**
+ * The ServerInfo entity contains information about the configuration of the
+ * Gerrit server.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#server-info
+ */
+export interface ServerInfo {
+  accounts: AccountsConfigInfo;
+  auth: AuthInfo;
+  change: ChangeConfigInfo;
+  download: DownloadInfo;
+  gerrit: GerritInfo;
+  // docs mentions index property, but it doesn't exists in Java class
+  // index: IndexConfigInfo;
+  note_db_enabled?: boolean;
+  plugin: PluginConfigInfo;
+  receive?: ReceiveInfo;
+  sshd?: SshdInfo;
+  suggest: SuggestInfo;
+  user: UserConfigInfo;
+  default_theme?: string;
+}
+
+/**
+ * The SshdInfo entity contains information about Gerrit configuration from the sshd section.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#sshd-info
+ * This entity doesn’t contain any data, but the presence of this (empty) entity
+ * in the ServerInfo entity means that SSHD is enabled on the server.
+ */
+export type SshdInfo = {};
+
+export type StarLabel = BrandType<string, '_startLabel'>;
+// Timestamps are given in UTC and have the format
+// "'yyyy-mm-dd hh:mm:ss.fffffffff'"
+// where "'ffffffffff'" represents nanoseconds.
+
+/**
+ * Information about the default submittype of a project, taking into account
+ * project inheritance.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#submit-type-info
+ */
+export interface SubmitTypeInfo {
+  value: Exclude<SubmitType, SubmitType.INHERIT>;
+  configured_value: SubmitType;
+  inherited_value: Exclude<SubmitType, SubmitType.INHERIT>;
+}
+
+/**
+ * The SuggestInfo entity contains information about Gerritconfiguration from
+ * the suggest section.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#suggest-info
+ */
+export interface SuggestInfo {
+  from: number;
+}
+
+export type Timestamp = BrandType<string, '_timestamp'>;
+// The timezone offset from UTC in minutes
+
+export type TimezoneOffset = BrandType<number, '_timezoneOffset'>;
+
+export type TopicName = BrandType<string, '_topicName'>;
+
+export type TrackingId = BrandType<string, '_trackingId'>;
+
+/**
+ * The TrackingIdInfo entity describes a reference to an external tracking
+ * system.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#tracking-id-info
+ */
+export declare interface TrackingIdInfo {
+  system: string;
+  id: TrackingId;
+}
+
+/**
+ * The UserConfigInfo entity contains information about Gerrit configuration
+ * from the user section.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#user-config-info
+ */
+export interface UserConfigInfo {
+  anonymous_coward_name: string;
+}
+
+/**
+ * The VotingRangeInfo entity describes the continuous voting range from minto
+ * max values.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#voting-range-info
+ */
+export declare interface VotingRangeInfo {
+  min: number;
+  max: number;
+}
+
+/**
+ * The WebLinkInfo entity describes a link to an external site.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#web-link-info
+ */
+export declare interface WebLinkInfo {
+  /** The link name. */
+  name: string;
+  /** The link URL. */
+  url: string;
+  /** URL to the icon of the link. */
+  image_url: string;
+}
diff --git a/polygerrit-ui/app/api/rest.ts b/polygerrit-ui/app/api/rest.ts
index 2b91bf6..4c93ef0 100644
--- a/polygerrit-ui/app/api/rest.ts
+++ b/polygerrit-ui/app/api/rest.ts
@@ -78,29 +78,29 @@
   /**
    * Fetch and parse REST API response, if request succeeds.
    */
-  send(
+  send<T>(
     method: HttpMethod,
     url: string,
     payload?: RequestPayload,
     errFn?: ErrorCallback,
     contentType?: string
-  ): Promise<unknown>;
+  ): Promise<T>;
 
-  get(url: string): Promise<unknown>;
+  get<T>(url: string): Promise<T>;
 
-  post(
+  post<T>(
     url: string,
     payload?: RequestPayload,
     errFn?: ErrorCallback,
     contentType?: string
-  ): Promise<unknown>;
+  ): Promise<T>;
 
-  put(
+  put<T>(
     url: string,
     payload?: RequestPayload,
     errFn?: ErrorCallback,
     contentType?: string
-  ): Promise<unknown>;
+  ): Promise<T>;
 
   delete(url: string): Promise<Response>;
 }
diff --git a/polygerrit-ui/app/constants/constants.ts b/polygerrit-ui/app/constants/constants.ts
index 689347a..ddd5199 100644
--- a/polygerrit-ui/app/constants/constants.ts
+++ b/polygerrit-ui/app/constants/constants.ts
@@ -21,6 +21,45 @@
 import {DiffViewMode} from '../api/diff';
 import {DiffPreferencesInfo} from '../types/diff';
 import {EditPreferencesInfo, PreferencesInfo} from '../types/common';
+import {
+  AccountTag,
+  AuthType,
+  ChangeStatus,
+  ConfigParameterInfoType,
+  DefaultDisplayNameConfig,
+  EditableAccountField,
+  FileInfoStatus,
+  GpgKeyInfoStatus,
+  HttpMethod,
+  InheritedBooleanInfoConfiguredValue,
+  MergeabilityComputationBehavior,
+  ProblemInfoStatus,
+  ProjectState,
+  RequirementStatus,
+  ReviewerState,
+  RevisionKind,
+  SubmitType,
+} from '../api/rest-api';
+
+export {
+  AccountTag,
+  AuthType,
+  ChangeStatus,
+  ConfigParameterInfoType,
+  DefaultDisplayNameConfig,
+  EditableAccountField,
+  FileInfoStatus,
+  GpgKeyInfoStatus,
+  HttpMethod,
+  InheritedBooleanInfoConfiguredValue,
+  MergeabilityComputationBehavior,
+  ProblemInfoStatus,
+  ProjectState,
+  RequirementStatus,
+  ReviewerState,
+  RevisionKind,
+  SubmitType,
+};
 
 export enum PrimaryTab {
   FILES = 'files',
@@ -76,15 +115,6 @@
 }
 
 /**
- * @desc Specifies status for a change
- */
-export enum ChangeStatus {
-  ABANDONED = 'ABANDONED',
-  MERGED = 'MERGED',
-  NEW = 'NEW',
-}
-
-/**
  * @desc Special file paths
  */
 export enum SpecialFilePath {
@@ -93,115 +123,9 @@
   MERGE_LIST = '/MERGE_LIST',
 }
 
-/**
- * @desc The reviewer state
- */
-export enum RequirementStatus {
-  OK = 'OK',
-  NOT_READY = 'NOT_READY',
-  RULE_ERROR = 'RULE_ERROR',
-}
-
-/**
- * @desc The reviewer state
- */
-export enum ReviewerState {
-  REVIEWER = 'REVIEWER',
-  CC = 'CC',
-  REMOVED = 'REMOVED',
-}
-
-/**
- * @desc The patchset kind
- */
-export enum RevisionKind {
-  REWORK = 'REWORK',
-  TRIVIAL_REBASE = 'TRIVIAL_REBASE',
-  MERGE_FIRST_PARENT_UPDATE = 'MERGE_FIRST_PARENT_UPDATE',
-  NO_CODE_CHANGE = 'NO_CODE_CHANGE',
-  NO_CHANGE = 'NO_CHANGE',
-}
-
-/**
- * @desc The status of fixing the problem
- */
-export enum ProblemInfoStatus {
-  FIXED = 'FIXED',
-  FIX_FAILED = 'FIX_FAILED',
-}
-
-/**
- * @desc The status of the file
- */
-export enum FileInfoStatus {
-  ADDED = 'A',
-  DELETED = 'D',
-  RENAMED = 'R',
-  COPIED = 'C',
-  REWRITTEN = 'W',
-  // Modifed = 'M', // but API not set it if the file was modified
-  UNMODIFIED = 'U', // Not returned by BE, but added by UI for certain files
-}
-
-/**
- * @desc The status of the file
- */
-export enum GpgKeyInfoStatus {
-  BAD = 'BAD',
-  OK = 'OK',
-  TRUSTED = 'TRUSTED',
-}
-
-/**
- * @desc Used for server config of accounts
- */
-export enum DefaultDisplayNameConfig {
-  USERNAME = 'USERNAME',
-  FIRST_NAME = 'FIRST_NAME',
-  FULL_NAME = 'FULL_NAME',
-}
-
-/**
- * @desc The state of the projects
- */
-export enum ProjectState {
-  ACTIVE = 'ACTIVE',
-  READ_ONLY = 'READ_ONLY',
-  HIDDEN = 'HIDDEN',
-}
-
 export {Side} from '../api/diff';
 
 /**
- * The type in ConfigParameterInfo entity.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#config-parameter-info
- */
-export enum ConfigParameterInfoType {
-  // Should be kept in sync with
-  // gerrit/java/com/google/gerrit/extensions/api/projects/ProjectConfigEntryType.java.
-  STRING = 'STRING',
-  INT = 'INT',
-  LONG = 'LONG',
-  BOOLEAN = 'BOOLEAN',
-  LIST = 'LIST',
-  ARRAY = 'ARRAY',
-}
-
-/**
- * All supported submit types.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#submit-type-info
- */
-export enum SubmitType {
-  MERGE_IF_NECESSARY = 'MERGE_IF_NECESSARY',
-  FAST_FORWARD_ONLY = 'FAST_FORWARD_ONLY',
-  REBASE_IF_NECESSARY = 'REBASE_IF_NECESSARY',
-  REBASE_ALWAYS = 'REBASE_ALWAYS',
-  MERGE_ALWAYS = 'MERGE_ALWAYS ',
-  CHERRY_PICK = 'CHERRY_PICK',
-  INHERIT = 'INHERIT',
-}
-
-/**
  * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#mergeable-info
  */
 export enum MergeStrategy {
@@ -212,20 +136,6 @@
   THEIRS = 'theirs',
 }
 
-/*
- * Enum for possible configured value in InheritedBooleanInfo.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#inherited-boolean-info
- */
-export enum InheritedBooleanInfoConfiguredValue {
-  TRUE = 'TRUE',
-  FALSE = 'FALSE',
-  INHERITED = 'INHERITED',
-}
-
-export enum AccountTag {
-  SERVICE_USER = 'SERVICE_USER',
-}
-
 /**
  * Enum for possible PermissionRuleInfo actions
  * https://gerrit-review.googlesource.com/Documentation/rest-api-access.html#permission-info
@@ -249,17 +159,6 @@
 }
 
 /**
- * Enum for all http methods used in Gerrit.
- */
-export enum HttpMethod {
-  HEAD = 'HEAD',
-  POST = 'POST',
-  GET = 'GET',
-  DELETE = 'DELETE',
-  PUT = 'PUT',
-}
-
-/**
  * The side on which the comment was added
  * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#comment-info
  */
@@ -347,23 +246,6 @@
 }
 
 /**
- * The authentication type that is configured on the server.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#auth-info
- */
-export enum AuthType {
-  OPENID = 'OPENID',
-  OPENID_SSO = 'OPENID_SSO',
-  OAUTH = 'OAUTH',
-  HTTP = 'HTTP',
-  HTTP_LDAP = 'HTTP_LDAP',
-  CLIENT_SSL_CERT_LDAP = 'CLIENT_SSL_CERT_LDAP',
-  LDAP = 'LDAP',
-  LDAP_BIND = 'LDAP_BIND',
-  CUSTOM_EXTENSION = 'CUSTOM_EXTENSION',
-  DEVELOPMENT_BECOME_ANY_ACCOUNT = 'DEVELOPMENT_BECOME_ANY_ACCOUNT',
-}
-
-/**
  * Controls visibility of other users' dashboard pages and completion suggestions to web users
  * https://gerrit-review.googlesource.com/Documentation/config-gerrit.html#accounts.visibility
  */
@@ -374,26 +256,6 @@
   NONE = 'NONE',
 }
 
-/**
- * Account fields that are editable
- * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#auth-info
- */
-export enum EditableAccountField {
-  FULL_NAME = 'FULL_NAME',
-  USER_NAME = 'USER_NAME',
-  REGISTER_NEW_EMAIL = 'REGISTER_NEW_EMAIL',
-}
-
-/**
- * This setting determines when Gerrit computes if a change is mergeable or not.
- * https://gerrit-review.googlesource.com/Documentation/config-gerrit.html#change.mergeabilityComputationBehavior
- */
-export enum MergeabilityComputationBehavior {
-  API_REF_UPDATED_AND_CHANGE_REINDEX = 'API_REF_UPDATED_AND_CHANGE_REINDEX',
-  REF_UPDATED_AND_CHANGE_REINDEX = 'REF_UPDATED_AND_CHANGE_REINDEX',
-  NEVER = 'NEVER',
-}
-
 // TODO(TS): Many properties are omitted here, but they are required.
 // Add default values for missing properties.
 export function createDefaultPreferences() {
diff --git a/polygerrit-ui/app/constants/reporting.ts b/polygerrit-ui/app/constants/reporting.ts
index 0738825..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 {
@@ -90,3 +92,9 @@
   // This measures the same interval as ExpandAllDiffs, but the result is divided by the number of diffs expanded.
   FILE_EXPAND_ALL_AVG = 'ExpandAllPerDiff',
 }
+
+export enum Interaction {
+  TOGGLE_SHOW_ALL_BUTTON = 'toggle show all button',
+  SHOW_TAB = 'show-tab',
+  ATTENTION_SET_CHIP = 'attention-set-chip',
+}
diff --git a/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section_test.js b/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section_test.js
index 97a7fa3..7182ede 100644
--- a/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section_test.js
+++ b/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section_test.js
@@ -423,7 +423,7 @@
             1);
       });
 
-      test('edit section reference', done => {
+      test('edit section reference', async () => {
         element.canUpload = true;
         element.ownerOf = [];
         element.section = {id: 'refs/for/bar', value: {permissions: {}}};
@@ -433,15 +433,13 @@
         assert.isFalse(element._editingRef);
         MockInteractions.tap(element.$.editBtn);
         element.editRefInput().bindValue='new/ref';
-        setTimeout(() => {
-          assert.equal(element.section.id, 'new/ref');
-          assert.isTrue(element._editingRef);
-          assert.isTrue(element.$.section.classList.contains('editingRef'));
-          element.editing = false;
-          assert.isFalse(element._editingRef);
-          assert.equal(element.section.id, 'refs/for/bar');
-          done();
-        });
+        await flush();
+        assert.equal(element.section.id, 'new/ref');
+        assert.isTrue(element._editingRef);
+        assert.isTrue(element.$.section.classList.contains('editingRef'));
+        element.editing = false;
+        assert.isFalse(element._editingRef);
+        assert.equal(element.section.id, 'refs/for/bar');
       });
 
       test('_handleValueChange', () => {
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.ts b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.ts
index 37a2f3e..e0887ce 100644
--- a/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.ts
+++ b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.ts
@@ -65,7 +65,7 @@
   readonly _path = '/admin/groups';
 
   @property({type: Boolean})
-  _hasNewGroupName?: boolean;
+  _hasNewGroupName = false;
 
   @property({type: Boolean})
   _createNewCapability = false;
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list_test.js b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list_test.js
index 2df1ac6..7b7b959 100644
--- a/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list_test.js
+++ b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list_test.js
@@ -104,10 +104,11 @@
   });
 
   suite('test with less then 25 groups', () => {
-    setup(done => {
+    setup(async () => {
       groups = _.times(25, groupGenerator);
       stubRestApi('getGroups').returns(Promise.resolve(groups));
-      element._paramsChanged(value).then(() => { flush(done); });
+      await element._paramsChanged(value);
+      await flush();
     });
 
     test('_shownGroups', () => {
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_test.js b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_test.js
index 69c218c..8ed72fe 100644
--- a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_test.js
+++ b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_test.js
@@ -20,7 +20,7 @@
 import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
 import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
-import {stubBaseUrl, stubRestApi} from '../../../test/test-utils.js';
+import {mockPromise, stubBaseUrl, stubRestApi} from '../../../test/test-utils.js';
 import {GerritView} from '../../../services/router/router-model.js';
 
 const basicFixture = fixtureFromElement('gr-admin-view');
@@ -36,12 +36,13 @@
 suite('gr-admin-view tests', () => {
   let element;
 
-  setup(done => {
+  setup(async () => {
     element = basicFixture.instantiate();
     stubRestApi('getProjectConfig').returns(Promise.resolve({}));
     const pluginsLoaded = Promise.resolve();
     sinon.stub(getPluginLoader(), 'awaitPluginsLoaded').returns(pluginsLoaded);
-    pluginsLoaded.then(() => flush(done));
+    await pluginsLoaded;
+    await flush();
   });
 
   test('_computeURLHelper', () => {
@@ -87,25 +88,23 @@
         .querySelector('gr-admin-create-repo'));
   });
 
-  test('_filteredLinks admin', done => {
+  test('_filteredLinks admin', async () => {
     stubRestApi('getAccount').returns(Promise.resolve({
       name: 'test-user',
     }));
     stubRestApi('getAccountCapabilities').returns(
         Promise.resolve(createAdminCapabilities()));
-    element.reload().then(() => {
-      assert.equal(element._filteredLinks.length, 3);
+    await element.reload();
+    assert.equal(element._filteredLinks.length, 3);
 
-      // Repos
-      assert.isNotOk(element._filteredLinks[0].subsection);
+    // Repos
+    assert.isNotOk(element._filteredLinks[0].subsection);
 
-      // Groups
-      assert.isNotOk(element._filteredLinks[0].subsection);
+    // Groups
+    assert.isNotOk(element._filteredLinks[0].subsection);
 
-      // Plugins
-      assert.isNotOk(element._filteredLinks[0].subsection);
-      done();
-    });
+    // Plugins
+    assert.isNotOk(element._filteredLinks[0].subsection);
   });
 
   test('_filteredLinks non admin authenticated', async () => {
@@ -154,25 +153,23 @@
     });
   });
 
-  test('Repo shows up in nav', done => {
+  test('Repo shows up in nav', async () => {
     element._repoName = 'Test Repo';
     stubRestApi('getAccount').returns(Promise.resolve({
       name: 'test-user',
     }));
     stubRestApi('getAccountCapabilities').returns(
         Promise.resolve(createAdminCapabilities()));
-    element.reload().then(() => {
-      flush();
-      assert.equal(dom(element.root)
-          .querySelectorAll('.sectionTitle').length, 3);
-      assert.equal(element.shadowRoot
-          .querySelector('.breadcrumbText').innerText, 'Test Repo');
-      assert.equal(
-          element.shadowRoot.querySelector('#pageSelect').items.length,
-          6
-      );
-      done();
-    });
+    await element.reload();
+    await flush();
+    assert.equal(dom(element.root)
+        .querySelectorAll('.sectionTitle').length, 3);
+    assert.equal(element.shadowRoot
+        .querySelector('.breadcrumbText').innerText, 'Test Repo');
+    assert.equal(
+        element.shadowRoot.querySelector('#pageSelect').items.length,
+        6
+    );
   });
 
   test('Group shows up in nav', async () => {
@@ -218,23 +215,24 @@
     assert.equal(element.reload.callCount, 1);
   });
 
-  test('Nav is reloaded when group name changes', done => {
+  test('Nav is reloaded when group name changes', async () => {
     const newName = 'newName';
+    const reloadCalled = mockPromise();
     sinon.stub(element, '_computeGroupName');
     sinon.stub(element, 'reload').callsFake(() => {
       assert.equal(element._groupName, newName);
-      assert.isTrue(element.reload.called);
-      done();
+      reloadCalled.resolve();
     });
     element.params = {group: 1, view: GerritNav.View.GROUP};
     element._groupName = 'oldName';
-    flush();
+    await flush();
     element.shadowRoot
         .querySelector('gr-group').dispatchEvent(
             new CustomEvent('name-changed', {
               detail: {name: newName},
               composed: true, bubbles: true,
             }));
+    await reloadCalled;
   });
 
   test('dropdown displays if there is a subsection', () => {
@@ -261,7 +259,7 @@
         'none');
   });
 
-  test('Dropdown only triggers navigation on explicit select', done => {
+  test('Dropdown only triggers navigation on explicit select', async () => {
     element._repoName = 'my-repo';
     element.params = {
       repo: 'my-repo',
@@ -271,7 +269,7 @@
     stubRestApi('getAccountCapabilities').returns(
         Promise.resolve(createAdminCapabilities()));
     stubRestApi('getAccount').returns(Promise.resolve({_id: 1}));
-    flush();
+    await flush();
     const expectedFilteredLinks = [
       {
         name: 'Repositories',
@@ -386,23 +384,21 @@
     sinon.stub(GerritNav, 'navigateToRelativeUrl');
     sinon.spy(element, '_selectedIsCurrentPage');
     sinon.spy(element, '_handleSubsectionChange');
-    element.reload().then(() => {
-      assert.deepEqual(element._filteredLinks, expectedFilteredLinks);
-      assert.deepEqual(element._subsectionLinks, expectedSubsectionLinks);
-      assert.equal(
-          element.shadowRoot.querySelector('#pageSelect').value,
-          'repoaccess'
-      );
-      assert.isTrue(element._selectedIsCurrentPage.calledOnce);
-      // Doesn't trigger navigation from the page select menu.
-      assert.isFalse(GerritNav.navigateToRelativeUrl.called);
+    await element.reload();
+    assert.deepEqual(element._filteredLinks, expectedFilteredLinks);
+    assert.deepEqual(element._subsectionLinks, expectedSubsectionLinks);
+    assert.equal(
+        element.shadowRoot.querySelector('#pageSelect').value,
+        'repoaccess'
+    );
+    assert.isTrue(element._selectedIsCurrentPage.calledOnce);
+    // Doesn't trigger navigation from the page select menu.
+    assert.isFalse(GerritNav.navigateToRelativeUrl.called);
 
-      // When explicitly changed, navigation is called
-      element.shadowRoot.querySelector('#pageSelect').value = 'repo';
-      assert.isTrue(element._selectedIsCurrentPage.calledTwice);
-      assert.isTrue(GerritNav.navigateToRelativeUrl.calledOnce);
-      done();
-    });
+    // When explicitly changed, navigation is called
+    element.shadowRoot.querySelector('#pageSelect').value = 'repo';
+    assert.isTrue(element._selectedIsCurrentPage.calledTwice);
+    assert.isTrue(GerritNav.navigateToRelativeUrl.calledOnce);
   });
 
   test('_selectedIsCurrentPage', () => {
diff --git a/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog.ts b/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog.ts
index d545b4c..f8f186e 100644
--- a/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog.ts
+++ b/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog.ts
@@ -20,13 +20,6 @@
 import {htmlTemplate} from './gr-confirm-delete-item-dialog_html';
 import {customElement, property} from '@polymer/decorators';
 
-// TODO(TS): add description for this
-export enum DetailType {
-  BRANCHES = 'branches',
-  ID = 'id',
-  TAGS = 'tags',
-}
-
 declare global {
   interface HTMLElementTagNameMap {
     'gr-confirm-delete-item-dialog': GrConfirmDeleteItemDialog;
@@ -55,7 +48,7 @@
   item?: string;
 
   @property({type: String})
-  itemType?: DetailType;
+  itemTypeName?: string;
 
   _handleConfirmTap(e: Event) {
     e.preventDefault();
@@ -78,17 +71,4 @@
       })
     );
   }
-
-  _computeItemName(detailType: DetailType) {
-    if (detailType === DetailType.BRANCHES) {
-      return 'Branch';
-    } else if (detailType === DetailType.TAGS) {
-      return 'Tag';
-    } else if (detailType === DetailType.ID) {
-      return 'ID';
-    }
-    // TODO(TS): should never happen, this is to pass:
-    // not all code returns value
-    return '';
-  }
 }
diff --git a/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog_html.ts b/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog_html.ts
index 890d345..b06177d 100644
--- a/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog_html.ts
+++ b/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog_html.ts
@@ -24,18 +24,15 @@
     }
   </style>
   <gr-dialog
-    confirm-label="Delete [[_computeItemName(itemType)]]"
+    confirm-label="Delete [[itemTypeName]]"
     confirm-on-enter=""
     on-confirm="_handleConfirmTap"
     on-cancel="_handleCancelTap"
   >
-    <div class="header" slot="header">
-      [[_computeItemName(itemType)]] Deletion
-    </div>
+    <div class="header" slot="header">[[itemTypeName]] Deletion</div>
     <div class="main" slot="main">
       <label for="branchInput">
-        Do you really want to delete the following
-        [[_computeItemName(itemType)]]?
+        Do you really want to delete the following [[itemTypeName]]?
       </label>
       <div>[[item]]</div>
     </div>
diff --git a/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog_test.js b/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog_test.js
index 485a48b..252821c 100644
--- a/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog_test.js
+++ b/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog_test.js
@@ -56,15 +56,5 @@
     assert.isTrue(element._handleCancelTap.called);
     assert.isTrue(element._handleCancelTap.calledOnce);
   });
-
-  test('_computeItemName function for branches', () => {
-    assert.deepEqual(element._computeItemName('branches'), 'Branch');
-    assert.notEqual(element._computeItemName('branches'), 'Tag');
-  });
-
-  test('_computeItemName function for tags', () => {
-    assert.deepEqual(element._computeItemName('tags'), 'Tag');
-    assert.notEqual(element._computeItemName('tags'), 'Branch');
-  });
 });
 
diff --git a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.ts b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.ts
index f37e6a3..1aa04ac 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.ts
+++ b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.ts
@@ -35,6 +35,12 @@
 import {GrAutocomplete} from '../../shared/gr-autocomplete/gr-autocomplete';
 import {IronAutogrowTextareaElement} from '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
 import {appContext} from '../../../services/app-context';
+import {Subject} from 'rxjs';
+import {
+  repoConfig$,
+  serverConfig$,
+} from '../../../services/config/config-model';
+import {takeUntil} from 'rxjs/operators';
 
 const SUGGESTIONS_LIMIT = 15;
 const REF_PREFIX = 'refs/heads/';
@@ -88,6 +94,8 @@
 
   restApiService = appContext.restApiService;
 
+  disconnected$ = new Subject();
+
   constructor() {
     super();
     this._query = (input: string) => this._getRepoBranchesSuggestions(input);
@@ -96,31 +104,22 @@
   /** @override */
   connectedCallback() {
     super.connectedCallback();
-    if (!this.repoName) {
-      return Promise.resolve();
-    }
+    if (!this.repoName) return;
 
-    const promises = [];
+    repoConfig$.pipe(takeUntil(this.disconnected$)).subscribe(config => {
+      this.privateByDefault = config?.private_by_default;
+    });
 
-    promises.push(
-      this.restApiService.getProjectConfig(this.repoName).then(config => {
-        if (!config) return;
-        this.privateByDefault = config.private_by_default;
-      })
-    );
+    serverConfig$.pipe(takeUntil(this.disconnected$)).subscribe(config => {
+      this._privateChangesEnabled =
+        config?.change?.disable_private_changes ?? false;
+    });
+  }
 
-    promises.push(
-      this.restApiService.getConfig().then(config => {
-        if (!config) {
-          return;
-        }
-
-        this._privateChangesEnabled =
-          config && config.change && !config.change.disable_private_changes;
-      })
-    );
-
-    return Promise.all(promises);
+  /** @override */
+  disconnectedCallback() {
+    this.disconnected$.next();
+    super.disconnectedCallback();
   }
 
   _computeBranchClass(baseChange: boolean) {
diff --git a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog_test.ts b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog_test.ts
index 8a4cbbe..a9de24a 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog_test.ts
+++ b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog_test.ts
@@ -118,19 +118,15 @@
     assert.isTrue(saveStub.called);
   });
 
-  test('_getRepoBranchesSuggestions empty', done => {
-    element._getRepoBranchesSuggestions('nonexistent').then(branches => {
-      assert.equal(branches.length, 0);
-      done();
-    });
+  test('_getRepoBranchesSuggestions empty', async () => {
+    const branches = await element._getRepoBranchesSuggestions('nonexistent');
+    assert.equal(branches.length, 0);
   });
 
-  test('_getRepoBranchesSuggestions non-empty', done => {
-    element._getRepoBranchesSuggestions('test-branch').then(branches => {
-      assert.equal(branches.length, 1);
-      assert.equal(branches[0].name, 'test-branch');
-      done();
-    });
+  test('_getRepoBranchesSuggestions non-empty', async () => {
+    const branches = await element._getRepoBranchesSuggestions('test-branch');
+    assert.equal(branches.length, 1);
+    assert.equal(branches[0].name, 'test-branch');
   });
 
   test('_computeBranchClass', () => {
diff --git a/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog.ts b/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog.ts
index 8875ad4..e483fc4 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog.ts
+++ b/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog.ts
@@ -26,11 +26,7 @@
 import {customElement, property, observe} from '@polymer/decorators';
 import {BranchName, RepoName} from '../../../types/common';
 import {appContext} from '../../../services/app-context';
-
-enum DetailType {
-  branches = 'branches',
-  tags = 'tags',
-}
+import {RepoDetailView} from '../../core/gr-navigation/gr-navigation';
 
 @customElement('gr-create-pointer-dialog')
 export class GrCreatePointerDialog extends PolymerElement {
@@ -48,7 +44,7 @@
   hasNewItemName = false;
 
   @property({type: String})
-  itemDetail?: DetailType;
+  itemDetail?: RepoDetailView.BRANCHES | RepoDetailView.TAGS;
 
   @property({type: String})
   _itemName?: BranchName;
@@ -75,7 +71,7 @@
     }
     const USE_HEAD = this._itemRevision ? this._itemRevision : 'HEAD';
     const url = `${getBaseUrl()}/admin/repos/${encodeURL(this.repoName, true)}`;
-    if (this.itemDetail === DetailType.branches) {
+    if (this.itemDetail === RepoDetailView.BRANCHES) {
       return this.restApiService
         .createRepoBranch(this.repoName, this._itemName, {revision: USE_HEAD})
         .then(itemRegistered => {
@@ -83,7 +79,7 @@
             page.show(`${url},branches`);
           }
         });
-    } else if (this.itemDetail === DetailType.tags) {
+    } else if (this.itemDetail === RepoDetailView.TAGS) {
       return this.restApiService
         .createRepoTag(this.repoName, this._itemName, {
           revision: USE_HEAD,
@@ -98,8 +94,8 @@
     throw new Error(`Invalid itemDetail: ${this.itemDetail}`);
   }
 
-  _computeHideItemClass(type: DetailType) {
-    return type === DetailType.branches ? 'hideItem' : '';
+  _computeHideItemClass(type: RepoDetailView.BRANCHES | RepoDetailView.TAGS) {
+    return type === RepoDetailView.BRANCHES ? 'hideItem' : '';
   }
 }
 
diff --git a/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog_test.js b/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog_test.js
index e6f9bbe..f1babee 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog_test.js
+++ b/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog_test.js
@@ -33,7 +33,7 @@
     assert.isFalse(element.$.parentRepo.bindValue);
   });
 
-  test('repo created', done => {
+  test('repo created', async () => {
     const configInputObj = {
       name: 'test-repo',
       create_empty_commit: true,
@@ -67,16 +67,14 @@
 
     assert.deepEqual(element._repoConfig, configInputObj);
 
-    element.handleCreateRepo().then(() => {
-      assert.isTrue(saveStub.lastCall.calledWithExactly(
-          {
-            ...configInputObj,
-            owners: ['testId'],
-            branches: ['main'],
-          }
-      ));
-      done();
-    });
+    await element.handleCreateRepo();
+    assert.isTrue(saveStub.lastCall.calledWithExactly(
+        {
+          ...configInputObj,
+          owners: ['testId'],
+          branches: ['main'],
+        }
+    ));
   });
 });
 
diff --git a/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log.ts b/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log.ts
index 0bf292d..87696f1 100644
--- a/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log.ts
+++ b/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log.ts
@@ -29,13 +29,13 @@
   AccountInfo,
   EncodedGroupId,
   GroupAuditEventInfo,
+  GroupAuditGroupEventInfo,
+  isGroupAuditGroupEventInfo,
 } from '../../../types/common';
 import {firePageError, fireTitleChange} from '../../../utils/event-util';
 import {appContext} from '../../../services/app-context';
 import {ErrorCallback} from '../../../api/rest';
 
-const GROUP_EVENTS = ['ADD_GROUP', 'REMOVE_GROUP'];
-
 @customElement('gr-group-audit-log')
 export class GrGroupAuditLog extends ListViewMixin(PolymerElement) {
   static get template() {
@@ -103,8 +103,8 @@
     return item;
   }
 
-  _isGroupEvent(type: string) {
-    return GROUP_EVENTS.indexOf(type) !== -1;
+  _isGroupEvent(event: GroupAuditEventInfo): event is GroupAuditGroupEventInfo {
+    return isGroupAuditGroupEventInfo(event);
   }
 
   _computeGroupUrl(group: GroupInfo) {
diff --git a/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log_html.ts b/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log_html.ts
index 32db13f..40c2f30 100644
--- a/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log_html.ts
+++ b/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log_html.ts
@@ -48,12 +48,12 @@
           </td>
           <td class="type">[[itemType(item.type)]]</td>
           <td class="member">
-            <template is="dom-if" if="[[_isGroupEvent(item.type)]]">
+            <template is="dom-if" if="[[_isGroupEvent(item)]]">
               <a href$="[[_computeGroupUrl(item.member)]]">
                 [[_getNameForGroup(item.member)]]
               </a>
             </template>
-            <template is="dom-if" if="[[!_isGroupEvent(item.type)]]">
+            <template is="dom-if" if="[[!_isGroupEvent(item)]]">
               <gr-account-link account="[[item.member]]"></gr-account-link>
               [[_getIdForUser(item.member)]]
             </template>
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.js
deleted file mode 100644
index fdd5d15..0000000
--- a/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log_test.js
+++ /dev/null
@@ -1,96 +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-group-audit-log.js';
-import {stubRestApi, addListenerForTest} from '../../../test/test-utils.js';
-
-const basicFixture = fixtureFromElement('gr-group-audit-log');
-
-suite('gr-group-audit-log tests', () => {
-  let element;
-
-  setup(() => {
-    element = basicFixture.instantiate();
-  });
-
-  suite('members', () => {
-    test('test _getNameForGroup', () => {
-      let group = {
-        member: {
-          name: 'test-name',
-        },
-      };
-      assert.equal(element._getNameForGroup(group.member), 'test-name');
-
-      group = {
-        member: {
-          id: 'test-id',
-        },
-      };
-      assert.equal(element._getNameForGroup(group.member), 'test-id');
-    });
-
-    test('test _isGroupEvent', () => {
-      assert.isTrue(element._isGroupEvent('ADD_GROUP'));
-      assert.isTrue(element._isGroupEvent('REMOVE_GROUP'));
-
-      assert.isFalse(element._isGroupEvent('ADD_USER'));
-      assert.isFalse(element._isGroupEvent('REMOVE_USER'));
-    });
-  });
-
-  suite('users', () => {
-    test('test _getIdForUser', () => {
-      const account = {
-        user: {
-          username: 'test-user',
-          _account_id: 12,
-        },
-      };
-      assert.equal(element._getIdForUser(account.user), ' (12)');
-    });
-
-    test('test _account_id not present', () => {
-      const account = {
-        user: {
-          username: 'test-user',
-        },
-      };
-      assert.equal(element._getIdForUser(account.user), '');
-    });
-  });
-
-  suite('404', () => {
-    test('fires page-error', done => {
-      element.groupId = 1;
-
-      const response = {status: 404};
-      stubRestApi('getGroupAuditLog').callsFake((group, errFn) => {
-        errFn(response);
-      });
-
-      addListenerForTest(document, 'page-error', e => {
-        assert.deepEqual(e.detail.response, response);
-        done();
-      });
-
-      element._getAuditLogs();
-    });
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log_test.ts b/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log_test.ts
new file mode 100644
index 0000000..dc09390
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log_test.ts
@@ -0,0 +1,125 @@
+/**
+ * @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-group-audit-log.js';
+import {
+  addListenerForTest,
+  mockPromise,
+  stubRestApi,
+} from '../../../test/test-utils.js';
+import {GrGroupAuditLog} from './gr-group-audit-log.js';
+import {
+  EncodedGroupId,
+  GroupAuditEventType,
+  GroupInfo,
+  GroupName,
+} from '../../../types/common.js';
+import {
+  createAccountWithId,
+  createGroupAuditEventInfo,
+  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: GrGroupAuditLog;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+  });
+
+  suite('members', () => {
+    test('test _getNameForGroup', () => {
+      let member: GroupInfo = {
+        ...createGroupInfo(),
+        name: 'test-name' as GroupName,
+      };
+      assert.equal(element._getNameForGroup(member), 'test-name');
+
+      member = createGroupInfo('test-id');
+      assert.equal(element._getNameForGroup(member), 'test-id');
+    });
+
+    test('test _isGroupEvent', () => {
+      assert.isTrue(
+        element._isGroupEvent(
+          createGroupAuditEventInfo(GroupAuditEventType.ADD_GROUP)
+        )
+      );
+      assert.isTrue(
+        element._isGroupEvent(
+          createGroupAuditEventInfo(GroupAuditEventType.REMOVE_GROUP)
+        )
+      );
+
+      assert.isFalse(
+        element._isGroupEvent(
+          createGroupAuditEventInfo(GroupAuditEventType.ADD_USER)
+        )
+      );
+      assert.isFalse(
+        element._isGroupEvent(
+          createGroupAuditEventInfo(GroupAuditEventType.REMOVE_USER)
+        )
+      );
+    });
+  });
+
+  suite('users', () => {
+    test('test _getIdForUser', () => {
+      const user = {
+        ...createAccountWithId(12),
+        username: 'test-user',
+      };
+      assert.equal(element._getIdForUser(user), ' (12)');
+    });
+
+    test('test _account_id not present', () => {
+      const account = {
+        user: {
+          username: 'test-user',
+        },
+      };
+      assert.equal(element._getIdForUser(account.user), '');
+    });
+  });
+
+  suite('404', () => {
+    test('fires page-error', async () => {
+      element.groupId = '1' as EncodedGroupId;
+      await flush();
+
+      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 as PageErrorEvent).detail.response, response);
+        pageErrorCalled.resolve();
+      });
+
+      element._getAuditLogs();
+      await pageErrorCalled;
+    });
+  });
+});
diff --git a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.ts b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.ts
index bc793fa..0a862eb 100644
--- a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.ts
+++ b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.ts
@@ -48,6 +48,7 @@
 } from '../../../utils/event-util';
 import {appContext} from '../../../services/app-context';
 import {ErrorCallback} from '../../../api/rest';
+import {assertNever} from '../../../utils/common-util';
 
 const SUGGESTIONS_LIMIT = 15;
 const SAVING_ERROR_TEXT =
@@ -55,6 +56,11 @@
 
 const URL_REGEX = '^(?:[a-z]+:)?//';
 
+export enum ItemType {
+  MEMBER = 'member',
+  INCLUDED_GROUP = 'includedGroup',
+}
+
 export interface GrGroupMembers {
   $: {
     overlay: GrOverlay;
@@ -94,10 +100,10 @@
   _includedGroups?: GroupInfo[];
 
   @property({type: String})
-  _itemName?: GroupInfo | AccountInfo;
+  _itemName?: string;
 
   @property({type: String})
-  _itemType?: string;
+  _itemType?: ItemType;
 
   @property({type: Object})
   _queryMembers: AutocompleteQuery;
@@ -229,7 +235,7 @@
       return Promise.reject(new Error('group name undefined'));
     }
     this.$.overlay.close();
-    if (this._itemType === 'member') {
+    if (this._itemType === ItemType.MEMBER) {
       return this.restApiService
         .deleteGroupMember(this._groupName, this._itemId! as AccountId)
         .then(itemDeleted => {
@@ -241,7 +247,7 @@
               });
           }
         });
-    } else if (this._itemType === 'includedGroup') {
+    } else if (this._itemType === ItemType.INCLUDED_GROUP) {
       return this.restApiService
         .deleteIncludedGroup(this._groupName, this._itemId! as GroupId)
         .then(itemDeleted => {
@@ -260,22 +266,34 @@
     return Promise.reject(new Error('Unrecognized item type'));
   }
 
+  _computeItemTypeName(itemType?: ItemType): string {
+    if (itemType === undefined) return '';
+    switch (itemType) {
+      case ItemType.INCLUDED_GROUP:
+        return 'Included Group';
+      case ItemType.MEMBER:
+        return 'Member';
+      default:
+        assertNever(itemType, 'unknown item type: ${itemType}');
+    }
+  }
+
   _handleConfirmDialogCancel() {
     this.$.overlay.close();
   }
 
   _handleDeleteMember(e: PolymerDomRepeatEvent<AccountInfo>) {
-    const id = (e.model.get('item._account_id') as unknown) as AccountId;
+    const id = e.model.get('item._account_id');
     const name = e.model.get('item.name');
     const username = e.model.get('item.username');
     const email = e.model.get('item.email');
-    const item = username || name || email || id;
+    const item = username || name || email || id?.toString();
     if (!item) {
       return;
     }
     this._itemName = item;
     this._itemId = id;
-    this._itemType = 'member';
+    this._itemType = ItemType.MEMBER;
     this.$.overlay.open();
   }
 
@@ -326,7 +344,7 @@
     }
     this._itemName = item;
     this._itemId = id;
-    this._itemType = 'includedGroup';
+    this._itemType = ItemType.INCLUDED_GROUP;
     this.$.overlay.open();
   }
 
diff --git a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_html.ts b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_html.ts
index 47da237..0c856bc 100644
--- a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_html.ts
+++ b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_html.ts
@@ -175,7 +175,7 @@
       on-confirm="_handleDeleteConfirm"
       on-cancel="_handleConfirmDialogCancel"
       item="[[_itemName]]"
-      item-type="[[_itemType]]"
+      item-type-name="[[_computeItemTypeName(_itemType)]]"
     ></gr-confirm-delete-item-dialog>
   </gr-overlay>
 `;
diff --git a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_test.js b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_test.js
index 672ac07..54c5099 100644
--- a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_test.js
+++ b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_test.js
@@ -19,6 +19,7 @@
 import './gr-group-members.js';
 import {dom, flush} from '@polymer/polymer/lib/legacy/polymer.dom.js';
 import {addListenerForTest, stubBaseUrl, stubRestApi} from '../../../test/test-utils.js';
+import {ItemType} from './gr-group-members.js';
 
 const basicFixture = fixtureFromElement('gr-group-members');
 
@@ -359,5 +360,11 @@
 
     element._loadGroupDetails();
   });
+
+  test('_computeItemName', () => {
+    assert.equal(element._computeItemTypeName(ItemType.MEMBER), 'Member');
+    assert.equal(element._computeItemTypeName(ItemType.INCLUDED_GROUP),
+        'Included Group');
+  });
 });
 
diff --git a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission_test.js b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission_test.js
index d5668d8..ab6a414 100644
--- a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission_test.js
+++ b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission_test.js
@@ -187,35 +187,31 @@
           groupsWithRules);
     });
 
-    test('_getGroupSuggestions without existing rules', done => {
+    test('_getGroupSuggestions without existing rules', async () => {
       element._groupsWithRules = {};
 
-      element._getGroupSuggestions().then(groups => {
-        assert.deepEqual(groups, [
-          {
-            name: 'Administrators',
-            value: '4c97682e6ce61b7247f3381b6f1789356666de7f',
-          }, {
-            name: 'Anonymous Users',
-            value: 'global%3AAnonymous-Users',
-          },
-        ]);
-        done();
-      });
+      const groups = await element._getGroupSuggestions();
+      assert.deepEqual(groups, [
+        {
+          name: 'Administrators',
+          value: '4c97682e6ce61b7247f3381b6f1789356666de7f',
+        }, {
+          name: 'Anonymous Users',
+          value: 'global%3AAnonymous-Users',
+        },
+      ]);
     });
 
-    test('_getGroupSuggestions with existing rules filters them', done => {
+    test('_getGroupSuggestions with existing rules filters them', async () => {
       element._groupsWithRules = {
         '4c97682e6ce61b7247f3381b6f1789356666de7f': true,
       };
 
-      element._getGroupSuggestions().then(groups => {
-        assert.deepEqual(groups, [{
-          name: 'Anonymous Users',
-          value: 'global%3AAnonymous-Users',
-        }]);
-        done();
-      });
+      const groups = await element._getGroupSuggestions();
+      assert.deepEqual(groups, [{
+        name: 'Anonymous Users',
+        value: 'global%3AAnonymous-Users',
+      }]);
     });
 
     test('_handleRemovePermission', () => {
diff --git a/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor.ts b/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor.ts
index 248a0e3..2d3b5c3 100644
--- a/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor.ts
+++ b/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor.ts
@@ -36,7 +36,7 @@
 }
 
 @customElement('gr-plugin-config-array-editor')
-class GrPluginConfigArrayEditor extends PolymerElement {
+export class GrPluginConfigArrayEditor extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
@@ -56,7 +56,7 @@
   pluginOption!: ArrayPluginOption;
 
   @property({type: Boolean, computed: '_computeDisabled(pluginOption.*)'})
-  disabled?: boolean;
+  disabled!: boolean; // _computeDisabled never returns null
 
   _computeDisabled(
     record: PolymerDeepPropertyChange<ArrayPluginOption, ArrayPluginOption>
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards.ts b/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards.ts
index 815e3ac..25c7297 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards.ts
@@ -109,7 +109,7 @@
     return project === definingProject ? '' : definingProject;
   }
 
-  _computeIsDefault(isDefault: boolean) {
+  _computeIsDefault(isDefault?: boolean) {
     return isDefault ? '✓' : '';
   }
 }
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards_html.ts b/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards_html.ts
index 51ea417..6657a20 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards_html.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards_html.ts
@@ -51,17 +51,17 @@
         <tr class="groupHeader">
           <td colspan="5">[[item.section]]</td>
         </tr>
-        <template is="dom-repeat" items="[[item.dashboards]]">
+        <template is="dom-repeat" items="[[item.dashboards]]" as="info">
           <tr class="table">
             <td class="name">
-              <a href$="[[_getUrl(item.project, item.id)]]">[[item.path]]</a>
+              <a href$="[[_getUrl(info.project, info.id)]]">[[info.path]]</a>
             </td>
-            <td class="title">[[item.title]]</td>
-            <td class="desc">[[item.description]]</td>
+            <td class="title">[[info.title]]</td>
+            <td class="desc">[[info.description]]</td>
             <td class="inherited">
-              [[_computeInheritedFrom(item.project, item.defining_project)]]
+              [[_computeInheritedFrom(info.project, info.defining_project)]]
             </td>
-            <td class="default">[[_computeIsDefault(item.is_default)]]</td>
+            <td class="default">[[_computeIsDefault(info.is_default)]]</td>
           </tr>
         </template>
       </template>
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list.ts b/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list.ts
index bb65f98..8511b90 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list.ts
@@ -69,7 +69,7 @@
   params?: AppElementRepoParams;
 
   @property({type: String})
-  detailType?: RepoDetailView;
+  detailType?: RepoDetailView.BRANCHES | RepoDetailView.TAGS;
 
   @property({type: Boolean})
   _editing = false;
@@ -89,8 +89,11 @@
   @property({type: Array})
   _items?: BranchInfo[] | TagInfo[];
 
+  // _shownItems should be BranchInfo[] | TagInfo[],
+  // but TS incorrectly assumes that in the loop for(const item of _shownItems)
+  // item has type BranchInfo, not BranchInfo | TagInfo.
   @property({type: Array, computed: 'computeShownItems(_items)'})
-  _shownItems?: BranchInfo[] | TagInfo[];
+  _shownItems?: Array<BranchInfo | TagInfo>;
 
   @property({type: Number})
   _itemsPerPage = 25;
@@ -105,10 +108,10 @@
   _refName?: GitRef;
 
   @property({type: Boolean})
-  _hasNewItemName?: boolean;
+  _hasNewItemName = false;
 
   @property({type: Boolean})
-  _isEditing?: boolean;
+  _isEditing = false;
 
   @property({type: String})
   _revisedRef?: GitRef;
@@ -204,11 +207,11 @@
     return Promise.reject(new Error('unknown detail type'));
   }
 
-  _getPath(repo: RepoName) {
-    return `/admin/repos/${encodeURL(repo, false)},${this.detailType}`;
+  _getPath(repo?: RepoName, detailType?: RepoDetailView) {
+    return `/admin/repos/${encodeURL(repo ?? '', false)},${detailType}`;
   }
 
-  _computeWeblink(repo: ProjectInfo) {
+  _computeWeblink(repo: ProjectInfo | BranchInfo | TagInfo) {
     if (!repo.web_links) {
       return '';
     }
@@ -216,7 +219,7 @@
     return webLinks.length ? webLinks : null;
   }
 
-  _computeFirstWebLink(repo: ProjectInfo) {
+  _computeFirstWebLink(repo: ProjectInfo | BranchInfo | TagInfo) {
     const webLinks = this._computeWeblink(repo);
     return webLinks ? webLinks[0].url : null;
   }
@@ -229,7 +232,7 @@
     return message.split(PGP_START)[0];
   }
 
-  _stripRefs(item: GitRef, detailType?: string) {
+  _stripRefs(item: GitRef, detailType?: RepoDetailView) {
     if (detailType === RepoDetailView.BRANCHES) {
       return item.replace('refs/heads/', '');
     } else if (detailType === RepoDetailView.TAGS) {
@@ -246,7 +249,12 @@
     return isEditing ? 'editing' : '';
   }
 
-  _computeCanEditClass(ref: GitRef, detailType: string, isOwner: boolean) {
+  _computeCanEditClass(
+    ref?: GitRef,
+    detailType?: RepoDetailView,
+    isOwner?: boolean
+  ) {
+    if (ref === undefined || detailType === undefined) return '';
     return isOwner && this._stripRefs(ref, detailType) === 'HEAD'
       ? 'canEdit'
       : '';
@@ -261,12 +269,16 @@
     this._isEditing = false;
   }
 
-  _handleSaveRevision(e: PolymerDomRepeatEvent<GitRef>) {
+  _handleSaveRevision(e: PolymerDomRepeatEvent<BranchInfo | TagInfo>) {
     if (this._revisedRef && this._repo)
       this._setRepoHead(this._repo, this._revisedRef, e);
   }
 
-  _setRepoHead(repo: RepoName, ref: GitRef, e: PolymerDomRepeatEvent<GitRef>) {
+  _setRepoHead(
+    repo: RepoName,
+    ref: GitRef,
+    e: PolymerDomRepeatEvent<BranchInfo | TagInfo>
+  ) {
     return this.restApiService.setRepoHead(repo, ref).then(res => {
       if (res.status < 400) {
         this._isEditing = false;
@@ -284,7 +296,8 @@
     });
   }
 
-  _computeItemName(detailType: string) {
+  _computeItemName(detailType?: RepoDetailView) {
+    if (detailType === undefined) return '';
     if (detailType === RepoDetailView.BRANCHES) {
       return 'Branch';
     } else if (detailType === RepoDetailView.TAGS) {
@@ -334,7 +347,7 @@
     this.$.overlay.close();
   }
 
-  _handleDeleteItem(e: PolymerDomRepeatEvent<GitRef>) {
+  _handleDeleteItem(e: PolymerDomRepeatEvent<BranchInfo | TagInfo>) {
     const name = this._stripRefs(
       e.model.get('item.ref'),
       this.detailType
@@ -346,7 +359,7 @@
     this.$.overlay.open();
   }
 
-  _computeHideDeleteClass(owner: boolean, canDelete: boolean) {
+  _computeHideDeleteClass(owner?: boolean, canDelete?: boolean) {
     if (canDelete || owner) {
       return 'show';
     }
@@ -367,7 +380,7 @@
     this.$.createOverlay.open();
   }
 
-  _hideIfBranch(type: string) {
+  _hideIfBranch(type?: RepoDetailView) {
     if (type === RepoDetailView.BRANCHES) {
       return 'hideItem';
     }
@@ -375,7 +388,7 @@
     return '';
   }
 
-  _computeHideTagger(tagger: GitPersonInfo) {
+  _computeHideTagger(tagger?: GitPersonInfo) {
     return tagger ? '' : 'hide';
   }
 }
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list_html.ts b/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list_html.ts
index 2ac32c0..4f66f0d 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list_html.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list_html.ts
@@ -188,7 +188,7 @@
         on-confirm="_handleDeleteItemConfirm"
         on-cancel="_handleConfirmDialogCancel"
         item="[[_refName]]"
-        item-type="[[detailType]]"
+        item-type-name="[[_computeItemName(detailType)]]"
       ></gr-confirm-delete-item-dialog>
     </gr-overlay>
   </gr-list-view>
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list_test.js b/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list_test.js
index 990ea4b..1af9c02 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list_test.js
+++ b/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list_test.js
@@ -21,6 +21,7 @@
 import {page} from '../../../utils/page-wrapper-utils.js';
 import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
 import {addListenerForTest, stubRestApi} from '../../../test/test-utils.js';
+import {RepoDetailView} from '../../core/gr-navigation/gr-navigation.js';
 
 const basicFixture = fixtureFromElement('gr-repo-detail-list');
 
@@ -508,6 +509,12 @@
       assert.deepEqual(element._computeHideDeleteClass(false, true), 'show');
       assert.deepEqual(element._computeHideDeleteClass(false, false), '');
     });
+
+    test('_computeItemName', () => {
+      assert.equal(element._computeItemName(RepoDetailView.BRANCHES), 'Branch');
+      assert.equal(element._computeItemName(RepoDetailView.TAGS),
+          'Tag');
+    });
   });
 });
 
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_test.js b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_test.js
index c8e058e..4864af5 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_test.js
+++ b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_test.js
@@ -23,20 +23,22 @@
 
 const basicFixture = fixtureFromElement('gr-repo-list');
 
-let counter;
-const repoGenerator = () => {
+function createRepo(name, counter) {
   return {
-    id: `test${++counter}`,
-    name: `test`,
+    id: `${name}${counter}`,
+    name: `${name}`,
     state: 'ACTIVE',
     web_links: [
       {
         name: 'diffusion',
-        url: `https://phabricator.example.org/r/project/test${counter}`,
+        url: `https://phabricator.example.org/r/project/${name}${counter}`,
       },
     ],
   };
-};
+}
+
+let counter;
+const repoGenerator = () => createRepo('test', ++counter);
 
 suite('gr-repo-list tests', () => {
   let element;
@@ -123,6 +125,15 @@
         done();
       });
     });
+
+    test('filter is case insensitive', async () => {
+      const repoStub = stubRestApi('getRepos');
+      const repos = [createRepo('aSDf', 0)];
+      repoStub.withArgs('asdf').returns(Promise.resolve(repos));
+      element._filter = 'asdf';
+      await element._getRepos('asdf', 25, 0);
+      assert.equal(element._repos.length, 1);
+    });
   });
 
   suite('loading', () => {
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config.ts b/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config.ts
index 66e4ed6..083a1d8 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config.ts
@@ -60,7 +60,7 @@
 }
 
 @customElement('gr-repo-plugin-config')
-class GrRepoPluginConfig extends PolymerElement {
+export class GrRepoPluginConfig extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
diff --git a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_html.ts b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_html.ts
index b88eeaf..ef0e6b4 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_html.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_html.ts
@@ -21,6 +21,9 @@
     /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
   </style>
   <style include="gr-subpage-styles">
+    .info {
+      margin-bottom: var(--spacing-xl);
+    }
     h2.edited:after {
       color: var(--deemphasized-text-color);
       content: ' *';
diff --git a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_test.js b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_test.js
index c299b55..89ad86e 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_test.js
+++ b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_test.js
@@ -17,6 +17,7 @@
 
 import '../../../test/common-test-setup-karma.js';
 import './gr-repo.js';
+import {mockPromise} from '../../../test/test-utils.js';
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 import {addListenerForTest, stubRestApi} from '../../../test/test-utils.js';
 
@@ -226,21 +227,24 @@
     ]);
   });
 
-  test('fires page-error', done => {
+  test('fires page-error', async () => {
     repoStub.restore();
 
     element.repo = 'test';
 
+    const pageErrorFired = mockPromise();
     const response = {status: 404};
     stubRestApi('getProjectConfig').callsFake((repo, errFn) => {
       errFn(response);
+      return Promise.resolve(undefined);
     });
     addListenerForTest(document, 'page-error', e => {
       assert.deepEqual(e.detail.response, response);
-      done();
+      pageErrorFired.resolve();
     });
 
     element._loadRepo();
+    await pageErrorFired;
   });
 
   suite('admin', () => {
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.ts
index 2869750..fa3c9c6 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.ts
@@ -48,6 +48,7 @@
 } from '../../../types/common';
 import {hasOwnProperty} from '../../../utils/common-util';
 import {pluralize} from '../../../utils/string-util';
+import {ChangeStates} from '../../shared/gr-change-status/gr-change-status';
 
 enum ChangeSize {
   XS = 10,
@@ -105,7 +106,7 @@
   changeURL?: string;
 
   @property({type: Array, computed: '_changeStatuses(change)'})
-  statuses?: string[];
+  statuses?: ChangeStates[];
 
   @property({type: Boolean})
   showStar = false;
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-change-list/gr-change-list.ts b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.ts
index 8c33f52..56d0105 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.ts
@@ -138,7 +138,7 @@
   @property({type: Object})
   _config?: ServerInfo;
 
-  flagsService = appContext.flagsService;
+  private readonly flagsService = appContext.flagsService;
 
   private readonly restApiService = appContext.restApiService;
 
diff --git a/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help.ts b/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help.ts
index 0e2668d..8582b5a 100644
--- a/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help.ts
@@ -30,7 +30,7 @@
 }
 
 @customElement('gr-create-change-help')
-class GrCreateChangeHelp extends PolymerElement {
+export class GrCreateChangeHelp extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
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-list/gr-dashboard-view/gr-dashboard-view.ts b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.ts
index d6f4bdd..41a7598 100644
--- a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.ts
@@ -252,7 +252,7 @@
       })
       .catch(err => {
         fireTitleChange(this, title || this._computeTitle(user));
-        console.warn(err);
+        this.reporting.error(err);
       })
       .then(() => {
         this._loading = false;
diff --git a/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header.ts b/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header.ts
index 6d7b5b1..16a605e 100644
--- a/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header.ts
@@ -27,7 +27,7 @@
 import {appContext} from '../../../services/app-context';
 
 @customElement('gr-repo-header')
-class GrRepoHeader extends PolymerElement {
+export class GrRepoHeader extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
diff --git a/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header_html.ts b/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header_html.ts
index 1924a66..42a6847 100644
--- a/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header_html.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header_html.ts
@@ -29,7 +29,7 @@
   </style>
   <gr-avatar
     account="[[_accountDetails]]"
-    image-size="100"
+    imageSize="100"
     aria-label="Account avatar"
   ></gr-avatar>
   <div class="info">
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts
index eb979a1..fce7db9 100644
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts
@@ -380,7 +380,7 @@
 
   RevisionActions = RevisionActions;
 
-  reporting = appContext.reportingService;
+  private readonly reporting = appContext.reportingService;
 
   private readonly jsAPI = appContext.jsApiService;
 
@@ -1486,6 +1486,7 @@
   }
 
   _handleDeleteConfirm() {
+    this._hideAllDialogs();
     this._fireAction(
       '/',
       assertUIActionInfo(this.actions[ChangeActions.DELETE]),
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.ts b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.ts
index 1790651..e13b8b9 100644
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.ts
@@ -30,9 +30,11 @@
 } from '../../../test/test-data-generators';
 import {ChangeStatus, HttpMethod} from '../../../constants/constants';
 import {
+  mockPromise,
   query,
   queryAll,
   queryAndAssert,
+  stubReporting,
   stubRestApi,
 } from '../../../test/test-utils';
 import {assertUIActionInfo, GrChangeActions} from './gr-change-actions';
@@ -144,13 +146,11 @@
       return element.reload();
     });
 
-    test('show-revision-actions event should fire', done => {
+    test('show-revision-actions event should fire', async () => {
       const spy = sinon.spy(element, '_sendShowRevisionActions');
       element.reload();
-      flush(() => {
-        assert.isTrue(spy.called);
-        done();
-      });
+      await flush();
+      assert.isTrue(spy.called);
     });
 
     test('primary and secondary actions split properly', () => {
@@ -198,7 +198,7 @@
       );
     });
 
-    test('plugin revision actions', done => {
+    test('plugin revision actions', async () => {
       const stub = stubRestApi('getChangeActionURL').returns(
         Promise.resolve('the-url')
       );
@@ -206,20 +206,18 @@
         'plugin~action': {},
       };
       assert.isOk(element.revisionActions['plugin~action']);
-      flush(() => {
-        assert.isTrue(
-          stub.calledWith(
-            element.changeNum,
-            element.latestPatchNum,
-            '/plugin~action'
-          )
-        );
-        assert.equal(
-          (element.revisionActions['plugin~action'] as UIActionInfo)!.__url,
-          'the-url'
-        );
-        done();
-      });
+      await flush();
+      assert.isTrue(
+        stub.calledWith(
+          element.changeNum,
+          element.latestPatchNum,
+          '/plugin~action'
+        )
+      );
+      assert.equal(
+        (element.revisionActions['plugin~action'] as UIActionInfo)!.__url,
+        'the-url'
+      );
     });
 
     test('plugin change actions', async () => {
@@ -266,71 +264,61 @@
       );
     });
 
-    test('hide revision action', done => {
-      flush(() => {
-        const buttonEl = queryAndAssert(element, '[data-action-key="submit"]');
-        assert.isOk(buttonEl);
-        element.setActionHidden(
-          element.ActionType.REVISION,
-          element.RevisionActions.SUBMIT,
-          true
-        );
-        assert.lengthOf(element._hiddenActions, 1);
-        element.setActionHidden(
-          element.ActionType.REVISION,
-          element.RevisionActions.SUBMIT,
-          true
-        );
-        assert.lengthOf(element._hiddenActions, 1);
-        flush(() => {
-          const buttonEl = element.shadowRoot?.querySelector(
-            '[data-action-key="submit"]'
-          );
-          assert.isNotOk(buttonEl);
+    test('hide revision action', async () => {
+      await flush();
+      let buttonEl: Element | undefined = queryAndAssert(
+        element,
+        '[data-action-key="submit"]'
+      );
+      assert.isOk(buttonEl);
+      element.setActionHidden(
+        element.ActionType.REVISION,
+        element.RevisionActions.SUBMIT,
+        true
+      );
+      assert.lengthOf(element._hiddenActions, 1);
+      element.setActionHidden(
+        element.ActionType.REVISION,
+        element.RevisionActions.SUBMIT,
+        true
+      );
+      assert.lengthOf(element._hiddenActions, 1);
+      await flush();
+      buttonEl = query(element, '[data-action-key="submit"]');
+      assert.isNotOk(buttonEl);
 
-          element.setActionHidden(
-            element.ActionType.REVISION,
-            element.RevisionActions.SUBMIT,
-            false
-          );
-          flush(() => {
-            const buttonEl = queryAndAssert(
-              element,
-              '[data-action-key="submit"]'
-            );
-            assert.isFalse(buttonEl.hasAttribute('hidden'));
-            done();
-          });
-        });
-      });
+      element.setActionHidden(
+        element.ActionType.REVISION,
+        element.RevisionActions.SUBMIT,
+        false
+      );
+      await flush();
+      buttonEl = queryAndAssert(element, '[data-action-key="submit"]');
+      assert.isFalse(buttonEl.hasAttribute('hidden'));
     });
 
-    test('buttons exist', done => {
+    test('buttons exist', async () => {
       element._loading = false;
-      flush(() => {
-        const buttonEls = queryAll(element, 'gr-button');
-        const menuItems = element.$.moreActions.items;
+      await flush();
+      const buttonEls = queryAll(element, 'gr-button');
+      const menuItems = element.$.moreActions.items;
 
-        // Total button number is one greater than the number of total actions
-        // due to the existence of the overflow menu trigger.
-        assert.equal(
-          buttonEls!.length + menuItems!.length,
-          element._allActionValues.length + 1
-        );
-        assert.isFalse(element.hidden);
-        done();
-      });
+      // Total button number is one greater than the number of total actions
+      // due to the existence of the overflow menu trigger.
+      assert.equal(
+        buttonEls!.length + menuItems!.length,
+        element._allActionValues.length + 1
+      );
+      assert.isFalse(element.hidden);
     });
 
-    test('delete buttons have explicit labels', done => {
-      flush(() => {
-        const deleteItems = element.$.moreActions.items!.filter(item =>
-          item.id!.startsWith('delete')
-        );
-        assert.equal(deleteItems.length, 1);
-        assert.equal(deleteItems[0].name, 'Delete change');
-        done();
-      });
+    test('delete buttons have explicit labels', async () => {
+      await flush();
+      const deleteItems = element.$.moreActions.items!.filter(item =>
+        item.id!.startsWith('delete')
+      );
+      assert.equal(deleteItems.length, 1);
+      assert.equal(deleteItems[0].name, 'Delete change');
     });
 
     test('get revision object from change', () => {
@@ -369,7 +357,7 @@
       assert.deepEqual(result, actions);
     });
 
-    test('submit change', () => {
+    test('submit change', async () => {
       const showSpy = sinon.spy(element, '_showActionDialog');
       stubRestApi('getFromProjectLookup').returns(
         Promise.resolve('test' as RepoName)
@@ -390,12 +378,15 @@
       );
       tap(submitButton);
 
-      flush();
+      await flush();
       assert.isTrue(showSpy.calledWith(element.$.confirmSubmitDialog));
     });
 
-    test('submit change, tap on icon', done => {
-      sinon.stub(element.$.confirmSubmitDialog, 'resetFocus').callsFake(done);
+    test('submit change, tap on icon', async () => {
+      const submitted = mockPromise();
+      sinon
+        .stub(element.$.confirmSubmitDialog, 'resetFocus')
+        .callsFake(() => submitted.resolve());
       stubRestApi('getFromProjectLookup').returns(
         Promise.resolve('test' as RepoName)
       );
@@ -414,6 +405,7 @@
         'gr-button[data-action-key="submit"] iron-icon'
       );
       tap(submitIcon);
+      await submitted;
     });
 
     test('_handleSubmitConfirm', () => {
@@ -435,19 +427,16 @@
       assert.isFalse(fireStub.called);
     });
 
-    test('submit change with plugin hook', done => {
+    test('submit change with plugin hook', async () => {
       sinon.stub(element, '_canSubmitChange').callsFake(() => false);
       const fireActionStub = sinon.stub(element, '_fireAction');
-      flush(() => {
-        const submitButton = queryAndAssert(
-          element,
-          'gr-button[data-action-key="submit"]'
-        );
-        tap(submitButton);
-        assert.equal(fireActionStub.callCount, 0);
-
-        done();
-      });
+      await flush();
+      const submitButton = queryAndAssert(
+        element,
+        'gr-button[data-action-key="submit"]'
+      );
+      tap(submitButton);
+      assert.equal(fireActionStub.callCount, 0);
     });
 
     test('chain state', () => {
@@ -490,55 +479,51 @@
       );
     });
 
-    test('rebase change', done => {
+    test('rebase change', async () => {
       const fireActionStub = sinon.stub(element, '_fireAction');
       const fetchChangesStub = sinon
         .stub(element.$.confirmRebase, 'fetchRecentChanges')
         .returns(Promise.resolve([]));
       element._hasKnownChainState = true;
-      flush(() => {
-        const rebaseButton = queryAndAssert(
-          element,
-          'gr-button[data-action-key="rebase"]'
-        );
-        tap(rebaseButton);
-        const rebaseAction = {
-          __key: 'rebase',
-          __type: 'revision',
-          __primary: false,
-          enabled: true,
-          label: 'Rebase',
-          method: HttpMethod.POST,
-          title: 'Rebase onto tip of branch or parent change',
-        };
-        assert.isTrue(fetchChangesStub.called);
-        element._handleRebaseConfirm(
-          new CustomEvent('', {detail: {base: '1234'}})
-        );
-        assert.deepEqual(fireActionStub.lastCall.args, [
-          '/rebase',
-          assertUIActionInfo(rebaseAction),
-          true,
-          {base: '1234'},
-        ]);
-        done();
-      });
+      await flush();
+      const rebaseButton = queryAndAssert(
+        element,
+        'gr-button[data-action-key="rebase"]'
+      );
+      tap(rebaseButton);
+      const rebaseAction = {
+        __key: 'rebase',
+        __type: 'revision',
+        __primary: false,
+        enabled: true,
+        label: 'Rebase',
+        method: HttpMethod.POST,
+        title: 'Rebase onto tip of branch or parent change',
+      };
+      assert.isTrue(fetchChangesStub.called);
+      element._handleRebaseConfirm(
+        new CustomEvent('', {detail: {base: '1234'}})
+      );
+      assert.deepEqual(fireActionStub.lastCall.args, [
+        '/rebase',
+        assertUIActionInfo(rebaseAction),
+        true,
+        {base: '1234'},
+      ]);
     });
 
-    test('rebase change fires reload event', done => {
+    test('rebase change fires reload event', async () => {
       const eventStub = sinon.stub(element, 'dispatchEvent');
       element._handleResponse(
         {__key: 'rebase', __type: ActionType.CHANGE, label: 'l'},
         new Response()
       );
-      flush(() => {
-        assert.isTrue(eventStub.called);
-        assert.equal(eventStub.lastCall.args[0].type, 'reload');
-        done();
-      });
+      await flush();
+      assert.isTrue(eventStub.called);
+      assert.equal(eventStub.lastCall.args[0].type, 'reload');
     });
 
-    test("rebase dialog gets recent changes each time it's opened", done => {
+    test("rebase dialog gets recent changes each time it's opened", async () => {
       const fetchChangesStub = sinon
         .stub(element.$.confirmRebase, 'fetchRecentChanges')
         .returns(Promise.resolve([]));
@@ -550,17 +535,15 @@
       tap(rebaseButton);
       assert.isTrue(fetchChangesStub.calledOnce);
 
-      flush(() => {
-        element.$.confirmRebase.dispatchEvent(
-          new CustomEvent('cancel', {
-            composed: true,
-            bubbles: true,
-          })
-        );
-        tap(rebaseButton);
-        assert.isTrue(fetchChangesStub.calledTwice);
-        done();
-      });
+      await flush();
+      element.$.confirmRebase.dispatchEvent(
+        new CustomEvent('cancel', {
+          composed: true,
+          bubbles: true,
+        })
+      );
+      tap(rebaseButton);
+      assert.isTrue(fetchChangesStub.calledTwice);
     });
 
     test('two dialogs are not shown at the same time', async () => {
@@ -624,7 +607,7 @@
     });
 
     suite('change edits', () => {
-      test('disableEdit', () => {
+      test('disableEdit', async () => {
         element.set('editMode', false);
         element.set('editPatchsetLoaded', false);
         element.change = {
@@ -632,7 +615,7 @@
           status: ChangeStatus.NEW,
         };
         element.set('disableEdit', true);
-        flush();
+        await flush();
 
         assert.isNotOk(
           query(element, 'gr-button[data-action-key="publishEdit"]')
@@ -647,7 +630,7 @@
         assert.isNotOk(query(element, 'gr-button[data-action-key="stopEdit"]'));
       });
 
-      test('shows confirm dialog for delete edit', () => {
+      test('shows confirm dialog for delete edit', async () => {
         element.set('editMode', true);
         element.set('editPatchsetLoaded', true);
 
@@ -660,12 +643,12 @@
             'gr-button[primary]'
           )
         );
-        flush();
+        await flush();
 
         assert.equal(fireActionStub.lastCall.args[0], '/edit');
       });
 
-      test('edit patchset is loaded, needs rebase', () => {
+      test('edit patchset is loaded, needs rebase', async () => {
         element.set('editMode', true);
         element.set('editPatchsetLoaded', true);
         element.change = {
@@ -673,7 +656,7 @@
           status: ChangeStatus.NEW,
         };
         element.editBasedOnCurrentPatchSet = false;
-        flush();
+        await flush();
 
         assert.isNotOk(
           query(element, 'gr-button[data-action-key="publishEdit"]')
@@ -684,7 +667,7 @@
         assert.isNotOk(query(element, 'gr-button[data-action-key="stopEdit"]'));
       });
 
-      test('edit patchset is loaded, does not need rebase', () => {
+      test('edit patchset is loaded, does not need rebase', async () => {
         element.set('editMode', true);
         element.set('editPatchsetLoaded', true);
         element.change = {
@@ -692,7 +675,7 @@
           status: ChangeStatus.NEW,
         };
         element.editBasedOnCurrentPatchSet = true;
-        flush();
+        await flush();
 
         assert.isOk(query(element, 'gr-button[data-action-key="publishEdit"]'));
         assert.isNotOk(
@@ -703,14 +686,14 @@
         assert.isNotOk(query(element, 'gr-button[data-action-key="stopEdit"]'));
       });
 
-      test('edit mode is loaded, no edit patchset', () => {
+      test('edit mode is loaded, no edit patchset', async () => {
         element.set('editMode', true);
         element.set('editPatchsetLoaded', false);
         element.change = {
           ...createChangeViewChange(),
           status: ChangeStatus.NEW,
         };
-        flush();
+        await flush();
 
         assert.isNotOk(
           query(element, 'gr-button[data-action-key="publishEdit"]')
@@ -725,14 +708,14 @@
         assert.isOk(query(element, 'gr-button[data-action-key="stopEdit"]'));
       });
 
-      test('normal patch set', () => {
+      test('normal patch set', async () => {
         element.set('editMode', false);
         element.set('editPatchsetLoaded', false);
         element.change = {
           ...createChangeViewChange(),
           status: ChangeStatus.NEW,
         };
-        flush();
+        await flush();
 
         assert.isNotOk(
           query(element, 'gr-button[data-action-key="publishEdit"]')
@@ -747,16 +730,17 @@
         assert.isNotOk(query(element, 'gr-button[data-action-key="stopEdit"]'));
       });
 
-      test('edit action', done => {
+      test('edit action', async () => {
+        const editTapped = mockPromise();
         element.addEventListener('edit-tap', () => {
-          done();
+          editTapped.resolve();
         });
         element.set('editMode', true);
         element.change = {
           ...createChangeViewChange(),
           status: ChangeStatus.NEW,
         };
-        flush();
+        await flush();
 
         assert.isNotOk(query(element, 'gr-button[data-action-key="edit"]'));
         assert.isOk(query(element, 'gr-button[data-action-key="stopEdit"]'));
@@ -764,7 +748,7 @@
           ...createChangeViewChange(),
           status: ChangeStatus.MERGED,
         };
-        flush();
+        await flush();
 
         assert.isNotOk(query(element, 'gr-button[data-action-key="edit"]'));
         element.change = {
@@ -772,13 +756,14 @@
           status: ChangeStatus.NEW,
         };
         element.set('editMode', false);
-        flush();
+        await flush();
 
         const editButton = queryAndAssert(
           element,
           'gr-button[data-action-key="edit"]'
         );
         tap(editButton);
+        await editTapped;
       });
     });
 
@@ -896,62 +881,57 @@
             status: ChangeStatus.NEW,
           },
         ];
-        setup(done => {
+        setup(async () => {
           stubRestApi('getChanges').returns(Promise.resolve(changes));
           element._handleCherrypickTap();
-          flush(() => {
-            const radioButtons = queryAll(
-              element.$.confirmCherrypick,
-              "input[name='cherryPickOptions']"
-            );
-            assert.equal(radioButtons.length, 2);
-            tap(radioButtons[1]);
-            flush(() => {
-              done();
-            });
-          });
+          await flush();
+          const radioButtons = queryAll(
+            element.$.confirmCherrypick,
+            "input[name='cherryPickOptions']"
+          );
+          assert.equal(radioButtons.length, 2);
+          tap(radioButtons[1]);
+          await flush();
         });
 
-        test('cherry pick topic dialog is rendered', done => {
+        test('cherry pick topic dialog is rendered', async () => {
           const dialog = element.$.confirmCherrypick;
-          flush(() => {
-            const changesTable = queryAndAssert(dialog, 'table');
-            const headers = Array.from(changesTable.querySelectorAll('th'));
-            const expectedHeadings = [
-              '',
-              'Change',
-              'Status',
-              'Subject',
-              'Project',
-              'Progress',
-              '',
-            ];
-            const headings = headers.map(header => header.innerText);
-            assert.equal(headings.length, expectedHeadings.length);
-            for (let i = 0; i < headings.length; i++) {
-              assert.equal(headings[i].trim(), expectedHeadings[i]);
-            }
-            const changeRows = queryAll(changesTable, 'tbody > tr');
-            const change = Array.from(changeRows[0].querySelectorAll('td')).map(
-              e => e.innerText
-            );
-            const expectedChange = [
-              '',
-              '1234567890',
-              'MERGED',
-              'random',
-              'A',
-              'NOT STARTED',
-              '',
-            ];
-            for (let i = 0; i < change.length; i++) {
-              assert.equal(change[i].trim(), expectedChange[i]);
-            }
-            done();
-          });
+          await flush();
+          const changesTable = queryAndAssert(dialog, 'table');
+          const headers = Array.from(changesTable.querySelectorAll('th'));
+          const expectedHeadings = [
+            '',
+            'Change',
+            'Status',
+            'Subject',
+            'Project',
+            'Progress',
+            '',
+          ];
+          const headings = headers.map(header => header.innerText);
+          assert.equal(headings.length, expectedHeadings.length);
+          for (let i = 0; i < headings.length; i++) {
+            assert.equal(headings[i].trim(), expectedHeadings[i]);
+          }
+          const changeRows = queryAll(changesTable, 'tbody > tr');
+          const change = Array.from(changeRows[0].querySelectorAll('td')).map(
+            e => e.innerText
+          );
+          const expectedChange = [
+            '',
+            '1234567890',
+            'MERGED',
+            'random',
+            'A',
+            'NOT STARTED',
+            '',
+          ];
+          for (let i = 0; i < change.length; i++) {
+            assert.equal(change[i].trim(), expectedChange[i]);
+          }
         });
 
-        test('changes with duplicate project show an error', done => {
+        test('changes with duplicate project show an error', async () => {
           const dialog = element.$.confirmCherrypick;
           const error = queryAndAssert(
             dialog,
@@ -974,13 +954,11 @@
               project: 'A' as RepoName,
             },
           ]);
-          flush(() => {
-            assert.equal(
-              error.innerText,
-              'Two changes cannot be of the same' + ' project'
-            );
-            done();
-          });
+          await flush();
+          assert.equal(
+            error.innerText,
+            'Two changes cannot be of the same' + ' project'
+          );
         });
       });
     });
@@ -1021,24 +999,24 @@
       });
     });
 
-    test('custom actions', done => {
+    test('custom actions', async () => {
       // Add a button with the same key as a server-based one to ensure
       // collisions are taken care of.
       const key = element.addActionButton(element.ActionType.REVISION, 'Bork!');
-      element.addEventListener(key + '-tap', e => {
+      const keyTapped = mockPromise();
+      element.addEventListener(key + '-tap', async e => {
         assert.equal(
           (e as CustomEvent).detail.node.getAttribute('data-action-key'),
           key
         );
         element.removeActionButton(key);
-        flush(() => {
-          assert.notOk(query(element, '[data-action-key="' + key + '"]'));
-          done();
-        });
+        await flush();
+        assert.notOk(query(element, '[data-action-key="' + key + '"]'));
+        keyTapped.resolve();
       });
-      flush(() => {
-        tap(queryAndAssert(element, '[data-action-key="' + key + '"]'));
-      });
+      await flush();
+      tap(queryAndAssert(element, '[data-action-key="' + key + '"]'));
+      await keyTapped;
     });
 
     test('_setLoadingOnButtonWithKey top-level', () => {
@@ -1095,32 +1073,28 @@
         return element.reload();
       });
 
-      test('abandon change with message', done => {
+      test('abandon change with message', async () => {
         const newAbandonMsg = 'Test Abandon Message';
         element.$.confirmAbandonDialog.message = newAbandonMsg;
-        flush(() => {
-          const abandonButton = queryAndAssert(
-            element,
-            'gr-button[data-action-key="abandon"]'
-          );
-          tap(abandonButton);
+        await flush();
+        const abandonButton = queryAndAssert(
+          element,
+          'gr-button[data-action-key="abandon"]'
+        );
+        tap(abandonButton);
 
-          assert.equal(element.$.confirmAbandonDialog.message, newAbandonMsg);
-          done();
-        });
+        assert.equal(element.$.confirmAbandonDialog.message, newAbandonMsg);
       });
 
-      test('abandon change with no message', done => {
-        flush(() => {
-          const abandonButton = queryAndAssert(
-            element,
-            'gr-button[data-action-key="abandon"]'
-          );
-          tap(abandonButton);
+      test('abandon change with no message', async () => {
+        await flush();
+        const abandonButton = queryAndAssert(
+          element,
+          'gr-button[data-action-key="abandon"]'
+        );
+        tap(abandonButton);
 
-          assert.isUndefined(element.$.confirmAbandonDialog.message);
-          done();
-        });
+        assert.isUndefined(element.$.confirmAbandonDialog.message);
       });
 
       test('works', () => {
@@ -1173,7 +1147,7 @@
         return element.reload();
       });
 
-      test('revert change with plugin hook', done => {
+      test('revert change with plugin hook', async () => {
         const newRevertMsg = 'Modified revert msg';
         sinon
           .stub(element.$.confirmRevertDialog, '_modifyRevertMsg')
@@ -1204,17 +1178,14 @@
             '_populateRevertSubmissionMessage'
           )
           .callsFake(() => 'original msg');
-        flush(() => {
-          const revertButton = queryAndAssert(
-            element,
-            'gr-button[data-action-key="revert"]'
-          );
-          tap(revertButton);
-          flush(() => {
-            assert.equal(element.$.confirmRevertDialog._message, newRevertMsg);
-            done();
-          });
-        });
+        await flush();
+        const revertButton = queryAndAssert(
+          element,
+          'gr-button[data-action-key="revert"]'
+        );
+        tap(revertButton);
+        await flush();
+        assert.equal(element.$.confirmRevertDialog._message, newRevertMsg);
       });
 
       suite('revert change submitted together', () => {
@@ -1243,60 +1214,56 @@
           );
         });
 
-        test('confirm revert dialog shows both options', done => {
+        test('confirm revert dialog shows both options', async () => {
           const revertButton = queryAndAssert(
             element,
             'gr-button[data-action-key="revert"]'
           );
           tap(revertButton);
-          flush(() => {
-            assert.equal(getChangesStub.args[0][1], 'submissionid: "199 0"');
-            const confirmRevertDialog = element.$.confirmRevertDialog;
-            const revertSingleChangeLabel = queryAndAssert(
-              confirmRevertDialog,
-              '.revertSingleChange'
-            ) as HTMLLabelElement;
-            const revertSubmissionLabel = queryAndAssert(
-              confirmRevertDialog,
-              '.revertSubmission'
-            ) as HTMLLabelElement;
-            assert(
-              revertSingleChangeLabel.innerText.trim() ===
-                'Revert single change'
-            );
-            assert(
-              revertSubmissionLabel.innerText.trim() ===
-                'Revert entire submission (2 Changes)'
-            );
-            let expectedMsg =
-              'Revert submission 199 0' +
-              '\n\n' +
-              'Reason for revert: <INSERT REASONING HERE>' +
-              '\n' +
-              'Reverted Changes:' +
-              '\n' +
-              '1234567890:random' +
-              '\n' +
-              '23456:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa...' +
-              '\n';
-            assert.equal(confirmRevertDialog._message, expectedMsg);
-            const radioInputs = queryAll(
-              confirmRevertDialog,
-              'input[name="revertOptions"]'
-            );
-            tap(radioInputs[0]);
-            flush(() => {
-              expectedMsg =
-                'Revert "random commit message"\n\nThis reverts ' +
-                'commit 2000.\n\nReason' +
-                ' for revert: <INSERT REASONING HERE>\n';
-              assert.equal(confirmRevertDialog._message, expectedMsg);
-              done();
-            });
-          });
+          await flush();
+          assert.equal(getChangesStub.args[0][1], 'submissionid: "199 0"');
+          const confirmRevertDialog = element.$.confirmRevertDialog;
+          const revertSingleChangeLabel = queryAndAssert(
+            confirmRevertDialog,
+            '.revertSingleChange'
+          ) as HTMLLabelElement;
+          const revertSubmissionLabel = queryAndAssert(
+            confirmRevertDialog,
+            '.revertSubmission'
+          ) as HTMLLabelElement;
+          assert(
+            revertSingleChangeLabel.innerText.trim() === 'Revert single change'
+          );
+          assert(
+            revertSubmissionLabel.innerText.trim() ===
+              'Revert entire submission (2 Changes)'
+          );
+          let expectedMsg =
+            'Revert submission 199 0' +
+            '\n\n' +
+            'Reason for revert: <INSERT REASONING HERE>' +
+            '\n' +
+            'Reverted Changes:' +
+            '\n' +
+            '1234567890:random' +
+            '\n' +
+            '23456:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa...' +
+            '\n';
+          assert.equal(confirmRevertDialog._message, expectedMsg);
+          const radioInputs = queryAll(
+            confirmRevertDialog,
+            'input[name="revertOptions"]'
+          );
+          tap(radioInputs[0]);
+          await flush();
+          expectedMsg =
+            'Revert "random commit message"\n\nThis reverts ' +
+            'commit 2000.\n\nReason' +
+            ' for revert: <INSERT REASONING HERE>\n';
+          assert.equal(confirmRevertDialog._message, expectedMsg);
         });
 
-        test('submit fails if message is not edited', done => {
+        test('submit fails if message is not edited', async () => {
           const revertButton = queryAndAssert(
             element,
             'gr-button[data-action-key="revert"]'
@@ -1304,69 +1271,58 @@
           const confirmRevertDialog = element.$.confirmRevertDialog;
           tap(revertButton);
           const fireStub = sinon.stub(confirmRevertDialog, 'dispatchEvent');
-          flush(() => {
-            const confirmButton = queryAndAssert(
-              queryAndAssert(element.$.confirmRevertDialog, 'gr-dialog'),
-              '#confirm'
-            );
-            tap(confirmButton);
-            flush(() => {
-              assert.isTrue(confirmRevertDialog._showErrorMessage);
-              assert.isFalse(fireStub.called);
-              done();
-            });
-          });
+          await flush();
+          const confirmButton = queryAndAssert(
+            queryAndAssert(element.$.confirmRevertDialog, 'gr-dialog'),
+            '#confirm'
+          );
+          tap(confirmButton);
+          await flush();
+          assert.isTrue(confirmRevertDialog._showErrorMessage);
+          assert.isFalse(fireStub.called);
         });
 
-        test('message modification is retained on switching', done => {
+        test('message modification is retained on switching', async () => {
           const revertButton = queryAndAssert(
             element,
             'gr-button[data-action-key="revert"]'
           );
           const confirmRevertDialog = element.$.confirmRevertDialog;
           tap(revertButton);
-          flush(() => {
-            const radioInputs = queryAll(
-              confirmRevertDialog,
-              'input[name="revertOptions"]'
-            );
-            const revertSubmissionMsg =
-              'Revert submission 199 0' +
-              '\n\n' +
-              'Reason for revert: <INSERT REASONING HERE>' +
-              '\n' +
-              'Reverted Changes:' +
-              '\n' +
-              '1234567890:random' +
-              '\n' +
-              '23456:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa...' +
-              '\n';
-            const singleChangeMsg =
-              'Revert "random commit message"\n\nThis reverts ' +
-              'commit 2000.\n\nReason' +
-              ' for revert: <INSERT REASONING HERE>\n';
-            assert.equal(confirmRevertDialog._message, revertSubmissionMsg);
-            const newRevertMsg = revertSubmissionMsg + 'random';
-            const newSingleChangeMsg = singleChangeMsg + 'random';
-            confirmRevertDialog._message = newRevertMsg;
-            tap(radioInputs[0]);
-            flush(() => {
-              assert.equal(confirmRevertDialog._message, singleChangeMsg);
-              confirmRevertDialog._message = newSingleChangeMsg;
-              tap(radioInputs[1]);
-              flush(() => {
-                assert.equal(confirmRevertDialog._message, newRevertMsg);
-                tap(radioInputs[0]);
-                flush(() => {
-                  assert.equal(
-                    confirmRevertDialog._message,
-                    newSingleChangeMsg
-                  );
-                  done();
-                });
-              });
-            });
-          });
+          await flush();
+          const radioInputs = queryAll(
+            confirmRevertDialog,
+            'input[name="revertOptions"]'
+          );
+          const revertSubmissionMsg =
+            'Revert submission 199 0' +
+            '\n\n' +
+            'Reason for revert: <INSERT REASONING HERE>' +
+            '\n' +
+            'Reverted Changes:' +
+            '\n' +
+            '1234567890:random' +
+            '\n' +
+            '23456:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa...' +
+            '\n';
+          const singleChangeMsg =
+            'Revert "random commit message"\n\nThis reverts ' +
+            'commit 2000.\n\nReason' +
+            ' for revert: <INSERT REASONING HERE>\n';
+          assert.equal(confirmRevertDialog._message, revertSubmissionMsg);
+          const newRevertMsg = revertSubmissionMsg + 'random';
+          const newSingleChangeMsg = singleChangeMsg + 'random';
+          confirmRevertDialog._message = newRevertMsg;
+          tap(radioInputs[0]);
+          await flush();
+          assert.equal(confirmRevertDialog._message, singleChangeMsg);
+          confirmRevertDialog._message = newSingleChangeMsg;
+          tap(radioInputs[1]);
+          await flush();
+          assert.equal(confirmRevertDialog._message, newRevertMsg);
+          tap(radioInputs[0]);
+          await flush();
+          assert.equal(confirmRevertDialog._message, newSingleChangeMsg);
         });
       });
 
@@ -1389,7 +1345,7 @@
           );
         });
 
-        test('submit fails if message is not edited', done => {
+        test('submit fails if message is not edited', async () => {
           const revertButton = queryAndAssert(
             element,
             'gr-button[data-action-key="revert"]'
@@ -1397,55 +1353,46 @@
           const confirmRevertDialog = element.$.confirmRevertDialog;
           tap(revertButton);
           const fireStub = sinon.stub(confirmRevertDialog, 'dispatchEvent');
-          flush(() => {
-            const confirmButton = queryAndAssert(
-              queryAndAssert(element.$.confirmRevertDialog, 'gr-dialog'),
-              '#confirm'
-            );
-            tap(confirmButton);
-            flush(() => {
-              assert.isTrue(confirmRevertDialog._showErrorMessage);
-              assert.isFalse(fireStub.called);
-              done();
-            });
-          });
+          await flush();
+          const confirmButton = queryAndAssert(
+            queryAndAssert(element.$.confirmRevertDialog, 'gr-dialog'),
+            '#confirm'
+          );
+          tap(confirmButton);
+          await flush();
+          assert.isTrue(confirmRevertDialog._showErrorMessage);
+          assert.isFalse(fireStub.called);
         });
 
-        test('confirm revert dialog shows no radio button', done => {
+        test('confirm revert dialog shows no radio button', async () => {
           const revertButton = queryAndAssert(
             element,
             'gr-button[data-action-key="revert"]'
           );
           tap(revertButton);
-          flush(() => {
-            const confirmRevertDialog = element.$.confirmRevertDialog;
-            const radioInputs = queryAll(
-              confirmRevertDialog,
-              'input[name="revertOptions"]'
-            );
-            assert.equal(radioInputs.length, 0);
-            const msg =
-              'Revert "random commit message"\n\n' +
-              'This reverts commit 2000.\n\nReason ' +
-              'for revert: <INSERT REASONING HERE>\n';
-            assert.equal(confirmRevertDialog._message, msg);
-            const editedMsg = msg + 'hello';
-            confirmRevertDialog._message += 'hello';
-            const confirmButton = queryAndAssert(
-              queryAndAssert(element.$.confirmRevertDialog, 'gr-dialog'),
-              '#confirm'
-            );
-            tap(confirmButton);
-            flush(() => {
-              assert.equal(fireActionStub.getCall(0).args[0], '/revert');
-              assert.equal(fireActionStub.getCall(0).args[1].__key, 'revert');
-              assert.equal(
-                fireActionStub.getCall(0).args[3].message,
-                editedMsg
-              );
-              done();
-            });
-          });
+          await flush();
+          const confirmRevertDialog = element.$.confirmRevertDialog;
+          const radioInputs = queryAll(
+            confirmRevertDialog,
+            'input[name="revertOptions"]'
+          );
+          assert.equal(radioInputs.length, 0);
+          const msg =
+            'Revert "random commit message"\n\n' +
+            'This reverts commit 2000.\n\nReason ' +
+            'for revert: <INSERT REASONING HERE>\n';
+          assert.equal(confirmRevertDialog._message, msg);
+          const editedMsg = msg + 'hello';
+          confirmRevertDialog._message += 'hello';
+          const confirmButton = queryAndAssert(
+            queryAndAssert(element.$.confirmRevertDialog, 'gr-dialog'),
+            '#confirm'
+          );
+          tap(confirmButton);
+          await flush();
+          assert.equal(fireActionStub.getCall(0).args[0], '/revert');
+          assert.equal(fireActionStub.getCall(0).args[1].__key, 'revert');
+          assert.equal(fireActionStub.getCall(0).args[3].message, editedMsg);
         });
       });
     });
@@ -1477,27 +1424,23 @@
       test(
         'make sure the mark private change button is not outside of the ' +
           'overflow menu',
-        done => {
-          flush(() => {
-            assert.isNotOk(query(element, '[data-action-key="private"]'));
-            done();
-          });
+        async () => {
+          await flush();
+          assert.isNotOk(query(element, '[data-action-key="private"]'));
         }
       );
 
-      test('private change', done => {
-        flush(() => {
-          assert.isOk(
-            query(element.$.moreActions, 'span[data-id="private-change"]')
-          );
-          element.setActionOverflow(ActionType.CHANGE, 'private', false);
-          flush();
-          assert.isOk(query(element, '[data-action-key="private"]'));
-          assert.isNotOk(
-            query(element.$.moreActions, 'span[data-id="private-change"]')
-          );
-          done();
-        });
+      test('private change', async () => {
+        await flush();
+        assert.isOk(
+          query(element.$.moreActions, 'span[data-id="private-change"]')
+        );
+        element.setActionOverflow(ActionType.CHANGE, 'private', false);
+        await flush();
+        assert.isOk(query(element, '[data-action-key="private"]'));
+        assert.isNotOk(
+          query(element.$.moreActions, 'span[data-id="private-change"]')
+        );
       });
     });
 
@@ -1528,35 +1471,23 @@
       test(
         'make sure the unmark private change button is not outside of the ' +
           'overflow menu',
-        done => {
-          flush(() => {
-            assert.isNotOk(
-              query(element, '[data-action-key="private.delete"]')
-            );
-            done();
-          });
+        async () => {
+          await flush();
+          assert.isNotOk(query(element, '[data-action-key="private.delete"]'));
         }
       );
 
-      test('unmark the private change', done => {
-        flush(() => {
-          assert.isOk(
-            query(
-              element.$.moreActions,
-              'span[data-id="private.delete-change"]'
-            )
-          );
-          element.setActionOverflow(ActionType.CHANGE, 'private.delete', false);
-          flush();
-          assert.isOk(query(element, '[data-action-key="private.delete"]'));
-          assert.isNotOk(
-            query(
-              element.$.moreActions,
-              'span[data-id="private.delete-change"]'
-            )
-          );
-          done();
-        });
+      test('unmark the private change', async () => {
+        await flush();
+        assert.isOk(
+          query(element.$.moreActions, 'span[data-id="private.delete-change"]')
+        );
+        element.setActionOverflow(ActionType.CHANGE, 'private.delete', false);
+        await flush();
+        assert.isOk(query(element, '[data-action-key="private.delete"]'));
+        assert.isNotOk(
+          query(element.$.moreActions, 'span[data-id="private.delete-change"]')
+        );
       });
     });
 
@@ -1586,7 +1517,7 @@
         assert.isFalse(fireActionStub.called);
       });
 
-      test('shows confirm dialog', () => {
+      test('shows confirm dialog', async () => {
         element._handleDeleteTap();
         assert.isFalse(
           (queryAndAssert(element, '#confirmDeleteDialog') as GrDialog).hidden
@@ -1597,11 +1528,11 @@
             'gr-button[primary]'
           )
         );
-        flush();
+        await flush();
         assert.isTrue(fireActionStub.calledWith('/', deleteAction, false));
       });
 
-      test('hides delete confirm on cancel', () => {
+      test('hides delete confirm on cancel', async () => {
         element._handleDeleteTap();
         tap(
           queryAndAssert(
@@ -1609,7 +1540,7 @@
             'gr-button:not([primary])'
           )
         );
-        flush();
+        await flush();
         assert.isTrue(
           (queryAndAssert(element, '#confirmDeleteDialog') as GrDialog).hidden
         );
@@ -1618,7 +1549,7 @@
     });
 
     suite('ignore change', () => {
-      setup(done => {
+      setup(async () => {
         sinon.stub(element, '_fireAction');
 
         const IgnoreAction = {
@@ -1638,19 +1569,18 @@
         element.changeNum = 2 as NumericChangeId;
         element.latestPatchNum = 2 as PatchSetNum;
 
-        element.reload().then(() => {
-          flush(done);
-        });
+        await element.reload();
+        await flush();
       });
 
       test('make sure the ignore button is not outside of the overflow menu', () => {
         assert.isNotOk(query(element, '[data-action-key="ignore"]'));
       });
 
-      test('ignoring change', () => {
+      test('ignoring change', async () => {
         queryAndAssert(element.$.moreActions, 'span[data-id="ignore-change"]');
         element.setActionOverflow(ActionType.CHANGE, 'ignore', false);
-        flush();
+        await flush();
         queryAndAssert(element, '[data-action-key="ignore"]');
         assert.isNotOk(
           query(element.$.moreActions, 'span[data-id="ignore-change"]')
@@ -1659,7 +1589,7 @@
     });
 
     suite('unignore change', () => {
-      setup(done => {
+      setup(async () => {
         sinon.stub(element, '_fireAction');
 
         const UnignoreAction = {
@@ -1679,21 +1609,20 @@
         element.changeNum = 2 as NumericChangeId;
         element.latestPatchNum = 2 as PatchSetNum;
 
-        element.reload().then(() => {
-          flush(done);
-        });
+        await element.reload();
+        await flush();
       });
 
       test('unignore button is not outside of the overflow menu', () => {
         assert.isNotOk(query(element, '[data-action-key="unignore"]'));
       });
 
-      test('unignoring change', () => {
+      test('unignoring change', async () => {
         assert.isOk(
           query(element.$.moreActions, 'span[data-id="unignore-change"]')
         );
         element.setActionOverflow(ActionType.CHANGE, 'unignore', false);
-        flush();
+        await flush();
         assert.isOk(query(element, '[data-action-key="unignore"]'));
         assert.isNotOk(
           query(element.$.moreActions, 'span[data-id="unignore-change"]')
@@ -1702,7 +1631,7 @@
     });
 
     suite('quick approve', () => {
-      setup(() => {
+      setup(async () => {
         element.change = {
           ...createChangeViewChange(),
           current_revision: 'abc1234' as CommitId,
@@ -1719,7 +1648,7 @@
             foo: ['-1', ' 0', '+1'],
           },
         };
-        flush();
+        await flush();
       });
 
       test('added when can approve', () => {
@@ -1730,7 +1659,7 @@
         assert.isNotNull(approveButton);
       });
 
-      test('hide quick approve', () => {
+      test('hide quick approve', async () => {
         const approveButton = query(
           element,
           "gr-button[data-action-key='review']"
@@ -1740,7 +1669,7 @@
 
         // Assert approve button gets removed from list of buttons.
         element.hideQuickApproveAction();
-        flush();
+        await flush();
         const approveButtonUpdated = query(
           element,
           "gr-button[data-action-key='review']"
@@ -1756,18 +1685,21 @@
         assert.equal(approveButton!.getAttribute('data-label'), 'foo+1');
       });
 
-      test('not added when change is merged', () => {
-        element.change!.status = ChangeStatus.MERGED;
-        flush(() => {
-          const approveButton = query(
-            element,
-            "gr-button[data-action-key='review']"
-          );
-          assert.isNotOk(approveButton);
-        });
+      test('not added when change is merged', async () => {
+        element.change = {
+          ...element.change!,
+          status: ChangeStatus.MERGED,
+        };
+
+        await flush();
+        const approveButton = query(
+          element,
+          "gr-button[data-action-key='review']"
+        );
+        assert.isNotOk(approveButton);
       });
 
-      test('not added when already approved', () => {
+      test('not added when already approved', async () => {
         element.change = {
           ...createChangeViewChange(),
           current_revision: 'abc1234' as CommitId,
@@ -1781,7 +1713,7 @@
             foo: [' 0', '+1'],
           },
         };
-        flush();
+        await flush();
         const approveButton = query(
           element,
           "gr-button[data-action-key='review']"
@@ -1789,7 +1721,7 @@
         assert.isNotOk(approveButton);
       });
 
-      test('not added when label not permitted', () => {
+      test('not added when label not permitted', async () => {
         element.change = {
           ...createChangeViewChange(),
           current_revision: 'abc1234' as CommitId,
@@ -1800,7 +1732,7 @@
             bar: [],
           },
         };
-        flush();
+        await flush();
         const approveButton = query(
           element,
           "gr-button[data-action-key='review']"
@@ -1808,17 +1740,17 @@
         assert.isNotOk(approveButton);
       });
 
-      test('approves when tapped', () => {
+      test('approves when tapped', async () => {
         const fireActionStub = sinon.stub(element, '_fireAction');
         tap(queryAndAssert(element, "gr-button[data-action-key='review']"));
-        flush();
+        await flush();
         assert.isTrue(fireActionStub.called);
         assert.isTrue(fireActionStub.calledWith('/review'));
         const payload = fireActionStub.lastCall.args[3];
         assert.deepEqual((payload as ReviewInput).labels, {foo: 1});
       });
 
-      test('not added when multiple labels are required without code review', () => {
+      test('not added when multiple labels are required without code review', async () => {
         element.change = {
           ...createChangeViewChange(),
           current_revision: 'abc1234' as CommitId,
@@ -1831,7 +1763,7 @@
             bar: [' 0', '+1', '+2'],
           },
         };
-        flush();
+        await flush();
         const approveButton = query(
           element,
           "gr-button[data-action-key='review']"
@@ -1839,7 +1771,7 @@
         assert.isNotOk(approveButton);
       });
 
-      test('code review shown with multiple missing approval', () => {
+      test('code review shown with multiple missing approval', async () => {
         element.change = {
           ...createChangeViewChange(),
           current_revision: 'abc1234' as CommitId,
@@ -1861,7 +1793,7 @@
             'Code-Review': [' 0', '+1', '+2'],
           },
         };
-        flush();
+        await flush();
         const approveButton = queryAndAssert(
           element,
           "gr-button[data-action-key='review']"
@@ -1869,7 +1801,7 @@
         assert.isOk(approveButton);
       });
 
-      test('button label for missing approval', () => {
+      test('button label for missing approval', async () => {
         element.change = {
           ...createChangeViewChange(),
           current_revision: 'abc1234' as CommitId,
@@ -1887,7 +1819,7 @@
             bar: [' 0', '+1', '+2'],
           },
         };
-        flush();
+        await flush();
         const approveButton = queryAndAssert(
           element,
           "gr-button[data-action-key='review']"
@@ -1895,7 +1827,7 @@
         assert.equal(approveButton.getAttribute('data-label'), 'foo+1');
       });
 
-      test('no quick approve if score is not maximal for a label', () => {
+      test('no quick approve if score is not maximal for a label', async () => {
         element.change = {
           ...createChangeViewChange(),
           current_revision: 'abc1234' as CommitId,
@@ -1913,7 +1845,7 @@
             bar: [' 0', '+1'],
           },
         };
-        flush();
+        await flush();
         const approveButton = query(
           element,
           "gr-button[data-action-key='review']"
@@ -1921,7 +1853,7 @@
         assert.isNotOk(approveButton);
       });
 
-      test('approving label with a non-max score', () => {
+      test('approving label with a non-max score', async () => {
         element.change = {
           ...createChangeViewChange(),
           current_revision: 'abc1234' as CommitId,
@@ -1939,7 +1871,7 @@
             bar: [' 0', '+1', '+2'],
           },
         };
-        flush();
+        await flush();
         const approveButton = queryAndAssert(
           element,
           "gr-button[data-action-key='review']"
@@ -1947,7 +1879,7 @@
         assert.equal(approveButton.getAttribute('data-label'), 'bar+2');
       });
 
-      test('added when can approve an already-approved code review label', () => {
+      test('added when can approve an already-approved code review label', async () => {
         element.change = {
           ...createChangeViewChange(),
           current_revision: 'abc1234' as CommitId,
@@ -1965,7 +1897,7 @@
             'Code-Review': [' 0', '+1', '+2'],
           },
         };
-        flush();
+        await flush();
         const approveButton = queryAndAssert(
           element,
           "gr-button[data-action-key='review']"
@@ -1973,7 +1905,7 @@
         assert.isNotNull(approveButton);
       });
 
-      test('not added when the user has already approved', () => {
+      test('not added when the user has already approved', async () => {
         const vote = {
           ...createApproval(),
           _account_id: 123 as AccountId,
@@ -1998,7 +1930,7 @@
             'Code-Review': [' 0', '+1', '+2'],
           },
         };
-        flush();
+        await flush();
         const approveButton = query(
           element,
           "gr-button[data-action-key='review']"
@@ -2006,7 +1938,7 @@
         assert.isNotOk(approveButton);
       });
 
-      test('not added when user owns the change', () => {
+      test('not added when user owns the change', async () => {
         element.change = {
           ...createChangeViewChange(),
           current_revision: 'abc1234' as CommitId,
@@ -2025,7 +1957,7 @@
             'Code-Review': [' 0', '+1', '+2'],
           },
         };
-        flush();
+        await flush();
         const approveButton = query(
           element,
           "gr-button[data-action-key='review']"
@@ -2034,12 +1966,12 @@
       });
     });
 
-    test('adds download revision action', () => {
+    test('adds download revision action', async () => {
       const handler = sinon.stub();
       element.addEventListener('download-tap', handler);
       assert.ok(element.revisionActions.download);
       element._handleDownloadTap();
-      flush();
+      await flush();
 
       assert.isTrue(handler.called);
     });
@@ -2061,14 +1993,14 @@
     });
 
     suite('setActionOverflow', () => {
-      test('move action from overflow', () => {
+      test('move action from overflow', async () => {
         assert.isNotOk(query(element, '[data-action-key="cherrypick"]'));
         assert.strictEqual(
           element.$.moreActions!.items![0].id,
           'cherrypick-revision'
         );
         element.setActionOverflow(ActionType.REVISION, 'cherrypick', false);
-        flush();
+        await flush();
         assert.isOk(query(element, '[data-action-key="cherrypick"]'));
         assert.notEqual(
           element.$.moreActions!.items![0].id,
@@ -2076,10 +2008,10 @@
         );
       });
 
-      test('move action to overflow', () => {
+      test('move action to overflow', async () => {
         assert.isOk(query(element, '[data-action-key="submit"]'));
         element.setActionOverflow(ActionType.REVISION, 'submit', true);
-        flush();
+        await flush();
         assert.isNotOk(query(element, '[data-action-key="submit"]'));
         assert.strictEqual(
           element.$.moreActions.items![3].id,
@@ -2233,31 +2165,24 @@
             );
           });
 
-          test('revert submission single change', done => {
-            element
-              ._send(
-                HttpMethod.POST,
-                {message: 'Revert submission'},
-                '/revert_submission',
-                false,
-                cleanup,
-                {} as UIActionInfo
-              )
-              .then(() => {
-                element
-                  ._handleResponse(
-                    {
-                      __key: 'revert_submission',
-                      __type: ActionType.CHANGE,
-                      label: 'l',
-                    },
-                    new Response()
-                  )!
-                  .then(() => {
-                    assert.isTrue(navigateToSearchQueryStub.called);
-                    done();
-                  });
-              });
+          test('revert submission single change', async () => {
+            await element._send(
+              HttpMethod.POST,
+              {message: 'Revert submission'},
+              '/revert_submission',
+              false,
+              cleanup,
+              {} as UIActionInfo
+            );
+            await element._handleResponse(
+              {
+                __key: 'revert_submission',
+                __type: ActionType.CHANGE,
+                label: 'l',
+              },
+              new Response()
+            );
+            assert.isTrue(navigateToSearchQueryStub.called);
           });
         });
 
@@ -2280,55 +2205,42 @@
             );
           });
 
-          test('revert submission multiple change', done => {
-            element
-              ._send(
-                HttpMethod.POST,
-                {message: 'Revert submission'},
-                '/revert_submission',
-                false,
-                cleanup,
-                {} as UIActionInfo
-              )
-              .then(() => {
-                element
-                  ._handleResponse(
-                    {
-                      __key: 'revert_submission',
-                      __type: ActionType.CHANGE,
-                      label: 'l',
-                    },
-                    new Response()
-                  )!
-                  .then(() => {
-                    assert.isFalse(showActionDialogStub.called);
-                    assert.isTrue(
-                      navigateToSearchQueryStub.calledWith('topic: T')
-                    );
-                    done();
-                  });
-              });
+          test('revert submission multiple change', async () => {
+            await element._send(
+              HttpMethod.POST,
+              {message: 'Revert submission'},
+              '/revert_submission',
+              false,
+              cleanup,
+              {} as UIActionInfo
+            );
+            await element._handleResponse(
+              {
+                __key: 'revert_submission',
+                __type: ActionType.CHANGE,
+                label: 'l',
+              },
+              new Response()
+            );
+            assert.isFalse(showActionDialogStub.called);
+            assert.isTrue(navigateToSearchQueryStub.calledWith('topic: T'));
           });
         });
 
-        test('revision action', done => {
-          element
-            ._send(
-              HttpMethod.DELETE,
-              payload,
-              '/endpoint',
-              true,
-              cleanup,
-              {} as UIActionInfo
-            )
-            .then(() => {
-              assert.isFalse(onShowError.called);
-              assert.isTrue(cleanup.calledOnce);
-              assert.isTrue(
-                sendStub.calledWith(42, 'DELETE', '/endpoint', 12, payload)
-              );
-              done();
-            });
+        test('revision action', async () => {
+          await element._send(
+            HttpMethod.DELETE,
+            payload,
+            '/endpoint',
+            true,
+            cleanup,
+            {} as UIActionInfo
+          );
+          assert.isFalse(onShowError.called);
+          assert.isTrue(cleanup.calledOnce);
+          assert.isTrue(
+            sendStub.calledWith(42, 'DELETE', '/endpoint', 12, payload)
+          );
         });
       });
 
@@ -2403,7 +2315,7 @@
       sinon.stub(element, '_fireAction');
       sinon.stub(element, '_handleChangeAction');
 
-      const reportStub = sinon.stub(element.reporting, 'reportInteraction');
+      const reportStub = stubReporting('reportInteraction');
       element._handleAction(ActionType.CHANGE, 'key');
       assert.isTrue(reportStub.called);
       assert.equal(reportStub.lastCall.args[0], 'change-key');
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.ts b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.ts
index 3d55097..e1a1cbb 100644
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.ts
@@ -86,6 +86,7 @@
   AutocompleteSuggestion,
 } from '../../shared/gr-autocomplete/gr-autocomplete';
 import {getRevertCreatedChangeIds} from '../../../utils/message-util';
+import {Interaction} from '../../../constants/reporting';
 
 const HASHTAG_ADD_MESSAGE = 'Add Hashtag';
 
@@ -619,7 +620,7 @@
 
   _onShowAllClick() {
     this._showAllSections = !this._showAllSections;
-    this.reporting.reportInteraction('toggle show all button', {
+    this.reporting.reportInteraction(Interaction.TOGGLE_SHOW_ALL_BUTTON, {
       sectionName: 'metadata',
       toState: this._showAllSections ? 'Show all' : 'Show less',
     });
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_html.ts b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_html.ts
index ca68926..07f9a4a 100644
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_html.ts
@@ -184,7 +184,7 @@
         <gr-account-chip
           account="[[change.owner]]"
           change="[[change]]"
-          highlight-attention
+          highlightAttention
         ></gr-account-chip>
         <template is="dom-if" if="[[_pushCertificateValidation]]">
           <gr-tooltip-content
@@ -213,7 +213,7 @@
         <gr-account-chip
           account="[[_getNonOwnerRole(change, _CHANGE_ROLE.UPLOADER)]]"
           change="[[change]]"
-          highlight-attention
+          highlightAttention
         ></gr-account-chip>
       </span>
     </section>
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.ts b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.ts
index ab8e404..0a8a999 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
@@ -347,8 +347,11 @@
             },
             commit: {
               ...createCommit(),
-              author: {...createGitPerson(), email: 'jkl@def'},
-              committer: {...createGitPerson(), email: 'ghi@def'},
+              author: {...createGitPerson(), email: 'jkl@def' as EmailAddress},
+              committer: {
+                ...createGitPerson(),
+                email: 'ghi@def' as EmailAddress,
+              },
             },
           },
         },
@@ -398,7 +401,7 @@
         change!.revisions.rev1.uploader!.email = 'ghh@def' as EmailAddress;
         assert.deepEqual(
           element._getNonOwnerRole(change, element._CHANGE_ROLE.COMMITTER),
-          {...createGitPerson(), email: 'ghi@def'}
+          {...createGitPerson(), email: 'ghi@def' as EmailAddress}
         );
       });
 
@@ -410,7 +413,7 @@
 
       test('_getNonOwnerRole that it does not return committer', () => {
         // Set the committer email to be the same as the owner.
-        change!.revisions.rev1.commit!.committer.email = 'abc@def';
+        change!.revisions.rev1.commit!.committer.email = 'abc@def' as EmailAddress;
         assert.isNotOk(
           element._getNonOwnerRole(change, element._CHANGE_ROLE.COMMITTER)
         );
@@ -428,13 +431,13 @@
       test('_getNonOwnerRole for author', () => {
         assert.deepEqual(
           element._getNonOwnerRole(change, element._CHANGE_ROLE.AUTHOR),
-          {...createGitPerson(), email: 'jkl@def'}
+          {...createGitPerson(), email: 'jkl@def' as EmailAddress}
         );
       });
 
       test('_getNonOwnerRole that it does not return author', () => {
         // Set the author email to be the same as the owner.
-        change!.revisions.rev1.commit!.author.email = 'abc@def';
+        change!.revisions.rev1.commit!.author.email = 'abc@def' as EmailAddress;
         assert.isNotOk(
           element._getNonOwnerRole(change, element._CHANGE_ROLE.AUTHOR)
         );
@@ -939,7 +942,7 @@
   });
 
   suite('plugin endpoints', () => {
-    test('endpoint params', done => {
+    test('endpoint params', async () => {
       element.change = createParsedChange();
       element.revision = createRevision();
       interface MetadataGrEndpointDecorator extends GrEndpointDecorator {
@@ -961,12 +964,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-requirements/gr-change-requirements.ts b/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements.ts
index 1d7db85..714c938 100644
--- a/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements.ts
@@ -35,6 +35,7 @@
 import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
 import {appContext} from '../../../services/app-context';
 import {labelCompare} from '../../../utils/label-util';
+import {Interaction} from '../../../constants/reporting';
 
 interface ChangeRequirement extends Requirement {
   satisfied: boolean;
@@ -55,7 +56,7 @@
 }
 
 @customElement('gr-change-requirements')
-class GrChangeRequirements extends PolymerElement {
+export class GrChangeRequirements extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
@@ -181,7 +182,7 @@
 
   _handleShowHide() {
     this._showOptionalLabels = !this._showOptionalLabels;
-    this.reporting.reportInteraction('toggle show all button', {
+    this.reporting.reportInteraction(Interaction.TOGGLE_SHOW_ALL_BUTTON, {
       sectionName: 'optional labels',
       toState: this._showOptionalLabels ? 'Show all' : 'Show less',
     });
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 ef8f250..a91178ba 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
@@ -20,25 +20,27 @@
 import {sharedStyles} from '../../../styles/shared-styles';
 import {appContext} from '../../../services/app-context';
 import {
-  allRunsLatest$,
+  allRunsLatestPatchsetLatestAttempt$,
   aPluginHasRegistered$,
   CheckResult,
   CheckRun,
-  errorMessage$,
-  loginCallback$,
-  someProvidersAreLoading$,
+  errorMessageLatest$,
+  loginCallbackLatest$,
+  someProvidersAreLoadingLatest$,
 } from '../../../services/checks/checks-model';
-import {Category, Link, RunStatus} from '../../../api/checks';
+import {Category, RunStatus} from '../../../api/checks';
 import {fireShowPrimaryTab} from '../../../utils/event-util';
 import '../../shared/gr-avatar/gr-avatar';
 import {
+  firstPrimaryLink,
   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 {
@@ -56,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',
@@ -105,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);
         }
@@ -116,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);
         }
@@ -147,7 +156,7 @@
 @customElement('gr-checks-chip')
 export class GrChecksChip extends GrLitElement {
   @property()
-  icon = '';
+  statusOrCategory?: Category | RunStatus;
 
   @property()
   text = '';
@@ -192,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);
         }
@@ -203,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);
         }
@@ -214,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);
         }
@@ -225,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);
         }
@@ -238,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);
         }
@@ -247,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>
@@ -307,11 +346,11 @@
 
   constructor() {
     super();
-    this.subscribe('runs', allRunsLatest$);
+    this.subscribe('runs', allRunsLatestPatchsetLatestAttempt$);
     this.subscribe('showChecksSummary', aPluginHasRegistered$);
-    this.subscribe('someProvidersAreLoading', someProvidersAreLoading$);
-    this.subscribe('errorMessage', errorMessage$);
-    this.subscribe('loginCallback', loginCallback$);
+    this.subscribe('someProvidersAreLoading', someProvidersAreLoadingLatest$);
+    this.subscribe('errorMessage', errorMessageLatest$);
+    this.subscribe('loginCallback', loginCallbackLatest$);
   }
 
   static get styles() {
@@ -326,16 +365,20 @@
           margin-bottom: var(--spacing-m);
         }
         .zeroState {
-          color: var(--primary-text-color);
+          color: var(--deemphasized-text-color);
         }
         .loading.zeroState {
-          color: var(--deemphasized-text-color);
           margin-right: var(--spacing-m);
         }
+        div.error,
+        .login {
+          display: flex;
+          color: var(--primary-text-color);
+          padding: var(--spacing-s);
+          width: 490px;
+        }
         div.error {
           background-color: var(--error-background);
-          display: flex;
-          padding: var(--spacing-s);
         }
         div.error iron-icon {
           color: var(--error-foreground);
@@ -345,6 +388,13 @@
           top: 2px;
           margin-right: var(--spacing-s);
         }
+        .login {
+          justify-content: space-between;
+          background: var(--info-background);
+        }
+        .login iron-icon {
+          color: var(--info-foreground);
+        }
         .login gr-button {
           margin: -4px var(--spacing-s);
         }
@@ -400,9 +450,17 @@
   renderChecksLogin() {
     if (this.errorMessage || !this.loginCallback) return;
     return html`
-      <div class="login zeroState">
-        Not logged in
-        <gr-button @click="${this.loginCallback}" link>Sign in</gr-button>
+      <div class="login">
+        <div class="left">
+          <iron-icon
+            class="info-outline"
+            icon="gr-icons:info-outline"
+          ></iron-icon>
+          Not logged in
+        </div>
+        <div class="right">
+          <gr-button @click="${this.loginCallback}" link>Sign in</gr-button>
+        </div>
       </div>
     `;
   }
@@ -411,18 +469,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 +487,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,26 +506,33 @@
     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);
-        const allLinks = resultFilter(run)
-          .reduce(
-            (links, result) => links.concat(result.links ?? []),
-            [] as Link[]
-          )
-          .filter(link => link.primary);
-        const links = allLinks.length === 1 ? allLinks : [];
+        const allPrimaryLinks = resultFilter(run)
+          .map(firstPrimaryLink)
+          .filter(notUndefined);
+        const links = allPrimaryLinks.length === 1 ? allPrimaryLinks : [];
         const text = `${run.checkName}`;
+        const tabState: ChecksTabState = {
+          checkName: run.checkName,
+          statusOrCategory,
+        };
         return html`<gr-checks-chip
-          class="${icon}"
-          .icon="${icon}"
+          .statusOrCategory="${statusOrCategory}"
           .text="${text}"
-          @click="${() => this.onChipClick({checkName: run.checkName})}"
+          @click="${() => this.onChipClick(tabState)}"
+          @keydown="${(e: KeyboardEvent) => this.onChipKeyDown(e, tabState)}"
           >${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>
             `
@@ -486,19 +548,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();
@@ -563,7 +640,7 @@
                   account =>
                     html`<gr-avatar
                       .account="${account}"
-                      image-size="32"
+                      imageSize="32"
                     ></gr-avatar>`
                 )}
                 ${countUnresolvedComments} unresolved</gr-summary-chip
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 f3b746a8..53bdb91 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
@@ -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,17 @@
   hasEditPatchsetLoaded,
   PatchSet,
 } from '../../../utils/patch-set-util';
-import {changeStatuses} from '../../../utils/change-util';
+import {
+  changeIsAbandoned,
+  changeIsMerged,
+  changeIsOpen,
+  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 +95,58 @@
 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,
+  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 +155,40 @@
   ParsedChangeInfo,
 } from '../../../types/types';
 import {
+  CloseFixPreviewEvent,
   CustomKeyboardEvent,
   EditableContentSaveEvent,
+  EventType,
   OpenFixPreviewEvent,
   ShowAlertEventDetail,
   SwitchTabEvent,
-  ThreadListModifiedEvent,
   TabState,
-  EventType,
-  CloseFixPreviewEvent,
 } 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 {Timing} from '../../../constants/reporting';
+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';
+import {
+  changeComments$,
+  drafts$,
+} from '../../../services/comments/comments-model';
 
-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;
@@ -409,7 +421,7 @@
 
   @property({
     type: String,
-    computed: '_computeReplyButtonLabel(_diffDrafts.*, _canStartReview)',
+    computed: '_computeReplyButtonLabel(_diffDrafts, _canStartReview)',
   })
   _replyButtonLabel = 'Reply';
 
@@ -429,7 +441,7 @@
     type: String,
     computed: '_computeChangeStatusChips(_change, _mergeable, _submitEnabled)',
   })
-  _changeStatuses?: string[];
+  _changeStatuses?: ChangeStates[];
 
   /** If false, then the "Show more" button was used to expand. */
   @property({type: Boolean})
@@ -519,12 +531,18 @@
   @property({type: Boolean})
   _showChecksTab = false;
 
+  @property({type: Boolean})
+  private isViewCurrent = false;
+
   @property({type: String})
   _tabState?: TabState;
 
   @property({type: Object})
   revertedChange?: ChangeInfo;
 
+  @property({type: String})
+  scrollCommentId?: UrlEncodedCommentId;
+
   @property({
     type: Array,
     computed: '_computeResolveWeblinks(_change, _commitInfo, _serverConfig)',
@@ -533,6 +551,10 @@
 
   restApiService = appContext.restApiService;
 
+  private readonly commentsService = appContext.commentsService;
+
+  private replyDialogResizeObserver?: ResizeObserver;
+
   keyboardShortcuts() {
     return {
       [Shortcut.SEND_REPLY]: null, // DOC_ONLY binding
@@ -569,6 +591,17 @@
     aPluginHasRegistered$.pipe(takeUntil(this.disconnected$)).subscribe(b => {
       this._showChecksTab = b;
     });
+    routerView$.pipe(takeUntil(this.disconnected$)).subscribe(view => {
+      this.isViewCurrent = view === GerritView.CHANGE;
+    });
+    drafts$.pipe(takeUntil(this.disconnected$)).subscribe(drafts => {
+      this._diffDrafts = {...drafts};
+    });
+    changeComments$
+      .pipe(takeUntil(this.disconnected$))
+      .subscribe(changeComments => {
+        this._changeComments = changeComments;
+      });
   }
 
   constructor() {
@@ -588,22 +621,13 @@
       this._handleShowBackgroundContent()
     );
 
-    this.addEventListener('diff-comments-modified', () =>
-      this._handleReloadCommentThreads()
-    );
-
-    this.addEventListener(
-      'thread-list-modified',
-      (e: ThreadListModifiedEvent) => this._handleReloadDiffComments(e)
-    );
-
     this.addEventListener('open-reply-dialog', () => this._openReplyDialog());
   }
 
   /** @override */
   connectedCallback() {
     super.connectedCallback();
-    this._throttledToggleChangeStar = this._throttleWrap(e =>
+    this._throttledToggleChangeStar = throttleWrap(e =>
       this._handleToggleChangeStar(e as CustomKeyboardEvent)
     );
     this._getServerConfig().then(config => {
@@ -621,6 +645,11 @@
       this._setDiffViewMode();
     });
 
+    this.replyDialogResizeObserver = new ResizeObserver(() =>
+      this.$.replyOverlay.center()
+    );
+    this.replyDialogResizeObserver.observe(this.$.replyDialog);
+
     getPluginLoader()
       .awaitPluginsLoaded()
       .then(() => {
@@ -634,16 +663,11 @@
           this._dynamicTabContentEndpoints.length !==
           this._dynamicTabHeaderEndpoints.length
         ) {
-          console.warn('Different number of tab headers and tab content.');
+          this.reporting.error(new Error('Mismatch of headers and content.'));
         }
       })
       .then(() => this._initActiveTabs(this.params));
 
-    this.addEventListener('comment-save', e => this._handleCommentSave(e));
-    this.addEventListener('comment-refresh', () => this._reloadDrafts());
-    this.addEventListener('comment-discard', e =>
-      this._handleCommentDiscard(e)
-    );
     this.addEventListener('change-message-deleted', () => fireReload(this));
     this.addEventListener('editable-content-save', e =>
       this._handleCommitMessageSave(e)
@@ -653,7 +677,6 @@
     );
     this.addEventListener('open-fix-preview', e => this._onOpenFixPreview(e));
     this.addEventListener('close-fix-preview', e => this._onCloseFixPreview(e));
-    window.addEventListener('scroll', this.handleScroll);
     document.addEventListener('visibilitychange', this.handleVisibilityChange);
 
     this.addEventListener(EventType.SHOW_PRIMARY_TAB, e =>
@@ -670,7 +693,6 @@
   /** @override */
   disconnectedCallback() {
     this.disconnected$.next();
-    window.removeEventListener('scroll', this.handleScroll);
     document.removeEventListener(
       'visibilitychange',
       this.handleVisibilityChange
@@ -767,7 +789,7 @@
       }
     }
     if (activeIndex === -1) {
-      console.warn('tab not found with given info', activeDetails);
+      this.reporting.error(new Error(`tab not found for ${activeDetails}`));
       return;
     }
     const tabName = tabs[activeIndex].dataset['name'];
@@ -777,7 +799,7 @@
     if (paperTabs.selected !== activeIndex) {
       // paperTabs.selected is undefined during rendering
       if (paperTabs.selected !== undefined) {
-        this.reporting.reportInteraction('show-tab', {tabName, src});
+        this.reporting.reportInteraction(Interaction.SHOW_TAB, {tabName, src});
       }
       paperTabs.selected = activeIndex;
     }
@@ -840,7 +862,7 @@
       if (hasUnresolvedThreads) this.unresolvedOnly = true;
     }
 
-    this.reporting.reportInteraction('show-tab', {
+    this.reporting.reportInteraction(Interaction.SHOW_TAB, {
       tabName,
       src: 'paper-tab-click',
     });
@@ -1007,30 +1029,6 @@
     );
   }
 
-  _handleReloadCommentThreads() {
-    // Get any new drafts that have been saved in the diff view and show
-    // in the comment thread view.
-    this._reloadDrafts().then(() => {
-      this._commentThreads = this._changeComments?.getAllThreadsForChange();
-      flush();
-    });
-  }
-
-  _handleReloadDiffComments(
-    e: CustomEvent<{rootId: UrlEncodedCommentId; path: string}>
-  ) {
-    // Keeps the file list counts updated.
-    this._reloadDrafts().then(() => {
-      // Get any new drafts that have been saved in the thread view and show
-      // in the diff view.
-      this.$.fileList.reloadCommentsForThreadWithRootId(
-        e.detail.rootId,
-        e.detail.path
-      );
-      flush();
-    });
-  }
-
   _computeTotalCommentCounts(
     unresolvedCount: number,
     changeComments: ChangeComments
@@ -1049,79 +1047,6 @@
     );
   }
 
-  _handleCommentSave(e: CustomEvent<{comment: DraftInfo}>) {
-    const draft = e.detail.comment;
-    if (!draft.__draft || !draft.path) return;
-    if (!this._patchRange)
-      throw new Error('missing required _patchRange property');
-
-    draft.patch_set = draft.patch_set || this._patchRange.patchNum;
-
-    // The use of path-based notification helpers (set, push) can’t be used
-    // because the paths could contain dots in them. A new object must be
-    // created to satisfy Polymer’s dirty checking.
-    // https://github.com/Polymer/polymer/issues/3127
-    const diffDrafts = {...this._diffDrafts};
-    if (!diffDrafts[draft.path]) {
-      diffDrafts[draft.path] = [draft];
-      this._diffDrafts = diffDrafts;
-      return;
-    }
-    for (let i = 0; i < diffDrafts[draft.path].length; i++) {
-      if (diffDrafts[draft.path][i].id === draft.id) {
-        diffDrafts[draft.path][i] = draft;
-        this._diffDrafts = diffDrafts;
-        return;
-      }
-    }
-    diffDrafts[draft.path].push(draft);
-    diffDrafts[draft.path].sort(
-      (c1, c2) =>
-        // No line number means that it’s a file comment. Sort it above the
-        // others.
-        (c1.line || -1) - (c2.line || -1)
-    );
-    this._diffDrafts = diffDrafts;
-  }
-
-  _handleCommentDiscard(e: CustomEvent<{comment: DraftInfo}>) {
-    const draft = e.detail.comment;
-    if (!draft.__draft || !draft.path) {
-      return;
-    }
-
-    if (!this._diffDrafts || !this._diffDrafts[draft.path]) {
-      return;
-    }
-    let index = -1;
-    for (let i = 0; i < this._diffDrafts[draft.path].length; i++) {
-      if (this._diffDrafts[draft.path][i].id === draft.id) {
-        index = i;
-        break;
-      }
-    }
-    if (index === -1) {
-      // It may be a draft that hasn’t been added to _diffDrafts since it was
-      // never saved.
-      return;
-    }
-
-    if (!this._patchRange)
-      throw new Error('missing required _patchRange property');
-    draft.patch_set = draft.patch_set || this._patchRange.patchNum;
-
-    // The use of path-based notification helpers (set, push) can’t be used
-    // because the paths could contain dots in them. A new object must be
-    // created to satisfy Polymer’s dirty checking.
-    // https://github.com/Polymer/polymer/issues/3127
-    const diffDrafts = {...this._diffDrafts};
-    diffDrafts[draft.path].splice(index, 1);
-    if (diffDrafts[draft.path].length === 0) {
-      delete diffDrafts[draft.path];
-    }
-    this._diffDrafts = diffDrafts;
-  }
-
   _handleReplyTap(e: MouseEvent) {
     e.preventDefault();
     this._openReplyDialog(this.$.replyDialog.FocusTarget.ANY);
@@ -1213,22 +1138,11 @@
     this._openReplyDialog(target);
   }
 
-  readonly handleScroll = () => {
-    this.scrollTask = debounce(
-      this.scrollTask,
-      () => (this.viewState.scrollTop = document.body.scrollTop),
-      150
-    );
-  };
-
   _setShownFiles(e: CustomEvent<{length: number}>) {
     this._shownFileCount = e.detail.length;
   }
 
-  _expandAllDiffs(e: CustomKeyboardEvent) {
-    if (this.shouldSuppressKeyboardShortcut(e)) {
-      return;
-    }
+  _expandAllDiffs() {
     this.$.fileList.expandAllDiffs();
   }
 
@@ -1236,16 +1150,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;
     }
@@ -1273,6 +1208,7 @@
 
     this.$.fileList.collapseAllDiffs();
     this._patchRange = patchRange;
+    this.scrollCommentId = value.commentId;
 
     const patchKnown =
       !patchRange.patchNum ||
@@ -1280,7 +1216,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;
@@ -1308,6 +1244,8 @@
     let primaryTab = PrimaryTab.FILES;
     if (params && params.queryMap && params.queryMap.has('tab')) {
       primaryTab = params.queryMap.get('tab') as PrimaryTab;
+    } else if (params && 'commentId' in params) {
+      primaryTab = PrimaryTab.COMMENT_THREADS;
     }
     this._setActivePrimaryTab(
       new CustomEvent('initActiveTab', {
@@ -1336,11 +1274,7 @@
     this._sendShowChangeEvent();
 
     setTimeout(() => {
-      if (this.viewState.scrollTop) {
-        document.documentElement.scrollTop = document.body.scrollTop = this.viewState.scrollTop;
-      } else {
-        this._maybeScrollToMessage(window.location.hash);
-      }
+      this._maybeScrollToMessage(window.location.hash);
       this._initialLoadComplete = true;
     });
   }
@@ -1446,13 +1380,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);
       }
     });
@@ -1467,7 +1394,6 @@
 
   _resetFileListViewState() {
     this.set('viewState.selectedFileIndex', 0);
-    this.set('viewState.scrollTop', 0);
     if (
       !!this.viewState.changeNum &&
       this.viewState.changeNum !== this._changeNum
@@ -1536,17 +1462,13 @@
   }
 
   _computeReplyButtonLabel(
-    changeRecord?: ElementPropertyDeepChange<
-      GrChangeView,
-      '_diffDrafts'
-    > | null,
+    drafts?: {[path: string]: UIDraft[]},
     canStartReview?: boolean
   ) {
-    if (changeRecord === undefined || canStartReview === undefined) {
+    if (drafts === undefined || canStartReview === undefined) {
       return 'Reply';
     }
 
-    const drafts = (changeRecord && changeRecord.base) || {};
     const draftCount = Object.keys(drafts).reduce(
       (count, file) => count + drafts[file].length,
       0
@@ -1789,8 +1711,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;
@@ -1833,9 +1753,34 @@
    * case an edit exists.
    */
   _processEdit(change: ParsedChangeInfo, edit?: EditInfo | false) {
+    if (
+      !edit &&
+      this._patchRange?.patchNum === EditPatchSetNum &&
+      changeIsOpen(change)
+    ) {
+      fireAlert(this, 'Change edit not found. Please create a change edit.');
+      GerritNav.navigateToChange(change);
+      return;
+    }
+
+    if (
+      !edit &&
+      (changeIsMerged(change) || changeIsAbandoned(change)) &&
+      this._editMode
+    ) {
+      fireAlert(
+        this,
+        'Change edits cannot be created if change is merged or abandoned. Redirected to non edit mode.'
+      );
+      GerritNav.navigateToChange(change);
+      return;
+    }
+
     if (!edit) return;
+
     if (!this._patchRange)
       throw new Error('missing required _patchRange property');
+
     if (!edit.commit.commit) throw new Error('undefined edit.commit.commit');
     const changeWithEdit = change;
     if (changeWithEdit.revisions)
@@ -1934,9 +1879,9 @@
         // Slice returns a number as a string, convert to an int.
         this._lineHeight = Number(lineHeight.slice(0, lineHeight.length - 2));
 
+        this.changeService.updateChange(change);
         this._change = change;
         this.computeRevertSubmitted(change);
-        this.changeService.updateChange(change);
         if (
           !this._patchRange ||
           !this._patchRange.patchNum ||
@@ -2022,22 +1967,37 @@
 
   _getCommitInfo() {
     if (!this._changeNum)
-      throw new Error('missing required changeNum property');
+      throw new Error('missing required _changeNum property');
     if (!this._patchRange)
       throw new Error('missing required _patchRange property');
     if (this._patchRange.patchNum === undefined)
       throw new Error('missing required patchNum property');
+
+    // We only call _getEdit if the patchset number is an edit.
+    // We have to do this to ensure we can tell if an edit
+    // exists or not.
+    // This safely works even if a edit does not exist.
+    if (this._patchRange!.patchNum! === EditPatchSetNum) {
+      return this._getEdit().then(edit => {
+        if (!edit) {
+          return Promise.resolve();
+        }
+
+        return this._getChangeCommitInfo();
+      });
+    }
+
+    return this._getChangeCommitInfo();
+  }
+
+  _getChangeCommitInfo() {
     return this.restApiService
-      .getChangeCommitInfo(this._changeNum, this._patchRange.patchNum)
+      .getChangeCommitInfo(this._changeNum!, this._patchRange!.patchNum!)
       .then(commitInfo => {
         this._commitInfo = commitInfo;
       });
   }
 
-  _reloadDraftsWithCallback(e: CustomEvent<{resolve: () => void}>) {
-    return this._reloadDrafts().then(() => e.detail.resolve());
-  }
-
   /**
    * Fetches a new changeComment object, and data for all types of comments
    * (comments, robot comments, draft comments) is requested.
@@ -2047,38 +2007,18 @@
     // a new change being loaded and then paired with outdated comments.
     this._changeComments = undefined;
     this._commentThreads = undefined;
-    this._diffDrafts = undefined;
     this._draftCommentThreads = undefined;
     this._robotCommentThreads = undefined;
     if (!this._changeNum)
       throw new Error('missing required changeNum property');
 
-    return this.$.commentAPI
-      .loadAll(this._changeNum, this._patchRange?.patchNum)
-      .then(comments => {
-        this._recomputeComments(comments);
-      });
+    this.commentsService.loadAll(this._changeNum, this._patchRange?.patchNum);
   }
 
-  /**
-   * Fetches a new changeComment object, but only updated data for drafts is
-   * requested.
-   *
-   * TODO(taoalpha): clean up this and _reloadComments, as single comment
-   * can be a thread so it does not make sense to only update drafts
-   * without updating threads
-   */
-  _reloadDrafts() {
-    if (!this._changeNum)
-      throw new Error('missing required changeNum property');
-    return this.$.commentAPI
-      .reloadDrafts(this._changeNum)
-      .then(comments => this._recomputeComments(comments));
-  }
-
-  _recomputeComments(comments: ChangeComments) {
+  @observe('_changeComments')
+  changeCommentsChanged(comments?: ChangeComments) {
+    if (!comments) return;
     this._changeComments = comments;
-    this._diffDrafts = {...this._changeComments.drafts};
     this._commentThreads = this._changeComments.getAllThreadsForChange();
     this._draftCommentThreads = this._commentThreads
       .filter(isDraftThread)
@@ -2107,6 +2047,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,7 +2074,11 @@
       .then(() => {
         this.reporting.timeEnd(Timing.CHANGE_RELOAD);
         if (isLocationChange) {
-          this.reporting.changeDisplayed();
+          this.reporting.changeDisplayed({
+            isOwner: isOwner(this._change, this._account),
+            isReviewer: isReviewer(this._change, this._account),
+            isCc: isCc(this._change, this._account),
+          });
         }
       });
 
@@ -2144,10 +2089,7 @@
     });
     allDataPromises.push(projectConfigLoaded);
 
-    // Resolves when change comments have loaded (comments, drafts and robot
-    // comments).
-    const commentsLoaded = this._reloadComments();
-    allDataPromises.push(commentsLoaded);
+    this._reloadComments();
 
     let coreDataPromise;
 
@@ -2232,6 +2174,7 @@
     }
 
     Promise.all(allDataPromises).then(() => {
+      // Loading of commments data is no longer part of this reporting
       this.reporting.timeEnd(Timing.CHANGE_DATA);
       if (isLocationChange) {
         this.reporting.changeFullyLoaded();
@@ -2370,6 +2313,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 +2341,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_html.ts b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_html.ts
index d351af6..c6975e6 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_html.ts
@@ -344,7 +344,7 @@
           <span class="headerSubject">[[_change.subject]]</span>
           <gr-copy-clipboard
             class="changeCopyClipboard"
-            hide-input=""
+            hideInput=""
             text="[[_computeCopyTextForTitle(_change)]]"
           >
           </gr-copy-clipboard>
@@ -429,6 +429,7 @@
                   ></gr-linked-text>
                 </gr-editable-content>
               </div>
+              <h3 class="assistive-tech-only">Comments and Checks Summary</h3>
               <gr-change-summary
                 change-comments="[[_changeComments]]"
                 comment-threads="[[_commentThreads]]"
@@ -464,7 +465,7 @@
       <paper-tab
         on-click="_onPaperTabClick"
         data-name$="[[_constants.PrimaryTab.FILES]]"
-        >Files</paper-tab
+        ><span>Files</span></paper-tab
       >
       <paper-tab
         on-click="_onPaperTabClick"
@@ -482,7 +483,7 @@
         <paper-tab
           data-name$="[[_constants.PrimaryTab.CHECKS]]"
           on-click="_onPaperTabClick"
-          >Checks</paper-tab
+          ><span>Checks</span></paper-tab
         >
       </template>
       <template
@@ -503,7 +504,7 @@
         data-name$="[[_constants.PrimaryTab.FINDINGS]]"
         on-click="_onPaperTabClick"
       >
-        Findings
+        <span>Findings</span>
       </paper-tab>
     </paper-tabs>
 
@@ -545,7 +546,6 @@
           change-num="[[_changeNum]]"
           patch-range="{{_patchRange}}"
           change-comments="[[_changeComments]]"
-          revisions="[[_change.revisions]]"
           selected-index="{{viewState.selectedFileIndex}}"
           diff-view-mode="[[viewState.diffMode]]"
           edit-mode="[[_editMode]]"
@@ -554,7 +554,6 @@
           file-list-increment="{{_numFilesShown}}"
           on-files-shown-changed="_setShownFiles"
           on-file-action-tap="_handleFileActionTap"
-          on-reload-drafts="_reloadDraftsWithCallback"
           observer-target="[[_computeObserverTarget()]]"
         >
         </gr-file-list>
@@ -563,14 +562,17 @@
         is="dom-if"
         if="[[_isTabActive(_constants.PrimaryTab.COMMENT_THREADS, _activeTabs)]]"
       >
+        <h3 class="assistive-tech-only">Comments</h3>
         <gr-thread-list
           threads="[[_commentThreads]]"
           change="[[_change]]"
           change-num="[[_changeNum]]"
           logged-in="[[_loggedIn]]"
+          account="[[_account]]"
           comment-tab-state="[[_tabState.commentTab]]"
           only-show-robot-comments-with-human-reply=""
           unresolved-only="[[unresolvedOnly]]"
+          scroll-comment-id="[[scrollCommentId]]"
           show-comment-context
         ></gr-thread-list>
       </template>
@@ -578,6 +580,7 @@
         is="dom-if"
         if="[[_isTabActive(_constants.PrimaryTab.CHECKS, _activeTabs)]]"
       >
+        <h3 class="assistive-tech-only">Checks</h3>
         <gr-checks-tab
           id="checksTab"
           tab-state="[[_tabState.checksTab]]"
@@ -599,7 +602,7 @@
           change="[[_change]]"
           change-num="[[_changeNum]]"
           logged-in="[[_loggedIn]]"
-          hide-toggle-buttons
+          hide-dropdown
           empty-thread-msg="[[_messages.NO_ROBOT_COMMENTS_THREADS_MSG]]"
         >
         </gr-thread-list>
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts
index 3d51ac6..867b3a7 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts
@@ -26,7 +26,6 @@
   HttpMethod,
   MessageTag,
   PrimaryTab,
-  SecondaryTab,
 } from '../../../constants/constants';
 import {GrEditConstants} from '../../edit/gr-edit-constants';
 import {_testOnly_resetEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints';
@@ -71,7 +70,6 @@
   CommitInfo,
   EditInfo,
   EditPatchSetNum,
-  ElementPropertyDeepChange,
   GitRef,
   NumericChangeId,
   ParentPatchSetNum,
@@ -94,13 +92,7 @@
 import {SinonFakeTimers, SinonStubbedMember} from 'sinon/pkg/sinon-esm';
 import {RestApiService} from '../../../services/gr-rest-api/gr-rest-api';
 import {CustomKeyboardEvent} from '../../../types/events';
-import {
-  CommentThread,
-  DraftInfo,
-  UIDraft,
-  UIRobot,
-} from '../../../utils/comment-util';
-import {ChangeComments} from '../../diff/gr-comment-api/gr-comment-api';
+import {CommentThread, UIRobot} from '../../../utils/comment-util';
 import {GerritView} from '../../../services/router/router-model';
 import {ParsedChangeInfo} from '../../../types/types';
 import {GrRelatedChangesList} from '../gr-related-changes-list/gr-related-changes-list';
@@ -136,8 +128,6 @@
     TestKeyboardShortcutBinder.pop();
   });
 
-  const TEST_SCROLL_TOP_PX = 100;
-
   const ROBOT_COMMENTS_LIMIT = 10;
 
   // TODO: should have a mock service to generate VALID fake data
@@ -865,179 +855,6 @@
     });
   });
 
-  suite('reloading drafts', () => {
-    let reloadStub: SinonStubbedMember<
-      typeof element.$.commentAPI.reloadDrafts
-    >;
-    const drafts: {[path: string]: UIDraft[]} = {
-      'testfile.txt': [
-        {
-          patch_set: 5 as PatchSetNum,
-          id: 'dd2982f5_c01c9e6a' as UrlEncodedCommentId,
-          line: 1,
-          updated: '2017-11-08 18:47:45.000000000' as Timestamp,
-          message: 'test',
-          unresolved: true,
-        },
-      ],
-    };
-    setup(() => {
-      // Fake computeDraftCount as its required for ChangeComments,
-      // see gr-comment-api#reloadDrafts.
-      reloadStub = sinon.stub(element.$.commentAPI, 'reloadDrafts').returns(
-        Promise.resolve({
-          drafts,
-          getAllThreadsForChange: () => [] as CommentThread[],
-          computeDraftCount: () => 1,
-        } as ChangeComments)
-      );
-      element._changeNum = 1 as NumericChangeId;
-    });
-
-    test('drafts are reloaded when reload-drafts fired', done => {
-      element.$.fileList.dispatchEvent(
-        new CustomEvent('reload-drafts', {
-          detail: {
-            resolve: () => {
-              assert.isTrue(reloadStub.called);
-              assert.deepEqual(element._diffDrafts, drafts);
-              done();
-            },
-          },
-          composed: true,
-          bubbles: true,
-        })
-      );
-    });
-
-    test('drafts are reloaded when comment-refresh fired', () => {
-      element.dispatchEvent(
-        new CustomEvent('comment-refresh', {
-          composed: true,
-          bubbles: true,
-        })
-      );
-      assert.isTrue(reloadStub.called);
-    });
-  });
-
-  suite('_recomputeComments', () => {
-    setup(() => {
-      element._changeNum = TEST_NUMERIC_CHANGE_ID;
-      element._change = createChangeViewChange();
-      flush();
-      // Fake computeDraftCount as its required for ChangeComments,
-      // see gr-comment-api#reloadDrafts.
-      sinon.stub(element.$.commentAPI, 'reloadDrafts').returns(
-        Promise.resolve({
-          drafts: {},
-          getAllThreadsForChange: () => THREADS,
-          computeDraftCount: () => 0,
-        } as ChangeComments)
-      );
-      element._change = createChangeViewChange();
-      element._changeNum = element._change._number;
-    });
-
-    test('draft threads should be a new copy with correct states', done => {
-      element.$.fileList.dispatchEvent(
-        new CustomEvent('reload-drafts', {
-          detail: {
-            resolve: () => {
-              assert.equal(element._draftCommentThreads!.length, 2);
-              assert.equal(
-                element._draftCommentThreads![0].rootId,
-                THREADS[0].rootId
-              );
-              assert.notEqual(
-                element._draftCommentThreads![0].comments,
-                THREADS[0].comments
-              );
-              assert.notEqual(
-                element._draftCommentThreads![0].comments[0],
-                THREADS[0].comments[0]
-              );
-              assert.isTrue(
-                element
-                  ._draftCommentThreads![0].comments.slice(0, 2)
-                  .every(c => c.collapsed === true)
-              );
-
-              assert.isTrue(
-                element._draftCommentThreads![0].comments[2].collapsed === false
-              );
-              done();
-            },
-          },
-          composed: true,
-          bubbles: true,
-        })
-      );
-    });
-  });
-
-  test('diff comments modified', () => {
-    const reloadThreadsSpy = sinon.spy(element, '_handleReloadCommentThreads');
-    return element._reloadComments().then(() => {
-      element.dispatchEvent(
-        new CustomEvent('diff-comments-modified', {
-          composed: true,
-          bubbles: true,
-        })
-      );
-      assert.isTrue(reloadThreadsSpy.called);
-    });
-  });
-
-  test('thread list modified', () => {
-    const reloadDiffSpy = sinon.spy(element, '_handleReloadDiffComments');
-    element._activeTabs = [PrimaryTab.COMMENT_THREADS, SecondaryTab.CHANGE_LOG];
-    flush();
-
-    return element._reloadComments().then(() => {
-      element.threadList!.dispatchEvent(
-        new CustomEvent('thread-list-modified', {
-          composed: true,
-          bubbles: true,
-        })
-      );
-      assert.isTrue(reloadDiffSpy.called);
-
-      let draftStub = sinon
-        .stub(element._changeComments!, 'computeDraftCount')
-        .returns(1);
-      assert.equal(
-        element._computeTotalCommentCounts(5, element._changeComments!),
-        '5 unresolved, 1 draft'
-      );
-      assert.equal(
-        element._computeTotalCommentCounts(0, element._changeComments!),
-        '1 draft'
-      );
-      draftStub.restore();
-      draftStub = sinon
-        .stub(element._changeComments!, 'computeDraftCount')
-        .returns(0);
-      assert.equal(
-        element._computeTotalCommentCounts(0, element._changeComments!),
-        ''
-      );
-      assert.equal(
-        element._computeTotalCommentCounts(1, element._changeComments!),
-        '1 unresolved'
-      );
-      draftStub.restore();
-      draftStub = sinon
-        .stub(element._changeComments!, 'computeDraftCount')
-        .returns(2);
-      assert.equal(
-        element._computeTotalCommentCounts(1, element._changeComments!),
-        '1 unresolved, 2 drafts'
-      );
-      draftStub.restore();
-    });
-  });
-
   suite('thread list and change log tabs', () => {
     setup(() => {
       element._changeNum = TEST_NUMERIC_CHANGE_ID;
@@ -1222,7 +1039,7 @@
       },
     };
     element._mergeable = true;
-    const expectedStatuses = ['Merged', 'WIP'];
+    const expectedStatuses = [ChangeStates.MERGED, ChangeStates.WIP];
     assert.deepEqual(element._changeStatuses, expectedStatuses);
     flush();
     const statusChips = element.shadowRoot!.querySelectorAll(
@@ -1458,66 +1275,18 @@
   test('reply button has updated count when there are drafts', () => {
     const getLabel = element._computeReplyButtonLabel;
 
-    assert.equal(getLabel(null, false), 'Reply');
-    assert.equal(getLabel(null, true), 'Start Review');
+    assert.equal(getLabel(undefined, false), 'Reply');
+    assert.equal(getLabel(undefined, true), 'Reply');
 
-    const changeRecord: ElementPropertyDeepChange<
-      GrChangeView,
-      '_diffDrafts'
-    > = {base: undefined, path: '', value: undefined};
-    assert.equal(getLabel(changeRecord, false), 'Reply');
+    let drafts = {};
+    assert.equal(getLabel(drafts, false), 'Reply');
 
-    changeRecord.base = {};
-    assert.equal(getLabel(changeRecord, false), 'Reply');
-
-    changeRecord.base = {
+    drafts = {
       'file1.txt': [{}],
       'file2.txt': [{}, {}],
     };
-    assert.equal(getLabel(changeRecord, false), 'Reply (3)');
-    assert.equal(getLabel(changeRecord, true), 'Start Review (3)');
-  });
-
-  test('comment events properly update diff drafts', () => {
-    element._patchRange = {
-      basePatchNum: ParentPatchSetNum,
-      patchNum: 2 as RevisionPatchSetNum,
-    };
-    const draft: DraftInfo = {
-      __draft: true,
-      id: 'id1' as UrlEncodedCommentId,
-      path: '/foo/bar.txt',
-      message: 'hello',
-    };
-    element._handleCommentSave(new CustomEvent('', {detail: {comment: draft}}));
-    draft.patch_set = 2 as PatchSetNum;
-    assert.deepEqual(element._diffDrafts, {'/foo/bar.txt': [draft]});
-    draft.patch_set = undefined;
-    draft.message = 'hello, there';
-    element._handleCommentSave(new CustomEvent('', {detail: {comment: draft}}));
-    draft.patch_set = 2 as PatchSetNum;
-    assert.deepEqual(element._diffDrafts, {'/foo/bar.txt': [draft]});
-    const draft2: DraftInfo = {
-      __draft: true,
-      id: 'id2' as UrlEncodedCommentId,
-      path: '/foo/bar.txt',
-      message: 'hola',
-    };
-    element._handleCommentSave(
-      new CustomEvent('', {detail: {comment: draft2}})
-    );
-    draft2.patch_set = 2 as PatchSetNum;
-    assert.deepEqual(element._diffDrafts, {'/foo/bar.txt': [draft, draft2]});
-    draft.patch_set = undefined;
-    element._handleCommentDiscard(
-      new CustomEvent('', {detail: {comment: draft}})
-    );
-    draft.patch_set = 2 as PatchSetNum;
-    assert.deepEqual(element._diffDrafts, {'/foo/bar.txt': [draft2]});
-    element._handleCommentDiscard(
-      new CustomEvent('', {detail: {comment: draft2}})
-    );
-    assert.deepEqual(element._diffDrafts, {});
+    assert.equal(getLabel(drafts, false), 'Reply (3)');
+    assert.equal(getLabel(drafts, true), 'Start Review (3)');
   });
 
   test('change num change', () => {
@@ -1614,7 +1383,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 +1398,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 +1413,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 +1436,8 @@
       view: GerritView.CHANGE,
       patchNum: 1 as RevisionPatchSetNum,
     };
-    element._paramsChanged(value);
+    element.params = value;
+    await flush();
 
     element._initialLoadComplete = true;
     element._change = {
@@ -1678,24 +1450,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();
@@ -2013,41 +1803,6 @@
     assert.isTrue(awaitPluginsLoadedStub.called);
   });
 
-  suite('scroll related tests', () => {
-    test('document scrolling calls function to set scroll height', done => {
-      const originalHeight = document.body.scrollHeight;
-      const scrollStub = sinon.stub(element, 'handleScroll').callsFake(() => {
-        assert.isTrue(scrollStub.called);
-        document.body.style.height = `${originalHeight}px`;
-        scrollStub.restore();
-        done();
-      });
-      document.body.style.height = '10000px';
-      element.handleScroll();
-    });
-
-    test('scrollTop is set correctly', () => {
-      element.viewState = {scrollTop: TEST_SCROLL_TOP_PX};
-
-      sinon.stub(element, 'loadData').callsFake(() => {
-        // When element is reloaded, ensure that the history
-        // state has the scrollTop set earlier. This will then
-        // be reset.
-        assert.isTrue(element.viewState.scrollTop === TEST_SCROLL_TOP_PX);
-        return Promise.resolve([]);
-      });
-
-      // simulate reloading component, which is done when route
-      // changes to match a regex of change view type.
-      element._paramsChanged({...createAppElementChangeViewParams()});
-    });
-
-    test('scrollTop is reset when new change is loaded', () => {
-      element._resetFileListViewState();
-      assert.equal(element.viewState.scrollTop, 0);
-    });
-  });
-
   suite('reply dialog tests', () => {
     setup(() => {
       sinon.stub(element.$.replyDialog, '_draftChanged');
@@ -2566,7 +2321,6 @@
       };
       sinon.stub(element, '_getChangeDetail').returns(Promise.resolve(false));
       sinon.stub(element, '_getProjectConfig').returns(Promise.resolve());
-      sinon.stub(element, '_reloadComments').returns(Promise.resolve());
       sinon.stub(element, '_getMergeability').returns(Promise.resolve());
       sinon.stub(element, '_getLatestCommitMessage').returns(Promise.resolve());
       sinon
@@ -2591,7 +2345,7 @@
       });
     });
 
-    test('report changeDisplayed on _paramsChanged', done => {
+    test('report changeDisplayed on _paramsChanged', async () => {
       const changeDisplayStub = sinon.stub(
         appContext.reportingService,
         'changeDisplayed'
@@ -2600,16 +2354,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-commit-info/gr-commit-info_html.ts b/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info_html.ts
index 65ca8b5..02fa090 100644
--- a/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info_html.ts
@@ -31,9 +31,9 @@
       >[[_computeShortHash(change, commitInfo, serverConfig)]]</a
     >
     <gr-copy-clipboard
-      has-tooltip=""
-      button-title="Copy full SHA to clipboard"
-      hide-input=""
+      hasTooltip=""
+      buttonTitle="Copy full SHA to clipboard"
+      hideInput=""
       text="[[commitInfo.commit]]"
     >
     </gr-copy-clipboard>
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-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-rebase-dialog/gr-confirm-rebase-dialog_html.ts b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_html.ts
index 6def4a5..bed9240 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_html.ts
@@ -57,12 +57,7 @@
         class="rebaseOption"
         hidden$="[[!_displayParentOption(rebaseOnCurrent, hasParent)]]"
       >
-        <input
-          id="rebaseOnParentInput"
-          name="rebaseOptions"
-          type="radio"
-          on-click="_handleRebaseOnParent"
-        />
+        <input id="rebaseOnParentInput" name="rebaseOptions" type="radio" />
         <label id="rebaseOnParentLabel" for="rebaseOnParentInput">
           Rebase on parent change
         </label>
diff --git a/polygerrit-ui/app/elements/change/gr-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-confirm-submit-dialog/gr-confirm-submit-dialog_html.ts b/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog_html.ts
index e4662da..5f99ee6 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog_html.ts
@@ -72,7 +72,7 @@
               change="[[change]]"
               change-num="[[change._number]]"
               logged-in="true"
-              hide-toggle-buttons
+              hide-dropdown
             >
             </gr-thread-list>
           </template>
diff --git a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.ts b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.ts
index 0d34967..5aef4bb 100644
--- a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.ts
@@ -20,14 +20,18 @@
 import {htmlTemplate} from './gr-download-dialog_html';
 import {changeBaseURL, getRevisionKey} from '../../../utils/change-util';
 import {customElement, property, computed, observe} from '@polymer/decorators';
-import {ChangeInfo, ServerInfo, PatchSetNum} from '../../../types/common';
-import {RevisionInfo} from '../../shared/revision-info/revision-info';
+import {
+  ChangeInfo,
+  DownloadInfo,
+  PatchSetNum,
+  RevisionInfo,
+} from '../../../types/common';
 import {GrDownloadCommands} from '../../shared/gr-download-commands/gr-download-commands';
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {hasOwnProperty} from '../../../utils/common-util';
 import {GrOverlayStops} from '../../shared/gr-overlay/gr-overlay';
 import {KeyboardShortcutMixin} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
-import {fireAlert} from '../../../utils/event-util';
+import {fireAlert, fireEvent} from '../../../utils/event-util';
 
 export interface GrDownloadDialog {
   $: {
@@ -53,7 +57,7 @@
   change: ChangeInfo | undefined;
 
   @property({type: Object})
-  config?: ServerInfo;
+  config?: DownloadInfo;
 
   @property({type: String})
   patchNum: PatchSetNum | undefined;
@@ -100,6 +104,7 @@
     if (index > commands.length) return;
     navigator.clipboard.writeText(commands[index].command).then(() => {
       fireAlert(this, `${commands[index].title} command copied to clipboard`);
+      fireEvent(this, 'close');
     });
   }
 
@@ -234,12 +239,7 @@
   _handleCloseTap(e: Event) {
     e.preventDefault();
     e.stopPropagation();
-    this.dispatchEvent(
-      new CustomEvent('close', {
-        composed: true,
-        bubbles: false,
-      })
-    );
+    fireEvent(this, 'close');
   }
 
   @observe('_schemes')
diff --git a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_html.ts b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_html.ts
index cbea584..6e40f84 100644
--- a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_html.ts
@@ -59,6 +59,9 @@
     .hidden {
       display: none;
     }
+    gr-download-commands {
+      width: min(80vw, 1200px);
+    }
   </style>
   <section>
     <h3 class="heading-3">
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..f61bb68 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
@@ -20,9 +20,9 @@
 import {
   createChange,
   createCommit,
+  createDownloadInfo,
   createRevision,
   createRevisions,
-  createServerInfo,
 } from '../../../test/test-data-generators';
 import {
   CommitId,
@@ -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');
 
@@ -116,7 +117,7 @@
   setup(() => {
     element = basicFixture.instantiate();
     element.patchNum = 1 as PatchSetNum;
-    element.config = createServerInfo();
+    element.config = createDownloadInfo();
     flush();
   });
 
@@ -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.ts b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.ts
index 532097b..ff303e3 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.ts
+++ b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.ts
@@ -44,6 +44,7 @@
 import {DiffViewMode} from '../../../constants/constants';
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {fireEvent} from '../../../utils/event-util';
+import {KeyboardShortcutMixin} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -60,7 +61,7 @@
 }
 
 @customElement('gr-file-list-header')
-export class GrFileListHeader extends PolymerElement {
+export class GrFileListHeader extends KeyboardShortcutMixin(PolymerElement) {
   static get template() {
     return htmlTemplate;
   }
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 69be729..bceee27 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,47 +167,7 @@
           <span class="separator"></span>
         </span>
       </template>
-      <span class="downloadContainer desktop">
-        <gr-button
-          link=""
-          class="download"
-          title="[[createTitle(Shortcut.OPEN_DOWNLOAD_DIALOG,
-                ShortcutSection.ACTIONS)]]"
-          on-click="_handleDownloadTap"
-          >Download</gr-button
-        >
-      </span>
-      <template
-        is="dom-if"
-        if="[[_fileListActionsVisible(shownFileCount, _maxFilesForBulkActions)]]"
-      >
-        <gr-button
-          id="expandBtn"
-          link=""
-          title="[[createTitle(Shortcut.TOGGLE_ALL_INLINE_DIFFS,
-                ShortcutSection.FILE_LIST)]]"
-          on-click="_expandAllDiffs"
-          >Expand All</gr-button
-        >
-        <gr-button
-          id="collapseBtn"
-          link=""
-          on-click="_collapseAllDiffs"
-          title="[[createTitle(Shortcut.TOGGLE_ALL_INLINE_DIFFS,
-          ShortcutSection.FILE_LIST)]]"
-          >Collapse All</gr-button
-        >
-      </template>
-      <template
-        is="dom-if"
-        if="[[!_fileListActionsVisible(shownFileCount, _maxFilesForBulkActions)]]"
-      >
-        <div class="warning">
-          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"
@@ -229,7 +189,50 @@
             ><iron-icon icon="gr-icons:settings"></iron-icon
           ></gr-button>
         </span>
+        <span class="separator"></span>
       </div>
+      <span class="downloadContainer desktop">
+        <gr-button
+          link=""
+          class="download"
+          title="[[createTitle(Shortcut.OPEN_DOWNLOAD_DIALOG,
+                ShortcutSection.ACTIONS)]]"
+          has-tooltip=""
+          on-click="_handleDownloadTap"
+          >Download</gr-button
+        >
+      </span>
+      <template
+        is="dom-if"
+        if="[[_fileListActionsVisible(shownFileCount, _maxFilesForBulkActions)]]"
+      >
+        <gr-button
+          id="expandBtn"
+          link=""
+          title="[[createTitle(Shortcut.TOGGLE_ALL_INLINE_DIFFS,
+                ShortcutSection.FILE_LIST)]]"
+          has-tooltip=""
+          on-click="_expandAllDiffs"
+          >Expand All</gr-button
+        >
+        <gr-button
+          id="collapseBtn"
+          link=""
+          on-click="_collapseAllDiffs"
+          title="[[createTitle(Shortcut.TOGGLE_ALL_INLINE_DIFFS,
+          ShortcutSection.FILE_LIST)]]"
+          has-tooltip=""
+          >Collapse All</gr-button
+        >
+      </template>
+      <template
+        is="dom-if"
+        if="[[!_fileListActionsVisible(shownFileCount, _maxFilesForBulkActions)]]"
+      >
+        <div class="warning">
+          Bulk actions disabled because there are too many files.
+        </div>
+      </template>
     </div>
   </div>
 `;
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts
index 579bb77..5206fdb 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts
@@ -17,6 +17,7 @@
 import '../../../styles/shared-styles';
 import '../../diff/gr-diff-cursor/gr-diff-cursor';
 import '../../diff/gr-diff-host/gr-diff-host';
+import '../../diff/gr-comment-api/gr-comment-api';
 import '../../diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog';
 import '../../edit/gr-edit-file-controls/gr-edit-file-controls';
 import '../../shared/gr-button/gr-button';
@@ -61,14 +62,13 @@
 } from '../../../utils/path-list-util';
 import {customElement, observe, property} from '@polymer/decorators';
 import {
+  BasePatchSetNum,
+  EditPatchSetNum,
   ElementPropertyDeepChange,
   FileInfo,
   FileNameToFileInfoMap,
   NumericChangeId,
   PatchRange,
-  PreferencesInfo,
-  RevisionInfo,
-  UrlEncodedCommentId,
 } from '../../../types/common';
 import {DiffPreferencesInfo} from '../../../types/diff';
 import {GrDiffHost} from '../../diff/gr-diff-host/gr-diff-host';
@@ -80,6 +80,15 @@
 import {CustomKeyboardEvent} from '../../../types/events';
 import {ParsedChangeInfo, PatchSetFile} from '../../../types/types';
 import {Timing} from '../../../constants/reporting';
+import {RevisionInfo} from '../../shared/revision-info/revision-info';
+import {preferences$} from '../../../services/user/user-model';
+import {
+  changeComments$,
+  drafts$,
+} from '../../../services/comments/comments-model';
+import {Subject} from 'rxjs';
+import {takeUntil} from 'rxjs/operators';
+import {UIDraft} from '../../../utils/comment-util';
 
 export const DEFAULT_NUM_FILES_SHOWN = 200;
 
@@ -95,7 +104,6 @@
 export interface GrFileList {
   $: {
     diffPreferencesDialog: GrDiffPreferencesDialog;
-    diffCursor: GrDiffCursor;
   };
 }
 
@@ -174,12 +182,6 @@
     return htmlTemplate;
   }
 
-  /**
-   * Fired when a draft refresh should get triggered
-   *
-   * @event reload-drafts
-   */
-
   @property({type: Object})
   patchRange?: PatchRange;
 
@@ -192,9 +194,6 @@
   @property({type: Object})
   changeComments?: ChangeComments;
 
-  @property({type: Array})
-  revisions?: {[revisionId: string]: RevisionInfo};
-
   @property({type: Number, notify: true})
   selectedIndex = -1;
 
@@ -228,9 +227,6 @@
   @property({type: Object, notify: true, observer: '_updateDiffPreferences'})
   diffPrefs?: DiffPreferencesInfo;
 
-  @property({type: Object})
-  _userPrefs?: PreferencesInfo;
-
   @property({type: Boolean})
   _showInlineDiffs?: boolean;
 
@@ -273,9 +269,18 @@
   @property({type: Object, computed: '_computeSizeBarLayout(_shownFiles.*)'})
   _sizeBarLayout: SizeBarLayout = createDefaultSizeBarLayout();
 
-  @property({type: Boolean, computed: '_computeShowSizeBars(_userPrefs)'})
+  @property({type: Boolean})
   _showSizeBars = true;
 
+  // For merge commits vs Auto Merge, an extra file row is shown detailing the
+  // files that were merged without conflict. These files are also passed to any
+  // plugins.
+  @property({type: Array})
+  _cleanlyMergedPaths: string[] = [];
+
+  @property({type: Array})
+  _cleanlyMergedOldPaths: string[] = [];
+
   private _cancelForEachDiff?: () => void;
 
   loadingTask?: DelayedTask;
@@ -311,10 +316,15 @@
   @property({type: Array})
   _dynamicPrependedContentEndpoints?: string[];
 
+  @property({type: Object})
+  diffDrafts?: {[path: string]: UIDraft[]} = {};
+
   private readonly reporting = appContext.reportingService;
 
   private readonly restApiService = appContext.restApiService;
 
+  disconnected$ = new Subject();
+
   get keyBindings() {
     return {
       esc: '_handleEscKey',
@@ -350,6 +360,8 @@
 
   private fileCursor = new GrCursorManager();
 
+  private diffCursor = new GrDiffCursor();
+
   constructor() {
     super();
     this.fileCursor.scrollMode = ScrollMode.KEEP_VISIBLE;
@@ -361,6 +373,14 @@
   /** @override */
   connectedCallback() {
     super.connectedCallback();
+    drafts$.pipe(takeUntil(this.disconnected$)).subscribe(drafts => {
+      this.diffDrafts = drafts;
+    });
+    changeComments$
+      .pipe(takeUntil(this.disconnected$))
+      .subscribe(changeComments => {
+        this.changeComments = changeComments;
+      });
     getPluginLoader()
       .awaitPluginsLoaded()
       .then(() => {
@@ -384,31 +404,27 @@
           this._dynamicHeaderEndpoints.length !==
           this._dynamicContentEndpoints.length
         ) {
-          console.warn(
-            'Different number of dynamic file-list header and content.'
-          );
+          this.reporting.error(new Error('dynamic header/content mismatch'));
         }
         if (
           this._dynamicPrependedHeaderEndpoints.length !==
           this._dynamicPrependedContentEndpoints.length
         ) {
-          console.warn(
-            'Different number of dynamic file-list header and content.'
-          );
+          this.reporting.error(new Error('dynamic header/content mismatch'));
         }
         if (
           this._dynamicHeaderEndpoints.length !==
           this._dynamicSummaryEndpoints.length
         ) {
-          console.warn(
-            'Different number of dynamic file-list headers and summary.'
-          );
+          this.reporting.error(new Error('dynamic header/content mismatch'));
         }
       });
   }
 
   /** @override */
   disconnectedCallback() {
+    this.disconnected$.next();
+    this.diffCursor.dispose();
     this.fileCursor.unsetCursor();
     this._cancelDiffs();
     this.loadingTask?.cancel();
@@ -450,6 +466,7 @@
           this._filesByPath = filesByPath;
         })
     );
+
     promises.push(
       this._getLoggedIn()
         .then(loggedIn => (this._loggedIn = loggedIn))
@@ -472,11 +489,9 @@
       })
     );
 
-    promises.push(
-      this._getPreferences().then(prefs => {
-        this._userPrefs = prefs;
-      })
-    );
+    preferences$.pipe(takeUntil(this.disconnected$)).subscribe(prefs => {
+      this._showSizeBars = !!prefs?.size_bar_in_change_table;
+    });
 
     return Promise.all(promises).then(() => {
       this._loading = false;
@@ -485,6 +500,42 @@
     });
   }
 
+  @observe('_filesByPath')
+  async _updateCleanlyMergedPaths(filesByPath?: FileNameToFileInfoMap) {
+    // When viewing Auto Merge base vs a patchset, add an additional row that
+    // knows how many files were cleanly merged. This requires an additional RPC
+    // for the diffs between target parent and the patch set. The cleanly merged
+    // files are all the files in the target RPC that weren't in the Auto Merge
+    // RPC.
+    if (
+      this.change &&
+      this.changeNum &&
+      this.patchRange?.patchNum &&
+      new RevisionInfo(this.change).isMergeCommit(this.patchRange.patchNum) &&
+      this.patchRange.basePatchNum === 'PARENT' &&
+      this.patchRange.patchNum !== EditPatchSetNum
+    ) {
+      const allFilesByPath = await this.restApiService.getChangeOrEditFiles(
+        this.changeNum,
+        {
+          basePatchNum: -1 as BasePatchSetNum, // -1 is first (target) parent
+          patchNum: this.patchRange.patchNum,
+        }
+      );
+      if (!allFilesByPath || !filesByPath) return;
+      const conflictingPaths = Object.keys(filesByPath);
+      this._cleanlyMergedPaths = Object.keys(allFilesByPath).filter(
+        path => !conflictingPaths.includes(path)
+      );
+      this._cleanlyMergedOldPaths = this._cleanlyMergedPaths
+        .map(path => allFilesByPath[path].old_path)
+        .filter((oldPath): oldPath is string => !!oldPath);
+    } else {
+      this._cleanlyMergedPaths = [];
+      this._cleanlyMergedOldPaths = [];
+    }
+  }
+
   _detectChromiteButler() {
     const hasButler = !!document.getElementById('butler-suggested-owners');
     if (hasButler) {
@@ -597,7 +648,7 @@
       this._expandedFiles.length,
       this._files.length
     );
-    this.$.diffCursor.handleDiffUpdate();
+    this.diffCursor.handleDiffUpdate();
   }
 
   /**
@@ -626,7 +677,8 @@
     patchRange?: PatchRange,
     file?: NormalizedFileInfo
   ) {
-    const draftCount = changeComments?.computeDraftCountForFile(
+    if (changeComments === undefined) return '';
+    const draftCount = changeComments.computeDraftCountForFile(
       patchRange,
       file
     );
@@ -642,7 +694,8 @@
     patchRange?: PatchRange,
     file?: NormalizedFileInfo
   ) {
-    const draftCount = changeComments?.computeDraftCountForFile(
+    if (changeComments === undefined) return '';
+    const draftCount = changeComments.computeDraftCountForFile(
       patchRange,
       file
     );
@@ -841,7 +894,7 @@
     }
 
     e.preventDefault();
-    this.$.diffCursor.moveLeft();
+    this.diffCursor.moveLeft();
   }
 
   _handleRightPane(e: CustomKeyboardEvent) {
@@ -850,7 +903,7 @@
     }
 
     e.preventDefault();
-    this.$.diffCursor.moveRight();
+    this.diffCursor.moveRight();
   }
 
   _handleToggleInlineDiff(e: CustomKeyboardEvent) {
@@ -891,7 +944,7 @@
 
     if (this._showInlineDiffs) {
       e.preventDefault();
-      this.$.diffCursor.moveDown();
+      this.diffCursor.moveDown();
       this._displayLine = true;
     } else {
       // Down key
@@ -911,7 +964,7 @@
 
     if (this._showInlineDiffs) {
       e.preventDefault();
-      this.$.diffCursor.moveUp();
+      this.diffCursor.moveUp();
       this._displayLine = true;
     } else {
       // Up key
@@ -930,7 +983,7 @@
     }
     e.preventDefault();
     this.classList.remove('hideComments');
-    this.$.diffCursor.createCommentInPlace();
+    this.diffCursor.createCommentInPlace();
   }
 
   _handleOpenLastFile(e: CustomKeyboardEvent) {
@@ -978,9 +1031,9 @@
 
     e.preventDefault();
     if (isShiftPressed(e)) {
-      this.$.diffCursor.moveToNextCommentThread();
+      this.diffCursor.moveToNextCommentThread();
     } else {
-      this.$.diffCursor.moveToNextChunk();
+      this.diffCursor.moveToNextChunk();
     }
   }
 
@@ -995,9 +1048,9 @@
 
     e.preventDefault();
     if (isShiftPressed(e)) {
-      this.$.diffCursor.moveToPreviousCommentThread();
+      this.diffCursor.moveToPreviousCommentThread();
     } else {
-      this.$.diffCursor.moveToPreviousChunk();
+      this.diffCursor.moveToPreviousChunk();
     }
   }
 
@@ -1033,7 +1086,7 @@
   }
 
   _openCursorFile() {
-    const diff = this.$.diffCursor.getTargetDiffElement();
+    const diff = this.diffCursor.getTargetDiffElement();
     if (!this.change || !diff || !this.patchRange || !diff.path) {
       throw new Error('change, diff and patchRange must be all set and valid');
     }
@@ -1164,6 +1217,24 @@
       : 'gr-icons:expand-more';
   }
 
+  _computeShowNumCleanlyMerged(cleanlyMergedPaths: string[]): boolean {
+    return cleanlyMergedPaths.length > 0;
+  }
+
+  _computeCleanlyMergedText(cleanlyMergedPaths: string[]): string {
+    const fileCount = pluralize(cleanlyMergedPaths.length, 'file');
+    return `${fileCount} merged cleanly in Parent 1`;
+  }
+
+  _handleShowParent1(): void {
+    if (!this.change || !this.patchRange) return;
+    GerritNav.navigateToChange(
+      this.change,
+      this.patchRange.patchNum,
+      -1 as BasePatchSetNum // Parent 1
+    );
+  }
+
   @observe(
     '_filesByPath',
     'changeComments',
@@ -1188,12 +1259,10 @@
     ) {
       return;
     }
-
     // Await all promises resolving from reload. @See Issue 9057
     if (loading || !changeComments) {
       return;
     }
-
     const commentedPaths = changeComments.getPaths(patchRange);
     const files: FileNameToReviewedFileInfoMap = {...filesByPath};
     addUnmodifiedFiles(files, commentedPaths);
@@ -1240,12 +1309,7 @@
 
   _updateDiffCursor() {
     // Overwrite the cursor's list of diffs:
-    this.$.diffCursor.splice(
-      'diffs',
-      0,
-      this.$.diffCursor.diffs.length,
-      ...this.diffs
-    );
+    this.diffCursor.replaceDiffs(this.diffs);
   }
 
   _filesChanged() {
@@ -1382,7 +1446,7 @@
     }
 
     this._updateDiffCursor();
-    this.$.diffCursor.reInitAndUpdateStops();
+    this.diffCursor.reInitAndUpdateStops();
   }
 
   private _clearCollapsedDiffs(collapsedDiffs: GrDiffHost[]) {
@@ -1415,64 +1479,50 @@
       }
     }
 
-    return new Promise(resolve => {
-      this.dispatchEvent(
-        new CustomEvent('reload-drafts', {
-          detail: {resolve},
-          composed: true,
-          bubbles: true,
-        })
+    asyncForeach(files, (file, cancel) => {
+      const path = file.path;
+      this._cancelForEachDiff = cancel;
+
+      iter++;
+      console.info('Expanding diff', iter, 'of', initialCount, ':', path);
+      const diffElem = this._findDiffByPath(path, diffElements);
+      if (!diffElem) {
+        this.reporting.error(
+          new Error(`Did not find <gr-diff-host> element for ${path}`)
+        );
+        return Promise.resolve();
+      }
+      if (!this.diffPrefs) {
+        throw new Error('diffPrefs must be set');
+      }
+
+      const promises: Array<Promise<unknown>> = [diffElem.reload()];
+      if (this._loggedIn && !this.diffPrefs.manual_review) {
+        promises.push(this._reviewFile(path, true));
+      }
+      return Promise.all(promises);
+    }).then(() => {
+      this._cancelForEachDiff = undefined;
+      console.info('Finished expanding', initialCount, 'diff(s)');
+      this.reporting.timeEndWithAverage(
+        Timing.FILE_EXPAND_ALL,
+        Timing.FILE_EXPAND_ALL_AVG,
+        initialCount
       );
-    }).then(() =>
-      asyncForeach(files, (file, cancel) => {
-        const path = file.path;
-        this._cancelForEachDiff = cancel;
+      /* Block diff cursor from auto scrolling after files are done rendering.
+      * This prevents the bug where the screen jumps to the first diff chunk
+      * after files are done being rendered after the user has already begun
+      * scrolling.
+      * This also however results in the fact that the cursor does not auto
+      * focus on the first diff chunk on a small screen. This is however, a use
+      * case we are willing to not support for now.
 
-        iter++;
-        console.info('Expanding diff', iter, 'of', initialCount, ':', path);
-        const diffElem = this._findDiffByPath(path, diffElements);
-        if (!diffElem) {
-          console.warn(`Did not find <gr-diff-host> element for ${path}`);
-          return Promise.resolve();
-        }
-        if (!this.changeComments || !this.patchRange || !this.diffPrefs) {
-          throw new Error(
-            'changeComments, patchRange and diffPrefs must be set'
-          );
-        }
-
-        diffElem.threads = this.changeComments.getThreadsBySideForFile(
-          file,
-          this.patchRange
-        );
-        const promises: Array<Promise<unknown>> = [diffElem.reload()];
-        if (this._loggedIn && !this.diffPrefs.manual_review) {
-          promises.push(this._reviewFile(path, true));
-        }
-        return Promise.all(promises);
-      }).then(() => {
-        this._cancelForEachDiff = undefined;
-        console.info('Finished expanding', initialCount, 'diff(s)');
-        this.reporting.timeEndWithAverage(
-          Timing.FILE_EXPAND_ALL,
-          Timing.FILE_EXPAND_ALL_AVG,
-          initialCount
-        );
-        /* Block diff cursor from auto scrolling after files are done rendering.
-       * This prevents the bug where the screen jumps to the first diff chunk
-       * after files are done being rendered after the user has already begun
-       * scrolling.
-       * This also however results in the fact that the cursor does not auto
-       * focus on the first diff chunk on a small screen. This is however, a use
-       * case we are willing to not support for now.
-
-       * Using handleDiffUpdate resulted in diffCursor.row being set which
-       * prevented the issue of scrolling to top when we expand the second
-       * file individually.
-       */
-        this.$.diffCursor.reInitAndUpdateStops();
-      })
-    );
+      * Using handleDiffUpdate resulted in diffCursor.row being set which
+      * prevented the issue of scrolling to top when we expand the second
+      * file individually.
+      */
+      this.diffCursor.reInitAndUpdateStops();
+    });
   }
 
   /** Cancel the rendering work of every diff in the list */
@@ -1495,47 +1545,6 @@
     return undefined;
   }
 
-  /**
-   * Reset the comments of a modified thread
-   */
-  reloadCommentsForThreadWithRootId(rootId: UrlEncodedCommentId, path: string) {
-    // Don't bother continuing if we already know that the path that contains
-    // the updated comment thread is not expanded.
-    if (!this._expandedFiles.some(f => f.path === path)) {
-      return;
-    }
-    const diff = this.diffs.find(d => d.path === path);
-
-    if (!diff) {
-      throw new Error("Can't find diff by path");
-    }
-
-    const threadEl = diff.getThreadEls().find(t => t.rootId === rootId);
-    if (!threadEl) {
-      return;
-    }
-
-    if (!this.changeComments) {
-      throw new Error('changeComments must be set');
-    }
-
-    const newComments = this.changeComments.getCommentsForThread(rootId);
-
-    // If newComments is null, it means that a single draft was
-    // removed from a thread in the thread view, and the thread should
-    // no longer exist. Remove the existing thread element in the diff
-    // view.
-    if (!newComments) {
-      threadEl.fireRemoveSelf();
-      return;
-    }
-
-    threadEl.comments = newComments.map(c => {
-      return {...c};
-    });
-    flush();
-  }
-
   _handleEscKey(e: CustomKeyboardEvent) {
     if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
       return;
@@ -1666,10 +1675,6 @@
     return stats.deletionOffset;
   }
 
-  _computeShowSizeBars(userPrefs?: PreferencesInfo) {
-    return !!userPrefs?.size_bar_in_change_table;
-  }
-
   _computeSizeBarsClass(showSizeBars?: boolean, path?: string) {
     let hideClass = '';
     if (!showSizeBars) {
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_html.ts b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_html.ts
index 40bd5bc..4d04744 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_html.ts
@@ -206,6 +206,12 @@
       cursor: pointer;
       opacity: 100;
     }
+    .showParentButton {
+      line-height: var(--line-height-normal);
+      margin-bottom: calc(var(--spacing-s) * -1);
+      margin-left: var(--spacing-m);
+      margin-top: calc(var(--spacing-s) * -1);
+    }
     .row:focus {
       outline: none;
     }
@@ -404,7 +410,7 @@
               </span>
               <gr-file-status-chip file="[[file]]"></gr-file-status-chip>
               <gr-copy-clipboard
-                hide-input=""
+                hideInput=""
                 text="[[file.__path]]"
               ></gr-copy-clipboard>
             </a>
@@ -412,7 +418,7 @@
               <div class="oldPath" title$="[[file.old_path]]">
                 [[file.old_path]]
                 <gr-copy-clipboard
-                  hide-input=""
+                  hideInput=""
                   text="[[file.old_path]]"
                 ></gr-copy-clipboard>
               </div>
@@ -641,6 +647,54 @@
         </template>
       </div>
     </template>
+    <template
+      is="dom-if"
+      if="[[_computeShowNumCleanlyMerged(_cleanlyMergedPaths)]]"
+    >
+      <div class="row">
+        <!-- endpoint: change-view-file-list-content-prepend -->
+        <template is="dom-if" if="[[_showPrependedDynamicColumns]]">
+          <template
+            is="dom-repeat"
+            items="[[_dynamicPrependedContentEndpoints]]"
+            as="contentEndpoint"
+          >
+            <gr-endpoint-decorator name="[[contentEndpoint]]" role="gridcell">
+              <gr-endpoint-param name="change" value="[[change]]">
+              </gr-endpoint-param>
+              <gr-endpoint-param name="changeNum" value="[[changeNum]]">
+              </gr-endpoint-param>
+              <gr-endpoint-param name="patchRange" value="[[patchRange]]">
+              </gr-endpoint-param>
+              <gr-endpoint-param
+                name="cleanlyMergedPaths"
+                value="[[_cleanlyMergedPaths]]"
+              >
+              </gr-endpoint-param>
+              <gr-endpoint-param
+                name="cleanlyMergedOldPaths"
+                value="[[_cleanlyMergedOldPaths]]"
+              >
+              </gr-endpoint-param>
+            </gr-endpoint-decorator>
+          </template>
+        </template>
+        <div role="gridcell">
+          <div>
+            <span class="cleanlyMergedText">
+              [[_computeCleanlyMergedText(_cleanlyMergedPaths)]]
+            </span>
+            <gr-button
+              link
+              class="showParentButton"
+              on-click="_handleShowParent1"
+            >
+              Show Parent 1
+            </gr-button>
+          </div>
+        </div>
+      </div>
+    </template>
   </div>
   <div class="row totalChanges" hidden$="[[_hideChangeTotals]]">
     <div class="total-stats">
@@ -735,5 +789,4 @@
     on-reload-diff-preference="_handleReloadingDiffPreference"
   >
   </gr-diff-preferences-dialog>
-  <gr-diff-cursor id="diffCursor"></gr-diff-cursor>
 `;
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.js b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.js
index 0c6c476..79bc9f6 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.js
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.js
@@ -25,16 +25,32 @@
 import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
 import {runA11yAudit} from '../../../test/a11y-test-utils.js';
 import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-import {TestKeyboardShortcutBinder, stubRestApi, spyRestApi, listenOnce} from '../../../test/test-utils.js';
+import {
+  TestKeyboardShortcutBinder,
+  stubRestApi,
+  spyRestApi,
+  listenOnce,
+  mockPromise,
+  query,
+} from '../../../test/test-utils.js';
+import {EditPatchSetNum} from '../../../types/common.js';
 import {Shortcut} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.js';
 import {createCommentThreads} from '../../../utils/comment-util.js';
-import {createChangeComments} from '../../../test/test-data-generators.js';
+import {
+  createChange,
+  createChangeComments,
+  createCommit,
+  createParsedChange,
+  createRevision,
+} from '../../../test/test-data-generators.js';
+import sinon from 'sinon/pkg/sinon-esm';
+import {createDefaultDiffPrefs} from '../../../constants/constants.js';
+import {queryAndAssert} from '../../../utils/common-util.js';
 
 const commentApiMock = createCommentApiMockWithTemplateElement(
     'gr-file-list-comment-api-mock', html`
     <gr-file-list id="fileList"
-        change-comments="[[_changeComments]]"
-        on-reload-drafts="_reloadDraftsWithCallback"></gr-file-list>
+        change-comments="[[_changeComments]]"></gr-file-list>
     <gr-comment-api id="commentAPI"></gr-comment-api>
 `);
 
@@ -51,7 +67,6 @@
   let commentApiWrapper;
 
   let saveStub;
-  let loadCommentSpy;
 
   suiteSetup(() => {
     const kb = TestKeyboardShortcutBinder.push();
@@ -79,7 +94,6 @@
 
   suite('basic tests', () => {
     setup(done => {
-      stubRestApi('getPreferences').returns(Promise.resolve({}));
       stubRestApi('getDiffComments').returns(Promise.resolve({}));
       stubRestApi('getDiffRobotComments').returns(Promise.resolve({}));
       stubRestApi('getDiffDrafts').returns(Promise.resolve({}));
@@ -94,14 +108,7 @@
       // comment API.
       commentApiWrapper = basicFixture.instantiate();
       element = commentApiWrapper.$.fileList;
-      loadCommentSpy = sinon.spy(commentApiWrapper.$.commentAPI, 'loadAll');
 
-      // Stub methods on the changeComments object after changeComments has
-      // been initialized.
-      commentApiWrapper.loadComments().then(() => {
-        sinon.stub(element.changeComments, 'getPaths').returns({});
-        done();
-      });
       element._loading = false;
       element.diffPrefs = {};
       element.numFilesShown = 200;
@@ -111,6 +118,7 @@
       };
       saveStub = sinon.stub(element, '_saveReviewedState').callsFake(
           () => Promise.resolve());
+      done();
     });
 
     test('correct number of files are shown', () => {
@@ -543,7 +551,7 @@
         assert.equal(element.fileCursor.index, 1);
         assert.equal(element.selectedIndex, 1);
 
-        const createCommentInPlaceStub = sinon.stub(element.$.diffCursor,
+        const createCommentInPlaceStub = sinon.stub(element.diffCursor,
             'createCommentInPlace');
         MockInteractions.pressAndReleaseKeyOn(element, 67, null, 'c');
         assert.isTrue(createCommentInPlaceStub.called);
@@ -664,8 +672,8 @@
       });
 
       test('shift+left/shift+right', () => {
-        const moveLeftStub = sinon.stub(element.$.diffCursor, 'moveLeft');
-        const moveRightStub = sinon.stub(element.$.diffCursor, 'moveRight');
+        const moveLeftStub = sinon.stub(element.diffCursor, 'moveLeft');
+        const moveRightStub = sinon.stub(element.diffCursor, 'moveRight');
 
         let noDiffsExpanded = true;
         sinon.stub(element, '_noDiffsExpanded')
@@ -913,9 +921,9 @@
 
     test('expandAllDiffs and collapseAllDiffs', () => {
       const collapseStub = sinon.stub(element, '_clearCollapsedDiffs');
-      const cursorUpdateStub = sinon.stub(element.$.diffCursor,
+      const cursorUpdateStub = sinon.stub(element.diffCursor,
           'handleDiffUpdate');
-      const reInitStub = sinon.stub(element.$.diffCursor,
+      const reInitStub = sinon.stub(element.diffCursor,
           'reInitAndUpdateStops');
 
       const path = 'path/to/my/file.txt';
@@ -993,7 +1001,7 @@
           FilesExpandedState.ALL);
     });
 
-    test('_renderInOrder', done => {
+    test('_renderInOrder', async () => {
       const reviewStub = sinon.stub(element, '_reviewFile');
       let callCount = 0;
       const diffs = [{
@@ -1023,15 +1031,12 @@
       }];
       element._renderInOrder([
         {path: 'p2'}, {path: 'p1'}, {path: 'p0'},
-      ], diffs, 3)
-          .then(() => {
-            assert.isFalse(reviewStub.called);
-            assert.isTrue(loadCommentSpy.called);
-            done();
-          });
+      ], diffs, 3);
+      await flush();
+      assert.isFalse(reviewStub.called);
     });
 
-    test('_renderInOrder logged in', done => {
+    test('_renderInOrder logged in', async () => {
       element._loggedIn = true;
       const reviewStub = sinon.stub(element, '_reviewFile');
       let callCount = 0;
@@ -1065,14 +1070,12 @@
       }];
       element._renderInOrder([
         {path: 'p2'}, {path: 'p1'}, {path: 'p0'},
-      ], diffs, 3)
-          .then(() => {
-            assert.equal(reviewStub.callCount, 3);
-            done();
-          });
+      ], diffs, 3);
+      await flush();
+      assert.equal(reviewStub.callCount, 3);
     });
 
-    test('_renderInOrder respects diffPrefs.manual_review', () => {
+    test('_renderInOrder respects diffPrefs.manual_review', async () => {
       element._loggedIn = true;
       element.diffPrefs = {manual_review: true};
       const reviewStub = sinon.stub(element, '_reviewFile');
@@ -1083,43 +1086,163 @@
         reload() { return Promise.resolve(); },
       }];
 
-      return element._renderInOrder([{path: 'p'}], diffs, 1).then(() => {
-        assert.isFalse(reviewStub.called);
-        delete element.diffPrefs.manual_review;
-        return element._renderInOrder([{path: 'p'}], diffs, 1).then(() => {
-          assert.isTrue(reviewStub.called);
-          assert.isTrue(reviewStub.calledWithExactly('p', true));
-        });
-      });
+      element._renderInOrder([{path: 'p'}], diffs, 1);
+      await flush();
+      assert.isFalse(reviewStub.called);
+      delete element.diffPrefs.manual_review;
+      element._renderInOrder([{path: 'p'}], diffs, 1);
+      await flush();
+      assert.isTrue(reviewStub.called);
+      assert.isTrue(reviewStub.calledWithExactly('p', true));
     });
 
-    test('_loadingChanged fired from reload in debouncer', done => {
-      sinon.stub(element, '_getReviewedFiles').returns(Promise.resolve([]));
+    test('_loadingChanged fired from reload in debouncer', async () => {
+      const reloadBlocker = mockPromise();
+      stubRestApi('getChangeOrEditFiles').resolves({'foo.bar': {}});
+      stubRestApi('getReviewedFiles').resolves(null);
+      stubRestApi('getDiffPreferences').resolves(createDefaultDiffPrefs());
+      stubRestApi('getLoggedIn').returns(reloadBlocker.then(() => false));
+
       element.changeNum = 123;
       element.patchRange = {patchNum: 12};
       element._filesByPath = {'foo.bar': {}};
+      element.change = {...createParsedChange(), _number: 123};
 
-      element.reload().then(() => {
-        assert.isFalse(element._loading);
-        element.loadingTask.flush();
-        assert.isFalse(element.classList.contains('loading'));
-        done();
-      });
+      const reloaded = element.reload();
       assert.isTrue(element._loading);
       assert.isFalse(element.classList.contains('loading'));
       element.loadingTask.flush();
       assert.isTrue(element.classList.contains('loading'));
+
+      reloadBlocker.resolve();
+      await reloaded;
+
+      assert.isFalse(element._loading);
+      element.loadingTask.flush();
+      assert.isFalse(element.classList.contains('loading'));
     });
 
     test('_loadingChanged does not set class when there are no files', () => {
-      sinon.stub(element, '_getReviewedFiles').returns(Promise.resolve([]));
+      const reloadBlocker = mockPromise();
+      stubRestApi('getLoggedIn').returns(reloadBlocker.then(() => false));
+      sinon.stub(element, '_getReviewedFiles').resolves([]);
       element.changeNum = 123;
       element.patchRange = {patchNum: 12};
+      element.change = {...createParsedChange(), _number: 123};
       element.reload();
+
       assert.isTrue(element._loading);
+
       element.loadingTask.flush();
+
       assert.isFalse(element.classList.contains('loading'));
     });
+
+    suite('for merge commits', () => {
+      let filesStub;
+
+      setup(async () => {
+        filesStub = stubRestApi('getChangeOrEditFiles')
+            .onFirstCall()
+            .resolves({'conflictingFile.js': {}})
+            .onSecondCall()
+            .resolves({'conflictingFile.js': {}, 'cleanlyMergedFile.js': {}});
+        stubRestApi('getReviewedFiles').resolves([]);
+        stubRestApi('getDiffPreferences').resolves(createDefaultDiffPrefs());
+        const changeWithMultipleParents = {
+          ...createChange(),
+          revisions: {
+            r1: {
+              ...createRevision(),
+              commit: {
+                ...createCommit(),
+                parents: [
+                  {commit: 'p1', subject: 'subject1'},
+                  {commit: 'p2', subject: 'subject2'},
+                ],
+              },
+            },
+          },
+        };
+        element.changeNum = changeWithMultipleParents._number;
+        element.change = changeWithMultipleParents;
+        element.patchRange = {basePatchNum: 'PARENT', patchNum: 1};
+        await flush();
+      });
+
+      test('displays cleanly merged file count', async () => {
+        await element.reload();
+        await flush();
+
+        const message = queryAndAssert(element, '.cleanlyMergedText')
+            .textContent.trim();
+        assert.equal(message, '1 file merged cleanly in Parent 1');
+      });
+
+      test('displays plural cleanly merged file count', async () => {
+        filesStub.restore();
+        stubRestApi('getChangeOrEditFiles')
+            .onFirstCall()
+            .resolves({'conflictingFile.js': {}})
+            .onSecondCall()
+            .resolves({
+              'conflictingFile.js': {},
+              'cleanlyMergedFile.js': {},
+              'anotherCleanlyMergedFile.js': {},
+            });
+        await element.reload();
+        await flush();
+
+        const message = queryAndAssert(
+            element,
+            '.cleanlyMergedText'
+        ).textContent.trim();
+        assert.equal(message, '2 files merged cleanly in Parent 1');
+      });
+
+      test('displays button for navigating to parent 1 base', async () => {
+        await element.reload();
+        await flush();
+
+        queryAndAssert(element, '.showParentButton');
+      });
+
+      test('computes old paths for cleanly merged files', async () => {
+        filesStub.restore();
+        stubRestApi('getChangeOrEditFiles')
+            .onFirstCall()
+            .resolves({'conflictingFile.js': {}})
+            .onSecondCall()
+            .resolves({
+              'conflictingFile.js': {},
+              'cleanlyMergedFile.js': {old_path: 'cleanlyMergedFileOldName.js'},
+            });
+        await element.reload();
+        await flush();
+
+        assert.deepEqual(element._cleanlyMergedOldPaths, [
+          'cleanlyMergedFileOldName.js',
+        ]);
+      });
+
+      test('not shown for non-Auto Merge base parents', async () => {
+        element.patchRange = {basePatchNum: 1, patchNum: 2};
+        await element.reload();
+        await flush();
+
+        assert.notOk(query(element, '.cleanlyMergedText'));
+        assert.notOk(query(element, '.showParentButton'));
+      });
+
+      test('not shown in edit mode', async () => {
+        element.patchRange = {basePatchNum: 1, patchNum: EditPatchSetNum};
+        await element.reload();
+        await flush();
+
+        assert.notOk(query(element, '.cleanlyMergedText'));
+        assert.notOk(query(element, '.showParentButton'));
+      });
+    });
   });
 
   suite('diff url file list', () => {
@@ -1378,14 +1501,12 @@
         ignore_whitespace: 'IGNORE_NONE',
       };
       diff.diff = getMockDiffResponse();
-      commentApiWrapper.loadComments().then(() => {
-        sinon.stub(element.changeComments, 'getCommentsForPath')
-            .withArgs('/COMMIT_MSG', {
-              basePatchNum: 'PARENT',
-              patchNum: 2,
-            })
-            .returns(diff.comments);
-      });
+      sinon.stub(diff.changeComments, 'getCommentsForPath')
+          .withArgs('/COMMIT_MSG', {
+            basePatchNum: 'PARENT',
+            patchNum: 2,
+          })
+          .returns(diff.comments);
       await listenOnce(diff, 'render');
     }
 
@@ -1398,7 +1519,7 @@
       }
 
       element._updateDiffCursor();
-      element.$.diffCursor.handleDiffUpdate();
+      element.diffCursor.handleDiffUpdate();
       return diffs;
     }
 
@@ -1417,17 +1538,10 @@
       // comment API.
       commentApiWrapper = basicFixture.instantiate();
       element = commentApiWrapper.$.fileList;
-      loadCommentSpy = sinon.spy(commentApiWrapper.$.commentAPI, 'loadAll');
       element.diffPrefs = {};
       element.change = {_number: 42, project: 'testRepo'};
       sinon.stub(element, '_reviewFile');
 
-      // Stub methods on the changeComments object after changeComments has
-      // been initialized.
-      commentApiWrapper.loadComments().then(() => {
-        sinon.stub(element.changeComments, 'getPaths').returns({});
-        done();
-      });
       element._loading = false;
       element.numFilesShown = 75;
       element.selectedIndex = 0;
@@ -1455,6 +1569,7 @@
       };
       sinon.stub(window, 'fetch').callsFake(() => Promise.resolve());
       flush();
+      done();
     });
 
     test('cursor with individually opened files', async () => {
@@ -1484,10 +1599,11 @@
 
       // The file cursor is now at 1.
       assert.equal(element.fileCursor.index, 1);
+
       MockInteractions.keyUpOn(element, 73, null, 'i');
       flush();
-
       diffs = await renderAndGetNewDiffs(1);
+
       // Two diffs should be rendered.
       assert.equal(diffs.length, 2);
       const diffStopsFirst = diffs[0].getCursorStops();
@@ -1537,9 +1653,9 @@
       setup(() => {
         sinon.stub(element, '_renderInOrder').returns(Promise.resolve());
         nKeySpy = sinon.spy(element, '_handleNextChunk');
-        nextCommentStub = sinon.stub(element.$.diffCursor,
+        nextCommentStub = sinon.stub(element.diffCursor,
             'moveToNextCommentThread');
-        nextChunkStub = sinon.stub(element.$.diffCursor,
+        nextChunkStub = sinon.stub(element.diffCursor,
             'moveToNextChunk');
         fileRows =
             element.root.querySelectorAll('.row:not(.header-row)');
@@ -1678,110 +1794,6 @@
               .map(row => row.querySelector('gr-edit-file-controls'));
       assert.isTrue(editControls[0].classList.contains('invisible'));
     });
-
-    test('reloadCommentsForThreadWithRootId', async () => {
-      // Expand the commit message diff
-      MockInteractions.keyUpOn(element, 73, 'shift', 'i');
-      const diffs = await renderAndGetNewDiffs(0);
-      flush();
-
-      // Two comment threads should be generated by renderAndGetNewDiffs
-      const threadEls = diffs[0].getThreadEls();
-      assert.equal(threadEls.length, 2);
-      const threadElsByRootId = new Map(
-          threadEls.map(threadEl => [threadEl.rootId, threadEl]));
-
-      const thread1 = threadElsByRootId.get('503008e2_0ab203ee');
-      assert.equal(thread1.comments.length, 1);
-      assert.equal(thread1.comments[0].message, 'a comment');
-      assert.equal(thread1.comments[0].line, 10);
-
-      const thread2 = threadElsByRootId.get('ecf0b9fa_fe1a5f62');
-      assert.equal(thread2.comments.length, 2);
-      assert.isTrue(thread2.comments[0].unresolved);
-      assert.equal(thread2.comments[0].message, 'another comment');
-      assert.equal(thread2.comments[0].line, 20);
-
-      const commentStub =
-          sinon.stub(element.changeComments, 'getCommentsForThread');
-      const commentStubRes1 = [
-        {
-          patch_set: 2,
-          path: '/p',
-          id: '503008e2_0ab203ee',
-          line: 20,
-          updated: '2018-02-08 18:49:18.000000000',
-          message: 'edited text',
-          unresolved: false,
-        },
-      ];
-      const commentStubRes2 = [
-        {
-          patch_set: 2,
-          path: '/p',
-          id: 'ecf0b9fa_fe1a5f62',
-          line: 20,
-          updated: '2018-02-08 18:49:18.000000000',
-          message: 'another comment',
-          unresolved: true,
-        },
-        {
-          patch_set: 2,
-          path: '/p',
-          id: '503008e2_0ab203ee',
-          line: 10,
-          in_reply_to: 'ecf0b9fa_fe1a5f62',
-          updated: '2018-02-14 22:07:43.000000000',
-          message: 'response',
-          unresolved: true,
-        },
-        {
-          patch_set: 2,
-          path: '/p',
-          id: '503008e2_0ab203ef',
-          line: 20,
-          in_reply_to: '503008e2_0ab203ee',
-          updated: '2018-02-15 22:07:43.000000000',
-          message: 'a third comment in the thread',
-          unresolved: true,
-        },
-      ];
-      commentStub.withArgs('503008e2_0ab203ee').returns(
-          commentStubRes1);
-      commentStub.withArgs('ecf0b9fa_fe1a5f62').returns(
-          commentStubRes2);
-
-      // Reload comments from the first comment thread, which should have a
-      // an updated message and a toggled resolve state.
-      element.reloadCommentsForThreadWithRootId('503008e2_0ab203ee',
-          '/COMMIT_MSG');
-      assert.equal(thread1.comments.length, 1);
-      assert.isFalse(thread1.comments[0].unresolved);
-      assert.equal(thread1.comments[0].message, 'edited text');
-
-      // Reload comments from the second comment thread, which should have a new
-      // reply.
-      element.reloadCommentsForThreadWithRootId('ecf0b9fa_fe1a5f62',
-          '/COMMIT_MSG');
-      assert.equal(thread2.comments.length, 3);
-
-      const commentStubCount = commentStub.callCount;
-      const getThreadsSpy = sinon.spy(diffs[0], 'getThreadEls');
-
-      // Should not be getting threads when the file is not expanded.
-      element.reloadCommentsForThreadWithRootId('ecf0b9fa_fe1a5f62',
-          'other/file');
-      assert.isFalse(getThreadsSpy.called);
-      assert.equal(commentStubCount, commentStub.callCount);
-
-      // Should be query selecting diffs when the file is expanded.
-      // Should not be fetching change comments when the rootId is not found
-      // to match.
-      element.reloadCommentsForThreadWithRootId('acf0b9fa_fe1a5f62',
-          '/COMMIT_MSG');
-      assert.isTrue(getThreadsSpy.called);
-      assert.equal(commentStubCount, commentStub.callCount);
-    });
   });
 });
 
diff --git a/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores_test.js b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores_test.ts
similarity index 60%
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 ef123c9..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(done => {
-    stubRestApi('getLoggedIn').returns(Promise.resolve(false));
+  setup(async () => {
+    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'],
     };
-    flush(done);
+    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-message/gr-message.ts b/polygerrit-ui/app/elements/change/gr-message/gr-message.ts
index 1711499..b097340 100644
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message.ts
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message.ts
@@ -121,6 +121,9 @@
   @property({type: Object})
   message: ChangeMessage | undefined;
 
+  @property({type: Array})
+  commentThreads: CommentThread[] = [];
+
   @computed('message')
   get author() {
     return this.message?.author || this.message?.updated_by;
@@ -195,13 +198,13 @@
       '_computeMessageContentCollapsed(message.message,' +
       ' message.accounts_in_message,' +
       ' message.tag,' +
-      ' message.commentThreads)',
+      ' commentThreads)',
   })
   _messageContentCollapsed = '';
 
   @property({
     type: String,
-    computed: '_computeCommentCountText(message.commentThreads.length)',
+    computed: '_computeCommentCountText(commentThreads)',
   })
   _commentCountText = '';
 
@@ -234,12 +237,12 @@
     }
   }
 
-  _computeCommentCountText(threadsLength?: number) {
-    if (!threadsLength) {
+  _computeCommentCountText(commentThreads?: CommentThread[]) {
+    if (!commentThreads?.length) {
       return undefined;
     }
 
-    return pluralize(threadsLength, 'comment');
+    return pluralize(commentThreads.length, 'comment');
   }
 
   _computeMessageContentExpanded(
diff --git a/polygerrit-ui/app/elements/change/gr-message/gr-message_html.ts b/polygerrit-ui/app/elements/change/gr-message/gr-message_html.ts
index 4a41316..9e24a09 100644
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message_html.ts
@@ -20,7 +20,7 @@
   <style include="gr-voting-styles">
     /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
   </style>
-  <style include="shared-styles">
+  <style>
     :host {
       display: block;
       position: relative;
@@ -148,6 +148,7 @@
       vertical-align: top;
     }
     .score {
+      box-sizing: border-box;
       border-radius: var(--border-radius);
       color: var(--vote-text-color);
       display: inline-block;
@@ -194,10 +195,12 @@
       padding-bottom: 1px;
       color: var(--vote-text-color);
     }
-    gr-account-label {
-      --gr-account-label-text-style: {
-        font-weight: var(--font-weight-bold);
-      }
+    gr-account-label::part(gr-account-label-text) {
+      font-weight: var(--font-weight-bold);
+    }
+    iron-icon {
+      --iron-icon-height: 20px;
+      --iron-icon-width: 20px;
     }
     @media screen and (max-width: 50em) {
       .expanded .content {
@@ -246,7 +249,7 @@
         <div class="content messageContent">
           <div class="message hideOnOpen">[[_messageContentCollapsed]]</div>
           <gr-formatted-text
-            no-trailing-margin=""
+            noTrailingMargin
             class="message hideOnCollapsed"
             content="[[_messageContentExpanded]]"
             config="[[_projectConfig.commentlinks]]"
@@ -285,7 +288,7 @@
               threads="[[message.commentThreads]]"
               change-num="[[changeNum]]"
               logged-in="[[_loggedIn]]"
-              hide-toggle-buttons
+              hide-dropdown
               show-comment-context
             >
             </gr-thread-list>
diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.ts b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.ts
index 6be0c07..fae624e 100644
--- a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.ts
@@ -90,9 +90,9 @@
  */
 function computeThreads(
   message: CombinedMessage,
-  changeComments: ChangeComments
+  changeComments?: ChangeComments
 ): CommentThread[] {
-  if (message._index === undefined) {
+  if (message._index === undefined || changeComments === undefined) {
     return [];
   }
   const messageId = getMessageId(message);
@@ -270,7 +270,9 @@
       | undefined;
 
     if (!el && this._showAllActivity) {
-      console.warn(`Failed to scroll to message: ${messageID}`);
+      this.reporting.error(
+        new Error(`Failed to scroll to message: ${messageID}`)
+      );
       return;
     }
     if (!el) {
@@ -367,6 +369,10 @@
     return combinedMessages;
   }
 
+  getCommentThreads(message: CombinedMessage, changeComments?: ChangeComments) {
+    return computeThreads(message, changeComments);
+  }
+
   _updateExpandedStateOfAllMessages(exp: boolean) {
     if (this._combinedMessages) {
       for (let i = 0; i < this._combinedMessages.length; i++) {
diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_html.ts b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_html.ts
index 8fa8eab..93df77e 100644
--- a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_html.ts
@@ -93,6 +93,7 @@
       change="[[change]]"
       change-num="[[changeNum]]"
       message="[[message]]"
+      comment-threads="[[getCommentThreads(message, changeComments)]]"
       project-name="[[projectName]]"
       show-reply-button="[[showReplyButtons]]"
       on-message-anchor-tap="_handleAnchorClick"
diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.js b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.js
index fd45eec..0939daa 100644
--- a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.js
+++ b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.js
@@ -23,6 +23,7 @@
 import {MessageTag} from '../../../constants/constants.js';
 import {html} from '@polymer/polymer/lib/utils/html-tag.js';
 import {stubRestApi} from '../../../test/test-utils.js';
+import {ChangeComments} from '../../diff/gr-comment-api/gr-comment-api.js';
 
 createCommentApiMockWithTemplateElement(
     'gr-messages-list-comment-mock-api', html`
@@ -143,11 +144,9 @@
       // comment API.
       commentApiWrapper = basicFixture.instantiate();
       element = commentApiWrapper.$.messagesList;
+      element.changeComments = new ChangeComments(comments);
       element.messages = messages;
-
-      // Stub methods on the changeComments object after changeComments has
-      // been initialized.
-      return commentApiWrapper.loadComments();
+      flush();
     });
 
     test('expand/collapse all', () => {
@@ -454,12 +453,9 @@
       // comment API.
       commentApiWrapper = basicFixture.instantiate();
       element = commentApiWrapper.$.messagesList;
-      sinon.spy(commentApiWrapper.$.commentAPI, 'loadAll');
+      element.changeComments = new ChangeComments();
       element.messages = messages;
-
-      // Stub methods on the changeComments object after changeComments has
-      // been initialized.
-      return commentApiWrapper.loadComments();
+      flush();
     });
 
     test('hide autogenerated button is not hidden', () => {
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.ts b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.ts
index 9c9b21a..360d7de 100644
--- a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.ts
@@ -40,6 +40,7 @@
   getRevisionKey,
   isChangeInfo,
 } from '../../../utils/change-util';
+import {Interaction} from '../../../constants/reporting';
 
 /** What is the maximum number of shown changes in collapsed list? */
 const DEFALT_NUM_CHANGES_WHEN_COLLAPSED = 3;
@@ -746,7 +747,7 @@
   private toggle(e: MouseEvent) {
     e.stopPropagation();
     this.showAll = !this.showAll;
-    this.reporting.reportInteraction('toggle show all button', {
+    this.reporting.reportInteraction(Interaction.TOGGLE_SHOW_ALL_BUTTON, {
       sectionName: this.title,
       toState: this.showAll ? 'Show all' : 'Show less',
     });
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-it_test.js b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog-it_test.js
index 2767940..933cb82 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog-it_test.js
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog-it_test.js
@@ -16,7 +16,7 @@
  */
 
 import '../../../test/common-test-setup-karma.js';
-import {resetPlugins, stubRestApi} from '../../../test/test-utils.js';
+import {queryAndAssert, resetPlugins, stubRestApi} from '../../../test/test-utils.js';
 import './gr-reply-dialog.js';
 import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
 import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit.js';
@@ -119,12 +119,12 @@
     getPluginLoader().loadPlugins([]);
     await getPluginLoader().awaitPluginsLoaded();
     await flush();
-    const textarea = element.$.textarea.getNativeTextarea();
+    const textarea = queryAndAssert(element, 'gr-textarea').getNativeTextarea();
     textarea.value = 'LGTM';
     textarea.dispatchEvent(new CustomEvent(
         'input', {bubbles: true, composed: true}));
     await flush();
-    const labelScoreRows = element.$.labelScores.shadowRoot
+    const labelScoreRows = element.getLabelScores().shadowRoot
         .querySelector('gr-label-score-row[name="Code-Review"]');
     const selectedBtn = labelScoreRows.shadowRoot
         .querySelector('gr-button[data-value="+1"].iron-selected');
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 5504877..0cfba86 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
@@ -75,6 +75,7 @@
   ParsedJSON,
   PatchSetNum,
   ProjectInfo,
+  ReviewerInput,
   Reviewers,
   ReviewInput,
   ReviewResult,
@@ -92,6 +93,7 @@
   areSetsEqual,
   assertIsDefined,
   containsAll,
+  queryAndAssert,
 } from '../../../utils/common-util';
 import {CommentThread, isUnresolved} from '../../../utils/comment-util';
 import {GrTextarea} from '../../shared/gr-textarea/gr-textarea';
@@ -113,7 +115,8 @@
 import {ErrorCallback} from '../../../api/rest';
 import {debounce, DelayedTask} from '../../../utils/async-util';
 import {StorageLocation} from '../../../services/storage/gr-storage';
-import {Timing} from '../../../constants/reporting';
+import {Interaction, Timing} from '../../../constants/reporting';
+import {KnownExperimentId} from '../../../services/flags/flags';
 
 const STORAGE_DEBOUNCE_INTERVAL_MS = 400;
 
@@ -213,11 +216,9 @@
 
   FocusTarget = FocusTarget;
 
-  reporting = appContext.reportingService;
+  private readonly reporting = appContext.reportingService;
 
-  flagsService = appContext.flagsService;
-
-  changeService = appContext.changeService;
+  private readonly changeService = appContext.changeService;
 
   @property({type: Object})
   change?: ChangeInfo;
@@ -354,6 +355,9 @@
   @property({type: Boolean})
   _isResolvedPatchsetLevelComment = true;
 
+  @property({type: Boolean})
+  showNewReplyDialog = false;
+
   @property({type: Array, computed: '_computeAllReviewers(_reviewers.*)'})
   _allReviewers: (AccountInfo | GroupInfo)[] = [];
 
@@ -406,6 +410,9 @@
     this.addEventListener('remove-reviewer', e => {
       this.$.reviewers.removeAccount((e as CustomEvent).detail.reviewer);
     });
+    this.showNewReplyDialog = appContext.flagsService.isEnabled(
+      KnownExperimentId.NEW_REPLY_DIALOG
+    );
   }
 
   /** @override */
@@ -471,7 +478,7 @@
   }
 
   setLabelValue(label: string, value: string) {
-    const selectorEl = this.$.labelScores.shadowRoot?.querySelector(
+    const selectorEl = this.getLabelScores().shadowRoot?.querySelector(
       `gr-label-score-row[name="${label}"]`
     );
     if (!selectorEl) {
@@ -481,7 +488,7 @@
   }
 
   getLabelValue(label: string) {
-    const selectorEl = this.$.labelScores.shadowRoot?.querySelector(
+    const selectorEl = this.getLabelScores().shadowRoot?.querySelector(
       `gr-label-score-row[name="${label}"]`
     );
     if (!selectorEl) {
@@ -542,9 +549,62 @@
     }
   }
 
+  getContainerClass(showNewReplyDialog: boolean) {
+    return showNewReplyDialog ? 'newReplyDialog' : '';
+  }
+
+  getUnresolvedPatchsetLevelClass(isResolvedPatchsetLevelComment: boolean) {
+    return isResolvedPatchsetLevelComment ? 'resolved' : 'unresolved';
+  }
+
+  computeReviewers(change: ChangeInfo) {
+    const reviewers: ReviewerInput[] = [];
+    const addToReviewInput = (
+      additions: AccountAddition[],
+      state?: ReviewerState
+    ) => {
+      additions.forEach(addition => {
+        const reviewer = mapReviewer(addition);
+        if (state) reviewer.state = state;
+        reviewers.push(reviewer);
+      });
+    };
+    addToReviewInput(this.$.reviewers.additions(), ReviewerState.REVIEWER);
+    addToReviewInput(this.$.ccs.additions(), ReviewerState.CC);
+    addToReviewInput(
+      this.$.reviewers.removals().filter(
+        r =>
+          isReviewerOrCC(change, r) &&
+          // ignore removal from reviewer request if being added to CC
+          !this.$.ccs
+            .additions()
+            .some(
+              account =>
+                mapReviewer(account).reviewer === mapReviewer(r).reviewer
+            )
+      ),
+      ReviewerState.REMOVED
+    );
+    addToReviewInput(
+      this.$.ccs.removals().filter(
+        r =>
+          isReviewerOrCC(change, r) &&
+          // ignore removal from CC request if being added as reviewer
+          !this.$.reviewers
+            .additions()
+            .some(
+              account =>
+                mapReviewer(account).reviewer === mapReviewer(r).reviewer
+            )
+      ),
+      ReviewerState.REMOVED
+    );
+    return reviewers;
+  }
+
   send(includeComments: boolean, startReview: boolean) {
     this.reporting.time(Timing.SEND_REPLY);
-    const labels = this.$.labelScores.getLabelValues();
+    const labels = this.getLabelScores().getLabelValues();
 
     const reviewInput: ReviewInput = {
       drafts: includeComments
@@ -589,30 +649,8 @@
       };
     }
 
-    const addToReviewInput = (
-      additions: AccountAddition[],
-      state?: ReviewerState
-    ) => {
-      additions.forEach(addition => {
-        const reviewer = mapReviewer(addition);
-        if (state) reviewer.state = state;
-        reviewInput.reviewers?.push(reviewer);
-      });
-    };
-    reviewInput.reviewers = [];
     assertIsDefined(this.change, 'change');
-    const change = this.change;
-    addToReviewInput(this.$.reviewers.additions(), ReviewerState.REVIEWER);
-    addToReviewInput(this.$.ccs.additions(), ReviewerState.CC);
-    addToReviewInput(
-      this.$.reviewers.removals().filter(r => isReviewerOrCC(change, r)),
-      ReviewerState.REMOVED
-    );
-    addToReviewInput(
-      this.$.ccs.removals().filter(r => isReviewerOrCC(change, r)),
-      ReviewerState.REMOVED
-    );
-
+    reviewInput.reviewers = this.computeReviewers(this.change);
     this.disabled = true;
 
     const errFn = (r?: Response | null) => this._handle400Error(r);
@@ -655,7 +693,7 @@
       section = this._chooseFocusTarget();
     }
     if (section === FocusTarget.BODY) {
-      const textarea = this.$.textarea;
+      const textarea = queryAndAssert<GrTextarea>(this, 'gr-textarea');
       setTimeout(() => textarea.getNativeTextarea().focus());
     } else if (section === FocusTarget.REVIEWERS) {
       const reviewerEntry = this.$.reviewers.focusStart;
@@ -758,7 +796,7 @@
     if (changeReviewers) {
       for (const key of Object.keys(changeReviewers)) {
         if (key !== 'REVIEWER' && key !== 'CC') {
-          console.warn('unexpected reviewer state:', key);
+          this.reporting.error(new Error(`Unexpected reviewer state: ${key}`));
           continue;
         }
         if (!changeReviewers[key]) continue;
@@ -820,12 +858,12 @@
 
     if (this._newAttentionSet.has(id)) {
       this._newAttentionSet.delete(id);
-      this.reporting.reportInteraction('attention-set-chip', {
+      this.reporting.reportInteraction(Interaction.ATTENTION_SET_CHIP, {
         action: `REMOVE${self}${role}`,
       });
     } else {
       this._newAttentionSet.add(id);
-      this.reporting.reportInteraction('attention-set-chip', {
+      this.reporting.reportInteraction(Interaction.ATTENTION_SET_CHIP, {
         action: `ADD${self}${role}`,
       });
     }
@@ -1085,9 +1123,8 @@
       } else if (isReviewerGroupSuggestion(suggestion)) {
         entry = suggestion.group;
       } else {
-        console.warn(
-          'received suggestion that was neither account nor group:',
-          suggestion
+        this.reporting.error(
+          new Error(`Suggestion is neither account nor group: ${suggestion}`)
         );
         return false;
       }
@@ -1120,7 +1157,7 @@
         bubbles: false,
       })
     );
-    this.$.textarea.closeDropdown();
+    queryAndAssert<GrTextarea>(this, 'gr-textarea').closeDropdown();
     this.$.reviewers.clearPendingRemovals();
     this._rebuildReviewerArrays(this.change.reviewers, this._owner);
   }
@@ -1250,9 +1287,13 @@
     fireEvent(this, 'autogrow');
   }
 
+  getLabelScores() {
+    return this.$.labelScores || queryAndAssert(this, 'gr-label-scores');
+  }
+
   _handleLabelsChanged() {
     this._labelsChanged =
-      Object.keys(this.$.labelScores.getLabelValues(false)).length !== 0;
+      Object.keys(this.getLabelScores().getLabelValues(false)).length !== 0;
   }
 
   _isState(knownLatestState?: LatestPatchState, value?: LatestPatchState) {
@@ -1326,7 +1367,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 17e5e26..8201dbc 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_html.ts
@@ -53,6 +53,9 @@
       /* @see Issue 8602 */
       z-index: 1;
     }
+    .stickyBottom.newReplyDialog {
+      margin-top: unset;
+    }
     .actions {
       display: flex;
       justify-content: space-between;
@@ -99,12 +102,28 @@
       min-height: 12em;
       position: relative;
     }
-    .textareaContainer,
+    .newReplyDialog.textareaContainer {
+      min-height: unset;
+    }
+    textareaContainer,
     #textarea,
     gr-endpoint-decorator[name='reply-text'] {
       display: flex;
       width: 100%;
     }
+    .newReplyDialog .textareaContainer,
+    #textarea,
+    gr-endpoint-decorator[name='reply-text'] {
+      display: block;
+      width: unset;
+      font-family: var(--monospace-font-family);
+      font-size: var(--font-size-code);
+      line-height: calc(var(--font-size-code) + var(--spacing-s));
+      font-weight: var(--font-weight-normal);
+    }
+    .newReplyDialog#textarea {
+      padding: var(--spacing-m);
+    }
     gr-endpoint-decorator[name='reply-text'] {
       flex-direction: column;
     }
@@ -222,8 +241,25 @@
     .attentionTip div iron-icon {
       margin-right: var(--spacing-s);
     }
+    .patchsetLevelContainer {
+      width: 80ch;
+      border-radius: var(--border-radius);
+      margin-left: var(--spacing-xl);
+      box-shadow: var(--elevation-level-2);
+    }
+    .patchsetLevelContainer.resolved{
+      background-color: var(--comment-background-color);
+    }
+    .patchsetLevelContainer.unresolved{
+      background-color: var(--unresolved-comment-background-color);
+    }
+    .labelContainer {
+      padding-left: var(--spacing-m);
+      padding-bottom: var(--spacing-l);
+    }
+
   </style>
-  <div class="container" tabindex="-1">
+  <div class$="container" tabindex="-1">
     <section class="peopleContainer">
       <gr-endpoint-decorator name="reply-reviewers">
         <gr-endpoint-param name="change" value="[[change]]"></gr-endpoint-param>
@@ -281,55 +317,115 @@
         </div>
       </gr-overlay>
     </section>
-    <section class="textareaContainer">
-      <gr-endpoint-decorator name="reply-text">
-        <gr-textarea
-          id="textarea"
-          class="message"
-          autocomplete="on"
-          placeholder="[[_messagePlaceholder]]"
-          fixed-position-dropdown=""
-          hide-border="true"
-          monospace="true"
-          disabled="{{disabled}}"
-          rows="4"
-          text="{{draft}}"
-          on-bind-value-changed="_handleHeightChanged"
-        >
-        </gr-textarea>
-      </gr-endpoint-decorator>
-    </section>
-    <section class="previewContainer">
-      <label>
-        <input
-          id="resolvedPatchsetLevelCommentCheckbox"
-          type="checkbox"
-          checked="{{_isResolvedPatchsetLevelComment::change}}"
-        />
-        Resolved
-      </label>
-      <label class="preview-formatting">
-        <input type="checkbox" checked="{{_previewFormatting::change}}" />
-        Preview formatting
-      </label>
-      <gr-formatted-text
-        content="[[draft]]"
-        hidden$="[[!_previewFormatting]]"
-        config="[[projectConfig.commentlinks]]"
-      ></gr-formatted-text>
-    </section>
-    <section class="labelsContainer">
-      <gr-endpoint-decorator name="reply-label-scores">
-        <gr-label-scores
-          id="labelScores"
-          account="[[_account]]"
-          change="[[change]]"
-          on-labels-changed="_handleLabelsChanged"
-          permitted-labels="[[permittedLabels]]"
-        ></gr-label-scores>
-      </gr-endpoint-decorator>
-      <div id="pluginMessage">[[_pluginMessage]]</div>
-    </section>
+
+    <template is="dom-if" if="[[showNewReplyDialog]]">
+      <section class="labelsContainer">
+        <gr-endpoint-decorator name="reply-label-scores">
+          <gr-label-scores
+            id="labelScores"
+            account="[[_account]]"
+            change="[[change]]"
+            on-labels-changed="_handleLabelsChanged"
+            permitted-labels="[[permittedLabels]]"
+          ></gr-label-scores>
+        </gr-endpoint-decorator>
+        <div id="pluginMessage">[[_pluginMessage]]</div>
+      </section>
+      <section class="newReplyDialog textareaContainer">
+        <div class$="patchsetLevelContainer [[getUnresolvedPatchsetLevelClass(_isResolvedPatchsetLevelComment)]]">
+          <gr-endpoint-decorator name="reply-text">
+            <gr-textarea
+              id="textarea"
+              class="message newReplyDialog"
+              autocomplete="on"
+              placeholder="[[_messagePlaceholder]]"
+              fixed-position-dropdown=""
+              monospace="true"
+              disabled="{{disabled}}"
+              rows="4"
+              text="{{draft}}"
+              on-bind-value-changed="_handleHeightChanged"
+            >
+            </gr-textarea>
+          </gr-endpoint-decorator>
+          <div class="labelContainer">
+            <label>
+              <input
+                id="resolvedPatchsetLevelCommentCheckbox"
+                type="checkbox"
+                checked="{{_isResolvedPatchsetLevelComment::change}}"
+              />
+              Resolved
+            </label>
+            <label class="preview-formatting">
+              <input type="checkbox" checked="{{_previewFormatting::change}}" />
+              Preview formatting
+            </label>
+          </div>
+        </div>
+      </section>
+      <template is="dom-if" if="[[_previewFormatting]]">
+        <section class="previewContainer">
+          <gr-formatted-text
+            content="[[draft]]"
+            config="[[projectConfig.commentlinks]]"
+          ></gr-formatted-text>
+      </template>
+      </section>
+    </template>
+
+    <template is="dom-if" if="[[!showNewReplyDialog]]">
+      <section class="textareaContainer">
+        <gr-endpoint-decorator name="reply-text">
+          <gr-textarea
+            id="textarea"
+            class="message"
+            autocomplete="on"
+            placeholder="[[_messagePlaceholder]]"
+            fixed-position-dropdown=""
+            hide-border="true"
+            monospace="true"
+            disabled="{{disabled}}"
+            rows="4"
+            text="{{draft}}"
+            on-bind-value-changed="_handleHeightChanged"
+          >
+          </gr-textarea>
+        </gr-endpoint-decorator>
+      </section>
+      <section class="previewContainer">
+        <label>
+          <input
+            id="resolvedPatchsetLevelCommentCheckbox"
+            type="checkbox"
+            checked="{{_isResolvedPatchsetLevelComment::change}}"
+          />
+          Resolved
+        </label>
+        <label class="preview-formatting">
+          <input type="checkbox" checked="{{_previewFormatting::change}}" />
+          Preview formatting
+        </label>
+        <gr-formatted-text
+          content="[[draft]]"
+          hidden$="[[!_previewFormatting]]"
+          config="[[projectConfig.commentlinks]]"
+        ></gr-formatted-text>
+      </section>
+      <section class="labelsContainer">
+        <gr-endpoint-decorator name="reply-label-scores">
+          <gr-label-scores
+            id="labelScores"
+            account="[[_account]]"
+            change="[[change]]"
+            on-labels-changed="_handleLabelsChanged"
+            permitted-labels="[[permittedLabels]]"
+          ></gr-label-scores>
+        </gr-endpoint-decorator>
+        <div id="pluginMessage">[[_pluginMessage]]</div>
+      </section>
+    </template>
+
     <section
       class="draftsContainer"
       hidden$="[[_computeHideDraftList(draftCommentThreads)]]"
@@ -351,7 +447,7 @@
         change="[[change]]"
         change-num="[[change._number]]"
         logged-in="true"
-        hide-toggle-buttons=""
+        hide-dropdown=""
       >
       </gr-thread-list>
       <span
@@ -361,7 +457,7 @@
         Saving comments...
       </span>
     </section>
-    <div class="stickyBottom">
+    <div class$="stickyBottom [[getContainerClass(showNewReplyDialog)]]">
       <section
         hidden$="[[!_showAttentionSummary(_attentionExpanded)]]"
         class="attention"
@@ -391,8 +487,8 @@
                   account="[[account]]"
                   force-attention="[[_computeHasNewAttention(account, _newAttentionSet)]]"
                   selected="[[_computeHasNewAttention(account, _newAttentionSet)]]"
-                  deselected="[[!_computeHasNewAttention(account, _newAttentionSet)]]"
-                  hide-hovercard=""
+                  hideHovercard
+                  selectionChipStyle
                   on-click="_handleAttentionClick"
                 ></gr-account-label>
               </template>
@@ -462,8 +558,8 @@
               account="[[_owner]]"
               force-attention="[[_computeHasNewAttention(_owner, _newAttentionSet)]]"
               selected="[[_computeHasNewAttention(_owner, _newAttentionSet)]]"
-              deselected="[[!_computeHasNewAttention(_owner, _newAttentionSet)]]"
-              hide-hovercard=""
+              hideHovercard
+              selectionChipStyle
               on-click="_handleAttentionClick"
             >
             </gr-account-label>
@@ -477,8 +573,8 @@
                 account="[[_uploader]]"
                 force-attention="[[_computeHasNewAttention(_uploader, _newAttentionSet)]]"
                 selected="[[_computeHasNewAttention(_uploader, _newAttentionSet)]]"
-                deselected="[[!_computeHasNewAttention(_uploader, _newAttentionSet)]]"
-                hide-hovercard=""
+                hideHovercard
+                selectionChipStyle
                 on-click="_handleAttentionClick"
               >
               </gr-account-label>
@@ -497,8 +593,8 @@
                 account="[[account]]"
                 force-attention="[[_computeHasNewAttention(account, _newAttentionSet)]]"
                 selected="[[_computeHasNewAttention(account, _newAttentionSet)]]"
-                deselected="[[!_computeHasNewAttention(account, _newAttentionSet)]]"
-                hide-hovercard=""
+                hideHovercard
+                selectionChipStyle
                 on-click="_handleAttentionClick"
               >
               </gr-account-label>
@@ -518,8 +614,8 @@
                   account="[[account]]"
                   force-attention="[[_computeHasNewAttention(account, _newAttentionSet)]]"
                   selected="[[_computeHasNewAttention(account, _newAttentionSet)]]"
-                  deselected="[[!_computeHasNewAttention(account, _newAttentionSet)]]"
-                  hide-hovercard=""
+                  hideHovercard
+                  selectionChipStyle
                   on-click="_handleAttentionClick"
                 >
                 </gr-account-label>
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.ts b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.ts
index 2554b73..f46e89a 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.ts
@@ -921,7 +921,7 @@
     element.draft = 'I wholeheartedly disapprove';
     const saveReviewPromise = interceptSaveReview();
 
-    sinon.stub(element.$.labelScores, 'getLabelValues').callsFake(() => {
+    sinon.stub(element.getLabelScores(), 'getLabelValues').callsFake(() => {
       return {
         'Code-Review': -1,
         Verified: -1,
@@ -1719,6 +1719,50 @@
     });
   });
 
+  test('Ignore removal requests if being added as reviewer/CC', async () => {
+    flush();
+    const reviewers = queryAndAssert(element, '#reviewers') as GrAccountList;
+    const ccs = queryAndAssert(element, '#ccs') as GrAccountList;
+    const reviewer1 = makeAccount();
+    element._reviewers = [reviewer1];
+    element._ccs = [];
+
+    element.change!.reviewers = {
+      [ReviewerState.CC]: [],
+      [ReviewerState.REVIEWER]: [{_account_id: reviewer1._account_id}],
+    };
+
+    const mutations: ReviewerInput[] = [];
+
+    stubSaveReview((review: ReviewInput) => {
+      mutations.push(...review!.reviewers!);
+    });
+
+    // Remove and add to other field.
+    reviewers.dispatchEvent(
+      new CustomEvent('remove', {
+        detail: {account: reviewer1},
+        composed: true,
+        bubbles: true,
+      })
+    );
+    ccs.$.entry.dispatchEvent(
+      new CustomEvent('add', {
+        detail: {value: {account: reviewer1}},
+        composed: true,
+        bubbles: true,
+      })
+    );
+
+    await element.send(false, false);
+    expect(mutations).to.have.lengthOf(1);
+    // Only 1 account was initially part of the change
+    expect(mutations[0]).to.deep.equal({
+      reviewer: reviewer1._account_id,
+      state: ReviewerState.CC,
+    });
+  });
+
   test('emits cancel on esc key', () => {
     const cancelHandler = sinon.spy();
     element.addEventListener('cancel', cancelHandler);
diff --git a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_html.ts b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_html.ts
index 25d8517..ca8bf87 100644
--- a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_html.ts
@@ -66,7 +66,7 @@
           account="[[reviewer]]"
           change="[[change]]"
           on-remove="_handleRemove"
-          highlight-attention
+          highlightAttention
           voteable-text="[[_computeVoteableText(reviewer, change)]]"
           removable="[[_computeCanRemoveReviewer(reviewer, mutable)]]"
         >
diff --git a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.ts b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.ts
index 4b57651..d3f23ad 100644
--- a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.ts
@@ -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,15 +57,17 @@
     );
   });
 
-  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', () => {
+  test('only show remove for removable reviewers', async () => {
     element.mutable = true;
     element.change = {
       ...createChange(),
@@ -106,7 +112,7 @@
         },
       ],
     };
-    flush();
+    await flush();
     const chips = element.root!.querySelectorAll('gr-account-chip');
     assert.equal(chips.length, 4);
 
diff --git a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.ts b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.ts
index 2f4adf5..5e6b076 100644
--- a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.ts
@@ -14,21 +14,27 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-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 {
+  AccountDetailInfo,
+  AccountInfo,
+  ChangeInfo,
+  UrlEncodedCommentId,
+} from '../../../types/common';
 import {
   CommentThread,
   isDraft,
@@ -36,11 +42,15 @@
   isDraftThread,
   isRobotThread,
   hasHumanReply,
+  getCommentAuthors,
+  computeId,
+  UIComment,
 } 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 +62,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 +96,7 @@
   @property({
     computed:
       '_computeDisplayedThreads(_sortedThreads.*, unresolvedOnly, ' +
-      '_draftsOnly, onlyShowRobotCommentsWithHumanReply)',
+      '_draftsOnly, onlyShowRobotCommentsWithHumanReply, selectedAuthors)',
     type: Array,
   })
   _displayedThreads: CommentThread[] = [];
@@ -97,11 +114,32 @@
   onlyShowRobotCommentsWithHumanReply = false;
 
   @property({type: Boolean})
-  hideToggleButtons = false;
+  hideDropdown = false;
 
   @property({type: Object, observer: '_commentTabStateChange'})
   commentTabState?: CommentTabState;
 
+  @property({type: Object})
+  sortDropdownValue: SortDropdownState = SortDropdownState.TIMESTAMP;
+
+  @property({type: Array, notify: true})
+  selectedAuthors: AccountInfo[] = [];
+
+  @property({type: Object})
+  account?: AccountDetailInfo;
+
+  @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;
+  }
+
+  @property({type: String})
+  scrollCommentId?: UrlEncodedCommentId;
+
   _showEmptyThreadsMessage(
     threads: CommentThread[],
     displayedThreads: CommentThread[],
@@ -112,7 +150,7 @@
   }
 
   _computeEmptyThreadsMessage(threads: CommentThread[]) {
-    return !threads.length ? 'No comments.' : 'No unresolved comments';
+    return !threads.length ? 'No comments' : 'No unresolved comments';
   }
 
   _showPartyPopper(threads: CommentThread[]) {
@@ -146,7 +184,86 @@
     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[], account?: AccountDetailInfo) {
+    return getCommentAuthors(threads, account);
+  }
+
+  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);
+  }
+
+  computeShouldScrollIntoView(
+    comments: UIComment[],
+    scrollCommentId?: UrlEncodedCommentId
+  ) {
+    const comment = comments?.[0];
+    if (!comment) return false;
+    return computeId(comment) === scrollCommentId;
+  }
+
+  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 &&
+      !this.hideDropdown
+    ) {
+      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 +319,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 +380,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 +390,8 @@
     >,
     unresolvedOnly?: boolean,
     draftsOnly?: boolean,
-    onlyShowRobotCommentsWithHumanReply?: boolean
+    onlyShowRobotCommentsWithHumanReply?: boolean,
+    selectedAuthors?: AccountInfo[]
   ) {
     if (!sortedThreadsRecord || !sortedThreadsRecord.base) return [];
     return sortedThreadsRecord.base.filter(t =>
@@ -277,7 +399,8 @@
         t,
         unresolvedOnly,
         draftsOnly,
-        onlyShowRobotCommentsWithHumanReply
+        onlyShowRobotCommentsWithHumanReply,
+        selectedAuthors
       )
     );
   }
@@ -287,14 +410,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 +434,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 +457,8 @@
         thread,
         unresolvedOnly,
         draftsOnly,
-        onlyShowRobotCommentsWithHumanReply
+        onlyShowRobotCommentsWithHumanReply,
+        selectedAuthors
       )
     );
   }
@@ -339,7 +467,8 @@
     thread: CommentThread,
     unresolvedOnly?: boolean,
     draftsOnly?: boolean,
-    onlyShowRobotCommentsWithHumanReply?: boolean
+    onlyShowRobotCommentsWithHumanReply?: boolean,
+    selectedAuthors?: AccountInfo[]
   ) {
     if (
       [
@@ -347,11 +476,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 &&
@@ -426,10 +570,6 @@
     this.removeThread(e.detail.rootId);
   }
 
-  _handleCommentsChanged(e: CustomEvent) {
-    fireThreadListModifiedEvent(this, e.detail.rootId, e.detail.path);
-  }
-
   _isOnParent(side?: CommentSide) {
     // TODO(TS): That looks like a bug? CommentSide.REVISION will also be
     // classified as parent??
diff --git a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_html.ts b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_html.ts
index da91095..93a432b 100644
--- a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_html.ts
@@ -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,72 @@
       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]]">
+  <template is="dom-if" if="[[!hideDropdown]]">
     <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>
+      <template is="dom-if" if="[[threads.length]]">
+        <span class="author-text">From:</span>
+        <template is="dom-repeat" items="[[getCommentAuthors(threads, account)]]">
+          <gr-account-label
+            account="[[item]]"
+            on-click="handleAccountClicked"
+            selectionChipStyle
+            selected="[[isSelected(item, selectedAuthors)]]"
+          > </gr-account-label>
+        </template>
+      </template>
     </div>
   </template>
   <div id="threads">
@@ -131,7 +149,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,14 +160,14 @@
         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]]"
         patch-num="[[thread.patchNum]]"
         path="[[thread.path]]"
         root-id="{{thread.rootId}}"
-        on-thread-changed="_handleCommentsChanged"
+        should-scroll-into-view="[[computeShouldScrollIntoView(thread.comments, scrollCommentId)]]"
         on-thread-discard="_handleThreadDiscard"
       ></gr-comment-thread>
     </template>
diff --git a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_test.js b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_test.js
index 1fe00ff..61f737a 100644
--- a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_test.js
+++ b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_test.js
@@ -19,6 +19,12 @@
 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';
+import {createAccountDetailWithId} from '../../../test/test-data-generators.js';
 
 const basicFixture = fixtureFromElement('gr-thread-list');
 
@@ -45,14 +51,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 +67,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 +80,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 +102,7 @@
         patchNum: 3,
         path: 'test.txt',
         rootId: '09a9fb0a_1484e6cf',
-        start_datetime: '2018-02-13 22:47:19.000000000',
+        updated: '2',
         commentSide: 'PARENT',
       },
       {
@@ -104,13 +110,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 +124,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 +147,7 @@
         path: '/COMMIT_MSG',
         line: 4,
         rootId: 'scaddf38_44770ec1',
-        start_datetime: '2018-02-14 22:48:40.000000000',
+        updated: '4',
       },
       {
         comments: [
@@ -149,7 +155,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 +168,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 +185,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 +202,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 +216,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 +226,7 @@
         path: '/COMMIT_MSG',
         line: 5,
         rootId: 'rc1',
-        start_datetime: '2019-02-08 18:49:18.000000000',
+        updated: '8',
       },
       {
         comments: [
@@ -234,7 +240,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 +255,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 +264,7 @@
         path: '/COMMIT_MSG',
         line: 7,
         rootId: 'rc2',
-        start_datetime: '2019-03-08 18:49:18.000000000',
+        updated: '10',
       },
     ];
 
@@ -270,15 +276,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 +304,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 +326,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 +461,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 +479,72 @@
     });
   });
 
+  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', () => {
+    element.account = createAccountDetailWithId(1);
+    flush();
+    const chips = queryAll(element, 'gr-account-label');
+    const authors = Array.from(chips).map(
+        chip => accountOrGroupKey(chip.account))
+        .sort();
+    assert.deepEqual(authors, [1, 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', () => {
+    element.account = createAccountDetailWithId(1);
+    flush();
+    const chips = queryAll(element, 'gr-account-label');
+
+    tap(chips[0]); // accountId 1000001
+    tap(chips[2]); // 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 +568,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 +606,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,46 +630,30 @@
     });
   });
 
-  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);
   });
 
-  test('modification events are consumed and displatched', () => {
-    sinon.spy(element, '_handleCommentsChanged');
-    const dispatchSpy = sinon.stub();
-    element.addEventListener('thread-list-modified', dispatchSpy);
-    threadElements[0].dispatchEvent(
-        new CustomEvent('thread-changed', {
-          detail: {
-            rootId: 'ecf0b9fa_fe1a5f62', path: '/COMMIT_MSG'},
-          composed: true, bubbles: true,
-        }));
-    assert.isTrue(element._handleCommentsChanged.called);
-    assert.isTrue(dispatchSpy.called);
-    assert.equal(dispatchSpy.lastCall.args[0].detail.rootId,
-        'ecf0b9fa_fe1a5f62');
-    assert.equal(dispatchSpy.lastCall.args[0].detail.path, '/COMMIT_MSG');
-  });
-
-  suite('hideToggleButtons', () => {
+  suite('hideDropdown', () => {
     setup(done => {
-      element.hideToggleButtons = true;
+      element.hideDropdown = true;
       flush(() => {
         done();
       });
@@ -611,7 +676,7 @@
     test('default empty message should show', () => {
       assert.isTrue(
           element.shadowRoot.querySelector('#threads').textContent.trim()
-              .includes('No comments.'));
+              .includes('No comments'));
     });
   });
 });
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 4b7aec5..f27a383 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-results.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-results.ts
@@ -17,6 +17,7 @@
 import {html} from 'lit-html';
 import {classMap} from 'lit-html/directives/class-map';
 import {repeat} from 'lit-html/directives/repeat';
+import {ifDefined} from 'lit-html/directives/if-defined';
 import {
   css,
   customElement,
@@ -39,26 +40,28 @@
 } from '../../api/checks';
 import {sharedStyles} from '../../styles/shared-styles';
 import {
-  allActions$,
-  allLinks$,
   CheckRun,
-  checksPatchsetNumber$,
+  checksSelectedPatchsetNumber$,
   RunResult,
-  someProvidersAreLoading$,
+  someProvidersAreLoadingSelected$,
+  topLevelActionsSelected$,
+  topLevelLinksSelected$,
 } from '../../services/checks/checks-model';
 import {
   allResults,
   fireActionTriggered,
+  firstPrimaryLink,
   hasCompletedWithoutResults,
-  iconForCategory,
+  iconFor,
   iconForLink,
-  otherLinks,
-  primaryLink,
+  isCategory,
+  otherPrimaryLinks,
   primaryRunAction,
+  secondaryLinks,
   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';
@@ -77,9 +80,13 @@
   getRepresentativeValue,
   valueString,
 } from '../../utils/label-util';
+import {GerritNav} from '../core/gr-navigation/gr-navigation';
 
 @customElement('gr-result-row')
 class GrResultRow extends GrLitElement {
+  @query('td.nameCol div.name')
+  nameEl?: HTMLElement;
+
   @property()
   result?: RunResult;
 
@@ -113,15 +120,14 @@
         gr-result-expanded {
           cursor: default;
         }
-        tr {
+        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;
@@ -130,6 +136,7 @@
           overflow: hidden;
           text-overflow: ellipsis;
           margin-right: var(--spacing-s);
+          outline-offset: var(--spacing-xs);
         }
         td.nameCol .space {
           flex-grow: 1;
@@ -137,6 +144,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
@@ -150,6 +158,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;
         }
@@ -169,27 +185,31 @@
           overflow: hidden;
           text-overflow: ellipsis;
         }
-        tr:hover {
+        tr.container:hover {
           background: var(--hover-background-color);
         }
-        tr td .summary-cell .links,
-        tr td .summary-cell .actions,
-        tr.collapsed:hover td .summary-cell .links,
-        tr.collapsed:hover td .summary-cell .actions,
+        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,
         :host(.dropdown-open) tr td .summary-cell .actions {
           display: inline-block;
           margin-left: var(--spacing-s);
         }
-        tr.collapsed td .summary-cell .message {
+        tr.container.collapsed td .summary-cell .message {
           color: var(--deemphasized-text-color);
         }
-        tr.collapsed td .summary-cell .links,
-        tr.collapsed td .summary-cell .actions {
+        tr.container.collapsed td .summary-cell .links,
+        tr.container.collapsed td .summary-cell .actions {
           display: none;
         }
-        tr.collapsed:hover .summary-cell .hoverHide.tags,
-        tr.collapsed:hover .summary-cell .hoverHide.label {
+        tr.detailsRow.collapsed {
           display: none;
         }
         td .summary-cell .tags .tag {
@@ -271,6 +291,10 @@
     super.update(changedProperties);
   }
 
+  focus() {
+    if (this.nameEl) this.nameEl.focus();
+  }
+
   firstUpdated() {
     const loading = this.shadowRoot?.querySelector('.container');
     assertIsDefined(loading, '"Loading" element');
@@ -282,7 +306,6 @@
     if (!this.shouldRender) {
       return html`
         <tr class="container">
-          <td class="iconCol"></td>
           <td class="nameCol">
             <div><span class="loading">Loading...</span></div>
           </td>
@@ -293,32 +316,37 @@
     }
     return html`
       <tr class="${classMap({container: true, collapsed: !this.isExpanded})}">
-        <td class="iconCol" @click="${this.toggleExpanded}">
-          <div>${this.renderIcon()}</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}</div>
+            <div
+              class="name"
+              role="button"
+              tabindex="0"
+              @click="${this.toggleExpandedClick}"
+              @keydown="${this.toggleExpandedPress}"
+            >
+              ${this.result.checkName}
+            </div>
             <div class="space"></div>
             ${this.renderPrimaryRunAction()}
           </div>
         </td>
         <td class="summaryCol">
           <div class="summary-cell">
-            ${this.renderLink(primaryLink(this.result))}
+            ${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>
-          ${this.renderExpanded()}
         </td>
-        <td class="expanderCol" @click="${this.toggleExpanded}">
+        <td class="expanderCol" @click="${this.toggleExpandedClick}">
           <div
             class="show-hide"
             role="switch"
@@ -328,7 +356,7 @@
             aria-label="${this.isExpanded
               ? 'Collapse result row'
               : 'Expand result row'}"
-            @keydown="${this.toggleExpanded}"
+            @keydown="${this.toggleExpandedPress}"
           >
             <iron-icon
               icon="${this.isExpanded
@@ -338,16 +366,12 @@
           </div>
         </td>
       </tr>
+      <tr class="${classMap({detailsRow: true, collapsed: !this.isExpanded})}">
+        <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);
@@ -362,6 +386,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;
@@ -376,11 +417,6 @@
     `;
   }
 
-  renderIcon() {
-    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;
@@ -389,23 +425,42 @@
     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>
     `;
   }
 
   renderLinks() {
-    const links = otherLinks(this.result);
+    const links = otherPrimaryLinks(this.result)
+      // Showing the same icons twice without text is super confusing.
+      .filter(
+        (link: Link, index: number, array: Link[]) =>
+          array.findIndex(other => link.icon === other.icon) === index
+      )
+      // 4 is enough for the summary row. All are shown in expanded state.
+      .slice(0, 4);
     if (links.length === 0) return;
-    return html`<div class="links">${links.map(this.renderLink)}</div>`;
+    return html`<div class="links">
+      ${links.map(link => this.renderLink(link))}
+    </div>`;
   }
 
   renderLink(link?: Link) {
+    // The expanded state renders all links in more detail. Hide in summary.
+    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"
@@ -473,7 +528,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>`;
   }
 }
 
@@ -485,12 +545,24 @@
   @property()
   repoConfig?: ConfigInfo;
 
+  private changeService = appContext.changeService;
+
   static get styles() {
     return [
       sharedStyles,
       css`
+        .links {
+          white-space: normal;
+        }
+        .links a {
+          display: inline-block;
+          margin-right: var(--spacing-xl);
+        }
+        .links a iron-icon {
+          margin-right: var(--spacing-xs);
+        }
         .message {
-          padding: var(--spacing-m) var(--spacing-m) var(--spacing-m) 0;
+          padding: var(--spacing-m) 0;
         }
       `,
     ];
@@ -504,6 +576,8 @@
   render() {
     if (!this.result) return '';
     return html`
+      ${this.renderFirstPrimaryLink()} ${this.renderOtherPrimaryLinks()}
+      ${this.renderSecondaryLinks()} ${this.renderCodePointers()}
       <gr-endpoint-decorator name="check-result-expanded">
         <gr-endpoint-param
           name="run"
@@ -514,21 +588,76 @@
           .value="${this.result}"
         ></gr-endpoint-param>
         <gr-formatted-text
-          no-trailing-margin=""
+          noTrailingMargin
           class="message"
-          content="${this.result.message}"
-          config="${this.repoConfig}"
+          .content="${this.result.message}"
+          .config="${this.repoConfig?.commentlinks}"
         ></gr-formatted-text>
       </gr-endpoint-decorator>
     `;
   }
-}
 
-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);
+  private renderFirstPrimaryLink() {
+    const link = firstPrimaryLink(this.result);
+    if (!link) return;
+    return html`<div class="links">${this.renderLink(link)}</div>`;
+  }
+
+  private renderOtherPrimaryLinks() {
+    const links = otherPrimaryLinks(this.result);
+    if (links.length === 0) return;
+    return html`<div class="links">
+      ${links.map(link => this.renderLink(link))}
+    </div>`;
+  }
+
+  private renderSecondaryLinks() {
+    const links = secondaryLinks(this.result);
+    if (links.length === 0) return;
+    return html`<div class="links">
+      ${links.map(link => this.renderLink(link))}
+    </div>`;
+  }
+
+  private renderCodePointers() {
+    const pointers = this.result?.codePointers ?? [];
+    if (pointers.length === 0) return;
+    const links = pointers.map(pointer => {
+      let rangeText = '';
+      const start = pointer?.range?.start_line;
+      const end = pointer?.range?.end_line;
+      if (start) rangeText += `#${start}`;
+      if (end && start !== end) rangeText += `-${end}`;
+      const change = this.changeService.getChange();
+      assertIsDefined(change);
+      const path = pointer.path;
+      const patchset = this.result?.patchset as PatchSetNumber | undefined;
+      const line = pointer?.range?.start_line;
+      return {
+        icon: LinkIcon.CODE,
+        tooltip: `${path}${rangeText}`,
+        url: GerritNav.getUrlForDiff(change, path, patchset, undefined, line),
+        primary: true,
+      };
+    });
+    return links.map(
+      link => html`<div class="links">${this.renderLink(link, false)}</div>`
+    );
+  }
+
+  private renderLink(link?: Link, targetBlank = true) {
+    if (!link) return;
+    const text = link.tooltip ?? tooltipForLink(link.icon);
+    const target = targetBlank ? '_blank' : undefined;
+    return html`<a href="${link.url}" target="${ifDefined(target)}">
+      <iron-icon
+        class="link"
+        icon="gr-icons:${iconForLink(link.icon)}"
+      ></iron-icon
+      ><span>${text}</span>
+    </a>`;
+  }
+}
 
 const CATEGORY_TOOLTIPS: Map<Category, string> = new Map();
 CATEGORY_TOOLTIPS.set(Category.ERROR, 'Must be fixed and is blocking submit');
@@ -611,11 +740,11 @@
 
   constructor() {
     super();
-    this.subscribe('actions', allActions$);
-    this.subscribe('links', allLinks$);
-    this.subscribe('checksPatchsetNumber', checksPatchsetNumber$);
+    this.subscribe('actions', topLevelActionsSelected$);
+    this.subscribe('links', topLevelLinksSelected$);
+    this.subscribe('checksPatchsetNumber', checksSelectedPatchsetNumber$);
     this.subscribe('latestPatchsetNumber', latestPatchNum$);
-    this.subscribe('someProvidersAreLoading', someProvidersAreLoading$);
+    this.subscribe('someProvidersAreLoading', someProvidersAreLoadingSelected$);
   }
 
   static get styles() {
@@ -634,6 +763,9 @@
             var(--spacing-xl);
           border-bottom: 1px solid var(--border-color);
         }
+        .header.notLatest {
+          background-color: var(--emphasis-color);
+        }
         .headerTopRow,
         .headerBottomRow {
           max-width: 1600px;
@@ -665,10 +797,20 @@
         .headerBottomRow {
           margin-top: var(--spacing-s);
         }
+        .headerTopRow .right,
         .headerBottomRow .right {
           display: flex;
           align-items: center;
         }
+        .headerTopRow .right .goToLatest {
+          display: none;
+        }
+        .notLatest .headerTopRow .right .goToLatest {
+          display: block;
+        }
+        .headerTopRow .right .goToLatest gr-button {
+          margin-right: var(--spacing-m);
+        }
         .headerBottomRow iron-icon {
           color: var(--link-color);
         }
@@ -768,17 +910,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 {
@@ -795,16 +936,19 @@
     super.updated(changedProperties);
     if (changedProperties.has('tabState') && this.tabState) {
       const {statusOrCategory, checkName} = this.tabState;
-      if (
+      if (isCategory(statusOrCategory)) {
+        const expanded = this.isSectionExpanded.get(statusOrCategory);
+        if (!expanded) this.toggleExpanded(statusOrCategory);
+      }
+      if (checkName) {
+        this.scrollElIntoView(`gr-result-row.${charsOnly(checkName)}`);
+      } else if (
         statusOrCategory &&
         statusOrCategory !== RunStatus.RUNNING &&
         statusOrCategory !== RunStatus.RUNNABLE
       ) {
-        let cat = statusOrCategory.toString().toLowerCase();
-        if (statusOrCategory === RunStatus.COMPLETED) cat = 'success';
-        this.scrollElIntoView(`.categoryHeader .${cat}`);
-      } else if (checkName) {
-        this.scrollElIntoView(`gr-result-row.${charsOnly(checkName)}`);
+        const cat = statusOrCategory.toString().toLowerCase();
+        this.scrollElIntoView(`.categoryHeader.${cat} + table gr-result-row`);
       }
     }
   }
@@ -812,16 +956,35 @@
   private scrollElIntoView(selector: string) {
     this.updateComplete.then(() => {
       let el = this.shadowRoot?.querySelector(selector);
-      // <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;
-      el?.scrollIntoView({block: 'center'});
+      // 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;
+        el?.scrollIntoView({block: 'center'});
+      }, 0);
     });
   }
 
   render() {
-    return html`
-      <div class="header">
+    // To pass CSS mixins for @apply to Polymer components, they need to appear
+    // in <style> inside the template.
+    const style = html`<style>
+      .headerTopRow .right .goToLatest gr-button {
+        --gr-button: {
+          padding: var(--spacing-s) var(--spacing-m);
+          text-transform: none;
+        }
+      }
+    </style>`;
+    const headerClasses = classMap({
+      header: true,
+      notLatest: !!this.checksPatchsetNumber,
+    });
+    return html`${style}
+      <div class="${headerClasses}">
         <div class="headerTopRow">
           <div class="left">
             <h2 class="heading-2">Results</h2>
@@ -831,8 +994,13 @@
             </div>
           </div>
           <div class="right">
+            <div class="goToLatest">
+              <gr-button @click="${this.goToLatestPatchset}" link
+                >Go to latest patchset</gr-button
+              >
+            </div>
             <gr-dropdown-list
-              value="${this.checksPatchsetNumber}"
+              value="${this.checksPatchsetNumber ?? this.latestPatchsetNumber}"
               .items="${this.createPatchsetDropdownItems()}"
               @value-change="${this.onPatchsetSelected}"
             ></gr-dropdown-list>
@@ -852,14 +1020,20 @@
         ${this.renderSection(Category.WARNING)}
         ${this.renderSection(Category.INFO)}
         ${this.renderSection(Category.SUCCESS)}
-      </div>
-    `;
+      </div>`;
   }
 
   private renderLinks() {
     const links = this.links ?? [];
     if (links.length === 0) return;
-    const primaryLinks = links.filter(a => a.primary).slice(0, 4);
+    const primaryLinks = links
+      .filter(a => a.primary)
+      // Showing the same icons twice without text is super confusing.
+      .filter(
+        (link: Link, index: number, array: Link[]) =>
+          array.findIndex(other => link.icon === other.icon) === index
+      )
+      .slice(0, 4);
     const overflowLinks = links.filter(a => !primaryLinks.includes(a));
     return html`
       ${primaryLinks.map(this.renderLink)}
@@ -957,6 +1131,10 @@
     this.checksService.setPatchset(patchset as PatchSetNumber);
   }
 
+  private goToLatestPatchset() {
+    this.checksService.setPatchset(undefined);
+  }
+
   private createPatchsetDropdownItems() {
     if (!this.latestPatchsetNumber) return [];
     return Array.from(Array(this.latestPatchsetNumber), (_, i) => {
@@ -1007,6 +1185,8 @@
 
   renderSection(category: Category) {
     const catString = category.toString().toLowerCase();
+    const isWarningOrError =
+      category === Category.WARNING || category === Category.ERROR;
     const allRuns = this.runs.filter(run =>
       isAttemptSelected(this.selectedAttempts, run)
     );
@@ -1017,6 +1197,7 @@
       ],
       []
     );
+    const isSelection = this.selectedRuns.length > 0;
     const selected = all.filter(result => this.isRunSelected(result));
     const filtered = selected.filter(
       result =>
@@ -1027,19 +1208,18 @@
     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);
       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`
@@ -1051,17 +1231,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,
@@ -1085,7 +1263,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
           >
@@ -1125,7 +1303,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>
@@ -1148,13 +1325,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 d2e4453..d29d483 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-runs.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-runs.ts
@@ -33,16 +33,19 @@
   AttemptDetail,
   compareByWorstCategory,
   fireActionTriggered,
-  iconForCategory,
+  iconFor,
   iconForRun,
   PRIMARY_STATUS_ACTIONS,
   primaryRunAction,
   worstCategory,
 } from '../../services/checks/checks-util';
 import {
+  allRunsSelectedPatchset$,
   CheckRun,
-  allRuns$,
+  ChecksPatchset,
+  errorMessageLatest$,
   fakeActions,
+  fakeLinks,
   fakeRun0,
   fakeRun1,
   fakeRun2,
@@ -51,11 +54,11 @@
   fakeRun4_2,
   fakeRun4_3,
   fakeRun4_4,
+  loginCallbackLatest$,
   updateStateSetResults,
-  fakeLinks,
 } from '../../services/checks/checks-model';
 import {assertIsDefined} from '../../utils/common-util';
-import {whenVisible} from '../../utils/dom-util';
+import {modifierPressed, whenVisible} from '../../utils/dom-util';
 import {
   fireAttemptSelected,
   fireRunSelected,
@@ -126,6 +129,12 @@
         iron-icon.check-circle-outline {
           color: var(--success-foreground);
         }
+        div.chip:hover {
+          background-color: var(--hover-background-color);
+        }
+        div.chip:focus-within {
+          background-color: var(--selection-background-color);
+        }
         /* Additional 'div' for increased specificity. */
         div.chip.selected {
           border: 1px solid var(--selected-background);
@@ -136,10 +145,6 @@
         div.chip.selected iron-icon.filter {
           color: var(--selected-foreground);
         }
-        .chip.selected gr-checks-action,
-        .chip.deselected gr-checks-action {
-          display: none;
-        }
         gr-checks-action {
           /* The button should fit into the 20px line-height. The negative
              margin provides the extra space needed for the vertical padding.
@@ -164,6 +169,10 @@
           top: 3px;
           margin-right: var(--spacing-s);
         }
+        .statusLinkIcon {
+          color: var(--link-color);
+          margin-left: var(--spacing-s);
+        }
       `,
     ];
   }
@@ -223,14 +232,20 @@
     const action = primaryRunAction(this.run);
 
     return html`
-      <div @click="${this.handleChipClick}" class="${classMap(classes)}">
-        <gr-hovercard-run .run="${this.run}"></gr-hovercard-run>
+      <div
+        @click="${this.handleChipClick}"
+        @keydown="${this.handleChipKey}"
+        class="${classMap(classes)}"
+        tabindex="0"
+      >
         <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
@@ -277,6 +292,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`
@@ -292,7 +330,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>
     `;
@@ -303,6 +341,15 @@
     e.preventDefault();
     fireRunSelected(this, this.run.checkName);
   }
+
+  private handleChipKey(e: KeyboardEvent) {
+    if (modifierPressed(e)) return;
+    // Only react to `return` and `space`.
+    if (e.keyCode !== 13 && e.keyCode !== 32) return;
+    e.preventDefault();
+    e.stopPropagation();
+    fireRunSelected(this, this.run.checkName);
+  }
 }
 
 @customElement('gr-checks-runs')
@@ -332,13 +379,21 @@
   @property()
   tabState?: ChecksTabState;
 
+  @property()
+  errorMessage?: string;
+
+  @property()
+  loginCallback?: () => void;
+
   private isSectionExpanded = new Map<RunStatus, boolean>();
 
   private flagService = appContext.flagsService;
 
   constructor() {
     super();
-    this.subscribe('runs', allRuns$);
+    this.subscribe('runs', allRunsSelectedPatchset$);
+    this.subscribe('errorMessage', errorMessageLatest$);
+    this.subscribe('loginCallback', loginCallbackLatest$);
   }
 
   static get styles() {
@@ -409,6 +464,33 @@
         .testing:hover * {
           visibility: visible;
         }
+        .login,
+        .error {
+          padding: var(--spacing-m);
+          color: var(--primary-text-color);
+          margin-top: var(--spacing-m);
+        }
+        .error {
+          display: flex;
+          background-color: var(--error-background);
+        }
+        .error iron-icon {
+          color: var(--error-foreground);
+          margin-right: var(--spacing-m);
+        }
+        .login {
+          background: var(--info-background);
+        }
+        .login iron-icon {
+          color: var(--info-foreground);
+        }
+        .login .buttonRow {
+          text-align: right;
+          margin-top: var(--spacing-xl);
+        }
+        .login gr-button {
+          margin: 0 var(--spacing-s);
+        }
       `,
     ];
   }
@@ -440,6 +522,7 @@
         <div class="flex-space"></div>
         ${this.renderTitleButtons()} ${this.renderCollapseButton()}
       </h2>
+      ${this.renderError()} ${this.renderSignIn()}
       <input
         id="filterInput"
         type="text"
@@ -447,12 +530,45 @@
         ?hidden="${!this.showFilter()}"
         @input="${this.onInput}"
       />
-      ${this.renderSection(RunStatus.COMPLETED)}
       ${this.renderSection(RunStatus.RUNNING)}
+      ${this.renderSection(RunStatus.COMPLETED)}
       ${this.renderSection(RunStatus.RUNNABLE)} ${this.renderFakeControls()}
     `;
   }
 
+  private renderError() {
+    if (!this.errorMessage) return;
+    return html`
+      <div class="error">
+        <div class="left">
+          <iron-icon icon="gr-icons:error"></iron-icon>
+        </div>
+        <div class="right">
+          <div>Error while fetching check results</div>
+          <div>${this.errorMessage}</div>
+        </div>
+      </div>
+    `;
+  }
+
+  private renderSignIn() {
+    if (this.errorMessage || !this.loginCallback) return;
+    return html`
+      <div class="login">
+        <div>
+          <iron-icon
+            class="info-outline"
+            icon="gr-icons:info-outline"
+          ></iron-icon>
+          Sign in to Checks Plugin to see runs and results
+        </div>
+        <div class="buttonRow">
+          <gr-button @click="${this.loginCallback}" link>Sign in</gr-button>
+        </div>
+      </div>
+    `;
+  }
+
   private renderTitleButtons() {
     if (this.selectedRuns.length < 2) return;
     const actions = this.selectedRuns.map(selected => {
@@ -477,7 +593,7 @@
         class="font-normal"
         link
         title="${runButtonDisabled
-          ? 'Disabled. All selected runs must have a "Run" action.'
+          ? 'Disabled. Unselect checks without a "Run" action to enable the button.'
           : ''}"
         has-tooltip="${runButtonDisabled}"
         ?disabled="${runButtonDisabled}"
@@ -518,24 +634,31 @@
   }
 
   none() {
-    updateStateSetResults('f0', [], []);
-    updateStateSetResults('f1', []);
-    updateStateSetResults('f2', []);
-    updateStateSetResults('f3', []);
-    updateStateSetResults('f4', []);
+    updateStateSetResults('f0', [], [], [], ChecksPatchset.LATEST);
+    updateStateSetResults('f1', [], [], [], ChecksPatchset.LATEST);
+    updateStateSetResults('f2', [], [], [], ChecksPatchset.LATEST);
+    updateStateSetResults('f3', [], [], [], ChecksPatchset.LATEST);
+    updateStateSetResults('f4', [], [], [], ChecksPatchset.LATEST);
   }
 
   all() {
-    updateStateSetResults('f0', [fakeRun0], fakeActions, fakeLinks);
-    updateStateSetResults('f1', [fakeRun1]);
-    updateStateSetResults('f2', [fakeRun2]);
-    updateStateSetResults('f3', [fakeRun3]);
-    updateStateSetResults('f4', [
-      fakeRun4_1,
-      fakeRun4_2,
-      fakeRun4_3,
-      fakeRun4_4,
-    ]);
+    updateStateSetResults(
+      'f0',
+      [fakeRun0],
+      fakeActions,
+      fakeLinks,
+      ChecksPatchset.LATEST
+    );
+    updateStateSetResults('f1', [fakeRun1], [], [], ChecksPatchset.LATEST);
+    updateStateSetResults('f2', [fakeRun2], [], [], ChecksPatchset.LATEST);
+    updateStateSetResults('f3', [fakeRun3], [], [], ChecksPatchset.LATEST);
+    updateStateSetResults(
+      'f4',
+      [fakeRun4_1, fakeRun4_2, fakeRun4_3, fakeRun4_4],
+      [],
+      [],
+      ChecksPatchset.LATEST
+    );
   }
 
   toggle(
@@ -545,7 +668,13 @@
     links: Link[] = []
   ) {
     const newRuns = this.runs.includes(runs[0]) ? [] : runs;
-    updateStateSetResults(plugin, newRuns, actions, links);
+    updateStateSetResults(
+      plugin,
+      newRuns,
+      actions,
+      links,
+      ChecksPatchset.LATEST
+    );
   }
 
   renderSection(status: RunStatus) {
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-tab.ts b/polygerrit-ui/app/elements/checks/gr-checks-tab.ts
index a04c190..d60bded 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-tab.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-tab.ts
@@ -21,13 +21,13 @@
 import {
   CheckResult,
   CheckRun,
-  allResults$,
-  allRuns$,
-  checksPatchsetNumber$,
+  allResultsSelected$,
+  checksSelectedPatchsetNumber$,
+  allRunsSelectedPatchset$,
 } from '../../services/checks/checks-model';
 import './gr-checks-runs';
 import './gr-checks-results';
-import {changeNum$} from '../../services/change/change-model';
+import {changeNum$, latestPatchNum$} from '../../services/change/change-model';
 import {NumericChangeId, PatchSetNumber} from '../../types/common';
 import {ActionTriggeredEvent} from '../../services/checks/checks-util';
 import {AttemptSelectedEvent, RunSelectedEvent} from './gr-checks-util';
@@ -56,6 +56,9 @@
   checksPatchsetNumber: PatchSetNumber | undefined = undefined;
 
   @property()
+  latestPatchsetNumber: PatchSetNumber | undefined = undefined;
+
+  @property()
   changeNum: NumericChangeId | undefined = undefined;
 
   @state()
@@ -72,9 +75,10 @@
 
   constructor() {
     super();
-    this.subscribe('runs', allRuns$);
-    this.subscribe('results', allResults$);
-    this.subscribe('checksPatchsetNumber', checksPatchsetNumber$);
+    this.subscribe('runs', allRunsSelectedPatchset$);
+    this.subscribe('results', allResultsSelected$);
+    this.subscribe('checksPatchsetNumber', checksSelectedPatchsetNumber$);
+    this.subscribe('latestPatchsetNumber', latestPatchNum$);
     this.subscribe('changeNum', changeNum$);
 
     this.addEventListener('action-triggered', (e: ActionTriggeredEvent) =>
@@ -105,6 +109,7 @@
       <div class="container">
         <gr-checks-runs
           class="runs"
+          ?collapsed="${this.offsetWidth < 1000}"
           .runs="${this.runs}"
           .selectedRuns="${this.selectedRuns}"
           .selectedAttempts="${this.selectedAttempts}"
@@ -135,10 +140,11 @@
 
   handleActionTriggered(action: Action, run?: CheckRun) {
     if (!this.changeNum) return;
-    if (!this.checksPatchsetNumber) return;
+    const patchSet = this.checksPatchsetNumber ?? this.latestPatchsetNumber;
+    if (!patchSet) return;
     const promise = action.callback(
       this.changeNum,
-      this.checksPatchsetNumber,
+      patchSet,
       run?.attempt,
       run?.externalId,
       run?.checkName,
diff --git a/polygerrit-ui/app/elements/checks/gr-hovercard-run.ts b/polygerrit-ui/app/elements/checks/gr-hovercard-run.ts
index 10f036e..1ae3e2b 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';
@@ -84,13 +88,10 @@
     return !run.statusLink && !run.statusDescription;
   }
 
-  hideAttemptSection(run?: CheckRun) {
+  hideTimestampSection(run?: CheckRun) {
     if (!run) return true;
     return (
-      !run.startedTimestamp &&
-      !run.scheduledTimestamp &&
-      !run.finishedTimestamp &&
-      this.hideAttempts(run)
+      !run.startedTimestamp && !run.scheduledTimestamp && !run.finishedTimestamp
     );
   }
 
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..277bd16 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);
@@ -140,9 +146,9 @@
         </div>
       </div>
     </div>
-    <div class="section" hidden$="[[hideAttemptSection(run)]]">
+    <div class="section" hidden$="[[hideAttempts(run)]]">
       <div class="sectionIcon">
-        <iron-icon class="small" icon="gr-icons:schedule"></iron-icon>
+        <iron-icon class="small" icon="gr-icons:arrow-forward"></iron-icon>
       </div>
       <div class="sectionContent">
         <div hidden$="[[hideAttempts(run)]]" class="row">
@@ -155,10 +161,17 @@
                   icon="gr-icons:[[item.icon]]"
                 ></iron-icon>
               </div>
-              <div class="attemptNumber">[[item.attempt]]</div>
+              <div class="attemptNumber">[[computeAttempt(item.attempt)]]</div>
             </div>
           </template>
         </div>
+      </div>
+    </div>
+    <div class="section" hidden$="[[hideTimestampSection(run)]]">
+      <div class="sectionIcon">
+        <iron-icon class="small" icon="gr-icons:schedule"></iron-icon>
+      </div>
+      <div class="sectionContent">
         <div hidden$="[[hideScheduled(run)]]" class="row">
           <div class="title">Scheduled</div>
           <div>[[computeDuration(run.scheduledTimestamp)]]</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-account-dropdown/gr-account-dropdown.ts b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.ts
index 198f1d9..1bbda20 100644
--- a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.ts
+++ b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.ts
@@ -25,6 +25,10 @@
 import {AccountInfo, ServerInfo} from '../../../types/common';
 import {appContext} from '../../../services/app-context';
 import {fireEvent} from '../../../utils/event-util';
+import {
+  DropdownContent,
+  DropdownLink,
+} from '../../shared/gr-dropdown/gr-dropdown';
 
 const INTERPOLATE_URL_PATTERN = /\${([\w]+)}/g;
 
@@ -47,10 +51,10 @@
   config?: ServerInfo;
 
   @property({type: Array, computed: '_getLinks(_switchAccountUrl, _path)'})
-  links?: string[];
+  links?: DropdownLink[];
 
   @property({type: Array, computed: '_getTopContent(account)'})
-  topContent?: string[];
+  topContent?: DropdownContent[];
 
   @property({type: String})
   _path = '/';
diff --git a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_html.ts b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_html.ts
index 0fa2f1e..97f4a89 100644
--- a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_html.ts
+++ b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_html.ts
@@ -45,7 +45,7 @@
       account="[[account]]"
       hidden$="[[!_hasAvatars]]"
       hidden=""
-      image-size="56"
+      imageSize="56"
       aria-label="Account avatar"
     ></gr-avatar>
   </gr-dropdown>
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 4985c1b..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() {
@@ -217,9 +248,11 @@
             url,
             trace,
           });
+        } else if (response.status === 429) {
+          this._showQuotaExceeded({status, statusText});
         } else {
           this._showErrorDialog(
-            this._constructServerErrorMsg({
+            constructServerErrorMsg({
               status,
               statusText,
               errorText,
@@ -229,7 +262,7 @@
           );
         }
       }
-      console.info(`server error: ${errorText}`);
+      this.reporting.error(new Error(`Server error: ${errorText}`));
     });
   };
 
@@ -245,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,
@@ -260,35 +293,17 @@
     });
   }
 
-  _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;
+  _showQuotaExceeded({status, statusText}: ErrorMsg) {
+    const tip = 'Try again later';
+    const errorText = 'Too many requests from this client';
+    this._showErrorDialog(
+      constructServerErrorMsg({
+        status,
+        statusText,
+        errorText,
+        tip,
+      })
+    );
   }
 
   private readonly handleShowAlert = (e: CustomEvent<ShowAlertEventDetail>) => {
@@ -304,7 +319,7 @@
 
   private readonly handleNetworkError = (e: NetworkErrorEvent) => {
     this._showAlert('Server unavailable');
-    console.error(e.detail.error.message);
+    this.reporting.error(new Error(`network error: ${e.detail.error.message}`));
   };
 
   // TODO(dhruvsr): allow less priority alerts to override high priority alerts
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 91f119e..0000000
--- a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.js
+++ /dev/null
@@ -1,578 +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 consoleErrorStub = sinon.stub(console, 'error');
-      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'));
-        assert.isTrue(consoleErrorStub.calledOnce);
-        assert.isTrue(consoleErrorStub.lastCall.calledWithExactly('ZOMG'));
-        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-key-binding-display/gr-key-binding-display.css.ts b/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display.css.ts
deleted file mode 100644
index ccba289..0000000
--- a/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display.css.ts
+++ /dev/null
@@ -1,30 +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 {css} from 'lit-element';
-
-export const cssTemplate = css`
-  .key {
-    background-color: var(--chip-background-color);
-    color: var(--primary-text-color);
-    border: 1px solid var(--border-color);
-    border-radius: var(--border-radius);
-    display: inline-block;
-    font-weight: var(--font-weight-bold);
-    padding: var(--spacing-xxs) var(--spacing-m);
-    text-align: center;
-  }
-`;
diff --git a/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display.ts b/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display.ts
index 091684f..657d7cc 100644
--- a/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display.ts
+++ b/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display.ts
@@ -16,9 +16,7 @@
  */
 import {html} from 'lit-html';
 import {GrLitElement} from '../../lit/gr-lit-element';
-import {customElement, property} from 'lit-element';
-import {cssTemplate} from './gr-key-binding-display.css';
-import {sharedStyles} from '../../../styles/shared-styles';
+import {css, customElement, property} from 'lit-element';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -29,7 +27,20 @@
 @customElement('gr-key-binding-display')
 export class GrKeyBindingDisplay extends GrLitElement {
   static get styles() {
-    return [sharedStyles, cssTemplate];
+    return [
+      css`
+        .key {
+          background-color: var(--chip-background-color);
+          color: var(--primary-text-color);
+          border: 1px solid var(--border-color);
+          border-radius: var(--border-radius);
+          display: inline-block;
+          font-weight: var(--font-weight-bold);
+          padding: var(--spacing-xxs) var(--spacing-m);
+          text-align: center;
+        }
+      `,
+    ];
   }
 
   render() {
diff --git a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.ts b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.ts
index 816ed87..f7e61bf 100644
--- a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.ts
+++ b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.ts
@@ -24,7 +24,7 @@
 import {getBaseUrl, getDocsBaseUrl} from '../../../utils/url-util';
 import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
 import {getAdminLinks, NavLink} from '../../../utils/admin-nav-util';
-import {customElement, property, observe} from '@polymer/decorators';
+import {customElement, property} from '@polymer/decorators';
 import {
   AccountDetailInfo,
   RequireProperties,
@@ -35,6 +35,11 @@
 import {AuthType} from '../../../constants/constants';
 import {DropdownLink} from '../../shared/gr-dropdown/gr-dropdown';
 import {appContext} from '../../../services/app-context';
+import {Subject} from 'rxjs';
+import {serverConfig$} from '../../../services/config/config-model';
+import {takeUntil} from 'rxjs/operators';
+import {myTopMenuItems$} from '../../../services/user/user-model';
+import {assertIsDefined} from '../../../utils/common-util';
 
 type MainHeaderLink = RequireProperties<DropdownLink, 'url' | 'name'>;
 
@@ -154,6 +159,8 @@
 
   private readonly jsAPI = appContext.jsApiService;
 
+  private readonly disconnected$ = new Subject();
+
   /** @override */
   ready() {
     super.ready();
@@ -162,13 +169,31 @@
 
   /** @override */
   connectedCallback() {
+    // TODO(brohlfs): This just ensures that the userService is instantiated at
+    // all. We need the service to manage the model, but we are not making any
+    // direct calls. Will need to find a better solution to this problem ...
+    assertIsDefined(appContext.userService);
+
     super.connectedCallback();
     this._loadAccount();
-    this._loadConfig();
+
+    myTopMenuItems$.pipe(takeUntil(this.disconnected$)).subscribe(items => {
+      this._userLinks = items.map(this._createHeaderLink);
+    });
+
+    serverConfig$.pipe(takeUntil(this.disconnected$)).subscribe(config => {
+      if (!config) return;
+      this._retrieveFeedbackURL(config);
+      this._retrieveRegisterURL(config);
+      getDocsBaseUrl(config, this.restApiService).then(docBaseUrl => {
+        this._docBaseUrl = docBaseUrl;
+      });
+    });
   }
 
   /** @override */
   disconnectedCallback() {
+    this.disconnected$.next();
     super.disconnectedCallback();
   }
 
@@ -293,34 +318,6 @@
     });
   }
 
-  _loadConfig() {
-    this.restApiService
-      .getConfig()
-      .then(config => {
-        if (!config) {
-          throw new Error('getConfig returned undefined');
-        }
-        this._retrieveFeedbackURL(config);
-        this._retrieveRegisterURL(config);
-        return getDocsBaseUrl(config, this.restApiService);
-      })
-      .then(docBaseUrl => {
-        this._docBaseUrl = docBaseUrl;
-      });
-  }
-
-  @observe('_account')
-  _accountLoaded(account?: AccountDetailInfo) {
-    if (!account) {
-      return;
-    }
-
-    this.restApiService.getPreferences().then(prefs => {
-      this._userLinks =
-        prefs && prefs.my ? prefs.my.map(this._createHeaderLink) : [];
-    });
-  }
-
   _retrieveFeedbackURL(config: ServerInfo) {
     if (config.gerrit?.report_bug_url) {
       this._feedbackURL = config.gerrit.report_bug_url;
diff --git a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_html.ts b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_html.ts
index 7f3970f..4bd048c 100644
--- a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_html.ts
+++ b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_html.ts
@@ -215,6 +215,7 @@
           href$="[[_feedbackURL]]"
           title="File a bug"
           aria-label="File a bug"
+          target="_blank"
           role="button"
         >
           <iron-icon icon="gr-icons:bug"></iron-icon>
diff --git a/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.ts b/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.ts
index f8ee589..6eaca5e 100644
--- a/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.ts
+++ b/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.ts
@@ -257,6 +257,7 @@
   host?: string;
   messageHash?: string;
   queryMap?: Map<string, string> | URLSearchParams;
+  commentId?: UrlEncodedCommentId;
 
   // TODO(TS): querystring isn't set anywhere, try to remove
   querystring?: string;
@@ -695,6 +696,19 @@
     });
   },
 
+  getUrlForCommentsTab(
+    changeNum: NumericChangeId,
+    project: RepoName,
+    commentId: UrlEncodedCommentId
+  ) {
+    return this._getUrlFor({
+      view: GerritView.CHANGE,
+      changeNum,
+      project,
+      commentId,
+    });
+  },
+
   /**
    * @param basePatchNum The string 'PARENT' can be used for none.
    */
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-router.ts b/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
index aa9b299..7532101 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
@@ -193,6 +193,10 @@
   // links generated in the emails.
   COMMENT: /^\/c\/(.+)\/\+\/(\d+)\/comment\/(\w+)\/?$/,
 
+  // Matches /c/<project>/+/<changeNum>/comments/<commentId>/
+  // Navigates to the commentId inside the Comments Tab
+  COMMENTS_TAB: /^\/c\/(.+)\/\+\/(\d+)\/comments(?:\/)?(\w+)?\/?$/,
+
   // Matches
   // /c/<project>/+/<changeNum>/[<basePatchNum|edit>..]<patchNum|edit>/<path>.
   // TODO(kaspern): Migrate completely to project based URLs, with backwards
@@ -543,6 +547,9 @@
     if (params.messageHash) {
       suffix += params.messageHash;
     }
+    if (params.commentId) {
+      suffix = suffix + `/comments/${params.commentId}`;
+    }
     if (params.project) {
       const encodedProject = encodeURL(params.project, true);
       return `/c/${encodedProject}/+/${params.changeNum}${suffix}`;
@@ -1078,6 +1085,8 @@
 
     this._mapRoute(RoutePattern.COMMENT, '_handleCommentRoute');
 
+    this._mapRoute(RoutePattern.COMMENTS_TAB, '_handleCommentsRoute');
+
     this._mapRoute(RoutePattern.DIFF, '_handleDiffRoute');
 
     this._mapRoute(RoutePattern.CHANGE, '_handleChangeRoute');
@@ -1610,6 +1619,19 @@
     this._redirectOrNavigate(params);
   }
 
+  _handleCommentsRoute(ctx: PageContextWithQueryMap) {
+    const changeNum = Number(ctx.params[1]) as NumericChangeId;
+    const params: GenerateUrlChangeViewParameters = {
+      project: ctx.params[0] as RepoName,
+      changeNum,
+      commentId: ctx.params[2] as UrlEncodedCommentId,
+      view: GerritView.CHANGE,
+    };
+    this.reporting.setRepoName(params.project);
+    this.reporting.setChangeId(changeNum);
+    this._redirectOrNavigate(params);
+  }
+
   _handleDiffRoute(ctx: PageContextWithQueryMap) {
     const changeNum = Number(ctx.params[1]) as NumericChangeId;
     // Parameter order is based on the regex group number matched.
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-router_test.js b/polygerrit-ui/app/elements/core/gr-router/gr-router_test.js
index ad4ebcb..6e80a35 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router_test.js
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router_test.js
@@ -21,6 +21,7 @@
 import {GerritNav} from '../gr-navigation/gr-navigation.js';
 import {stubBaseUrl, stubRestApi, addListenerForTest} from '../../../test/test-utils.js';
 import {_testOnly_RoutePattern} from './gr-router.js';
+import {GerritView} from '../../../services/router/router-model.js';
 
 const basicFixture = fixtureFromElement('gr-router');
 
@@ -186,6 +187,7 @@
       '_handleChangeNumberLegacyRoute',
       '_handleChangeRoute',
       '_handleCommentRoute',
+      '_handleCommentsRoute',
       '_handleDiffRoute',
       '_handleDefaultRoute',
       '_handleChangeLegacyRoute',
@@ -305,12 +307,12 @@
 
     test('change', () => {
       const params = {
-        view: GerritNav.View.CHANGE,
+        view: GerritView.CHANGE,
         changeNum: '1234',
         project: 'test',
       };
       const paramsWithQuery = {
-        view: GerritNav.View.CHANGE,
+        view: GerritView.CHANGE,
         changeNum: '1234',
         project: 'test',
         querystring: 'revert&foo=bar',
@@ -338,7 +340,7 @@
 
     test('change with repo name encoding', () => {
       const params = {
-        view: GerritNav.View.CHANGE,
+        view: GerritView.CHANGE,
         changeNum: '1234',
         project: 'x+/y+/z+/w',
       };
@@ -348,7 +350,7 @@
 
     test('diff', () => {
       const params = {
-        view: GerritNav.View.DIFF,
+        view: GerritView.DIFF,
         changeNum: '42',
         path: 'x+y/path.cpp',
         patchNum: 12,
@@ -382,7 +384,7 @@
 
     test('diff with repo name encoding', () => {
       const params = {
-        view: GerritNav.View.DIFF,
+        view: GerritView.DIFF,
         changeNum: '42',
         path: 'x+y/path.cpp',
         patchNum: 12,
@@ -1374,7 +1376,7 @@
           changeNum: 1234,
           basePatchNum: 6,
           patchNum: 9,
-          view: GerritNav.View.CHANGE,
+          view: GerritView.CHANGE,
           querystring: '',
         });
       });
@@ -1401,7 +1403,7 @@
           changeNum: 1234,
           basePatchNum: 3,
           patchNum: 8,
-          view: GerritNav.View.DIFF,
+          view: GerritView.DIFF,
           path: 'foo/bar',
           lineNum: 123,
           leftSide: true,
@@ -1464,7 +1466,7 @@
           sinon.stub(element, '_generateUrl').returns('foo');
           const ctx = makeParams(null, '');
           assertDataToParams(ctx, '_handleChangeRoute', {
-            view: GerritNav.View.CHANGE,
+            view: GerritView.CHANGE,
             project: 'foo/bar',
             changeNum: 1234,
             basePatchNum: 4,
@@ -1518,7 +1520,7 @@
           sinon.stub(element, '_generateUrl').returns('foo');
           const ctx = makeParams('foo/bar/baz', 'b44');
           assertDataToParams(ctx, '_handleDiffRoute', {
-            view: GerritNav.View.DIFF,
+            view: GerritView.DIFF,
             project: 'foo/bar',
             changeNum: 1234,
             basePatchNum: 4,
@@ -1544,9 +1546,26 @@
             changeNum: 264833,
             commentId: '00049681_f34fd6a9',
             commentLink: true,
-            view: GerritNav.View.DIFF,
+            view: GerritView.DIFF,
           });
         });
+
+        test('comments route', () => {
+          const url = '/c/gerrit/+/264833/comments/00049681_f34fd6a9/';
+          const groups = url.match(_testOnly_RoutePattern.COMMENTS_TAB);
+          assert.deepEqual(groups.slice(1), [
+            'gerrit', // project
+            '264833', // changeNum
+            '00049681_f34fd6a9', // commentId
+          ]);
+          assertDataToParams({params: groups.slice(1)},
+              '_handleCommentsRoute', {
+                project: 'gerrit',
+                changeNum: 264833,
+                commentId: '00049681_f34fd6a9',
+                view: GerritView.CHANGE,
+              });
+        });
       });
 
       test('_handleDiffEditRoute', () => {
@@ -1623,7 +1642,7 @@
         const appParams = {
           project: 'foo/bar',
           changeNum: 1234,
-          view: GerritNav.View.CHANGE,
+          view: GerritView.CHANGE,
           patchNum: 3,
           edit: true,
         };
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..e30e75e 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',
@@ -103,6 +104,7 @@
   'project:',
   'projects:',
   'query:',
+  'repo:',
   'ref:',
   'reviewedby:',
   'reviewer:',
@@ -309,6 +311,7 @@
 
       case 'parentproject':
       case 'project':
+      case 'repo':
         // Fetch projects.
         return this.projectSuggestions(predicate, expression);
 
diff --git a/polygerrit-ui/app/elements/custom-dark-theme_test.js b/polygerrit-ui/app/elements/custom-dark-theme_test.js
index 97a2750..768a461 100644
--- a/polygerrit-ui/app/elements/custom-dark-theme_test.js
+++ b/polygerrit-ui/app/elements/custom-dark-theme_test.js
@@ -25,13 +25,13 @@
 
 suite('gr-app custom dark theme tests', () => {
   let element;
-  setup(done => {
+  setup(async () => {
     window.localStorage.setItem('dark-theme', 'true');
 
     element = basicFixture.instantiate();
     getPluginLoader().loadPlugins([]);
-    getPluginLoader().awaitPluginsLoaded()
-        .then(() => flush(done));
+    await getPluginLoader().awaitPluginsLoaded();
+    await flush();
   });
 
   teardown(() => {
diff --git a/polygerrit-ui/app/elements/custom-light-theme_test.js b/polygerrit-ui/app/elements/custom-light-theme_test.js
index 35bc3a6..c6e9642 100644
--- a/polygerrit-ui/app/elements/custom-light-theme_test.js
+++ b/polygerrit-ui/app/elements/custom-light-theme_test.js
@@ -25,7 +25,7 @@
 
 suite('gr-app custom light theme tests', () => {
   let element;
-  setup(done => {
+  setup(async () => {
     window.localStorage.removeItem('dark-theme');
     stubRestApi('getConfig').returns(Promise.resolve({test: 'config'}));
     stubRestApi('getAccount').returns(Promise.resolve({}));
@@ -34,8 +34,8 @@
     stubRestApi('getDiffDrafts').returns(Promise.resolve({}));
     element = basicFixture.instantiate();
     getPluginLoader().loadPlugins([]);
-    getPluginLoader().awaitPluginsLoaded()
-        .then(() => flush(done));
+    await getPluginLoader().awaitPluginsLoaded();
+    await flush();
   });
   teardown(() => {
     // The app sends requests to server. This can lead to
diff --git a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.ts b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.ts
index 73afde8..315fbfb 100644
--- a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.ts
+++ b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.ts
@@ -16,19 +16,18 @@
  */
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-comment-api_html';
-import {CURRENT} from '../../../utils/patch-set-util';
 import {customElement, property} from '@polymer/decorators';
 import {
   CommentBasics,
   PatchRange,
   PatchSetNum,
-  PathToRobotCommentsInfoMap,
   RobotCommentInfo,
   UrlEncodedCommentId,
   NumericChangeId,
   PathToCommentsInfoMap,
   FileInfo,
   ParentPatchSetNum,
+  CommentInfo,
 } from '../../../types/common';
 import {
   Comment,
@@ -37,15 +36,13 @@
   DraftInfo,
   isUnresolved,
   UIComment,
-  UIDraft,
-  UIHuman,
-  UIRobot,
   createCommentThreads,
   isInPatchRange,
   isDraftThread,
   isInBaseOfPatchRange,
   isInRevisionOfPatchRange,
   isPatchsetLevel,
+  addPath,
 } from '../../../utils/comment-util';
 import {PatchSetFile, PatchNumOnly, isPatchSetFile} from '../../../types/types';
 import {appContext} from '../../../services/app-context';
@@ -58,11 +55,11 @@
 };
 
 export class ChangeComments {
-  private readonly _comments: {[path: string]: UIHuman[]};
+  private readonly _comments: PathToCommentsInfoMap;
 
-  private readonly _robotComments: {[path: string]: UIRobot[]};
+  private readonly _robotComments: {[path: string]: RobotCommentInfo[]};
 
-  private readonly _drafts: {[path: string]: UIDraft[]};
+  private readonly _drafts: {[path: string]: DraftInfo[]};
 
   private readonly _portedComments: PathToCommentsInfoMap;
 
@@ -73,48 +70,30 @@
    * elements of that which uses the gr-comment-api.
    */
   constructor(
-    comments: {[path: string]: UIHuman[]} | undefined,
-    robotComments: {[path: string]: UIRobot[]} | undefined,
-    drafts: {[path: string]: UIDraft[]} | undefined,
+    comments: PathToCommentsInfoMap | undefined,
+    robotComments: {[path: string]: RobotCommentInfo[]} | undefined,
+    drafts: {[path: string]: DraftInfo[]} | undefined,
     portedComments: PathToCommentsInfoMap | undefined,
     portedDrafts: PathToCommentsInfoMap | undefined
   ) {
-    this._comments = this._addPath(comments);
-    this._robotComments = this._addPath(robotComments);
-    this._drafts = this._addPath(drafts);
+    this._comments = addPath(comments);
+    this._robotComments = addPath(robotComments);
+    this._drafts = addPath(drafts);
     this._portedComments = portedComments || {};
     this._portedDrafts = portedDrafts || {};
   }
 
-  /**
-   * Add path info to every comment as CommentInfo returned
-   * from server does not have that.
-   *
-   * TODO(taoalpha): should consider changing BE to send path
-   * back within CommentInfo
-   */
-  _addPath<T>(
-    comments: {[path: string]: T[]} = {}
-  ): {[path: string]: Array<T & {path: string}>} {
-    const updatedComments: {[path: string]: Array<T & {path: string}>} = {};
-    for (const filePath of Object.keys(comments)) {
-      const allCommentsForPath = comments[filePath] || [];
-      if (allCommentsForPath.length) {
-        updatedComments[filePath] = allCommentsForPath.map(comment => {
-          return {...comment, path: filePath};
-        });
-      }
-    }
-    return updatedComments;
-  }
-
   get drafts() {
     return this._drafts;
   }
 
-  findCommentById(commentId?: UrlEncodedCommentId): UIComment | undefined {
+  findCommentById(
+    commentId?: UrlEncodedCommentId
+  ): CommentInfo | DraftInfo | undefined {
     if (!commentId) return undefined;
-    const findComment = (comments: {[path: string]: UIComment[]}) => {
+    const findComment = (comments: {
+      [path: string]: (CommentInfo | DraftInfo)[];
+    }) => {
       let comment;
       for (const path of Object.keys(comments)) {
         comment = comment || comments[path].find(c => c.id === commentId);
@@ -199,7 +178,7 @@
    */
   getAllDrafts(patchNum?: PatchSetNum) {
     const paths = this.getPaths();
-    const drafts: {[path: string]: UIDraft[]} = {};
+    const drafts: {[path: string]: DraftInfo[]} = {};
     for (const path of Object.keys(paths)) {
       drafts[path] = this.getAllDraftsForPath(path, patchNum);
     }
@@ -254,7 +233,7 @@
     return allComments;
   }
 
-  cloneWithUpdatedDrafts(drafts: {[path: string]: UIDraft[]} | undefined) {
+  cloneWithUpdatedDrafts(drafts: {[path: string]: DraftInfo[]} | undefined) {
     return new ChangeComments(
       this._comments,
       this._robotComments,
@@ -625,12 +604,6 @@
   }
 }
 
-// TODO(TS): move findCommentById out of class
-export const _testOnly_findCommentById =
-  ChangeComments.prototype.findCommentById;
-
-export const _testOnly_getCommentsForPath =
-  ChangeComments.prototype.getCommentsForPath;
 @customElement('gr-comment-api')
 export class GrCommentApi extends PolymerElement {
   static get template() {
@@ -642,56 +615,11 @@
 
   private readonly restApiService = appContext.restApiService;
 
-  /**
-   * Load all comments (with drafts and robot comments) for the given change
-   * number. The returned promise resolves when the comments have loaded, but
-   * does not yield the comment data.
-   */
-  loadAll(changeNum: NumericChangeId, patchNum?: PatchSetNum) {
-    const revision = patchNum || CURRENT;
-    const commentsPromise = [
-      this.restApiService.getDiffComments(changeNum),
-      this.restApiService.getDiffRobotComments(changeNum),
-      this.restApiService.getDiffDrafts(changeNum),
-      this.restApiService.getPortedComments(changeNum, revision),
-      this.restApiService.getPortedDrafts(changeNum, revision),
-    ];
-
-    return Promise.all(commentsPromise).then(
-      ([comments, robotComments, drafts, portedComments, portedDrafts]) => {
-        this._changeComments = new ChangeComments(
-          comments,
-          // TS 4.0.5 fails without 'as'
-          robotComments as PathToRobotCommentsInfoMap | undefined,
-          drafts,
-          portedComments,
-          portedDrafts
-        );
-        return this._changeComments;
-      }
-    );
-  }
-
-  /**
-   * Re-initialize _changeComments with a new ChangeComments object, that
-   * uses the previous values for comments and robot comments, but fetches
-   * updated draft comments.
-   */
-  reloadDrafts(changeNum: NumericChangeId) {
-    if (!this._changeComments) {
-      return this.loadAll(changeNum);
-    }
-    return this.restApiService.getDiffDrafts(changeNum).then(drafts => {
-      this._changeComments = this._changeComments!.cloneWithUpdatedDrafts(
-        drafts
-      );
-      return this._changeComments;
-    });
-  }
+  private readonly commentsService = appContext.commentsService;
 
   reloadPortedComments(changeNum: NumericChangeId, patchNum: PatchSetNum) {
     if (!this._changeComments) {
-      this.loadAll(changeNum);
+      this.commentsService.loadAll(changeNum);
       return Promise.resolve();
     }
     return Promise.all([
diff --git a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_test.js b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_test.js
index fbbe1fb..7e01371 100644
--- a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_test.js
@@ -34,107 +34,11 @@
     element = basicFixture.instantiate();
   });
 
-  test('loads logged-out', () => {
-    const changeNum = 1234;
-
-    stubRestApi('getLoggedIn').returns(Promise.resolve(false));
-    const diffCommentsSpy = stubRestApi('getDiffComments').returns(
-        Promise.resolve({
-          'foo.c': [{id: '123', message: 'foo bar', in_reply_to: '321'}],
-        }));
-    const diffRobotCommentsSpy = stubRestApi('getDiffRobotComments').returns(
-        Promise.resolve({'foo.c': [{id: '321', message: 'done'}]}));
-    const diffDraftsSpy = stubRestApi('getDiffDrafts').returns(
-        Promise.resolve({}));
-
-    return element.loadAll(changeNum).then(() => {
-      assert.isTrue(diffCommentsSpy.calledWithExactly(changeNum));
-      assert.isTrue(diffRobotCommentsSpy.calledWithExactly(changeNum));
-      assert.isTrue(diffDraftsSpy.calledWithExactly(changeNum));
-      assert.isOk(element._changeComments._comments);
-      assert.isOk(element._changeComments._robotComments);
-      assert.deepEqual(element._changeComments._drafts, {});
-    });
-  });
-
-  test('loads logged-in', () => {
-    const changeNum = 1234;
-
-    const getCommentsStub = stubRestApi('getDiffComments').returns(
-        Promise.resolve({
-          'foo.c': [{id: '123', message: 'foo bar', in_reply_to: '321'}],
-        })
-    );
-    const getRobotCommentsStub = stubRestApi('getDiffRobotComments')
-        .returns(Promise.resolve({'foo.c': [{id: '321', message: 'done'}]}));
-    const getDraftsStub = stubRestApi('getDiffDrafts')
-        .returns(Promise.resolve({'foo.c': [{id: '555', message: 'ack'}]}));
-
-    return element.loadAll(changeNum).then(() => {
-      assert.isTrue(getCommentsStub.calledWithExactly(changeNum));
-      assert.isTrue(getRobotCommentsStub.calledWithExactly(changeNum));
-      assert.isTrue(getDraftsStub.calledWithExactly(changeNum));
-      assert.isOk(element._changeComments._comments);
-      assert.isOk(element._changeComments._robotComments);
-      assert.notDeepEqual(element._changeComments._drafts, {});
-    });
-  });
-
-  suite('reloadDrafts', () => {
-    let commentStub;
-    let robotCommentStub;
-    let draftStub;
-    setup(() => {
-      commentStub = stubRestApi('getDiffComments')
-          .returns(Promise.resolve({}));
-      robotCommentStub = stubRestApi(
-          'getDiffRobotComments').returns(Promise.resolve({}));
-      draftStub = stubRestApi('getDiffDrafts')
-          .returns(Promise.resolve({}));
-    });
-
-    test('without loadAll first', done => {
-      assert.isNotOk(element._changeComments);
-      sinon.spy(element, 'loadAll');
-      element.reloadDrafts().then(() => {
-        assert.isTrue(element.loadAll.called);
-        assert.isOk(element._changeComments);
-        assert.equal(commentStub.callCount, 1);
-        assert.equal(robotCommentStub.callCount, 1);
-        assert.equal(draftStub.callCount, 1);
-        done();
-      });
-    });
-
-    test('with loadAll first', done => {
-      assert.isNotOk(element._changeComments);
-      element.loadAll()
-          .then(() => {
-            assert.isOk(element._changeComments);
-            assert.equal(commentStub.callCount, 1);
-            assert.equal(robotCommentStub.callCount, 1);
-            assert.equal(draftStub.callCount, 1);
-            return element.reloadDrafts();
-          })
-          .then(() => {
-            assert.isOk(element._changeComments);
-            assert.equal(commentStub.callCount, 1);
-            assert.equal(robotCommentStub.callCount, 1);
-            assert.equal(draftStub.callCount, 2);
-            done();
-          });
-    });
-  });
-
   suite('_changeComment methods', () => {
-    setup(done => {
-      const changeNum = 1234;
+    setup(() => {
       stubRestApi('getDiffComments').returns(Promise.resolve({}));
       stubRestApi('getDiffRobotComments').returns(Promise.resolve({}));
       stubRestApi('getDiffDrafts').returns(Promise.resolve({}));
-      element.loadAll(changeNum).then(() => {
-        done();
-      });
     });
 
     suite('ported comments', () => {
diff --git a/polygerrit-ui/app/elements/diff/gr-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-builder/gr-diff-builder-binary.ts b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-binary.ts
index 763a524..51fc207 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-binary.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-binary.ts
@@ -18,6 +18,8 @@
 import {GrDiffBuilderUnified} from './gr-diff-builder-unified';
 import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
 import {GrDiffLine, GrDiffLineType} from '../gr-diff/gr-diff-line';
+import {queryAndAssert} from '../../../utils/common-util';
+import {RenderPreferences} from '../../../api/diff';
 
 export class GrDiffBuilderBinary extends GrDiffBuilderUnified {
   constructor(
@@ -32,9 +34,12 @@
     const section = this._createElement('tbody', 'binary-diff');
     const line = new GrDiffLine(GrDiffLineType.BOTH, 'FILE', 'FILE');
     const fileRow = this._createRow(line);
-    const contentTd = fileRow.querySelector('td.both.file')!;
+    const contentTd = queryAndAssert(fileRow, 'td.both.file')!;
     contentTd.textContent = ' Difference in binary files';
     section.appendChild(fileRow);
     return section;
   }
+
+  /** @override */
+  updateRenderPrefs(_renderPrefs: RenderPreferences) {}
 }
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-element.ts b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-element.ts
index d65deb6..733c940 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-element.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-element.ts
@@ -522,6 +522,11 @@
     if (!this._builder) return;
     this._builder.setBlame(blame);
   }
+
+  updateRenderPrefs(renderPrefs: RenderPreferences) {
+    if (!this._builder) return;
+    this._builder.updateRenderPrefs(renderPrefs);
+  }
 }
 
 declare global {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-image.ts b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-image.ts
index 650a9e7..1243e8f 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-image.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-image.ts
@@ -96,6 +96,8 @@
 
     imageViewer.baseUrl = this._getImageSrc(this._baseImage);
     imageViewer.revisionUrl = this._getImageSrc(this._revisionImage);
+    imageViewer.automaticBlink = !!this._renderPrefs?.image_diff_prefs
+      ?.automatic_blink;
 
     td.appendChild(imageViewer);
     tr.appendChild(td);
@@ -213,6 +215,17 @@
 
     section.appendChild(tr);
   }
+
+  /** @override */
+  updateRenderPrefs(renderPrefs: RenderPreferences) {
+    const imageViewer = this._outputEl.querySelector(
+      'gr-image-viewer'
+    ) as GrImageViewer;
+    if (this._useNewImageDiffUi && imageViewer) {
+      imageViewer.automaticBlink = !!renderPrefs?.image_diff_prefs
+        ?.automatic_blink;
+    }
+  }
 }
 
 function _getImageLabel(image: ImageInfo | null) {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-side-by-side.ts b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-side-by-side.ts
index f44e006..da1d971 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-side-by-side.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-side-by-side.ts
@@ -137,4 +137,7 @@
     }
     return null;
   }
+
+  /** @override */
+  updateRenderPrefs(_renderPrefs: RenderPreferences) {}
 }
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified.ts b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified.ts
index 2067455..4ecfcbf 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified.ts
@@ -146,4 +146,7 @@
     }
     return null;
   }
+
+  /** @override */
+  updateRenderPrefs(_renderPrefs: RenderPreferences) {}
 }
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.ts b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.ts
index 424df2f..5634238 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.ts
@@ -17,11 +17,11 @@
 import {
   ContentLoadNeededEventDetail,
   DiffContextExpandedExternalDetail,
-  LineNumberEventDetail,
   MovedLinkClickedEventDetail,
   RenderPreferences,
 } from '../../../api/diff';
 import {getBaseUrl} from '../../../utils/url-util';
+import {fire} from '../../../utils/event-util';
 import {GrDiffLine, GrDiffLineType, LineNumber} from '../gr-diff/gr-diff-line';
 import {GrDiffGroup, GrDiffGroupType} from '../gr-diff/gr-diff-group';
 
@@ -78,7 +78,7 @@
 
   private readonly _prefs: DiffPreferencesInfo;
 
-  private readonly _renderPrefs?: RenderPreferences;
+  protected readonly _renderPrefs?: RenderPreferences;
 
   protected readonly _outputEl: HTMLElement;
 
@@ -315,7 +315,9 @@
     const firstGroupIsSkipped = !!contextGroups[0].skip;
     const lastGroupIsSkipped = !!contextGroups[contextGroups.length - 1].skip;
 
-    const showAbove = leftStart > 1 && !firstGroupIsSkipped;
+    const containsWholeFile = this._numLinesLeft === leftEnd - leftStart + 1;
+    const showAbove =
+      (leftStart > 1 && !firstGroupIsSkipped) || containsWholeFile;
     const showBelow = leftEnd < this._numLinesLeft && !lastGroupIsSkipped;
 
     if (showAbove) {
@@ -459,35 +461,20 @@
           button.setAttribute('aria-label', `${number} added`);
         }
       }
-      button.addEventListener('mouseenter', () => {
-        button.dispatchEvent(
-          new CustomEvent<LineNumberEventDetail>('line-number-mouse-enter', {
-            detail: {
-              lineNum: number,
-              side,
-            },
-            composed: true,
-            bubbles: true,
-          })
-        );
-      });
-      button.addEventListener('mouseleave', () => {
-        button.dispatchEvent(
-          new CustomEvent<LineNumberEventDetail>('line-number-mouse-leave', {
-            detail: {
-              lineNum: number,
-              side,
-            },
-            composed: true,
-            bubbles: true,
-          })
-        );
-      });
+      this._addLineNumberMouseEvents(td, number, side);
     }
-
     return td;
   }
 
+  _addLineNumberMouseEvents(el: HTMLElement, number: LineNumber, side: Side) {
+    el.addEventListener('mouseenter', () => {
+      fire(el, 'line-mouse-enter', {lineNum: number, side});
+    });
+    el.addEventListener('mouseleave', () => {
+      fire(el, 'line-mouse-leave', {lineNum: number, side});
+    });
+  }
+
   _createTextEl(
     lineNumberEl: HTMLElement | null,
     line: GrDiffLine,
@@ -505,7 +492,8 @@
     }
     td.classList.add(line.type);
 
-    if (line.beforeNumber !== 'FILE' && line.beforeNumber !== 'LOST') {
+    const {beforeNumber, afterNumber} = line;
+    if (beforeNumber !== 'FILE' && beforeNumber !== 'LOST') {
       const lineLimit = !this._prefs.line_wrapping
         ? this._prefs.line_length
         : Infinity;
@@ -517,6 +505,8 @@
 
       if (side) {
         contentText.setAttribute('data-side', side);
+        const number = side === Side.LEFT ? beforeNumber : afterNumber;
+        this._addLineNumberMouseEvents(td, number, side);
       }
 
       if (lineNumberEl && side) {
@@ -893,4 +883,6 @@
     while (row && !row.classList.contains('diff-row')) row = row.parentElement;
     return row ? (row.querySelector('.lineNum.' + side) as HTMLElement) : null;
   }
+
+  updateRenderPrefs(_renderPrefs: RenderPreferences) {}
 }
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 eabc8c7..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,54 +15,89 @@
  * limitations under the License.
  */
 
-import '../../shared/gr-cursor-manager/gr-cursor-manager';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom';
+import {Subscription} from 'rxjs';
+import {AbortStop, CursorMoveResult, Stop} from '../../../api/core';
 import {
-  AbortStop,
-  CursorMoveResult,
+  DiffViewMode,
+  GrDiffCursor as GrDiffCursorApi,
+  LineNumberEventDetail,
+} from '../../../api/diff';
+import {ScrollMode, Side} from '../../../constants/constants';
+import {PolymerDomWrapper} from '../../../types/types';
+import {toggleClass} from '../../../utils/dom-util';
+import {
   GrCursorManager,
-  Stop,
   isTargetable,
 } from '../../shared/gr-cursor-manager/gr-cursor-manager';
-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 {htmlTemplate} from './gr-diff-cursor_html';
-import {DiffViewMode, LineNumberEventDetail} from '../../../api/diff';
-import {ScrollMode, Side} from '../../../constants/constants';
-import {customElement, property, observe} from '@polymer/decorators';
 import {GrDiffLineType} from '../gr-diff/gr-diff-line';
-import {PolymerSpliceChange} from '@polymer/polymer/interfaces';
-import {PolymerDomWrapper} from '../../../types/types';
 import {GrDiffGroupType} from '../gr-diff/gr-diff-group';
 import {GrDiff} from '../gr-diff/gr-diff';
-import {Subscription} from 'rxjs';
-import {toggleClass} from '../../../utils/dom-util';
 
 type GrDiffRowType = GrDiffLineType | GrDiffGroupType;
 
 const LEFT_SIDE_CLASS = 'target-side-left';
 const RIGHT_SIDE_CLASS = 'target-side-right';
 
-export interface GrDiffCursor {
-  $: {};
+/** A subset of the GrDiff API that the cursor is using. */
+export interface GrDiffCursorable extends HTMLElement {
+  isRangeSelected(): boolean;
+  createRangeComment(): void;
+  getCursorStops(): Stop[];
+  path?: string;
 }
 
-@customElement('gr-diff-cursor')
-export class GrDiffCursor extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrDiffCursor implements GrDiffCursorApi {
   private preventAutoScrollOnManualScroll = false;
 
-  @property({type: String, notify: true, observer: '_updateSide'})
-  side = Side.RIGHT;
+  set side(side: Side) {
+    if (this.sideInternal === side) {
+      return;
+    }
+    if (this.sideInternal && this.diffRow) {
+      this.fireCursorMoved(
+        'line-cursor-moved-out',
+        this.diffRow,
+        this.sideInternal
+      );
+    }
+    this.sideInternal = side;
+    this.updateSideClass();
+    if (this.diffRow) {
+      this.fireCursorMoved('line-cursor-moved-in', this.diffRow, this.side);
+    }
+  }
 
-  @property({type: Object, notify: true, observer: '_rowChanged'})
-  diffRow?: HTMLElement;
+  get side(): Side {
+    return this.sideInternal;
+  }
 
-  @property({type: Object})
-  diffs: GrDiff[] = [];
+  private sideInternal = Side.RIGHT;
+
+  set diffRow(diffRow: HTMLElement | undefined) {
+    if (this.diffRowInternal) {
+      this.diffRowInternal.classList.remove(LEFT_SIDE_CLASS, RIGHT_SIDE_CLASS);
+      this.fireCursorMoved(
+        'line-cursor-moved-out',
+        this.diffRowInternal,
+        this.side
+      );
+    }
+    this.diffRowInternal = diffRow;
+
+    this.updateSideClass();
+    if (this.diffRow) {
+      this.fireCursorMoved('line-cursor-moved-in', this.diffRow, this.side);
+    }
+  }
+
+  get diffRow(): HTMLElement | undefined {
+    return this.diffRowInternal;
+  }
+
+  private diffRowInternal?: HTMLElement;
+
+  private diffs: GrDiffCursorable[] = [];
 
   /**
    * If set, the cursor will attempt to move to the line number (instead of
@@ -72,62 +107,27 @@
    * to that position. This parameter should be set at most for one gr-diff
    * element in the page.
    */
-  @property({type: Number})
   initialLineNumber: number | null = null;
 
-  @property({type: Boolean})
-  _listeningForScroll = false;
-
   private cursorManager = new GrCursorManager();
 
+  private targetSubscription?: Subscription;
+
   constructor() {
-    super();
     this.cursorManager.cursorTargetClass = 'target-row';
     this.cursorManager.scrollMode = ScrollMode.KEEP_VISIBLE;
     this.cursorManager.focusOnMove = true;
-  }
 
-  /** @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,
-        })
-      );
-    });
-  }
-
-  private targetSubscription?: Subscription;
-
-  /** @override */
-  connectedCallback() {
-    super.connectedCallback();
-    // Catch when users are scrolling as the view loads.
     window.addEventListener('scroll', this._boundHandleWindowScroll);
     this.targetSubscription = this.cursorManager.target$.subscribe(target => {
       this.diffRow = target || undefined;
     });
   }
 
-  /** @override */
-  disconnectedCallback() {
+  dispose() {
     if (this.targetSubscription) this.targetSubscription.unsubscribe();
     window.removeEventListener('scroll', this._boundHandleWindowScroll);
     this.cursorManager.unsetCursor();
-    super.disconnectedCallback();
   }
 
   // Don't remove - used by clients embedding gr-diff outside of Gerrit.
@@ -297,10 +297,10 @@
         this.moveToFirstChunk();
       }
     }
-    this.reInit();
+    this.resetScrollMode();
   }
 
-  reInit() {
+  resetScrollMode() {
     this.cursorManager.scrollMode = ScrollMode.KEEP_VISIBLE;
   }
 
@@ -313,7 +313,7 @@
   };
 
   reInitAndUpdateStops() {
-    this.reInit();
+    this.resetScrollMode();
     this._updateStops();
   }
 
@@ -366,7 +366,6 @@
    * {leftSide: false, number: 123} for line 123 of the revision, or
    * {leftSide: true, number: 321} for line 321 of the base patch.
    * Returns null if an address is not available.
-   *
    */
   getAddress() {
     if (!this.diffRow) {
@@ -459,16 +458,6 @@
     );
   }
 
-  _rowChanged(_: HTMLElement, oldRow: HTMLElement) {
-    if (oldRow) {
-      this.fireCursorMoved('line-cursor-moved-out', oldRow, this.side);
-    }
-    this.updateSideClass();
-    if (this.diffRow) {
-      this.fireCursorMoved('line-cursor-moved-in', this.diffRow, this.side);
-    }
-  }
-
   private fireCursorMoved(
     event: 'line-cursor-moved-out' | 'line-cursor-moved-in',
     row: HTMLElement,
@@ -498,19 +487,6 @@
     toggleClass(this.diffRow, RIGHT_SIDE_CLASS, this.side === Side.RIGHT);
   }
 
-  _updateSide(_: Side, oldSide: Side) {
-    if (!this.diffRow) {
-      return;
-    }
-    if (oldSide) {
-      this.fireCursorMoved('line-cursor-moved-out', this.diffRow, oldSide);
-    }
-    this.updateSideClass();
-    if (this.diffRow) {
-      this.fireCursorMoved('line-cursor-moved-in', this.diffRow, this.side);
-    }
-  }
-
   _isActionType(type: GrDiffRowType) {
     return (
       type !== GrDiffLineType.BLANK && type !== GrDiffGroupType.CONTEXT_CONTROL
@@ -537,69 +513,53 @@
     );
   }
 
-  /**
-   * Setup and tear down on-render listeners for any diffs that are added or
-   * removed from the cursor.
-   */
-  @observe('diffs.splices')
-  _diffsChanged(changeRecord: PolymerSpliceChange<GrDiff[]>) {
-    if (!changeRecord) {
-      return;
+  replaceDiffs(diffs: GrDiffCursorable[]) {
+    for (const diff of this.diffs) {
+      this.removeEventListeners(diff);
     }
-
+    this.diffs = [];
+    for (const diff of diffs) {
+      this.addEventListeners(diff);
+    }
+    this.diffs.push(...diffs);
     this._updateStops();
+  }
 
-    let splice;
-    let i;
-    for (
-      let spliceIdx = 0;
-      changeRecord.indexSplices && spliceIdx < changeRecord.indexSplices.length;
-      spliceIdx++
-    ) {
-      splice = changeRecord.indexSplices[spliceIdx];
-
-      // Removals must come before additions, because the gr-diff instances
-      // might be the same.
-      for (i = 0; i < splice?.removed.length; i++) {
-        splice.removed[i].removeEventListener(
-          'loading-changed',
-          this.boundHandleDiffLoadingChanged
-        );
-        splice.removed[i].removeEventListener(
-          'render-start',
-          this._boundHandleDiffRenderStart
-        );
-        splice.removed[i].removeEventListener(
-          'render-content',
-          this._boundHandleDiffRenderContent
-        );
-        splice.removed[i].removeEventListener(
-          'line-selected',
-          this._boundHandleDiffLineSelected
-        );
-      }
-
-      for (i = splice.index; i < splice.index + splice.addedCount; i++) {
-        this.diffs[i].addEventListener(
-          'loading-changed',
-          this.boundHandleDiffLoadingChanged
-        );
-        this.diffs[i].addEventListener(
-          'render-start',
-          this._boundHandleDiffRenderStart
-        );
-        this.diffs[i].addEventListener(
-          'render-content',
-          this._boundHandleDiffRenderContent
-        );
-        this.diffs[i].addEventListener(
-          'line-selected',
-          this._boundHandleDiffLineSelected
-        );
-      }
+  unregisterDiff(diff: GrDiffCursorable) {
+    // This can happen during destruction - just don't unregister then.
+    if (!this.diffs) return;
+    const i = this.diffs.indexOf(diff);
+    if (i !== -1) {
+      this.diffs.splice(i, 1);
     }
   }
 
+  private removeEventListeners(diff: GrDiffCursorable) {
+    diff.removeEventListener(
+      'loading-changed',
+      this.boundHandleDiffLoadingChanged
+    );
+    diff.removeEventListener('render-start', this._boundHandleDiffRenderStart);
+    diff.removeEventListener(
+      'render-content',
+      this._boundHandleDiffRenderContent
+    );
+    diff.removeEventListener(
+      'line-selected',
+      this._boundHandleDiffLineSelected
+    );
+  }
+
+  private addEventListeners(diff: GrDiffCursorable) {
+    diff.addEventListener(
+      'loading-changed',
+      this.boundHandleDiffLoadingChanged
+    );
+    diff.addEventListener('render-start', this._boundHandleDiffRenderStart);
+    diff.addEventListener('render-content', this._boundHandleDiffRenderContent);
+    diff.addEventListener('line-selected', this._boundHandleDiffLineSelected);
+  }
+
   _findRowByNumberAndFile(
     targetNumber: number,
     side: Side,
@@ -619,9 +579,3 @@
     return targetableStops.find(stop => stop.querySelector(selector));
   }
 }
-
-declare global {
-  interface HTMLElementTagNameMap {
-    'gr-diff-cursor': GrDiffCursor;
-  }
-}
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor_html.ts b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor_html.ts
deleted file mode 100644
index 1489006..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor_html.ts
+++ /dev/null
@@ -1,19 +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``;
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor_test.js b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor_test.js
index 7b72da8..3e63b0a 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor_test.js
@@ -22,26 +22,23 @@
 import {listenOnce} from '../../../test/test-utils.js';
 import {getMockDiffResponse} from '../../../test/mocks/diff-response.js';
 import {createDefaultDiffPrefs} from '../../../constants/constants.js';
+import {GrDiffCursor} from './gr-diff-cursor.js';
 
 const basicFixture = fixtureFromTemplate(html`
   <gr-diff></gr-diff>
-  <gr-diff-cursor></gr-diff-cursor>
 `);
 
-const emptyFixture = fixtureFromElement('div');
-
 suite('gr-diff-cursor tests', () => {
-  let cursorElement;
+  let cursor;
   let diffElement;
   let diff;
 
   setup(done => {
-    const fixtureElems = basicFixture.instantiate();
-    diffElement = fixtureElems[0];
-    cursorElement = fixtureElems[1];
+    diffElement = basicFixture.instantiate();
+    cursor = new GrDiffCursor();
 
     // Register the diff with the cursor.
-    cursorElement.push('diffs', diffElement);
+    cursor.replaceDiffs([diffElement]);
 
     diffElement.loggedIn = false;
     diffElement.patchRange = {basePatchNum: 1, patchNum: 2};
@@ -52,8 +49,8 @@
     };
     diffElement.path = 'some/path.ts';
     const setupDone = () => {
-      cursorElement._updateStops();
-      cursorElement.moveToFirstChunk();
+      cursor._updateStops();
+      cursor.moveToFirstChunk();
       diffElement.removeEventListener('render', setupDone);
       done();
     };
@@ -66,21 +63,21 @@
 
   test('diff cursor functionality (side-by-side)', () => {
     // The cursor has been initialized to the first delta.
-    assert.isOk(cursorElement.diffRow);
+    assert.isOk(cursor.diffRow);
 
     const firstDeltaRow = diffElement.shadowRoot
         .querySelector('.section.delta .diff-row');
-    assert.equal(cursorElement.diffRow, firstDeltaRow);
+    assert.equal(cursor.diffRow, firstDeltaRow);
 
-    cursorElement.moveDown();
+    cursor.moveDown();
 
-    assert.notEqual(cursorElement.diffRow, firstDeltaRow);
-    assert.equal(cursorElement.diffRow, firstDeltaRow.nextSibling);
+    assert.notEqual(cursor.diffRow, firstDeltaRow);
+    assert.equal(cursor.diffRow, firstDeltaRow.nextSibling);
 
-    cursorElement.moveUp();
+    cursor.moveUp();
 
-    assert.notEqual(cursorElement.diffRow, firstDeltaRow.nextSibling);
-    assert.equal(cursorElement.diffRow, firstDeltaRow);
+    assert.notEqual(cursor.diffRow, firstDeltaRow.nextSibling);
+    assert.equal(cursor.diffRow, firstDeltaRow);
   });
 
   test('moveToFirstChunk', async () => {
@@ -116,24 +113,24 @@
     // moveToFirstChunk() works correctly even if the button is not shown.
     diffElement.prefs.show_file_comment_button = false;
     await flush();
-    cursorElement._updateStops();
+    cursor._updateStops();
 
     const chunks = Array.from(diffElement.root.querySelectorAll(
         '.section.delta'));
     assert.equal(chunks.length, 2);
 
     // Verify it works on fresh diff.
-    cursorElement.moveToFirstChunk();
-    assert.equal(chunks.indexOf(cursorElement.diffRow.parentElement), 0);
-    assert.equal(cursorElement.side, 'right');
+    cursor.moveToFirstChunk();
+    assert.equal(chunks.indexOf(cursor.diffRow.parentElement), 0);
+    assert.equal(cursor.side, 'right');
 
     // Verify it works from other cursor positions.
-    cursorElement.moveToNextChunk();
-    assert.equal(chunks.indexOf(cursorElement.diffRow.parentElement), 1);
-    assert.equal(cursorElement.side, 'left');
-    cursorElement.moveToFirstChunk();
-    assert.equal(chunks.indexOf(cursorElement.diffRow.parentElement), 0);
-    assert.equal(cursorElement.side, 'right');
+    cursor.moveToNextChunk();
+    assert.equal(chunks.indexOf(cursor.diffRow.parentElement), 1);
+    assert.equal(cursor.side, 'left');
+    cursor.moveToFirstChunk();
+    assert.equal(chunks.indexOf(cursor.diffRow.parentElement), 0);
+    assert.equal(cursor.side, 'right');
   });
 
   test('moveToLastChunk', async () => {
@@ -166,45 +163,45 @@
 
     diffElement.diff = diff;
     await flush();
-    cursorElement._updateStops();
+    cursor._updateStops();
 
     const chunks = Array.from(diffElement.root.querySelectorAll(
         '.section.delta'));
     assert.equal(chunks.length, 2);
 
     // Verify it works on fresh diff.
-    cursorElement.moveToLastChunk();
-    assert.equal(chunks.indexOf(cursorElement.diffRow.parentElement), 1);
-    assert.equal(cursorElement.side, 'right');
+    cursor.moveToLastChunk();
+    assert.equal(chunks.indexOf(cursor.diffRow.parentElement), 1);
+    assert.equal(cursor.side, 'right');
 
     // Verify it works from other cursor positions.
-    cursorElement.moveToPreviousChunk();
-    assert.equal(chunks.indexOf(cursorElement.diffRow.parentElement), 0);
-    assert.equal(cursorElement.side, 'left');
-    cursorElement.moveToLastChunk();
-    assert.equal(chunks.indexOf(cursorElement.diffRow.parentElement), 1);
-    assert.equal(cursorElement.side, 'right');
+    cursor.moveToPreviousChunk();
+    assert.equal(chunks.indexOf(cursor.diffRow.parentElement), 0);
+    assert.equal(cursor.side, 'left');
+    cursor.moveToLastChunk();
+    assert.equal(chunks.indexOf(cursor.diffRow.parentElement), 1);
+    assert.equal(cursor.side, 'right');
   });
 
   test('cursor scroll behavior', () => {
-    assert.equal(cursorElement.cursorManager.scrollMode, 'keep-visible');
+    assert.equal(cursor.cursorManager.scrollMode, 'keep-visible');
 
     diffElement.dispatchEvent(new Event('render-start'));
-    assert.isTrue(cursorElement.cursorManager.focusOnMove);
+    assert.isTrue(cursor.cursorManager.focusOnMove);
 
     window.dispatchEvent(new Event('scroll'));
-    assert.equal(cursorElement.cursorManager.scrollMode, 'never');
-    assert.isFalse(cursorElement.cursorManager.focusOnMove);
+    assert.equal(cursor.cursorManager.scrollMode, 'never');
+    assert.isFalse(cursor.cursorManager.focusOnMove);
 
     diffElement.dispatchEvent(new Event('render-content'));
-    assert.isTrue(cursorElement.cursorManager.focusOnMove);
+    assert.isTrue(cursor.cursorManager.focusOnMove);
 
-    cursorElement.reInitCursor();
-    assert.equal(cursorElement.cursorManager.scrollMode, 'keep-visible');
+    cursor.reInitCursor();
+    assert.equal(cursor.cursorManager.scrollMode, 'keep-visible');
   });
 
   test('moves to selected line', () => {
-    const moveToNumStub = sinon.stub(cursorElement, 'moveToLineNumber');
+    const moveToNumStub = sinon.stub(cursor, 'moveToLineNumber');
 
     diffElement.dispatchEvent(
         new CustomEvent('line-selected', {
@@ -222,7 +219,7 @@
       // We must allow the diff to re-render after setting the viewMode.
       const renderHandler = function() {
         diffElement.removeEventListener('render', renderHandler);
-        cursorElement.reInitCursor();
+        cursor.reInitCursor();
         done();
       };
       diffElement.addEventListener('render', renderHandler);
@@ -231,25 +228,25 @@
 
     test('diff cursor functionality (unified)', () => {
       // The cursor has been initialized to the first delta.
-      assert.isOk(cursorElement.diffRow);
+      assert.isOk(cursor.diffRow);
 
       let firstDeltaRow = diffElement.shadowRoot
           .querySelector('.section.delta .diff-row');
-      assert.equal(cursorElement.diffRow, firstDeltaRow);
+      assert.equal(cursor.diffRow, firstDeltaRow);
 
       firstDeltaRow = diffElement.shadowRoot
           .querySelector('.section.delta .diff-row');
-      assert.equal(cursorElement.diffRow, firstDeltaRow);
+      assert.equal(cursor.diffRow, firstDeltaRow);
 
-      cursorElement.moveDown();
+      cursor.moveDown();
 
-      assert.notEqual(cursorElement.diffRow, firstDeltaRow);
-      assert.equal(cursorElement.diffRow, firstDeltaRow.nextSibling);
+      assert.notEqual(cursor.diffRow, firstDeltaRow);
+      assert.equal(cursor.diffRow, firstDeltaRow.nextSibling);
 
-      cursorElement.moveUp();
+      cursor.moveUp();
 
-      assert.notEqual(cursorElement.diffRow, firstDeltaRow.nextSibling);
-      assert.equal(cursorElement.diffRow, firstDeltaRow);
+      assert.notEqual(cursor.diffRow, firstDeltaRow.nextSibling);
+      assert.equal(cursor.diffRow, firstDeltaRow);
     });
   });
 
@@ -264,29 +261,29 @@
 
     // Because the first delta in this diff is on the right, it should be set
     // to the right side.
-    assert.equal(cursorElement.side, 'right');
-    assert.equal(cursorElement.diffRow, firstDeltaRow);
-    const firstIndex = cursorElement.cursorManager.index;
+    assert.equal(cursor.side, 'right');
+    assert.equal(cursor.diffRow, firstDeltaRow);
+    const firstIndex = cursor.cursorManager.index;
 
     // Move the side to the left. Because this delta only has a right side, we
     // should be moved up to the previous line where there is content on the
     // right. The previous row is part of the previous section.
-    cursorElement.moveLeft();
+    cursor.moveLeft();
 
-    assert.equal(cursorElement.side, 'left');
-    assert.notEqual(cursorElement.diffRow, firstDeltaRow);
-    assert.equal(cursorElement.cursorManager.index, firstIndex - 1);
-    assert.equal(cursorElement.diffRow.parentElement,
+    assert.equal(cursor.side, 'left');
+    assert.notEqual(cursor.diffRow, firstDeltaRow);
+    assert.equal(cursor.cursorManager.index, firstIndex - 1);
+    assert.equal(cursor.diffRow.parentElement,
         firstDeltaSection.previousSibling);
 
     // If we move down, we should skip everything in the first delta because
     // we are on the left side and the first delta has no content on the left.
-    cursorElement.moveDown();
+    cursor.moveDown();
 
-    assert.equal(cursorElement.side, 'left');
-    assert.notEqual(cursorElement.diffRow, firstDeltaRow);
-    assert.isTrue(cursorElement.cursorManager.index > firstIndex);
-    assert.equal(cursorElement.diffRow.parentElement,
+    assert.equal(cursor.side, 'left');
+    assert.notEqual(cursor.diffRow, firstDeltaRow);
+    assert.isTrue(cursor.cursorManager.index > firstIndex);
+    assert.equal(cursor.diffRow.parentElement,
         firstDeltaSection.nextSibling);
   });
 
@@ -299,26 +296,26 @@
 
     // We should be initialized to the first chunk. Since this chunk only has
     // content on the right side, our side should be right.
-    let currentIndex = indexOfChunk(cursorElement.diffRow.parentElement);
+    let currentIndex = indexOfChunk(cursor.diffRow.parentElement);
     assert.equal(currentIndex, 0);
-    assert.equal(cursorElement.side, 'right');
+    assert.equal(cursor.side, 'right');
 
     // Move to the next chunk.
-    cursorElement.moveToNextChunk();
+    cursor.moveToNextChunk();
 
     // Since this chunk only has content on the left side. we should have been
     // automatically moved over.
     const previousIndex = currentIndex;
-    currentIndex = indexOfChunk(cursorElement.diffRow.parentElement);
+    currentIndex = indexOfChunk(cursor.diffRow.parentElement);
     assert.equal(currentIndex, previousIndex + 1);
-    assert.equal(cursorElement.side, 'left');
+    assert.equal(cursor.side, 'left');
   });
 
   suite('moved chunks without line range)', () => {
     setup(done => {
       const renderHandler = function() {
         diffElement.removeEventListener('render', renderHandler);
-        cursorElement.reInitCursor();
+        cursor.reInitCursor();
         done();
       };
       diffElement.addEventListener('render', renderHandler);
@@ -369,7 +366,7 @@
     setup(done => {
       const renderHandler = function() {
         diffElement.removeEventListener('render', renderHandler);
-        cursorElement.reInitCursor();
+        cursor.reInitCursor();
         done();
       };
       diffElement.addEventListener('render', renderHandler);
@@ -447,19 +444,19 @@
 
   test('initialLineNumber not provided', done => {
     let scrollBehaviorDuringMove;
-    const moveToNumStub = sinon.stub(cursorElement, 'moveToLineNumber');
-    const moveToChunkStub = sinon.stub(cursorElement, 'moveToFirstChunk')
+    const moveToNumStub = sinon.stub(cursor, 'moveToLineNumber');
+    const moveToChunkStub = sinon.stub(cursor, 'moveToFirstChunk')
         .callsFake(() => {
-          scrollBehaviorDuringMove = cursorElement.cursorManager.scrollMode;
+          scrollBehaviorDuringMove = cursor.cursorManager.scrollMode;
         });
 
     function renderHandler() {
       diffElement.removeEventListener('render', renderHandler);
-      cursorElement.reInitCursor();
+      cursor.reInitCursor();
       assert.isFalse(moveToNumStub.called);
       assert.isTrue(moveToChunkStub.called);
       assert.equal(scrollBehaviorDuringMove, 'never');
-      assert.equal(cursorElement.cursorManager.scrollMode, 'keep-visible');
+      assert.equal(cursor.cursorManager.scrollMode, 'keep-visible');
       done();
     }
     diffElement.addEventListener('render', renderHandler);
@@ -468,34 +465,34 @@
 
   test('initialLineNumber provided', done => {
     let scrollBehaviorDuringMove;
-    const moveToNumStub = sinon.stub(cursorElement, 'moveToLineNumber')
+    const moveToNumStub = sinon.stub(cursor, 'moveToLineNumber')
         .callsFake(() => {
-          scrollBehaviorDuringMove = cursorElement.cursorManager.scrollMode;
+          scrollBehaviorDuringMove = cursor.cursorManager.scrollMode;
         });
-    const moveToChunkStub = sinon.stub(cursorElement, 'moveToFirstChunk');
+    const moveToChunkStub = sinon.stub(cursor, 'moveToFirstChunk');
     function renderHandler() {
       diffElement.removeEventListener('render', renderHandler);
-      cursorElement.reInitCursor();
+      cursor.reInitCursor();
       assert.isFalse(moveToChunkStub.called);
       assert.isTrue(moveToNumStub.called);
       assert.equal(moveToNumStub.lastCall.args[0], 10);
       assert.equal(moveToNumStub.lastCall.args[1], 'right');
       assert.equal(scrollBehaviorDuringMove, 'keep-visible');
-      assert.equal(cursorElement.cursorManager.scrollMode, 'keep-visible');
+      assert.equal(cursor.cursorManager.scrollMode, 'keep-visible');
       done();
     }
     diffElement.addEventListener('render', renderHandler);
-    cursorElement.initialLineNumber = 10;
-    cursorElement.side = 'right';
+    cursor.initialLineNumber = 10;
+    cursor.side = 'right';
 
     diffElement._diffChanged(getMockDiffResponse());
   });
 
   test('getTargetDiffElement', () => {
-    cursorElement.initialLineNumber = 1;
-    assert.isTrue(!!cursorElement.diffRow);
+    cursor.initialLineNumber = 1;
+    assert.isTrue(!!cursor.diffRow);
     assert.equal(
-        cursorElement.getTargetDiffElement(),
+        cursor.getTargetDiffElement(),
         diffElement
     );
   });
@@ -506,7 +503,7 @@
     });
 
     test('adds new draft for selected line on the left', done => {
-      cursorElement.moveToLineNumber(2, 'left');
+      cursor.moveToLineNumber(2, 'left');
       diffElement.addEventListener('create-comment', e => {
         const {lineNum, range, side} = e.detail;
         assert.equal(lineNum, 2);
@@ -514,11 +511,11 @@
         assert.equal(side, 'left');
         done();
       });
-      cursorElement.createCommentInPlace();
+      cursor.createCommentInPlace();
     });
 
     test('adds draft for selected line on the right', done => {
-      cursorElement.moveToLineNumber(4, 'right');
+      cursor.moveToLineNumber(4, 'right');
       diffElement.addEventListener('create-comment', e => {
         const {lineNum, range, side} = e.detail;
         assert.equal(lineNum, 4);
@@ -526,7 +523,7 @@
         assert.equal(side, 'right');
         done();
       });
-      cursorElement.createCommentInPlace();
+      cursor.createCommentInPlace();
     });
 
     test('creates comment for range if selected', done => {
@@ -547,15 +544,15 @@
         assert.equal(side, 'right');
         done();
       });
-      cursorElement.createCommentInPlace();
+      cursor.createCommentInPlace();
     });
 
     test('ignores call if nothing is selected', () => {
       const createRangeCommentStub = sinon.stub(diffElement,
           'createRangeComment');
       const addDraftAtLineStub = sinon.stub(diffElement, 'addDraftAtLine');
-      cursorElement.diffRow = undefined;
-      cursorElement.createCommentInPlace();
+      cursor.diffRow = undefined;
+      cursor.createCommentInPlace();
       assert.isFalse(createRangeCommentStub.called);
       assert.isFalse(addDraftAtLineStub.called);
     });
@@ -563,31 +560,31 @@
 
   test('getAddress', () => {
     // It should initialize to the first chunk: line 5 of the revision.
-    assert.deepEqual(cursorElement.getAddress(),
+    assert.deepEqual(cursor.getAddress(),
         {leftSide: false, number: 5});
 
     // Revision line 4 is up.
-    cursorElement.moveUp();
-    assert.deepEqual(cursorElement.getAddress(),
+    cursor.moveUp();
+    assert.deepEqual(cursor.getAddress(),
         {leftSide: false, number: 4});
 
     // Base line 4 is left.
-    cursorElement.moveLeft();
-    assert.deepEqual(cursorElement.getAddress(), {leftSide: true, number: 4});
+    cursor.moveLeft();
+    assert.deepEqual(cursor.getAddress(), {leftSide: true, number: 4});
 
     // Moving to the next chunk takes it back to the start.
-    cursorElement.moveToNextChunk();
-    assert.deepEqual(cursorElement.getAddress(),
+    cursor.moveToNextChunk();
+    assert.deepEqual(cursor.getAddress(),
         {leftSide: false, number: 5});
 
     // The following chunk is a removal starting on line 10 of the base.
-    cursorElement.moveToNextChunk();
-    assert.deepEqual(cursorElement.getAddress(),
+    cursor.moveToNextChunk();
+    assert.deepEqual(cursor.getAddress(),
         {leftSide: true, number: 10});
 
     // Should be null if there is no selection.
-    cursorElement.cursorManager.unsetCursor();
-    assert.isNotOk(cursorElement.getAddress());
+    cursor.cursorManager.unsetCursor();
+    assert.isNotOk(cursor.getAddress());
   });
 
   test('_findRowByNumberAndFile', () => {
@@ -595,43 +592,25 @@
     const row = diffElement.root.querySelectorAll('tr')[9];
 
     // It should be line 8 on the right, but line 5 on the left.
-    assert.equal(cursorElement._findRowByNumberAndFile(8, 'right'), row);
-    assert.equal(cursorElement._findRowByNumberAndFile(5, 'left'), row);
+    assert.equal(cursor._findRowByNumberAndFile(8, 'right'), row);
+    assert.equal(cursor._findRowByNumberAndFile(5, 'left'), row);
   });
 
   test('expand context updates stops', done => {
-    sinon.spy(cursorElement, '_updateStops');
+    sinon.spy(cursor, '_updateStops');
     MockInteractions.tap(diffElement.shadowRoot
         .querySelector('gr-context-controls').shadowRoot
         .querySelector('.showContext'));
     flush(() => {
-      assert.isTrue(cursorElement._updateStops.called);
+      assert.isTrue(cursor._updateStops.called);
       done();
     });
   });
 
   test('updates stops when loading changes', () => {
-    sinon.spy(cursorElement, '_updateStops');
+    sinon.spy(cursor, '_updateStops');
     diffElement.dispatchEvent(new Event('loading-changed'));
-    assert.isTrue(cursorElement._updateStops.called);
-  });
-
-  suite('gr-diff-cursor event tests', () => {
-    let someEmptyDiv;
-
-    setup(() => {
-      someEmptyDiv = emptyFixture.instantiate();
-    });
-
-    teardown(() => sinon.restore());
-
-    test('ready is fired after component is rendered', done => {
-      const cursorElement = document.createElement('gr-diff-cursor');
-      cursorElement.addEventListener('ready', () => {
-        done();
-      });
-      someEmptyDiv.appendChild(cursorElement);
-    });
+    assert.isTrue(cursor._updateStops.called);
   });
 
   suite('multi diff', () => {
@@ -639,18 +618,16 @@
       <gr-diff></gr-diff>
       <gr-diff></gr-diff>
       <gr-diff></gr-diff>
-      <gr-diff-cursor></gr-diff-cursor>
     `);
 
     let diffElements;
 
     setup(() => {
-      const fixtureElems = multiDiffFixture.instantiate();
-      diffElements = fixtureElems.slice(0, 3);
-      cursorElement = fixtureElems[3];
+      diffElements = multiDiffFixture.instantiate();
+      cursor = new GrDiffCursor();
 
       // Register the diff with the cursor.
-      cursorElement.push('diffs', ...diffElements);
+      cursor.replaceDiffs(diffElements);
 
       for (const el of diffElements) {
         el.prefs = createDefaultDiffPrefs();
@@ -664,7 +641,7 @@
       // assertion because of the async nature assertion errors are handled and
       // can cause the test simply timing out, causing a lot of debugging headache.
       // Working with indices circumvents the problem.
-      return diffElements.indexOf(cursorElement.getTargetDiffElement());
+      return diffElements.indexOf(cursor.getTargetDiffElement());
     }
 
     test('do not skip loading diffs', async () => {
@@ -678,28 +655,28 @@
       const lastLine = diffElements[0].diff.meta_b.lines;
 
       // Goto second last line of the first diff
-      cursorElement.moveToLineNumber(lastLine - 1, 'right');
+      cursor.moveToLineNumber(lastLine - 1, 'right');
       assert.equal(
-          cursorElement.getTargetLineElement().textContent, lastLine - 1);
+          cursor.getTargetLineElement().textContent, lastLine - 1);
 
       // Can move down until we reach the loading file
-      cursorElement.moveDown();
+      cursor.moveDown();
       assert.equal(getTargetDiffIndex(), 0);
-      assert.equal(cursorElement.getTargetLineElement().textContent, lastLine);
+      assert.equal(cursor.getTargetLineElement().textContent, lastLine);
 
       // Cannot move down while still loading the diff we would switch to
-      cursorElement.moveDown();
+      cursor.moveDown();
       assert.equal(getTargetDiffIndex(), 0);
-      assert.equal(cursorElement.getTargetLineElement().textContent, lastLine);
+      assert.equal(cursor.getTargetLineElement().textContent, lastLine);
 
       // Diff 1 finishing to load
       diffElements[1].diff = getMockDiffResponse();
       await diffRenderedPromises[1];
 
       // Now we can go down
-      cursorElement.moveDown();
+      cursor.moveDown();
       assert.equal(getTargetDiffIndex(), 1);
-      assert.equal(cursorElement.getTargetLineElement().textContent, 'File');
+      assert.equal(cursor.getTargetLineElement().textContent, 'File');
     });
   });
 });
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.ts b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.ts
index 6216644..cbd5047 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.ts
@@ -36,6 +36,7 @@
   getSideByLineEl,
 } from '../gr-diff/gr-diff-utils';
 import {debounce, DelayedTask} from '../../../utils/async-util';
+import {queryAndAssert} from '../../../utils/common-util';
 
 interface SidedRange {
   side: Side;
@@ -574,7 +575,7 @@
   _getLength(node: Node | null): number {
     if (node === null) return 0;
     if (node instanceof Element && node.classList.contains('content')) {
-      return this._getLength(node.querySelector('.contentText')!);
+      return this._getLength(queryAndAssert(node, '.contentText'));
     } else {
       return GrAnnotation.getLength(node);
     }
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts
index b42deda..d26e1af 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts
@@ -87,6 +87,10 @@
 import {DiffContextExpandedEventDetail} from '../gr-diff-builder/gr-diff-builder';
 import {TokenHighlightLayer} from '../gr-diff-builder/token-highlight-layer';
 import {Timing} from '../../../constants/reporting';
+import {changeComments$} from '../../../services/comments/comments-model';
+import {takeUntil} from 'rxjs/operators';
+import {ChangeComments} from '../gr-comment-api/gr-comment-api';
+import {Subject} from 'rxjs';
 
 const MSG_EMPTY_BLAME = 'No blame information for this diff.';
 
@@ -145,12 +149,6 @@
    * @event show-auth-required
    */
 
-  /**
-   * Fired when a comment is saved or discarded
-   *
-   * @event diff-comments-modified
-   */
-
   @property({type: Number})
   changeNum?: NumericChangeId;
 
@@ -238,6 +236,9 @@
   diff?: DiffInfo;
 
   @property({type: Object})
+  changeComments?: ChangeComments;
+
+  @property({type: Object})
   _fetchDiffPromise: Promise<DiffInfo> | null = null;
 
   @property({type: Object})
@@ -272,6 +273,8 @@
 
   private readonly syntaxLayer = new GrSyntaxLayer();
 
+  disconnected$ = new Subject();
+
   constructor() {
     super();
     this.addEventListener(
@@ -284,12 +287,6 @@
       'create-comment',
       e => this._handleCreateComment(e)
     );
-    this.addEventListener('comment-discard', () =>
-      this._handleCommentSaveOrDiscard()
-    );
-    this.addEventListener('comment-save', () =>
-      this._handleCommentSaveOrDiscard()
-    );
     this.addEventListener('render-start', () => this._handleRenderStart());
     this.addEventListener('render-content', () => this._handleRenderContent());
     this.addEventListener('normalize-range', event =>
@@ -314,10 +311,16 @@
     this._getLoggedIn().then(loggedIn => {
       this._loggedIn = loggedIn;
     });
+    changeComments$
+      .pipe(takeUntil(this.disconnected$))
+      .subscribe(changeComments => {
+        this.changeComments = changeComments;
+      });
   }
 
   /** @override */
   disconnectedCallback() {
+    this.disconnected$.next();
     this.clear();
     super.disconnectedCallback();
   }
@@ -398,7 +401,7 @@
       if (e instanceof Response) {
         this._handleGetDiffError(e);
       } else {
-        console.warn('Error encountered loading diff:', e);
+        this.reporting.error(e);
       }
     } finally {
       this.reporting.timeEnd(Timing.DIFF_TOTAL);
@@ -483,12 +486,12 @@
               });
             })
             .catch(err => {
-              console.warn('Applying coverage from provider failed: ', err);
+              this.reporting.error(err);
             });
         });
       })
       .catch(err => {
-        console.warn('Loading coverage ranges failed: ', err);
+        this.reporting.error(err);
       });
   }
 
@@ -502,6 +505,16 @@
     );
   }
 
+  @observe('changeComments', 'patchRange', 'file')
+  computeFileThreads(
+    changeComments?: ChangeComments,
+    patchRange?: PatchRange,
+    file?: PatchSetFile
+  ) {
+    if (!changeComments || !patchRange || !file) return;
+    this.threads = changeComments.getThreadsBySideForFile(file, patchRange);
+  }
+
   _getFilesWeblinks(diff: DiffInfo) {
     if (!this.projectName || !this.commitRange || !this.path) return {};
     return {
@@ -1010,10 +1023,6 @@
       : null;
   }
 
-  _handleCommentSaveOrDiscard() {
-    fireEvent(this, 'diff-comments-modified');
-  }
-
   _syntaxHighlightingEnabledChanged(_syntaxHighlightingEnabled: boolean) {
     this.syntaxLayer.setEnabled(_syntaxHighlightingEnabled);
   }
@@ -1168,7 +1177,6 @@
     'normalize-range': CustomEvent;
     'diff-context-expanded': CustomEvent<DiffContextExpandedEventDetail>;
     'create-comment': CustomEvent;
-    'comment-discard': CustomEvent;
     'comment-update': CustomEvent;
     'comment-save': CustomEvent;
     'root-id-changed': CustomEvent;
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-image-viewer/gr-image-viewer.ts b/polygerrit-ui/app/elements/diff/gr-diff-image-viewer/gr-image-viewer.ts
index dad855c..b0c7097 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
@@ -25,6 +25,9 @@
 import './gr-overview-image';
 import './gr-zoomed-image';
 
+import {GrLibLoader} from '../../shared/gr-lib-loader/gr-lib-loader';
+import {RESEMBLEJS_LIBRARY_CONFIG} from '../../shared/gr-lib-loader/resemblejs_config';
+
 import {
   css,
   customElement,
@@ -60,12 +63,18 @@
  */
 @customElement('gr-image-viewer')
 export class GrImageViewer extends LitElement {
-  // URL for the image to use as base.
+  /** URL for the image to use as base. */
   @property({type: String}) baseUrl = '';
 
-  // URL for the image to use as revision.
+  /** URL for the image to use as revision. */
   @property({type: String}) revisionUrl = '';
 
+  /**
+   * When true, cycle automatically between base and revision image, if both
+   * are available.
+   */
+  @property({type: Boolean}) automaticBlink = false;
+
   @state() protected baseSelected = false;
 
   @state() protected scaledSelected = true;
@@ -78,8 +87,6 @@
 
   @state() protected backgroundColor = '';
 
-  @state() protected automaticBlink = false;
-
   @state() protected automaticBlinkShown = false;
 
   @state() protected zoomedImageStyle: StyleInfo = {};
@@ -120,6 +127,12 @@
 
   @state() protected grabbing = false;
 
+  @state() protected canHighlightDiffs = false;
+
+  @state() protected diffHighlightSrc?: string;
+
+  @state() protected showHighlight = false;
+
   private ownsMouseDown = false;
 
   private centerOnDown: Point = {x: 0, y: 0};
@@ -155,9 +168,14 @@
 
   private automaticBlinkTimer?: ReturnType<typeof setInterval>;
 
+  // TODO(hermannloose): Make GrLibLoader a singleton.
+  private static readonly libLoader = new GrLibLoader();
+
   static styles = css`
     :host {
-      display: flex;
+      display: grid;
+      grid-template-rows: 1fr auto;
+      grid-template-columns: 1fr auto;
       width: 100%;
       height: 100%;
       box-sizing: border-box;
@@ -166,6 +184,8 @@
       --image-border-width: 2px;
     }
     .imageArea {
+      grid-row-start: 1;
+      grid-column-start: 1;
       box-sizing: border-box;
       flex-grow: 1;
       overflow: hidden;
@@ -226,7 +246,21 @@
         var(--square-size) calc(-1 * var(--square-size)),
         calc(-1 * var(--square-size)) 0;
     }
+    .dimensions {
+      grid-row-start: 2;
+      justify-self: center;
+      align-self: center;
+      background: var(--primary-button-background-color);
+      color: var(--primary-button-text-color);
+      font-family: var(--font-family);
+      font-size: var(--font-size-small);
+      line-height: var(--line-height-small);
+      border-radius: var(--border-radius, 4px);
+      margin: var(--spacing-s);
+      padding: var(--spacing-xxs) var(--spacing-s);
+    }
     .controls {
+      grid-column-start: 2;
       flex-grow: 0;
       display: flex;
       flex-direction: column;
@@ -251,7 +285,9 @@
     #version-switcher {
       display: flex;
       align-items: center;
-      margin: var(--spacing-xl);
+      margin: var(--spacing-xl) var(--spacing-xl) var(--spacing-m);
+      /* Start a stacking context to contain FAB below. */
+      z-index: 0;
     }
     #version-switcher paper-button {
       flex-grow: 1;
@@ -264,7 +300,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);
@@ -275,11 +311,15 @@
     #version-explanation {
       color: var(--deemphasized-text-color);
       text-align: center;
-      margin: var(--spacing-xl);
+      margin: var(--spacing-xl) var(--spacing-xl) var(--spacing-m);
+    }
+    #highlight-changes {
+      margin: var(--spacing-m) var(--spacing-xl);
     }
     gr-overview-image {
       min-width: 200px;
       min-height: 150px;
+      margin-top: var(--spacing-m);
     }
     #zoom-control {
       margin: 0 var(--spacing-xl);
@@ -327,6 +367,14 @@
       height: 100%;
       box-sizing: border-box;
     }
+    #source-plus-highlight-container {
+      position: relative;
+    }
+    #source-plus-highlight-container img {
+      position: absolute;
+      top: 0;
+      left: 0;
+    }
   `;
 
   private renderColorPickerButton(color: string, colorPicked: () => void) {
@@ -382,6 +430,19 @@
       />
     `;
 
+    const sourceImageWithHighlight = html`
+      <div id="source-plus-highlight-container">
+        ${sourceImage}
+        <img
+          id="highlight-image"
+          style="${styleMap({
+            opacity: this.showHighlight ? '1' : '0',
+          })}"
+          src="${this.diffHighlightSrc}"
+        />
+      </div>
+    `;
+
     const versionExplanation = html`
       <div id="version-explanation">
         This file is being ${this.revisionUrl ? 'added' : 'deleted'}.
@@ -417,6 +478,18 @@
       ${this.baseUrl && this.revisionUrl ? versionToggle : versionExplanation}
     `;
 
+    const highlightSwitcher = this.diffHighlightSrc
+      ? html`
+          <paper-checkbox
+            id="highlight-changes"
+            ?checked="${this.showHighlight}"
+            @change="${this.showHighlightChanged}"
+          >
+            Highlight differences
+          </paper-checkbox>
+        `
+      : '';
+
     const overviewImage = html`
       <gr-overview-image
         .frameRect="${this.overviewFrame}"
@@ -551,13 +624,17 @@
           @mouseleave="${this.mouseleaveMagnifier}"
           @dragstart="${this.dragstartMagnifier}"
         >
-          ${sourceImage}
+          ${sourceImageWithHighlight}
         </gr-zoomed-image>
         ${this.baseUrl && this.revisionUrl ? automaticBlink : ''} ${spacer}
       </div>
 
+      <div class="dimensions">
+        ${this.imageSize.width} x ${this.imageSize.height}
+      </div>
+
       <paper-card class="controls">
-        ${versionSwitcher} ${overviewImage} ${zoomControl}
+        ${versionSwitcher} ${highlightSwitcher} ${overviewImage} ${zoomControl}
         ${!this.scaledSelected ? followMouse : ''} ${backgroundPicker}
       </paper-card>
     `;
@@ -565,6 +642,10 @@
 
   firstUpdated() {
     this.resizeObserver.observe(this.imageArea, {box: 'content-box'});
+    GrImageViewer.libLoader.getLibrary(RESEMBLEJS_LIBRARY_CONFIG).then(() => {
+      this.canHighlightDiffs = true;
+      this.computeDiffImage();
+    });
   }
 
   // We don't want property changes in updateSizes() to trigger infinite update
@@ -583,6 +664,30 @@
     ) {
       this.frameConstrainer.requestCenter({x: 0, y: 0});
     }
+    if (changedProperties.has('automaticBlink')) {
+      this.updateAutomaticBlink();
+    }
+    if (
+      this.canHighlightDiffs &&
+      (changedProperties.has('baseUrl') || changedProperties.has('revisionUrl'))
+    ) {
+      this.computeDiffImage();
+    }
+  }
+
+  private computeDiffImage() {
+    if (!(this.baseUrl && this.revisionUrl)) return;
+    window
+      .resemble(this.baseUrl)
+      .compareTo(this.revisionUrl)
+      // By default Resemble.js applies some color / alpha tolerance as well as
+      // min / max brightness beyond which to ignore changes. Until we have
+      // controls to let the user affect these options, always highlight all
+      // changed pixels.
+      .ignoreNothing()
+      .onComplete(result => {
+        this.diffHighlightSrc = result.getImageDataUrl();
+      });
   }
 
   selectBase() {
@@ -616,29 +721,49 @@
 
   toggleAutomaticBlink() {
     this.automaticBlink = !this.automaticBlink;
-    if (this.automaticBlink) {
-      this.toggleImage();
-      this.setBlinkInterval();
-    } else {
-      if (this.automaticBlinkTimer) {
-        clearInterval(this.automaticBlinkTimer);
-        this.automaticBlinkTimer = undefined;
-      }
-    }
     this.dispatchEvent(
       createEvent({type: 'automatic-blink-changed', value: this.automaticBlink})
     );
   }
 
-  private setBlinkInterval() {
-    if (this.automaticBlinkTimer) {
-      clearInterval(this.automaticBlinkTimer);
+  private updateAutomaticBlink() {
+    if (this.automaticBlink) {
+      this.toggleImage();
+      this.setBlinkInterval();
+    } else {
+      this.clearBlinkInterval();
     }
+  }
+
+  private setBlinkInterval() {
+    this.clearBlinkInterval();
     this.automaticBlinkTimer = setInterval(() => {
       this.toggleImage();
     }, DEFAULT_AUTOMATIC_BLINK_TIME_MS);
   }
 
+  private clearBlinkInterval() {
+    if (this.automaticBlinkTimer) {
+      clearInterval(this.automaticBlinkTimer);
+      this.automaticBlinkTimer = undefined;
+    }
+  }
+
+  showHighlightChanged() {
+    this.toggleHighlight('controls');
+  }
+
+  private toggleHighlight(source: 'controls' | 'magnifier') {
+    this.showHighlight = !this.showHighlight;
+    this.dispatchEvent(
+      createEvent({
+        type: 'highlight-changes-changed',
+        value: this.showHighlight,
+        source,
+      })
+    );
+  }
+
   zoomControlChanged(event: CustomEvent) {
     const value = event.detail.value;
     if (!value) return;
@@ -715,6 +840,12 @@
     if (!this.ownsMouseDown) return;
     this.grabbing = false;
     this.ownsMouseDown = false;
+
+    if (event.shiftKey && this.diffHighlightSrc) {
+      this.toggleHighlight('magnifier');
+      return;
+    }
+
     const offsetX = event.clientX - this.pointerOnDown.x;
     const offsetY = event.clientY - this.pointerOnDown.y;
     const distance = Math.max(Math.abs(offsetX), Math.abs(offsetY));
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 b7ac0de..fe8a1f7 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts
@@ -71,7 +71,6 @@
   ConfigInfo,
   EditInfo,
   EditPatchSetNum,
-  ElementPropertyDeepChange,
   FileInfo,
   NumericChangeId,
   ParentPatchSetNum,
@@ -106,7 +105,11 @@
 import {GerritView} from '../../../services/router/router-model';
 import {assertIsDefined} from '../../../utils/common-util';
 import {toggleClass, getKeyboardEvent} from '../../../utils/dom-util';
-import {CursorMoveResult} from '../../shared/gr-cursor-manager/gr-cursor-manager';
+import {CursorMoveResult} from '../../../api/core';
+import {throttleWrap} from '../../../utils/async-util';
+import {changeComments$} from '../../../services/comments/comments-model';
+import {takeUntil} from 'rxjs/operators';
+import {Subject} from 'rxjs';
 
 const ERR_REVIEW_STATUS = 'Couldn’t change file review status.';
 const MSG_LOADING_BLAME = 'Loading blame...';
@@ -128,7 +131,6 @@
 export interface GrDiffView {
   $: {
     commentAPI: GrCommentApi;
-    cursor: GrDiffCursor;
     diffHost: GrDiffHost;
     reviewed: HTMLInputElement;
     dropdown: GrDropdownList;
@@ -276,6 +278,11 @@
   @property({type: Number})
   _focusLineNum?: number;
 
+  private getReviewedParams: {
+    changeNum?: NumericChangeId;
+    patchNum?: PatchSetNum;
+  } = {};
+
   get keyBindings() {
     return {
       esc: '_handleEscKey',
@@ -324,43 +331,73 @@
     };
   }
 
-  reporting = appContext.reportingService;
-
-  flagsService = appContext.flagsService;
+  private readonly reporting = appContext.reportingService;
 
   private readonly restApiService = appContext.restApiService;
 
+  private readonly commentsService = appContext.commentsService;
+
   _throttledToggleFileReviewed?: EventListener;
 
   _onRenderHandler?: EventListener;
 
+  private cursor = new GrDiffCursor();
+
+  disconnected$ = new Subject();
+
   /** @override */
   connectedCallback() {
     super.connectedCallback();
-    this._throttledToggleFileReviewed = this._throttleWrap(e =>
+    this._throttledToggleFileReviewed = throttleWrap(e =>
       this._handleToggleFileReviewed(e as CustomKeyboardEvent)
     );
     this._getLoggedIn().then(loggedIn => {
       this._loggedIn = loggedIn;
     });
-
+    changeComments$
+      .pipe(takeUntil(this.disconnected$))
+      .subscribe(changeComments => {
+        this._changeComments = changeComments;
+      });
     this.addEventListener('open-fix-preview', e => this._onOpenFixPreview(e));
-    this.$.cursor.push('diffs', this.$.diffHost);
+    this.cursor.replaceDiffs([this.$.diffHost]);
     this._onRenderHandler = (_: Event) => {
-      this.$.cursor.reInitCursor();
+      this.cursor.reInitCursor();
     };
     this.$.diffHost.addEventListener('render', this._onRenderHandler);
   }
 
   /** @override */
   disconnectedCallback() {
+    this.disconnected$.next();
+    this.cursor.dispose();
     if (this._onRenderHandler) {
       this.$.diffHost.removeEventListener('render', this._onRenderHandler);
     }
     super.disconnectedCallback();
   }
 
-  _getLoggedIn() {
+  @observe('_changeComments', '_path', '_patchRange')
+  computeThreads(
+    changeComments?: ChangeComments,
+    path?: string,
+    patchRange?: PatchRange
+  ) {
+    if (
+      changeComments === undefined ||
+      path === undefined ||
+      patchRange === undefined
+    ) {
+      return;
+    }
+    // TODO(dhruvsri): check if basePath should be set here
+    this.$.diffHost.threads = changeComments.getThreadsBySideForFile(
+      {path},
+      patchRange
+    );
+  }
+
+  _getLoggedIn(): Promise<boolean> {
     return this.restApiService.getLoggedIn();
   }
 
@@ -453,8 +490,15 @@
   _setReviewed(reviewed: boolean) {
     if (this._editMode) return;
     this.$.reviewed.checked = reviewed;
-    if (!this._patchRange?.patchNum) return;
+    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 => {
+      if (this._reviewedFiles.has(path)) this._reviewedFiles.delete(path);
+      else this._reviewedFiles.add(path);
       fireAlert(this, ERR_REVIEW_STATUS);
       throw err;
     });
@@ -492,14 +536,14 @@
     if (this.shouldSuppressKeyboardShortcut(e)) return;
 
     e.preventDefault();
-    this.$.cursor.moveLeft();
+    this.cursor.moveLeft();
   }
 
   _handleRightPane(e: CustomKeyboardEvent) {
     if (this.shouldSuppressKeyboardShortcut(e)) return;
 
     e.preventDefault();
-    this.$.cursor.moveRight();
+    this.cursor.moveRight();
   }
 
   _handlePrevLineOrFileWithComments(e: CustomKeyboardEvent) {
@@ -519,14 +563,14 @@
 
     e.preventDefault();
     this.$.diffHost.displayLine = true;
-    this.$.cursor.moveUp();
+    this.cursor.moveUp();
   }
 
   _handleVisibleLine(e: CustomKeyboardEvent) {
     if (this.shouldSuppressKeyboardShortcut(e)) return;
 
     e.preventDefault();
-    this.$.cursor.moveToVisibleArea();
+    this.cursor.moveToVisibleArea();
   }
 
   _onOpenFixPreview(e: OpenFixPreviewEvent) {
@@ -550,7 +594,7 @@
 
     e.preventDefault();
     this.$.diffHost.displayLine = true;
-    this.$.cursor.moveDown();
+    this.cursor.moveDown();
   }
 
   _moveToPreviousFileWithComment() {
@@ -598,7 +642,7 @@
 
     e.preventDefault();
     this.classList.remove('hideComments');
-    this.$.cursor.createCommentInPlace();
+    this.cursor.createCommentInPlace();
   }
 
   _handlePrevFile(e: CustomKeyboardEvent) {
@@ -628,18 +672,18 @@
 
     e.preventDefault();
     if (e.detail.keyboardEvent?.shiftKey) {
-      const result = this.$.cursor.moveToNextCommentThread();
+      const result = this.cursor.moveToNextCommentThread();
       if (result === CursorMoveResult.CLIPPED) {
         this._navigateToNextFileWithCommentThread();
       }
     } else {
       if (this.modifierPressed(e)) return;
-      const result = this.$.cursor.moveToNextChunk();
+      const result = this.cursor.moveToNextChunk();
       // navigate to next file if key is not being held down
       if (
         !e.detail.keyboardEvent?.repeat &&
         result === CursorMoveResult.CLIPPED &&
-        this.$.cursor.isAtEnd()
+        this.cursor.isAtEnd()
       ) {
         this.showToastAndNavigateFile('next', 'n');
       }
@@ -689,11 +733,11 @@
 
     e.preventDefault();
     if (e.detail.keyboardEvent?.shiftKey) {
-      this.$.cursor.moveToPreviousCommentThread();
+      this.cursor.moveToPreviousCommentThread();
     } else {
       if (this.modifierPressed(e)) return;
-      this.$.cursor.moveToPreviousChunk();
-      if (!e.detail.keyboardEvent?.repeat && this.$.cursor.isAtStart()) {
+      this.cursor.moveToPreviousChunk();
+      if (!e.detail.keyboardEvent?.repeat && this.cursor.isAtStart()) {
         this.showToastAndNavigateFile('previous', 'p');
       }
     }
@@ -848,7 +892,7 @@
     if (!this._patchRange) return;
 
     // TODO(taoalpha): add a shortcut for editing
-    const cursorAddress = this.$.cursor.getAddress();
+    const cursorAddress = this.cursor.getAddress();
     const editUrl = GerritNav.getEditUrlForDiff(
       this._change,
       this._path,
@@ -894,31 +938,26 @@
     return {path: fileList[idx]};
   }
 
-  _getReviewedFiles(
-    changeNum?: NumericChangeId,
-    patchNum?: PatchSetNum
-  ): Promise<Set<string>> {
-    if (!changeNum || !patchNum) return Promise.resolve(new Set<string>());
-    return this.restApiService
-      .getReviewedFiles(changeNum, patchNum)
-      .then(files => {
-        this._reviewedFiles = new Set(files);
-        return this._reviewedFiles;
-      });
+  _getReviewedFiles(changeNum?: NumericChangeId, patchNum?: PatchSetNum) {
+    if (!changeNum || !patchNum) return;
+    if (
+      this.getReviewedParams.changeNum === changeNum &&
+      this.getReviewedParams.patchNum === patchNum
+    ) {
+      return;
+    }
+    this.getReviewedParams = {
+      changeNum,
+      patchNum,
+    };
+    this.restApiService.getReviewedFiles(changeNum, patchNum).then(files => {
+      this._reviewedFiles = new Set(files);
+    });
   }
 
-  _getReviewedStatus(
-    editMode?: boolean,
-    changeNum?: NumericChangeId,
-    patchNum?: PatchSetNum,
-    path?: string
-  ) {
-    if (editMode || !path) {
-      return Promise.resolve(false);
-    }
-    return this._getReviewedFiles(changeNum, patchNum).then(files =>
-      files.has(path)
-    );
+  _getReviewedStatus(path: string) {
+    if (this._editMode) return false;
+    return this._reviewedFiles.has(path);
   }
 
   _initLineOfInterestAndCursor(leftSide: boolean) {
@@ -1093,7 +1132,9 @@
     // the top-level change info view) and therefore undefined in `params`.
     // If route is of type /comment/<commentId>/ then no patchNum is present
     if (!value.patchNum && !value.commentLink) {
-      console.warn('invalid url, no patchNum found');
+      this.reporting.error(
+        new Error(`Invalid diff view URL, no patchNum found: ${value}`)
+      );
       return;
     }
 
@@ -1108,7 +1149,7 @@
     );
 
     promises.push(this._getChangeDetail(this._changeNum));
-    promises.push(this._loadComments(value.patchNum));
+    this._loadComments(value.patchNum);
 
     promises.push(this._getChangeEdit());
 
@@ -1121,17 +1162,6 @@
         this._initPatchRange();
         this._initCommitRange();
 
-        assertIsDefined(this._path, '_path');
-        if (!this._changeComments)
-          throw new Error('change comments must be defined');
-        assertIsDefined(this._patchRange, '_patchRange');
-
-        // TODO(dhruvsri): check if basePath should be set here
-        this.$.diffHost.threads = this._changeComments.getThreadsBySideForFile(
-          {path: this._path},
-          this._patchRange
-        );
-
         const edit = r[4] as EditInfo | undefined;
         if (edit) {
           this.set(`_change.revisions.${edit.commit.commit}`, {
@@ -1197,44 +1227,41 @@
     }
   }
 
-  @observe('_loggedIn', 'params.*', '_prefs', '_patchRange.*')
+  @observe('_path', '_prefs', '_reviewedFiles', '_patchRange')
   _setReviewedObserver(
+    path?: string,
+    prefs?: DiffPreferencesInfo,
+    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.
+      this.$.reviewed.checked = this._getReviewedStatus(path);
+    } else {
+      this._setReviewed(true);
+    }
+  }
+
+  @observe('_loggedIn', '_changeNum', '_patchRange')
+  getReviewedFiles(
     _loggedIn?: boolean,
-    paramsRecord?: ElementPropertyDeepChange<GrDiffView, 'params'>,
-    _prefs?: DiffPreferencesInfo,
-    patchRangeRecord?: ElementPropertyDeepChange<GrDiffView, '_patchRange'>
+    _changeNum?: NumericChangeId,
+    patchRange?: PatchRange
   ) {
     if (_loggedIn === undefined) return;
-    if (paramsRecord === undefined) return;
-    if (_prefs === undefined) return;
-    if (patchRangeRecord === undefined) return;
-    if (patchRangeRecord.base === undefined) return;
+    if (_changeNum === undefined) return;
+    if (patchRange === undefined) return;
 
-    const patchRange = patchRangeRecord.base;
     if (!_loggedIn) {
       return;
     }
 
-    if (_prefs.manual_review) {
-      // Checkbox state needs to be set explicitly only when manual_review
-      // is specified.
-
-      if (patchRange.patchNum) {
-        this._getReviewedStatus(
-          this._editMode,
-          this._changeNum,
-          patchRange.patchNum,
-          this._path
-        ).then((status: boolean) => {
-          this.$.reviewed.checked = status;
-        });
-      }
-      return;
-    }
-
-    if (paramsRecord.base?.view === GerritNav.View.DIFF) {
-      this._setReviewed(true);
-    }
+    this._getReviewedFiles(this._changeNum, patchRange.patchNum);
   }
 
   /**
@@ -1245,11 +1272,11 @@
       return;
     }
     if (leftSide) {
-      this.$.cursor.side = Side.LEFT;
+      this.cursor.side = Side.LEFT;
     } else {
-      this.$.cursor.side = Side.RIGHT;
+      this.cursor.side = Side.RIGHT;
     }
-    this.$.cursor.initialLineNumber = this._focusLineNum;
+    this.cursor.initialLineNumber = this._focusLineNum;
   }
 
   _getLineOfInterest(leftSide: boolean): LineOfInterest | undefined {
@@ -1552,15 +1579,18 @@
 
   _loadComments(patchSet?: PatchSetNum) {
     assertIsDefined(this._changeNum, '_changeNum');
-    return this.$.commentAPI
-      .loadAll(this._changeNum, patchSet)
-      .then(comments => {
-        this._changeComments = comments;
-      });
+    return this.commentsService.loadAll(this._changeNum, patchSet);
   }
 
-  @observe('_files.changeFilesByPath', '_path', '_patchRange', '_projectConfig')
+  @observe(
+    '_changeComments',
+    '_files.changeFilesByPath',
+    '_path',
+    '_patchRange',
+    '_projectConfig'
+  )
   _recomputeComments(
+    changeComments?: ChangeComments,
     files?: {[path: string]: FileInfo},
     path?: string,
     patchRange?: PatchRange,
@@ -1570,11 +1600,11 @@
     if (!path) return;
     if (!patchRange) return;
     if (!projectConfig) return;
-    if (!this._changeComments) return;
+    if (!changeComments) return;
 
     const file = files[path];
     if (file && file.old_path) {
-      this.$.diffHost.threads = this._changeComments.getThreadsBySideForFile(
+      this.$.diffHost.threads = changeComments.getThreadsBySideForFile(
         {path, basePath: file.old_path},
         patchRange
       );
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_html.ts b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_html.ts
index 7897a4a..8d69007d 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_html.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_html.ts
@@ -429,6 +429,5 @@
     on-reload-diff-preference="_handleReloadingDiffPreference"
   >
   </gr-diff-preferences-dialog>
-  <gr-diff-cursor id="cursor"></gr-diff-cursor>
   <gr-comment-api id="commentAPI"></gr-comment-api>
 `;
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.js b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.js
index 39ec384..1960ced 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.js
@@ -21,7 +21,7 @@
 import {ChangeStatus} from '../../../constants/constants.js';
 import {TestKeyboardShortcutBinder, stubRestApi} from '../../../test/test-utils.js';
 import {Shortcut} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.js';
-import {ChangeComments, _testOnly_findCommentById, _testOnly_getCommentsForPath} from '../gr-comment-api/gr-comment-api.js';
+import {ChangeComments} from '../gr-comment-api/gr-comment-api.js';
 import {GerritView} from '../../../services/router/router-model.js';
 import {
   createChange,
@@ -30,7 +30,7 @@
 } from '../../../test/test-data-generators.js';
 import {EditPatchSetNum} from '../../../types/common.js';
 import sinon from 'sinon/pkg/sinon-esm';
-import {CursorMoveResult} from '../../shared/gr-cursor-manager/gr-cursor-manager.js';
+import {CursorMoveResult} from '../../../api/core.js';
 
 const basicFixture = fixtureFromElement('gr-diff-view');
 
@@ -40,6 +40,7 @@
   suite('basic tests', () => {
     let element;
     let clock;
+    let diffCommentsStub;
 
     suiteSetup(() => {
       const kb = TestKeyboardShortcutBinder.push();
@@ -91,7 +92,6 @@
     }
 
     let getDiffChangeDetailStub;
-    let getReviewedFilesStub;
     setup(async () => {
       clock = sinon.useFakeTimers();
       stubRestApi('getConfig').returns(Promise.resolve({change: {}}));
@@ -101,12 +101,11 @@
           Promise.resolve({}));
       stubRestApi('getChangeFiles').returns(Promise.resolve({}));
       stubRestApi('saveFileReviewed').returns(Promise.resolve());
-      stubRestApi('getDiffComments').returns(Promise.resolve({}));
+      diffCommentsStub = stubRestApi('getDiffComments');
+      diffCommentsStub.returns(Promise.resolve({}));
       stubRestApi('getDiffRobotComments').returns(Promise.resolve({}));
       stubRestApi('getDiffDrafts').returns(Promise.resolve({}));
       stubRestApi('getPortedComments').returns(Promise.resolve({}));
-      getReviewedFilesStub = stubRestApi('getReviewedFiles').returns(
-          Promise.resolve([]));
 
       element = basicFixture.instantiate();
       element._changeNum = '42';
@@ -117,32 +116,21 @@
         patchNum: 77,
         basePatchNum: 'PARENT',
       };
-      sinon.stub(element.$.commentAPI, 'loadAll').returns(Promise.resolve({
-        _comments: {'/COMMIT_MSG': [
-          {
-            ...createComment(),
-            id: 'c1',
-            line: 10,
-            patch_set: 2,
-            path: '/COMMIT_MSG',
-          }, {
-            ...createComment(),
-            id: 'c3',
-            line: 10,
-            patch_set: 'PARENT',
-            path: '/COMMIT_MSG',
-          },
-        ]},
-        computeCommentThreadCount: () => {},
-        computeCommentsString: () => '',
-        computeUnresolvedNum: () => {},
-        getPaths: () => {},
-        getThreadsBySideForFile: () => [],
-        getCommentsForPath: _testOnly_getCommentsForPath,
-        findCommentById: _testOnly_findCommentById,
-
-      }));
-      await element._loadComments();
+      element._changeComments = new ChangeComments({'/COMMIT_MSG': [
+        {
+          ...createComment(),
+          id: 'c1',
+          line: 10,
+          patch_set: 2,
+          path: '/COMMIT_MSG',
+        }, {
+          ...createComment(),
+          id: 'c3',
+          line: 10,
+          patch_set: 'PARENT',
+          path: '/COMMIT_MSG',
+        },
+      ]});
       await flush();
     });
 
@@ -189,6 +177,22 @@
       });
 
       test('comment url resolves to comment.patch_set vs latest', () => {
+        diffCommentsStub.returns(Promise.resolve({
+          '/COMMIT_MSG': [
+            {
+              ...createComment(),
+              id: 'c1',
+              line: 10,
+              patch_set: 2,
+              path: '/COMMIT_MSG',
+            }, {
+              ...createComment(),
+              id: 'c3',
+              line: 10,
+              patch_set: 'PARENT',
+              path: '/COMMIT_MSG',
+            },
+          ]}));
         element.params = {
           view: GerritNav.View.DIFF,
           changeNum: '42',
@@ -239,6 +243,22 @@
     test('unchanged diff X vs latest from comment links navigates to base vs X'
         , () => {
           const diffNavStub = sinon.stub(GerritNav, 'navigateToDiff');
+          diffCommentsStub.returns(Promise.resolve({
+            '/COMMIT_MSG': [
+              {
+                ...createComment(),
+                id: 'c1',
+                line: 10,
+                patch_set: 2,
+                path: '/COMMIT_MSG',
+              }, {
+                ...createComment(),
+                id: 'c3',
+                line: 10,
+                patch_set: 'PARENT',
+                path: '/COMMIT_MSG',
+              },
+            ]}));
           sinon.stub(element.reporting, 'diffViewDisplayed');
           sinon.stub(element, '_loadBlame');
           sinon.stub(element.$.diffHost, 'reload').returns(Promise.resolve());
@@ -268,6 +288,22 @@
     test('unchanged diff Base vs latest from comment does not navigate'
         , () => {
           const diffNavStub = sinon.stub(GerritNav, 'navigateToDiff');
+          diffCommentsStub.returns(Promise.resolve({
+            '/COMMIT_MSG': [
+              {
+                ...createComment(),
+                id: 'c1',
+                line: 10,
+                patch_set: 2,
+                path: '/COMMIT_MSG',
+              }, {
+                ...createComment(),
+                id: 'c3',
+                line: 10,
+                patch_set: 'PARENT',
+                path: '/COMMIT_MSG',
+              },
+            ]}));
           sinon.stub(element.reporting, 'diffViewDisplayed');
           sinon.stub(element, '_loadBlame');
           sinon.stub(element.$.diffHost, 'reload').returns(Promise.resolve());
@@ -325,6 +361,22 @@
     });
 
     test('diff toast to go to latest is shown and not base', async () => {
+      diffCommentsStub.returns(Promise.resolve({
+        '/COMMIT_MSG': [
+          {
+            ...createComment(),
+            id: 'c1',
+            line: 10,
+            patch_set: 2,
+            path: '/COMMIT_MSG',
+          }, {
+            ...createComment(),
+            id: 'c3',
+            line: 10,
+            patch_set: 'PARENT',
+            path: '/COMMIT_MSG',
+          },
+        ]}));
       sinon.stub(element.reporting, 'diffViewDisplayed');
       sinon.stub(element, '_loadBlame');
       sinon.stub(element.$.diffHost, 'reload').returns(Promise.resolve());
@@ -422,19 +474,19 @@
       MockInteractions.pressAndReleaseKeyOn(element, 188, null, ',');
       assert(showPrefsStub.calledOnce);
 
-      let scrollStub = sinon.stub(element.$.cursor, 'moveToNextChunk');
+      let scrollStub = sinon.stub(element.cursor, 'moveToNextChunk');
       MockInteractions.pressAndReleaseKeyOn(element, 78, null, 'n');
       assert(scrollStub.calledOnce);
 
-      scrollStub = sinon.stub(element.$.cursor, 'moveToPreviousChunk');
+      scrollStub = sinon.stub(element.cursor, 'moveToPreviousChunk');
       MockInteractions.pressAndReleaseKeyOn(element, 80, null, 'p');
       assert(scrollStub.calledOnce);
 
-      scrollStub = sinon.stub(element.$.cursor, 'moveToNextCommentThread');
+      scrollStub = sinon.stub(element.cursor, 'moveToNextCommentThread');
       MockInteractions.pressAndReleaseKeyOn(element, 78, 'shift', 'n');
       assert(scrollStub.calledOnce);
 
-      scrollStub = sinon.stub(element.$.cursor,
+      scrollStub = sinon.stub(element.cursor,
           'moveToPreviousCommentThread');
       MockInteractions.pressAndReleaseKeyOn(element, 80, 'shift', 'p');
       assert(scrollStub.calledOnce);
@@ -470,7 +522,7 @@
     test('moveToNextCommentThread navigates to next file', () => {
       const diffNavStub = sinon.stub(GerritNav, 'navigateToDiff');
       const diffChangeStub = sinon.stub(element, '_navigateToChange');
-      sinon.stub(element.$.cursor, 'isAtEnd').returns(true);
+      sinon.stub(element.cursor, 'isAtEnd').returns(true);
       element._changeNum = '42';
       const comment = {
         'wheatley.md': [{
@@ -857,7 +909,7 @@
           b: {_number: 2, commit: {parents: []}},
         },
       };
-      sinon.stub(element.$.cursor, 'getAddress')
+      sinon.stub(element.cursor, 'getAddress')
           .returns({number: lineNumber, isLeftSide: false});
       const redirectStub = sinon.stub(GerritNav, 'navigateToRelativeUrl');
       flush(() => {
@@ -1157,10 +1209,11 @@
       const saveReviewedStub = sinon.stub(element, '_saveReviewedState')
           .callsFake(() => Promise.resolve());
       const getReviewedStub = sinon.stub(element, '_getReviewedStatus')
-          .callsFake(() => Promise.resolve());
+          .returns(false);
 
       sinon.stub(element.$.diffHost, 'reload');
       element._loggedIn = true;
+      element._prefs = {manual_review: true};
       element.params = {
         view: GerritNav.View.DIFF,
         changeNum: '42',
@@ -1172,17 +1225,19 @@
         patchNum: 2,
         basePatchNum: 1,
       };
-      element._prefs = {manual_review: true};
       flush();
 
       assert.isFalse(saveReviewedStub.called);
       assert.isTrue(getReviewedStub.called);
 
+      const oldCount = getReviewedStub.callCount;
+
       element._prefs = {};
+      element._path = 'abcd';
       flush();
 
       assert.isTrue(saveReviewedStub.called);
-      assert.isTrue(getReviewedStub.calledOnce);
+      assert.equal(getReviewedStub.callCount, oldCount);
     });
 
     test('file review status', () => {
@@ -1202,6 +1257,7 @@
         patchNum: 2,
         basePatchNum: 1,
       };
+      element._path = 'abcd';
       element._prefs = {};
       flush();
 
@@ -1380,34 +1436,34 @@
     });
 
     test('_initCursor', () => {
-      assert.isNotOk(element.$.cursor.initialLineNumber);
+      assert.isNotOk(element.cursor.initialLineNumber);
 
       // Does nothing when params specify no cursor address:
       element._initCursor(false);
-      assert.isNotOk(element.$.cursor.initialLineNumber);
+      assert.isNotOk(element.cursor.initialLineNumber);
 
       // Does nothing when params specify side but no number:
       element._initCursor(true);
-      assert.isNotOk(element.$.cursor.initialLineNumber);
+      assert.isNotOk(element.cursor.initialLineNumber);
 
       // Revision hash: specifies lineNum but not side.
 
       element._focusLineNum = 234;
       element._initCursor(false);
-      assert.equal(element.$.cursor.initialLineNumber, 234);
-      assert.equal(element.$.cursor.side, 'right');
+      assert.equal(element.cursor.initialLineNumber, 234);
+      assert.equal(element.cursor.side, 'right');
 
       // Base hash: specifies lineNum and side.
       element._focusLineNum = 345;
       element._initCursor(true);
-      assert.equal(element.$.cursor.initialLineNumber, 345);
-      assert.equal(element.$.cursor.side, 'left');
+      assert.equal(element.cursor.initialLineNumber, 345);
+      assert.equal(element.cursor.side, 'left');
 
       // Specifies right side:
       element._focusLineNum = 123;
       element._initCursor(false);
-      assert.equal(element.$.cursor.initialLineNumber, 123);
-      assert.equal(element.$.cursor.side, 'right');
+      assert.equal(element.cursor.initialLineNumber, 123);
+      assert.equal(element.cursor.side, 'right');
     });
 
     test('_getLineOfInterest', () => {
@@ -1426,7 +1482,7 @@
     test('_onLineSelected', () => {
       const getUrlStub = sinon.stub(GerritNav, 'getUrlForDiffById');
       const replaceStateStub = sinon.stub(history, 'replaceState');
-      sinon.stub(element.$.cursor, 'getAddress')
+      sinon.stub(element.cursor, 'getAddress')
           .returns({number: 123, isLeftSide: false});
 
       element._changeNum = 321;
@@ -1448,7 +1504,7 @@
     test('line selected on left side', () => {
       const getUrlStub = sinon.stub(GerritNav, 'getUrlForDiffById');
       const replaceStateStub = sinon.stub(history, 'replaceState');
-      sinon.stub(element.$.cursor, 'getAddress')
+      sinon.stub(element.cursor, 'getAddress')
           .returns({number: 123, isLeftSide: true});
 
       element._changeNum = 321;
@@ -1673,25 +1729,6 @@
           [{value: '/foo'}, {value: '/bar'}]), 'show');
     });
 
-    test('_getReviewedStatus', () => {
-      const promises = [];
-      getReviewedFilesStub.returns(Promise.resolve(['path']));
-
-      promises.push(element._getReviewedStatus(true, null, null, 'path')
-          .then(reviewed => assert.isFalse(reviewed)));
-
-      promises.push(element._getReviewedStatus(false, null, null, 'otherPath')
-          .then(reviewed => assert.isFalse(reviewed)));
-
-      promises.push(element._getReviewedStatus(false, null, null, 'path')
-          .then(reviewed => assert.isFalse(reviewed)));
-
-      promises.push(element._getReviewedStatus(false, 3, 5, 'path')
-          .then(reviewed => assert.isTrue(reviewed)));
-
-      return Promise.all(promises);
-    });
-
     test('f open file dropdown', () => {
       assert.isFalse(element.$.dropdown.$.dropdown.opened);
       MockInteractions.pressAndReleaseKeyOn(element, 70, null, 'f');
@@ -1751,11 +1788,11 @@
             element, 'dispatchEvent').callThrough();
         navToFileStub = sinon.stub(element, '_navToFile');
         moveToPreviousChunkStub =
-            sinon.stub(element.$.cursor, 'moveToPreviousChunk');
+            sinon.stub(element.cursor, 'moveToPreviousChunk');
         moveToNextChunkStub =
-            sinon.stub(element.$.cursor, 'moveToNextChunk');
-        isAtStartStub = sinon.stub(element.$.cursor, 'isAtStart');
-        isAtEndStub = sinon.stub(element.$.cursor, 'isAtEnd');
+            sinon.stub(element.cursor, 'moveToNextChunk');
+        isAtStartStub = sinon.stub(element.cursor, 'isAtStart');
+        isAtEndStub = sinon.stub(element.cursor, 'isAtEnd');
         nowStub = sinon.stub(Date, 'now');
       });
 
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 486bf3a..3d9bba7 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.ts
@@ -62,14 +62,14 @@
 import {KeyLocations} from '../gr-diff-processor/gr-diff-processor';
 import {FlattenedNodesObserver} from '@polymer/polymer/lib/utils/flattened-nodes-observer';
 import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
-import {AbortStop} from '../../shared/gr-cursor-manager/gr-cursor-manager';
 import {fireAlert, fireEvent} from '../../../utils/event-util';
 import {MovedLinkClickedEvent} from '../../../types/events';
 import {getContentEditableRange} from '../../../utils/safari-selection-util';
-
+import {AbortStop} from '../../../api/core';
 import {
   CreateCommentEventDetail as CreateCommentEventDetailApi,
   RenderPreferences,
+  GrDiff as GrDiffApi,
 } from '../../../api/diff';
 import {isSafari, toggleClass} from '../../../utils/dom-util';
 import {assertIsDefined} from '../../../utils/common-util';
@@ -110,7 +110,7 @@
 }
 
 @customElement('gr-diff')
-export class GrDiff extends PolymerElement {
+export class GrDiff extends PolymerElement implements GrDiffApi {
   static get template() {
     return htmlTemplate;
   }
@@ -323,6 +323,12 @@
     super.disconnectedCallback();
   }
 
+  getLineNumEls(side: Side): HTMLElement[] {
+    return Array.from(
+      this.root?.querySelectorAll<HTMLElement>(`.lineNum.${side}`) ?? []
+    );
+  }
+
   showNoChangeMessage(
     loading?: boolean,
     prefs?: DiffPreferencesInfo,
@@ -760,6 +766,7 @@
     if (renderPrefs.hide_line_length_indicator) {
       this.classList.add('hide-line-length-indicator');
     }
+    this.$.diffBuilder.updateRenderPrefs(renderPrefs);
   }
 
   _diffChanged(newValue?: DiffInfo) {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_html.ts b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_html.ts
index 9da3bf1..d01943c 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_html.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_html.ts
@@ -429,9 +429,16 @@
     }
     /** Support the line length indicator **/
     .full-width td.content .contentText {
-      background-image: var(--line-length-indicator);
+      /*
+      Same strategy as in https://stackoverflow.com/questions/1179928/how-can-i-put-a-vertical-line-down-the-center-of-a-div
+      */
+      background-image: linear-gradient(
+        var(--line-length-indicator-color),
+        var(--line-length-indicator-color)
+      );
+      background-size: 1px 100%;
       background-position: var(--line-limit) 0;
-      background-repeat: repeat-y;
+      background-repeat: no-repeat;
     }
     .newlineWarning {
       color: var(--deemphasized-text-color);
diff --git a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.js b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.js
index 9af7e05..89b8b4a 100644
--- a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.js
@@ -63,7 +63,7 @@
 
     // Stub methods on the changeComments object after changeComments has
     // been initialized.
-    return commentApiWrapper.loadComments();
+    element.changeComments = new ChangeComments();
   });
 
   test('enabled/disabled options', () => {
@@ -217,11 +217,10 @@
         // Should be recomputed for each available patch
         sinon.stub(element, '_computeBaseDropdownContent');
         assert.equal(element._computeBaseDropdownContent.callCount, 0);
-        commentApiWrapper.loadComments().then()
-            .then(() => {
-              assert.equal(element._computeBaseDropdownContent.callCount, 1);
-              done();
-            });
+        element.changeComments = new ChangeComments();
+        flush();
+        assert.equal(element._computeBaseDropdownContent.callCount, 1);
+        done();
       });
 
   test('_computePatchDropdownContent called when basePatchNum updates', () => {
@@ -248,33 +247,6 @@
     assert.equal(element._computePatchDropdownContent.callCount, 1);
   });
 
-  test('_computePatchDropdownContent called when comments update', done => {
-    element.revisions = [
-      {commit: {parents: []}},
-      {commit: {parents: []}},
-      {commit: {parents: []}},
-      {commit: {parents: []}},
-    ];
-    element.revisionInfo = getInfo(element.revisions);
-    element.availablePatches = [
-      {num: 1, sha: '1'},
-      {num: 2, sha: '2'},
-      {num: 3, sha: '3'},
-      {num: 'edit', sha: '4'},
-    ];
-    element.patchNum = 2;
-    element.basePatchNum = 'PARENT';
-    flush();
-
-    // Should be recomputed for each available patch
-    sinon.stub(element, '_computePatchDropdownContent');
-    assert.equal(element._computePatchDropdownContent.callCount, 0);
-    commentApiWrapper.loadComments().then()
-        .then(() => {
-          done();
-        });
-  });
-
   test('_computePatchDropdownContent', () => {
     const availablePatches = [
       {num: 'edit', sha: '1'},
diff --git a/polygerrit-ui/app/elements/diff/gr-ranged-comment-themes/gr-ranged-comment-theme.ts b/polygerrit-ui/app/elements/diff/gr-ranged-comment-themes/gr-ranged-comment-theme.ts
index 131825a..948578d 100644
--- a/polygerrit-ui/app/elements/diff/gr-ranged-comment-themes/gr-ranged-comment-theme.ts
+++ b/polygerrit-ui/app/elements/diff/gr-ranged-comment-themes/gr-ranged-comment-theme.ts
@@ -36,9 +36,3 @@
 </dom-module>`;
 
 document.head.appendChild($_documentContainer.content);
-
-/*
-  FIXME(polymer-modulizer): the above comments were extracted
-  from HTML and may be out of place here. Review them and
-  then delete this comment!
-*/
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/diff/gr-syntax-themes/gr-syntax-theme.ts b/polygerrit-ui/app/elements/diff/gr-syntax-themes/gr-syntax-theme.ts
index ac015e1..df0d71a 100644
--- a/polygerrit-ui/app/elements/diff/gr-syntax-themes/gr-syntax-theme.ts
+++ b/polygerrit-ui/app/elements/diff/gr-syntax-themes/gr-syntax-theme.ts
@@ -118,9 +118,3 @@
 </dom-module>`;
 
 document.head.appendChild($_documentContainer.content);
-
-/*
-  FIXME(polymer-modulizer): the above comments were extracted
-  from HTML and may be out of place here. Review them and
-  then delete this comment!
-*/
diff --git a/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search.ts b/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search.ts
index 7e4edd6..f125bfa 100644
--- a/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search.ts
+++ b/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search.ts
@@ -48,7 +48,7 @@
   _loading = true;
 
   @property({type: String})
-  _filter = '';
+  _filter?: string;
 
   private readonly restApiService = appContext.restApiService;
 
diff --git a/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_html.ts b/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_html.ts
index dd1faeb..95ce1ec 100644
--- a/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_html.ts
+++ b/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_html.ts
@@ -25,7 +25,6 @@
   </style>
   <gr-list-view
     filter="[[_filter]]"
-    items="false"
     offset="0"
     loading="[[_loading]]"
     path="/Documentation"
diff --git a/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_test.js b/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_test.ts
similarity index 68%
rename from polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_test.js
rename to polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_test.ts
index f5e47ca..9c196c7 100644
--- a/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_test.js
+++ b/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_test.ts
@@ -15,15 +15,18 @@
  * limitations under the License.
  */
 
-import '../../../test/common-test-setup-karma.js';
-import './gr-documentation-search.js';
-import {page} from '../../../utils/page-wrapper-utils.js';
-import 'lodash/lodash.js';
-import {stubRestApi} from '../../../test/test-utils.js';
+import '../../../test/common-test-setup-karma';
+import './gr-documentation-search';
+import {GrDocumentationSearch} from './gr-documentation-search';
+import {page} from '../../../utils/page-wrapper-utils';
+import 'lodash/lodash';
+import {stubRestApi} from '../../../test/test-utils';
+import {ListViewParams} from '../../../mixins/gr-list-view-mixin/gr-list-view-mixin';
+import {DocResult} from '../../../types/common';
 
 const basicFixture = fixtureFromElement('gr-documentation-search');
 
-let counter;
+let counter: number;
 const documentationGenerator = () => {
   return {
     title: `Gerrit Code Review - REST API Developers Notes${++counter}`,
@@ -32,10 +35,10 @@
 };
 
 suite('gr-documentation-search tests', () => {
-  let element;
-  let documentationSearches;
+  let element: GrDocumentationSearch;
+  let documentationSearches: DocResult[];
 
-  let value;
+  let value: ListViewParams;
 
   setup(() => {
     sinon.stub(page, 'show');
@@ -47,16 +50,23 @@
     setup(done => {
       documentationSearches = _.times(26, documentationGenerator);
       stubRestApi('getDocumentationSearches').returns(
-          Promise.resolve(documentationSearches));
-      element._paramsChanged(value).then(() => { flush(done); });
+        Promise.resolve(documentationSearches)
+      );
+      element._paramsChanged(value).then(() => {
+        flush(done);
+      });
     });
 
     test('test for test repo in the list', done => {
       flush(() => {
-        assert.equal(element._documentationSearches[0].title,
-            'Gerrit Code Review - REST API Developers Notes1');
-        assert.equal(element._documentationSearches[0].url,
-            'Documentation/dev-rest-api.html');
+        assert.equal(
+          element._documentationSearches![0].title,
+          'Gerrit Code Review - REST API Developers Notes1'
+        );
+        assert.equal(
+          element._documentationSearches![0].url,
+          'Documentation/dev-rest-api.html'
+        );
         done();
       });
     });
@@ -65,12 +75,12 @@
   suite('filter', () => {
     setup(() => {
       documentationSearches = _.times(25, documentationGenerator);
-      _.times(1, documentationSearches);
     });
 
     test('_paramsChanged', async () => {
       const stub = stubRestApi('getDocumentationSearches').returns(
-          Promise.resolve(documentationSearches));
+        Promise.resolve(documentationSearches)
+      );
       const value = {filter: 'test'};
       await element._paramsChanged(value);
       assert.isTrue(stub.lastCall.calledWithExactly('test'));
@@ -84,7 +94,6 @@
       assert.equal(getComputedStyle(element.$.loading).display, 'block');
 
       element._loading = false;
-      element._repos = _.times(25, documentationGenerator);
 
       flush();
       assert.equal(element.computeLoadingClass(element._loading), '');
@@ -92,4 +101,3 @@
     });
   });
 });
-
diff --git a/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor.ts b/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor.ts
index 2828918..0016ac6 100644
--- a/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor.ts
+++ b/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor.ts
@@ -42,7 +42,7 @@
    */
 
   @property({type: String})
-  fileContent: string | null = null;
+  fileContent = '';
 
   _handleTextareaInput(e: Event) {
     this.dispatchEvent(
diff --git a/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls.ts b/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls.ts
index 77eccca..f0a3ca1 100644
--- a/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls.ts
+++ b/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls.ts
@@ -35,9 +35,12 @@
   AutocompleteSuggestion,
 } from '../../shared/gr-autocomplete/gr-autocomplete';
 import {appContext} from '../../../services/app-context';
+import {IronInputElement} from '@polymer/iron-input';
+import {fireAlert} from '../../../utils/event-util';
 
 export interface GrEditControls {
   $: {
+    newPathIronInput: IronInputElement;
     overlay: GrOverlay;
     openDialog: GrDialog;
     deleteDialog: GrDialog;
@@ -174,11 +177,15 @@
       '.dialog'
     ) as NodeListOf<GrDialog>;
     for (const dialog of dialogs) {
-      this._closeDialog(dialog);
+      // We set the second param to false, because this function
+      // is called by _showDialog which when you open either restore,
+      // delete or rename dialogs, it reseted the automatically
+      // set input.
+      this._closeDialog(dialog, false);
     }
   }
 
-  _closeDialog(dialog?: GrDialog, clearInputs = false) {
+  _closeDialog(dialog?: GrDialog, clearInputs = true) {
     if (!dialog) return;
 
     if (clearInputs) {
@@ -203,19 +210,25 @@
   }
 
   _handleOpenConfirm(e: Event) {
+    if (!this.change || !this._path) {
+      fireAlert(this, 'You must enter a path.');
+      this._closeDialog(this.$.openDialog);
+      return;
+    }
     const url = GerritNav.getEditUrlForDiff(
       this.change,
       this._path,
       this.patchNum
     );
     GerritNav.navigateToRelativeUrl(url);
-    this._closeDialog(this._getDialogFromEvent(e), true);
+    this._closeDialog(this._getDialogFromEvent(e));
   }
 
   _handleUploadConfirm(path: string, fileData: string) {
     if (!this.change || !path || !fileData) {
-      this._closeDialog(this.$.openDialog, true);
-      return;
+      fireAlert(this, 'You must enter a path and data.');
+      this._closeDialog(this.$.openDialog);
+      return Promise.resolve();
     }
     return this.restApiService
       .saveFileUploadChangeEdit(this.change._number, path, fileData)
@@ -223,7 +236,7 @@
         if (!res || !res.ok) {
           return;
         }
-        this._closeDialog(this.$.openDialog, true);
+        this._closeDialog(this.$.openDialog);
         GerritNav.navigateToChange(this.change);
       });
   }
@@ -232,39 +245,54 @@
     // Get the dialog before the api call as the event will change during bubbling
     // which will make Polymer.dom(e).path an empty array in polymer 2
     const dialog = this._getDialogFromEvent(e);
+    if (!this.change || !this._path) {
+      fireAlert(this, 'You must enter a path.');
+      this._closeDialog(dialog);
+      return;
+    }
     this.restApiService
       .deleteFileInChangeEdit(this.change._number, this._path)
       .then(res => {
         if (!res || !res.ok) {
           return;
         }
-        this._closeDialog(dialog, true);
+        this._closeDialog(dialog);
         GerritNav.navigateToChange(this.change);
       });
   }
 
   _handleRestoreConfirm(e: Event) {
     const dialog = this._getDialogFromEvent(e);
+    if (!this.change || !this._path) {
+      fireAlert(this, 'You must enter a path.');
+      this._closeDialog(dialog);
+      return;
+    }
     this.restApiService
       .restoreFileInChangeEdit(this.change._number, this._path)
       .then(res => {
         if (!res || !res.ok) {
           return;
         }
-        this._closeDialog(dialog, true);
+        this._closeDialog(dialog);
         GerritNav.navigateToChange(this.change);
       });
   }
 
   _handleRenameConfirm(e: Event) {
     const dialog = this._getDialogFromEvent(e);
+    if (!this.change || !this._path || !this._newPath) {
+      fireAlert(this, 'You must enter a old path and a new path.');
+      this._closeDialog(dialog);
+      return;
+    }
     return this.restApiService
       .renameFileInChangeEdit(this.change._number, this._path, this._newPath)
       .then(res => {
         if (!res || !res.ok) {
           return;
         }
-        this._closeDialog(dialog, true);
+        this._closeDialog(dialog);
         GerritNav.navigateToChange(this.change);
       });
   }
@@ -322,6 +350,11 @@
       fr.readAsDataURL(file);
     }
   }
+
+  _handleKeyPress(event: InputEvent) {
+    event.preventDefault();
+    event.stopImmediatePropagation();
+  }
 }
 
 declare global {
diff --git a/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls_html.ts b/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls_html.ts
index eac796e..60aa1eb 100644
--- a/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls_html.ts
+++ b/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls_html.ts
@@ -95,21 +95,28 @@
           query="[[_query]]"
           text="{{_path}}"
         ></gr-autocomplete>
-        <div id="dragDropArea" on-drop="_handleDragAndDropUpload">
-          <p>Drag and drop a file here</p>
-          <p>or</p>
-          <p>
+        <div
+          id="dragDropArea"
+          contenteditable="true"
+          on-drop="_handleDragAndDropUpload"
+          on-keypress="_handleKeyPress"
+        >
+          <p contenteditable="false">Drag and drop a file here</p>
+          <p contenteditable="false">or</p>
+          <p contenteditable="false">
             <iron-input>
               <input
-                is="iron-input"
                 id="fileUploadInput"
                 type="file"
                 on-change="_handleFileUploadChanged"
+                multiple
                 hidden
               />
             </iron-input>
             <label for="fileUploadInput">
-              <gr-button id="fileUploadBrowse">Browse</gr-button>
+              <gr-button id="fileUploadBrowse" contenteditable="false"
+                >Browse</gr-button
+              >
             </label>
           </p>
         </div>
@@ -150,16 +157,11 @@
           text="{{_path}}"
         ></gr-autocomplete>
         <iron-input
-          class="newPathIronInput"
+          id="newPathIronInput"
           bind-value="{{_newPath}}"
           placeholder="Enter the new path."
         >
-          <input
-            class="newPathInput"
-            is="iron-input"
-            bind-value="{{_newPath}}"
-            placeholder="Enter the new path."
-          />
+          <input id="newPathInput" placeholder="Enter the new path." />
         </iron-input>
       </div>
     </gr-dialog>
@@ -174,7 +176,7 @@
       <div class="header" slot="header">Restore this file?</div>
       <div class="main" slot="main">
         <iron-input disabled="" bind-value="{{_path}}">
-          <input is="iron-input" disabled="" bind-value="{{_path}}" />
+          <input disabled="" />
         </iron-input>
       </div>
     </gr-dialog>
diff --git a/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls_test.js b/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls_test.ts
similarity index 61%
rename from polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls_test.js
rename to polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls_test.ts
index bbf4790..a0b5392 100644
--- a/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls_test.js
+++ b/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls_test.ts
@@ -15,27 +15,34 @@
  * limitations under the License.
  */
 
-import '../../../test/common-test-setup-karma.js';
-import './gr-edit-controls.js';
-import {PolymerElement} from '@polymer/polymer/polymer-element.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-import {stubRestApi} from '../../../test/test-utils.js';
+import '../../../test/common-test-setup-karma';
+import './gr-edit-controls';
+import {GrEditControls} from './gr-edit-controls';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {stubRestApi} from '../../../test/test-utils';
+import {createChange, createRevision} from '../../../test/test-data-generators';
+import {GrAutocomplete} from '../../shared/gr-autocomplete/gr-autocomplete';
+import {CommitId, NumericChangeId, PatchSetNum} from '../../../types/common';
+import {RepoName} from '../../../api/rest-api';
+import {queryAndAssert} from '../../../test/test-utils';
+import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
 
 const basicFixture = fixtureFromElement('gr-edit-controls');
 
 suite('gr-edit-controls tests', () => {
-  let element;
+  let element: GrEditControls;
 
-  let showDialogSpy;
-  let closeDialogSpy;
-  let queryStub;
+  let showDialogSpy: sinon.SinonSpy;
+  let closeDialogSpy: sinon.SinonSpy;
+  let hideDialogStub: sinon.SinonStub;
+  let queryStub: sinon.SinonStub;
 
   setup(() => {
     element = basicFixture.instantiate();
-    element.change = {_number: '42'};
+    element.change = createChange();
     showDialogSpy = sinon.spy(element, '_showDialog');
     closeDialogSpy = sinon.spy(element, '_closeDialog');
-    sinon.stub(element, '_hideAllDialogs');
+    hideDialogStub = sinon.stub(element, '_hideAllDialogs');
     queryStub = stubRestApi('queryChangeFiles').returns(Promise.resolve([]));
     flush();
   });
@@ -44,20 +51,22 @@
     // We take 1 away from the total found, due to an extra button being
     // added for the file uploads (browse).
     assert.equal(
-        element.root.querySelectorAll('gr-button').length - 1,
-        element._actions.length);
+      element.root!.querySelectorAll('gr-button').length - 1,
+      element._actions.length
+    );
   });
 
   suite('edit button CUJ', () => {
-    let navStubs;
-    let openAutoComplete;
+    let editDiffStub: sinon.SinonStub;
+    let navStub: sinon.SinonStub;
+    let openAutoComplete: GrAutocomplete;
 
     setup(() => {
-      navStubs = [
-        sinon.stub(GerritNav, 'getEditUrlForDiff'),
-        sinon.stub(GerritNav, 'navigateToRelativeUrl'),
-      ];
-      openAutoComplete = element.$.openDialog.querySelector('gr-autocomplete');
+      editDiffStub = sinon.stub(GerritNav, 'getEditUrlForDiff');
+      navStub = sinon.stub(GerritNav, 'navigateToRelativeUrl');
+      openAutoComplete = element.$.openDialog!.querySelector(
+        'gr-autocomplete'
+      )!;
     });
 
     test('_isValidPath', () => {
@@ -69,10 +78,11 @@
     });
 
     test('open', () => {
-      MockInteractions.tap(element.shadowRoot.querySelector('#open'));
-      element.patchNum = 1;
+      assert.isFalse(hideDialogStub.called);
+      MockInteractions.tap(queryAndAssert(element, '#open'));
+      element.patchNum = 1 as PatchSetNum;
       return showDialogSpy.lastCall.returnValue.then(() => {
-        assert.isTrue(element._hideAllDialogs.called);
+        assert.isTrue(hideDialogStub.called);
         assert.isTrue(element.$.openDialog.disabled);
         assert.isFalse(queryStub.called);
         // Setup _focused manually - in headless mode Chrome sometimes don't
@@ -82,46 +92,52 @@
         openAutoComplete.text = 'src/test.cpp';
         assert.isTrue(queryStub.called);
         assert.isFalse(element.$.openDialog.disabled);
-        MockInteractions.tap(element.$.openDialog.shadowRoot
-            .querySelector('gr-button[primary]'));
-        for (const stub of navStubs) { assert.isTrue(stub.called); }
-        assert.deepEqual(GerritNav.getEditUrlForDiff.lastCall.args,
-            [element.change, 'src/test.cpp', element.patchNum]);
+        MockInteractions.tap(
+          queryAndAssert(element.$.openDialog, 'gr-button[primary]')
+        );
+        assert.isTrue(editDiffStub.called);
+        assert.isTrue(navStub.called);
+        assert.deepEqual(editDiffStub.lastCall.args, [
+          element.change,
+          'src/test.cpp',
+          element.patchNum,
+        ]);
         assert.isTrue(closeDialogSpy.called);
       });
     });
 
     test('cancel', () => {
-      MockInteractions.tap(element.shadowRoot.querySelector('#open'));
+      MockInteractions.tap(queryAndAssert(element, '#open'));
       return showDialogSpy.lastCall.returnValue.then(() => {
         assert.isTrue(element.$.openDialog.disabled);
         openAutoComplete.noDebounce = true;
         openAutoComplete.text = 'src/test.cpp';
         assert.isFalse(element.$.openDialog.disabled);
-        MockInteractions.tap(element.$.openDialog.shadowRoot
-            .querySelector('gr-button'));
-        for (const stub of navStubs) { assert.isFalse(stub.called); }
+        MockInteractions.tap(queryAndAssert(element.$.openDialog, 'gr-button'));
+        assert.isFalse(editDiffStub.called);
+        assert.isFalse(navStub.called);
         assert.isTrue(closeDialogSpy.called);
-        assert.equal(element._path, 'src/test.cpp');
+        assert.equal(element._path, '');
       });
     });
   });
 
   suite('delete button CUJ', () => {
-    let navStub;
-    let deleteStub;
-    let deleteAutocomplete;
+    let navStub: sinon.SinonStub;
+    let deleteStub: sinon.SinonStub;
+    let deleteAutocomplete: GrAutocomplete;
 
     setup(() => {
       navStub = sinon.stub(GerritNav, 'navigateToChange');
       deleteStub = stubRestApi('deleteFileInChangeEdit');
-      deleteAutocomplete =
-          element.$.deleteDialog.querySelector('gr-autocomplete');
+      deleteAutocomplete = element.$.deleteDialog!.querySelector(
+        'gr-autocomplete'
+      )!;
     });
 
     test('delete', () => {
       deleteStub.returns(Promise.resolve({ok: true}));
-      MockInteractions.tap(element.shadowRoot.querySelector('#delete'));
+      MockInteractions.tap(queryAndAssert(element, '#delete'));
       return showDialogSpy.lastCall.returnValue.then(() => {
         assert.isTrue(element.$.deleteDialog.disabled);
         assert.isFalse(queryStub.called);
@@ -132,8 +148,9 @@
         deleteAutocomplete.text = 'src/test.cpp';
         assert.isTrue(queryStub.called);
         assert.isFalse(element.$.deleteDialog.disabled);
-        MockInteractions.tap(element.$.deleteDialog.shadowRoot
-            .querySelector('gr-button[primary]'));
+        MockInteractions.tap(
+          queryAndAssert(element.$.deleteDialog, 'gr-button[primary]')
+        );
         flush();
 
         assert.isTrue(deleteStub.called);
@@ -148,7 +165,7 @@
 
     test('delete fails', () => {
       deleteStub.returns(Promise.resolve({ok: false}));
-      MockInteractions.tap(element.shadowRoot.querySelector('#delete'));
+      MockInteractions.tap(queryAndAssert(element, '#delete'));
       return showDialogSpy.lastCall.returnValue.then(() => {
         assert.isTrue(element.$.deleteDialog.disabled);
         assert.isFalse(queryStub.called);
@@ -159,8 +176,9 @@
         deleteAutocomplete.text = 'src/test.cpp';
         assert.isTrue(queryStub.called);
         assert.isFalse(element.$.deleteDialog.disabled);
-        MockInteractions.tap(element.$.deleteDialog.shadowRoot
-            .querySelector('gr-button[primary]'));
+        MockInteractions.tap(
+          queryAndAssert(element.$.deleteDialog, 'gr-button[primary]')
+        );
         flush();
 
         assert.isTrue(deleteStub.called);
@@ -173,39 +191,38 @@
     });
 
     test('cancel', () => {
-      MockInteractions.tap(element.shadowRoot.querySelector('#delete'));
+      MockInteractions.tap(queryAndAssert(element, '#delete'));
       return showDialogSpy.lastCall.returnValue.then(() => {
         assert.isTrue(element.$.deleteDialog.disabled);
-        element.$.deleteDialog.querySelector('gr-autocomplete').text =
-            'src/test.cpp';
+        element.$.deleteDialog!.querySelector('gr-autocomplete')!.text =
+          'src/test.cpp';
         assert.isFalse(element.$.deleteDialog.disabled);
-        MockInteractions.tap(element.$.deleteDialog.shadowRoot
-            .querySelector('gr-button'));
+        MockInteractions.tap(
+          queryAndAssert(element.$.deleteDialog, 'gr-button')
+        );
         assert.isFalse(navStub.called);
         assert.isTrue(closeDialogSpy.called);
-        assert.equal(element._path, 'src/test.cpp');
+        assert.equal(element._path, '');
       });
     });
   });
 
   suite('rename button CUJ', () => {
-    let navStub;
-    let renameStub;
-    let renameAutocomplete;
-    const inputSelector = PolymerElement ?
-      '.newPathIronInput' :
-      '.newPathInput';
+    let navStub: sinon.SinonStub;
+    let renameStub: sinon.SinonStub;
+    let renameAutocomplete: GrAutocomplete;
 
     setup(() => {
       navStub = sinon.stub(GerritNav, 'navigateToChange');
       renameStub = stubRestApi('renameFileInChangeEdit');
-      renameAutocomplete =
-          element.$.renameDialog.querySelector('gr-autocomplete');
+      renameAutocomplete = element.$.renameDialog!.querySelector(
+        'gr-autocomplete'
+      )!;
     });
 
     test('rename', () => {
       renameStub.returns(Promise.resolve({ok: true}));
-      MockInteractions.tap(element.shadowRoot.querySelector('#rename'));
+      MockInteractions.tap(queryAndAssert(element, '#rename'));
       return showDialogSpy.lastCall.returnValue.then(() => {
         assert.isTrue(element.$.renameDialog.disabled);
         assert.isFalse(queryStub.called);
@@ -217,12 +234,12 @@
         assert.isTrue(queryStub.called);
         assert.isTrue(element.$.renameDialog.disabled);
 
-        element.$.renameDialog.querySelector(inputSelector).bindValue =
-            'src/test.newPath';
+        element.$.newPathIronInput.bindValue = 'src/test.newPath';
 
         assert.isFalse(element.$.renameDialog.disabled);
-        MockInteractions.tap(element.$.renameDialog.shadowRoot
-            .querySelector('gr-button[primary]'));
+        MockInteractions.tap(
+          queryAndAssert(element.$.renameDialog, 'gr-button[primary]')
+        );
         flush();
 
         assert.isTrue(renameStub.called);
@@ -237,7 +254,7 @@
 
     test('rename fails', () => {
       renameStub.returns(Promise.resolve({ok: false}));
-      MockInteractions.tap(element.shadowRoot.querySelector('#rename'));
+      MockInteractions.tap(queryAndAssert(element, '#rename'));
       return showDialogSpy.lastCall.returnValue.then(() => {
         assert.isTrue(element.$.renameDialog.disabled);
         assert.isFalse(queryStub.called);
@@ -249,12 +266,12 @@
         assert.isTrue(queryStub.called);
         assert.isTrue(element.$.renameDialog.disabled);
 
-        element.$.renameDialog.querySelector(inputSelector).bindValue =
-            'src/test.newPath';
+        element.$.newPathIronInput.bindValue = 'src/test.newPath';
 
         assert.isFalse(element.$.renameDialog.disabled);
-        MockInteractions.tap(element.$.renameDialog.shadowRoot
-            .querySelector('gr-button[primary]'));
+        MockInteractions.tap(
+          queryAndAssert(element.$.renameDialog, 'gr-button[primary]')
+        );
         flush();
 
         assert.isTrue(renameStub.called);
@@ -267,46 +284,47 @@
     });
 
     test('cancel', () => {
-      MockInteractions.tap(element.shadowRoot.querySelector('#rename'));
+      MockInteractions.tap(queryAndAssert(element, '#rename'));
       return showDialogSpy.lastCall.returnValue.then(() => {
         assert.isTrue(element.$.renameDialog.disabled);
-        element.$.renameDialog.querySelector('gr-autocomplete').text =
-            'src/test.cpp';
-        element.$.renameDialog.querySelector(inputSelector).bindValue =
-            'src/test.newPath';
+        element.$.renameDialog!.querySelector('gr-autocomplete')!.text =
+          'src/test.cpp';
+        element.$.newPathIronInput.bindValue = 'src/test.newPath';
         assert.isFalse(element.$.renameDialog.disabled);
-        MockInteractions.tap(element.$.renameDialog.shadowRoot
-            .querySelector('gr-button'));
+        MockInteractions.tap(
+          queryAndAssert(element.$.renameDialog, 'gr-button')
+        );
         assert.isFalse(navStub.called);
         assert.isTrue(closeDialogSpy.called);
-        assert.equal(element._path, 'src/test.cpp');
-        assert.equal(element._newPath, 'src/test.newPath');
+        assert.equal(element._path, '');
+        assert.equal(element._newPath, '');
       });
     });
   });
 
   suite('restore button CUJ', () => {
-    let navStub;
-    let restoreStub;
+    let navStub: sinon.SinonStub;
+    let restoreStub: sinon.SinonStub;
 
     setup(() => {
       navStub = sinon.stub(GerritNav, 'navigateToChange');
-      restoreStub = stubRestApi(
-          'restoreFileInChangeEdit');
+      restoreStub = stubRestApi('restoreFileInChangeEdit');
     });
 
     test('restore hidden by default', () => {
-      assert.isTrue(element.shadowRoot
-          .querySelector('#restore').classList.contains('invisible'));
+      assert.isTrue(
+        queryAndAssert(element, '#restore').classList!.contains('invisible')!
+      );
     });
 
     test('restore', () => {
       restoreStub.returns(Promise.resolve({ok: true}));
       element._path = 'src/test.cpp';
-      MockInteractions.tap(element.shadowRoot.querySelector('#restore'));
+      MockInteractions.tap(queryAndAssert(element, '#restore'));
       return showDialogSpy.lastCall.returnValue.then(() => {
-        MockInteractions.tap(element.$.restoreDialog.shadowRoot
-            .querySelector('gr-button[primary]'));
+        MockInteractions.tap(
+          queryAndAssert(element.$.restoreDialog, 'gr-button[primary]')
+        );
         flush();
 
         assert.isTrue(restoreStub.called);
@@ -322,10 +340,11 @@
     test('restore fails', () => {
       restoreStub.returns(Promise.resolve({ok: false}));
       element._path = 'src/test.cpp';
-      MockInteractions.tap(element.shadowRoot.querySelector('#restore'));
+      MockInteractions.tap(queryAndAssert(element, '#restore'));
       return showDialogSpy.lastCall.returnValue.then(() => {
-        MockInteractions.tap(element.$.restoreDialog.shadowRoot
-            .querySelector('gr-button[primary]'));
+        MockInteractions.tap(
+          queryAndAssert(element.$.restoreDialog, 'gr-button[primary]')
+        );
         flush();
 
         assert.isTrue(restoreStub.called);
@@ -339,20 +358,21 @@
 
     test('cancel', () => {
       element._path = 'src/test.cpp';
-      MockInteractions.tap(element.shadowRoot.querySelector('#restore'));
+      MockInteractions.tap(queryAndAssert(element, '#restore'));
       return showDialogSpy.lastCall.returnValue.then(() => {
-        MockInteractions.tap(element.$.restoreDialog.shadowRoot
-            .querySelector('gr-button'));
+        MockInteractions.tap(
+          queryAndAssert(element.$.restoreDialog, 'gr-button')
+        );
         assert.isFalse(navStub.called);
         assert.isTrue(closeDialogSpy.called);
-        assert.equal(element._path, 'src/test.cpp');
+        assert.equal(element._path, '');
       });
     });
   });
 
   suite('save file upload', () => {
-    let navStub;
-    let fileStub;
+    let navStub: sinon.SinonStub;
+    let fileStub: sinon.SinonStub;
 
     setup(() => {
       navStub = sinon.stub(GerritNav, 'navigateToChange');
@@ -363,32 +383,37 @@
       fileStub.returns(Promise.resolve({ok: true}));
 
       element.change = {
-        _number: '1',
-        project: 'project',
+        ...createChange(),
+        _number: 1 as NumericChangeId,
+        project: 'project' as RepoName,
         revisions: {
-          abcd: {_number: 1},
-          efgh: {_number: 2},
+          abcd: {
+            ...createRevision(1),
+            _number: 1 as PatchSetNum,
+          },
+          efgh: {
+            ...createRevision(2),
+            _number: 2 as PatchSetNum,
+          },
         },
-        current_revision: 'efgh',
+        current_revision: 'efgh' as CommitId,
       };
 
       element._handleUploadConfirm('test.php', 'base64').then(() => {
-        assert.equal(
-            navStub.lastCall.args,
-            '/c/project/+/1');
+        assert.isTrue(navStub.calledWithExactly(1 as NumericChangeId));
       });
     });
   });
 
   test('openOpenDialog', done => {
-    element.openOpenDialog('test/path.cpp')
-        .then(() => {
-          assert.isFalse(element.$.openDialog.hasAttribute('hidden'));
-          assert.equal(
-              element.$.openDialog.querySelector('gr-autocomplete').text,
-              'test/path.cpp');
-          done();
-        });
+    element.openOpenDialog('test/path.cpp').then(() => {
+      assert.isFalse(element.$.openDialog.hasAttribute('hidden'));
+      assert.equal(
+        element.$.openDialog!.querySelector('gr-autocomplete')!.text,
+        'test/path.cpp'
+      );
+      done();
+    });
   });
 
   test('_getDialogFromEvent', () => {
@@ -397,20 +422,20 @@
 
     MockInteractions.tap(element.$.openDialog);
     flush();
-    assert.equal(spy.lastCall.returnValue.id, 'openDialog');
+    assert.equal(spy!.lastCall!.returnValue!.id, 'openDialog');
 
     MockInteractions.tap(element.$.deleteDialog);
     flush();
-    assert.equal(spy.lastCall.returnValue.id, 'deleteDialog');
+    assert.equal(spy!.lastCall!.returnValue!.id, 'deleteDialog');
 
     MockInteractions.tap(
-        element.$.deleteDialog.querySelector('gr-autocomplete'));
+      element.$.deleteDialog!.querySelector('gr-autocomplete')!
+    );
     flush();
-    assert.equal(spy.lastCall.returnValue.id, 'deleteDialog');
+    assert.equal(spy!.lastCall!.returnValue!.id, 'deleteDialog');
 
     MockInteractions.tap(element);
     flush();
-    assert.notOk(spy.lastCall.returnValue);
+    assert.notOk(spy!.lastCall!.returnValue);
   });
 });
-
diff --git a/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls.ts b/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls.ts
index d2e1d64..fabc5c0 100644
--- a/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls.ts
+++ b/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls.ts
@@ -28,7 +28,7 @@
 }
 
 @customElement('gr-edit-file-controls')
-class GrEditFileControls extends PolymerElement {
+export class GrEditFileControls extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
diff --git a/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls_test.js b/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls_test.js
deleted file mode 100644
index 180a3a4..0000000
--- a/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls_test.js
+++ /dev/null
@@ -1,92 +0,0 @@
-/**
- * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../../../test/common-test-setup-karma.js';
-import '../gr-edit-constants.js';
-import './gr-edit-file-controls.js';
-import {GrEditConstants} from '../gr-edit-constants.js';
-
-const basicFixture = fixtureFromElement('gr-edit-file-controls');
-
-suite('gr-edit-file-controls tests', () => {
-  let element;
-
-  let fileActionHandler;
-
-  setup(() => {
-    element = basicFixture.instantiate();
-    fileActionHandler = sinon.stub();
-    element.addEventListener('file-action-tap', fileActionHandler);
-  });
-
-  test('open tap emits event', () => {
-    const actions = element.$.actions;
-    element.filePath = 'foo';
-    actions._open();
-    flush();
-
-    MockInteractions.tap(actions.shadowRoot
-        .querySelector('li [data-id="open"]'));
-    assert.isTrue(fileActionHandler.called);
-    assert.deepEqual(fileActionHandler.lastCall.args[0].detail,
-        {action: GrEditConstants.Actions.OPEN.id, path: 'foo'});
-  });
-
-  test('delete tap emits event', () => {
-    const actions = element.$.actions;
-    element.filePath = 'foo';
-    actions._open();
-    flush();
-
-    MockInteractions.tap(actions.shadowRoot
-        .querySelector('li [data-id="delete"]'));
-    assert.isTrue(fileActionHandler.called);
-    assert.deepEqual(fileActionHandler.lastCall.args[0].detail,
-        {action: GrEditConstants.Actions.DELETE.id, path: 'foo'});
-  });
-
-  test('restore tap emits event', () => {
-    const actions = element.$.actions;
-    element.filePath = 'foo';
-    actions._open();
-    flush();
-
-    MockInteractions.tap(actions.shadowRoot
-        .querySelector('li [data-id="restore"]'));
-    assert.isTrue(fileActionHandler.called);
-    assert.deepEqual(fileActionHandler.lastCall.args[0].detail,
-        {action: GrEditConstants.Actions.RESTORE.id, path: 'foo'});
-  });
-
-  test('rename tap emits event', () => {
-    const actions = element.$.actions;
-    element.filePath = 'foo';
-    actions._open();
-    flush();
-
-    MockInteractions.tap(actions.shadowRoot
-        .querySelector('li [data-id="rename"]'));
-    assert.isTrue(fileActionHandler.called);
-    assert.deepEqual(fileActionHandler.lastCall.args[0].detail,
-        {action: GrEditConstants.Actions.RENAME.id, path: 'foo'});
-  });
-
-  test('computed properties', () => {
-    assert.equal(element._allFileActions.length, 4);
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls_test.ts b/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls_test.ts
new file mode 100644
index 0000000..a432ebf
--- /dev/null
+++ b/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls_test.ts
@@ -0,0 +1,102 @@
+/**
+ * @license
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma';
+import './gr-edit-file-controls';
+import {GrEditFileControls} from './gr-edit-file-controls';
+import {GrEditConstants} from '../gr-edit-constants';
+import {queryAndAssert} from '../../../test/test-utils';
+import {GrDropdown} from '../../shared/gr-dropdown/gr-dropdown';
+import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
+
+const basicFixture = fixtureFromElement('gr-edit-file-controls');
+
+suite('gr-edit-file-controls tests', () => {
+  let element: GrEditFileControls;
+
+  let fileActionHandler: sinon.SinonStub;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+    fileActionHandler = sinon.stub();
+    element.addEventListener('file-action-tap', fileActionHandler);
+  });
+
+  test('open tap emits event', () => {
+    const actions = queryAndAssert<GrDropdown>(element, '#actions');
+    element.filePath = 'foo';
+    actions._open();
+    flush();
+
+    const row = queryAndAssert(actions, 'li [data-id="open"]');
+    MockInteractions.tap(row);
+    assert.isTrue(fileActionHandler.called);
+    assert.deepEqual(fileActionHandler.lastCall.args[0].detail, {
+      action: GrEditConstants.Actions.OPEN.id,
+      path: 'foo',
+    });
+  });
+
+  test('delete tap emits event', () => {
+    const actions = queryAndAssert<GrDropdown>(element, '#actions');
+    element.filePath = 'foo';
+    actions._open();
+    flush();
+
+    const row = queryAndAssert(actions, 'li [data-id="delete"]');
+    MockInteractions.tap(row);
+    assert.isTrue(fileActionHandler.called);
+    assert.deepEqual(fileActionHandler.lastCall.args[0].detail, {
+      action: GrEditConstants.Actions.DELETE.id,
+      path: 'foo',
+    });
+  });
+
+  test('restore tap emits event', () => {
+    const actions = queryAndAssert<GrDropdown>(element, '#actions');
+    element.filePath = 'foo';
+    actions._open();
+    flush();
+
+    const row = queryAndAssert(actions, 'li [data-id="restore"]');
+    MockInteractions.tap(row);
+    assert.isTrue(fileActionHandler.called);
+    assert.deepEqual(fileActionHandler.lastCall.args[0].detail, {
+      action: GrEditConstants.Actions.RESTORE.id,
+      path: 'foo',
+    });
+  });
+
+  test('rename tap emits event', () => {
+    const actions = queryAndAssert<GrDropdown>(element, '#actions');
+    element.filePath = 'foo';
+    actions._open();
+    flush();
+
+    const row = queryAndAssert(actions, 'li [data-id="rename"]');
+    MockInteractions.tap(row);
+    assert.isTrue(fileActionHandler.called);
+    assert.deepEqual(fileActionHandler.lastCall.args[0].detail, {
+      action: GrEditConstants.Actions.RENAME.id,
+      path: 'foo',
+    });
+  });
+
+  test('computed properties', () => {
+    assert.equal(element._allFileActions.length, 4);
+  });
+});
diff --git a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.ts b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.ts
index fb8c2e0..da75914 100644
--- a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.ts
+++ b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.ts
@@ -43,6 +43,10 @@
 import {ErrorCallback} from '../../../api/rest';
 import {assertIsDefined} from '../../../utils/common-util';
 import {debounce, DelayedTask} from '../../../utils/async-util';
+import {changeIsMerged, changeIsAbandoned} from '../../../utils/change-util';
+import {GrButton} from '../../shared/gr-button/gr-button';
+import {GrDefaultEditor} from '../gr-default-editor/gr-default-editor';
+import {GrEndpointDecorator} from '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
 
 const RESTORED_MESSAGE = 'Content restored from a previous edit.';
 const SAVING_MESSAGE = 'Saving changes...';
@@ -53,6 +57,17 @@
 
 const STORAGE_DEBOUNCE_INTERVAL_MS = 100;
 
+// Used within the tests
+export interface GrEditorView {
+  $: {
+    close: GrButton;
+    editorEndpoint: GrEndpointDecorator;
+    file: GrDefaultEditor;
+    publish: GrButton;
+    save: GrButton;
+  };
+}
+
 @customElement('gr-editor-view')
 export class GrEditorView extends KeyboardShortcutMixin(PolymerElement) {
   static get template() {
@@ -74,7 +89,7 @@
   @property({type: Object, observer: '_paramsChanged'})
   params?: GenerateUrlEditViewParameters;
 
-  @property({type: Object})
+  @property({type: Object, observer: '_editChange'})
   _change?: ChangeInfo | null;
 
   @property({type: Number})
@@ -93,7 +108,7 @@
   _content?: string;
 
   @property({type: String})
-  _newContent?: string;
+  _newContent = '';
 
   @property({type: Boolean})
   _saving = false;
@@ -117,9 +132,10 @@
 
   private readonly storage = appContext.storageService;
 
-  private storeTask?: DelayedTask;
+  private readonly reporting = appContext.reportingService;
 
-  reporting = appContext.reportingService;
+  // Tests use this so needs to be non private
+  storeTask?: DelayedTask;
 
   get keyBindings() {
     return {
@@ -194,6 +210,16 @@
     });
   }
 
+  _editChange(value?: ChangeInfo | null) {
+    if (!changeIsMerged(value) && !changeIsAbandoned(value)) return;
+    if (!value) return;
+    fireAlert(
+      this,
+      'Change edits cannot be created if change is merged or abandoned. Redirected to non edit mode.'
+    );
+    GerritNav.navigateToChange(value);
+  }
+
   _handlePathChanged(e: CustomEvent<string>) {
     // TODO(TS) could be cleaned up, it was added for type requirements
     if (this._changeNum === undefined || !this._path) {
@@ -216,16 +242,8 @@
   }
 
   _viewEditInChangeView() {
-    const patch = this._successfulSave
-      ? (EditPatchSetNum as PatchSetNum)
-      : this._patchNum;
-    if (this._change && patch)
-      GerritNav.navigateToChange(
-        this._change,
-        patch,
-        undefined,
-        patch !== EditPatchSetNum
-      );
+    if (this._change)
+      GerritNav.navigateToChange(this._change, undefined, undefined, true);
   }
 
   _getFileData(
diff --git a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_html.ts b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_html.ts
index 0df04cb..4761037 100644
--- a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_html.ts
+++ b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_html.ts
@@ -28,10 +28,10 @@
       top: 0;
       z-index: 1;
     }
-    header,
-    .subHeader {
+    header {
       align-items: center;
       display: flex;
+      flex-wrap: wrap;
       justify-content: space-between;
       padding: var(--spacing-m) var(--spacing-l);
     }
@@ -68,15 +68,6 @@
     .rightControls {
       justify-content: flex-end;
     }
-    @media screen and (max-width: 50em) {
-      header,
-      .subHeader {
-        display: block;
-      }
-      .rightControls {
-        float: right;
-      }
-    }
   </style>
   <div class="stickyHeader">
     <header>
diff --git a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_test.js b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_test.js
deleted file mode 100644
index a29d45d..0000000
--- a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_test.js
+++ /dev/null
@@ -1,433 +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-editor-view.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-import {HttpMethod} from '../../../constants/constants.js';
-import {stubRestApi} from '../../../test/test-utils.js';
-import {EditPatchSetNum} from '../../../types/common.js';
-
-const basicFixture = fixtureFromElement('gr-editor-view');
-
-suite('gr-editor-view tests', () => {
-  let element;
-
-  let savePathStub;
-  let saveFileStub;
-  let changeDetailStub;
-  let navigateStub;
-  const mockParams = {
-    changeNum: '42',
-    path: 'foo/bar.baz',
-    patchNum: 'edit',
-  };
-
-  setup(() => {
-    element = basicFixture.instantiate();
-    savePathStub = stubRestApi('renameFileInChangeEdit');
-    saveFileStub = stubRestApi('saveChangeEdit');
-    changeDetailStub = stubRestApi(
-        'getDiffChangeDetail');
-    navigateStub = sinon.stub(element, '_viewEditInChangeView');
-  });
-
-  suite('_paramsChanged', () => {
-    test('incorrect view returns immediately', () => {
-      element._paramsChanged(
-          {...mockParams, view: GerritNav.View.DIFF});
-      assert.notOk(element._changeNum);
-    });
-
-    test('good params proceed', () => {
-      changeDetailStub.returns(Promise.resolve({}));
-      const fileStub = sinon.stub(element, '_getFileData').callsFake(() => {
-        element._content = 'text';
-        element._newContent = 'text';
-        element._type = 'application/octet-stream';
-      });
-
-      const promises = element._paramsChanged(
-          {...mockParams, view: GerritNav.View.EDIT});
-
-      flush();
-      assert.equal(element._changeNum, mockParams.changeNum);
-      assert.equal(element._path, mockParams.path);
-      assert.deepEqual(changeDetailStub.lastCall.args[0],
-          mockParams.changeNum);
-      assert.deepEqual(fileStub.lastCall.args,
-          [mockParams.changeNum, mockParams.path, mockParams.patchNum]);
-
-      return promises.then(() => {
-        assert.equal(element._content, 'text');
-        assert.equal(element._newContent, 'text');
-        assert.equal(element._type, 'application/octet-stream');
-      });
-    });
-  });
-
-  test('edit file path', () => {
-    element._changeNum = mockParams.changeNum;
-    element._path = mockParams.path;
-    savePathStub.onFirstCall().returns(Promise.resolve({}));
-    savePathStub.onSecondCall().returns(Promise.resolve({ok: true}));
-
-    // Calling with the same path should not navigate.
-    return element._handlePathChanged({detail: mockParams.path}).then(() => {
-      assert.isFalse(savePathStub.called);
-      // !ok response
-      element._handlePathChanged({detail: 'newPath'}).then(() => {
-        assert.isTrue(savePathStub.called);
-        assert.isFalse(navigateStub.called);
-        // ok response
-        element._handlePathChanged({detail: 'newPath'}).then(() => {
-          assert.isTrue(navigateStub.called);
-          assert.isTrue(element._successfulSave);
-        });
-      });
-    });
-  });
-
-  test('reacts to content-change event', () => {
-    const storeStub = sinon.spy(element.storage, 'setEditableContentItem');
-    element._newContent = 'test';
-    element.$.editorEndpoint.dispatchEvent(new CustomEvent('content-change', {
-      bubbles: true, composed: true,
-      detail: {value: 'new content value'},
-    }));
-    element.storeTask.flush();
-    flush();
-
-    assert.equal(element._newContent, 'new content value');
-    assert.isTrue(storeStub.called);
-    assert.equal(storeStub.lastCall.args[1], 'new content value');
-  });
-
-  suite('edit file content', () => {
-    const originalText = 'file text';
-    const newText = 'file text changed';
-
-    setup(() => {
-      element._changeNum = mockParams.changeNum;
-      element._path = mockParams.path;
-      element._content = originalText;
-      element._newContent = originalText;
-      flush();
-    });
-
-    test('initial load', () => {
-      assert.equal(element.$.file.fileContent, originalText);
-      assert.isTrue(element.$.save.hasAttribute('disabled'));
-    });
-
-    test('file modification and save, !ok response', () => {
-      const saveSpy = sinon.spy(element, '_saveEdit');
-      const eraseStub = sinon.stub(element.storage,
-          'eraseEditableContentItem');
-      const alertStub = sinon.stub(element, '_showAlert');
-      saveFileStub.returns(Promise.resolve({ok: false}));
-      element._newContent = newText;
-      flush();
-
-      assert.isFalse(element.$.save.hasAttribute('disabled'));
-      assert.isFalse(element._saving);
-
-      MockInteractions.tap(element.$.save);
-      assert.isTrue(saveSpy.called);
-      assert.equal(alertStub.lastCall.args[0], 'Saving changes...');
-      assert.isTrue(element._saving);
-      assert.isTrue(element.$.save.hasAttribute('disabled'));
-
-      return saveSpy.lastCall.returnValue.then(() => {
-        assert.isTrue(saveFileStub.called);
-        assert.isTrue(eraseStub.called);
-        assert.isFalse(element._saving);
-        assert.equal(alertStub.lastCall.args[0], 'Failed to save changes');
-        assert.deepEqual(saveFileStub.lastCall.args,
-            [mockParams.changeNum, mockParams.path, newText]);
-        assert.isFalse(navigateStub.called);
-        assert.isFalse(element.$.save.hasAttribute('disabled'));
-        assert.notEqual(element._content, element._newContent);
-      });
-    });
-
-    test('file modification and save', () => {
-      const saveSpy = sinon.spy(element, '_saveEdit');
-      const alertStub = sinon.stub(element, '_showAlert');
-      saveFileStub.returns(Promise.resolve({ok: true}));
-      element._newContent = newText;
-      flush();
-
-      assert.isFalse(element._saving);
-      assert.isFalse(element.$.save.hasAttribute('disabled'));
-
-      MockInteractions.tap(element.$.save);
-      assert.isTrue(saveSpy.called);
-      assert.equal(alertStub.lastCall.args[0], 'Saving changes...');
-      assert.isTrue(element._saving);
-      assert.isTrue(element.$.save.hasAttribute('disabled'));
-
-      return saveSpy.lastCall.returnValue.then(() => {
-        assert.isTrue(saveFileStub.called);
-        assert.isFalse(element._saving);
-        assert.equal(alertStub.lastCall.args[0], 'All changes saved');
-        assert.isTrue(element.$.save.hasAttribute('disabled'));
-        assert.equal(element._content, element._newContent);
-        assert.isTrue(element._successfulSave);
-        assert.isTrue(navigateStub.called);
-      });
-    });
-
-    test('file modification and publish', () => {
-      const saveSpy = sinon.spy(element, '_saveEdit');
-      const alertStub = sinon.stub(element, '_showAlert');
-      const changeActionsStub =
-        stubRestApi('executeChangeAction');
-      saveFileStub.returns(Promise.resolve({ok: true}));
-      element._newContent = newText;
-      flush();
-
-      assert.isFalse(element._saving);
-      assert.isFalse(element.$.save.hasAttribute('disabled'));
-
-      MockInteractions.tap(element.$.publish);
-      assert.isTrue(saveSpy.called);
-      assert.equal(alertStub.getCall(0).args[0], 'Saving changes...');
-      assert.isTrue(element._saving);
-      assert.isTrue(element.$.save.hasAttribute('disabled'));
-
-      return saveSpy.lastCall.returnValue.then(() => {
-        assert.isTrue(saveFileStub.called);
-        assert.isFalse(element._saving);
-
-        assert.equal(alertStub.getCall(1).args[0], 'All changes saved');
-        assert.equal(alertStub.getCall(2).args[0], 'Publishing edit...');
-
-        assert.isTrue(element.$.save.hasAttribute('disabled'));
-        assert.equal(element._content, element._newContent);
-        assert.isTrue(element._successfulSave);
-        assert.isFalse(navigateStub.called);
-
-        const args = changeActionsStub.lastCall.args;
-        assert.equal(args[0], '42');
-        assert.equal(args[1], HttpMethod.POST);
-        assert.equal(args[2], '/edit:publish');
-      });
-    });
-
-    test('file modification and close', () => {
-      const closeSpy = sinon.spy(element, '_handleCloseTap');
-      element._newContent = newText;
-      flush();
-
-      assert.isFalse(element.$.save.hasAttribute('disabled'));
-
-      MockInteractions.tap(element.$.close);
-      assert.isTrue(closeSpy.called);
-      assert.isFalse(saveFileStub.called);
-      assert.isTrue(navigateStub.called);
-    });
-  });
-
-  suite('_getFileData', () => {
-    setup(() => {
-      element._newContent = 'initial';
-      element._content = 'initial';
-      element._type = 'initial';
-      sinon.stub(element.storage, 'getEditableContentItem').returns(null);
-    });
-
-    test('res.ok', () => {
-      stubRestApi('getFileContent')
-          .returns(Promise.resolve({
-            ok: true,
-            type: 'text/javascript',
-            content: 'new content',
-          }));
-
-      // Ensure no data is set with a bad response.
-      return element._getFileData('1', 'test/path', 'edit').then(() => {
-        assert.equal(element._newContent, 'new content');
-        assert.equal(element._content, 'new content');
-        assert.equal(element._type, 'text/javascript');
-      });
-    });
-
-    test('!res.ok', () => {
-      stubRestApi('getFileContent')
-          .returns(Promise.resolve({}));
-
-      // Ensure no data is set with a bad response.
-      return element._getFileData('1', 'test/path', 'edit').then(() => {
-        assert.equal(element._newContent, '');
-        assert.equal(element._content, '');
-        assert.equal(element._type, '');
-      });
-    });
-
-    test('content is undefined', () => {
-      stubRestApi('getFileContent')
-          .returns(Promise.resolve({
-            ok: true,
-            type: 'text/javascript',
-          }));
-
-      return element._getFileData('1', 'test/path', 'edit').then(() => {
-        assert.equal(element._newContent, '');
-        assert.equal(element._content, '');
-        assert.equal(element._type, 'text/javascript');
-      });
-    });
-
-    test('content and type is undefined', () => {
-      stubRestApi('getFileContent')
-          .returns(Promise.resolve({
-            ok: true,
-          }));
-
-      return element._getFileData('1', 'test/path', 'edit').then(() => {
-        assert.equal(element._newContent, '');
-        assert.equal(element._content, '');
-        assert.equal(element._type, '');
-      });
-    });
-  });
-
-  test('_showAlert', done => {
-    element.addEventListener('show-alert', e => {
-      assert.deepEqual(e.detail, {message: 'test message'});
-      assert.isTrue(e.bubbles);
-      done();
-    });
-
-    element._showAlert('test message');
-  });
-
-  test('_viewEditInChangeView respects _patchNum', () => {
-    element._change = {};
-    navigateStub.restore();
-    const navStub = sinon.stub(GerritNav, 'navigateToChange');
-    element._patchNum = EditPatchSetNum;
-    element._viewEditInChangeView();
-    assert.equal(navStub.lastCall.args[1], EditPatchSetNum);
-    element._patchNum = '1';
-    element._viewEditInChangeView();
-    assert.equal(navStub.lastCall.args[1], '1');
-    element._successfulSave = true;
-    element._viewEditInChangeView();
-    assert.equal(navStub.lastCall.args[1], EditPatchSetNum);
-  });
-
-  suite('keyboard shortcuts', () => {
-    // Used as the spy on the handler for each entry in keyBindings.
-    let handleSpy;
-
-    suite('_handleSaveShortcut', () => {
-      let saveStub;
-      setup(() => {
-        handleSpy = sinon.spy(element, '_handleSaveShortcut');
-        saveStub = sinon.stub(element, '_saveEdit');
-      });
-
-      test('save enabled', () => {
-        element._content = '';
-        element._newContent = '_test';
-        MockInteractions.pressAndReleaseKeyOn(element, 83, 'ctrl', 's');
-        flush();
-
-        assert.isTrue(handleSpy.calledOnce);
-        assert.isTrue(saveStub.calledOnce);
-
-        MockInteractions.pressAndReleaseKeyOn(element, 83, 'meta', 's');
-        flush();
-
-        assert.equal(handleSpy.callCount, 2);
-        assert.equal(saveStub.callCount, 2);
-      });
-
-      test('save disabled', () => {
-        MockInteractions.pressAndReleaseKeyOn(element, 83, 'ctrl', 's');
-        flush();
-
-        assert.isTrue(handleSpy.calledOnce);
-        assert.isFalse(saveStub.called);
-
-        MockInteractions.pressAndReleaseKeyOn(element, 83, 'meta', 's');
-        flush();
-
-        assert.equal(handleSpy.callCount, 2);
-        assert.isFalse(saveStub.called);
-      });
-    });
-  });
-
-  suite('gr-storage caching', () => {
-    test('local edit exists', () => {
-      sinon.stub(element.storage, 'getEditableContentItem')
-          .returns({message: 'pending edit'});
-      stubRestApi('getFileContent')
-          .returns(Promise.resolve({
-            ok: true,
-            type: 'text/javascript',
-            content: 'old content',
-          }));
-
-      const alertStub = sinon.stub();
-      element.addEventListener('show-alert', alertStub);
-
-      return element._getFileData(1, 'test', 1).then(() => {
-        flush();
-
-        assert.isTrue(alertStub.called);
-        assert.equal(element._newContent, 'pending edit');
-        assert.equal(element._content, 'old content');
-        assert.equal(element._type, 'text/javascript');
-      });
-    });
-
-    test('local edit exists, is same as remote edit', () => {
-      sinon.stub(element.storage, 'getEditableContentItem')
-          .returns({message: 'pending edit'});
-      stubRestApi('getFileContent')
-          .returns(Promise.resolve({
-            ok: true,
-            type: 'text/javascript',
-            content: 'pending edit',
-          }));
-
-      const alertStub = sinon.stub();
-      element.addEventListener('show-alert', alertStub);
-
-      return element._getFileData(1, 'test', 1).then(() => {
-        flush();
-
-        assert.isFalse(alertStub.called);
-        assert.equal(element._newContent, 'pending edit');
-        assert.equal(element._content, 'pending edit');
-        assert.equal(element._type, 'text/javascript');
-      });
-    });
-
-    test('storage key computation', () => {
-      element._changeNum = 1;
-      element._patchNum = 1;
-      element._path = 'test';
-      assert.equal(element.storageKey, 'c1_ps1_test');
-    });
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_test.ts b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_test.ts
new file mode 100644
index 0000000..5b04a81
--- /dev/null
+++ b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_test.ts
@@ -0,0 +1,478 @@
+/**
+ * @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 {GrEditorView} from './gr-editor-view';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {HttpMethod} from '../../../constants/constants';
+import {stubRestApi, stubStorage} from '../../../test/test-utils';
+import {
+  EditPatchSetNum,
+  NumericChangeId,
+  PatchSetNum,
+} from '../../../types/common';
+import {
+  createChangeViewChange,
+  createGenerateUrlEditViewParameters,
+} from '../../../test/test-data-generators';
+import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
+
+const basicFixture = fixtureFromElement('gr-editor-view');
+
+suite('gr-editor-view tests', () => {
+  let element: GrEditorView;
+
+  let savePathStub: sinon.SinonStub;
+  let saveFileStub: sinon.SinonStub;
+  let changeDetailStub: sinon.SinonStub;
+  let navigateStub: sinon.SinonStub;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+    savePathStub = stubRestApi('renameFileInChangeEdit');
+    saveFileStub = stubRestApi('saveChangeEdit');
+    changeDetailStub = stubRestApi('getDiffChangeDetail');
+    navigateStub = sinon.stub(element, '_viewEditInChangeView');
+  });
+
+  suite('_paramsChanged', () => {
+    test('good params proceed', () => {
+      changeDetailStub.returns(Promise.resolve({}));
+      const fileStub = sinon.stub(element, '_getFileData').callsFake(() => {
+        element._content = 'text';
+        element._newContent = 'text';
+        element._type = 'application/octet-stream';
+        return Promise.resolve();
+      });
+
+      const promises = element._paramsChanged({
+        ...createGenerateUrlEditViewParameters(),
+      });
+
+      flush();
+      const changeNum = 42 as NumericChangeId;
+      assert.equal(element._changeNum, changeNum);
+      assert.equal(element._path, 'foo/bar.baz');
+      assert.deepEqual(changeDetailStub.lastCall.args[0], changeNum);
+      assert.deepEqual(fileStub.lastCall.args, [
+        changeNum,
+        'foo/bar.baz',
+        EditPatchSetNum as PatchSetNum,
+      ]);
+
+      return promises?.then(() => {
+        assert.equal(element._content, 'text');
+        assert.equal(element._newContent, 'text');
+        assert.equal(element._type, 'application/octet-stream');
+      });
+    });
+  });
+
+  test('edit file path', () => {
+    element._changeNum = 42 as NumericChangeId;
+    element._path = 'foo/bar.baz';
+    savePathStub.onFirstCall().returns(Promise.resolve({}));
+    savePathStub.onSecondCall().returns(Promise.resolve({ok: true}));
+
+    // Calling with the same path should not navigate.
+    return element
+      ._handlePathChanged(new CustomEvent('change', {detail: 'foo/bar.baz'}))
+      .then(() => {
+        assert.isFalse(savePathStub.called);
+        // !ok response
+        element
+          ._handlePathChanged(new CustomEvent('change', {detail: 'newPath'}))
+          .then(() => {
+            assert.isTrue(savePathStub.called);
+            assert.isFalse(navigateStub.called);
+            // ok response
+            element
+              ._handlePathChanged(
+                new CustomEvent('change', {detail: 'newPath'})
+              )
+              .then(() => {
+                assert.isTrue(navigateStub.called);
+                assert.isTrue(element._successfulSave);
+              });
+          });
+      });
+  });
+
+  test('reacts to content-change event', () => {
+    const storageStub = stubStorage('setEditableContentItem');
+    element._newContent = 'test';
+    element.$.editorEndpoint.dispatchEvent(
+      new CustomEvent('content-change', {
+        bubbles: true,
+        composed: true,
+        detail: {value: 'new content value'},
+      })
+    );
+    element.storeTask?.flush();
+    flush();
+
+    assert.equal(element._newContent, 'new content value');
+    assert.isTrue(storageStub.called);
+    assert.equal(storageStub.lastCall.args[1], 'new content value');
+  });
+
+  suite('edit file content', () => {
+    const originalText = 'file text';
+    const newText = 'file text changed';
+
+    setup(() => {
+      element._changeNum = 42 as NumericChangeId;
+      element._path = 'foo/bar.baz';
+      element._content = originalText;
+      element._newContent = originalText;
+      flush();
+    });
+
+    test('initial load', () => {
+      assert.equal(element.$.file.fileContent, originalText);
+      assert.isTrue(element.$.save.hasAttribute('disabled'));
+    });
+
+    test('file modification and save, !ok response', () => {
+      const saveSpy = sinon.spy(element, '_saveEdit');
+      const eraseStub = stubStorage('eraseEditableContentItem');
+      const alertStub = sinon.stub(element, '_showAlert');
+      saveFileStub.returns(Promise.resolve({ok: false}));
+      element._newContent = newText;
+      flush();
+
+      assert.isFalse(element.$.save.hasAttribute('disabled'));
+      assert.isFalse(element._saving);
+
+      MockInteractions.tap(element.$.save);
+      assert.isTrue(saveSpy.called);
+      assert.equal(alertStub.lastCall.args[0], 'Saving changes...');
+      assert.isTrue(element._saving);
+      assert.isTrue(element.$.save.hasAttribute('disabled'));
+
+      return saveSpy.lastCall.returnValue.then(() => {
+        assert.isTrue(saveFileStub.called);
+        assert.isTrue(eraseStub.called);
+        assert.isFalse(element._saving);
+        assert.equal(alertStub.lastCall.args[0], 'Failed to save changes');
+        assert.deepEqual(saveFileStub.lastCall.args, [
+          42 as NumericChangeId,
+          'foo/bar.baz',
+          newText,
+        ]);
+        assert.isFalse(navigateStub.called);
+        assert.isFalse(element.$.save.hasAttribute('disabled'));
+        assert.notEqual(element._content, element._newContent);
+      });
+    });
+
+    test('file modification and save', () => {
+      const saveSpy = sinon.spy(element, '_saveEdit');
+      const alertStub = sinon.stub(element, '_showAlert');
+      saveFileStub.returns(Promise.resolve({ok: true}));
+      element._newContent = newText;
+      flush();
+
+      assert.isFalse(element._saving);
+      assert.isFalse(element.$.save.hasAttribute('disabled'));
+
+      MockInteractions.tap(element.$.save);
+      assert.isTrue(saveSpy.called);
+      assert.equal(alertStub.lastCall.args[0], 'Saving changes...');
+      assert.isTrue(element._saving);
+      assert.isTrue(element.$.save.hasAttribute('disabled'));
+
+      return saveSpy.lastCall.returnValue.then(() => {
+        assert.isTrue(saveFileStub.called);
+        assert.isFalse(element._saving);
+        assert.equal(alertStub.lastCall.args[0], 'All changes saved');
+        assert.isTrue(element.$.save.hasAttribute('disabled'));
+        assert.equal(element._content, element._newContent);
+        assert.isTrue(element._successfulSave);
+        assert.isTrue(navigateStub.called);
+      });
+    });
+
+    test('file modification and publish', () => {
+      const saveSpy = sinon.spy(element, '_saveEdit');
+      const alertStub = sinon.stub(element, '_showAlert');
+      const changeActionsStub = stubRestApi('executeChangeAction');
+      saveFileStub.returns(Promise.resolve({ok: true}));
+      element._newContent = newText;
+      flush();
+
+      assert.isFalse(element._saving);
+      assert.isFalse(element.$.save.hasAttribute('disabled'));
+
+      MockInteractions.tap(element.$.publish);
+      assert.isTrue(saveSpy.called);
+      assert.equal(alertStub.getCall(0).args[0], 'Saving changes...');
+      assert.isTrue(element._saving);
+      assert.isTrue(element.$.save.hasAttribute('disabled'));
+
+      return saveSpy.lastCall.returnValue.then(() => {
+        assert.isTrue(saveFileStub.called);
+        assert.isFalse(element._saving);
+
+        assert.equal(alertStub.getCall(1).args[0], 'All changes saved');
+        assert.equal(alertStub.getCall(2).args[0], 'Publishing edit...');
+
+        assert.isTrue(element.$.save.hasAttribute('disabled'));
+        assert.equal(element._content, element._newContent);
+        assert.isTrue(element._successfulSave);
+        assert.isFalse(navigateStub.called);
+
+        const args = changeActionsStub.lastCall.args;
+        assert.equal(args[0], 42 as NumericChangeId);
+        assert.equal(args[1], HttpMethod.POST);
+        assert.equal(args[2], '/edit:publish');
+      });
+    });
+
+    test('file modification and close', () => {
+      const closeSpy = sinon.spy(element, '_handleCloseTap');
+      element._newContent = newText;
+      flush();
+
+      assert.isFalse(element.$.save.hasAttribute('disabled'));
+
+      MockInteractions.tap(element.$.close);
+      assert.isTrue(closeSpy.called);
+      assert.isFalse(saveFileStub.called);
+      assert.isTrue(navigateStub.called);
+    });
+  });
+
+  suite('_getFileData', () => {
+    setup(() => {
+      element._newContent = 'initial';
+      element._content = 'initial';
+      element._type = 'initial';
+      stubStorage('getEditableContentItem').returns(null);
+    });
+
+    test('res.ok', () => {
+      stubRestApi('getFileContent').returns(
+        Promise.resolve({
+          ok: true,
+          type: 'text/javascript',
+          content: 'new content',
+        })
+      );
+
+      // Ensure no data is set with a bad response.
+      return element
+        ._getFileData(
+          1 as NumericChangeId,
+          'test/path',
+          EditPatchSetNum as PatchSetNum
+        )
+        .then(() => {
+          assert.equal(element._newContent, 'new content');
+          assert.equal(element._content, 'new content');
+          assert.equal(element._type, 'text/javascript');
+        });
+    });
+
+    test('!res.ok', () => {
+      stubRestApi('getFileContent').returns(
+        Promise.resolve(new Response(null, {status: 500}))
+      );
+
+      // Ensure no data is set with a bad response.
+      return element
+        ._getFileData(
+          1 as NumericChangeId,
+          'test/path',
+          EditPatchSetNum as PatchSetNum
+        )
+        .then(() => {
+          assert.equal(element._newContent, '');
+          assert.equal(element._content, '');
+          assert.equal(element._type, '');
+        });
+    });
+
+    test('content is undefined', () => {
+      stubRestApi('getFileContent').returns(
+        Promise.resolve({
+          ...new Response(),
+          ok: true,
+          type: 'text/javascript' as ResponseType,
+        })
+      );
+
+      return element
+        ._getFileData(
+          1 as NumericChangeId,
+          'test/path',
+          EditPatchSetNum as PatchSetNum
+        )
+        .then(() => {
+          assert.equal(element._newContent, '');
+          assert.equal(element._content, '');
+          assert.equal(element._type, 'text/javascript');
+        });
+    });
+
+    test('content and type is undefined', () => {
+      stubRestApi('getFileContent').returns(
+        Promise.resolve({...new Response(), ok: true})
+      );
+
+      return element
+        ._getFileData(
+          1 as NumericChangeId,
+          'test/path',
+          EditPatchSetNum as PatchSetNum
+        )
+        .then(() => {
+          assert.equal(element._newContent, '');
+          assert.equal(element._content, '');
+          assert.equal(element._type, '');
+        });
+    });
+  });
+
+  test('_showAlert', done => {
+    element.addEventListener('show-alert', e => {
+      assert.deepEqual(e.detail, {message: 'test message'});
+      assert.isTrue(e.bubbles);
+      done();
+    });
+
+    element._showAlert('test message');
+  });
+
+  test('_viewEditInChangeView', () => {
+    element._change = createChangeViewChange();
+    navigateStub.restore();
+    const navStub = sinon.stub(GerritNav, 'navigateToChange');
+    element._patchNum = EditPatchSetNum;
+    element._viewEditInChangeView();
+    assert.equal(navStub.lastCall.args[1], undefined);
+    assert.equal(navStub.lastCall.args[3], true);
+  });
+
+  suite('keyboard shortcuts', () => {
+    // Used as the spy on the handler for each entry in keyBindings.
+    let handleSpy: sinon.SinonSpy;
+
+    suite('_handleSaveShortcut', () => {
+      let saveStub: sinon.SinonStub;
+      setup(() => {
+        handleSpy = sinon.spy(element, '_handleSaveShortcut');
+        saveStub = sinon.stub(element, '_saveEdit');
+      });
+
+      test('save enabled', () => {
+        element._content = '';
+        element._newContent = '_test';
+        MockInteractions.pressAndReleaseKeyOn(element, 83, 'ctrl', 's');
+        flush();
+
+        assert.isTrue(handleSpy.calledOnce);
+        assert.isTrue(saveStub.calledOnce);
+
+        MockInteractions.pressAndReleaseKeyOn(element, 83, 'meta', 's');
+        flush();
+
+        assert.equal(handleSpy.callCount, 2);
+        assert.equal(saveStub.callCount, 2);
+      });
+
+      test('save disabled', () => {
+        MockInteractions.pressAndReleaseKeyOn(element, 83, 'ctrl', 's');
+        flush();
+
+        assert.isTrue(handleSpy.calledOnce);
+        assert.isFalse(saveStub.called);
+
+        MockInteractions.pressAndReleaseKeyOn(element, 83, 'meta', 's');
+        flush();
+
+        assert.equal(handleSpy.callCount, 2);
+        assert.isFalse(saveStub.called);
+      });
+    });
+  });
+
+  suite('gr-storage caching', () => {
+    test('local edit exists', () => {
+      stubStorage('getEditableContentItem').returns({
+        message: 'pending edit',
+        updated: 0,
+      });
+      stubRestApi('getFileContent').returns(
+        Promise.resolve({
+          ok: true,
+          type: 'text/javascript',
+          content: 'old content',
+        })
+      );
+
+      const alertStub = sinon.stub();
+      element.addEventListener('show-alert', alertStub);
+
+      return element
+        ._getFileData(1 as NumericChangeId, 'test', 1 as PatchSetNum)
+        .then(() => {
+          flush();
+
+          assert.isTrue(alertStub.called);
+          assert.equal(element._newContent, 'pending edit');
+          assert.equal(element._content, 'old content');
+          assert.equal(element._type, 'text/javascript');
+        });
+    });
+
+    test('local edit exists, is same as remote edit', () => {
+      stubStorage('getEditableContentItem').returns({
+        message: 'pending edit',
+        updated: 0,
+      });
+      stubRestApi('getFileContent').returns(
+        Promise.resolve({
+          ok: true,
+          type: 'text/javascript',
+          content: 'pending edit',
+        })
+      );
+
+      const alertStub = sinon.stub();
+      element.addEventListener('show-alert', alertStub);
+
+      return element
+        ._getFileData(1 as NumericChangeId, 'test', 1 as PatchSetNum)
+        .then(() => {
+          flush();
+
+          assert.isFalse(alertStub.called);
+          assert.equal(element._newContent, 'pending edit');
+          assert.equal(element._content, 'pending edit');
+          assert.equal(element._type, 'text/javascript');
+        });
+    });
+
+    test('storage key computation', () => {
+      element._changeNum = 1 as NumericChangeId;
+      element._patchNum = 1 as PatchSetNum;
+      element._path = 'test';
+      assert.equal(element.storageKey, 'c1_ps1_test');
+    });
+  });
+});
diff --git a/polygerrit-ui/app/elements/gr-app-element.ts b/polygerrit-ui/app/elements/gr-app-element.ts
index be9c7c5..ebd143c 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 {
@@ -292,7 +295,6 @@
         showDownloadDialog: false,
         diffMode: null,
         numFilesShown: null,
-        scrollTop: 0,
       },
       changeListView: {
         query: null,
@@ -565,7 +567,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/gr-app-entry-point.ts b/polygerrit-ui/app/elements/gr-app-entry-point.ts
new file mode 100644
index 0000000..b1f9621
--- /dev/null
+++ b/polygerrit-ui/app/elements/gr-app-entry-point.ts
@@ -0,0 +1,23 @@
+/**
+ * @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.
+ */
+
+// DO NOT EXPORT ANYTHING FROM THIS FILE!
+// The rollupjs re-exports everything from the entry-point file. So, we
+// can't use gr-app.ts as an entry point, because it has some exports.
+// This file is used as an entry point for the whole application; as a result,
+// the generated bundle doesn't contain any exports.
+import './gr-app';
diff --git a/polygerrit-ui/app/elements/gr-app-global-var-init.ts b/polygerrit-ui/app/elements/gr-app-global-var-init.ts
index 85a6d49..de749df 100644
--- a/polygerrit-ui/app/elements/gr-app-global-var-init.ts
+++ b/polygerrit-ui/app/elements/gr-app-global-var-init.ts
@@ -24,15 +24,12 @@
 
 import {GrAnnotation} from './diff/gr-diff-highlight/gr-annotation';
 import {page} from '../utils/page-wrapper-utils';
-import {appContext} from '../services/app-context';
 import {GrPluginActionContext} from './shared/gr-js-api-interface/gr-plugin-action-context';
-import {GerritNav} from './core/gr-navigation/gr-navigation';
+import {initGerritPluginApi} from './shared/gr-js-api-interface/gr-gerrit';
 
 export function initGlobalVariables() {
   window.GrAnnotation = GrAnnotation;
   window.page = page;
   window.GrPluginActionContext = GrPluginActionContext;
-  window.Gerrit = window.Gerrit || {};
-  window.Gerrit.Nav = GerritNav;
-  window.Gerrit.Auth = appContext.authService;
+  initGerritPluginApi();
 }
diff --git a/polygerrit-ui/app/elements/gr-app-types.ts b/polygerrit-ui/app/elements/gr-app-types.ts
index 4d60274..e5096c0 100644
--- a/polygerrit-ui/app/elements/gr-app-types.ts
+++ b/polygerrit-ui/app/elements/gr-app-types.ts
@@ -124,6 +124,7 @@
   patchNum?: RevisionPatchSetNum;
   basePatchNum?: BasePatchSetNum;
   queryMap?: Map<string, string> | URLSearchParams;
+  commentId?: UrlEncodedCommentId;
 }
 
 export interface AppElementJustRegisteredParams {
diff --git a/polygerrit-ui/app/elements/gr-app.ts b/polygerrit-ui/app/elements/gr-app.ts
index 2d3289d..463fab9 100644
--- a/polygerrit-ui/app/elements/gr-app.ts
+++ b/polygerrit-ui/app/elements/gr-app.ts
@@ -45,7 +45,7 @@
 installPolymerResin(safeTypesBridge);
 
 @customElement('gr-app')
-class GrApp extends PolymerElement {
+export class GrApp extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
diff --git a/polygerrit-ui/app/elements/gr-app_test.js b/polygerrit-ui/app/elements/gr-app_test.js
index 8178c89..5a3b1f2 100644
--- a/polygerrit-ui/app/elements/gr-app_test.js
+++ b/polygerrit-ui/app/elements/gr-app_test.js
@@ -28,7 +28,7 @@
   let element;
   let configStub;
 
-  setup(done => {
+  setup(async () => {
     sinon.stub(appContext.reportingService, 'appStarted');
     stub('gr-account-dropdown', '_getTopContent');
     stub('gr-router', 'start');
@@ -45,7 +45,7 @@
     stubRestApi('probePath').returns(Promise.resolve(42));
 
     element = basicFixture.instantiate();
-    flush(done);
+    await flush();
   });
 
   const appElement = () => element.$['app-element'];
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/plugins/gr-dom-hooks/gr-dom-hooks.ts b/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks.ts
index 3e8f0a4..3613f4c 100644
--- a/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks.ts
@@ -16,10 +16,10 @@
  */
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {PluginApi} from '../../../api/plugin';
-import {HookApi, HookCallback} from '../../../api/hook';
+import {HookApi, HookCallback, PluginElement} from '../../../api/hook';
 
 export class GrDomHooksManager {
-  private hooks: Record<string, GrDomHook>;
+  private hooks: Record<string, GrDomHook<PluginElement>>;
 
   private plugin: PluginApi;
 
@@ -41,23 +41,33 @@
     }
   }
 
-  getDomHook(endpointName: string, moduleName?: string) {
+  getDomHook<T extends PluginElement>(
+    endpointName: string,
+    moduleName?: string
+  ): HookApi<T> {
     const hookName = this._getHookName(endpointName, moduleName);
     if (!this.hooks[hookName]) {
-      this.hooks[hookName] = new GrDomHook(hookName, moduleName);
+      this.hooks[hookName] = (new GrDomHook<T>(
+        hookName,
+        moduleName
+      ) as unknown) as GrDomHook<PluginElement>;
     }
-    return this.hooks[hookName];
+    return (this.hooks[hookName] as unknown) as GrDomHook<T>;
   }
 }
 
-export class GrDomHook implements HookApi {
+export class GrDomHook<T extends PluginElement> implements HookApi<T> {
   private instances: HTMLElement[] = [];
 
-  private attachCallbacks: HookCallback[] = [];
+  private attachCallbacks: HookCallback<T>[] = [];
 
-  private detachCallbacks: HookCallback[] = [];
+  private detachCallbacks: HookCallback<T>[] = [];
 
-  private moduleName: string;
+  /**
+   * The name of the (custom) element that is going to be created. Matches the T
+   * type parameter.
+   */
+  private readonly moduleName: string;
 
   private lastAttachedPromise: Promise<HTMLElement> | null = null;
 
@@ -87,7 +97,7 @@
     customElements.define(HookPlaceholder.is, HookPlaceholder);
   }
 
-  handleInstanceDetached(instance: HTMLElement) {
+  handleInstanceDetached(instance: T) {
     const index = this.instances.indexOf(instance);
     if (index !== -1) {
       this.instances.splice(index, 1);
@@ -95,7 +105,7 @@
     this.detachCallbacks.forEach(callback => callback(instance));
   }
 
-  handleInstanceAttached(instance: HTMLElement) {
+  handleInstanceAttached(instance: T) {
     this.instances.push(instance);
     this.attachCallbacks.forEach(callback => callback(instance));
   }
@@ -109,7 +119,7 @@
       return Promise.resolve(this.instances.slice(-1)[0]);
     }
     if (!this.lastAttachedPromise) {
-      let resolve: HookCallback;
+      let resolve: HookCallback<T>;
       const promise = new Promise<HTMLElement>(r => {
         resolve = r;
         this.attachCallbacks.push(resolve);
@@ -137,7 +147,7 @@
    * Install a new callback to invoke when a new instance of DOM hook element
    * is attached.
    */
-  onAttached(callback: HookCallback) {
+  onAttached(callback: HookCallback<T>) {
     this.attachCallbacks.push(callback);
     return this;
   }
@@ -147,7 +157,7 @@
    * is detached.
    *
    */
-  onDetached(callback: HookCallback) {
+  onDetached(callback: HookCallback<T>) {
     this.detachCallbacks.push(callback);
     return this;
   }
diff --git a/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator.ts b/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator.ts
index 0e8cbcb..4776eac 100644
--- a/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator.ts
@@ -23,7 +23,7 @@
 import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
 import {customElement, property} from '@polymer/decorators';
 import {PluginApi} from '../../../api/plugin';
-import {HookApi} from '../../../api/hook';
+import {HookApi, PluginElement} from '../../../api/hook';
 
 const INIT_PROPERTIES_TIMEOUT_MS = 10000;
 
@@ -37,7 +37,7 @@
   name!: string;
 
   @property({type: Object})
-  _domHooks = new Map<HTMLElement, HookApi>();
+  _domHooks = new Map<PluginElement, HookApi<PluginElement>>();
 
   @property({type: Object})
   _initializedPlugins = new Map<string, boolean>();
diff --git a/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper.ts b/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper.ts
index 0c6dd4d..0c36cd5 100644
--- a/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper.ts
@@ -57,7 +57,7 @@
         try {
           mayContinue = callback(e);
         } catch (exception) {
-          console.warn(`Plugin error handing event: ${exception}`);
+          this.reporting.error(exception);
         }
         if (mayContinue === false) {
           e.stopImmediatePropagation();
diff --git a/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style.ts b/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style.ts
index 007e4c2..f8d77ba 100644
--- a/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style.ts
@@ -22,7 +22,7 @@
 import {customElement, property} from '@polymer/decorators';
 
 @customElement('gr-external-style')
-class GrExternalStyle extends PolymerElement {
+export class GrExternalStyle extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
diff --git a/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host.ts b/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host.ts
index d0c11fc..8fe9563 100644
--- a/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host.ts
@@ -20,7 +20,7 @@
 import {ServerInfo} from '../../../types/common';
 
 @customElement('gr-plugin-host')
-class GrPluginHost extends PolymerElement {
+export class GrPluginHost extends PolymerElement {
   @property({type: Object, observer: '_configChanged'})
   config?: ServerInfo;
 
diff --git a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.ts b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.ts
index 7ef5a73..bd6835c 100644
--- a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.ts
+++ b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.ts
@@ -22,7 +22,7 @@
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-account-info_html';
 import {customElement, property, observe} from '@polymer/decorators';
-import {AccountInfo, ServerInfo} from '../../../types/common';
+import {AccountDetailInfo, ServerInfo} from '../../../types/common';
 import {EditableAccountField} from '../../../constants/constants';
 import {appContext} from '../../../services/app-context';
 import {fireEvent} from '../../../utils/event-util';
@@ -81,7 +81,7 @@
   _saving = false;
 
   @property({type: Object})
-  _account?: AccountInfo;
+  _account?: AccountDetailInfo;
 
   @property({type: Object})
   _serverConfig?: ServerInfo;
@@ -105,6 +105,8 @@
       })
     );
 
+    promises.push(this.restApiService.invalidateAccountsDetailCache());
+
     promises.push(
       this.restApiService.getAccount().then(account => {
         if (!account) return;
diff --git a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_html.ts b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_html.ts
index 6ca484d..514f00e 100644
--- a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_html.ts
+++ b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_html.ts
@@ -35,7 +35,7 @@
     <section>
       <span class="title"></span>
       <span class="value">
-        <gr-avatar account="[[_account]]" image-size="120"></gr-avatar>
+        <gr-avatar account="[[_account]]" imageSize="120"></gr-avatar>
       </span>
     </section>
     <section class$="[[_hideAvatarChangeUrl(_avatarChangeUrl)]]">
@@ -71,7 +71,6 @@
           id="usernameIronInput"
         >
           <input
-            is="iron-input"
             id="usernameInput"
             disabled="[[_saving]]"
             on-keydown="_handleKeydown"
@@ -89,7 +88,6 @@
           id="nameIronInput"
         >
           <input
-            is="iron-input"
             id="nameInput"
             disabled="[[_saving]]"
             on-keydown="_handleKeydown"
@@ -105,7 +103,6 @@
           bind-value="{{_account.display_name}}"
         >
           <input
-            is="iron-input"
             id="displayNameInput"
             disabled="[[_saving]]"
             on-keydown="_handleKeydown"
@@ -121,7 +118,6 @@
           bind-value="{{_account.status}}"
         >
           <input
-            is="iron-input"
             id="statusInput"
             disabled="[[_saving]]"
             on-keydown="_handleKeydown"
diff --git a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_test.ts b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_test.ts
index 6aa7f7c..4a4862b 100644
--- a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_test.ts
+++ b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_test.ts
@@ -21,6 +21,7 @@
 import {GrAccountInfo} from './gr-account-info';
 import {AccountDetailInfo, ServerInfo} from '../../../types/common';
 import {
+  createAccountDetailWithId,
   createAccountWithIdNameAndEmail,
   createPreferences,
   createServerInfo,
@@ -306,7 +307,7 @@
   });
 
   test('_usernameChanged compares usernames with loose equality', () => {
-    element._account = {};
+    element._account = createAccountDetailWithId();
     element._username = '';
     element._hasUsernameChange = false;
     element._loading = false;
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-change-table-editor/gr-change-table-editor.ts b/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor.ts
index d13b2a9..8b8a923 100644
--- a/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor.ts
+++ b/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor.ts
@@ -44,7 +44,7 @@
   @property({type: Array})
   defaultColumns: string[] = [];
 
-  flagsService = appContext.flagsService;
+  private readonly flagsService = appContext.flagsService;
 
   @observe('serverConfig')
   _configChanged(config: ServerInfo) {
diff --git a/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view.ts b/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view.ts
index b0b7ab8..b724e72 100644
--- a/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view.ts
+++ b/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view.ts
@@ -168,7 +168,7 @@
 
   _hideAgreements(
     item: ContributorAgreementInfo,
-    groups: GroupInfo[],
+    groups?: GroupInfo[],
     signedAgreements?: ContributorAgreementInfo[]
   ) {
     return this._disableAgreements(item, groups, signedAgreements)
@@ -176,18 +176,18 @@
       : 'hide';
   }
 
-  _disableAgreementsText(text: string) {
-    return text.toLowerCase() === 'i agree' ? false : true;
+  _disableAgreementsText(text?: string) {
+    return text?.toLowerCase() === 'i agree' ? false : true;
   }
 
   // This checks for auto_verify_group,
   // if specified it returns 'hideAgreementsTextBox' which
   // then hides the text box and submit button.
   _computeHideAgreementClass(
-    name: string,
+    name?: string,
     contributorAgreements?: ContributorAgreementInfo[]
   ) {
-    if (!contributorAgreements) return '';
+    if (!name || !contributorAgreements) return '';
     return contributorAgreements.some(
       (contributorAgreement: ContributorAgreementInfo) =>
         name === contributorAgreement.name &&
diff --git a/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view_html.ts b/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view_html.ts
index 887382e..4800e5b 100644
--- a/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view_html.ts
+++ b/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view_html.ts
@@ -104,12 +104,7 @@
           bind-value="{{_agreementsText}}"
           placeholder="Enter 'I agree' here"
         >
-          <input
-            id="input-agreements"
-            is="iron-input"
-            bind-value="{{_agreementsText}}"
-            placeholder="Enter 'I agree' here"
-          />
+          <input id="input-agreements" placeholder="Enter 'I agree' here" />
         </iron-input>
         <gr-button
           on-click="_handleSaveAgreements"
diff --git a/polygerrit-ui/app/elements/settings/gr-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/settings/gr-edit-preferences/gr-edit-preferences.ts b/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences.ts
index 5e2c5cb..4909bef 100644
--- a/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences.ts
+++ b/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences.ts
@@ -26,6 +26,9 @@
 
 export interface GrEditPreferences {
   $: {
+    editTabWidth: HTMLInputElement;
+    editColumns: HTMLInputElement;
+    editIndentUnit: HTMLInputElement;
     editSyntaxHighlighting: HTMLInputElement;
     showAutoCloseBrackets: HTMLInputElement;
     showIndentWithTabs: HTMLInputElement;
@@ -58,6 +61,21 @@
     this.hasUnsavedChanges = true;
   }
 
+  _handleEditTabWidthChanged() {
+    this.set('editPrefs.tab_size', Number(this.$.editTabWidth.value));
+    this._handleEditPrefsChanged();
+  }
+
+  _handleEditLineLengthChanged() {
+    this.set('editPrefs.line_length', Number(this.$.editColumns.value));
+    this._handleEditPrefsChanged();
+  }
+
+  _handleEditIndentUnitChanged() {
+    this.set('editPrefs.indent_unit', Number(this.$.editIndentUnit.value));
+    this._handleEditPrefsChanged();
+  }
+
   _handleEditSyntaxHighlightingChanged() {
     this.set(
       'editPrefs.syntax_highlighting',
@@ -101,6 +119,16 @@
       this.hasUnsavedChanges = false;
     });
   }
+
+  /**
+   * bind-value has type string so we have to convert
+   * anything inputed to string.
+   *
+   * This is so typescript checker doesn't fail.
+   */
+  _convertToString(key?: number) {
+    return key !== undefined ? String(key) : '';
+  }
 }
 
 declare global {
diff --git a/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences_html.ts b/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences_html.ts
index f6344f0..a51deaf 100644
--- a/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences_html.ts
+++ b/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences_html.ts
@@ -28,23 +28,11 @@
       <label for="editTabWidth" class="title">Tab width</label>
       <span class="value">
         <iron-input
-          type="number"
-          prevent-invalid-input=""
           allowed-pattern="[0-9]"
-          bind-value="{{editPrefs.tab_size}}"
-          on-keypress="_handleEditPrefsChanged"
-          on-change="_handleEditPrefsChanged"
+          bind-value="[[_convertToString(editPrefs.tab_size)]]"
+          on-change="_handleEditTabWidthChanged"
         >
-          <input
-            is="iron-input"
-            id="editTabWidth"
-            type="number"
-            prevent-invalid-input=""
-            allowed-pattern="[0-9]"
-            bind-value="{{editPrefs.tab_size}}"
-            on-keypress="_handleEditPrefsChanged"
-            on-change="_handleEditPrefsChanged"
-          />
+          <input id="editTabWidth" type="number" />
         </iron-input>
       </span>
     </section>
@@ -52,47 +40,23 @@
       <label for="editColumns" class="title">Columns</label>
       <span class="value">
         <iron-input
-          type="number"
-          prevent-invalid-input=""
           allowed-pattern="[0-9]"
-          bind-value="{{editPrefs.line_length}}"
-          on-keypress="_handleEditPrefsChanged"
-          on-change="_handleEditPrefsChanged"
+          bind-value="[[_convertToString(editPrefs.line_length)]]"
+          on-change="_handleEditLineLengthChanged"
         >
-          <input
-            id="editColumns"
-            is="iron-input"
-            type="number"
-            prevent-invalid-input=""
-            allowed-pattern="[0-9]"
-            bind-value="{{editPrefs.line_length}}"
-            on-keypress="_handleEditPrefsChanged"
-            on-change="_handleEditPrefsChanged"
-          />
+          <input id="editColumns" type="number" />
         </iron-input>
       </span>
     </section>
     <section>
-      <label for="indentUnit" class="title">Indent unit</label>
+      <label for="editIndentUnit" class="title">Indent unit</label>
       <span class="value">
         <iron-input
-          type="number"
-          prevent-invalid-input=""
           allowed-pattern="[0-9]"
-          bind-value="{{editPrefs.indent_unit}}"
-          on-keypress="_handleEditPrefsChanged"
-          on-change="_handleEditPrefsChanged"
+          bind-value="[[_convertToString(editPrefs.indent_unit)]]"
+          on-change="_handleEditIndentUnitChanged"
         >
-          <input
-            is="iron-input"
-            id="indentUnit"
-            type="number"
-            prevent-invalid-input=""
-            allowed-pattern="[0-9]"
-            bind-value="{{editPrefs.indent_unit}}"
-            on-keypress="_handleEditPrefsChanged"
-            on-change="_handleEditPrefsChanged"
-          />
+          <input id="indentUnit" type="number" />
         </iron-input>
       </span>
     </section>
diff --git a/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor.ts b/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor.ts
index 74c6ac0..7f25a86 100644
--- a/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor.ts
+++ b/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor.ts
@@ -105,6 +105,10 @@
       }
     }
   }
+
+  _checkPreferred(preferred?: boolean) {
+    return preferred ?? false;
+  }
 }
 
 declare global {
diff --git a/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor_html.ts b/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor_html.ts
index 0591e4b..666afb7 100644
--- a/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor_html.ts
+++ b/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor_html.ts
@@ -71,12 +71,10 @@
                 checked$="[[item.preferred]]"
               >
                 <input
-                  is="iron-input"
                   class="preferredRadio"
                   type="radio"
                   on-change="_handlePreferredChange"
                   name="preferred"
-                  value="[[item.email]]"
                   checked$="[[item.preferred]]"
                 />
               </iron-input>
@@ -85,7 +83,7 @@
               <gr-button
                 data-index$="[[index]]"
                 on-click="_handleDeleteButton"
-                disabled="[[item.preferred]]"
+                disabled="[[_checkPreferred(item.preferred)]]"
                 class="remove-button"
                 >Delete</gr-button
               >
diff --git a/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor_html.ts b/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor_html.ts
index ab24168..f4641c2 100644
--- a/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor_html.ts
+++ b/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor_html.ts
@@ -70,9 +70,9 @@
               </td>
               <td>
                 <gr-copy-clipboard
-                  has-tooltip=""
-                  button-title="Copy GPG public key to clipboard"
-                  hide-input=""
+                  hasTooltip=""
+                  buttonTitle="Copy GPG public key to clipboard"
+                  hideInput=""
                   text="[[key.key]]"
                 >
                 </gr-copy-clipboard>
diff --git a/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password_html.ts b/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password_html.ts
index 549fc93..811b85c 100644
--- a/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password_html.ts
+++ b/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password_html.ts
@@ -78,9 +78,9 @@
         <span class="title">New Password:</span>
         <span class="value">[[_generatedPassword]]</span>
         <gr-copy-clipboard
-          has-tooltip=""
-          button-title="Copy password to clipboard"
-          hide-input=""
+          hasTooltip=""
+          buttonTitle="Copy password to clipboard"
+          hideInput=""
           text="[[_generatedPassword]]"
         >
         </gr-copy-clipboard>
diff --git a/polygerrit-ui/app/elements/settings/gr-identities/gr-identities_html.ts b/polygerrit-ui/app/elements/settings/gr-identities/gr-identities_html.ts
index ef5d72f..f53881e 100644
--- a/polygerrit-ui/app/elements/settings/gr-identities/gr-identities_html.ts
+++ b/polygerrit-ui/app/elements/settings/gr-identities/gr-identities_html.ts
@@ -98,7 +98,7 @@
       on-confirm="_handleDeleteItemConfirm"
       on-cancel="_handleConfirmDialogCancel"
       item="[[_idName]]"
-      item-type="id"
+      item-type-name="ID"
     ></gr-confirm-delete-item-dialog>
   </gr-overlay>
 `;
diff --git a/polygerrit-ui/app/elements/settings/gr-identities/gr-identities_test.js b/polygerrit-ui/app/elements/settings/gr-identities/gr-identities_test.js
deleted file mode 100644
index 867473f..0000000
--- a/polygerrit-ui/app/elements/settings/gr-identities/gr-identities_test.js
+++ /dev/null
@@ -1,168 +0,0 @@
-/**
- * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../../../test/common-test-setup-karma.js';
-import './gr-identities.js';
-import {stubRestApi} from '../../../test/test-utils.js';
-
-const basicFixture = fixtureFromElement('gr-identities');
-
-suite('gr-identities tests', () => {
-  let element;
-
-  const ids = [
-    {
-      identity: 'username:john',
-      email_address: 'john.doe@example.com',
-      trusted: true,
-    }, {
-      identity: 'gerrit:gerrit',
-      email_address: 'gerrit@example.com',
-    }, {
-      identity: 'mailto:gerrit2@example.com',
-      email_address: 'gerrit2@example.com',
-      trusted: true,
-      can_delete: true,
-    },
-  ];
-
-  setup(async () => {
-    stubRestApi('getExternalIds').returns(Promise.resolve(ids));
-
-    element = basicFixture.instantiate();
-    await element.loadData();
-    await flush();
-  });
-
-  test('renders', () => {
-    const rows = Array.from(
-        element.root.querySelectorAll('tbody tr'));
-
-    assert.equal(rows.length, 2);
-
-    const nameCells = rows.map(row =>
-      row.querySelectorAll('td')[2].textContent
-    );
-
-    assert.equal(nameCells[0].trim(), 'gerrit:gerrit');
-    assert.equal(nameCells[1].trim(), '');
-  });
-
-  test('renders email', () => {
-    const rows = Array.from(
-        element.root.querySelectorAll('tbody tr'));
-
-    assert.equal(rows.length, 2);
-
-    const nameCells = rows.map(row =>
-      row.querySelectorAll('td')[1].textContent
-    );
-
-    assert.equal(nameCells[0], 'gerrit@example.com');
-    assert.equal(nameCells[1], 'gerrit2@example.com');
-  });
-
-  test('_computeIdentity', () => {
-    assert.equal(
-        element._computeIdentity(ids[0].identity), 'username:john');
-    assert.equal(element._computeIdentity(ids[2].identity), '');
-  });
-
-  test('filterIdentities', () => {
-    assert.isFalse(element.filterIdentities(ids[0]));
-
-    assert.isTrue(element.filterIdentities(ids[1]));
-  });
-
-  test('delete id', done => {
-    element._idName = 'mailto:gerrit2@example.com';
-    const loadDataStub = sinon.stub(element, 'loadData');
-    element._handleDeleteItemConfirm().then(() => {
-      assert.isTrue(loadDataStub.called);
-      done();
-    });
-  });
-
-  test('_handleDeleteItem opens modal', () => {
-    const deleteBtn =
-        element.root.querySelector('.deleteButton');
-    const deleteItem = sinon.stub(element, '_handleDeleteItem');
-    MockInteractions.tap(deleteBtn);
-    assert.isTrue(deleteItem.called);
-  });
-
-  test('_computeShowLinkAnotherIdentity', () => {
-    let serverConfig;
-
-    serverConfig = {
-      auth: {
-        git_basic_auth_policy: 'OAUTH',
-      },
-    };
-    assert.isTrue(element._computeShowLinkAnotherIdentity(serverConfig));
-
-    serverConfig = {
-      auth: {
-        git_basic_auth_policy: 'OpenID',
-      },
-    };
-    assert.isTrue(element._computeShowLinkAnotherIdentity(serverConfig));
-
-    serverConfig = {
-      auth: {
-        git_basic_auth_policy: 'HTTP_LDAP',
-      },
-    };
-    assert.isFalse(element._computeShowLinkAnotherIdentity(serverConfig));
-
-    serverConfig = {
-      auth: {
-        git_basic_auth_policy: 'LDAP',
-      },
-    };
-    assert.isFalse(element._computeShowLinkAnotherIdentity(serverConfig));
-
-    serverConfig = {
-      auth: {
-        git_basic_auth_policy: 'HTTP',
-      },
-    };
-    assert.isFalse(element._computeShowLinkAnotherIdentity(serverConfig));
-
-    serverConfig = {};
-    assert.isFalse(element._computeShowLinkAnotherIdentity(serverConfig));
-  });
-
-  test('_showLinkAnotherIdentity', () => {
-    element.serverConfig = {
-      auth: {
-        git_basic_auth_policy: 'OAUTH',
-      },
-    };
-
-    assert.isTrue(element._showLinkAnotherIdentity);
-
-    element.serverConfig = {
-      auth: {
-        git_basic_auth_policy: 'LDAP',
-      },
-    };
-
-    assert.isFalse(element._showLinkAnotherIdentity);
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/settings/gr-identities/gr-identities_test.ts b/polygerrit-ui/app/elements/settings/gr-identities/gr-identities_test.ts
new file mode 100644
index 0000000..d1a4f8f
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-identities/gr-identities_test.ts
@@ -0,0 +1,148 @@
+/**
+ * @license
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma';
+import './gr-identities';
+import {GrIdentities} from './gr-identities';
+import {stubRestApi} from '../../../test/test-utils';
+import {ServerInfo} from '../../../types/common';
+import {createServerInfo} from '../../../test/test-data-generators';
+import {queryAll, queryAndAssert} from '../../../test/test-utils';
+import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
+
+const basicFixture = fixtureFromElement('gr-identities');
+
+suite('gr-identities tests', () => {
+  let element: GrIdentities;
+
+  const ids = [
+    {
+      identity: 'username:john',
+      email_address: 'john.doe@example.com',
+      trusted: true,
+    },
+    {
+      identity: 'gerrit:gerrit',
+      email_address: 'gerrit@example.com',
+    },
+    {
+      identity: 'mailto:gerrit2@example.com',
+      email_address: 'gerrit2@example.com',
+      trusted: true,
+      can_delete: true,
+    },
+  ];
+
+  setup(async () => {
+    stubRestApi('getExternalIds').returns(Promise.resolve(ids));
+
+    element = basicFixture.instantiate();
+    await element.loadData();
+    await flush();
+  });
+
+  test('renders', () => {
+    const rows = Array.from(queryAll(element, 'tbody tr'));
+
+    assert.equal(rows.length, 2);
+
+    const nameCells = rows.map(row => queryAll(row, 'td')[2].textContent);
+
+    assert.equal(nameCells[0]!.trim(), 'gerrit:gerrit');
+    assert.equal(nameCells[1]!.trim(), '');
+  });
+
+  test('renders email', () => {
+    const rows = Array.from(queryAll(element, 'tbody tr'));
+
+    assert.equal(rows.length, 2);
+
+    const nameCells = rows.map(row => queryAll(row, 'td')[1]!.textContent);
+
+    assert.equal(nameCells[0]!, 'gerrit@example.com');
+    assert.equal(nameCells[1]!, 'gerrit2@example.com');
+  });
+
+  test('_computeIdentity', () => {
+    assert.equal(element._computeIdentity(ids[0].identity), 'username:john');
+    assert.equal(element._computeIdentity(ids[2].identity), '');
+  });
+
+  test('filterIdentities', () => {
+    assert.isFalse(element.filterIdentities(ids[0]));
+
+    assert.isTrue(element.filterIdentities(ids[1]));
+  });
+
+  test('delete id', done => {
+    element._idName = 'mailto:gerrit2@example.com';
+    const loadDataStub = sinon.stub(element, 'loadData');
+    element._handleDeleteItemConfirm().then(() => {
+      assert.isTrue(loadDataStub.called);
+      done();
+    });
+  });
+
+  test('_handleDeleteItem opens modal', () => {
+    const deleteBtn = queryAndAssert(element, '.deleteButton');
+    const deleteItem = sinon.stub(element, '_handleDeleteItem');
+    MockInteractions.tap(deleteBtn);
+    assert.isTrue(deleteItem.called);
+  });
+
+  test('_computeShowLinkAnotherIdentity', () => {
+    const config: ServerInfo = {
+      ...createServerInfo(),
+    };
+
+    config.auth.git_basic_auth_policy = 'OAUTH';
+    assert.isTrue(element._computeShowLinkAnotherIdentity(config));
+
+    config.auth.git_basic_auth_policy = 'OpenID';
+    assert.isTrue(element._computeShowLinkAnotherIdentity(config));
+
+    config.auth.git_basic_auth_policy = 'HTTP_LDAP';
+    assert.isFalse(element._computeShowLinkAnotherIdentity(config));
+
+    config.auth.git_basic_auth_policy = 'LDAP';
+    assert.isFalse(element._computeShowLinkAnotherIdentity(config));
+
+    config.auth.git_basic_auth_policy = 'HTTP';
+    assert.isFalse(element._computeShowLinkAnotherIdentity(config));
+
+    assert.isFalse(element._computeShowLinkAnotherIdentity(undefined));
+  });
+
+  test('_showLinkAnotherIdentity', () => {
+    let config: ServerInfo = {
+      ...createServerInfo(),
+    };
+    config.auth.git_basic_auth_policy = 'OAUTH';
+
+    element.serverConfig = config;
+
+    assert.isTrue(element._showLinkAnotherIdentity);
+
+    config = {
+      ...createServerInfo(),
+    };
+    config.auth.git_basic_auth_policy = 'LDAP';
+    element.serverConfig = config;
+
+    assert.isFalse(element._showLinkAnotherIdentity);
+  });
+});
diff --git a/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog_html.ts b/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog_html.ts
index 4b86709..6f270f5 100644
--- a/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog_html.ts
+++ b/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog_html.ts
@@ -75,12 +75,7 @@
         <span class="title">Full Name</span>
         <span class="value">
           <iron-input bind-value="{{_account.name}}">
-            <input
-              is="iron-input"
-              id="name"
-              bind-value="{{_account.name}}"
-              disabled="[[_saving]]"
-            />
+            <input id="name" disabled="[[_saving]]" />
           </iron-input>
         </span>
       </section>
@@ -92,12 +87,7 @@
           >
           <span hidden$="[[!_usernameMutable]]" class="value">
             <iron-input bind-value="{{_username}}">
-              <input
-                is="iron-input"
-                id="username"
-                bind-value="{{_username}}"
-                disabled="[[_saving]]"
-              />
+              <input id="username" disabled="[[_saving]]" />
             </iron-input>
           </span>
         </section>
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-item.ts b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-item.ts
index 7540960..f922483 100644
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-item.ts
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-item.ts
@@ -25,7 +25,7 @@
 }
 
 @customElement('gr-settings-item')
-class GrSettingsItem extends PolymerElement {
+export class GrSettingsItem extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-menu-item.ts b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-menu-item.ts
index 8a6c084..15c7075 100644
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-menu-item.ts
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-menu-item.ts
@@ -26,7 +26,7 @@
 }
 
 @customElement('gr-settings-menu-item')
-class GrSettingsMenuItem extends PolymerElement {
+export class GrSettingsMenuItem extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
diff --git a/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor_html.ts b/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor_html.ts
index 0bee1d3..e853b58 100644
--- a/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor_html.ts
+++ b/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor_html.ts
@@ -76,9 +76,9 @@
               </td>
               <td>
                 <gr-copy-clipboard
-                  has-tooltip=""
-                  button-title="Copy SSH public key to clipboard"
-                  hide-input=""
+                  hasTooltip=""
+                  buttonTitle="Copy SSH public key to clipboard"
+                  hideInput=""
                   text="[[key.ssh_public_key]]"
                 >
                 </gr-copy-clipboard>
diff --git a/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor.ts b/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor.ts
index 6178e7a..4381a59 100644
--- a/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor.ts
+++ b/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor.ts
@@ -31,6 +31,7 @@
 import {hasOwnProperty} from '../../../utils/common-util';
 import {ProjectWatchInfo} from '../../../types/common';
 import {appContext} from '../../../services/app-context';
+import {IronInputElement} from '@polymer/iron-input';
 
 const NOTIFICATION_TYPES = [
   {name: 'Changes', key: 'notify_new_changes'},
@@ -43,9 +44,11 @@
 export interface GrWatchedProjectsEditor {
   $: {
     newFilter: HTMLInputElement;
+    newFilterInput: IronInputElement;
     newProject: GrAutocomplete;
   };
 }
+
 @customElement('gr-watched-projects-editor')
 export class GrWatchedProjectsEditor extends PolymerElement {
   static get template() {
@@ -62,7 +65,7 @@
   _projectsToRemove: ProjectWatchInfo[] = [];
 
   @property({type: Object})
-  _query?: AutocompleteQuery;
+  _query: AutocompleteQuery;
 
   private readonly restApiService = appContext.restApiService;
 
diff --git a/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor_html.ts b/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor_html.ts
index edc8fb2..fb65a03 100644
--- a/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor_html.ts
+++ b/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor_html.ts
@@ -102,13 +102,13 @@
           </th>
           <th colspan$="[[_getTypeCount()]]">
             <iron-input
+              id="newFilterInput"
               class="newFilterInput"
               placeholder="branch:name, or other search expression"
             >
               <input
                 id="newFilter"
                 class="newFilterInput"
-                is="iron-input"
                 placeholder="branch:name, or other search expression"
               />
             </iron-input>
diff --git a/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor_test.js b/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor_test.ts
similarity index 76%
rename from polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor_test.js
rename to polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor_test.ts
index aac8995..cb4b86d 100644
--- a/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor_test.js
+++ b/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor_test.ts
@@ -15,14 +15,18 @@
  * limitations under the License.
  */
 
-import '../../../test/common-test-setup-karma.js';
-import './gr-watched-projects-editor.js';
-import {stubRestApi} from '../../../test/test-utils.js';
+import '../../../test/common-test-setup-karma';
+import './gr-watched-projects-editor';
+import {GrWatchedProjectsEditor} from './gr-watched-projects-editor';
+import {stubRestApi} from '../../../test/test-utils';
+import {ProjectWatchInfo} from '../../../types/common';
+import {queryAll, queryAndAssert} from '../../../test/test-utils';
+import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
 
 const basicFixture = fixtureFromElement('gr-watched-projects-editor');
 
 suite('gr-watched-projects-editor tests', () => {
-  let element;
+  let element: GrWatchedProjectsEditor;
 
   setup(done => {
     const projects = [
@@ -30,29 +34,34 @@
         project: 'project a',
         notify_submitted_changes: true,
         notify_abandoned_changes: true,
-      }, {
+      },
+      {
         project: 'project b',
         filter: 'filter 1',
         notify_new_changes: true,
-      }, {
+      },
+      {
         project: 'project b',
         filter: 'filter 2',
-      }, {
+      },
+      {
         project: 'project c',
         notify_new_changes: true,
         notify_new_patch_sets: true,
         notify_all_comments: true,
       },
-    ];
+    ] as ProjectWatchInfo[];
 
     stubRestApi('getWatchedProjects').returns(Promise.resolve(projects));
     stubRestApi('getSuggestedProjects').callsFake(input => {
       if (input.startsWith('th')) {
-        return Promise.resolve({'the project': {
-          id: 'the project',
-          state: 'ACTIVE',
-          web_links: [],
-        }});
+        return Promise.resolve({
+          'the project': {
+            id: 'the project',
+            state: 'ACTIVE',
+            web_links: [],
+          },
+        });
       } else {
         return Promise.resolve({});
       }
@@ -60,18 +69,18 @@
 
     element = basicFixture.instantiate();
 
-    element.loadData().then(() => { flush(done); });
+    element.loadData().then(() => {
+      flush(done);
+    });
   });
 
   test('renders', () => {
-    const rows = element.shadowRoot
-        .querySelector('table').querySelectorAll('tbody tr');
+    const rows = queryAndAssert(element, 'table').querySelectorAll('tbody tr');
     assert.equal(rows.length, 4);
 
-    function getKeysOfRow(row) {
-      const boxes = rows[row].querySelectorAll('input[checked]');
-      return Array.prototype.map.call(boxes,
-          e => e.getAttribute('data-key'));
+    function getKeysOfRow(row: number) {
+      const boxes = queryAll(rows[row], 'input[checked]');
+      return Array.prototype.map.call(boxes, e => e.getAttribute('data-key'));
     }
 
     let checkedKeys = getKeysOfRow(0);
@@ -157,41 +166,44 @@
   test('_handleAddProject', () => {
     element.$.newProject.value = 'project d';
     element.$.newProject.setText('project d');
-    element.$.newFilter.bindValue = '';
+    element.$.newFilterInput.bindValue = '';
 
     element._handleAddProject();
 
-    assert.equal(element._projects.length, 5);
-    assert.equal(element._projects[4].project, 'project d');
-    assert.isNotOk(element._projects[4].filter);
-    assert.isTrue(element._projects[4]._is_local);
+    const projects = element._projects!;
+    assert.equal(projects.length, 5);
+    assert.equal(projects[4].project, 'project d');
+    assert.isNotOk(projects[4].filter);
+    assert.isTrue(projects[4]._is_local);
   });
 
   test('_handleAddProject with invalid inputs', () => {
     element.$.newProject.value = 'project b';
     element.$.newProject.setText('project b');
-    element.$.newFilter.bindValue = 'filter 1';
+    element.$.newFilterInput.bindValue = 'filter 1';
     element.$.newFilter.value = 'filter 1';
 
     element._handleAddProject();
 
-    assert.equal(element._projects.length, 4);
+    assert.equal(element._projects!.length, 4);
   });
 
   test('_handleRemoveProject', () => {
-    assert.equal(element._projectsToRemove, 0);
-    const button = element.shadowRoot
-        .querySelector('table tbody tr:nth-child(2) gr-button');
+    assert.deepEqual(element._projectsToRemove, []);
+
+    const button = queryAndAssert(
+      element,
+      'table tbody tr:nth-child(2) gr-button'
+    );
     MockInteractions.tap(button);
 
     flush();
 
-    const rows = element.shadowRoot
-        .querySelector('table tbody').querySelectorAll('tr');
+    const rows = queryAndAssert(element, 'table tbody').querySelectorAll('tr');
+
     assert.equal(rows.length, 3);
 
     assert.equal(element._projectsToRemove.length, 1);
     assert.equal(element._projectsToRemove[0].project, 'project b');
   });
 });
-
diff --git a/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.ts b/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.ts
index dab778b..f703037 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.ts
+++ b/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.ts
@@ -17,19 +17,14 @@
 import '../gr-account-link/gr-account-link';
 import '../gr-button/gr-button';
 import '../gr-icons/gr-icons';
-import '../../../styles/shared-styles';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-account-chip_html';
-import {customElement, property} from '@polymer/decorators';
 import {AccountInfo, ChangeInfo} from '../../../types/common';
 import {appContext} from '../../../services/app-context';
+import {GrLitElement} from '../../lit/gr-lit-element';
+import {css, customElement, html, property} from 'lit-element';
+import {classMap} from 'lit-html/directives/class-map';
 
 @customElement('gr-account-chip')
-export class GrAccountChip extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrAccountChip extends GrLitElement {
   /**
    * Fired to indicate a key was pressed while this chip was focused.
    *
@@ -64,10 +59,10 @@
   @property({type: String})
   voteableText?: string;
 
-  @property({type: Boolean, reflectToAttribute: true})
+  @property({type: Boolean, reflect: true})
   disabled = false;
 
-  @property({type: Boolean, reflectToAttribute: true})
+  @property({type: Boolean, reflect: true})
   removable = false;
 
   /**
@@ -78,7 +73,7 @@
   @property({type: Boolean})
   highlightAttention = false;
 
-  @property({type: Boolean, reflectToAttribute: true})
+  @property({type: Boolean, reflect: true})
   showAvatar?: boolean;
 
   @property({type: Boolean})
@@ -86,18 +81,126 @@
 
   private readonly restApiService = appContext.restApiService;
 
-  /** @override */
-  ready() {
-    super.ready();
+  static get styles() {
+    return [
+      css`
+        :host {
+          display: block;
+          overflow: hidden;
+        }
+        .container {
+          align-items: center;
+          background-color: var(--background-color-primary);
+          /** round */
+          border-radius: var(--account-chip-border-radius, 20px);
+          border: 1px solid var(--border-color);
+          display: inline-flex;
+          padding: 0 1px;
+        }
+        :host:focus {
+          border-color: transparent;
+          box-shadow: none;
+          outline: none;
+        }
+        :host:focus .container,
+        :host:focus gr-button {
+          background: #ccc;
+        }
+        .transparentBackground,
+        gr-button.transparentBackground {
+          background-color: transparent;
+        }
+        :host([disabled]) {
+          opacity: 0.6;
+          pointer-events: none;
+        }
+        iron-icon {
+          height: 1.2rem;
+          width: 1.2rem;
+        }
+        .container gr-account-link::part(gr-account-link-text) {
+          color: var(--deemphasized-text-color);
+        }
+      `,
+    ];
+  }
+
+  render() {
+    // To pass CSS mixins for @apply to Polymer components, they need to appear
+    // in <style> inside the template.
+    const customStyle = html`
+      <style>
+        .container {
+          --account-label-padding-horizontal: 6px;
+        }
+        gr-button.remove {
+          --gr-remove-button-style: {
+            border-top-width: 0;
+            border-right-width: 0;
+            border-bottom-width: 0;
+            border-left-width: 0;
+            color: var(--deemphasized-text-color);
+            font-weight: var(--font-weight-normal);
+            height: 0.6em;
+            line-height: 10px;
+            /* This cancels most of the --account-label-padding-horizontal. */
+            margin-left: -4px;
+            padding: 0 2px 0 0;
+            text-decoration: none;
+          }
+        }
+
+        gr-button.remove:hover,
+        gr-button.remove:focus {
+          --gr-button: {
+            @apply --gr-remove-button-style;
+          }
+        }
+        gr-button.remove {
+          --gr-button: {
+            @apply --gr-remove-button-style;
+          }
+        }
+      </style>
+    `;
+    return html`${customStyle}
+      <div
+        class="${classMap({
+          container: true,
+          transparentBackground: this.transparentBackground,
+        })}"
+      >
+        <gr-account-link
+          .account="${this.account}"
+          .change="${this.change}"
+          ?forceAttention=${this.forceAttention}
+          ?highlightAttention=${this.highlightAttention}
+          .voteableText=${this.voteableText}
+        >
+        </gr-account-link>
+        <gr-button
+          id="remove"
+          link=""
+          ?hidden=${!this.removable}
+          aria-label="Remove"
+          class="${classMap({
+            remove: true,
+            transparentBackground: this.transparentBackground,
+          })}"
+          @click=${this._handleRemoveTap}
+        >
+          <iron-icon icon="gr-icons:close"></iron-icon>
+        </gr-button>
+      </div>`;
+  }
+
+  constructor() {
+    super();
     this._getHasAvatars().then(hasAvatars => {
       this.showAvatar = hasAvatars;
     });
   }
 
-  _getBackgroundClass(transparent: boolean) {
-    return transparent ? 'transparentBackground' : '';
-  }
-
   _handleRemoveTap(e: MouseEvent) {
     e.preventDefault();
     this.dispatchEvent(
diff --git a/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip_html.ts b/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip_html.ts
deleted file mode 100644
index e5efebb..0000000
--- a/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip_html.ts
+++ /dev/null
@@ -1,114 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style>
-    :host {
-      display: block;
-      overflow: hidden;
-    }
-    .container {
-      align-items: center;
-      background-color: var(--background-color-primary);
-      /** round */
-      border-radius: var(--account-chip-border-radius, 20px);
-      border: 1px solid var(--border-color);
-      display: inline-flex;
-      padding: 0 1px;
-
-      --account-label-padding-horizontal: 6px;
-      --gr-account-label-text-style: {
-        color: var(--deemphasized-text-color);
-      }
-    }
-    :host([show-avatar]) .container {
-    }
-    :host([removable]) .container {
-    }
-    gr-button.remove {
-      --gr-remove-button-style: {
-        border-top-width: 0;
-        border-right-width: 0;
-        border-bottom-width: 0;
-        border-left-width: 0;
-        color: var(--deemphasized-text-color);
-        font-weight: var(--font-weight-normal);
-        height: 0.6em;
-        line-height: 10px;
-        /* This cancels most of the --account-label-padding-horizontal. */
-        margin-left: -4px;
-        padding: 0 2px 0 0;
-        text-decoration: none;
-      }
-    }
-
-    gr-button.remove:hover,
-    gr-button.remove:focus {
-      --gr-button: {
-        @apply --gr-remove-button-style;
-      }
-    }
-    gr-button.remove {
-      --gr-button: {
-        @apply --gr-remove-button-style;
-      }
-    }
-    :host:focus {
-      border-color: transparent;
-      box-shadow: none;
-      outline: none;
-    }
-    :host:focus .container,
-    :host:focus gr-button {
-      background: #ccc;
-    }
-    .transparentBackground,
-    gr-button.transparentBackground {
-      background-color: transparent;
-    }
-    :host([disabled]) {
-      opacity: 0.6;
-      pointer-events: none;
-    }
-    iron-icon {
-      height: 1.2rem;
-      width: 1.2rem;
-    }
-  </style>
-  <div class$="container [[_getBackgroundClass(transparentBackground)]]">
-    <gr-account-link
-      account="[[account]]"
-      change="[[change]]"
-      force-attention="[[forceAttention]]"
-      highlight-attention="[[highlightAttention]]"
-      voteable-text="[[voteableText]]"
-    >
-    </gr-account-link>
-    <gr-button
-      id="remove"
-      link=""
-      hidden$="[[!removable]]"
-      hidden=""
-      aria-label="Remove"
-      class$="remove [[_getBackgroundClass(transparentBackground)]]"
-      on-click="_handleRemoveTap"
-    >
-      <iron-icon icon="gr-icons:close"></iron-icon>
-    </gr-button>
-  </div>
-`;
diff --git a/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry.ts b/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry.ts
index d793a8d..c250428 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry.ts
+++ b/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry.ts
@@ -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 {
   $: {
@@ -51,22 +54,22 @@
    */
 
   @property({type: Boolean})
-  allowAnyInput?: boolean;
+  allowAnyInput = false;
 
   @property({type: Boolean})
-  borderless?: boolean;
+  borderless = false;
 
   @property({type: String})
-  placeholder?: string;
+  placeholder = '';
 
   @property({type: Number})
   suggestFrom = 0;
 
   @property({type: Object, notify: true})
-  querySuggestions = () => Promise.resolve([]);
+  querySuggestions: AutocompleteQuery = () => Promise.resolve([]);
 
   @property({type: String, observer: '_inputTextChanged'})
-  _inputText?: string;
+  _inputText = '';
 
   get focusStart() {
     return this.$.input.focusStart;
diff --git a/polygerrit-ui/app/elements/shared/gr-account-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.ts b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.ts
index ab4c5a5..66214b4 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.ts
+++ b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.ts
@@ -15,30 +15,25 @@
  * limitations under the License.
  */
 import '@polymer/iron-icon/iron-icon';
-import '../../../styles/shared-styles';
 import '../gr-avatar/gr-avatar';
 import '../gr-hovercard-account/gr-hovercard-account';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-account-label_html';
 import {appContext} from '../../../services/app-context';
 import {getDisplayName} from '../../../utils/display-name-util';
 import {isSelf, isServiceUser} from '../../../utils/account-util';
-import {customElement, property} from '@polymer/decorators';
 import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
 import {ChangeInfo, AccountInfo, ServerInfo} from '../../../types/common';
 import {hasOwnProperty} from '../../../utils/common-util';
 import {fireEvent} from '../../../utils/event-util';
 import {isInvolved} from '../../../utils/change-util';
 import {ShowAlertEventDetail} from '../../../types/events';
+import {GrLitElement} from '../../lit/gr-lit-element';
+import {css, customElement, html, property, state} from 'lit-element';
+import {classMap} from 'lit-html/directives/class-map';
 
 @customElement('gr-account-label')
-export class GrAccountLabel extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrAccountLabel extends GrLitElement {
   @property({type: Object})
-  account!: AccountInfo;
+  account?: AccountInfo;
 
   @property({type: Object})
   _selfAccount?: AccountInfo;
@@ -49,7 +44,7 @@
    * related features like adding the user as a reviewer.
    */
   @property({type: Object})
-  change!: ChangeInfo;
+  change?: ChangeInfo;
 
   @property({type: String})
   voteableText?: string;
@@ -83,37 +78,190 @@
 
   @property({
     type: Boolean,
-    reflectToAttribute: true,
-    computed:
-      '_computeCancelLeftPadding(hideAvatar, ' +
-      'highlightAttention, account, change, forceAttention)',
+    reflect: true,
   })
   cancelLeftPadding = false;
 
   @property({type: Boolean})
   hideStatus = false;
 
-  @property({type: Object})
+  @state()
   _config?: ServerInfo;
 
-  @property({type: Boolean, reflectToAttribute: true})
+  @property({type: Boolean, reflect: true})
+  selectionChipStyle = false;
+
+  @property({
+    type: Boolean,
+    reflect: true,
+  })
   selected = false;
 
-  @property({type: Boolean, reflectToAttribute: true})
+  @property({type: Boolean, reflect: true})
   deselected = false;
 
   reporting: ReportingService;
 
   private readonly restApiService = appContext.restApiService;
 
+  static get styles() {
+    return [
+      css`
+        :host {
+          display: inline-block;
+          vertical-align: top;
+          position: relative;
+          border-radius: var(--label-border-radius);
+          box-sizing: border-box;
+          white-space: nowrap;
+          padding: 0 var(--account-label-padding-horizontal, 0);
+        }
+        /* If the first element is the avatar, then we cancel the left padding,
+        so we can fit nicely into the gr-account-chip rounding. The obvious
+        alternative of 'chip has padding' and 'avatar gets negative margin'
+        does not work, because we need 'overflow:hidden' on the label. */
+        :host([cancelLeftPadding]) {
+          padding-left: 0;
+        }
+        :host::after {
+          content: var(--account-label-suffix);
+        }
+        :host([deselected][selectionChipStyle]) {
+          background-color: var(--background-color-primary);
+          border: 1px solid var(--comment-separator-color);
+          border-radius: 8px;
+          color: var(--deemphasized-text-color);
+        }
+        :host([selected][selectionChipStyle]) {
+          background-color: var(--chip-selected-background-color);
+          border: 1px solid var(--chip-selected-background-color);
+          border-radius: 8px;
+          color: var(--chip-selected-text-color);
+        }
+        :host([selected]) iron-icon.attention {
+          color: var(--chip-selected-text-color);
+        }
+        gr-avatar {
+          height: calc(var(--line-height-normal) - 2px);
+          width: calc(var(--line-height-normal) - 2px);
+          vertical-align: top;
+          position: relative;
+          top: 1px;
+        }
+        #attentionButton {
+          /* This negates the 4px horizontal padding, which we appreciate as a
+         larger click target, but which we don't want to consume space. :-) */
+          margin: 0 -4px 0 -4px;
+          vertical-align: top;
+        }
+        iron-icon.attention {
+          color: var(--deemphasized-text-color);
+          width: 12px;
+          height: 12px;
+          vertical-align: top;
+        }
+        iron-icon.status {
+          color: var(--deemphasized-text-color);
+          width: 14px;
+          height: 14px;
+          vertical-align: top;
+          position: relative;
+          top: 2px;
+        }
+        .name {
+          display: inline-block;
+          text-decoration: inherit;
+          vertical-align: top;
+          overflow: hidden;
+          text-overflow: ellipsis;
+          max-width: var(--account-max-length, 180px);
+        }
+        .hasAttention .name {
+          font-weight: var(--font-weight-bold);
+        }
+      `,
+    ];
+  }
+
+  render() {
+    const {account, change, highlightAttention, forceAttention} = this;
+    if (!account) return;
+    const hasAttention =
+      forceAttention ||
+      this._hasUnforcedAttention(highlightAttention, account, change);
+    this.deselected = !this.selected;
+    this.cancelLeftPadding = !this.hideAvatar && !hasAttention;
+    return html`<span>
+        ${!this.hideHovercard
+          ? html`<gr-hovercard-account
+              for="hovercardTarget"
+              .account="${account}"
+              .change="${change}"
+              ?highlight-attention=${highlightAttention}
+              .voteable-text=${this.voteableText}
+            ></gr-hovercard-account>`
+          : ''}
+        ${hasAttention
+          ? html`<gr-button
+              id="attentionButton"
+              link=""
+              aria-label="Remove user from attention set"
+              @click=${this._handleRemoveAttentionClick}
+              ?disabled=${!this._computeAttentionButtonEnabled(
+                highlightAttention,
+                account,
+                change,
+                this.selected,
+                this._selfAccount
+              )}
+              ?has-tooltip=${this._computeAttentionButtonEnabled(
+                highlightAttention,
+                account,
+                change,
+                false,
+                this._selfAccount
+              )}
+              title="${this._computeAttentionIconTitle(
+                highlightAttention,
+                account,
+                change,
+                forceAttention,
+                this.selected,
+                this._selfAccount
+              )}"
+              ><iron-icon
+                class="attention"
+                icon="gr-icons:attention"
+              ></iron-icon>
+            </gr-button>`
+          : ''}
+      </span>
+      <span
+        id="hovercardTarget"
+        class="${classMap({
+          hasAttention: !!hasAttention,
+        })}"
+      >
+        ${!this.hideAvatar
+          ? html`<gr-avatar .account="${account}" imageSize="32"></gr-avatar>`
+          : ''}
+        <span class="text" part="gr-account-label-text">
+          <span class="name"
+            >${this._computeName(account, this.firstName, this._config)}</span
+          >
+          ${!this.hideStatus && account.status
+            ? html`<iron-icon
+                class="status"
+                icon="gr-icons:calendar"
+              ></iron-icon>`
+            : ''}
+        </span>
+      </span>`;
+  }
+
   constructor() {
     super();
     this.reporting = appContext.reportingService;
-  }
-
-  /** @override */
-  ready() {
-    super.ready();
     this.restApiService.getConfig().then(config => {
       this._config = config;
     });
@@ -122,72 +270,42 @@
     });
     this.addEventListener('attention-set-updated', () => {
       // For re-evaluation of everything that depends on 'change'.
-      this.change = {...this.change};
+      if (this.change) this.change = {...this.change};
     });
   }
 
   _isAttentionSetEnabled(
     highlight: boolean,
     account: AccountInfo,
-    change: ChangeInfo
+    change?: ChangeInfo
   ) {
     return highlight && !!change && !!account && !isServiceUser(account);
   }
 
-  _computeCancelLeftPadding(
-    hideAvatar: boolean,
-    highlight: boolean,
-    account: AccountInfo,
-    change: ChangeInfo,
-    force: boolean
-  ) {
-    return (
-      !hideAvatar && !this._hasAttention(highlight, account, change, force)
-    );
-  }
-
-  _hasAttention(
-    highlight: boolean,
-    account: AccountInfo,
-    change: ChangeInfo,
-    force: boolean
-  ) {
-    return force || this._hasUnforcedAttention(highlight, account, change);
-  }
-
   _hasUnforcedAttention(
     highlight: boolean,
     account: AccountInfo,
-    change: ChangeInfo
+    change?: ChangeInfo
   ) {
     return (
       this._isAttentionSetEnabled(highlight, account, change) &&
+      change &&
       change.attention_set &&
       !!account._account_id &&
       hasOwnProperty(change.attention_set, account._account_id)
     );
   }
 
-  _computeHasAttentionClass(
-    highlight: boolean,
-    account: AccountInfo,
-    change: ChangeInfo,
-    force: boolean
-  ) {
-    return this._hasAttention(highlight, account, change, force)
-      ? 'hasAttention'
-      : '';
-  }
-
   _computeName(
     account?: AccountInfo,
-    config?: ServerInfo,
-    firstName?: boolean
+    firstName?: boolean,
+    config?: ServerInfo
   ) {
     return getDisplayName(config, account, firstName);
   }
 
   _handleRemoveAttentionClick(e: MouseEvent) {
+    if (!this.account || !this.change) return;
     if (this.selected) return;
     e.preventDefault();
     e.stopPropagation();
@@ -229,6 +347,7 @@
   }
 
   _reportingDetails() {
+    if (!this.account) return;
     const targetId = this.account._account_id;
     const ownerId =
       (this.change && this.change.owner && this.change.owner._account_id) || -1;
@@ -252,13 +371,13 @@
   _computeAttentionButtonEnabled(
     highlight: boolean,
     account: AccountInfo,
-    change: ChangeInfo,
-    selfAccount: AccountInfo,
-    selected: boolean
+    change: ChangeInfo | undefined,
+    selected: boolean,
+    selfAccount?: AccountInfo
   ) {
     if (selected) return true;
     return (
-      this._hasUnforcedAttention(highlight, account, change) &&
+      !!this._hasUnforcedAttention(highlight, account, change) &&
       (isInvolved(change, selfAccount) || isSelf(account, selfAccount))
     );
   }
@@ -266,17 +385,17 @@
   _computeAttentionIconTitle(
     highlight: boolean,
     account: AccountInfo,
-    change: ChangeInfo,
-    selfAccount: AccountInfo,
+    change: ChangeInfo | undefined,
     force: boolean,
-    selected: boolean
+    selected: boolean,
+    selfAccount?: AccountInfo
   ) {
     const enabled = this._computeAttentionButtonEnabled(
       highlight,
       account,
       change,
-      selfAccount,
-      selected
+      selected,
+      selfAccount
     );
     return enabled
       ? 'Click to remove the user from the attention set'
diff --git a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_html.ts b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_html.ts
deleted file mode 100644
index c5b66ce3..0000000
--- a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_html.ts
+++ /dev/null
@@ -1,142 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style>
-    :host {
-      display: inline-block;
-      vertical-align: top;
-      position: relative;
-      border-radius: var(--label-border-radius);
-      box-sizing: border-box;
-      white-space: nowrap;
-      padding: 0 var(--account-label-padding-horizontal, 0);
-    }
-    /* If the first element is the avatar, then we cancel the left padding, so
-       we can fit nicely into the gr-account-chip rounding.
-       The obvious alternative of 'chip has padding' and 'avatar gets negative
-       margin' does not work, because we need 'overflow:hidden' on the label. */
-    :host([cancel-left-padding]) {
-      padding-left: 0;
-    }
-    :host::after {
-      content: var(--account-label-suffix);
-    }
-    :host([deselected]) {
-      background-color: var(--background-color-primary);
-      border: 1px solid var(--comment-separator-color);
-      border-radius: 8px;
-      color: var(--deemphasized-text-color);
-    }
-    :host([selected]) {
-      background-color: var(--chip-selected-background-color);
-      border: 1px solid var(--chip-selected-background-color);
-      border-radius: 8px;
-      color: var(--chip-selected-text-color);
-    }
-    :host([selected]) iron-icon.attention {
-      color: var(--chip-selected-text-color);
-    }
-    gr-avatar {
-      height: calc(var(--line-height-normal) - 2px);
-      width: calc(var(--line-height-normal) - 2px);
-      vertical-align: top;
-      position: relative;
-      top: 1px;
-    }
-    .text {
-      @apply --gr-account-label-text-style;
-    }
-    .text:hover {
-      @apply --gr-account-label-text-hover-style;
-    }
-    #attentionButton {
-      /* This negates the 4px horizontal padding, which we appreciate as a
-         larger click target, but which we don't want to consume space. :-) */
-      margin: 0 -4px 0 -4px;
-      vertical-align: top;
-    }
-    iron-icon.attention {
-      color: var(--deemphasized-text-color);
-      width: 12px;
-      height: 12px;
-      vertical-align: top;
-    }
-    iron-icon.status {
-      color: var(--deemphasized-text-color);
-      width: 14px;
-      height: 14px;
-      vertical-align: top;
-      position: relative;
-      top: 2px;
-    }
-    .name {
-      display: inline-block;
-      vertical-align: top;
-      overflow: hidden;
-      text-overflow: ellipsis;
-      max-width: var(--account-max-length, 180px);
-    }
-    .hasAttention .name {
-      font-weight: var(--font-weight-bold);
-    }
-  </style>
-  <span>
-    <template is="dom-if" if="[[!hideHovercard]]">
-      <gr-hovercard-account
-        for="hovercardTarget"
-        account="[[account]]"
-        change="[[change]]"
-        highlight-attention="[[highlightAttention]]"
-        voteable-text="[[voteableText]]"
-      >
-      </gr-hovercard-account>
-    </template>
-    <template
-      is="dom-if"
-      if="[[_hasAttention(highlightAttention, account, change, forceAttention)]]"
-    >
-      <gr-button
-        id="attentionButton"
-        link=""
-        aria-label="Remove user from attention set"
-        on-click="_handleRemoveAttentionClick"
-        disabled="[[!_computeAttentionButtonEnabled(highlightAttention, account, change, _selfAccount, selected)]]"
-        has-tooltip="[[_computeAttentionButtonEnabled(highlightAttention, account, change, _selfAccount, false)]]"
-        title="[[_computeAttentionIconTitle(highlightAttention, account, change, _selfAccount, forceAttention, selected)]]"
-        ><iron-icon class="attention" icon="gr-icons:attention"></iron-icon>
-      </gr-button>
-    </template>
-  </span>
-  <span
-    id="hovercardTarget"
-    class$="[[_computeHasAttentionClass(highlightAttention, account, change, forceAttention)]]"
-  >
-    <template is="dom-if" if="[[!hideAvatar]]">
-      <gr-avatar account="[[account]]" image-size="32"></gr-avatar>
-    </template>
-    <span class="text">
-      <span class="name">[[_computeName(account, _config, firstName)]]</span>
-      <template is="dom-if" if="[[!hideStatus]]">
-        <template is="dom-if" if="[[account.status]]">
-          <iron-icon class="status" icon="gr-icons:calendar"></iron-icon>
-        </template>
-      </template>
-    </span>
-  </span>
-`;
diff --git a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_test.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..574e450
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_test.ts
@@ -0,0 +1,136 @@
+/**
+ * @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, false), 'Wyatt');
+    });
+
+    test('showing anonymous but no config', () => {
+      const account = {};
+      assert.deepEqual(element._computeName(account, false), '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, false, config),
+        'Anonymous'
+      );
+    });
+
+    test('test for anonymous_coward_name', () => {
+      const config = {
+        ...createServerInfo(),
+        user: {
+          anonymous_coward_name: 'TestAnon',
+        },
+      };
+      const account = {};
+      assert.deepEqual(
+        element._computeName(account, false, 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..a610ffa 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link.ts
+++ b/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link.ts
@@ -16,19 +16,13 @@
  */
 
 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, property} from 'lit-element';
+import {GrLitElement} from '../../lit/gr-lit-element';
 
 @customElement('gr-account-link')
-class GrAccountLink extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrAccountLink extends GrLitElement {
   @property({type: String})
   voteableText?: string;
 
@@ -70,6 +64,44 @@
   @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::part(gr-account-label-text):hover {
+          text-decoration: underline !important;
+        }
+      `,
+    ];
+  }
+
+  render() {
+    if (!this.account) return;
+    return html`<span>
+      <a href="${this._computeOwnerLink(this.account)}">
+        <gr-account-label
+          .account="${this.account}"
+          .change="${this.change}"
+          ?forceAttention=${this.forceAttention}
+          ?highlightAttention=${this.highlightAttention}
+          ?hideAvatar=${this.hideAvatar}
+          ?hideStatus=${this.hideStatus}
+          ?firstName=${this.firstName}
+          .voteableText=${this.voteableText}
+          part="gr-account-link-text => gr-account-label-text"
+        >
+        </gr-account-label>
+      </a>
+    </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 231fc36..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.
@@ -326,7 +326,9 @@
         return;
       }
     }
-    console.warn('received remove event for missing account', toRemove);
+    this.reporting.error(
+      new Error(`Received "remove" event for missing account: ${toRemove}`)
+    );
   }
 
   _getNativeInput(paperInput: PaperInputElementExt) {
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-avatar/gr-avatar.ts b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar.ts
index e30e995..80576a7 100644
--- a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar.ts
+++ b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar.ts
@@ -14,22 +14,16 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../styles/shared-styles';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-avatar_html';
 import {getBaseUrl} from '../../../utils/url-util';
 import {getPluginLoader} from '../gr-js-api-interface/gr-plugin-loader';
-import {customElement, property} from '@polymer/decorators';
 import {AccountInfo} from '../../../types/common';
 import {appContext} from '../../../services/app-context';
+import {GrLitElement} from '../../lit/gr-lit-element';
+import {css, customElement, html, property} from 'lit-element';
 
 @customElement('gr-avatar')
-export class GrAvatar extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
-  @property({type: Object, observer: '_accountChanged'})
+export class GrAvatar extends GrLitElement {
+  @property({type: Object})
   account?: AccountInfo;
 
   @property({type: Number})
@@ -40,6 +34,27 @@
 
   private readonly restApiService = appContext.restApiService;
 
+  static get styles() {
+    return [
+      css`
+        :host {
+          display: inline-block;
+          border-radius: 50%;
+          background-size: cover;
+          background-color: var(
+            --avatar-background-color,
+            var(--gray-background)
+          );
+        }
+      `,
+    ];
+  }
+
+  render() {
+    this._updateAvatarURL();
+    return html``;
+  }
+
   /** @override */
   connectedCallback() {
     super.connectedCallback();
@@ -57,10 +72,6 @@
     return this.restApiService.getConfig();
   }
 
-  _accountChanged() {
-    this._updateAvatarURL();
-  }
-
   _updateAvatarURL() {
     if (!this._hasAvatars || !this.account) {
       this.hidden = true;
@@ -80,7 +91,7 @@
     );
   }
 
-  _buildAvatarURL(account: AccountInfo) {
+  _buildAvatarURL(account?: AccountInfo) {
     if (!account) {
       return '';
     }
diff --git a/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star.ts b/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star.ts
index 01ca57a..5069ba4 100644
--- a/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star.ts
+++ b/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star.ts
@@ -48,16 +48,16 @@
   @property({type: Object, notify: true})
   change?: ChangeInfo;
 
-  _computeStarClass(starred: boolean) {
+  _computeStarClass(starred?: boolean) {
     return starred ? 'active' : '';
   }
 
-  _computeStarIcon(starred: boolean) {
+  _computeStarIcon(starred?: boolean) {
     // Hollow star is used to indicate inactive state.
     return `gr-icons:star${starred ? '' : '-border'}`;
   }
 
-  _computeAriaLabel(starred: boolean) {
+  _computeAriaLabel(starred?: boolean) {
     return starred ? 'Unstar this change' : 'Star this change';
   }
 
diff --git a/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status.ts b/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status.ts
index a4f46db..65e8e9f 100644
--- a/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status.ts
+++ b/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status.ts
@@ -28,13 +28,15 @@
 import {ParsedChangeInfo} from '../../../types/types';
 
 export enum ChangeStates {
-  MERGED = 'Merged',
   ABANDONED = 'Abandoned',
+  ACTIVE = 'Active',
   MERGE_CONFLICT = 'Merge Conflict',
-  WIP = 'WIP',
+  MERGED = 'Merged',
   PRIVATE = 'Private',
+  READY_TO_SUBMIT = 'Ready to submit',
   REVERT_CREATED = 'Revert Created',
   REVERT_SUBMITTED = 'Revert Submitted',
+  WIP = 'WIP',
 }
 
 const WIP_TOOLTIP =
@@ -52,7 +54,7 @@
   'current reviewers (or anyone with "View Private Changes" permission).';
 
 @customElement('gr-change-status')
-class GrChangeStatus extends PolymerElement {
+export class GrChangeStatus extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
@@ -91,8 +93,12 @@
     resolveWeblinks?: GeneratedWebLink[],
     status?: ChangeStates
   ): boolean {
+    const isRevertCreatedOrSubmitted =
+      (status === ChangeStates.REVERT_SUBMITTED ||
+        status === ChangeStates.REVERT_CREATED) &&
+      revertedChange !== undefined;
     return (
-      revertedChange !== undefined ||
+      isRevertCreatedOrSubmitted ||
       !!(status === ChangeStates.MERGE_CONFLICT && resolveWeblinks?.length)
     );
   }
diff --git a/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status_html.ts b/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status_html.ts
index 116dcaf..2ca2744b 100644
--- a/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status_html.ts
+++ b/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status_html.ts
@@ -87,7 +87,7 @@
   >
     <template
       is="dom-if"
-      if="[[!!hasStatusLink(revertedChange, resolveWeblinks, status)]]">
+      if="[[hasStatusLink(revertedChange, resolveWeblinks, status)]]">
       <a class="status-link"
          href="[[getStatusLink(revertedChange, resolveWeblinks, status)]]">
         <div class="chip" aria-label$="Label: [[status]]">
@@ -100,7 +100,7 @@
         </div>
       </a>
     </template>
-    <template is="dom-if" if="[[!hasStatusLink(revertedChange)]]">
+    <template is="dom-if" if="[[!hasStatusLink(revertedChange, resolveWeblinks, status)]]">
       <div class="chip" aria-label$="Label: [[status]]">
         [[_computeStatusString(status)]]
       </div>
diff --git a/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status_test.js b/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status_test.js
deleted file mode 100644
index 7bc2466..0000000
--- a/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status_test.js
+++ /dev/null
@@ -1,153 +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 sinon from 'sinon/pkg/sinon-esm';
-import '../../../test/common-test-setup-karma.js';
-import {createChange} from '../../../test/test-data-generators.js';
-import './gr-change-status.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-import {MERGE_CONFLICT_TOOLTIP} from './gr-change-status.js';
-
-const basicFixture = fixtureFromElement('gr-change-status');
-
-const WIP_TOOLTIP = 'This change isn\'t ready to be reviewed or submitted. ' +
-    'It will not appear on dashboards unless you are CC\'ed or assigned, ' +
-    'and email notifications will be silenced until the review is started.';
-
-const PRIVATE_TOOLTIP = 'This change is only visible to its owner and ' +
-    'current reviewers (or anyone with "View Private Changes" permission).';
-
-suite('gr-change-status tests', () => {
-  let element;
-
-  setup(() => {
-    element = basicFixture.instantiate();
-  });
-
-  test('WIP', () => {
-    element.status = 'WIP';
-    flush();
-    assert.equal(element.shadowRoot
-        .querySelector('.chip').innerText, 'Work in Progress');
-    assert.equal(element.tooltipText, WIP_TOOLTIP);
-    assert.isTrue(element.classList.contains('wip'));
-  });
-
-  test('WIP flat', () => {
-    element.flat = true;
-    element.status = 'WIP';
-    flush();
-    assert.equal(element.shadowRoot
-        .querySelector('.chip').innerText, 'WIP');
-    assert.isDefined(element.tooltipText);
-    assert.isTrue(element.classList.contains('wip'));
-    assert.isTrue(element.hasAttribute('flat'));
-  });
-
-  test('merged', () => {
-    element.status = 'Merged';
-    flush();
-    assert.equal(element.shadowRoot
-        .querySelector('.chip').innerText, element.status);
-    assert.equal(element.tooltipText, '');
-    assert.isTrue(element.classList.contains('merged'));
-    assert.isFalse(
-        element.showResolveIcon([{url: 'http://google.com'}], 'Merged'));
-  });
-
-  test('abandoned', () => {
-    element.status = 'Abandoned';
-    flush();
-    assert.equal(element.shadowRoot
-        .querySelector('.chip').innerText, element.status);
-    assert.equal(element.tooltipText, '');
-    assert.isTrue(element.classList.contains('abandoned'));
-  });
-
-  test('merge conflict', () => {
-    const status = 'Merge Conflict';
-    element.status = status;
-    flush();
-
-    assert.equal(element.shadowRoot
-        .querySelector('.chip').innerText, element.status);
-    assert.equal(element.tooltipText, MERGE_CONFLICT_TOOLTIP);
-    assert.isTrue(element.classList.contains('merge-conflict'));
-    assert.isFalse(element.hasStatusLink(undefined, [], status));
-    assert.isFalse(element.showResolveIcon([], status));
-  });
-
-  test('merge conflict with resolve link', () => {
-    const status = 'Merge Conflict';
-    const url = 'http://google.com';
-    const weblinks = [{url}];
-
-    assert.isTrue(element.hasStatusLink(undefined, weblinks, status));
-    assert.equal(element.getStatusLink(undefined, weblinks, status), url);
-    assert.isTrue(element.showResolveIcon(weblinks, status));
-  });
-
-  test('reverted change', () => {
-    const url = 'http://google.com';
-    const status = 'Revert Submitted';
-    const revertedChange = createChange();
-    sinon.stub(GerritNav, 'getUrlForSearchQuery').returns(url);
-
-    assert.isTrue(element.hasStatusLink(revertedChange, [], status));
-    assert.equal(element.getStatusLink(revertedChange, [], status), url);
-  });
-
-  test('private', () => {
-    element.status = 'Private';
-    flush();
-    assert.equal(element.shadowRoot
-        .querySelector('.chip').innerText, element.status);
-    assert.equal(element.tooltipText, PRIVATE_TOOLTIP);
-    assert.isTrue(element.classList.contains('private'));
-  });
-
-  test('active', () => {
-    element.status = 'Active';
-    flush();
-    assert.equal(element.shadowRoot
-        .querySelector('.chip').innerText, element.status);
-    assert.equal(element.tooltipText, '');
-    assert.isTrue(element.classList.contains('active'));
-  });
-
-  test('ready to submit', () => {
-    element.status = 'Ready to submit';
-    flush();
-    assert.equal(element.shadowRoot
-        .querySelector('.chip').innerText, element.status);
-    assert.equal(element.tooltipText, '');
-    assert.isTrue(element.classList.contains('ready-to-submit'));
-  });
-
-  test('updating status removes the previous class', () => {
-    element.status = 'Private';
-    flush();
-    assert.isTrue(element.classList.contains('private'));
-    assert.isFalse(element.classList.contains('wip'));
-
-    element.status = 'WIP';
-    flush();
-    assert.isFalse(element.classList.contains('private'));
-    assert.isTrue(element.classList.contains('wip'));
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status_test.ts b/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status_test.ts
new file mode 100644
index 0000000..a56f6f1
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status_test.ts
@@ -0,0 +1,172 @@
+/**
+ * @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 sinon from 'sinon/pkg/sinon-esm';
+import '../../../test/common-test-setup-karma';
+import {createChange} from '../../../test/test-data-generators';
+import './gr-change-status';
+import {ChangeStates, GrChangeStatus} from './gr-change-status';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {MERGE_CONFLICT_TOOLTIP} from './gr-change-status';
+
+const basicFixture = fixtureFromElement('gr-change-status');
+
+const WIP_TOOLTIP =
+  "This change isn't ready to be reviewed or submitted. " +
+  "It will not appear on dashboards unless you are CC'ed or assigned, " +
+  'and email notifications will be silenced until the review is started.';
+
+const PRIVATE_TOOLTIP =
+  'This change is only visible to its owner and ' +
+  'current reviewers (or anyone with "View Private Changes" permission).';
+
+suite('gr-change-status tests', () => {
+  let element: GrChangeStatus;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+  });
+
+  test('WIP', () => {
+    element.status = ChangeStates.WIP;
+    flush();
+    assert.equal(
+      element.shadowRoot!.querySelector<HTMLDivElement>('.chip')!.innerText,
+      'Work in Progress'
+    );
+    assert.equal(element.tooltipText, WIP_TOOLTIP);
+    assert.isTrue(element.classList.contains('wip'));
+  });
+
+  test('WIP flat', () => {
+    element.flat = true;
+    element.status = ChangeStates.WIP;
+    flush();
+    assert.equal(
+      element.shadowRoot!.querySelector<HTMLDivElement>('.chip')!.innerText,
+      'WIP'
+    );
+    assert.isDefined(element.tooltipText);
+    assert.isTrue(element.classList.contains('wip'));
+    assert.isTrue(element.hasAttribute('flat'));
+  });
+
+  test('merged', () => {
+    element.status = ChangeStates.MERGED;
+    flush();
+    assert.equal(
+      element.shadowRoot!.querySelector<HTMLDivElement>('.chip')!.innerText,
+      'Merged'
+    );
+    assert.equal(element.tooltipText, '');
+    assert.isTrue(element.classList.contains('merged'));
+    assert.isFalse(
+      element.showResolveIcon([{url: 'http://google.com'}], ChangeStates.MERGED)
+    );
+  });
+
+  test('abandoned', () => {
+    element.status = ChangeStates.ABANDONED;
+    flush();
+    assert.equal(
+      element.shadowRoot!.querySelector<HTMLDivElement>('.chip')!.innerText,
+      'Abandoned'
+    );
+    assert.equal(element.tooltipText, '');
+    assert.isTrue(element.classList.contains('abandoned'));
+  });
+
+  test('merge conflict', () => {
+    const status = ChangeStates.MERGE_CONFLICT;
+    element.status = status;
+    flush();
+
+    assert.equal(
+      element.shadowRoot!.querySelector<HTMLDivElement>('.chip')!.innerText,
+      'Merge Conflict'
+    );
+    assert.equal(element.tooltipText, MERGE_CONFLICT_TOOLTIP);
+    assert.isTrue(element.classList.contains('merge-conflict'));
+    assert.isFalse(element.hasStatusLink(undefined, [], status));
+    assert.isFalse(element.showResolveIcon([], status));
+  });
+
+  test('merge conflict with resolve link', () => {
+    const status = ChangeStates.MERGE_CONFLICT;
+    const url = 'http://google.com';
+    const weblinks = [{url}];
+
+    assert.isTrue(element.hasStatusLink(undefined, weblinks, status));
+    assert.equal(element.getStatusLink(undefined, weblinks, status), url);
+    assert.isTrue(element.showResolveIcon(weblinks, status));
+  });
+
+  test('reverted change', () => {
+    const url = 'http://google.com';
+    const status = ChangeStates.REVERT_SUBMITTED;
+    const revertedChange = createChange();
+    sinon.stub(GerritNav, 'getUrlForSearchQuery').returns(url);
+
+    assert.isTrue(element.hasStatusLink(revertedChange, [], status));
+    assert.equal(element.getStatusLink(revertedChange, [], status), url);
+  });
+
+  test('private', () => {
+    element.status = ChangeStates.PRIVATE;
+    flush();
+    assert.equal(
+      element.shadowRoot!.querySelector<HTMLDivElement>('.chip')!.innerText,
+      'Private'
+    );
+    assert.equal(element.tooltipText, PRIVATE_TOOLTIP);
+    assert.isTrue(element.classList.contains('private'));
+  });
+
+  test('active', () => {
+    element.status = ChangeStates.ACTIVE;
+    flush();
+    assert.equal(
+      element.shadowRoot!.querySelector<HTMLDivElement>('.chip')!.innerText,
+      'Active'
+    );
+    assert.equal(element.tooltipText, '');
+    assert.isTrue(element.classList.contains('active'));
+  });
+
+  test('ready to submit', () => {
+    element.status = ChangeStates.READY_TO_SUBMIT;
+    flush();
+    assert.equal(
+      element.shadowRoot!.querySelector<HTMLDivElement>('.chip')!.innerText,
+      'Ready to submit'
+    );
+    assert.equal(element.tooltipText, '');
+    assert.isTrue(element.classList.contains('ready-to-submit'));
+  });
+
+  test('updating status removes the previous class', () => {
+    element.status = ChangeStates.PRIVATE;
+    flush();
+    assert.isTrue(element.classList.contains('private'));
+    assert.isFalse(element.classList.contains('wip'));
+
+    element.status = ChangeStates.WIP;
+    flush();
+    assert.isFalse(element.classList.contains('private'));
+    assert.isTrue(element.classList.contains('wip'));
+  });
+});
diff --git a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts
index cf8a0ed..2ad3be6 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts
@@ -23,6 +23,7 @@
 import {KeyboardShortcutMixin} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
 import {
   computeDiffFromContext,
+  computeId,
   isDraft,
   isRobot,
   sortComments,
@@ -41,6 +42,7 @@
 import {computeDisplayPath} from '../../../utils/path-list-util';
 import {computed, customElement, observe, property} from '@polymer/decorators';
 import {
+  AccountDetailInfo,
   CommentRange,
   ConfigInfo,
   NumericChangeId,
@@ -57,11 +59,13 @@
 import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
 import {RenderPreferences} from '../../../api/diff';
 import {check, assertIsDefined} from '../../../utils/common-util';
-import {waitForEventOnce} from '../../../utils/event-util';
+import {fireAlert, waitForEventOnce} from '../../../utils/event-util';
 import {GrSyntaxLayer} from '../../diff/gr-syntax-layer/gr-syntax-layer';
 import {StorageLocation} from '../../../services/storage/gr-storage';
 import {TokenHighlightLayer} from '../../diff/gr-diff-builder/token-highlight-layer';
 import {anyLineTooLong} from '../../diff/gr-diff/gr-diff-utils';
+import {getUserName} from '../../../utils/display-name-util';
+import {generateAbsoluteUrl} from '../../../utils/url-util';
 
 const UNRESOLVED_EXPAND_COUNT = 5;
 const NEWLINE_PATTERN = /\n/g;
@@ -88,12 +92,6 @@
    */
 
   /**
-   * Fired when a comment in the thread is permanently modified.
-   *
-   * @event thread-changed
-   */
-
-  /**
    * gr-comment-thread exposes the following attributes that allow a
    * diff widget like gr-diff to show the thread in the right location:
    *
@@ -153,6 +151,9 @@
   })
   rootId?: UrlEncodedCommentId;
 
+  @property({type: Boolean, observer: 'handleShouldScrollIntoViewChanged'})
+  shouldScrollIntoView = false;
+
   @property({type: Boolean})
   showFilePath = false;
 
@@ -200,15 +201,18 @@
   @property({type: Boolean})
   showCommentContext = false;
 
+  @property({type: Object})
+  _selfAccount?: AccountDetailInfo;
+
   get keyBindings() {
     return {
       'e shift+e': '_handleEKey',
     };
   }
 
-  reporting = appContext.reportingService;
+  private readonly reporting = appContext.reportingService;
 
-  flagsService = appContext.flagsService;
+  private readonly flagsService = appContext.flagsService;
 
   readonly storage = appContext.storageService;
 
@@ -221,6 +225,18 @@
     this.addEventListener('comment-update', e =>
       this._handleCommentUpdate(e as CustomEvent)
     );
+    // Wait for comment to be rendered before scrolling to it
+    if (this.shouldScrollIntoView) {
+      const resizeObserver = new ResizeObserver(
+        (_entries: ResizeObserverEntry[], observer: ResizeObserver) => {
+          if (this.offsetHeight > 0) {
+            this.scrollIntoView();
+            observer.unobserve(this);
+          }
+        }
+      );
+      resizeObserver.observe(this);
+    }
   }
 
   /** @override */
@@ -239,6 +255,9 @@
       };
       this.syntaxLayer.setEnabled(!!prefs.syntax_highlighting);
     });
+    this.restApiService.getAccount().then(account => {
+      this._selfAccount = account;
+    });
     this._setInitialExpandedState();
   }
 
@@ -260,8 +279,27 @@
     return diff;
   }
 
-  _shouldShowCommentContext(diff?: DiffInfo) {
-    return this.showCommentContext && !!diff;
+  handleShouldScrollIntoViewChanged(shouldScrollIntoView?: boolean) {
+    // Wait for comment to be rendered before scrolling to it
+    if (shouldScrollIntoView) {
+      const resizeObserver = new ResizeObserver(
+        (_entries: ResizeObserverEntry[], observer: ResizeObserver) => {
+          if (this.offsetHeight > 0) {
+            this.scrollIntoView();
+          }
+          observer.unobserve(this);
+        }
+      );
+      resizeObserver.observe(this);
+    }
+  }
+
+  _shouldShowCommentContext(
+    changeNum?: NumericChangeId,
+    showCommentContext?: boolean,
+    diff?: DiffInfo
+  ) {
+    return changeNum && showCommentContext && !!diff;
   }
 
   addOrEditDraft(lineNum?: LineNumber, rangeParam?: CommentRange) {
@@ -341,24 +379,22 @@
   _getLayers(diff?: DiffInfo) {
     if (!diff) return [];
     const layers = [];
-    if (
-      appContext.flagsService.isEnabled(KnownExperimentId.TOKEN_HIGHLIGHTING)
-    ) {
+    if (this.flagsService.isEnabled(KnownExperimentId.TOKEN_HIGHLIGHTING)) {
       layers.push(new TokenHighlightLayer());
     }
     layers.push(this.syntaxLayer);
     return layers;
   }
 
-  _getUrlForViewDiff(comments: UIComment[]) {
-    assertIsDefined(this.changeNum, 'changeNum');
-    assertIsDefined(this.projectName, 'projectName');
+  _getUrlForViewDiff(
+    comments: UIComment[],
+    changeNum?: NumericChangeId,
+    projectName?: RepoName
+  ) {
+    if (!changeNum) return;
+    if (!projectName) return;
     check(comments.length > 0, 'comment not found');
-    return GerritNav.getUrlForComment(
-      this.changeNum,
-      this.projectName,
-      comments[0].id!
-    );
+    return GerritNav.getUrlForComment(changeNum, projectName, comments[0].id!);
   }
 
   _getDiffUrlForComment(
@@ -387,6 +423,21 @@
     return GerritNav.getUrlForComment(changeNum, projectName, id);
   }
 
+  handleCopyLink() {
+    assertIsDefined(this.changeNum, 'changeNum');
+    assertIsDefined(this.projectName, 'projectName');
+    const url = generateAbsoluteUrl(
+      GerritNav.getUrlForCommentsTab(
+        this.changeNum,
+        this.projectName,
+        this.comments[0].id!
+      )
+    );
+    navigator.clipboard.writeText(url).then(() => {
+      fireAlert(this, 'Link copied to clipboard');
+    });
+  }
+
   _isPatchsetLevelComment(path: string) {
     return path === SpecialFilePath.PATCHSET_LEVEL_COMMENTS;
   }
@@ -404,16 +455,16 @@
     return displayPath;
   }
 
-  _computeDisplayLine() {
-    if (this.lineNum === FILE) {
+  _computeDisplayLine(lineNum?: LineNumber, range?: CommentRange) {
+    if (lineNum === FILE) {
       if (this.path === SpecialFilePath.PATCHSET_LEVEL_COMMENTS) {
         return '';
       }
       return FILE;
     }
-    if (this.lineNum) return `#${this.lineNum}`;
+    if (lineNum) return `#${lineNum}`;
     // If range is set, then lineNum equals the end line of the range.
-    if (this.range) return `#${this.range.end_line}`;
+    if (range) return `#${range.end_line}`;
     return '';
   }
 
@@ -643,10 +694,7 @@
     if (!comments.base.length) {
       return this.rootId;
     }
-    const rootComment = comments.base[0];
-    if (rootComment.id) return rootComment.id;
-    if (isDraft(rootComment)) return rootComment.__draftID;
-    throw new Error('Missing id in root comment.');
+    return computeId(comments.base[0]);
   }
 
   _handleCommentDiscard(e: Event) {
@@ -664,7 +712,6 @@
     if (this.comments.length === 0) {
       this.fireRemoveSelf();
     }
-    this._handleCommentSavedOrDiscarded();
 
     // Check to see if there are any other open comments getting edited and
     // set the local storage value to its message value.
@@ -684,21 +731,14 @@
     }
   }
 
-  _handleCommentSavedOrDiscarded() {
-    this.dispatchEvent(
-      new CustomEvent('thread-changed', {
-        detail: {rootId: this.rootId, path: this.path},
-        bubbles: false,
-      })
-    );
-  }
-
   _handleCommentUpdate(e: CustomEvent) {
     const comment = e.detail.comment;
     const index = this._indexOf(comment, this.comments);
     if (index === -1) {
       // This should never happen: comment belongs to another thread.
-      console.warn('Comment update for another comment thread.');
+      this.reporting.error(
+        new Error(`Comment update for another comment thread: ${comment}`)
+      );
       return;
     }
     this.set(['comments', index], comment);
@@ -743,6 +783,17 @@
       this._projectConfig = config;
     });
   }
+
+  _computeAriaHeading(_orderedComments: UIComment[]) {
+    const firstComment = _orderedComments[0];
+    const author = firstComment?.author ?? this._selfAccount;
+    const lastComment = _orderedComments[_orderedComments.length - 1] || {};
+    const status = [
+      lastComment.unresolved ? 'Unresolved' : '',
+      isDraft(lastComment) ? 'Draft' : '',
+    ].join(' ');
+    return `${status} Comment thread by ${getUserName(undefined, author)}`;
+  }
 }
 
 declare global {
diff --git a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_html.ts b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_html.ts
index 1f0ce3d..5c01467 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_html.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_html.ts
@@ -102,6 +102,17 @@
       border-top: 1px solid var(--border-color);
       background-color: var(--background-color-primary);
     }
+
+    /* In saved state the "reply" and "quote" buttons are 28px height.
+     * top:4px  positions the 20px icon vertically centered.
+     * Currently in draft state the "save" and "cancel" buttons are 20px
+     * height, so the link icon does not need a top:4px in gr-comment_html.
+     */
+    .link-icon {
+      position: relative;
+      top: 4px;
+      cursor: pointer;
+    }
   </style>
 
   <template is="dom-if" if="[[showFilePath]]">
@@ -123,12 +134,15 @@
       <template is="dom-if" if="[[!_isPatchsetLevelComment(path)]]">
         <a
           href$="[[_getDiffUrlForComment(projectName, changeNum, path, patchNum)]]"
-          >[[_computeDisplayLine()]]</a
+          >[[_computeDisplayLine(lineNum, range)]]</a
         >
       </template>
     </div>
   </template>
   <div id="container">
+    <h3 class="assistive-tech-only">
+      [[_computeAriaHeading(_orderedComments)]]
+    </h3>
     <div class$="[[_computeHostClass(unresolved, isRobotComment)]] comment-box">
       <template
         id="commentList"
@@ -151,7 +165,7 @@
           project-config="[[_projectConfig]]"
           on-create-fix-comment="_handleCommentFix"
           on-comment-discard="_handleCommentDiscard"
-          on-comment-save="_handleCommentSavedOrDiscarded"
+          on-copy-comment-link="handleCopyLink"
         ></gr-comment>
       </template>
       <div
@@ -160,6 +174,16 @@
       >
         <span id="unresolvedLabel">[[_getUnresolvedLabel(unresolved)]]</span>
         <div id="actions">
+          <iron-icon
+            class="link-icon"
+            on-click="handleCopyLink"
+            class="copy"
+            title="Copy link to this comment"
+            icon="gr-icons:link"
+            role="button"
+            tabindex="0"
+          >
+          </iron-icon>
           <gr-button
             id="replyBtn"
             link=""
@@ -193,7 +217,10 @@
         </div>
       </div>
     </div>
-    <template is="dom-if" if="[[_shouldShowCommentContext(_diff)]]">
+    <template
+      is="dom-if"
+      if="[[_shouldShowCommentContext(changeNum, showCommentContext, _diff)]]"
+    >
       <div class="diff-container">
         <gr-diff
           id="diff"
@@ -207,7 +234,7 @@
         >
         </gr-diff>
         <div class="view-diff-container">
-          <a href="[[_getUrlForViewDiff(comments)]]">
+          <a href="[[_getUrlForViewDiff(comments, changeNum, projectName)]]">
             <gr-button link class="view-diff-button" on-click="_handleViewDiff">
               View Diff
             </gr-button>
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 64f5ad8..82a455e 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.ts
@@ -44,8 +44,12 @@
   tap,
   pressAndReleaseKeyOn,
 } from '@polymer/iron-test-helpers/mock-interactions';
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-import {stubRestApi, stubStorage} from '../../../test/test-utils';
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+import {
+  stubReporting,
+  stubRestApi,
+  stubStorage,
+} from '../../../test/test-utils';
 
 const basicFixture = fixtureFromElement('gr-comment-thread');
 
@@ -285,15 +289,24 @@
 
     test('_computeDisplayLine', () => {
       element.lineNum = 5;
-      assert.equal(element._computeDisplayLine(), '#5');
+      assert.equal(
+        element._computeDisplayLine(element.lineNum, element.range),
+        '#5'
+      );
 
       element.path = SpecialFilePath.COMMIT_MESSAGE;
       element.lineNum = 5;
-      assert.equal(element._computeDisplayLine(), '#5');
+      assert.equal(
+        element._computeDisplayLine(element.lineNum, element.range),
+        '#5'
+      );
 
       element.lineNum = undefined;
       element.path = SpecialFilePath.PATCHSET_LEVEL_COMMENTS;
-      assert.equal(element._computeDisplayLine(), '');
+      assert.equal(
+        element._computeDisplayLine(element.lineNum, element.range),
+        ''
+      );
     });
   });
 });
@@ -353,7 +366,7 @@
 
   test('reply', () => {
     const commentEl = element.shadowRoot?.querySelector('gr-comment');
-    const reportStub = sinon.stub(element.reporting, 'recordDraftInteraction');
+    const reportStub = stubReporting('recordDraftInteraction');
     assert.ok(commentEl);
 
     const replyBtn = element.$.replyBtn;
@@ -372,7 +385,7 @@
 
   test('quote reply', () => {
     const commentEl = element.shadowRoot?.querySelector('gr-comment');
-    const reportStub = sinon.stub(element.reporting, 'recordDraftInteraction');
+    const reportStub = stubReporting('recordDraftInteraction');
     assert.ok(commentEl);
 
     const quoteBtn = element.$.quoteBtn;
@@ -390,7 +403,7 @@
   });
 
   test('quote reply multiline', () => {
-    const reportStub = sinon.stub(element.reporting, 'recordDraftInteraction');
+    const reportStub = stubReporting('recordDraftInteraction');
     element.comments = [
       {
         author: {
@@ -427,7 +440,7 @@
   });
 
   test('ack', done => {
-    const reportStub = sinon.stub(element.reporting, 'recordDraftInteraction');
+    const reportStub = stubReporting('recordDraftInteraction');
     element.changeNum = 42 as NumericChangeId;
     element.patchNum = 1 as PatchSetNum;
 
@@ -452,7 +465,7 @@
   });
 
   test('done', done => {
-    const reportStub = sinon.stub(element.reporting, 'recordDraftInteraction');
+    const reportStub = stubReporting('recordDraftInteraction');
     element.changeNum = 42 as NumericChangeId;
     element.patchNum = 1 as PatchSetNum;
     const commentEl = element.shadowRoot?.querySelector('gr-comment');
@@ -482,21 +495,10 @@
     const commentEl = element.shadowRoot?.querySelector('gr-comment');
     assert.ok(commentEl);
 
-    const saveOrDiscardStub = sinon.stub();
-    element.addEventListener('thread-changed', saveOrDiscardStub);
     element.shadowRoot?.querySelector('gr-comment')?._fireSave();
 
     flush(() => {
-      assert.isTrue(saveOrDiscardStub.called);
-      assert.equal(
-        saveOrDiscardStub.lastCall.args[0].detail.rootId,
-        'baf0414d_60047215'
-      );
       assert.equal(element.rootId, 'baf0414d_60047215' as UrlEncodedCommentId);
-      assert.equal(
-        saveOrDiscardStub.lastCall.args[0].detail.path,
-        '/path/to/file.txt'
-      );
       done();
     });
   });
@@ -543,22 +545,11 @@
     );
     flush();
 
-    const saveOrDiscardStub = sinon.stub();
-    element.addEventListener('thread-changed', saveOrDiscardStub);
     const draftEl = element.root?.querySelectorAll('gr-comment')[1];
     assert.ok(draftEl);
     draftEl!.addEventListener('comment-discard', () => {
       const drafts = element.comments.filter(c => isDraft(c));
       assert.equal(drafts.length, 0);
-      assert.isTrue(saveOrDiscardStub.called);
-      assert.equal(
-        saveOrDiscardStub.lastCall.args[0].detail.rootId,
-        element.rootId
-      );
-      assert.equal(
-        saveOrDiscardStub.lastCall.args[0].detail.path,
-        element.path
-      );
       done();
     });
     draftEl!.dispatchEvent(
@@ -580,18 +571,10 @@
     const rootId = element.rootId;
     assert.isOk(rootId);
 
-    const saveOrDiscardStub = sinon.stub();
-    element.addEventListener('thread-changed', saveOrDiscardStub);
     const draftEl = element.root?.querySelectorAll('gr-comment')[0];
     assert.ok(draftEl);
     draftEl!.addEventListener('comment-discard', () => {
       assert.equal(element.comments.length, 0);
-      assert.isTrue(saveOrDiscardStub.called);
-      assert.equal(saveOrDiscardStub.lastCall.args[0].detail.rootId, rootId);
-      assert.equal(
-        saveOrDiscardStub.lastCall.args[0].detail.path,
-        element.path
-      );
       done();
     });
     draftEl!.dispatchEvent(
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
index 2d20510..1303e48 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
@@ -56,7 +56,7 @@
   UIRobot,
 } from '../../../utils/comment-util';
 import {OpenFixPreviewEventDetail} from '../../../types/events';
-import {fireAlert} from '../../../utils/event-util';
+import {fireAlert, fireEvent} from '../../../utils/event-util';
 import {pluralize} from '../../../utils/string-util';
 import {assertIsDefined} from '../../../utils/common-util';
 import {debounce, DelayedTask} from '../../../utils/async-util';
@@ -271,7 +271,9 @@
 
   private readonly storage = appContext.storageService;
 
-  reporting = appContext.reportingService;
+  private readonly reporting = appContext.reportingService;
+
+  private readonly commentsService = appContext.commentsService;
 
   private fireUpdateTask?: DelayedTask;
 
@@ -422,6 +424,11 @@
     this._showRobotActions = showActions && isRobotComment;
   }
 
+  hasPublishedComment(comments: UIComment[]) {
+    if (!comments.length) return false;
+    return comments.length > 1 || !isDraft(comments[0]);
+  }
+
   @observe('comment')
   _isRobotComment(comment: UIRobot) {
     this.isRobotComment = !!comment.robot_id;
@@ -446,6 +453,10 @@
     return 'DRAFT' + (unableToSave ? '(Failed to save)' : '');
   }
 
+  handleCopyLink() {
+    fireEvent(this, 'copy-comment-link');
+  }
+
   save(opt_comment?: UIComment) {
     let comment = opt_comment;
     if (!comment) {
@@ -477,6 +488,7 @@
           if (this.comment?.__draftID) {
             resComment.__draftID = this.comment.__draftID;
           }
+          if (!resComment.patch_set) resComment.patch_set = this.patchNum;
           this.comment = resComment;
           this._fireSave();
           return obj;
@@ -533,6 +545,7 @@
   }
 
   _fireSave() {
+    if (this.comment) this.commentsService.addDraft(this.comment);
     this.dispatchEvent(
       new CustomEvent('comment-save', {
         detail: this._getEventPayload(),
@@ -734,6 +747,7 @@
   }
 
   _fireDiscard() {
+    if (this.comment) this.commentsService.deleteDraft(this.comment);
     this.fireUpdateTask?.cancel();
     this.dispatchEvent(
       new CustomEvent('comment-discard', {
@@ -912,15 +926,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-comment/gr-comment_html.ts b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_html.ts
index aad8c98..3e0b9a4 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_html.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_html.ts
@@ -236,18 +236,21 @@
       margin-left: var(--spacing-s);
     }
     .headerLeft gr-account-label {
-      --gr-account-label-text-style: {
-        font-weight: var(--font-weight-bold);
-      }
       --account-max-length: 130px;
       width: 150px;
     }
+    .headerLeft gr-account-label::part(gr-account-label-text) {
+      font-weight: var(--font-weight-bold);
+    }
     .draft gr-account-label {
       width: unset;
     }
     .portedMessage {
       margin: 0 var(--spacing-m);
     }
+    .link-icon {
+      cursor: pointer;
+    }
   </style>
   <div id="container" class="container">
     <div class="header" id="header" on-click="_handleToggleCollapsed">
@@ -259,7 +262,7 @@
           <gr-account-label
             account="[[_getAuthor(comment, _selfAccount)]]"
             class$="[[_computeAccountLabelClass(draft)]]"
-            hide-status=""
+            hideStatus
           >
           </gr-account-label>
         </template>
@@ -269,12 +272,6 @@
               >From patchset [[comment.patch_set]]</span
             ></a
           >
-          <a
-            href="https://bugs.chromium.org/p/gerrit/issues/entry?template=Porting+Comments"
-            target="_blank"
-          >
-            <iron-icon icon="gr-icons:bug" title="report a problem"></iron-icon>
-          </a>
         </template>
         <gr-tooltip-content
           class="draftTooltip"
@@ -411,6 +408,18 @@
         </div>
         <template is="dom-if" if="[[draft]]">
           <div class="rightActions">
+            <template is="dom-if" if="[[hasPublishedComment(comments)]]">
+              <iron-icon
+                class="link-icon"
+                on-click="handleCopyLink"
+                class="copy"
+                title="Copy link to this comment"
+                icon="gr-icons:link"
+                role="button"
+                tabindex="0"
+              >
+              </iron-icon>
+            </template>
             <gr-button
               link=""
               class="action cancel hideOnPublished"
@@ -440,6 +449,18 @@
         </template>
       </div>
       <div class="robotActions" hidden$="[[!_showRobotActions]]">
+        <template is="dom-if" if="[[hasPublishedComment(comments)]]">
+          <iron-icon
+            class="link-icon"
+            on-click="handleCopyLink"
+            class="copy"
+            title="Copy link to this comment"
+            icon="gr-icons:link"
+            role="button"
+            tabindex="0"
+          >
+          </iron-icon>
+        </template>
         <template is="dom-if" if="[[isRobotComment]]">
           <gr-endpoint-decorator name="robot-comment-controls">
             <gr-endpoint-param name="comment" value="[[comment]]">
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts
index 6d5cec7..a218959 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts
@@ -27,6 +27,7 @@
   spyStorage,
   query,
   isVisible,
+  stubReporting,
 } from '../../../test/test-utils';
 import {
   AccountId,
@@ -363,28 +364,23 @@
         endStub = sinon.stub();
         const mockTimer = new MockTimer();
         mockTimer.end = endStub;
-        getTimerStub = sinon
-          .stub(element.reporting, 'getTimer')
-          .returns(mockTimer);
+        getTimerStub = stubReporting('getTimer').returns(mockTimer);
       });
 
-      test('create', () => {
+      test('create', async () => {
         element.patchNum = 1 as PatchSetNum;
         element.comment = {};
-        return element._handleSave(mockEvent)!.then(() => {
-          assert.equal(
-            (queryAndAssert(
-              element,
-              'gr-account-label'
-            ).shadowRoot?.querySelector(
-              'span.name'
-            ) as HTMLSpanElement).innerText.trim(),
-            'Dhruv Srivastava'
-          );
-          assert.isTrue(endStub.calledOnce);
-          assert.isTrue(getTimerStub.calledOnce);
-          assert.equal(getTimerStub.lastCall.args[0], 'CreateDraftComment');
-        });
+        await element._handleSave(mockEvent);
+        await flush();
+        const grAccountLabel = queryAndAssert(element, 'gr-account-label');
+        const spanName = queryAndAssert<HTMLSpanElement>(
+          grAccountLabel,
+          'span.name'
+        );
+        assert.equal(spanName.innerText.trim(), 'Dhruv Srivastava');
+        assert.isTrue(endStub.calledOnce);
+        assert.isTrue(getTimerStub.calledOnce);
+        assert.equal(getTimerStub.lastCall.args[0], 'CreateDraftComment');
       });
 
       test('update', () => {
@@ -414,10 +410,7 @@
     });
 
     test('edit reports interaction', () => {
-      const reportStub = sinon.stub(
-        element.reporting,
-        'recordDraftInteraction'
-      );
+      const reportStub = stubReporting('recordDraftInteraction');
       element.draft = true;
       flush();
       tap(queryAndAssert(element, '.edit'));
@@ -425,10 +418,7 @@
     });
 
     test('discard reports interaction', () => {
-      const reportStub = sinon.stub(
-        element.reporting,
-        'recordDraftInteraction'
-      );
+      const reportStub = stubReporting('recordDraftInteraction');
       element.draft = true;
       flush();
       tap(queryAndAssert(element, '.discard'));
diff --git a/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard.ts b/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard.ts
index 110c242..4a2bcee 100644
--- a/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard.ts
+++ b/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard.ts
@@ -15,15 +15,14 @@
  * limitations under the License.
  */
 import '@polymer/iron-input/iron-input';
-import '../../../styles/shared-styles';
 import '../gr-button/gr-button';
 import '../gr-icons/gr-icons';
-import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-copy-clipboard_html';
-import {GrButton} from '../gr-button/gr-button';
-import {customElement, property} from '@polymer/decorators';
 import {IronIconElement} from '@polymer/iron-icon';
+import {assertIsDefined, queryAndAssert} from '../../../utils/common-util';
+import {classMap} from 'lit-html/directives/class-map';
+import {css, customElement, html, property} from 'lit-element';
+import {GrLitElement} from '../../lit/gr-lit-element';
+import {GrButton} from '../gr-button/gr-button';
 
 const COPY_TIMEOUT_MS = 1000;
 
@@ -32,17 +31,8 @@
     'gr-copy-clipboard': GrCopyClipboard;
   }
 }
-
-export interface GrCopyClipboard {
-  $: {button: GrButton; icon: IronIconElement; input: HTMLInputElement};
-}
-
 @customElement('gr-copy-clipboard')
-export class GrCopyClipboard extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrCopyClipboard extends GrLitElement {
   @property({type: String})
   text: string | undefined;
 
@@ -55,36 +45,121 @@
   @property({type: Boolean})
   hideInput = false;
 
-  focusOnCopy() {
-    this.$.button.focus();
+  static get styles() {
+    return [
+      css`
+        .text {
+          align-items: center;
+          display: flex;
+          flex-wrap: wrap;
+        }
+        .copyText {
+          flex-grow: 1;
+          margin-right: var(--spacing-s);
+        }
+        .hideInput {
+          display: none;
+        }
+        input#input {
+          font-family: var(--monospace-font-family);
+          font-size: var(--font-size-mono);
+          line-height: var(--line-height-mono);
+          width: 100%;
+        }
+        /*
+         * Typically icons are 20px, which is the normal line-height.
+         * The copy icon is too prominent at 20px, so we choose 16px
+         * here, but add 2x2px padding below, so the entire
+         * component should still fit nicely into a normal inline
+         * layout flow.
+         */
+        #icon {
+          height: 16px;
+          width: 16px;
+        }
+        iron-icon {
+          color: var(--deemphasized-text-color);
+          vertical-align: top;
+        }
+      `,
+    ];
   }
 
-  _computeInputClass(hideInput: boolean) {
-    return hideInput ? 'hideInput' : '';
+  render() {
+    // To pass CSS mixins for @apply to Polymer components, they need to appear
+    // in <style> inside the template.
+    const customStyle = html`
+      <style>
+        iron-icon {
+          --iron-icon-height: 20px;
+          --iron-icon-width: 20px;
+        }
+        gr-button {
+          --gr-button: {
+            padding: 2px;
+          }
+        }
+      </style>
+    `;
+    return html`${customStyle}
+      <div class="text">
+        <iron-input
+          class="copyText"
+          type="text"
+          @click="${this._handleInputClick}"
+          readonly=""
+          bind-value=${this.text}
+        >
+          <input
+            id="input"
+            is="iron-input"
+            class="${classMap({hideInput: this.hideInput})}"
+            type="text"
+            @click="${this._handleInputClick}"
+            readonly=""
+            .value=${this.text}
+            part="text-container-style"
+          />
+        </iron-input>
+        <gr-button
+          id="copy-clipboard-button"
+          link=""
+          ?has-tooltip=${this.hasTooltip}
+          class="copyToClipboard"
+          title="${this.buttonTitle}"
+          @click="${this._copyToClipboard}"
+          aria-label="Click to copy to clipboard"
+        >
+          <iron-icon id="icon" icon="gr-icons:content-copy"></iron-icon>
+        </gr-button>
+      </div> `;
+  }
+
+  focusOnCopy() {
+    queryAndAssert<GrButton>(this, '#copy-clipboard-button').focus();
   }
 
   _handleInputClick(e: MouseEvent) {
     e.preventDefault();
-    ((dom(e) as EventApi).rootTarget as HTMLInputElement).select();
+    const rootTarget = e.composedPath()[0];
+    (rootTarget as HTMLInputElement).select();
   }
 
   _copyToClipboard(e: MouseEvent) {
     e.preventDefault();
     e.stopPropagation();
 
-    if (this.hideInput) {
-      this.$.input.style.display = 'block';
-    }
-    this.$.input.focus();
-    this.$.input.select();
-    document.execCommand('copy');
-    if (this.hideInput) {
-      this.$.input.style.display = 'none';
-    }
-    this.$.icon.icon = 'gr-icons:check';
+    this.text = queryAndAssert<HTMLInputElement>(this, '#input').value;
+    assertIsDefined(this.text, 'text');
+    this.iconEl.icon = 'gr-icons:check';
+    navigator.clipboard.writeText(this.text);
     setTimeout(
-      () => (this.$.icon.icon = 'gr-icons:content-copy'),
+      () => (this.iconEl.icon = 'gr-icons:content-copy'),
       COPY_TIMEOUT_MS
     );
   }
+
+  private get iconEl(): IronIconElement {
+    return queryAndAssert<IronIconElement>(this, '#icon');
+  }
 }
diff --git a/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard_html.ts b/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard_html.ts
deleted file mode 100644
index 3ccc46f..0000000
--- a/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard_html.ts
+++ /dev/null
@@ -1,93 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style>
-    .text {
-      align-items: center;
-      display: flex;
-      flex-wrap: wrap;
-    }
-    .copyText {
-      flex-grow: 1;
-      margin-right: var(--spacing-s);
-    }
-    .hideInput {
-      display: none;
-    }
-    input#input {
-      font-family: var(--monospace-font-family);
-      font-size: var(--font-size-mono);
-      line-height: var(--line-height-mono);
-      @apply --text-container-style;
-      width: 100%;
-    }
-    /*
-       * Typically icons are 20px, which is the normal line-height.
-       * The copy icon is too prominent at 20px, so we choose 16px
-       * here, but add 2x2px padding below, so the entire
-       * component should still fit nicely into a normal inline
-       * layout flow.
-       */
-    #icon {
-      height: 16px;
-      width: 16px;
-    }
-    iron-icon {
-      color: var(--deemphasized-text-color);
-      vertical-align: top;
-      --iron-icon-height: 20px;
-      --iron-icon-width: 20px;
-    }
-    gr-button {
-      --gr-button: {
-        padding: 2px;
-      }
-    }
-  </style>
-  <div class="text">
-    <iron-input
-      class="copyText"
-      type="text"
-      bind-value="[[text]]"
-      on-click="_handleInputClick"
-      readonly=""
-    >
-      <input
-        id="input"
-        is="iron-input"
-        class$="[[_computeInputClass(hideInput)]]"
-        type="text"
-        bind-value="[[text]]"
-        on-click="_handleInputClick"
-        readonly=""
-      />
-    </iron-input>
-    <gr-button
-      id="button"
-      link=""
-      has-tooltip="[[hasTooltip]]"
-      class="copyToClipboard"
-      title="[[buttonTitle]]"
-      on-click="_copyToClipboard"
-      aria-label="Click to copy to clipboard"
-    >
-      <iron-icon id="icon" icon="gr-icons:content-copy"></iron-icon>
-    </gr-button>
-  </div>
-`;
diff --git a/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard_test.js b/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard_test.js
index 55b2483..45847d7 100644
--- a/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard_test.js
@@ -17,7 +17,7 @@
 
 import '../../../test/common-test-setup-karma.js';
 import './gr-copy-clipboard.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {queryAndAssert} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-copy-clipboard');
 
@@ -32,18 +32,18 @@
   });
 
   test('copy to clipboard', () => {
-    const clipboardSpy = sinon.spy(element, '_copyToClipboard');
+    const clipboardSpy = sinon.spy(navigator.clipboard, 'writeText');
     const copyBtn = element.shadowRoot
         .querySelector('.copyToClipboard');
-    MockInteractions.tap(copyBtn);
+    MockInteractions.click(copyBtn);
     assert.isTrue(clipboardSpy.called);
   });
 
   test('focusOnCopy', () => {
     element.focusOnCopy();
-    assert.deepEqual(dom(element.root).activeElement,
-        element.shadowRoot
-            .querySelector('.copyToClipboard'));
+    const activeElement = element.shadowRoot.activeElement;
+    const button = element.shadowRoot.querySelector('.copyToClipboard');
+    assert.deepEqual(activeElement, button);
   });
 
   test('_handleInputClick', () => {
@@ -58,16 +58,17 @@
     assert.equal(inputElement.selectionEnd, element.text.length - 1);
   });
 
-  test('hideInput', () => {
+  test('hideInput', async () => {
     // iron-input as parent should never be hidden as copy won't work
     // on nested hidden elements
     const ironInputElement = element.shadowRoot.querySelector('iron-input');
     assert.notEqual(getComputedStyle(ironInputElement).display, 'none');
 
-    assert.notEqual(getComputedStyle(element.$.input).display, 'none');
+    const input = queryAndAssert(element, 'input');
+    assert.notEqual(getComputedStyle(input).display, 'none');
     element.hideInput = true;
-    flush();
-    assert.equal(getComputedStyle(element.$.input).display, 'none');
+    await flush();
+    assert.equal(getComputedStyle(input).display, 'none');
   });
 
   test('stop events propagation', () => {
diff --git a/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.ts b/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.ts
index 90face9..9dce127 100644
--- a/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.ts
+++ b/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.ts
@@ -15,31 +15,10 @@
  * limitations under the License.
  */
 import {BehaviorSubject} from 'rxjs';
+import {AbortStop, CursorMoveResult, Stop} from '../../../api/core';
 import {ScrollMode} from '../../../constants/constants';
 
 /**
- * Return type for cursor moves, that indicate whether a move was possible.
- */
-export enum CursorMoveResult {
-  /** The cursor was successfully moved. */
-  MOVED,
-  /** There were no stops - the cursor was reset. */
-  NO_STOPS,
-  /**
-   * There was no more matching stop to move to - the cursor was clipped to the
-   * end.
-   */
-  CLIPPED,
-  /** The abort condition would have been fulfilled for the new target. */
-  ABORTED,
-}
-
-/** A sentinel that can be inserted to disallow moving across. */
-export class AbortStop {}
-
-export type Stop = HTMLElement | AbortStop;
-
-/**
  * Type guard and checker to check if a stop can be targeted.
  * Abort stops cannot be targeted.
  */
diff --git a/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager_test.js b/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager_test.js
index 862d41c..d0bd420 100644
--- a/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager_test.js
@@ -18,7 +18,8 @@
 import '../../../test/common-test-setup-karma.js';
 import './gr-cursor-manager.js';
 import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-import {AbortStop, CursorMoveResult, GrCursorManager} from './gr-cursor-manager.js';
+import {AbortStop, CursorMoveResult} from '../../../api/core.js';
+import {GrCursorManager} from './gr-cursor-manager.js';
 
 const basicTestFixutre = fixtureFromTemplate(html`
     <ul>
diff --git a/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.ts b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.ts
index 391028a..191cadf 100644
--- a/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.ts
+++ b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.ts
@@ -19,9 +19,14 @@
 import '../../../styles/shared-styles';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-download-commands_html';
-import {customElement, property, observe} from '@polymer/decorators';
+import {customElement, property} from '@polymer/decorators';
 import {PaperTabsElement} from '@polymer/paper-tabs/paper-tabs';
 import {appContext} from '../../../services/app-context';
+import {queryAndAssert} from '../../../utils/common-util';
+import {GrShellCommand} from '../gr-shell-command/gr-shell-command';
+import {preferences$} from '../../../services/user/user-model';
+import {takeUntil} from 'rxjs/operators';
+import {Subject} from 'rxjs';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -64,29 +69,15 @@
 
   private readonly restApiService = appContext.restApiService;
 
+  disconnected$ = new Subject();
+
   /** @override */
   connectedCallback() {
     super.connectedCallback();
     this._getLoggedIn().then(loggedIn => {
       this._loggedIn = loggedIn;
     });
-  }
-
-  focusOnCopy() {
-    // TODO(TS): remove ! assertion later
-    this.shadowRoot!.querySelector('gr-shell-command')!.focusOnCopy();
-  }
-
-  _getLoggedIn() {
-    return this.restApiService.getLoggedIn();
-  }
-
-  @observe('_loggedIn')
-  _loggedInChanged(loggedIn: boolean) {
-    if (!loggedIn) {
-      return;
-    }
-    return this.restApiService.getPreferences().then(prefs => {
+    preferences$.pipe(takeUntil(this.disconnected$)).subscribe(prefs => {
       if (prefs?.download_scheme) {
         // Note (issue 5180): normalize the download scheme with lower-case.
         this.selectedScheme = prefs.download_scheme.toLowerCase();
@@ -94,6 +85,20 @@
     });
   }
 
+  /** @override */
+  disconnectedCallback() {
+    this.disconnected$.next();
+    super.disconnectedCallback();
+  }
+
+  focusOnCopy() {
+    queryAndAssert<GrShellCommand>(this, 'gr-shell-command').focusOnCopy();
+  }
+
+  _getLoggedIn() {
+    return this.restApiService.getLoggedIn();
+  }
+
   _handleTabChange(e: CustomEvent<{value: number}>) {
     const scheme = this.schemes[e.detail.value];
     if (scheme && scheme !== this.selectedScheme) {
diff --git a/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_html.ts b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_html.ts
index a739ad1..5a75c13 100644
--- a/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_html.ts
+++ b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_html.ts
@@ -44,7 +44,6 @@
       flex-direction: column;
     }
     gr-shell-command {
-      width: 60em;
       margin-bottom: var(--spacing-m);
     }
     .hidden {
diff --git a/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_test.js b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_test.js
index 6427c6b..694cfca 100644
--- a/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_test.js
@@ -18,6 +18,8 @@
 import '../../../test/common-test-setup-karma.js';
 import './gr-download-commands.js';
 import {isHidden, stubRestApi} from '../../../test/test-utils.js';
+import {updatePreferences} from '../../../services/user/user-model.js';
+import {createPreferences} from '../../../test/test-data-generators.js';
 
 const basicFixture = fixtureFromElement('gr-download-commands');
 
@@ -84,26 +86,6 @@
       assert.equal(element.$.downloadTabs.selected, '2');
     });
 
-    test('loads scheme from preferences', async () => {
-      const stub = stubRestApi('getPreferences').returns(
-          Promise.resolve({download_scheme: 'repo'}));
-      element._loggedIn = true;
-      await flush();
-      assert.isTrue(stub.called);
-      await stub.lastCall.returnValue;
-      assert.equal(element.selectedScheme, 'repo');
-    });
-
-    test('normalize scheme from preferences', async () => {
-      const stub = stubRestApi('getPreferences').returns(
-          Promise.resolve({download_scheme: 'REPO'}));
-      element._loggedIn = true;
-      await flush();
-      assert.isTrue(stub.called);
-      await stub.lastCall.returnValue;
-      assert.equal(element.selectedScheme, 'repo');
-    });
-
     test('saves scheme to preferences', () => {
       element._loggedIn = true;
       const savePrefsStub = stubRestApi('savePreferences').returns(
@@ -121,5 +103,26 @@
           repoTab.getAttribute('data-scheme'));
     });
   });
+  suite('authenticated', () => {
+    test('loads scheme from preferences', async () => {
+      updatePreferences({
+        ...createPreferences(),
+        download_scheme: 'repo',
+      });
+      const element = basicFixture.instantiate();
+      await flush();
+      assert.equal(element.selectedScheme, 'repo');
+    });
+
+    test('normalize scheme from preferences', async () => {
+      updatePreferences({
+        ...createPreferences(),
+        download_scheme: 'REPO',
+      });
+      const element = basicFixture.instantiate();
+      await flush();
+      assert.equal(element.selectedScheme, 'repo');
+    });
+  });
 });
 
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list_html.ts b/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list_html.ts
index c163924..18a46a0 100644
--- a/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list_html.ts
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list_html.ts
@@ -127,7 +127,7 @@
     <span id="triggerText">[[text]]</span>
     <gr-copy-clipboard
       hidden="[[!showCopyForTriggerText]]"
-      hide-input=""
+      hideInput=""
       text="[[text]]"
     ></gr-copy-clipboard>
   </gr-button>
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.ts b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.ts
index a4a6fe3..98887c5 100644
--- a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.ts
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.ts
@@ -57,7 +57,7 @@
   base: string[];
 }
 
-interface Content {
+export interface DropdownContent {
   text: string;
   bold?: boolean;
 }
@@ -87,7 +87,7 @@
   downArrow?: boolean;
 
   @property({type: Array})
-  topContent?: Content[];
+  topContent?: DropdownContent[];
 
   @property({type: String})
   horizontalAlign = 'left';
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.ts b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.ts
index eb44a60..83cd380 100644
--- a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.ts
+++ b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.ts
@@ -23,6 +23,9 @@
 import {fireAlert, fireEvent} from '../../../utils/event-util';
 import {appContext} from '../../../services/app-context';
 import {debounce, DelayedTask} from '../../../utils/async-util';
+import {queryAndAssert} from '../../../utils/common-util';
+import {IronAutogrowTextareaElement} from '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
+import {Interaction} from '../../../constants/reporting';
 
 const RESTORED_MESSAGE = 'Content restored from a previous edit.';
 const STORAGE_DEBOUNCE_INTERVAL_MS = 400;
@@ -131,7 +134,10 @@
   }
 
   focusTextarea() {
-    this.shadowRoot!.querySelector('iron-autogrow-textarea')!.textarea.focus();
+    queryAndAssert<IronAutogrowTextareaElement>(
+      this,
+      'iron-autogrow-textarea'
+    ).textarea.focus();
   }
 
   _newContentChanged(newContent: string) {
@@ -227,7 +233,7 @@
 
   _toggleCommitCollapsed() {
     this._commitCollapsed = !this._commitCollapsed;
-    this.reporting.reportInteraction('toggle show all button', {
+    this.reporting.reportInteraction(Interaction.TOGGLE_SHOW_ALL_BUTTON, {
       sectionName: 'Commit message',
       toState: !this._commitCollapsed ? 'Show all' : 'Show less',
     });
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.ts b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.ts
index 1702b4a..b133472 100644
--- a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.ts
+++ b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.ts
@@ -61,13 +61,13 @@
    */
 
   @property({type: String})
-  labelText?: string;
+  labelText = '';
 
   @property({type: Boolean})
   editing = false;
 
   @property({type: String, notify: true, observer: '_updateTitle'})
-  value = '';
+  value?: string;
 
   @property({type: String})
   placeholder = '';
@@ -82,7 +82,7 @@
   maxLength?: number;
 
   @property({type: String})
-  _inputText?: string;
+  _inputText = '';
 
   // This is used to push the iron-input element up on the page, so
   // the input is placed in approximately the same position as the
@@ -97,7 +97,7 @@
   autocomplete = false;
 
   @property({type: Object})
-  query?: AutocompleteQuery;
+  query: AutocompleteQuery = () => Promise.resolve([]);
 
   /** @override */
   ready() {
@@ -193,7 +193,7 @@
     }
     this.$.dropdown.close();
     this.editing = false;
-    this._inputText = this.value;
+    this._inputText = this.value || '';
   }
 
   get _nativeInput(): HTMLInputElement {
diff --git a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.ts b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.ts
index 17b659f..0193197 100644
--- a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.ts
+++ b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.ts
@@ -15,11 +15,10 @@
  * limitations under the License.
  */
 import '../gr-linked-text/gr-linked-text';
-import '../../../styles/shared-styles';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {customElement, property} from '@polymer/decorators';
-import {htmlTemplate} from './gr-formatted-text_html';
 import {CommentLinks} from '../../../types/common';
+import {appContext} from '../../../services/app-context';
+import {GrLitElement} from '../../lit/gr-lit-element';
+import {css, customElement, html, property} from 'lit-element';
 
 const CODE_MARKER_PATTERN = /^(`{1,3})([^`]+?)\1$/;
 
@@ -35,63 +34,73 @@
     'gr-formatted-text': GrFormattedText;
   }
 }
-
-export interface GrFormattedText {
-  $: {
-    container: HTMLElement;
-  };
-}
-
 @customElement('gr-formatted-text')
-export class GrFormattedText extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
-  @property({type: String, observer: '_contentChanged'})
+export class GrFormattedText extends GrLitElement {
+  @property({type: String})
   content?: string;
 
   @property({type: Object})
   config?: CommentLinks;
 
-  @property({type: Boolean})
+  @property({type: Boolean, reflect: true})
   noTrailingMargin = false;
 
-  static get observers() {
-    return ['_contentOrConfigChanged(content, config)'];
+  private readonly reporting = appContext.reportingService;
+
+  static get styles() {
+    return [
+      css`
+        :host {
+          display: block;
+          font-family: var(--font-family);
+        }
+        p,
+        ul,
+        code,
+        blockquote,
+        gr-linked-text.pre {
+          margin: 0 0 var(--spacing-m) 0;
+        }
+        p,
+        ul,
+        code,
+        blockquote {
+          max-width: var(--gr-formatted-text-prose-max-width, none);
+        }
+        :host([noTrailingMargin]) p:last-child,
+        :host([noTrailingMargin]) ul:last-child,
+        :host([noTrailingMargin]) blockquote:last-child,
+        :host([noTrailingMargin]) gr-linked-text.pre:last-child {
+          margin: 0;
+        }
+        code,
+        blockquote {
+          border-left: 1px solid #aaa;
+          padding: 0 var(--spacing-m);
+        }
+        code {
+          display: block;
+          white-space: pre-wrap;
+          color: var(--deemphasized-text-color);
+        }
+        li {
+          list-style-type: disc;
+          margin-left: var(--spacing-xl);
+        }
+        code,
+        gr-linked-text.pre {
+          font-family: var(--monospace-font-family);
+          font-size: var(--font-size-code);
+          /* usually 16px = 12px + 4px */
+          line-height: calc(var(--font-size-code) + var(--spacing-s));
+        }
+      `,
+    ];
   }
 
-  /** @override */
-  ready() {
-    super.ready();
-    if (this.noTrailingMargin) {
-      this.classList.add('noTrailingMargin');
-    }
-  }
-
-  _contentChanged(content: string) {
-    // In the case where the config may not be set (perhaps due to the
-    // request for it still being in flight), set the content anyway to
-    // prevent waiting on the config to display the text.
-    if (this.config) return;
-    this._contentOrConfigChanged(content);
-  }
-
-  /**
-   * Given a source string, update the DOM inside #container.
-   */
-  _contentOrConfigChanged(content?: string) {
-    const container = this.$.container;
-
-    // Remove existing content.
-    while (container.firstChild) {
-      container.removeChild(container.firstChild);
-    }
-
-    // Add new content.
-    for (const node of this._computeNodes(this._computeBlocks(content))) {
-      if (node) container.appendChild(node);
-    }
+  render() {
+    const nodes = this._computeNodes(this._computeBlocks(this.content));
+    return html`<div id="container">${nodes}</div>`;
   }
 
   /**
@@ -304,7 +313,7 @@
         return ul;
       }
 
-      console.warn('Unrecognized type.');
+      this.reporting.error(new Error(`Unrecognized block type: ${block.type}`));
       return document.createElement('span');
     });
   }
diff --git a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_html.ts b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_html.ts
deleted file mode 100644
index 04e4954..0000000
--- a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_html.ts
+++ /dev/null
@@ -1,67 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style>
-    :host {
-      display: block;
-      font-family: var(--font-family);
-    }
-    p,
-    ul,
-    code,
-    blockquote,
-    gr-linked-text.pre {
-      margin: 0 0 var(--spacing-m) 0;
-    }
-    p,
-    ul,
-    code,
-    blockquote {
-      max-width: var(--gr-formatted-text-prose-max-width, none);
-    }
-    :host(.noTrailingMargin) p:last-child,
-    :host(.noTrailingMargin) ul:last-child,
-    :host(.noTrailingMargin) blockquote:last-child,
-    :host(.noTrailingMargin) gr-linked-text.pre:last-child {
-      margin: 0;
-    }
-    code,
-    blockquote {
-      border-left: 1px solid #aaa;
-      padding: 0 var(--spacing-m);
-    }
-    code {
-      display: block;
-      white-space: pre-wrap;
-      color: var(--deemphasized-text-color);
-    }
-    li {
-      list-style-type: disc;
-      margin-left: var(--spacing-xl);
-    }
-    code,
-    gr-linked-text.pre {
-      font-family: var(--monospace-font-family);
-      font-size: var(--font-size-code);
-      /* usually 16px = 12px + 4px */
-      line-height: calc(var(--font-size-code) + var(--spacing-s));
-    }
-  </style>
-  <div id="container"></div>
-`;
diff --git a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_test.js b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_test.js
index fd5a9ba..3e05f11 100644
--- a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_test.js
@@ -387,20 +387,5 @@
     assert.equal(result[3].type, 'code');
     assert.equal(result[4].type, 'quote');
   });
-
-  test('_computeNodes called without config', () => {
-    const computeNodesSpy = sinon.spy(element, '_computeNodes');
-    element.content = 'some text';
-    assert.isTrue(computeNodesSpy.called);
-  });
-
-  test('_contentOrConfigChanged called with config', () => {
-    const contentStub = sinon.stub(element, '_contentChanged');
-    const contentConfigStub = sinon.stub(element, '_contentOrConfigChanged');
-    element.content = 'some text';
-    element.config = {};
-    assert.isTrue(contentStub.called);
-    assert.isTrue(contentConfigStub.called);
-  });
 });
 
diff --git a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_html.ts b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_html.ts
index d0986de..dac5962 100644
--- a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_html.ts
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_html.ts
@@ -75,7 +75,7 @@
     <template is="dom-if" if="[[_isShowing]]">
       <div class="top">
         <div class="avatar">
-          <gr-avatar account="[[account]]" image-size="56"></gr-avatar>
+          <gr-avatar account="[[account]]" imageSize="56"></gr-avatar>
         </div>
         <div class="account">
           <h3 class="name heading-3">[[account.name]]</h3>
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 e3a34c8..a3d9e58 100644
--- a/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.ts
+++ b/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.ts
@@ -148,6 +148,12 @@
       <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://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>
+      <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material%20Icons%3Aarrow_forward-->
+      <g id="arrow-forward"><path d="M0 0h24v24H0z" fill="none"/><path d="M12 4l-1.41 1.41L16.17 11H4v2h12.17l-5.58 5.59L12 20l8-8z"/></g>
     </defs>
   </svg>
 </iron-iconset-svg>`;
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api.ts
index 3f75970..82c7118 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api.ts
@@ -42,7 +42,9 @@
   ): GrAnnotationActionsInterface {
     this.reporting.trackApi(this.plugin, 'annotation', 'setCoverageProvider');
     if (this.coverageProvider) {
-      console.warn('Overwriting an existing coverage provider.');
+      this.reporting.error(
+        new Error(`Overwriting cov provider: ${this.plugin.getPluginName()}`)
+      );
     }
     this.coverageProvider = coverageProvider;
     return this;
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api.ts
index 15d4680..9a56f2a 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api.ts
@@ -80,7 +80,7 @@
    */
   private setEl(el?: GrChangeActionsElement) {
     if (!el) {
-      console.warn('changeActions() is not ready');
+      this.reporting.error(new Error('changeActions() API is not ready'));
       return;
     }
     this.el = el;
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api.ts
index fc5a4aa..b8f5aff 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api.ts
@@ -26,6 +26,7 @@
   ValueChangedDetail,
 } from '../../../api/change-reply';
 import {appContext} from '../../../services/app-context';
+import {HookApi, PluginElement} from '../../../api/hook';
 
 /**
  * GrChangeReplyInterface, provides a set of handy methods on reply dialog.
@@ -58,7 +59,7 @@
 
   addReplyTextChangedCallback(handler: ReplyChangedCallback) {
     this.reporting.trackApi(this.plugin, 'reply', 'addReplyTextChangedCb');
-    const hookApi = this.plugin.hook('reply-text');
+    const hookApi = this.plugin.hook('reply-text') as HookApi<PluginElement>;
     const registeredHandler = (e: Event) => {
       const ce = e as CustomEvent<ValueChangedDetail>;
       handler(ce.detail.value);
@@ -79,7 +80,9 @@
 
   addLabelValuesChangedCallback(handler: LabelsChangedCallback) {
     this.reporting.trackApi(this.plugin, 'reply', 'addLabelValuesChangedCb');
-    const hookApi = this.plugin.hook('reply-label-scores');
+    const hookApi = this.plugin.hook(
+      'reply-label-scores'
+    ) as HookApi<PluginElement>;
     const registeredHandler = (e: Event) => {
       const ce = e as CustomEvent<LabelsChangedDetail>;
       handler(ce.detail);
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit.ts
index 33afde0..a195206 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit.ts
@@ -30,8 +30,15 @@
   EventEmitterService,
 } from '../../../services/gr-event-interface/gr-event-interface';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {Gerrit} from '../../../api/gerrit';
 
-export interface GerritGlobal extends EventEmitterService {
+/**
+ * These are the methods and properties that are exposed explicitly in the
+ * public global `Gerrit` interface. In reality JavaScript plugins do depend
+ * on some of this "internal" stuff. But we want to convert plugins to
+ * TypeScript one by one and while doing that remove those dependencies.
+ */
+export interface GerritInternal extends EventEmitterService, Gerrit {
   css(rule: string): string;
   install(
     callback: (plugin: PluginApi) => void,
@@ -57,8 +64,7 @@
   _arePluginsLoaded(): boolean;
   _isPluginEnabled(pathOrUrl: string): boolean;
   _isPluginLoaded(pathOrUrl: string): boolean;
-  _eventEmitter: EventEmitterService;
-  _customStyleSheet: CSSStyleSheet;
+  _customStyleSheet?: CSSStyleSheet;
 
   // exposed methods
   Nav: typeof GerritNav;
@@ -66,14 +72,12 @@
 }
 
 export function initGerritPluginApi() {
-  window.Gerrit = window.Gerrit || {};
-  initGerritPluginsMethods(window.Gerrit as GerritGlobal);
+  window.Gerrit = window.Gerrit ?? new GerritImpl();
 }
 
-export function _testOnly_initGerritPluginApi(): GerritGlobal {
-  window.Gerrit = window.Gerrit || {};
+export function _testOnly_initGerritPluginApi(): GerritInternal {
   initGerritPluginApi();
-  return window.Gerrit as GerritGlobal;
+  return window.Gerrit as GerritInternal;
 }
 
 export function deprecatedDelete(
@@ -102,121 +106,124 @@
   getPluginName: () => 'global',
 };
 
-function initGerritPluginsMethods(globalGerritObj: GerritGlobal) {
+/**
+ * TODO(brohlfs): Reduce this step by step until it only contains install().
+ */
+class GerritImpl implements GerritInternal {
+  _customStyleSheet?: CSSStyleSheet;
+
+  public readonly Nav = GerritNav;
+
+  public readonly Auth = appContext.authService;
+
   /**
    * @deprecated Use plugin.styles().css(rulesStr) instead. Please, consult
    * the documentation how to replace it accordingly.
    */
-  globalGerritObj.css = (rulesStr: string) => {
+  css(rulesStr: string) {
     appContext.reportingService.trackApi(fakeApi, 'global', 'css');
     console.warn(
       'Gerrit.css(rulesStr) is deprecated!',
       'Use plugin.styles().css(rulesStr)'
     );
-    if (!globalGerritObj._customStyleSheet) {
+    if (!this._customStyleSheet) {
       const styleEl = document.createElement('style');
       document.head.appendChild(styleEl);
-      globalGerritObj._customStyleSheet = styleEl.sheet!;
+      this._customStyleSheet = styleEl.sheet!;
     }
 
-    const name = `__pg_js_api_class_${globalGerritObj._customStyleSheet.cssRules.length}`;
-    globalGerritObj._customStyleSheet.insertRule(
-      '.' + name + '{' + rulesStr + '}',
-      0
-    );
+    const name = `__pg_js_api_class_${this._customStyleSheet.cssRules.length}`;
+    this._customStyleSheet.insertRule('.' + name + '{' + rulesStr + '}', 0);
     return name;
-  };
+  }
 
-  globalGerritObj.install = (callback, opt_version, opt_src) => {
-    getPluginLoader().install(callback, opt_version, opt_src);
-  };
+  install(
+    callback: (plugin: PluginApi) => void,
+    version?: string,
+    src?: string
+  ) {
+    getPluginLoader().install(callback, version, src);
+  }
 
-  globalGerritObj.getLoggedIn = () => {
+  getLoggedIn() {
     appContext.reportingService.trackApi(fakeApi, 'global', 'getLoggedIn');
     console.warn(
       'Gerrit.getLoggedIn() is deprecated! ' +
         'Use plugin.restApi().getLoggedIn()'
     );
     return appContext.restApiService.getLoggedIn();
-  };
+  }
 
-  globalGerritObj.get = (
-    url: string,
-    callback?: (response: unknown) => void
-  ) => {
+  get(url: string, callback?: (response: unknown) => void) {
     appContext.reportingService.trackApi(fakeApi, 'global', 'get');
     console.warn('.get() is deprecated! Use plugin.restApi().get()');
     send(HttpMethod.GET, url, callback);
-  };
+  }
 
-  globalGerritObj.post = (
+  post(
     url: string,
     payload?: RequestPayload,
     callback?: (response: unknown) => void
-  ) => {
+  ) {
     appContext.reportingService.trackApi(fakeApi, 'global', 'post');
     console.warn('.post() is deprecated! Use plugin.restApi().post()');
     send(HttpMethod.POST, url, callback, payload);
-  };
+  }
 
-  globalGerritObj.put = (
+  put(
     url: string,
     payload?: RequestPayload,
     callback?: (response: unknown) => void
-  ) => {
+  ) {
     appContext.reportingService.trackApi(fakeApi, 'global', 'put');
     console.warn('.put() is deprecated! Use plugin.restApi().put()');
     send(HttpMethod.PUT, url, callback, payload);
-  };
+  }
 
-  globalGerritObj.delete = (
-    url: string,
-    callback?: (response: Response) => void
-  ) => {
+  delete(url: string, callback?: (response: Response) => void) {
     appContext.reportingService.trackApi(fakeApi, 'global', 'delete');
     deprecatedDelete(url, callback);
-  };
+  }
 
-  globalGerritObj.awaitPluginsLoaded = () => {
+  awaitPluginsLoaded() {
     appContext.reportingService.trackApi(
       fakeApi,
       'global',
       'awaitPluginsLoaded'
     );
     return getPluginLoader().awaitPluginsLoaded();
-  };
+  }
 
   // TODO(taoalpha): consider removing these proxy methods
   // and using getPluginLoader() directly
-  globalGerritObj._loadPlugins = plugins => {
+  _loadPlugins(plugins: string[] = []) {
     appContext.reportingService.trackApi(fakeApi, 'global', '_loadPlugins');
     getPluginLoader().loadPlugins(plugins);
-  };
+  }
 
-  globalGerritObj._arePluginsLoaded = () => {
+  _arePluginsLoaded() {
     appContext.reportingService.trackApi(
       fakeApi,
       'global',
       '_arePluginsLoaded'
     );
     return getPluginLoader().arePluginsLoaded();
-  };
+  }
 
-  globalGerritObj._isPluginEnabled = pathOrUrl => {
+  _isPluginEnabled(pathOrUrl: string) {
     appContext.reportingService.trackApi(fakeApi, 'global', '_isPluginEnabled');
     return getPluginLoader().isPluginEnabled(pathOrUrl);
-  };
+  }
 
-  globalGerritObj._isPluginLoaded = pathOrUrl => {
+  isPluginLoaded(pathOrUrl: string) {
+    return this._isPluginLoaded(pathOrUrl);
+  }
+
+  _isPluginLoaded(pathOrUrl: string) {
     appContext.reportingService.trackApi(fakeApi, 'global', '_isPluginLoaded');
     return getPluginLoader().isPluginLoaded(pathOrUrl);
-  };
+  }
 
-  const eventEmitter = appContext.eventEmitter;
-
-  // TODO(taoalpha): List all internal supported event names.
-  // Also convert this to inherited class once we move Gerrit to class.
-  globalGerritObj._eventEmitter = eventEmitter;
   /**
    * Enabling EventEmitter interface on Gerrit.
    *
@@ -239,42 +246,49 @@
    *   });
    * });
    */
-  globalGerritObj.addListener = (eventName: string, cb: EventCallback) => {
+  addListener(eventName: string, cb: EventCallback) {
     appContext.reportingService.trackApi(fakeApi, 'global', 'addListener');
-    return eventEmitter.addListener(eventName, cb);
-  };
+    return appContext.eventEmitter.addListener(eventName, cb);
+  }
+
   // eslint-disable-next-line @typescript-eslint/no-explicit-any
-  globalGerritObj.dispatch = (eventName: string, detail: any) => {
+  dispatch(eventName: string, detail: any) {
     appContext.reportingService.trackApi(fakeApi, 'global', 'dispatch');
-    return eventEmitter.dispatch(eventName, detail);
-  };
+    return appContext.eventEmitter.dispatch(eventName, detail);
+  }
+
   // eslint-disable-next-line @typescript-eslint/no-explicit-any
-  globalGerritObj.emit = (eventName: string, detail: any) => {
+  emit(eventName: string, detail: any) {
     appContext.reportingService.trackApi(fakeApi, 'global', 'emit');
-    return eventEmitter.emit(eventName, detail);
-  };
-  globalGerritObj.off = (eventName: string, cb: EventCallback) => {
+    return appContext.eventEmitter.emit(eventName, detail);
+  }
+
+  off(eventName: string, cb: EventCallback) {
     appContext.reportingService.trackApi(fakeApi, 'global', 'off');
-    return eventEmitter.off(eventName, cb);
-  };
-  globalGerritObj.on = (eventName: string, cb: EventCallback) => {
+    return appContext.eventEmitter.off(eventName, cb);
+  }
+
+  on(eventName: string, cb: EventCallback) {
     appContext.reportingService.trackApi(fakeApi, 'global', 'on');
-    return eventEmitter.on(eventName, cb);
-  };
-  globalGerritObj.once = (eventName: string, cb: EventCallback) => {
+    return appContext.eventEmitter.on(eventName, cb);
+  }
+
+  once(eventName: string, cb: EventCallback) {
     appContext.reportingService.trackApi(fakeApi, 'global', 'once');
-    return eventEmitter.once(eventName, cb);
-  };
-  globalGerritObj.removeAllListeners = (eventName: string) => {
+    return appContext.eventEmitter.once(eventName, cb);
+  }
+
+  removeAllListeners(eventName: string) {
     appContext.reportingService.trackApi(
       fakeApi,
       'global',
       'removeAllListeners'
     );
-    return eventEmitter.removeAllListeners(eventName);
-  };
-  globalGerritObj.removeListener = (eventName: string, cb: EventCallback) => {
+    return appContext.eventEmitter.removeAllListeners(eventName);
+  }
+
+  removeListener(eventName: string, cb: EventCallback) {
     appContext.reportingService.trackApi(fakeApi, 'global', 'removeListener');
-    return eventEmitter.removeListener(eventName, cb);
-  };
+    return appContext.eventEmitter.removeListener(eventName, cb);
+  }
 }
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context.ts
index 2135c30..46c7ad6 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context.ts
@@ -22,6 +22,7 @@
 import {windowLocationReload} from '../../../utils/dom-util';
 import {PopupPluginApi} from '../../../api/popup';
 import {GrPopupInterface} from '../../plugins/gr-popup-interface/gr-popup-interface';
+import {appContext} from '../../../services/app-context';
 
 interface ButtonCallBacks {
   onclick: (event: Event) => boolean;
@@ -30,6 +31,8 @@
 export class GrPluginActionContext {
   private popups: PopupPluginApi[] = [];
 
+  private readonly reporting = appContext.reportingService;
+
   constructor(
     public readonly plugin: PluginApi,
     public readonly action: UIActionInfo,
@@ -108,7 +111,9 @@
   call(payload: RequestPayload, onSuccess: (result: unknown) => void) {
     if (!this.action.method) return;
     if (!this.action.__url) {
-      console.warn(`Unable to ${this.action.method} to ${this.action.__key}!`);
+      this.reporting.error(
+        new Error(`Unable to ${this.action.method} to ${this.action.__key}!`)
+      );
       return;
     }
     this.plugin
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints.ts
index 0cc95a3..f7475bf 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints.ts
@@ -16,7 +16,7 @@
  */
 import {PluginApi} from '../../../api/plugin';
 import {notUndefined} from '../../../types/types';
-import {HookApi} from '../../../api/hook';
+import {HookApi, PluginElement} from '../../../api/hook';
 
 // eslint-disable-next-line @typescript-eslint/no-explicit-any
 type Callback = (value: any) => void;
@@ -26,7 +26,7 @@
   plugin: PluginApi;
   pluginUrl?: URL;
   type?: string;
-  domHook?: HookApi;
+  domHook?: HookApi<PluginElement>;
   slot?: string;
 }
 
@@ -36,7 +36,7 @@
   slot?: string;
   type?: string;
   moduleName?: string;
-  domHook?: HookApi;
+  domHook?: HookApi<PluginElement>;
 }
 
 export class GrPluginEndpoints {
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader.ts
index fe101c5..6fc8f1b 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader.ts
@@ -132,7 +132,7 @@
       try {
         url = new URL(url);
       } catch (e) {
-        console.warn(e);
+        this._getReporting().error(e);
         return false;
       }
     }
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api.ts
index 150c45a..903fb2c 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api.ts
@@ -125,7 +125,7 @@
   /**
    * Fetch and parse REST API response, if request succeeds.
    */
-  send(
+  send<T>(
     method: HttpMethod,
     url: string,
     payload?: RequestPayload,
@@ -167,7 +167,7 @@
             Promise.reject(new Error(msg))
           );
         } else {
-          return this.restApi.getResponseObject(response);
+          return this.restApi.getResponseObject(response) as Promise<T>;
         }
       })
       .catch(err => {
@@ -180,29 +180,29 @@
       });
   }
 
-  get(url: string) {
+  get<T>(url: string) {
     this.reporting.trackApi(this.plugin, 'rest', 'get');
-    return this.send(HttpMethod.GET, url);
+    return this.send<T>(HttpMethod.GET, url);
   }
 
-  post(
+  post<T>(
     url: string,
     payload?: RequestPayload,
     errFn?: ErrorCallback,
     contentType?: string
   ) {
     this.reporting.trackApi(this.plugin, 'rest', 'post');
-    return this.send(HttpMethod.POST, url, payload, errFn, contentType);
+    return this.send<T>(HttpMethod.POST, url, payload, errFn, contentType);
   }
 
-  put(
+  put<T>(
     url: string,
     payload?: RequestPayload,
     errFn?: ErrorCallback,
     contentType?: string
   ) {
     this.reporting.trackApi(this.plugin, 'rest', 'put');
-    return this.send(HttpMethod.PUT, url, payload, errFn, contentType);
+    return this.send<T>(HttpMethod.PUT, url, payload, errFn, contentType);
   }
 
   delete(url: string) {
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.ts
index 35acdef..7d3911a 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.ts
@@ -41,7 +41,7 @@
 import {ChangeActionsPluginApi} from '../../../api/change-actions';
 import {ChangeReplyPluginApi} from '../../../api/change-reply';
 import {RestPluginApi} from '../../../api/rest';
-import {HookApi, RegisterOptions} from '../../../api/hook';
+import {HookApi, PluginElement, RegisterOptions} from '../../../api/hook';
 import {AttributeHelperPluginApi} from '../../../api/attribute-helper';
 
 /**
@@ -79,9 +79,10 @@
     this.domHooks = new GrDomHooksManager(this);
 
     if (!url) {
-      console.warn(
-        'Plugin not being loaded from /plugins base path.',
-        'Unable to determine name.'
+      this.report.error(
+        new Error(
+          'Plugin not being loaded from /plugins base path. Unable to determine name.'
+        )
       );
       return this;
     }
@@ -107,11 +108,11 @@
   /**
    * Registers an endpoint for the plugin.
    */
-  registerCustomComponent(
+  registerCustomComponent<T extends PluginElement>(
     endpointName: string,
     moduleName?: string,
     options?: RegisterOptions
-  ): HookApi {
+  ): HookApi<T> {
     this.report.trackApi(this, 'plugin', 'registerCustomComponent');
     return this._registerCustomComponent(endpointName, moduleName, options);
   }
@@ -122,11 +123,11 @@
    * Dynamic plugins are registered by specific prefix, such as
    * 'change-list-header'.
    */
-  registerDynamicCustomComponent(
+  registerDynamicCustomComponent<T extends PluginElement>(
     endpointName: string,
     moduleName?: string,
     options?: RegisterOptions
-  ): HookApi {
+  ): HookApi<T> {
     this.report.trackApi(this, 'plugin', 'registerDynamicCustomComponent');
     const fullEndpointName = `${endpointName}-${this.getPluginName()}`;
     return this._registerCustomComponent(
@@ -137,16 +138,17 @@
     );
   }
 
-  _registerCustomComponent(
+  _registerCustomComponent<T extends PluginElement>(
     endpoint: string,
     moduleName?: string,
     options?: RegisterOptions,
     dynamicEndpoint?: string
-  ): HookApi {
-    const type =
-      options && options.replace ? EndpointType.REPLACE : EndpointType.DECORATE;
-    const slot = (options && options.slot) || '';
-    const domHook = this.domHooks.getDomHook(endpoint, moduleName);
+  ): HookApi<T> {
+    const type = options?.replace
+      ? EndpointType.REPLACE
+      : EndpointType.DECORATE;
+    const slot = options?.slot ?? '';
+    const domHook = this.domHooks.getDomHook<T>(endpoint, moduleName);
     moduleName = moduleName || domHook.getModuleName();
     getPluginEndpoints().registerModule(this, {
       slot,
@@ -163,7 +165,10 @@
    * Returns instance of DOM hook API for endpoint. Creates a placeholder
    * element for the first call.
    */
-  hook(endpointName: string, options?: RegisterOptions) {
+  hook<T extends PluginElement>(
+    endpointName: string,
+    options?: RegisterOptions
+  ): HookApi<T> {
     this.report.trackApi(this, 'plugin', 'hook');
     return this.registerCustomComponent(endpointName, undefined, options);
   }
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-reporting-js-api.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-reporting-js-api.ts
index 595fb4f..0427b43 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-reporting-js-api.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-reporting-js-api.ts
@@ -18,7 +18,6 @@
 import {appContext} from '../../../services/app-context';
 import {PluginApi} from '../../../api/plugin';
 import {EventDetails, ReportingPluginApi} from '../../../api/reporting';
-import {LifeCycle} from '../../../constants/reporting';
 
 /**
  * Defines all methods that will be exported to plugin from reporting service.
@@ -32,7 +31,7 @@
 
   reportInteraction(eventName: string, details?: EventDetails) {
     this.reporting.trackApi(this.plugin, 'reporting', 'reportInteraction');
-    this.reporting.reportInteraction(
+    this.reporting.reportPluginInteractionLog(
       `${this.plugin.getPluginName()}-${eventName}`,
       details
     );
@@ -40,10 +39,9 @@
 
   reportLifeCycle(eventName: string, details?: EventDetails) {
     this.reporting.trackApi(this.plugin, 'reporting', 'reportLifeCycle');
-    this.reporting.reportLifeCycle(LifeCycle.PLUGIN_LIFE_CYCLE, {
-      ...details,
-      pluginName: this.plugin.getPluginName(),
-      eventName,
-    });
+    this.reporting.reportPluginLifeCycleLog(
+      `${this.plugin.getPluginName()}-${eventName}`,
+      details
+    );
   }
 }
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-reporting-js-api_test.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-reporting-js-api_test.js
index ffc2fbb..71cc565 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-reporting-js-api_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-reporting-js-api_test.js
@@ -43,30 +43,34 @@
     });
 
     test('redirect reportInteraction call to reportingService', () => {
-      sinon.spy(appContext.reportingService, 'reportInteraction');
+      sinon.spy(appContext.reportingService, 'reportPluginInteractionLog');
       reporting.reportInteraction('test', {});
-      assert.isTrue(appContext.reportingService.reportInteraction.called);
+      assert.isTrue(appContext.reportingService.reportPluginInteractionLog
+          .called);
       assert.equal(
-          appContext.reportingService.reportInteraction.lastCall.args[0],
+          appContext.reportingService.reportPluginInteractionLog.lastCall
+              .args[0],
           'testplugin-test'
       );
       assert.deepEqual(
-          appContext.reportingService.reportInteraction.lastCall.args[1],
+          appContext.reportingService.reportPluginInteractionLog.lastCall
+              .args[1],
           {}
       );
     });
 
     test('redirect reportLifeCycle call to reportingService', () => {
-      sinon.spy(appContext.reportingService, 'reportLifeCycle');
+      sinon.spy(appContext.reportingService, 'reportPluginLifeCycleLog');
       reporting.reportLifeCycle('test', {});
-      assert.isTrue(appContext.reportingService.reportLifeCycle.called);
+      assert.isTrue(appContext.reportingService.reportPluginLifeCycleLog
+          .called);
       assert.equal(
-          appContext.reportingService.reportLifeCycle.lastCall.args[0],
-          'Plugin life cycle'
+          appContext.reportingService.reportPluginLifeCycleLog.lastCall.args[0],
+          'testplugin-test'
       );
       assert.deepEqual(
-          appContext.reportingService.reportLifeCycle.lastCall.args[1],
-          {pluginName: 'testplugin', eventName: 'test'}
+          appContext.reportingService.reportPluginLifeCycleLog.lastCall.args[1],
+          {}
       );
     });
   });
diff --git a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.ts b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.ts
index db22ce5..31a709a 100644
--- a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.ts
+++ b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.ts
@@ -81,6 +81,8 @@
 
   private readonly restApiService = appContext.restApiService;
 
+  private readonly reporting = appContext.reportingService;
+
   // TODO(TS): not used, remove later
   _xhrPromise?: Promise<void>;
 
@@ -209,7 +211,7 @@
         }
       })
       .catch(err => {
-        console.warn(err);
+        this.reporting.error(err);
         target.disabled = false;
         return;
       });
diff --git a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_html.ts b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_html.ts
index 56ab8c6..b6583d9 100644
--- a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_html.ts
+++ b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_html.ts
@@ -88,7 +88,7 @@
   <p
     class$="placeholder [[_computeShowPlaceholder(labelInfo, change.labels.*)]]"
   >
-    No votes.
+    No votes
   </p>
   <table>
     <template
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-lib-loader/resemblejs_config.ts b/polygerrit-ui/app/elements/shared/gr-lib-loader/resemblejs_config.ts
new file mode 100644
index 0000000..872d01c
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-lib-loader/resemblejs_config.ts
@@ -0,0 +1,33 @@
+/**
+ * @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 {LibraryConfig} from './gr-lib-loader';
+
+export const RESEMBLEJS_LIBRARY_CONFIG: LibraryConfig = {
+  src: 'bower_components/resemblejs/resemble.js',
+  checkPresent: () => window.resemble !== undefined,
+  configureCallback: () => {
+    window.resemble!.outputSettings({
+      errorColor: {red: 255, green: 0, blue: 255},
+      errorType: 'flat',
+      transparency: 0,
+      // Disable large image threshold; by default this otherwise skips pixels
+      // if width or height exceed 1200 pixels.
+      largeImageThreshold: 0,
+    });
+    return window.resemble;
+  },
+};
diff --git a/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text.ts b/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text.ts
index f76f8d7..e3d3077 100644
--- a/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text.ts
+++ b/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text.ts
@@ -39,14 +39,14 @@
 
   /** The un-truncated text to display. */
   @property({type: String})
-  text = '';
+  text?: string;
 
   /** The maximum length for the text to display before truncating. */
   @property({type: Number})
-  limit: number | null = null;
+  limit?: number;
 
   @property({type: String})
-  tooltip = '';
+  tooltip?: string;
 
   /** Boolean property used by TooltipMixin. */
   @property({type: Boolean})
@@ -61,11 +61,10 @@
    * enabled.
    */
   @observe('text', 'tooltip', 'limit')
-  _updateTitle(text: string, tooltip: string, limit?: number) {
-    // Polymer 2: check for undefined
-    if ([text, limit, tooltip].includes(undefined)) {
-      return;
-    }
+  _updateTitle(text?: string, tooltip?: string, limit?: number) {
+    text = text ?? '';
+    tooltip = tooltip ?? '';
+    limit = limit ?? 0;
 
     this.hasTooltip = !!tooltip || (!!limit && text.length > limit);
     if (this.hasTooltip && !this.disableTooltip) {
@@ -80,7 +79,7 @@
     }
   }
 
-  _computeDisplayText(text: string, limit?: number) {
+  _computeDisplayText(text?: string, limit?: number) {
     if (!!limit && !!text && text.length > limit) {
       return text.substr(0, limit - 1) + '…';
     }
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-linked-text/gr-linked-text_html.ts b/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text_html.ts
index 4bdc1ab..0d44bc8 100644
--- a/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text_html.ts
+++ b/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text_html.ts
@@ -17,7 +17,7 @@
 import {html} from '@polymer/polymer/lib/utils/html-tag';
 
 export const htmlTemplate = html`
-  <style include="shared-styles">
+  <style>
     :host {
       display: block;
     }
@@ -30,6 +30,9 @@
       text-decoration: none;
       pointer-events: none;
     }
+    a {
+      color: var(--link-color);
+    }
   </style>
   <span id="output"></span>
 `;
diff --git a/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text_test.js b/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text_test.ts
similarity index 68%
rename from polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text_test.js
rename to polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text_test.ts
index a67fbc4..b2cdba1 100644
--- a/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text_test.ts
@@ -15,27 +15,30 @@
  * limitations under the License.
  */
 
-import '../../../test/common-test-setup-karma.js';
-import './gr-linked-text.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+import '../../../test/common-test-setup-karma';
+import './gr-linked-text';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+import {GrLinkedText} from './gr-linked-text';
+import {CommentLinks} from '../../../types/common';
+import {queryAndAssert} from '../../../test/test-utils';
 
 const basicFixture = fixtureFromTemplate(html`
-<gr-linked-text>
-      <div id="output"></div>
-    </gr-linked-text>
+  <gr-linked-text>
+    <div id="output"></div>
+  </gr-linked-text>
 `);
 
 suite('gr-linked-text tests', () => {
-  let element;
+  let element: GrLinkedText;
 
-  let originalCanonicalPath;
+  let originalCanonicalPath: string | undefined;
 
   setup(() => {
     originalCanonicalPath = window.CANONICAL_PATH;
-    element = basicFixture.instantiate();
+    element = basicFixture.instantiate() as GrLinkedText;
 
-    sinon.stub(GerritNav, 'mapCommentlinks').value( x => x);
+    sinon.stub(GerritNav, 'mapCommentlinks').value((x: CommentLinks) => x);
     element.config = {
       ph: {
         match: '([Bb]ug|[Ii]ssue)\\s*#?(\\d+)',
@@ -86,7 +89,8 @@
     // Regular inline link.
     const url = 'https://bugs.chromium.org/p/gerrit/issues/detail?id=3650';
     element.content = url;
-    const linkEl = element.$.output.childNodes[0];
+    const linkEl = queryAndAssert(element, '#output')
+      .childNodes[0] as HTMLAnchorElement;
     assert.equal(linkEl.target, '_blank');
     assert.equal(linkEl.rel, 'noopener');
     assert.equal(linkEl.href, url);
@@ -97,14 +101,16 @@
     // "Issue/Bug" pattern.
     element.content = 'Issue 3650';
 
-    let linkEl = element.$.output.childNodes[0];
+    let linkEl = queryAndAssert(element, '#output')
+      .childNodes[0] as HTMLAnchorElement;
     const url = 'https://bugs.chromium.org/p/gerrit/issues/detail?id=3650';
     assert.equal(linkEl.target, '_blank');
     assert.equal(linkEl.href, url);
     assert.equal(linkEl.textContent, 'Issue 3650');
 
     element.content = 'Bug 3650';
-    linkEl = element.$.output.childNodes[0];
+    linkEl = queryAndAssert(element, '#output')
+      .childNodes[0] as HTMLAnchorElement;
     assert.equal(linkEl.target, '_blank');
     assert.equal(linkEl.rel, 'noopener');
     assert.equal(linkEl.href, url);
@@ -115,8 +121,9 @@
     // Pattern starts with the same prefix (`http`) as the url.
     element.content = 'httpexample 3650';
 
-    assert.equal(element.$.output.childNodes.length, 1);
-    const linkEl = element.$.output.childNodes[0];
+    assert.equal(queryAndAssert(element, '#output').childNodes.length, 1);
+    const linkEl = queryAndAssert(element, '#output')
+      .childNodes[0] as HTMLAnchorElement;
     const url = 'https://bugs.chromium.org/p/gerrit/issues/detail?id=3650';
     assert.equal(linkEl.target, '_blank');
     assert.equal(linkEl.href, url);
@@ -129,8 +136,9 @@
     const prefix = 'Change-Id: ';
     element.content = prefix + changeID;
 
-    const textNode = element.$.output.childNodes[0];
-    const linkEl = element.$.output.childNodes[1];
+    const textNode = queryAndAssert(element, '#output').childNodes[0];
+    const linkEl = queryAndAssert(element, '#output')
+      .childNodes[1] as HTMLAnchorElement;
     assert.equal(textNode.textContent, prefix);
     const url = '/q/' + changeID;
     assert.isFalse(linkEl.hasAttribute('target'));
@@ -147,8 +155,9 @@
     const prefix = 'Change-Id: ';
     element.content = prefix + changeID;
 
-    const textNode = element.$.output.childNodes[0];
-    const linkEl = element.$.output.childNodes[1];
+    const textNode = queryAndAssert(element, '#output').childNodes[0];
+    const linkEl = queryAndAssert(element, '#output')
+      .childNodes[1] as HTMLAnchorElement;
     assert.equal(textNode.textContent, prefix);
     const url = '/r/q/' + changeID;
     assert.isFalse(linkEl.hasAttribute('target'));
@@ -159,17 +168,23 @@
 
   test('Multiple matches', () => {
     element.content = 'Issue 3650\nIssue 3450';
-    const linkEl1 = element.$.output.childNodes[0];
-    const linkEl2 = element.$.output.childNodes[2];
+    const linkEl1 = queryAndAssert(element, '#output')
+      .childNodes[0] as HTMLAnchorElement;
+    const linkEl2 = queryAndAssert(element, '#output')
+      .childNodes[2] as HTMLAnchorElement;
 
     assert.equal(linkEl1.target, '_blank');
-    assert.equal(linkEl1.href,
-        'https://bugs.chromium.org/p/gerrit/issues/detail?id=3650');
+    assert.equal(
+      linkEl1.href,
+      'https://bugs.chromium.org/p/gerrit/issues/detail?id=3650'
+    );
     assert.equal(linkEl1.textContent, 'Issue 3650');
 
     assert.equal(linkEl2.target, '_blank');
-    assert.equal(linkEl2.href,
-        'https://bugs.chromium.org/p/gerrit/issues/detail?id=3450');
+    assert.equal(
+      linkEl2.href,
+      'https://bugs.chromium.org/p/gerrit/issues/detail?id=3450'
+    );
     assert.equal(linkEl2.textContent, 'Issue 3450');
   });
 
@@ -186,9 +201,11 @@
 
     element.content = prefix + changeID + bug;
 
-    const textNode = element.$.output.childNodes[0];
-    const changeLinkEl = element.$.output.childNodes[1];
-    const bugLinkEl = element.$.output.childNodes[2];
+    const textNode = queryAndAssert(element, '#output').childNodes[0];
+    const changeLinkEl = queryAndAssert(element, '#output')
+      .childNodes[1] as HTMLAnchorElement;
+    const bugLinkEl = queryAndAssert(element, '#output')
+      .childNodes[2] as HTMLAnchorElement;
 
     assert.equal(textNode.textContent, prefix);
 
@@ -203,15 +220,19 @@
 
   test('html field in link config', () => {
     element.content = 'google:do a barrel roll';
-    const linkEl = element.$.output.childNodes[0];
-    assert.equal(linkEl.getAttribute('href'),
-        'https://google.com/search?q=do a barrel roll');
+    const linkEl = queryAndAssert(element, '#output')
+      .childNodes[0] as HTMLAnchorElement;
+    assert.equal(
+      linkEl.getAttribute('href'),
+      'https://google.com/search?q=do a barrel roll'
+    );
     assert.equal(linkEl.textContent, 'do a barrel roll');
   });
 
   test('removing hash from links', () => {
     element.content = 'hash:foo';
-    const linkEl = element.$.output.childNodes[0];
+    const linkEl = queryAndAssert(element, '#output')
+      .childNodes[0] as HTMLAnchorElement;
     assert.isTrue(linkEl.href.endsWith('/awesomesauce'));
     assert.equal(linkEl.textContent, 'foo');
   });
@@ -220,7 +241,8 @@
     window.CANONICAL_PATH = '/r';
 
     element.content = 'test foo';
-    const linkEl = element.$.output.childNodes[0];
+    const linkEl = queryAndAssert(element, '#output')
+      .childNodes[0] as HTMLAnchorElement;
     assert.isTrue(linkEl.href.endsWith('/r/awesomesauce'));
     assert.equal(linkEl.textContent, 'foo');
   });
@@ -229,7 +251,8 @@
     window.CANONICAL_PATH = '/r';
 
     element.content = 'a test foo';
-    const linkEl = element.$.output.childNodes[1];
+    const linkEl = queryAndAssert(element, '#output')
+      .childNodes[1] as HTMLAnchorElement;
     assert.isTrue(linkEl.href.endsWith('/r/awesomesauce'));
     assert.equal(linkEl.textContent, 'foo');
   });
@@ -238,94 +261,119 @@
     window.CANONICAL_PATH = '/r';
 
     element.content = 'hash:foo';
-    const linkEl = element.$.output.childNodes[0];
+    const linkEl = queryAndAssert(element, '#output')
+      .childNodes[0] as HTMLAnchorElement;
     assert.isTrue(linkEl.href.endsWith('/r/awesomesauce'));
     assert.equal(linkEl.textContent, 'foo');
   });
 
   test('disabled config', () => {
     element.content = 'foo:baz';
-    assert.equal(element.$.output.innerHTML, 'foo:baz');
+    assert.equal(queryAndAssert(element, '#output').innerHTML, 'foo:baz');
   });
 
   test('R=email labels link correctly', () => {
     element.removeZeroWidthSpace = true;
     element.content = 'R=\u200Btest@google.com';
-    assert.equal(element.$.output.textContent, 'R=test@google.com');
-    assert.equal(element.$.output.innerHTML.match(/(R=<a)/g).length, 1);
+    assert.equal(
+      queryAndAssert(element, '#output').textContent,
+      'R=test@google.com'
+    );
+    assert.equal(
+      queryAndAssert(element, '#output').innerHTML.match(/(R=<a)/g)!.length,
+      1
+    );
   });
 
   test('CC=email labels link correctly', () => {
     element.removeZeroWidthSpace = true;
     element.content = 'CC=\u200Btest@google.com';
-    assert.equal(element.$.output.textContent, 'CC=test@google.com');
-    assert.equal(element.$.output.innerHTML.match(/(CC=<a)/g).length, 1);
+    assert.equal(
+      queryAndAssert(element, '#output').textContent,
+      'CC=test@google.com'
+    );
+    assert.equal(
+      queryAndAssert(element, '#output').innerHTML.match(/(CC=<a)/g)!.length,
+      1
+    );
   });
 
   test('only {http,https,mailto} protocols are linkified', () => {
     element.content = 'xx mailto:test@google.com yy';
-    let links = element.$.output.querySelectorAll('a');
+    let links = queryAndAssert(element, '#output').querySelectorAll('a');
     assert.equal(links.length, 1);
     assert.equal(links[0].getAttribute('href'), 'mailto:test@google.com');
     assert.equal(links[0].innerHTML, 'mailto:test@google.com');
 
     element.content = 'xx http://google.com yy';
-    links = element.$.output.querySelectorAll('a');
+    links = queryAndAssert(element, '#output').querySelectorAll('a');
     assert.equal(links.length, 1);
     assert.equal(links[0].getAttribute('href'), 'http://google.com');
     assert.equal(links[0].innerHTML, 'http://google.com');
 
     element.content = 'xx https://google.com yy';
-    links = element.$.output.querySelectorAll('a');
+    links = queryAndAssert(element, '#output').querySelectorAll('a');
     assert.equal(links.length, 1);
     assert.equal(links[0].getAttribute('href'), 'https://google.com');
     assert.equal(links[0].innerHTML, 'https://google.com');
 
     element.content = 'xx ssh://google.com yy';
-    links = element.$.output.querySelectorAll('a');
+    links = queryAndAssert(element, '#output').querySelectorAll('a');
     assert.equal(links.length, 0);
 
     element.content = 'xx ftp://google.com yy';
-    links = element.$.output.querySelectorAll('a');
+    links = queryAndAssert(element, '#output').querySelectorAll('a');
     assert.equal(links.length, 0);
   });
 
   test('links without leading whitespace are linkified', () => {
     element.content = 'xx abcmailto:test@google.com yy';
-    assert.equal(element.$.output.innerHTML.substr(0, 6), 'xx abc');
-    let links = element.$.output.querySelectorAll('a');
+    assert.equal(
+      queryAndAssert(element, '#output').innerHTML.substr(0, 6),
+      'xx abc'
+    );
+    let links = queryAndAssert(element, '#output').querySelectorAll('a');
     assert.equal(links.length, 1);
     assert.equal(links[0].getAttribute('href'), 'mailto:test@google.com');
     assert.equal(links[0].innerHTML, 'mailto:test@google.com');
 
     element.content = 'xx defhttp://google.com yy';
-    assert.equal(element.$.output.innerHTML.substr(0, 6), 'xx def');
-    links = element.$.output.querySelectorAll('a');
+    assert.equal(
+      queryAndAssert(element, '#output').innerHTML.substr(0, 6),
+      'xx def'
+    );
+    links = queryAndAssert(element, '#output').querySelectorAll('a');
     assert.equal(links.length, 1);
     assert.equal(links[0].getAttribute('href'), 'http://google.com');
     assert.equal(links[0].innerHTML, 'http://google.com');
 
     element.content = 'xx qwehttps://google.com yy';
-    assert.equal(element.$.output.innerHTML.substr(0, 6), 'xx qwe');
-    links = element.$.output.querySelectorAll('a');
+    assert.equal(
+      queryAndAssert(element, '#output').innerHTML.substr(0, 6),
+      'xx qwe'
+    );
+    links = queryAndAssert(element, '#output').querySelectorAll('a');
     assert.equal(links.length, 1);
     assert.equal(links[0].getAttribute('href'), 'https://google.com');
     assert.equal(links[0].innerHTML, 'https://google.com');
 
     // Non-latin character
     element.content = 'xx абвhttps://google.com yy';
-    assert.equal(element.$.output.innerHTML.substr(0, 6), 'xx абв');
-    links = element.$.output.querySelectorAll('a');
+    assert.equal(
+      queryAndAssert(element, '#output').innerHTML.substr(0, 6),
+      'xx абв'
+    );
+    links = queryAndAssert(element, '#output').querySelectorAll('a');
     assert.equal(links.length, 1);
     assert.equal(links[0].getAttribute('href'), 'https://google.com');
     assert.equal(links[0].innerHTML, 'https://google.com');
 
     element.content = 'xx ssh://google.com yy';
-    links = element.$.output.querySelectorAll('a');
+    links = queryAndAssert(element, '#output').querySelectorAll('a');
     assert.equal(links.length, 0);
 
     element.content = 'xx ftp://google.com yy';
-    links = element.$.output.querySelectorAll('a');
+    links = queryAndAssert(element, '#output').querySelectorAll('a');
     assert.equal(links.length, 0);
   });
 
@@ -341,11 +389,13 @@
       },
     };
     element.content = '- B: 123, 45';
-    const links = element.root.querySelectorAll('a');
+    const links = element.root!.querySelectorAll('a');
 
     assert.equal(links.length, 2);
-    assert.equal(element.shadowRoot
-        .querySelector('span').textContent, '- B: 123, 45');
+    assert.equal(
+      queryAndAssert<HTMLSpanElement>(element, 'span').textContent,
+      '- B: 123, 45'
+    );
 
     assert.equal(links[0].href, 'ftp://foo/123');
     assert.equal(links[0].textContent, '123');
@@ -362,4 +412,3 @@
     assert.isTrue(contentConfigStub.called);
   });
 });
-
diff --git a/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view.ts b/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view.ts
index f586f48..bdce2c9 100644
--- a/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view.ts
+++ b/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view.ts
@@ -35,7 +35,7 @@
 }
 
 @customElement('gr-list-view')
-class GrListView extends PolymerElement {
+export class GrListView extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
diff --git a/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav.ts b/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav.ts
index 9a9dc03..221af37 100644
--- a/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav.ts
+++ b/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav.ts
@@ -31,6 +31,12 @@
   };
 }
 
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-page-nav': GrPageNav;
+  }
+}
+
 @customElement('gr-page-nav')
 export class GrPageNav extends PolymerElement {
   static get template() {
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.ts b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.ts
index 852f0b6..28bc229 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.ts
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.ts
@@ -1443,6 +1443,10 @@
     this._restApiHelper.invalidateFetchPromisesPrefix('/accounts/');
   }
 
+  invalidateAccountsDetailCache() {
+    this._restApiHelper.invalidateFetchPromisesPrefix('/accounts/self/detail');
+  }
+
   getGroups(filter: string, groupsPerPage: number, offset?: number) {
     const url = this._getGroupsUrl(filter, groupsPerPage, offset);
 
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-select/gr-select_test.js b/polygerrit-ui/app/elements/shared/gr-select/gr-select_test.ts
similarity index 75%
rename from polygerrit-ui/app/elements/shared/gr-select/gr-select_test.js
rename to polygerrit-ui/app/elements/shared/gr-select/gr-select_test.ts
index c697850..245d7df 100644
--- a/polygerrit-ui/app/elements/shared/gr-select/gr-select_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-select/gr-select_test.ts
@@ -15,41 +15,42 @@
  * limitations under the License.
  */
 
-import '../../../test/common-test-setup-karma.js';
-import './gr-select.js';
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+import '../../../test/common-test-setup-karma';
+import './gr-select';
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+import {GrSelect} from './gr-select';
 
 const basicFixture = fixtureFromTemplate(html`
-<gr-select>
-      <select>
-        <option value="1">One</option>
-        <option value="2">Two</option>
-        <option value="3">Three</option>
-      </select>
-    </gr-select>
+  <gr-select>
+    <select>
+      <option value="1">One</option>
+      <option value="2">Two</option>
+      <option value="3">Three</option>
+    </select>
+  </gr-select>
 `);
 
 const noOptionsFixture = fixtureFromTemplate(html`
-<gr-select>
-      <select>
-      </select>
-    </gr-select>
+  <gr-select>
+    <select></select>
+  </gr-select>
 `);
 
 suite('gr-select tests', () => {
-  let element;
+  let element: GrSelect;
 
   setup(() => {
-    element = basicFixture.instantiate();
+    element = basicFixture.instantiate() as GrSelect;
   });
 
   test('bindValue must be set to the first option value', () => {
     assert.equal(element.bindValue, '1');
+    assert.equal(element.nativeSelect.value, '1');
   });
 
   test('value of 0 should still trigger value updates', () => {
-    element.bindValue = 0;
-    assert.equal(element.nativeSelect.value, 0);
+    element.bindValue = '0';
+    assert.equal(element.nativeSelect.value, '');
   });
 
   test('bidirectional binding property-to-attribute', () => {
@@ -82,9 +83,11 @@
     // Now change the value.
     element.nativeSelect.value = '3';
     element.dispatchEvent(
-        new CustomEvent('change', {
-          composed: true, bubbles: true,
-        }));
+      new CustomEvent('change', {
+        composed: true,
+        bubbles: true,
+      })
+    );
 
     // It should be updated.
     assert.equal(element.nativeSelect.value, '3');
@@ -93,10 +96,10 @@
   });
 
   suite('gr-select no options tests', () => {
-    let element;
+    let element: GrSelect;
 
     setup(() => {
-      element = noOptionsFixture.instantiate();
+      element = noOptionsFixture.instantiate() as GrSelect;
     });
 
     test('bindValue must not be changed', () => {
@@ -104,4 +107,3 @@
     });
   });
 });
-
diff --git a/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command.ts b/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command.ts
index 56d01d2..b10c43b 100644
--- a/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command.ts
+++ b/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command.ts
@@ -27,7 +27,7 @@
 }
 
 @customElement('gr-shell-command')
-class GrShellCommand extends PolymerElement {
+export class GrShellCommand extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
diff --git a/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command_html.ts b/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command_html.ts
index 6c7f0d8..e43460d 100644
--- a/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command_html.ts
+++ b/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command_html.ts
@@ -45,17 +45,15 @@
       /* Should roughly match the height of .commandContainer without padding. */
       line-height: 26px;
     }
-    .commandContainer gr-copy-clipboard {
-      --text-container-style: {
-        border: none;
-      }
+    .commandContainer gr-copy-clipboard::part(text-container-style) {
+      border: none;
     }
   </style>
   <label>[[label]]</label>
   <div class="commandContainer">
     <gr-copy-clipboard
       text="[[command]]"
-      has-tooltip
+      hasTooltip
       button-title="[[tooltip]]"
     ></gr-copy-clipboard>
   </div>
diff --git a/polygerrit-ui/app/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-textarea/gr-textarea.ts b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts
index b3d1578..a747ac4 100644
--- a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts
+++ b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts
@@ -227,11 +227,13 @@
     this._setEmoji(this.$.emojiSuggestions.getCurrentText());
   }
 
-  _handleEnterByKey(e: KeyboardEvent) {
+  _handleEnterByKey(e: CustomEvent<{keyboardEvent: KeyboardEvent}>) {
     // Enter should have newline behavior if the picker is closed or if the user
-    // has only typed ':'.
+    // has only typed ':'. Also make sure that shortcuts aren't clobbered.
     if (this._hideEmojiAutocomplete || this.disableEnterKeyForSelectingEmoji) {
-      this.indent(e);
+      if (!e.detail.keyboardEvent.metaKey && !e.detail.keyboardEvent.ctrlKey) {
+        this.indent(e);
+      }
       return;
     }
 
@@ -402,7 +404,7 @@
     );
   }
 
-  private indent(e: KeyboardEvent): void {
+  private indent(e: CustomEvent<{keyboardEvent: KeyboardEvent}>): void {
     if (!document.queryCommandSupported('insertText')) {
       return;
     }
diff --git a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_test.js b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_test.js
index 1747fce..7c2f209 100644
--- a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_test.js
@@ -230,6 +230,26 @@
     assert.deepEqual(indentCommand.args[0], ['insertText', false, '\n    ']);
   });
 
+  test('ctrl+enter and meta+enter do not indent', async () => {
+    const indentCommand = sinon.stub(document, 'execCommand');
+    element.$.textarea.value = '    a';
+    element._handleEnterByKey(
+        new CustomEvent('keydown', {
+          detail: {keyboardEvent: {keyCode: 13, ctrlKey: true}},
+        })
+    );
+    await flush();
+    assert.isTrue(indentCommand.notCalled);
+
+    element._handleEnterByKey(
+        new CustomEvent('keydown', {
+          detail: {keyboardEvent: {keyCode: 13, metaKey: true}},
+        })
+    );
+    await flush();
+    assert.isTrue(indentCommand.notCalled);
+  });
+
   test('emoji dropdown is closed when iron-overlay-closed is fired', () => {
     const resetSpy = sinon.spy(element, '_resetEmojiDropdown');
     element.$.emojiSuggestions.dispatchEvent(
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/elements/shared/revision-info/revision-info.ts b/polygerrit-ui/app/elements/shared/revision-info/revision-info.ts
index b876f2e..f533bbd 100644
--- a/polygerrit-ui/app/elements/shared/revision-info/revision-info.ts
+++ b/polygerrit-ui/app/elements/shared/revision-info/revision-info.ts
@@ -63,6 +63,10 @@
     return this.getParentCountMap()[patchNum as number];
   }
 
+  isMergeCommit(patchNum: PatchSetNum) {
+    return this.getParentCount(patchNum) > 1;
+  }
+
   /**
    * Get the commit ID of the (0-offset) indexed parent in the given revision
    * number.
diff --git a/polygerrit-ui/app/embed/gr-diff.ts b/polygerrit-ui/app/embed/gr-diff.ts
index ffee627..422667a4 100644
--- a/polygerrit-ui/app/embed/gr-diff.ts
+++ b/polygerrit-ui/app/embed/gr-diff.ts
@@ -25,9 +25,10 @@
 import '../scripts/bundled-polymer';
 import '../elements/diff/gr-diff/gr-diff';
 import '../elements/diff/gr-diff-cursor/gr-diff-cursor';
-import {initDiffAppContext} from './gr-diff-app-context-init';
-import {GrAnnotation} from '../elements/diff/gr-diff-highlight/gr-annotation';
 import {TokenHighlightLayer} from '../elements/diff/gr-diff-builder/token-highlight-layer';
+import {GrDiffCursor} from '../elements/diff/gr-diff-cursor/gr-diff-cursor';
+import {GrAnnotation} from '../elements/diff/gr-diff-highlight/gr-annotation';
+import {initDiffAppContext} from './gr-diff-app-context-init';
 
 // Setup appContext for diff.
 // TODO (dmfilippov): find a better solution
@@ -35,6 +36,7 @@
 // Setup global variables for existing usages of this component
 window.grdiff = {
   GrAnnotation,
+  GrDiffCursor,
   TokenHighlightLayer,
 };
 
diff --git a/polygerrit-ui/app/empty_test.sh b/polygerrit-ui/app/empty_test.sh
new file mode 100755
index 0000000..e69de29
--- /dev/null
+++ b/polygerrit-ui/app/empty_test.sh
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/node_modules_licenses/licenses.ts b/polygerrit-ui/app/node_modules_licenses/licenses.ts
index ac49388..bcdab0e 100644
--- a/polygerrit-ui/app/node_modules_licenses/licenses.ts
+++ b/polygerrit-ui/app/node_modules_licenses/licenses.ts
@@ -288,6 +288,14 @@
     license: SharedLicenses.Polymer2017
   },
   {
+    name: "@types/resemblejs",
+    license: {
+      name: 'DefinitelyTyped',
+      type: LicenseTypes.Mit,
+      packageLicenseFile: "LICENSE"
+    }
+  },
+  {
     name: "@types/resize-observer-browser",
     license: {
       name: 'DefinitelyTyped',
@@ -389,6 +397,22 @@
     },
     nonPackages: ["modules", "test/validateModuleExportsMatchCommonJS"],
   },
+  {
+    name: "resemblejs",
+    license: {
+      name: "resemblejs",
+      type: LicenseTypes.Mit,
+      packageLicenseFile: "LICENSE",
+    }
+  },
+  {
+    name: "immer",
+    license: {
+      name: "immer",
+      type: LicenseTypes.Mit,
+      packageLicenseFile: "LICENSE",
+    }
+  }
 ];
 
 export default packages;
diff --git a/polygerrit-ui/app/package.json b/polygerrit-ui/app/package.json
index 4f5acf5..d26dc97 100644
--- a/polygerrit-ui/app/package.json
+++ b/polygerrit-ui/app/package.json
@@ -31,15 +31,18 @@
     "@polymer/paper-toggle-button": "^3.0.1",
     "@polymer/paper-tooltip": "^3.0.1",
     "@polymer/polymer": "^3.4.1",
+    "@types/resemblejs": "^3.2.0",
     "@types/resize-observer-browser": "^0.1.5",
     "@webcomponents/shadycss": "^1.10.2",
     "@webcomponents/webcomponentsjs": "^1.3.3",
     "ba-linkify": "file:../../lib/ba-linkify/src/",
-    "codemirror-minified": "^5.60.0",
+    "codemirror-minified": "^5.62.0",
+    "immer": "^9.0.5",
     "lit-element": "^2.5.1",
     "page": "^1.11.6",
     "polymer-bridges": "file:../../polymer-bridges/",
     "polymer-resin": "^2.0.1",
+    "resemblejs": "^4.0.0",
     "rxjs": "^6.6.7"
   },
   "license": "Apache-2.0",
diff --git a/polygerrit-ui/app/rules.bzl b/polygerrit-ui/app/rules.bzl
index feb1a82..140434f 100644
--- a/polygerrit-ui/app/rules.bzl
+++ b/polygerrit-ui/app/rules.bzl
@@ -34,54 +34,60 @@
         result.append(_get_ts_compiled_path(outdir, f))
     return result
 
-def compile_ts(name, srcs, ts_outdir, include_tests = False):
-    """Compiles srcs files with the typescript compiler
+def compile_ts(name, srcs, ts_outdir, additional_deps = [], ts_project = "tsconfig_bazel.json", emitJS = True, tags = []):
+    """Compiles srcs files with the typescript compiler. The following
+    dependencies are always passed:
+      the file specified by the ts_project argument
+      :tsconfig.json"
+      @ui_npm//:node_modules,
+    If compilation succeed, the file name+".success" is created. This is useful
+    for wrapping compilation in bazel test rules.
 
     Args:
       name: rule name
       srcs: list of input files (.js, .d.ts and .ts)
-      ts_outdir: typescript output directory
+      ts_outdir: typescript output directory; ignored if emitJS is True
+      additional_deps: list of additional dependencies for compilation
+      ts_project: the file with typescript project. If it extends another
+        typescript file, ensure that this other file is either in the default or
+        in the additional_deps dependencies.
+      emitJS: True - the rule generates JS output; otherwise(False) the rule
+        just run a compiler (for error checking)
 
     Returns:
-      The list of compiled files
+      The list of compiled JS files if emitJS is True; otherwise returns an
+      empty list
     """
     ts_rule_name = name + "_ts_compiled"
 
     # List of files produced by the typescript compiler
-    generated_js = _get_ts_output_files(ts_outdir, srcs)
+    generated_js = _get_ts_output_files(ts_outdir, srcs) if emitJS else []
 
     all_srcs = srcs + [
         ":tsconfig.json",
-        ":tsconfig_bazel.json",
         "@ui_npm//:node_modules",
-    ]
-    ts_project = "tsconfig_bazel.json"
+    ] + [ts_project] + additional_deps
 
-    if include_tests:
-        all_srcs = all_srcs + [
-            ":tsconfig_bazel_test.json",
-            "@ui_dev_npm//:node_modules",
-        ]
-        ts_project = "tsconfig_bazel_test.json"
+    success_out = name + ".success"
 
     # Run the compiler
     native.genrule(
         name = ts_rule_name,
         srcs = all_srcs,
-        outs = generated_js,
+        outs = generated_js + [success_out],
         cmd = " && ".join([
-            "$(location //tools/node_tools:tsc-bin) --project $(location :" +
-            ts_project +
-            ") --outdir $(RULEDIR)/" +
-            ts_outdir +
+            "$(location //tools/node_tools:tsc-bin) --project $(location :{})".format(ts_project) +
+            (" --outdir $(RULEDIR)/{}".format(ts_outdir) if emitJS else "") +
             " --baseUrl ./external/ui_npm/node_modules/",
+            "touch $(location {})".format(success_out),
         ]),
         tools = ["//tools/node_tools:tsc-bin"],
+        tags = tags,
     )
 
     return generated_js
 
-def polygerrit_bundle(name, srcs, outs, entry_point):
+def polygerrit_bundle(name, srcs, outs, entry_point, app_name):
     """Build .zip bundle from source code
 
     Args:
@@ -89,10 +95,10 @@
         srcs: source files
         outs: array with a single item - the output file name
         entry_point: application js entry-point
+        app_name: defines the application name. Bundled js code is added to .zip
+          archive with this name.
     """
 
-    app_name = entry_point.split(".js")[0].split("/").pop()  # eg: gr-app
-
     native.filegroup(
         name = app_name + "-full-src",
         srcs = srcs + [
@@ -142,19 +148,21 @@
             "//lib/fonts:robotofonts",
             "//lib/js:highlightjs__files",
             "@ui_npm//:node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js",
+            "@ui_npm//:node_modules/resemblejs/resemble.js",
             "@ui_npm//@polymer/font-roboto-local",
             "@ui_npm//:node_modules/@polymer/font-roboto-local/package.json",
         ],
         outs = outs,
         cmd = " && ".join([
             "FONT_DIR=$$(dirname $(location @ui_npm//:node_modules/@polymer/font-roboto-local/package.json))/fonts",
-            "mkdir -p $$TMP/polygerrit_ui/{styles/themes,fonts/{roboto,robotomono},bower_components/{highlightjs,webcomponentsjs},elements}",
+            "mkdir -p $$TMP/polygerrit_ui/{styles/themes,fonts/{roboto,robotomono},bower_components/{highlightjs,webcomponentsjs,resemblejs},elements}",
             "for f in $(locations " + name + "_app_sources); do ext=$${f##*.}; cp -p $$f $$TMP/polygerrit_ui/elements/" + app_name + ".$$ext; done",
             "cp $(locations //lib/fonts:robotofonts) $$TMP/polygerrit_ui/fonts/",
             "for f in $(locations " + name + "_top_sources); do cp $$f $$TMP/polygerrit_ui/; done",
             "for f in $(locations " + name + "_css_sources); do cp $$f $$TMP/polygerrit_ui/styles; done",
             "for f in $(locations //lib/js:highlightjs__files); do cp $$f $$TMP/polygerrit_ui/bower_components/highlightjs/ ; done",
             "cp $(location @ui_npm//:node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js) $$TMP/polygerrit_ui/bower_components/webcomponentsjs/webcomponents-lite.js",
+            "cp $(location @ui_npm//:node_modules/resemblejs/resemble.js) $$TMP/polygerrit_ui/bower_components/resemblejs/resemble.js",
             "cp $$FONT_DIR/roboto/*.ttf $$TMP/polygerrit_ui/fonts/roboto/",
             "cp $$FONT_DIR/robotomono/*.ttf $$TMP/polygerrit_ui/fonts/robotomono/",
             "cd $$TMP",
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/app-context-init.ts b/polygerrit-ui/app/services/app-context-init.ts
index 9af86d2..f74962a 100644
--- a/polygerrit-ui/app/services/app-context-init.ts
+++ b/polygerrit-ui/app/services/app-context-init.ts
@@ -25,6 +25,8 @@
 import {GrJsApiInterface} from '../elements/shared/gr-js-api-interface/gr-js-api-interface-element';
 import {GrStorageService} from './storage/gr-storage_impl';
 import {ConfigService} from './config/config-service';
+import {UserService} from './user/user-service';
+import {CommentsService} from './comments/comments-service';
 
 type ServiceName = keyof AppContext;
 type ServiceCreator<T> = () => T;
@@ -73,9 +75,11 @@
     authService: () => new Auth(appContext.eventEmitter),
     restApiService: () => new GrRestApiInterface(appContext.authService),
     changeService: () => new ChangeService(),
+    commentsService: () => new CommentsService(appContext.restApiService),
     checksService: () => new ChecksService(appContext.reportingService),
     jsApiService: () => new GrJsApiInterface(),
     storageService: () => new GrStorageService(),
     configService: () => new ConfigService(),
+    userService: () => new UserService(appContext.restApiService),
   });
 }
diff --git a/polygerrit-ui/app/services/app-context.ts b/polygerrit-ui/app/services/app-context.ts
index fbc3115..161378d 100644
--- a/polygerrit-ui/app/services/app-context.ts
+++ b/polygerrit-ui/app/services/app-context.ts
@@ -24,6 +24,8 @@
 import {JsApiService} from '../elements/shared/gr-js-api-interface/gr-js-api-types';
 import {StorageService} from './storage/gr-storage';
 import {ConfigService} from './config/config-service';
+import {UserService} from './user/user-service';
+import {CommentsService} from './comments/comments-service';
 
 export interface AppContext {
   flagsService: FlagsService;
@@ -32,10 +34,12 @@
   authService: AuthService;
   restApiService: RestApiService;
   changeService: ChangeService;
+  commentsService: CommentsService;
   checksService: ChecksService;
   jsApiService: JsApiService;
   storageService: StorageService;
   configService: ConfigService;
+  userService: UserService;
 }
 
 /**
diff --git a/polygerrit-ui/app/services/change/change-model.ts b/polygerrit-ui/app/services/change/change-model.ts
index 9036871..312e78d 100644
--- a/polygerrit-ui/app/services/change/change-model.ts
+++ b/polygerrit-ui/app/services/change/change-model.ts
@@ -48,7 +48,7 @@
 export function updateState(change?: ParsedChangeInfo) {
   const current = privateState$.getValue();
   // We want to make it easy for subscribers to react to change changes, so we
-  // are explicitly emitting and additional `undefined` when the change number
+  // are explicitly emitting an additional `undefined` when the change number
   // changes. So if you are subscribed to the latestPatchsetNumber for example,
   // then you can rely on emissions even if the old and the new change have the
   // same latestPatchsetNumber.
diff --git a/polygerrit-ui/app/services/change/change-service.ts b/polygerrit-ui/app/services/change/change-service.ts
index 1c6fc4c..0b9a1f2 100644
--- a/polygerrit-ui/app/services/change/change-service.ts
+++ b/polygerrit-ui/app/services/change/change-service.ts
@@ -15,7 +15,7 @@
  * limitations under the License.
  */
 import {routerChangeNum$} from '../router/router-model';
-import {updateState} from './change-model';
+import {change$, updateState} from './change-model';
 import {ParsedChangeInfo} from '../../types/types';
 import {appContext} from '../app-context';
 import {ChangeInfo} from '../../types/common';
@@ -25,6 +25,8 @@
 } from '../../utils/patch-set-util';
 
 export class ChangeService {
+  private change?: ParsedChangeInfo;
+
   private readonly restApiService = appContext.restApiService;
 
   constructor() {
@@ -34,6 +36,9 @@
     routerChangeNum$.subscribe(changeNum => {
       if (!changeNum) updateState(undefined);
     });
+    change$.subscribe(change => {
+      this.change = change;
+    });
   }
 
   /**
@@ -48,6 +53,15 @@
   }
 
   /**
+   * Typically you would just subscribe to change$ yourself to get updates. But
+   * sometimes it is nice to also be able to get the current ChangeInfo on
+   * demand. So here it is for your convenience.
+   */
+  getChange() {
+    return this.change;
+  }
+
+  /**
    * Check whether there is no newer patch than the latest patch that was
    * available when this change was loaded.
    *
diff --git a/polygerrit-ui/app/services/checks/checks-model.ts b/polygerrit-ui/app/services/checks/checks-model.ts
index 60cb780..3a0bbf2 100644
--- a/polygerrit-ui/app/services/checks/checks-model.ts
+++ b/polygerrit-ui/app/services/checks/checks-model.ts
@@ -21,7 +21,6 @@
   Category,
   CheckResult as CheckResultApi,
   CheckRun as CheckRunApi,
-  ChecksApiConfig,
   Link,
   LinkIcon,
   RunStatus,
@@ -32,6 +31,17 @@
 import {AttemptDetail, createAttemptMap} from './checks-util';
 import {assertIsDefined} from '../../utils/common-util';
 
+/**
+ * The checks model maintains the state of checks for two patchsets: the latest
+ * and (if different) also for the one selected in the checks tab. So we need
+ * the distinction in a lot of places for checks about whether the code affects
+ * the checks data of the LATEST or the SELECTED patchset.
+ */
+export enum ChecksPatchset {
+  LATEST = 'LATEST',
+  SELECTED = 'SELECTED',
+}
+
 export interface CheckResult extends CheckResultApi {
   /**
    * Internally we want to uniquely identify a run with an id, for example when
@@ -75,51 +85,92 @@
   errorMessage?: string;
   /** Presence of loginCallback implicitly means that the provider is in NOT_LOGGED_IN state. */
   loginCallback?: () => void;
-  config?: ChecksApiConfig;
   runs: CheckRun[];
   actions: Action[];
   links: Link[];
 }
 
 interface ChecksState {
-  patchsetNumber?: PatchSetNumber;
-  providerNameToState: {
+  /**
+   * This is the patchset number selected by the user. The *latest* patchset
+   * can be picked up from the change model.
+   */
+  patchsetNumberSelected?: PatchSetNumber;
+  /** Checks data for the latest patchset. */
+  pluginStateLatest: {
+    [name: string]: ChecksProviderState;
+  };
+  /**
+   * Checks data for the selected patchset. Note that `checksSelected$` below
+   * falls back to the data for the latest patchset, if no patchset is selected.
+   */
+  pluginStateSelected: {
     [name: string]: ChecksProviderState;
   };
 }
 
 const initialState: ChecksState = {
-  providerNameToState: {},
+  pluginStateLatest: {},
+  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$;
 
-export const checksPatchsetNumber$ = checksState$.pipe(
-  map(state => state.patchsetNumber),
+export const checksSelectedPatchsetNumber$ = checksState$.pipe(
+  map(state => state.patchsetNumberSelected),
   distinctUntilChanged()
 );
 
-export const checksProviderState$ = checksState$.pipe(
-  map(state => state.providerNameToState),
+export const checksLatest$ = checksState$.pipe(
+  map(state => state.pluginStateLatest),
   distinctUntilChanged()
 );
 
-export const aPluginHasRegistered$ = checksProviderState$.pipe(
+export const checksSelected$ = checksState$.pipe(
+  map(state =>
+    state.patchsetNumberSelected
+      ? state.pluginStateSelected
+      : state.pluginStateLatest
+  ),
+  distinctUntilChanged()
+);
+
+export const aPluginHasRegistered$ = checksLatest$.pipe(
   map(state => Object.keys(state).length > 0),
   distinctUntilChanged()
 );
 
-export const someProvidersAreLoading$ = checksProviderState$.pipe(
+export const someProvidersAreLoadingLatest$ = checksLatest$.pipe(
   map(state =>
     Object.values(state).some(providerState => providerState.loading)
   ),
   distinctUntilChanged()
 );
 
-export const errorMessage$ = checksProviderState$.pipe(
+export const someProvidersAreLoadingSelected$ = checksSelected$.pipe(
+  map(state =>
+    Object.values(state).some(providerState => providerState.loading)
+  ),
+  distinctUntilChanged()
+);
+
+export const errorMessageLatest$ = checksLatest$.pipe(
   map(
     state =>
       Object.values(state).find(
@@ -129,7 +180,7 @@
   distinctUntilChanged()
 );
 
-export const loginCallback$ = checksProviderState$.pipe(
+export const loginCallbackLatest$ = checksLatest$.pipe(
   map(
     state =>
       Object.values(state).find(
@@ -139,7 +190,7 @@
   distinctUntilChanged()
 );
 
-export const allActions$ = checksProviderState$.pipe(
+export const topLevelActionsSelected$ = checksSelected$.pipe(
   map(state =>
     Object.values(state).reduce(
       (allActions: Action[], providerState: ChecksProviderState) => [
@@ -151,7 +202,7 @@
   )
 );
 
-export const allLinks$ = checksProviderState$.pipe(
+export const topLevelLinksSelected$ = checksSelected$.pipe(
   map(state =>
     Object.values(state).reduce(
       (allActions: Link[], providerState: ChecksProviderState) => [
@@ -163,7 +214,7 @@
   )
 );
 
-export const allRuns$ = checksProviderState$.pipe(
+export const allRunsLatestPatchset$ = checksLatest$.pipe(
   map(state =>
     Object.values(state).reduce(
       (allRuns: CheckRun[], providerState: ChecksProviderState) => [
@@ -175,11 +226,23 @@
   )
 );
 
-export const allRunsLatest$ = allRuns$.pipe(
+export const allRunsSelectedPatchset$ = checksSelected$.pipe(
+  map(state =>
+    Object.values(state).reduce(
+      (allRuns: CheckRun[], providerState: ChecksProviderState) => [
+        ...allRuns,
+        ...providerState.runs,
+      ],
+      []
+    )
+  )
+);
+
+export const allRunsLatestPatchsetLatestAttempt$ = allRunsLatestPatchset$.pipe(
   map(runs => runs.filter(run => run.isLatestAttempt))
 );
 
-export const checkToPluginMap$ = checksProviderState$.pipe(
+export const checkToPluginMap$ = checksLatest$.pipe(
   map(state => {
     const map = new Map<string, string>();
     for (const [pluginName, providerState] of Object.entries(state)) {
@@ -191,7 +254,7 @@
   })
 );
 
-export const allResults$ = checksProviderState$.pipe(
+export const allResultsSelected$ = checksSelected$.pipe(
   map(state =>
     Object.values(state)
       .reduce(
@@ -213,14 +276,13 @@
 // model.
 export function updateStateSetProvider(
   pluginName: string,
-  config?: ChecksApiConfig
+  patchset: ChecksPatchset
 ) {
   const nextState = {...privateState$.getValue()};
-  nextState.providerNameToState = {...nextState.providerNameToState};
-  nextState.providerNameToState[pluginName] = {
+  const pluginState = getPluginState(nextState, patchset);
+  pluginState[pluginName] = {
     pluginName,
     loading: false,
-    config,
     runs: [],
     actions: [],
     links: [],
@@ -253,6 +315,7 @@
       internalResultId: 'f0r1',
       category: Category.ERROR,
       summary: 'Running the mighty test has failed by crashing.',
+      message: 'Btw, 1 is also not equal to 3. Did you know?',
       actions: [
         {
           name: 'Ignore',
@@ -276,8 +339,16 @@
       ],
       tags: [{name: 'INTERRUPTED', color: TagColor.BROWN}, {name: 'WINDOWS'}],
       links: [
-        {primary: true, url: 'https://google.com', icon: LinkIcon.EXTERNAL},
+        {primary: false, url: 'https://google.com', icon: LinkIcon.EXTERNAL},
         {primary: true, url: 'https://google.com', icon: LinkIcon.DOWNLOAD},
+        {
+          primary: true,
+          url: 'https://google.com',
+          icon: LinkIcon.DOWNLOAD_MOBILE,
+        },
+        {primary: true, url: 'https://google.com', icon: LinkIcon.IMAGE},
+        {primary: true, url: 'https://google.com', icon: LinkIcon.IMAGE},
+        {primary: false, url: 'https://google.com', icon: LinkIcon.IMAGE},
         {primary: true, url: 'https://google.com', icon: LinkIcon.REPORT_BUG},
         {primary: true, url: 'https://google.com', icon: LinkIcon.HELP_PAGE},
         {primary: true, url: 'https://google.com', icon: LinkIcon.HISTORY},
@@ -290,6 +361,8 @@
 export const fakeRun1: CheckRun = {
   internalRunId: 'f1',
   checkName: 'FAKE Super Check',
+  statusLink: 'https://www.google.com/',
+  patchset: 1,
   labelName: 'Verified',
   isSingleAttempt: true,
   isLatestAttempt: true,
@@ -302,6 +375,26 @@
       message: `There is a lot to be said. A lot. I say, a lot.\n
                 So please keep reading.`,
       tags: [{name: 'INTERRUPTED', color: TagColor.PURPLE}, {name: 'WINDOWS'}],
+      codePointers: [
+        {
+          path: '/COMMIT_MSG',
+          range: {
+            start_line: 10,
+            start_character: 0,
+            end_line: 10,
+            end_character: 0,
+          },
+        },
+        {
+          path: 'polygerrit-ui/app/api/checks.ts',
+          range: {
+            start_line: 5,
+            start_character: 0,
+            end_line: 7,
+            end_character: 0,
+          },
+        },
+      ],
       links: [
         {primary: true, url: 'https://google.com', icon: LinkIcon.EXTERNAL},
         {primary: true, url: 'https://google.com', icon: LinkIcon.DOWNLOAD},
@@ -311,6 +404,18 @@
           icon: LinkIcon.DOWNLOAD_MOBILE,
         },
         {primary: true, url: 'https://google.com', icon: LinkIcon.IMAGE},
+        {
+          primary: false,
+          url: 'https://google.com',
+          tooltip: 'look at this',
+          icon: LinkIcon.IMAGE,
+        },
+        {
+          primary: false,
+          url: 'https://google.com',
+          tooltip: 'not at this',
+          icon: LinkIcon.IMAGE,
+        },
       ],
     },
   ],
@@ -441,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'}),
+        },
+      ],
     },
   ],
 };
@@ -472,32 +585,82 @@
   {
     url: 'https://www.google.com',
     primary: true,
-    tooltip: 'Tooltip for Bug Report Fake Link',
+    tooltip: 'Fake Bug Report 1',
     icon: LinkIcon.REPORT_BUG,
   },
   {
     url: 'https://www.google.com',
-    primary: false,
-    tooltip: 'Tooltip for External Fake Link',
+    primary: true,
+    tooltip: 'Fake Bug Report 2',
+    icon: LinkIcon.REPORT_BUG,
+  },
+  {
+    url: 'https://www.google.com',
+    primary: true,
+    tooltip: 'Fake Link 1',
     icon: LinkIcon.EXTERNAL,
   },
+  {
+    url: 'https://www.google.com',
+    primary: false,
+    tooltip: 'Fake Link 2',
+    icon: LinkIcon.EXTERNAL,
+  },
+  {
+    url: 'https://www.google.com',
+    primary: true,
+    tooltip: 'Fake Code Link',
+    icon: LinkIcon.CODE,
+  },
+  {
+    url: 'https://www.google.com',
+    primary: true,
+    tooltip: 'Fake Image Link',
+    icon: LinkIcon.IMAGE,
+  },
+  {
+    url: 'https://www.google.com',
+    primary: true,
+    tooltip: 'Fake Help Link',
+    icon: LinkIcon.HELP_PAGE,
+  },
 ];
 
-export function updateStateSetLoading(pluginName: string) {
+export function getPluginState(
+  state: ChecksState,
+  patchset: ChecksPatchset = ChecksPatchset.LATEST
+) {
+  if (patchset === ChecksPatchset.LATEST) {
+    state.pluginStateLatest = {...state.pluginStateLatest};
+    return state.pluginStateLatest;
+  } else {
+    state.pluginStateSelected = {...state.pluginStateSelected};
+    return state.pluginStateSelected;
+  }
+}
+
+export function updateStateSetLoading(
+  pluginName: string,
+  patchset: ChecksPatchset
+) {
   const nextState = {...privateState$.getValue()};
-  nextState.providerNameToState = {...nextState.providerNameToState};
-  nextState.providerNameToState[pluginName] = {
-    ...nextState.providerNameToState[pluginName],
+  const pluginState = getPluginState(nextState, patchset);
+  pluginState[pluginName] = {
+    ...pluginState[pluginName],
     loading: true,
   };
   privateState$.next(nextState);
 }
 
-export function updateStateSetError(pluginName: string, errorMessage: string) {
+export function updateStateSetError(
+  pluginName: string,
+  errorMessage: string,
+  patchset: ChecksPatchset
+) {
   const nextState = {...privateState$.getValue()};
-  nextState.providerNameToState = {...nextState.providerNameToState};
-  nextState.providerNameToState[pluginName] = {
-    ...nextState.providerNameToState[pluginName],
+  const pluginState = getPluginState(nextState, patchset);
+  pluginState[pluginName] = {
+    ...pluginState[pluginName],
     loading: false,
     errorMessage,
     loginCallback: undefined,
@@ -509,12 +672,13 @@
 
 export function updateStateSetNotLoggedIn(
   pluginName: string,
-  loginCallback: () => void
+  loginCallback: () => void,
+  patchset: ChecksPatchset
 ) {
   const nextState = {...privateState$.getValue()};
-  nextState.providerNameToState = {...nextState.providerNameToState};
-  nextState.providerNameToState[pluginName] = {
-    ...nextState.providerNameToState[pluginName],
+  const pluginState = getPluginState(nextState, patchset);
+  pluginState[pluginName] = {
+    ...pluginState[pluginName],
     loading: false,
     errorMessage: undefined,
     loginCallback,
@@ -528,18 +692,19 @@
   pluginName: string,
   runs: CheckRunApi[],
   actions: Action[] = [],
-  links: Link[] = []
+  links: Link[] = [],
+  patchset: ChecksPatchset
 ) {
   const attemptMap = createAttemptMap(runs);
   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()};
-  nextState.providerNameToState = {...nextState.providerNameToState};
-  nextState.providerNameToState[pluginName] = {
-    ...nextState.providerNameToState[pluginName],
+  const pluginState = getPluginState(nextState, patchset);
+  pluginState[pluginName] = {
+    ...pluginState[pluginName],
     loading: false,
     errorMessage: undefined,
     loginCallback: undefined,
@@ -567,8 +732,44 @@
   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.patchsetNumber = patchsetNumber;
+  nextState.patchsetNumberSelected = patchsetNumber;
   privateState$.next(nextState);
 }
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 48d61e2..164074b 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,
@@ -32,7 +34,8 @@
 } from '../../api/checks';
 import {change$, changeNum$, latestPatchNum$} from '../change/change-model';
 import {
-  checksPatchsetNumber$,
+  ChecksPatchset,
+  checksSelectedPatchsetNumber$,
   checkToPluginMap$,
   updateStateSetError,
   updateStateSetLoading,
@@ -40,6 +43,7 @@
   updateStateSetPatchset,
   updateStateSetProvider,
   updateStateSetResults,
+  updateStateUpdateResult,
 } from './checks-model';
 import {
   BehaviorSubject,
@@ -50,11 +54,13 @@
   Subject,
   timer,
 } from 'rxjs';
-import {PatchSetNumber} from '../../types/common';
+import {ChangeInfo, PatchSetNumber} from '../../types/common';
 import {getCurrentRevision} from '../../utils/change-util';
 import {getShaByPatchNum} from '../../utils/patch-set-util';
 import {assertIsDefined} from '../../utils/common-util';
 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} = {};
@@ -63,15 +69,26 @@
 
   private checkToPluginMap = new Map<string, string>();
 
+  private latestPatchNum?: PatchSetNumber;
+
   private readonly documentVisibilityChange$ = new BehaviorSubject(undefined);
 
   constructor(readonly reporting: ReportingService) {
     checkToPluginMap$.subscribe(map => {
       this.checkToPluginMap = map;
     });
-    latestPatchNum$.subscribe(num => {
-      updateStateSetPatchset(num);
-    });
+    combineLatest([routerPatchNum$, latestPatchNum$]).subscribe(
+      ([routerPs, latestPs]) => {
+        this.latestPatchNum = latestPs;
+        if (latestPs === undefined) {
+          this.setPatchset(undefined);
+        } else if (typeof routerPs === 'number') {
+          this.setPatchset(routerPs);
+        } else {
+          this.setPatchset(latestPs);
+        }
+      }
+    );
     document.addEventListener('visibilitychange', () => {
       this.documentVisibilityChange$.next(undefined);
     });
@@ -80,8 +97,8 @@
     });
   }
 
-  setPatchset(num: PatchSetNumber) {
-    updateStateSetPatchset(num);
+  setPatchset(num?: PatchSetNumber) {
+    updateStateSetPatchset(num === this.latestPatchNum ? undefined : num);
   }
 
   reload(pluginName: string) {
@@ -98,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,
@@ -105,7 +127,17 @@
   ) {
     this.providers[pluginName] = provider;
     this.reloadSubjects[pluginName] = new BehaviorSubject<void>(undefined);
-    updateStateSetProvider(pluginName, config);
+    updateStateSetProvider(pluginName, ChecksPatchset.LATEST);
+    updateStateSetProvider(pluginName, ChecksPatchset.SELECTED);
+    this.initFetchingOfData(pluginName, config, ChecksPatchset.LATEST);
+    this.initFetchingOfData(pluginName, config, ChecksPatchset.SELECTED);
+  }
+
+  initFetchingOfData(
+    pluginName: string,
+    config: ChecksApiConfig,
+    patchset: ChecksPatchset
+  ) {
     const pollIntervalMs = (config?.fetchPollingIntervalSeconds ?? 60) * 1000;
     // Various events should trigger fetching checks from the provider:
     // 1. Change number and patchset number changes.
@@ -114,7 +146,9 @@
     // 4. A hidden Gerrit tab becoming visible.
     combineLatest([
       changeNum$,
-      checksPatchsetNumber$,
+      patchset === ChecksPatchset.LATEST
+        ? latestPatchNum$
+        : checksSelectedPatchsetNumber$,
       this.reloadSubjects[pluginName].pipe(throttleTime(1000)),
       timer(0, pollIntervalMs),
       this.documentVisibilityChange$,
@@ -125,36 +159,22 @@
         withLatestFrom(change$),
         switchMap(
           ([[changeNum, patchNum], change]): Observable<FetchResponse> => {
-            if (
-              !change ||
-              !changeNum ||
-              !patchNum ||
-              typeof patchNum !== 'number'
-            ) {
-              return of({
-                responseCode: ResponseCode.OK,
-                runs: [],
-              });
-            }
+            if (!change || !changeNum || !patchNum) return of(this.empty());
+            if (typeof patchNum !== 'number') return of(this.empty());
             assertIsDefined(change.revisions, 'change.revisions');
             const patchsetSha = getShaByPatchNum(change.revisions, patchNum);
             // Sometimes patchNum is updated earlier than change, so change
             // revisions don't have patchNum yet
-            if (!patchsetSha) {
-              return of({
-                responseCode: ResponseCode.OK,
-                runs: [],
-              });
-            }
+            if (!patchsetSha) return of(this.empty());
             const data: ChangeData = {
               changeNumber: changeNum,
               patchsetNumber: patchNum,
               patchsetSha,
               repo: change.project,
-              commmitMessage: getCurrentRevision(change)?.commit?.message,
-              changeInfo: change,
+              commitMessage: getCurrentRevision(change)?.commit?.message,
+              changeInfo: change as ChangeInfo,
             };
-            return this.fetchResults(pluginName, data);
+            return this.fetchResults(pluginName, data, patchset);
           }
         ),
         catchError(e => {
@@ -162,46 +182,71 @@
           // 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);
+          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');
-            updateStateSetNotLoggedIn(pluginName, response.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 ?? [],
               response.actions ?? [],
-              response.links ?? []
+              response.links ?? [],
+              patchset
             );
             break;
+          }
         }
       });
   }
 
+  private empty(): FetchResponse {
+    return {
+      responseCode: ResponseCode.OK,
+      runs: [],
+    };
+  }
+
   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)}`,
     };
   }
 
   private fetchResults(
     pluginName: string,
-    data: ChangeData
+    data: ChangeData,
+    patchset: ChecksPatchset
   ): Observable<FetchResponse> {
-    updateStateSetLoading(pluginName);
+    updateStateSetLoading(pluginName, patchset);
     const timer = this.reporting.getTimer('ChecksPluginFetch');
     const fetchPromise = this.providers[pluginName]
       .fetch(data)
@@ -210,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 f08732f..becd398 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';
@@ -43,6 +43,10 @@
       return 'help-outline';
     case LinkIcon.REPORT_BUG:
       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
@@ -68,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
@@ -84,8 +92,48 @@
   return undefined;
 }
 
-export function iconForCategory(category: Category) {
-  switch (category) {
+export function isCategory(
+  catStat?: Category | RunStatus
+): catStat is Category {
+  return (
+    catStat === Category.ERROR ||
+    catStat === Category.WARNING ||
+    catStat === Category.INFO ||
+    catStat === Category.SUCCESS
+  );
+}
+
+export function isStatus(catStat?: Category | RunStatus): catStat is 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:
@@ -94,8 +142,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}`);
   }
 }
 
@@ -143,24 +198,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);
   }
 }
 
@@ -312,14 +353,19 @@
   };
 }
 
-export function primaryLink(result?: CheckResultApi): Link | undefined {
-  const links = result?.links ?? [];
-  return links.find(link => link.primary);
+function allPrimaryLinks(result?: CheckResultApi): Link[] {
+  return (result?.links ?? []).filter(link => link.primary);
 }
 
-export function otherLinks(result?: CheckResultApi): Link[] {
-  const primary = primaryLink(result);
-  const links = result?.links ?? [];
-  // Just filter the one primary link, not all primary links.
-  return links.filter(link => link !== primary);
+export function firstPrimaryLink(result?: CheckResultApi): Link | undefined {
+  return allPrimaryLinks(result).find(link => link.icon === LinkIcon.EXTERNAL);
+}
+
+export function otherPrimaryLinks(result?: CheckResultApi): Link[] {
+  const first = firstPrimaryLink(result);
+  return allPrimaryLinks(result).filter(link => link !== first);
+}
+
+export function secondaryLinks(result?: CheckResultApi): Link[] {
+  return (result?.links ?? []).filter(link => !link.primary);
 }
diff --git a/polygerrit-ui/app/services/comments/comments-model.ts b/polygerrit-ui/app/services/comments/comments-model.ts
new file mode 100644
index 0000000..b26ec9b
--- /dev/null
+++ b/polygerrit-ui/app/services/comments/comments-model.ts
@@ -0,0 +1,136 @@
+/**
+ * @license
+ * Copyright (C) 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import {BehaviorSubject, Observable} from 'rxjs';
+import {distinctUntilChanged, map} from 'rxjs/operators';
+import {ChangeComments} from '../../elements/diff/gr-comment-api/gr-comment-api';
+import {
+  CommentInfo,
+  PathToCommentsInfoMap,
+  RobotCommentInfo,
+} from '../../types/common';
+import {addPath, DraftInfo} from '../../utils/comment-util';
+
+interface CommentState {
+  comments: PathToCommentsInfoMap;
+  robotComments: {[path: string]: RobotCommentInfo[]};
+  drafts: {[path: string]: DraftInfo[]};
+  portedComments: PathToCommentsInfoMap;
+  portedDrafts: PathToCommentsInfoMap;
+}
+
+const initialState: CommentState = {
+  comments: {},
+  robotComments: {},
+  drafts: {},
+  portedComments: {},
+  portedDrafts: {},
+};
+
+const privateState$ = new BehaviorSubject(initialState);
+
+// Re-exporting as Observable so that you can only subscribe, but not emit.
+export const commentState$: Observable<CommentState> = privateState$;
+
+export const drafts$ = commentState$.pipe(
+  map(commentState => commentState.drafts),
+  distinctUntilChanged()
+);
+
+// Emits a new value even if only a single draft is changed. Components should
+// aim to subsribe to something more specific.
+export const changeComments$ = commentState$.pipe(
+  map(
+    commentState =>
+      new ChangeComments(
+        commentState.comments,
+        commentState.robotComments,
+        commentState.drafts,
+        commentState.portedComments,
+        commentState.portedDrafts
+      )
+  ),
+  distinctUntilChanged()
+);
+
+export function updateStateComments(comments?: {
+  [path: string]: CommentInfo[];
+}) {
+  const nextState = {...privateState$.getValue()};
+  nextState.comments = addPath(comments) || {};
+  privateState$.next(nextState);
+}
+
+export function updateStateRobotComments(robotComments?: {
+  [path: string]: RobotCommentInfo[];
+}) {
+  const nextState = {...privateState$.getValue()};
+  nextState.robotComments = addPath(robotComments) || {};
+  privateState$.next(nextState);
+}
+
+export function updateStateDrafts(drafts?: {[path: string]: DraftInfo[]}) {
+  const nextState = {...privateState$.getValue()};
+  nextState.drafts = addPath(drafts) || {};
+  privateState$.next(nextState);
+}
+
+export function updateStatePortedComments(
+  portedComments?: PathToCommentsInfoMap
+) {
+  const nextState = {...privateState$.getValue()};
+  nextState.portedComments = portedComments || {};
+  privateState$.next(nextState);
+}
+
+export function updateStatePortedDrafts(portedDrafts?: PathToCommentsInfoMap) {
+  const nextState = {...privateState$.getValue()};
+  nextState.portedDrafts = portedDrafts || {};
+  privateState$.next(nextState);
+}
+
+export function updateStateAddDraft(draft: DraftInfo) {
+  const nextState = {...privateState$.getValue()};
+  if (!draft.path) throw new Error('draft path undefined');
+  nextState.drafts = {...nextState.drafts};
+  const drafts = nextState.drafts;
+  if (!drafts[draft.path]) drafts[draft.path] = [] as DraftInfo[];
+  else drafts[draft.path] = [...drafts[draft.path]];
+  const index = drafts[draft.path].findIndex(
+    d => d.__draftID === draft.__draftID || d.id === draft.id
+  );
+  if (index !== -1) {
+    drafts[draft.path][index] = draft;
+  } else {
+    drafts[draft.path].push(draft);
+  }
+  privateState$.next(nextState);
+}
+
+export function updateStateDeleteDraft(draft: DraftInfo) {
+  const nextState = {...privateState$.getValue()};
+  if (!draft.path) throw new Error('draft path undefined');
+  nextState.drafts = {...nextState.drafts};
+  const drafts = nextState.drafts;
+  const index = (drafts[draft.path] || []).findIndex(
+    d => d.__draftID === draft.__draftID || d.id === draft.id
+  );
+  if (index === -1) return;
+  drafts[draft.path] = [...drafts[draft.path]];
+  drafts[draft.path].splice(index, 1);
+  privateState$.next(nextState);
+}
diff --git a/polygerrit-ui/app/services/comments/comments-service.ts b/polygerrit-ui/app/services/comments/comments-service.ts
new file mode 100644
index 0000000..05dfc4c
--- /dev/null
+++ b/polygerrit-ui/app/services/comments/comments-service.ts
@@ -0,0 +1,68 @@
+/**
+ * @license
+ * Copyright (C) 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import {NumericChangeId, RevisionId} from '../../types/common';
+import {DraftInfo} from '../../utils/comment-util';
+import {CURRENT} from '../../utils/patch-set-util';
+import {RestApiService} from '../gr-rest-api/gr-rest-api';
+import {
+  updateStateAddDraft,
+  updateStateDeleteDraft,
+  updateStateComments,
+  updateStateRobotComments,
+  updateStateDrafts,
+  updateStatePortedComments,
+  updateStatePortedDrafts,
+} from './comments-model';
+
+export class CommentsService {
+  constructor(readonly restApiService: RestApiService) {}
+
+  /**
+   * Load all comments (with drafts and robot comments) for the given change
+   * number. The returned promise resolves when the comments have loaded, but
+   * does not yield the comment data.
+   */
+  // TODO(dhruvsri): listen to changeNum changes or reload event to update
+  // automatically
+  loadAll(changeNum: NumericChangeId, patchNum = CURRENT as RevisionId) {
+    const revision = patchNum;
+    this.restApiService
+      .getDiffComments(changeNum)
+      .then(comments => updateStateComments(comments));
+    this.restApiService
+      .getDiffRobotComments(changeNum)
+      .then(robotComments => updateStateRobotComments(robotComments));
+    this.restApiService
+      .getDiffDrafts(changeNum)
+      .then(drafts => updateStateDrafts(drafts));
+    this.restApiService
+      .getPortedComments(changeNum, revision)
+      .then(portedComments => updateStatePortedComments(portedComments));
+    this.restApiService
+      .getPortedDrafts(changeNum, revision)
+      .then(portedDrafts => updateStatePortedDrafts(portedDrafts));
+  }
+
+  addDraft(draft: DraftInfo) {
+    updateStateAddDraft(draft);
+  }
+
+  deleteDraft(draft: DraftInfo) {
+    updateStateDeleteDraft(draft);
+  }
+}
diff --git a/polygerrit-ui/app/services/comments/comments-service_test.ts b/polygerrit-ui/app/services/comments/comments-service_test.ts
new file mode 100644
index 0000000..604b5c4
--- /dev/null
+++ b/polygerrit-ui/app/services/comments/comments-service_test.ts
@@ -0,0 +1,126 @@
+/**
+ * @license
+ * Copyright (C) 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../test/common-test-setup-karma';
+import {
+  createComment,
+  createFixSuggestionInfo,
+} from '../../test/test-data-generators';
+import {stubRestApi} from '../../test/test-utils';
+import {
+  NumericChangeId,
+  RobotId,
+  RobotRunId,
+  Timestamp,
+  UrlEncodedCommentId,
+} from '../../types/common';
+import {appContext} from '../app-context';
+import {CommentsService} from './comments-service';
+
+suite('change service tests', () => {
+  let commentsService: CommentsService;
+
+  test('loads logged-out', () => {
+    const changeNum = 1234 as NumericChangeId;
+    commentsService = new CommentsService(appContext.restApiService);
+    stubRestApi('getLoggedIn').returns(Promise.resolve(false));
+    const diffCommentsSpy = stubRestApi('getDiffComments').returns(
+      Promise.resolve({
+        'foo.c': [
+          {
+            ...createComment(),
+            id: '123' as UrlEncodedCommentId,
+            message: 'Done',
+            updated: '2017-02-08 16:40:49' as Timestamp,
+          },
+        ],
+      })
+    );
+    const diffRobotCommentsSpy = stubRestApi('getDiffRobotComments').returns(
+      Promise.resolve({
+        'foo.c': [
+          {
+            ...createComment(),
+            id: '321' as UrlEncodedCommentId,
+            message: 'Done',
+            updated: '2017-02-08 16:40:49' as Timestamp,
+            robot_id: 'robot_1' as RobotId,
+            robot_run_id: 'run_1' as RobotRunId,
+            properties: {},
+            fix_suggestions: [
+              createFixSuggestionInfo('fix_1'),
+              createFixSuggestionInfo('fix_2'),
+            ],
+          },
+        ],
+      })
+    );
+    const diffDraftsSpy = stubRestApi('getDiffDrafts').returns(
+      Promise.resolve({})
+    );
+
+    commentsService.loadAll(changeNum);
+    assert.isTrue(diffCommentsSpy.calledWithExactly(changeNum));
+    assert.isTrue(diffRobotCommentsSpy.calledWithExactly(changeNum));
+    assert.isTrue(diffDraftsSpy.calledWithExactly(changeNum));
+  });
+
+  test('loads logged-in', () => {
+    const changeNum = 1234 as NumericChangeId;
+
+    stubRestApi('getLoggedIn').returns(Promise.resolve(true));
+    const diffCommentsSpy = stubRestApi('getDiffComments').returns(
+      Promise.resolve({
+        'foo.c': [
+          {
+            ...createComment(),
+            id: '123' as UrlEncodedCommentId,
+            message: 'Done',
+            updated: '2017-02-08 16:40:49' as Timestamp,
+          },
+        ],
+      })
+    );
+    const diffRobotCommentsSpy = stubRestApi('getDiffRobotComments').returns(
+      Promise.resolve({
+        'foo.c': [
+          {
+            ...createComment(),
+            id: '321' as UrlEncodedCommentId,
+            message: 'Done',
+            updated: '2017-02-08 16:40:49' as Timestamp,
+            robot_id: 'robot_1' as RobotId,
+            robot_run_id: 'run_1' as RobotRunId,
+            properties: {},
+            fix_suggestions: [
+              createFixSuggestionInfo('fix_1'),
+              createFixSuggestionInfo('fix_2'),
+            ],
+          },
+        ],
+      })
+    );
+    const diffDraftsSpy = stubRestApi('getDiffDrafts').returns(
+      Promise.resolve({})
+    );
+
+    commentsService.loadAll(changeNum);
+    assert.isTrue(diffCommentsSpy.calledWithExactly(changeNum));
+    assert.isTrue(diffRobotCommentsSpy.calledWithExactly(changeNum));
+    assert.isTrue(diffDraftsSpy.calledWithExactly(changeNum));
+  });
+});
diff --git a/polygerrit-ui/app/services/config/config-model.ts b/polygerrit-ui/app/services/config/config-model.ts
index 254760b..f5e10c5 100644
--- a/polygerrit-ui/app/services/config/config-model.ts
+++ b/polygerrit-ui/app/services/config/config-model.ts
@@ -14,12 +14,13 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import {ConfigInfo} from '../../types/common';
+import {ConfigInfo, ServerInfo} from '../../types/common';
 import {BehaviorSubject, Observable} from 'rxjs';
 import {map, distinctUntilChanged} from 'rxjs/operators';
 
 interface ConfigState {
   repoConfig?: ConfigInfo;
+  serverConfig?: ServerInfo;
 }
 
 // TODO: Figure out how to best enforce immutability of all states. Use Immer?
@@ -31,14 +32,22 @@
 // Re-exporting as Observable so that you can only subscribe, but not emit.
 export const configState$: Observable<ConfigState> = privateState$;
 
-// Must only be used by the change service or whatever is in control of this
-// model.
 export function updateRepoConfig(repoConfig?: ConfigInfo) {
   const current = privateState$.getValue();
   privateState$.next({...current, repoConfig});
 }
 
+export function updateServerConfig(serverConfig?: ServerInfo) {
+  const current = privateState$.getValue();
+  privateState$.next({...current, serverConfig});
+}
+
 export const repoConfig$ = configState$.pipe(
   map(configState => configState.repoConfig),
   distinctUntilChanged()
 );
+
+export const serverConfig$ = configState$.pipe(
+  map(configState => configState.serverConfig),
+  distinctUntilChanged()
+);
diff --git a/polygerrit-ui/app/services/config/config-service.ts b/polygerrit-ui/app/services/config/config-service.ts
index d6ff99c..7cd1538 100644
--- a/polygerrit-ui/app/services/config/config-service.ts
+++ b/polygerrit-ui/app/services/config/config-service.ts
@@ -14,17 +14,20 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import {updateRepoConfig} from './config-model';
+import {updateRepoConfig, updateServerConfig} from './config-model';
 import {repo$} from '../change/change-model';
 import {appContext} from '../app-context';
 import {switchMap} from 'rxjs/operators';
-import {ConfigInfo, RepoName} from '../../types/common';
+import {ConfigInfo, RepoName, ServerInfo} from '../../types/common';
 import {from, of} from 'rxjs';
 
 export class ConfigService {
   private readonly restApiService = appContext.restApiService;
 
   constructor() {
+    from(this.restApiService.getConfig()).subscribe((config?: ServerInfo) => {
+      updateServerConfig(config);
+    });
     repo$
       .pipe(
         switchMap((repo?: RepoName) => {
diff --git a/polygerrit-ui/app/services/flags/flags.ts b/polygerrit-ui/app/services/flags/flags.ts
index 789ff4e..c5fbce3 100644
--- a/polygerrit-ui/app/services/flags/flags.ts
+++ b/polygerrit-ui/app/services/flags/flags.ts
@@ -27,4 +27,5 @@
   NEW_IMAGE_DIFF_UI = 'UiFeature__new_image_diff_ui',
   TOKEN_HIGHLIGHTING = 'UiFeature__token_highlighting',
   CHECKS_DEVELOPER = 'UiFeature__checks_developer',
+  NEW_REPLY_DIALOG = 'UiFeature__new_reply_dialog',
 }
diff --git a/polygerrit-ui/app/services/flags/flags_impl.ts b/polygerrit-ui/app/services/flags/flags_impl.ts
index fbfa833..18e225b 100644
--- a/polygerrit-ui/app/services/flags/flags_impl.ts
+++ b/polygerrit-ui/app/services/flags/flags_impl.ts
@@ -18,7 +18,7 @@
 
 declare global {
   interface Window {
-    ENABLED_EXPERIMENTS: string[];
+    ENABLED_EXPERIMENTS?: string[];
   }
 }
 
@@ -40,7 +40,7 @@
   }
 
   _loadExperiments(): Set<string> {
-    return new Set(window.ENABLED_EXPERIMENTS);
+    return new Set(window.ENABLED_EXPERIMENTS ?? []);
   }
 
   get enabledExperiments() {
diff --git a/polygerrit-ui/app/services/flags/flags_test.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..4ae11bf 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[] | undefined;
+  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/gr-reporting/gr-reporting.ts b/polygerrit-ui/app/services/gr-reporting/gr-reporting.ts
index 509a140..1445a59 100644
--- a/polygerrit-ui/app/services/gr-reporting/gr-reporting.ts
+++ b/polygerrit-ui/app/services/gr-reporting/gr-reporting.ts
@@ -18,7 +18,12 @@
 import {NumericChangeId} from '../../types/common';
 import {EventDetails} from '../../api/reporting';
 import {PluginApi} from '../../api/plugin';
-import {Execution, LifeCycle, Timing} from '../../constants/reporting';
+import {
+  Execution,
+  Interaction,
+  LifeCycle,
+  Timing,
+} from '../../constants/reporting';
 
 export type EventValue = string | number | {error?: Error};
 
@@ -43,7 +48,7 @@
   beforeLocationChanged(): void;
   locationChanged(page: string): void;
   dashboardDisplayed(): void;
-  changeDisplayed(): void;
+  changeDisplayed(eventDetails?: EventDetails): void;
   changeFullyLoaded(): void;
   diffViewDisplayed(): void;
   diffViewFullyLoaded(): void;
@@ -89,7 +94,8 @@
    */
   reportRpcTiming(anonymizedUrl: string, elapsed: number): void;
   reportLifeCycle(eventName: LifeCycle, details?: EventDetails): void;
-
+  reportPluginLifeCycleLog(eventName: string, details?: EventDetails): void;
+  reportPluginInteractionLog(eventName: string, details?: EventDetails): void;
   /**
    * Use this method, if you want to check/count how often a certain code path
    * is executed. For example you can use this method to prove that certain code
@@ -104,7 +110,10 @@
     object: string,
     method: string
   ): void;
-  reportInteraction(eventName: string, details?: EventDetails): void;
+  reportInteraction(
+    eventName: string | Interaction,
+    details?: EventDetails
+  ): void;
   /**
    * A draft interaction was started. Update the time-between-draft-actions
    * timer.
diff --git a/polygerrit-ui/app/services/gr-reporting/gr-reporting_impl.ts b/polygerrit-ui/app/services/gr-reporting/gr-reporting_impl.ts
index 4e6ece2..0df7d12 100644
--- a/polygerrit-ui/app/services/gr-reporting/gr-reporting_impl.ts
+++ b/polygerrit-ui/app/services/gr-reporting/gr-reporting_impl.ts
@@ -21,7 +21,12 @@
 import {NumericChangeId} from '../../types/common';
 import {EventDetails} from '../../api/reporting';
 import {PluginApi} from '../../api/plugin';
-import {Execution, LifeCycle, Timing} from '../../constants/reporting';
+import {
+  Execution,
+  Interaction,
+  LifeCycle,
+  Timing,
+} from '../../constants/reporting';
 
 // Latency reporting constants.
 
@@ -70,6 +75,14 @@
   },
 };
 
+const PLUGIN = {
+  TYPE: 'plugin-log',
+  CATEGORY: {
+    LIFECYCLE: 'lifecycle',
+    INTERACTION: 'interaction',
+  },
+};
+
 const STARTUP_TIMERS: {[name: string]: number} = {
   [Timing.PLUGINS_LOADED]: 0,
   [Timing.METRICS_PLUGIN_LOADED]: 0,
@@ -406,7 +419,10 @@
       eventInfo.inBackgroundTab = isInBackgroundTab;
     }
 
-    if (this._flagsService.enabledExperiments.length) {
+    if (
+      name === Timing.APP_STARTED &&
+      this._flagsService.enabledExperiments.length
+    ) {
       eventInfo.enabledExperiments = JSON.stringify(
         this._flagsService.enabledExperiments
       );
@@ -508,11 +524,12 @@
     }
   }
 
-  changeDisplayed() {
+  changeDisplayed(eventDetails?: EventDetails) {
+    eventDetails = {...eventDetails, ...this._pageLoadDetails()};
     if (hasOwnProperty(this._baselines, Timing.STARTUP_CHANGE_DISPLAYED)) {
-      this.timeEnd(Timing.STARTUP_CHANGE_DISPLAYED, this._pageLoadDetails());
+      this.timeEnd(Timing.STARTUP_CHANGE_DISPLAYED, eventDetails);
     } else {
-      this.timeEnd(Timing.CHANGE_DISPLAYED, this._pageLoadDetails());
+      this.timeEnd(Timing.CHANGE_DISPLAYED, eventDetails);
     }
   }
 
@@ -772,7 +789,29 @@
     );
   }
 
-  reportInteraction(eventName: string, details: EventDetails) {
+  reportPluginLifeCycleLog(eventName: string, details: EventDetails) {
+    this.reporter(
+      PLUGIN.TYPE,
+      PLUGIN.CATEGORY.LIFECYCLE,
+      eventName,
+      undefined,
+      details,
+      true
+    );
+  }
+
+  reportPluginInteractionLog(eventName: string, details: EventDetails) {
+    this.reporter(
+      PLUGIN.TYPE,
+      PLUGIN.CATEGORY.INTERACTION,
+      eventName,
+      undefined,
+      details,
+      true
+    );
+  }
+
+  reportInteraction(eventName: string | Interaction, details: EventDetails) {
     this.reporter(
       INTERACTION.TYPE,
       INTERACTION.CATEGORY.DEFAULT,
diff --git a/polygerrit-ui/app/services/gr-reporting/gr-reporting_mock.ts b/polygerrit-ui/app/services/gr-reporting/gr-reporting_mock.ts
index c180439..13461bf 100644
--- a/polygerrit-ui/app/services/gr-reporting/gr-reporting_mock.ts
+++ b/polygerrit-ui/app/services/gr-reporting/gr-reporting_mock.ts
@@ -17,7 +17,7 @@
 import {ReportingService, Timer} from './gr-reporting';
 import {EventDetails} from '../../api/reporting';
 import {PluginApi} from '../../api/plugin';
-import {Execution} from '../../constants/reporting';
+import {Execution, Interaction} from '../../constants/reporting';
 
 export class MockTimer implements Timer {
   end(): this {
@@ -72,10 +72,15 @@
     log(`trackApi '${plugin}', ${object}, ${method}`);
   },
   reportExtension: () => {},
-  reportInteraction: (eventName: string, details?: EventDetails) => {
+  reportInteraction: (
+    eventName: string | Interaction,
+    details?: EventDetails
+  ) => {
     log(`reportInteraction '${eventName}': ${JSON.stringify(details)}`);
   },
   reportLifeCycle: () => {},
+  reportPluginLifeCycleLog: () => {},
+  reportPluginInteractionLog: () => {},
   reportRpcTiming: () => {},
   setRepoName: () => {},
   setChangeId: () => {},
diff --git a/polygerrit-ui/app/services/gr-rest-api/gr-rest-api.ts b/polygerrit-ui/app/services/gr-rest-api/gr-rest-api.ts
index 3a890d9..cd3fab3 100644
--- a/polygerrit-ui/app/services/gr-rest-api/gr-rest-api.ts
+++ b/polygerrit-ui/app/services/gr-rest-api/gr-rest-api.ts
@@ -534,6 +534,7 @@
   invalidateGroupsCache(): void;
   invalidateReposCache(): void;
   invalidateAccountsCache(): void;
+  invalidateAccountsDetailCache(): void;
   removeFromAttentionSet(
     changeNum: NumericChangeId,
     user: AccountId,
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/services/user/user-model.ts b/polygerrit-ui/app/services/user/user-model.ts
new file mode 100644
index 0000000..4115a71
--- /dev/null
+++ b/polygerrit-ui/app/services/user/user-model.ts
@@ -0,0 +1,62 @@
+/**
+ * @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 {AccountDetailInfo, PreferencesInfo} from '../../types/common';
+import {BehaviorSubject, Observable} from 'rxjs';
+import {map, distinctUntilChanged} from 'rxjs/operators';
+import {createDefaultPreferences} from '../../constants/constants';
+
+interface UserState {
+  /**
+   * Keeps being defined even when credentials have expired.
+   */
+  account?: AccountDetailInfo;
+  preferences: PreferencesInfo;
+}
+
+const initialState: UserState = {
+  preferences: createDefaultPreferences(),
+};
+
+const privateState$ = new BehaviorSubject(initialState);
+
+// Re-exporting as Observable so that you can only subscribe, but not emit.
+export const userState$: Observable<UserState> = privateState$;
+
+export function updateAccount(account?: AccountDetailInfo) {
+  const current = privateState$.getValue();
+  privateState$.next({...current, account});
+}
+
+export function updatePreferences(preferences: PreferencesInfo) {
+  const current = privateState$.getValue();
+  privateState$.next({...current, preferences});
+}
+
+export const account$ = userState$.pipe(
+  map(userState => userState.account),
+  distinctUntilChanged()
+);
+
+export const preferences$ = userState$.pipe(
+  map(userState => userState.preferences),
+  distinctUntilChanged()
+);
+
+export const myTopMenuItems$ = preferences$.pipe(
+  map(preferences => preferences?.my ?? []),
+  distinctUntilChanged()
+);
diff --git a/polygerrit-ui/app/services/user/user-service.ts b/polygerrit-ui/app/services/user/user-service.ts
new file mode 100644
index 0000000..0612aca
--- /dev/null
+++ b/polygerrit-ui/app/services/user/user-service.ts
@@ -0,0 +1,42 @@
+/**
+ * @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 {AccountDetailInfo, PreferencesInfo} from '../../types/common';
+import {from, of} from 'rxjs';
+import {account$, updateAccount, updatePreferences} from './user-model';
+import {switchMap} from 'rxjs/operators';
+import {createDefaultPreferences} from '../../constants/constants';
+import {RestApiService} from '../gr-rest-api/gr-rest-api';
+
+export class UserService {
+  constructor(readonly restApiService: RestApiService) {
+    from(this.restApiService.getAccount()).subscribe(
+      (account?: AccountDetailInfo) => {
+        updateAccount(account);
+      }
+    );
+    account$
+      .pipe(
+        switchMap(account => {
+          if (!account) return of(createDefaultPreferences());
+          return from(this.restApiService.getPreferences());
+        })
+      )
+      .subscribe((preferences?: PreferencesInfo) => {
+        updatePreferences(preferences ?? createDefaultPreferences());
+      });
+  }
+}
diff --git a/polygerrit-ui/app/styles/dashboard-header-styles.ts b/polygerrit-ui/app/styles/dashboard-header-styles.ts
index 2354f65..0c9f38d 100644
--- a/polygerrit-ui/app/styles/dashboard-header-styles.ts
+++ b/polygerrit-ui/app/styles/dashboard-header-styles.ts
@@ -55,9 +55,3 @@
 </dom-module>`;
 
 document.head.appendChild($_documentContainer.content);
-
-/*
-  FIXME(polymer-modulizer): the above comments were extracted
-  from HTML and may be out of place here. Review them and
-  then delete this comment!
-*/
diff --git a/polygerrit-ui/app/styles/gr-change-list-styles.ts b/polygerrit-ui/app/styles/gr-change-list-styles.ts
index d1fcdc9..e0a7a28 100644
--- a/polygerrit-ui/app/styles/gr-change-list-styles.ts
+++ b/polygerrit-ui/app/styles/gr-change-list-styles.ts
@@ -202,9 +202,3 @@
 </dom-module>`;
 
 document.head.appendChild($_documentContainer.content);
-
-/*
-  FIXME(polymer-modulizer): the above comments were extracted
-  from HTML and may be out of place here. Review them and
-  then delete this comment!
-*/
diff --git a/polygerrit-ui/app/styles/gr-change-metadata-shared-styles.ts b/polygerrit-ui/app/styles/gr-change-metadata-shared-styles.ts
index 3d07d2e..67a6963 100644
--- a/polygerrit-ui/app/styles/gr-change-metadata-shared-styles.ts
+++ b/polygerrit-ui/app/styles/gr-change-metadata-shared-styles.ts
@@ -55,9 +55,3 @@
 </dom-module>`;
 
 document.head.appendChild($_documentContainer.content);
-
-/*
-  FIXME(polymer-modulizer): the above comments were extracted
-  from HTML and may be out of place here. Review them and
-  then delete this comment!
-*/
diff --git a/polygerrit-ui/app/styles/gr-change-view-integration-shared-styles.ts b/polygerrit-ui/app/styles/gr-change-view-integration-shared-styles.ts
index 57c8d78..145f0d5 100644
--- a/polygerrit-ui/app/styles/gr-change-view-integration-shared-styles.ts
+++ b/polygerrit-ui/app/styles/gr-change-view-integration-shared-styles.ts
@@ -22,6 +22,15 @@
 
 const $_documentContainer = document.createElement('template');
 
+/*
+  These are shared styles for change-view-integration endpoints.
+  All plugins that registered that endpoint should include this in
+  the component to have a consistent UX:
+
+  <style include="gr-change-view-integration-shared-styles"></style>
+
+  And use those defined class to apply these styles.
+*/
 $_documentContainer.innerHTML = `<dom-module id="gr-change-view-integration-shared-styles">
   <template>
     <style include="shared-styles">
@@ -61,18 +70,3 @@
 </dom-module>`;
 
 document.head.appendChild($_documentContainer.content);
-
-/*
-  This is shared styles for change-view-integration endpoints.
-  All plugins that registered that endpoint should include this in
-  the component to have a consistent UX:
-
-  <style include="gr-change-view-integration-shared-styles"></style>
-
-  And use those defined class to apply these styles.
-*/
-/*
-  FIXME(polymer-modulizer): the above comments were extracted
-  from HTML and may be out of place here. Review them and
-  then delete this comment!
-*/
diff --git a/polygerrit-ui/app/styles/gr-form-styles.ts b/polygerrit-ui/app/styles/gr-form-styles.ts
index 3284ad5..f58a02c 100644
--- a/polygerrit-ui/app/styles/gr-form-styles.ts
+++ b/polygerrit-ui/app/styles/gr-form-styles.ts
@@ -124,9 +124,3 @@
 </dom-module>`;
 
 document.head.appendChild($_documentContainer.content);
-
-/*
-  FIXME(polymer-modulizer): the above comments were extracted
-  from HTML and may be out of place here. Review them and
-  then delete this comment!
-*/
diff --git a/polygerrit-ui/app/styles/gr-menu-page-styles.ts b/polygerrit-ui/app/styles/gr-menu-page-styles.ts
index e9a79c1f..d46f136 100644
--- a/polygerrit-ui/app/styles/gr-menu-page-styles.ts
+++ b/polygerrit-ui/app/styles/gr-menu-page-styles.ts
@@ -79,9 +79,3 @@
 </dom-module>`;
 
 document.head.appendChild($_documentContainer.content);
-
-/*
-  FIXME(polymer-modulizer): the above comments were extracted
-  from HTML and may be out of place here. Review them and
-  then delete this comment!
-*/
diff --git a/polygerrit-ui/app/styles/gr-page-nav-styles.ts b/polygerrit-ui/app/styles/gr-page-nav-styles.ts
index 9010b2d..8c29b85 100644
--- a/polygerrit-ui/app/styles/gr-page-nav-styles.ts
+++ b/polygerrit-ui/app/styles/gr-page-nav-styles.ts
@@ -72,9 +72,3 @@
 </dom-module>`;
 
 document.head.appendChild($_documentContainer.content);
-
-/*
-  FIXME(polymer-modulizer): the above comments were extracted
-  from HTML and may be out of place here. Review them and
-  then delete this comment!
-*/
diff --git a/polygerrit-ui/app/styles/gr-subpage-styles.ts b/polygerrit-ui/app/styles/gr-subpage-styles.ts
index 41ee952..5aab0dc 100644
--- a/polygerrit-ui/app/styles/gr-subpage-styles.ts
+++ b/polygerrit-ui/app/styles/gr-subpage-styles.ts
@@ -42,9 +42,3 @@
 </dom-module>`;
 
 document.head.appendChild($_documentContainer.content);
-
-/*
-  FIXME(polymer-modulizer): the above comments were extracted
-  from HTML and may be out of place here. Review them and
-  then delete this comment!
-*/
diff --git a/polygerrit-ui/app/styles/gr-table-styles.ts b/polygerrit-ui/app/styles/gr-table-styles.ts
index 52fdc67..09d1161 100644
--- a/polygerrit-ui/app/styles/gr-table-styles.ts
+++ b/polygerrit-ui/app/styles/gr-table-styles.ts
@@ -116,9 +116,3 @@
 </dom-module>`;
 
 document.head.appendChild($_documentContainer.content);
-
-/*
-  FIXME(polymer-modulizer): the above comments were extracted
-  from HTML and may be out of place here. Review them and
-  then delete this comment!
-*/
diff --git a/polygerrit-ui/app/styles/gr-voting-styles.ts b/polygerrit-ui/app/styles/gr-voting-styles.ts
index b50aee6..cb8b0be8 100644
--- a/polygerrit-ui/app/styles/gr-voting-styles.ts
+++ b/polygerrit-ui/app/styles/gr-voting-styles.ts
@@ -48,9 +48,3 @@
 </dom-module>`;
 
 document.head.appendChild($_documentContainer.content);
-
-/*
-  FIXME(polymer-modulizer): the above comments were extracted
-  from HTML and may be out of place here. Review them and
-  then delete this comment!
-*/
diff --git a/polygerrit-ui/app/styles/shared-styles.ts b/polygerrit-ui/app/styles/shared-styles.ts
index 1bee660..287cf68 100644
--- a/polygerrit-ui/app/styles/shared-styles.ts
+++ b/polygerrit-ui/app/styles/shared-styles.ts
@@ -304,9 +304,3 @@
 </dom-module>`;
 
 document.head.appendChild($_documentContainer.content);
-
-/*
-  FIXME(polymer-modulizer): the above comments were extracted
-  from HTML and may be out of place here. Review them and
-  then delete this comment!
-*/
diff --git a/polygerrit-ui/app/styles/themes/app-theme.ts b/polygerrit-ui/app/styles/themes/app-theme.ts
index d768c96..1996800 100644
--- a/polygerrit-ui/app/styles/themes/app-theme.ts
+++ b/polygerrit-ui/app/styles/themes/app-theme.ts
@@ -377,8 +377,7 @@
     /* misc */
     --border-radius: 4px;
     --reply-overlay-z-index: 1000;
-    /* Base 64 encoded 1x1px of #681da8 */
-    --line-length-indicator: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQYV2PIlF2xAgAD+AHXfBDdKAAAAABJRU5ErkJggg==');
+    --line-length-indicator-color: #681da8;
 
     /* paper and iron component overrides */
     --iron-overlay-backdrop-background-color: black;
diff --git a/polygerrit-ui/app/styles/themes/dark-theme.ts b/polygerrit-ui/app/styles/themes/dark-theme.ts
index dec438d..926b02d 100644
--- a/polygerrit-ui/app/styles/themes/dark-theme.ts
+++ b/polygerrit-ui/app/styles/themes/dark-theme.ts
@@ -230,8 +230,7 @@
       --syntax-variable-color: #f77669;
 
       /* misc */
-      /* Base 64 encoded 1x1px of #d7aefb; */
-      --line-length-indicator: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQYV2O4vu73fwAIYAOAtqAXCQAAAABJRU5ErkJggg==');
+      --line-length-indicator-color: #d7aefb;
 
       /* paper and iron component overrides */
       --iron-overlay-backdrop-background-color: white;
diff --git a/polygerrit-ui/app/test/common-test-setup.ts b/polygerrit-ui/app/test/common-test-setup.ts
index 641e4b3..5096e09 100644
--- a/polygerrit-ui/app/test/common-test-setup.ts
+++ b/polygerrit-ui/app/test/common-test-setup.ts
@@ -44,6 +44,8 @@
 } from '../scripts/polymer-resin-install';
 import {_testOnly_allTasks} from '../utils/async-util';
 import {cleanUpStorage} from '../services/storage/gr-storage_mock';
+import {updatePreferences} from '../services/user/user-model';
+import {createDefaultPreferences} from '../constants/constants';
 
 declare global {
   interface Window {
@@ -95,8 +97,6 @@
 
 setup(() => {
   testSetupTimestampMs = new Date().getTime();
-  window.Gerrit = {};
-  initGlobalVariables();
   addIronOverlayBackdropStyleEl();
 
   // If the following asserts fails - then window.stub is
@@ -106,6 +106,7 @@
   // tests.
   TestKeyboardShortcutBinder.push();
   _testOnlyInitAppContext();
+  initGlobalVariables();
   _testOnly_initGerritPluginApi();
   const mgr = _testOnly_getShortcutManagerInstance();
   assert.isTrue(mgr._testOnly_isEmpty());
@@ -202,6 +203,8 @@
   removeIronOverlayBackdropStyleEl();
   cancelAllTasks();
   cleanUpStorage();
+  // Reset state
+  updatePreferences(createDefaultPreferences());
   const testTeardownTimestampMs = new Date().getTime();
   const elapsedMs = testTeardownTimestampMs - testSetupTimestampMs;
   if (elapsedMs > 1000) {
diff --git a/polygerrit-ui/app/test/mocks/comment-api.js b/polygerrit-ui/app/test/mocks/comment-api.js
index 526aabe..fc4599d 100644
--- a/polygerrit-ui/app/test/mocks/comment-api.js
+++ b/polygerrit-ui/app/test/mocks/comment-api.js
@@ -28,27 +28,6 @@
       _changeComments: Object,
     };
   }
-
-  loadComments() {
-    return this._reloadComments();
-  }
-
-  /**
-   * For the purposes of the mock, _reloadDrafts is not included because its
-   * response is the same type as reloadComments, just makes less API
-   * requests. Since this is for test purposes/mocked data anyway, keep this
-   * file simpler by just using _reloadComments here instead.
-   */
-  _reloadDraftsWithCallback(e) {
-    return this._reloadComments().then(() => e.detail.resolve());
-  }
-
-  _reloadComments() {
-    return this.$.commentAPI.loadAll(this._changeNum)
-        .then(comments => {
-          this._changeComments = this.$.commentAPI._changeComments;
-        });
-  }
 }
 
 /**
diff --git a/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts b/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts
index 3971fa5..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.');
@@ -411,6 +411,7 @@
   invalidateAccountsCache(): void {},
   invalidateGroupsCache(): void {},
   invalidateReposCache(): void {},
+  invalidateAccountsDetailCache(): void {},
   probePath(): Promise<boolean> {
     return Promise.resolve(true);
   },
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..1911644 100644
--- a/polygerrit-ui/app/test/test-data-generators.ts
+++ b/polygerrit-ui/app/test/test-data-generators.ts
@@ -16,11 +16,13 @@
  */
 
 import {
+  AccountDetailInfo,
   AccountId,
   AccountInfo,
   AccountsConfigInfo,
   ApprovalInfo,
   AuthInfo,
+  BasePatchSetNum,
   BranchName,
   ChangeConfigInfo,
   ChangeId,
@@ -36,10 +38,16 @@
   ConfigInfo,
   DownloadInfo,
   EditPatchSetNum,
-  GerritInfo,
   EmailAddress,
+  FixId,
+  FixSuggestionInfo,
+  GerritInfo,
   GitPersonInfo,
   GitRef,
+  GroupAuditEventInfo,
+  GroupAuditEventType,
+  GroupId,
+  GroupInfo,
   InheritedBooleanInfo,
   MaxObjectSizeLimitInfo,
   MergeableInfo,
@@ -47,32 +55,29 @@
   PatchSetNum,
   PluginConfigInfo,
   PreferencesInfo,
+  RelatedChangeAndCommitInfo,
+  RelatedChangesInfo,
   RepoName,
+  Requirement,
+  RequirementType,
   Reviewers,
   RevisionInfo,
   SchemesInfoMap,
   ServerInfo,
+  SubmittedTogetherInfo,
   SubmitTypeInfo,
   SuggestInfo,
   Timestamp,
   TimezoneOffset,
-  UserConfigInfo,
-  AccountDetailInfo,
-  Requirement,
-  RequirementType,
   UrlEncodedCommentId,
-  BasePatchSetNum,
-  RelatedChangeAndCommitInfo,
-  SubmittedTogetherInfo,
-  RelatedChangesInfo,
-  FixSuggestionInfo,
-  FixId,
+  UserConfigInfo,
 } from '../types/common';
 import {
   AccountsVisibility,
   AppTheme,
   AuthType,
   ChangeStatus,
+  CommentSide,
   DateFormat,
   DefaultBase,
   DefaultDisplayNameConfig,
@@ -80,22 +85,22 @@
   EmailStrategy,
   InheritedBooleanInfoConfiguredValue,
   MergeabilityComputationBehavior,
+  RequirementStatus,
   RevisionKind,
   SubmitType,
   TimeFormat,
-  RequirementStatus,
-  CommentSide,
 } from '../constants/constants';
 import {formatDate} from '../utils/date-util';
 import {GetDiffCommentsOutput} from '../services/gr-rest-api/gr-rest-api';
 import {AppElementChangeViewParams} from '../elements/gr-app-types';
 import {CommitInfoWithRequiredCommit} from '../elements/change/gr-change-metadata/gr-change-metadata';
 import {WebLinkInfo} from '../types/diff';
-import {UIComment, UIDraft, createCommentThreads} from '../utils/comment-util';
+import {createCommentThreads, UIComment, UIDraft} from '../utils/comment-util';
 import {GerritView} from '../services/router/router-model';
 import {ChangeComments} from '../elements/diff/gr-comment-api/gr-comment-api';
 import {EditRevisionInfo, ParsedChangeInfo} from '../types/types';
 import {ChangeMessage} from '../elements/change/gr-message/gr-message';
+import {GenerateUrlEditViewParameters} from '../elements/core/gr-navigation/gr-navigation';
 
 export function dateToTimestamp(date: Date): Timestamp {
   const nanosecondSuffix = '.000000000';
@@ -191,7 +196,7 @@
 export function createGitPerson(name = 'Test name'): GitPersonInfo {
   return {
     name,
-    email: `${name}@`,
+    email: `${name}@` as EmailAddress,
     date: dateToTimestamp(new Date(2019, 11, 6, 14, 5, 8)),
     tz: 0 as TimezoneOffset,
   };
@@ -341,8 +346,6 @@
 export function createChangeConfig(): ChangeConfigInfo {
   return {
     large_change: 500,
-    reply_label: 'Reply',
-    reply_tooltip: 'Reply and score',
     // The default update_delay is 5 minutes, but we don't want to accidentally
     // start polling in tests
     update_delay: 0,
@@ -444,6 +447,16 @@
   };
 }
 
+export function createGenerateUrlEditViewParameters(): GenerateUrlEditViewParameters {
+  return {
+    view: GerritView.EDIT,
+    changeNum: TEST_NUMERIC_CHANGE_ID,
+    patchNum: EditPatchSetNum as PatchSetNum,
+    path: 'foo/bar.baz',
+    project: TEST_PROJECT_NAME,
+  };
+}
+
 export function createRequirement(): Requirement {
   return {
     status: RequirementStatus.OK,
@@ -623,3 +636,32 @@
     replacements: [],
   };
 }
+
+export function createGroupInfo(id = 'id'): GroupInfo {
+  return {
+    id: id as GroupId,
+  };
+}
+
+export function createGroupAuditEventInfo(
+  type: GroupAuditEventType
+): GroupAuditEventInfo {
+  if (
+    type === GroupAuditEventType.ADD_USER ||
+    type === GroupAuditEventType.REMOVE_USER
+  ) {
+    return {
+      type,
+      member: createAccountWithId(10),
+      user: createAccountWithId(),
+      date: dateToTimestamp(new Date(2019, 11, 6, 14, 5, 8)),
+    };
+  } else {
+    return {
+      type,
+      member: createGroupInfo(),
+      user: createAccountWithId(),
+      date: dateToTimestamp(new Date(2019, 11, 6, 14, 5, 8)),
+    };
+  }
+}
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/tsconfig.json b/polygerrit-ui/app/tsconfig.json
index 46d0173d..a533a0f 100644
--- a/polygerrit-ui/app/tsconfig.json
+++ b/polygerrit-ui/app/tsconfig.json
@@ -61,6 +61,7 @@
     "styles/**/*",
     "types/**/*",
     "utils/**/*",
-    "test/**/*"
+    "test/**/*",
+    "tmpl_out/**/*" //Created by template checker in dev-mode
   ]
 }
diff --git a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar_html.ts b/polygerrit-ui/app/types/aria-mixin.ts
similarity index 62%
rename from polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar_html.ts
rename to polygerrit-ui/app/types/aria-mixin.ts
index a1e51df..6ae8c2a 100644
--- a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar_html.ts
+++ b/polygerrit-ui/app/types/aria-mixin.ts
@@ -14,15 +14,17 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
+export {};
 
-export const htmlTemplate = html`
-  <style>
-    :host {
-      display: inline-block;
-      border-radius: 50%;
-      background-size: cover;
-      background-color: var(--avatar-background-color, var(--gray-background));
-    }
-  </style>
-`;
+declare global {
+  // The current version of lib.dom.d.ts doesn't contains AriaMixin definition,
+  // so we have to add some of them ourself.
+  // https://developer.mozilla.org/en-US/docs/Web/API/Element#properties_included_from_aria
+  interface AriaMixin {
+    ariaLabel?: string;
+  }
+
+  interface Element extends AriaMixin {
+    ariaLabel: string;
+  }
+}
diff --git a/polygerrit-ui/app/types/common.ts b/polygerrit-ui/app/types/common.ts
index 30eb658..0f2608e 100644
--- a/polygerrit-ui/app/types/common.ts
+++ b/polygerrit-ui/app/types/common.ts
@@ -14,24 +14,13 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-
 import {CommentRange} from '../api/core';
 import {
   ChangeStatus,
-  DefaultDisplayNameConfig,
-  FileInfoStatus,
-  GpgKeyInfoStatus,
-  ProblemInfoStatus,
   ProjectState,
-  RequirementStatus,
-  ReviewerState,
-  RevisionKind,
   SubmitType,
   InheritedBooleanInfoConfiguredValue,
-  ConfigParameterInfoType,
-  AccountTag,
   PermissionAction,
-  HttpMethod,
   CommentSide,
   AppTheme,
   DateFormat,
@@ -43,19 +32,167 @@
   DraftsAction,
   NotifyType,
   EmailFormat,
-  AuthType,
   MergeStrategy,
-  EditableAccountField,
-  MergeabilityComputationBehavior,
 } from '../constants/constants';
 import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
+import {
+  AccountId,
+  AccountInfo,
+  AccountsConfigInfo,
+  ActionInfo,
+  ActionNameToActionInfoMap,
+  ApprovalInfo,
+  AuthInfo,
+  BasePatchSetNum,
+  BranchName,
+  BrandType,
+  ChangeConfigInfo,
+  ChangeId,
+  ChangeInfo,
+  ChangeInfoId,
+  ChangeMessageId,
+  ChangeMessageInfo,
+  ChangeSubmissionId,
+  CommentLinkInfo,
+  CommentLinks,
+  CommitId,
+  CommitInfo,
+  ConfigArrayParameterInfo,
+  ConfigInfo,
+  ConfigListParameterInfo,
+  ConfigParameterInfo,
+  ConfigParameterInfoBase,
+  ContributorAgreementInfo,
+  DetailedLabelInfo,
+  DownloadInfo,
+  DownloadSchemeInfo,
+  EmailAddress,
+  FetchInfo,
+  FileInfo,
+  GerritInfo,
+  GitPersonInfo,
+  GitRef,
+  GpgKeyId,
+  GpgKeyInfo,
+  GroupId,
+  GroupInfo,
+  GroupName,
+  GroupOptionsInfo,
+  Hashtag,
+  InheritedBooleanInfo,
+  LabelInfo,
+  LabelNameToInfoMap,
+  LabelNameToValueMap,
+  LabelValueToDescriptionMap,
+  MaxObjectSizeLimitInfo,
+  NumericChangeId,
+  ParentCommitInfo,
+  PatchSetNum,
+  PluginConfigInfo,
+  PluginNameToPluginParametersMap,
+  PluginParameterToConfigParameterInfoMap,
+  QuickLabelInfo,
+  ReceiveInfo,
+  RepoName,
+  Requirement,
+  RequirementType,
+  ReviewInputTag,
+  ReviewerState,
+  ReviewerUpdateInfo,
+  Reviewers,
+  RevisionInfo,
+  SchemesInfoMap,
+  ServerInfo,
+  SubmitTypeInfo,
+  SuggestInfo,
+  Timestamp,
+  TimezoneOffset,
+  TopicName,
+  UserConfigInfo,
+  VotingRangeInfo,
+  WebLinkInfo,
+  isDetailedLabelInfo,
+  isQuickLabelInfo,
+} from '../api/rest-api';
+import {DiffInfo, IgnoreWhitespaceType} from './diff';
 
-import {DiffInfo, IgnoreWhitespaceType, WebLinkInfo} from './diff';
-
-export {CommentRange};
-
-export type BrandType<T, BrandName extends string> = T &
-  {[__brand in BrandName]: never};
+export {
+  AccountId,
+  AccountInfo,
+  AccountsConfigInfo,
+  ActionInfo,
+  ActionNameToActionInfoMap,
+  ApprovalInfo,
+  AuthInfo,
+  BasePatchSetNum,
+  BranchName,
+  BrandType,
+  ChangeConfigInfo,
+  ChangeId,
+  ChangeInfo,
+  ChangeInfoId,
+  ChangeMessageId,
+  ChangeMessageInfo,
+  ChangeSubmissionId,
+  CommentLinkInfo,
+  CommentLinks,
+  CommentRange,
+  CommitId,
+  CommitInfo,
+  ConfigArrayParameterInfo,
+  ConfigInfo,
+  ConfigListParameterInfo,
+  ConfigParameterInfo,
+  ConfigParameterInfoBase,
+  ContributorAgreementInfo,
+  DetailedLabelInfo,
+  DownloadInfo,
+  DownloadSchemeInfo,
+  EmailAddress,
+  FileInfo,
+  GerritInfo,
+  GitPersonInfo,
+  GitRef,
+  GpgKeyId,
+  GpgKeyInfo,
+  GroupId,
+  GroupInfo,
+  GroupName,
+  GroupOptionsInfo,
+  Hashtag,
+  InheritedBooleanInfo,
+  LabelInfo,
+  LabelNameToInfoMap,
+  LabelNameToValueMap,
+  LabelValueToDescriptionMap,
+  MaxObjectSizeLimitInfo,
+  NumericChangeId,
+  ParentCommitInfo,
+  PatchSetNum,
+  PluginConfigInfo,
+  PluginNameToPluginParametersMap,
+  PluginParameterToConfigParameterInfoMap,
+  QuickLabelInfo,
+  ReceiveInfo,
+  RepoName,
+  Requirement,
+  RequirementType,
+  ReviewInputTag,
+  ReviewerUpdateInfo,
+  Reviewers,
+  RevisionInfo,
+  SchemesInfoMap,
+  ServerInfo,
+  SubmitTypeInfo,
+  SuggestInfo,
+  Timestamp,
+  TimezoneOffset,
+  TopicName,
+  UserConfigInfo,
+  VotingRangeInfo,
+  isDetailedLabelInfo,
+  isQuickLabelInfo,
+};
 
 /*
  * In T, make a set of properties whose keys are in the union K required
@@ -75,29 +212,20 @@
  */
 export type ParsedJSON = BrandType<unknown, '_parsedJSON'>;
 
-export type PatchSetNum = BrandType<'PARENT' | 'edit' | number, '_patchSet'>;
-export type BasePatchSetNum = BrandType<'PARENT' | number, '_patchSet'>;
 export type RevisionPatchSetNum = BrandType<'edit' | number, '_patchSet'>;
+
 export type PatchSetNumber = BrandType<number, '_patchSet'>;
 
 export const EditPatchSetNum = 'edit' as RevisionPatchSetNum;
+
 // TODO(TS): This is not correct, it is better to have a separate ApiPatchSetNum
 // without 'parent'.
 export const ParentPatchSetNum = 'PARENT' as BasePatchSetNum;
 
-export type ChangeId = BrandType<string, '_changeId'>;
-export type ChangeMessageId = BrandType<string, '_changeMessageId'>;
-export type NumericChangeId = BrandType<number, '_numericChangeId'>;
-export type RepoName = BrandType<string, '_repoName'>;
 export type UrlEncodedRepoName = BrandType<string, '_urlEncodedRepoName'>;
-export type TopicName = BrandType<string, '_topicName'>;
-// TODO(TS): Probably, we should separate AccountId and EncodedAccountId
-export type AccountId = BrandType<number, '_accountId'>;
-export type GitRef = BrandType<string, '_gitRef'>;
-export type RequirementType = BrandType<string, '_requirementType'>;
-export type TrackingId = BrandType<string, '_trackingId'>;
-export type ReviewInputTag = BrandType<string, '_reviewInputTag'>;
+
 export type RobotId = BrandType<string, '_robotId'>;
+
 export type RobotRunId = BrandType<string, '_robotRunId'>;
 
 // RevisionId '0' is the same as 'current'. However, we want to avoid '0'
@@ -106,7 +234,6 @@
 
 // The UUID of the suggested fix.
 export type FixId = BrandType<string, '_fixId'>;
-export type EmailAddress = BrandType<string, '_emailAddress'>;
 
 // The URL encoded UUID of the comment
 export type UrlEncodedCommentId = BrandType<string, '_urlEncodedCommentId'>;
@@ -114,197 +241,19 @@
 // The ID of the dashboard, in the form of '<ref>:<path>'
 export type DashboardId = BrandType<string, '_dahsboardId'>;
 
-// The 8-char hex GPG key ID.
-export type GpgKeyId = BrandType<string, '_gpgKeyId'>;
-
-// The 40-char (plus spaces) hex GPG key fingerprint
-export type GpgKeyFingerprint = BrandType<string, '_gpgKeyFingerprint'>;
-
-// OpenPGP User IDs (https://tools.ietf.org/html/rfc4880#section-5.11).
-export type OpenPgpUserIds = BrandType<string, '_openPgpUserIds'>;
-
-// This ID is equal to the numeric ID of the change that triggered the
-// submission. If the change that triggered the submission also has a topic, it
-// will be "<id>-<topic>" of the change that triggered the submission
-// The callers must not rely on the format of the submission ID.
-export type ChangeSubmissionId = BrandType<
-  string | number,
-  '_changeSubmissionId'
->;
-
-// The refs/heads/ prefix is omitted in Branch name
-export type BranchName = BrandType<string, '_branchName'>;
-
 // The refs/tags/ prefix is omitted in Tag name
 export type TagName = BrandType<string, '_tagName'>;
 
-// The ID of the change in the format "'<project>~<branch>~<Change-Id>'"
-export type ChangeInfoId = BrandType<string, '_changeInfoId'>;
-export type Hashtag = BrandType<string, '_hashtag'>;
-export type StarLabel = BrandType<string, '_startLabel'>;
-export type CommitId = BrandType<string, '_commitId'>;
 export type LabelName = BrandType<string, '_labelName'>;
-export type GroupName = BrandType<string, '_groupName'>;
-
-// The UUID of the group
-export type GroupId = BrandType<string, '_groupId'>;
 
 // The Encoded UUID of the group
 export type EncodedGroupId = BrandType<string, '_encodedGroupId'>;
 
-// The timezone offset from UTC in minutes
-export type TimezoneOffset = BrandType<number, '_timezoneOffset'>;
-
-// Timestamps are given in UTC and have the format
-// "'yyyy-mm-dd hh:mm:ss.fffffffff'"
-// where "'ffffffffff'" represents nanoseconds.
-export type Timestamp = BrandType<string, '_timestamp'>;
-
-export type IdToAttentionSetMap = {[accountId: string]: AttentionSetInfo};
-export type LabelNameToInfoMap = {[labelName: string]: LabelInfo};
-
-// {Verified: ["-1", " 0", "+1"]}
-export type LabelNameToValueMap = {[labelName: string]: string[]};
-
-// The map maps the values (“-2”, “-1”, " `0`", “+1”, “+2”) to the value descriptions.
-export type LabelValueToDescriptionMap = {[labelValue: string]: string};
-
-/**
- * The LabelInfo entity contains information about a label on a change, always
- * corresponding to the current patch set.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#label-info
- */
-export type LabelInfo =
-  | QuickLabelInfo
-  | DetailedLabelInfo
-  | (QuickLabelInfo & DetailedLabelInfo);
-
-interface LabelCommonInfo {
-  optional?: boolean; // not set if false
-}
-
-export interface QuickLabelInfo extends LabelCommonInfo {
-  approved?: AccountInfo;
-  rejected?: AccountInfo;
-  recommended?: AccountInfo;
-  disliked?: AccountInfo;
-  blocking?: boolean; // not set if false
-  value?: number; // The voting value of the user who recommended/disliked this label on the change if it is not “+1”/“-1”.
-  default_value?: number;
-}
-
-/**
- * LabelInfo when DETAILED_LABELS are requested.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#_fields_set_by_code_detailed_labels_code
- */
-export interface DetailedLabelInfo extends LabelCommonInfo {
-  // This is not set when the change has no reviewers.
-  all?: ApprovalInfo[];
-  // Docs claim that 'values' is optional, but it is actually always set.
-  values?: LabelValueToDescriptionMap; // A map of all values that are allowed for this label
-  default_value?: number;
-}
-
-export function isQuickLabelInfo(
-  l: LabelInfo
-): l is QuickLabelInfo | (QuickLabelInfo & DetailedLabelInfo) {
-  const quickLabelInfo = l as QuickLabelInfo;
-  return (
-    quickLabelInfo.approved !== undefined ||
-    quickLabelInfo.rejected !== undefined ||
-    quickLabelInfo.recommended !== undefined ||
-    quickLabelInfo.disliked !== undefined ||
-    quickLabelInfo.blocking !== undefined ||
-    quickLabelInfo.blocking !== undefined ||
-    quickLabelInfo.value !== undefined
-  );
-}
-
-export function isDetailedLabelInfo(
-  label: LabelInfo
-): label is DetailedLabelInfo | (QuickLabelInfo & DetailedLabelInfo) {
-  return !!(label as DetailedLabelInfo).values;
-}
-
 // https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#contributor-agreement-input
 export interface ContributorAgreementInput {
   name?: string;
 }
 
-// https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#contributor-agreement-info
-export interface ContributorAgreementInfo {
-  name: string;
-  description: string;
-  url: string;
-  auto_verify_group?: GroupInfo;
-}
-
-/**
- * The ChangeInfo entity contains information about a change.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#change-info
- */
-export interface ChangeInfo {
-  id: ChangeInfoId;
-  project: RepoName;
-  branch: BranchName;
-  topic?: TopicName;
-  attention_set?: IdToAttentionSetMap;
-  assignee?: AccountInfo;
-  hashtags?: Hashtag[];
-  change_id: ChangeId;
-  subject: string;
-  status: ChangeStatus;
-  created: Timestamp;
-  updated: Timestamp;
-  submitted?: Timestamp;
-  submitter?: AccountInfo;
-  starred?: boolean; // not set if false
-  stars?: StarLabel[];
-  reviewed?: boolean; // not set if false
-  submit_type?: SubmitType;
-  mergeable?: boolean;
-  submittable?: boolean;
-  insertions: number; // Number of inserted lines
-  deletions: number; // Number of deleted lines
-  total_comment_count?: number;
-  unresolved_comment_count?: number;
-  _number: NumericChangeId;
-  owner: AccountInfo;
-  actions?: ActionNameToActionInfoMap;
-  requirements?: Requirement[];
-  labels?: LabelNameToInfoMap;
-  permitted_labels?: LabelNameToValueMap;
-  removable_reviewers?: AccountInfo[];
-  // This is documented as optional, but actually always set.
-  reviewers: Reviewers;
-  pending_reviewers?: AccountInfo[];
-  reviewer_updates?: ReviewerUpdateInfo[];
-  messages?: ChangeMessageInfo[];
-  current_revision?: CommitId;
-  revisions?: {[revisionId: string]: RevisionInfo};
-  tracking_ids?: TrackingIdInfo[];
-  _more_changes?: boolean; // not set if false
-  problems?: ProblemInfo[];
-  is_private?: boolean; // not set if false
-  work_in_progress?: boolean; // not set if false
-  has_review_started?: boolean; // not set if false
-  revert_of?: NumericChangeId;
-  submission_id?: ChangeSubmissionId;
-  cherry_pick_of_change?: NumericChangeId;
-  cherry_pick_of_patch_set?: PatchSetNum;
-  contains_git_conflicts?: boolean;
-  internalHost?: string; // TODO(TS): provide an explanation what is its
-}
-
-/**
- * The reviewers as a map that maps a reviewer state to a list of AccountInfo
- * entities. Possible reviewer states are REVIEWER, CC and REMOVED.
- * REVIEWER: Users with at least one non-zero vote on the change.
- * CC: Users that were added to the change, but have not voted.
- * REMOVED: Users that were previously reviewers on the change, but have been removed.
- */
-export type Reviewers = Partial<Record<ReviewerState, AccountInfo[]>>;
-
 /**
  * ChangeView request change detail with ALL_REVISIONS option set.
  * The response always contains current_revision and revisions.
@@ -313,27 +262,6 @@
   ChangeInfo,
   'current_revision' | 'revisions'
 >;
-/**
- * The AccountInfo entity contains information about an account.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#account-info
- */
-export interface AccountInfo {
-  // Normally _account_id is defined (for known Gerrit users), but users can
-  // also be CCed just with their email address. So you have to be prepared that
-  // _account_id is undefined, but then email must be set.
-  _account_id?: AccountId;
-  name?: string;
-  display_name?: string;
-  // Must be set, if _account_id is undefined.
-  email?: EmailAddress;
-  secondary_emails?: string[];
-  username?: string;
-  avatars?: AvatarInfo[];
-  _more_accounts?: boolean; // not set if false
-  status?: string; // status message of the account
-  inactive?: boolean; // not set if false
-  tags?: AccountTag[];
-}
 
 export function isAccount(x: AccountInfo | GroupInfo): x is AccountInfo {
   const account = x as AccountInfo;
@@ -359,20 +287,57 @@
  */
 export interface AccountExternalIdInfo {
   identity: string;
-  email?: string;
+  email_address?: string;
   trusted?: boolean;
   can_delete?: boolean;
 }
 
 /**
  * The GroupAuditEventInfo entity contains information about an auditevent of a group.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-groups.html
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-groups.html#group-audit-event-info
  */
-export interface GroupAuditEventInfo {
-  member: string;
-  type: string;
-  user: string;
-  date: string;
+export type GroupAuditEventInfo =
+  | GroupAuditAccountEventInfo
+  | GroupAuditGroupEventInfo;
+
+export enum GroupAuditEventType {
+  ADD_USER = 'ADD_USER',
+  REMOVE_USER = 'REMOVE_USER',
+  ADD_GROUP = 'ADD_GROUP',
+  REMOVE_GROUP = 'REMOVE_GROUP',
+}
+
+export interface GroupAuditEventInfoBase {
+  user: AccountInfo;
+  date: Timestamp;
+}
+
+export interface GroupAuditAccountEventInfo extends GroupAuditEventInfoBase {
+  type: GroupAuditEventType.ADD_USER | GroupAuditEventType.REMOVE_USER;
+  member: AccountInfo;
+}
+
+export interface GroupAuditGroupEventInfo extends GroupAuditEventInfoBase {
+  type: GroupAuditEventType.ADD_GROUP | GroupAuditEventType.REMOVE_GROUP;
+  member: GroupInfo;
+}
+
+export function isGroupAuditAccountEventInfo(
+  x: GroupAuditEventInfo
+): x is GroupAuditAccountEventInfo {
+  return (
+    x.type === GroupAuditEventType.ADD_USER ||
+    x.type === GroupAuditEventType.REMOVE_USER
+  );
+}
+
+export function isGroupAuditGroupEventInfo(
+  x: GroupAuditEventInfo
+): x is GroupAuditGroupEventInfo {
+  return (
+    x.type === GroupAuditEventType.ADD_GROUP ||
+    x.type === GroupAuditEventType.REMOVE_GROUP
+  );
 }
 
 /**
@@ -384,26 +349,6 @@
   name: GroupName;
 }
 
-/**
- * The GroupInfo entity contains information about a group. This can be a
- * Gerrit internal group, or an external group that is known to Gerrit.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-groups.html#group-info
- */
-export interface GroupInfo {
-  id: GroupId;
-  name?: GroupName;
-  url?: string;
-  options?: GroupOptionsInfo;
-  description?: string;
-  group_id?: string;
-  owner?: string;
-  owner_id?: string;
-  created_on?: string;
-  _more_groups?: boolean;
-  members?: AccountInfo[];
-  includes?: GroupInfo[];
-}
-
 export type GroupNameToGroupInfoMap = {[groupName: string]: GroupInfo};
 
 /**
@@ -421,14 +366,6 @@
 }
 
 /**
- * Options of the group.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-groups.html
- */
-export interface GroupOptionsInfo {
-  visible_to_all: boolean;
-}
-
-/**
  * New options for a group.
  * https://gerrit-review.googlesource.com/Documentation/rest-api-groups.html
  */
@@ -456,191 +393,6 @@
   members?: string[];
 }
 
-/**
- * The ActionInfo entity describes a REST API call the client canmake to
- * manipulate a resource. These are frequently implemented by plugins and may
- * be discovered at runtime.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#action-info
- */
-export interface ActionInfo {
-  method?: HttpMethod; // Most actions use POST, PUT or DELETE to cause state changes.
-  label?: string; // Short title to display to a user describing the action
-  title?: string; // Longer text to display describing the action
-  enabled?: boolean; // not set if false
-}
-
-export interface ActionNameToActionInfoMap {
-  [actionType: string]: ActionInfo | undefined;
-  // List of actions explicitly used in code:
-  wip?: ActionInfo;
-  publishEdit?: ActionInfo;
-  rebaseEdit?: ActionInfo;
-  deleteEdit?: ActionInfo;
-  edit?: ActionInfo;
-  stopEdit?: ActionInfo;
-  download?: ActionInfo;
-  rebase?: ActionInfo;
-  cherrypick?: ActionInfo;
-  move?: ActionInfo;
-  revert?: ActionInfo;
-  revert_submission?: ActionInfo;
-  abandon?: ActionInfo;
-  submit?: ActionInfo;
-  topic?: ActionInfo;
-  hashtags?: ActionInfo;
-  assignee?: ActionInfo;
-  ready?: ActionInfo;
-  includedIn?: ActionInfo;
-}
-
-/**
- * The Requirement entity contains information about a requirement relative to
- * a change.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#requirement
- */
-export interface Requirement {
-  status: RequirementStatus;
-  fallbackText: string; // A human readable reason
-  type: RequirementType;
-}
-
-/**
- * The ReviewerUpdateInfo entity contains information about updates to change’s
- * reviewers set.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#review-update-info
- */
-export interface ReviewerUpdateInfo {
-  updated: Timestamp;
-  updated_by: AccountInfo;
-  reviewer: AccountInfo;
-  state: ReviewerState;
-}
-
-/**
- * The ChangeMessageInfo entity contains information about a messageattached
- * to a change.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#change-message-info
- */
-export interface ChangeMessageInfo {
-  id: ChangeMessageId;
-  author?: AccountInfo;
-  reviewer?: AccountInfo;
-  updated_by?: AccountInfo;
-  real_author?: AccountInfo;
-  date: Timestamp;
-  message: string;
-  accounts_in_message?: AccountInfo[];
-  tag?: ReviewInputTag;
-  _revision_number?: PatchSetNum;
-}
-
-/**
- * The RevisionInfo entity contains information about a patch set.Not all
- * fields are returned by default.  Additional fields can be obtained by
- * adding o parameters as described in Query Changes.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#revision-info
- * basePatchNum is present in case RevisionInfo is of type 'edit'
- */
-export interface RevisionInfo {
-  kind: RevisionKind;
-  _number: PatchSetNum;
-  created: Timestamp;
-  uploader: AccountInfo;
-  ref: GitRef;
-  fetch?: {[protocol: string]: FetchInfo};
-  commit?: CommitInfo;
-  files?: {[filename: string]: FileInfo};
-  actions?: ActionNameToActionInfoMap;
-  reviewed?: boolean;
-  commit_with_footers?: boolean;
-  push_certificate?: PushCertificateInfo;
-  description?: string;
-  basePatchNum?: BasePatchSetNum;
-}
-
-/**
- * The TrackingIdInfo entity describes a reference to an external tracking
- * system.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#tracking-id-info
- */
-export interface TrackingIdInfo {
-  system: string;
-  id: TrackingId;
-}
-
-/**
- * The ProblemInfo entity contains a description of a potential consistency
- * problem with a change. These are not related to the code review process,
- * but rather indicate some inconsistency in Gerrit’s database or repository
- * metadata related to the enclosing change.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#problem-info
- */
-export interface ProblemInfo {
-  message: string;
-  status?: ProblemInfoStatus; // Only set if a fix was attempted
-  outcome?: string;
-}
-
-/**
- * The AttentionSetInfo entity contains details of users that are in the attention set.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#attention-set-info
- */
-export interface AttentionSetInfo {
-  account: AccountInfo;
-  last_update?: Timestamp;
-  reason?: string;
-}
-
-/**
- * The ApprovalInfo entity contains information about an approval from auser
- * for a label on a change.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#approval-info
- */
-export interface ApprovalInfo extends AccountInfo {
-  value?: number;
-  permitted_voting_range?: VotingRangeInfo;
-  date?: Timestamp;
-  tag?: ReviewInputTag;
-  post_submit?: boolean; // not set if false
-}
-
-/**
- * The AvartarInfo entity contains information about an avatar image ofan
- * account.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#avatar-info
- */
-export interface AvatarInfo {
-  url: string;
-  height: number;
-  width: number;
-}
-
-/**
- * The FetchInfo entity contains information about how to fetch a patchset via
- * a certain protocol.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#fetch-info
- */
-export interface FetchInfo {
-  url: string;
-  ref: string;
-  commands?: {[commandName: string]: string};
-}
-
-/**
- * The CommitInfo entity contains information about a commit.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#commit-info
- */
-export interface CommitInfo {
-  commit?: CommitId;
-  parents: ParentCommitInfo[];
-  author: GitPersonInfo;
-  committer: GitPersonInfo;
-  subject: string;
-  message: string;
-  web_links?: WebLinkInfo[];
-  resolve_conflicts_web_links?: WebLinkInfo[];
-}
-
 export interface CommitInfoWithRequiredCommit extends CommitInfo {
   commit: CommitId;
 }
@@ -655,55 +407,6 @@
 }
 
 /**
- * The parent commits of this commit as a list of CommitInfo entities.
- * In each parent only the commit and subject fields are populated.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#commit-info
- */
-export interface ParentCommitInfo {
-  commit: CommitId;
-  subject: string;
-}
-
-/**
- * The FileInfo entity contains information about a file in a patch set.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#file-info
- */
-export interface FileInfo {
-  status?: FileInfoStatus;
-  binary?: boolean; // not set if false
-  old_path?: string;
-  lines_inserted?: number;
-  lines_deleted?: number;
-  size_delta: number; // in bytes
-  size: number; // in bytes
-}
-
-/**
- * The PushCertificateInfo entity contains information about a pushcertificate
- * provided when the user pushed for review with git push
- * --signed HEAD:refs/for/<branch>. Only used when signed push is
- * enabled on the server.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#push-certificate-info
- */
-export interface PushCertificateInfo {
-  certificate: string;
-  key: GpgKeyInfo;
-}
-
-/**
- * The GpgKeyInfo entity contains information about a GPG public key.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#gpg-key-info
- */
-export interface GpgKeyInfo {
-  id?: GpgKeyId;
-  fingerprint?: GpgKeyFingerprint;
-  user_ids?: OpenPgpUserIds[];
-  key?: string; // ASCII armored public key material
-  status?: GpgKeyInfoStatus;
-  problems?: string[];
-}
-
-/**
  * The GpgKeysInput entity contains information for adding/deleting GPG keys.
  * https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#gpg-keys-input
  */
@@ -713,58 +416,6 @@
 }
 
 /**
- * The GitPersonInfo entity contains information about theauthor/committer of
- * a commit.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#git-person-info
- */
-export interface GitPersonInfo {
-  name: string;
-  email: string;
-  date: Timestamp;
-  tz: TimezoneOffset;
-}
-
-/**
- * The VotingRangeInfo entity describes the continuous voting range from minto
- * max values.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#voting-range-info
- */
-export interface VotingRangeInfo {
-  min: number;
-  max: number;
-}
-
-/**
- * The AccountsConfigInfo entity contains information about Gerrit configuration
- * from the accounts section.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#accounts-config-info
- */
-export interface AccountsConfigInfo {
-  visibility: string;
-  default_display_name: DefaultDisplayNameConfig;
-}
-
-/**
- * The AuthInfo entity contains information about the authentication
- * configuration of the Gerrit server.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#auth-info
- */
-export interface AuthInfo {
-  auth_type: AuthType; // docs incorrectly names it 'type'
-  use_contributor_agreements?: boolean;
-  contributor_agreements?: ContributorAgreementInfo[];
-  editable_account_fields: EditableAccountField[];
-  login_url?: string;
-  login_text?: string;
-  switch_account_url?: string;
-  register_url?: string;
-  register_text?: string;
-  edit_full_name_url?: string;
-  http_password_url?: string;
-  git_basic_auth_policy?: string;
-}
-
-/**
  * The CacheInfo entity contains information about a cache.
  * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html
  */
@@ -798,23 +449,6 @@
 export type CapabilityInfoMap = {[id: string]: CapabilityInfo};
 
 /**
- * The ChangeConfigInfo entity contains information about Gerrit configuration
- * from the change section.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#change-config-info
- */
-export interface ChangeConfigInfo {
-  allow_blame?: boolean;
-  large_change: number;
-  reply_label: string;
-  reply_tooltip: string;
-  update_delay: number;
-  submit_whole_topic?: boolean;
-  disable_private_changes?: boolean;
-  mergeability_computation_behavior: MergeabilityComputationBehavior;
-  enable_assignee: boolean;
-}
-
-/**
  * The ChangeIndexConfigInfo entity contains information about Gerrit
  * configuration from the index.change section.
  * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#change-index-config-info
@@ -901,32 +535,6 @@
   new_value: string;
 }
 
-export type SchemesInfoMap = {[name: string]: DownloadSchemeInfo};
-
-/**
- * The DownloadInfo entity contains information about supported download
- * options.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#download-info
- */
-export interface DownloadInfo {
-  schemes: SchemesInfoMap;
-  archives: string[];
-}
-
-export type CloneCommandMap = {[name: string]: string};
-/**
- * The DownloadSchemeInfo entity contains information about a supported download
- * scheme and its commands.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html
- */
-export interface DownloadSchemeInfo {
-  url: string;
-  is_auth_required: boolean;
-  is_auth_supported: boolean;
-  commands: string;
-  clone_commands: CloneCommandMap;
-}
-
 /**
  * The EmailConfirmationInput entity contains information for confirming an
  * email address.
@@ -947,22 +555,6 @@
 }
 
 /**
- * The GerritInfo entity contains information about Gerrit configuration from
- * the gerrit section.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#gerrit-info
- */
-export interface GerritInfo {
-  all_projects: string; // Doc contains incorrect name
-  all_users: string; // Doc contains incorrect name
-  doc_search: boolean;
-  doc_url?: string;
-  edit_gpg_keys?: boolean;
-  report_bug_url?: string;
-  // The following property is missed in doc
-  primary_weblink_name?: string;
-}
-
-/**
  * The IndexConfigInfo entity contains information about Gerrit configuration
  * from the index section.
  * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#index-config-info
@@ -1020,65 +612,6 @@
 }
 
 /**
- * The PluginConfigInfo entity contains information about Gerrit extensions by
- * plugins.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#plugin-config-info
- */
-export interface PluginConfigInfo {
-  has_avatars: boolean;
-  // Exists in Java class, but not mentioned in docs.
-  js_resource_paths: string[];
-}
-
-/**
- * The ReceiveInfo entity contains information about the configuration of
- * git-receive-pack behavior on the server.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#receive-info
- */
-export interface ReceiveInfo {
-  enable_signed_push?: string;
-}
-
-/**
- * The ServerInfo entity contains information about the configuration of the
- * Gerrit server.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#server-info
- */
-export interface ServerInfo {
-  accounts: AccountsConfigInfo;
-  auth: AuthInfo;
-  change: ChangeConfigInfo;
-  download: DownloadInfo;
-  gerrit: GerritInfo;
-  // docs mentions index property, but it doesn't exists in Java class
-  // index: IndexConfigInfo;
-  note_db_enabled?: boolean;
-  plugin: PluginConfigInfo;
-  receive?: ReceiveInfo;
-  sshd?: SshdInfo;
-  suggest: SuggestInfo;
-  user: UserConfigInfo;
-  default_theme?: string;
-}
-
-/**
- * The SshdInfo entity contains information about Gerrit configuration from the sshd section.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#sshd-info
- * This entity doesn’t contain any data, but the presence of this (empty) entity
- * in the ServerInfo entity means that SSHD is enabled on the server.
- */
-export type SshdInfo = {};
-
-/**
- * The SuggestInfo entity contains information about Gerritconfiguration from
- * the suggest section.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#suggest-info
- */
-export interface SuggestInfo {
-  from: number;
-}
-
-/**
  * The SummaryInfo entity contains information about the current state of the
  * server.
  * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html
@@ -1148,15 +681,6 @@
 }
 
 /**
- * The UserConfigInfo entity contains information about Gerrit configuration
- * from the user section.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#user-config-info
- */
-export interface UserConfigInfo {
-  anonymous_coward_name: string;
-}
-
-/*
  * The CommentInfo entity contains information about an inline comment.
  * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#comment-info
  */
@@ -1270,114 +794,6 @@
 }
 
 /**
- * A boolean value that can also be inherited.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#inherited-boolean-info
- */
-export interface InheritedBooleanInfo {
-  value: boolean;
-  configured_value: InheritedBooleanInfoConfiguredValue;
-  inherited_value?: boolean;
-}
-
-/**
- * The MaxObjectSizeLimitInfo entity contains information about the max object
- * size limit of a project.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#max-object-size-limit-info
- */
-export interface MaxObjectSizeLimitInfo {
-  value?: string;
-  configured_value?: string;
-  summary?: string;
-}
-
-/**
- * Information about the default submittype of a project, taking into account
- * project inheritance.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#submit-type-info
- */
-export interface SubmitTypeInfo {
-  value: Exclude<SubmitType, SubmitType.INHERIT>;
-  configured_value: SubmitType;
-  inherited_value: Exclude<SubmitType, SubmitType.INHERIT>;
-}
-
-/**
- * The CommentLinkInfo entity describes acommentlink.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#commentlink-info
- */
-export interface CommentLinkInfo {
-  match: string;
-  link?: string;
-  enabled?: boolean;
-  html?: string;
-}
-
-/**
- * The ConfigParameterInfo entity describes a project configurationparameter.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#config-parameter-info
- */
-export interface ConfigParameterInfoBase {
-  display_name?: string;
-  description?: string;
-  warning?: string;
-  type: ConfigParameterInfoType;
-  value?: string;
-  values?: string[];
-  editable?: boolean;
-  permitted_values?: string[];
-  inheritable?: boolean;
-  configured_value?: string;
-  inherited_value?: string;
-}
-
-export interface ConfigArrayParameterInfo extends ConfigParameterInfoBase {
-  type: ConfigParameterInfoType.ARRAY;
-  values: string[];
-}
-
-export interface ConfigListParameterInfo extends ConfigParameterInfoBase {
-  type: ConfigParameterInfoType.LIST;
-  permitted_values?: string[];
-}
-
-export type ConfigParameterInfo =
-  | ConfigParameterInfoBase
-  | ConfigArrayParameterInfo
-  | ConfigListParameterInfo;
-
-export interface CommentLinks {
-  [name: string]: CommentLinkInfo;
-}
-
-/**
- * The ConfigInfo entity contains information about the effective
- * project configuration.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#config-info
- */
-export interface ConfigInfo {
-  description?: string;
-  use_contributor_agreements?: InheritedBooleanInfo;
-  use_content_merge?: InheritedBooleanInfo;
-  use_signed_off_by?: InheritedBooleanInfo;
-  create_new_change_for_all_not_in_target?: InheritedBooleanInfo;
-  require_change_id?: InheritedBooleanInfo;
-  enable_signed_push?: InheritedBooleanInfo;
-  require_signed_push?: InheritedBooleanInfo;
-  reject_implicit_merges?: InheritedBooleanInfo;
-  private_by_default: InheritedBooleanInfo;
-  work_in_progress_by_default: InheritedBooleanInfo;
-  max_object_size_limit: MaxObjectSizeLimitInfo;
-  default_submit_type: SubmitTypeInfo;
-  submit_type: SubmitType;
-  match_author_to_committer_date?: InheritedBooleanInfo;
-  state?: ProjectState;
-  commentlinks: CommentLinks;
-  plugin_config?: PluginNameToPluginParametersMap;
-  actions?: {[viewName: string]: ActionInfo};
-  reject_empty_commit?: InheritedBooleanInfo;
-}
-
-/**
  * The ProjectAccessInfo entity contains information about the access rights for
  * a project.
  * https://gerrit-review.googlesource.com/Documentation/rest-api-access.html#project-access-info
@@ -1485,17 +901,6 @@
   plugin_config_values?: PluginNameToPluginParametersMap;
   commentlinks?: ConfigInfoCommentLinks;
 }
-/**
- * Plugin configuration values as map which maps the plugin name to a map of parameter names to values
- * https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#config-input
- */
-export type PluginNameToPluginParametersMap = {
-  [pluginName: string]: PluginParameterToConfigParameterInfoMap;
-};
-
-export type PluginParameterToConfigParameterInfoMap = {
-  [parameterName: string]: ConfigParameterInfo;
-};
 
 export type ConfigInfoCommentLinks = {
   [commentLinkName: string]: CommentLinkInfo;
@@ -1579,6 +984,7 @@
   notify_all_comments?: boolean;
   notify_submitted_changes?: boolean;
   notify_abandoned_changes?: boolean;
+  _is_local?: boolean; // Added manually
 }
 /**
  * The DeleteDraftCommentsInput entity contains information specifying a set of draft comments that should be deleted
diff --git a/polygerrit-ui/app/types/diff.ts b/polygerrit-ui/app/types/diff.ts
index 35c5726..16338335 100644
--- a/polygerrit-ui/app/types/diff.ts
+++ b/polygerrit-ui/app/types/diff.ts
@@ -20,22 +20,29 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
+import {WebLinkInfo} from '../api/rest-api';
+import {
+  ChangeType,
+  DiffContent as DiffContentApi,
+  DiffFileMetaInfo as DiffFileMetaInfoApi,
+  DiffInfo as DiffInfoApi,
+  DiffIntralineInfo,
+  DiffPreferencesInfo as DiffPreferenceInfoApi,
+  IgnoreWhitespaceType,
+  MarkLength,
+  MoveDetails,
+  SkipLength,
+} from '../api/diff';
 
 export {
   ChangeType,
-  MoveDetails,
-  SkipLength,
-  MarkLength,
   DiffIntralineInfo,
   IgnoreWhitespaceType,
-} from '../api/diff';
-
-import {
-  DiffInfo as DiffInfoApi,
-  DiffFileMetaInfo as DiffFileMetaInfoApi,
-  DiffContent as DiffContentApi,
-  DiffPreferencesInfo as DiffPreferenceInfoApi,
-} from '../api/diff';
+  MarkLength,
+  MoveDetails,
+  SkipLength,
+  WebLinkInfo,
+};
 
 export interface DiffInfo extends DiffInfoApi {
   /** Meta information about the file on side A as a DiffFileMetaInfo entity. */
@@ -82,19 +89,6 @@
   web_links?: WebLinkInfo[];
 }
 
-/**
- * The WebLinkInfo entity describes a link to an external site.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#web-link-info
- */
-export declare interface WebLinkInfo {
-  /** The link name. */
-  name: string;
-  /** The link URL. */
-  url: string;
-  /** URL to the icon of the link. */
-  image_url: string;
-}
-
 export interface DiffContent extends DiffContentApi {
   // TODO: Undocumented, but used in code.
   keyLocation?: boolean;
@@ -110,7 +104,6 @@
   hide_line_numbers?: boolean;
   hide_empty_pane?: boolean;
   match_brackets?: boolean;
-  line_wrapping?: boolean;
 }
 
 export declare type DiffPreferencesInfoKey = keyof DiffPreferencesInfo;
diff --git a/polygerrit-ui/app/types/events.ts b/polygerrit-ui/app/types/events.ts
index 8e89525..5145527 100644
--- a/polygerrit-ui/app/types/events.ts
+++ b/polygerrit-ui/app/types/events.ts
@@ -15,7 +15,7 @@
  * limitations under the License.
  */
 import {EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {PatchSetNum, UrlEncodedCommentId} from './common';
+import {PatchSetNum} from './common';
 import {UIComment} from '../utils/comment-util';
 import {FetchRequest} from './types';
 import {LineNumberEventDetail, MovedLinkClickedEventDetail} from '../api/diff';
@@ -23,8 +23,12 @@
 import {ChangeMessage} from '../elements/change/gr-message/gr-message';
 
 export enum EventType {
+  CHANGE = 'change',
+  CHANGED = 'changed',
   CHANGE_MESSAGE_DELETED = 'change-message-deleted',
+  COMMIT = 'commit',
   DIALOG_CHANGE = 'dialog-change',
+  DROP = 'drop',
   EDITABLE_CONTENT_SAVE = 'editable-content-save',
   GR_RPC_LOG = 'gr-rpc-log',
   LOCATION_CHANGE = 'location-change',
@@ -44,19 +48,29 @@
   SHOW_ERROR = 'show-error',
   SHOW_PRIMARY_TAB = 'show-primary-tab',
   SHOW_SECONDARY_TAB = 'show-secondary-tab',
-  THREAD_LIST_MODIFIED = 'thread-list-modified',
+  TAP_ITEM = 'tap-item',
   TITLE_CHANGE = 'title-change',
 }
 
 declare global {
   interface HTMLElementEventMap {
+    /* prettier-ignore */
+    'change': ChangeEvent;
+    /* prettier-ignore */
+    'changed': ChangedEvent;
     'change-message-deleted': ChangeMessageDeletedEvent;
+    /* prettier-ignore */
+    'commit': CommitEvent;
     'dialog-change': DialogChangeEvent;
+    /* prettier-ignore */
+    'drop': DropEvent;
     'editable-content-save': EditableContentSaveEvent;
     'location-change': LocationChangeEvent;
     'iron-announce': IronAnnounceEvent;
-    'line-number-mouse-enter': LineNumberEvent;
-    'line-number-mouse-leave': LineNumberEvent;
+    /* prettier-ignore */
+    'keypress': KeypressEvent;
+    'line-mouse-enter': LineNumberEvent;
+    'line-mouse-leave': LineNumberEvent;
     'line-cursor-moved-in': LineNumberEvent;
     'line-cursor-moved-out': LineNumberEvent;
     'moved-link-clicked': MovedLinkClickedEvent;
@@ -71,7 +85,7 @@
     'show-error': ShowErrorEvent;
     'show-primary-tab': SwitchTabEvent;
     'show-secondary-tab': SwitchTabEvent;
-    'thread-list-modified': ThreadListModifiedEvent;
+    'tap-item': TapItemEvent;
     'title-change': TitleChangeEvent;
   }
 }
@@ -89,11 +103,17 @@
   }
 }
 
+export type ChangeEvent = InputEvent;
+
+export type ChangedEvent = CustomEvent<string>;
+
 export interface ChangeMessageDeletedEventDetail {
   message: ChangeMessage;
 }
 export type ChangeMessageDeletedEvent = CustomEvent<ChangeMessageDeletedEventDetail>;
 
+export type CommitEvent = CustomEvent;
+
 // TODO(milutin) - remove once new gr-dialog will do it out of the box
 // This informs gr-app-element to remove footer, header from a11y tree
 export interface DialogChangeEventDetail {
@@ -102,6 +122,8 @@
 }
 export type DialogChangeEvent = CustomEvent<DialogChangeEventDetail>;
 
+export type DropEvent = DragEvent;
+
 export interface EditableContentSaveEventDetail {
   content: string;
 }
@@ -120,6 +142,8 @@
 }
 export type IronAnnounceEvent = CustomEvent<IronAnnounceEventDetail>;
 
+export type KeypressEvent = InputEvent;
+
 export interface LocationChangeEventDetail {
   hash: string;
   pathname: string;
@@ -212,11 +236,7 @@
 }
 export type SwitchTabEvent = CustomEvent<SwitchTabEventDetail>;
 
-export interface ThreadListModifiedDetail {
-  rootId: UrlEncodedCommentId;
-  path: string;
-}
-export type ThreadListModifiedEvent = CustomEvent<ThreadListModifiedDetail>;
+export type TapItemEvent = CustomEvent;
 
 export interface TitleChangeEventDetail {
   title: string;
diff --git a/polygerrit-ui/app/types/globals.ts b/polygerrit-ui/app/types/globals.ts
index 267ea12..a06c2c4 100644
--- a/polygerrit-ui/app/types/globals.ts
+++ b/polygerrit-ui/app/types/globals.ts
@@ -29,11 +29,6 @@
       options: {callback: (text: string, href?: string) => void}
     ): void;
     ASSETS_PATH?: string;
-    // TODO(TS): define gerrit type
-    Gerrit?: {
-      Nav?: unknown;
-      Auth?: unknown;
-    };
     // TODO(TS): define polymer type
     Polymer: {
       IronFocusablesHelper: {
@@ -57,8 +52,6 @@
       dashboardQuery?: string[];
     };
 
-    VERSION_INFO?: string;
-
     /** Enhancements on Gr elements or utils */
     // TODO(TS): should clean up those and removing them may break certain plugin behaviors
     // TODO(TS): as @brohlfs suggested, to avoid importing anything from elements/ to types/
diff --git a/polygerrit-ui/app/types/types.ts b/polygerrit-ui/app/types/types.ts
index e3e1ada..2d4d412 100644
--- a/polygerrit-ui/app/types/types.ts
+++ b/polygerrit-ui/app/types/types.ts
@@ -123,8 +123,33 @@
    * The index of the element in the dom-repeat.
    */
   index: number;
-  get: (name: string) => T;
-  set: (name: string, val: T) => void;
+  get(name: 'item'): T;
+  // Typed get for item.prop_name
+  get<K extends keyof T>(name: `item.${K extends string ? K : never}`): T[K];
+  // Typed get for item.prop_name.nested_prop_name
+  get<K1 extends keyof T, K2 extends keyof T[K1]>(
+    name: `item.${K1 extends string ? K1 : never}.${K2 extends string
+      ? K2
+      : never}`
+  ): T[K1][K2];
+  // Untyped get for other cases
+  get(name: string): unknown; // force get(...) as Type for nested properties
+
+  set(name: 'item', val: T): void;
+  // Typed set for item.prop_name
+  set<K extends keyof T>(
+    name: `item.${K extends string ? K : never}`,
+    val: T[K]
+  ): void;
+  // Typed get for item.prop_name.nested_prop_name
+  set<K1 extends keyof T, K2 extends keyof T[K1]>(
+    name: `item.${K1 extends string ? K1 : never}.${K2 extends string
+      ? K2
+      : never}`,
+    val: T[K1][K2]
+  ): void;
+  // Untyped set for other cases
+  set(name: string, val: unknown): void;
 }
 
 /** https://highlightjs.readthedocs.io/en/latest/api.html */
@@ -164,7 +189,6 @@
   showDownloadDialog: boolean;
   diffMode: DiffViewMode | null;
   numFilesShown: number | null;
-  scrollTop?: number;
   diffViewMode?: boolean;
 }
 
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 dd91f7b..c54c099 100644
--- a/polygerrit-ui/app/utils/change-util.ts
+++ b/polygerrit-ui/app/utils/change-util.ts
@@ -24,6 +24,7 @@
   RelatedChangeAndCommitInfo,
 } from '../types/common';
 import {ParsedChangeInfo} from '../types/types';
+import {ChangeStates} from '../elements/shared/gr-change-status/gr-change-status';
 
 // This can be wrong! See WARNING above
 interface ChangeStatusesOptions {
@@ -130,32 +131,41 @@
   return `${getBaseUrl()}/c/${changeNum}`;
 }
 
-export function changeIsOpen(change?: ChangeInfo | ParsedChangeInfo) {
+export function changeIsOpen(change?: ChangeInfo | ParsedChangeInfo | null) {
   return change?.status === ChangeStatus.NEW;
 }
 
-// TODO(TS): use enum ChangeStates in gr-change-status
+export function changeIsMerged(change?: ChangeInfo | ParsedChangeInfo | null) {
+  return change?.status === ChangeStatus.MERGED;
+}
+
+export function changeIsAbandoned(
+  change?: ChangeInfo | ParsedChangeInfo | null
+) {
+  return change?.status === ChangeStatus.ABANDONED;
+}
+
 export function changeStatuses(
   change: ChangeInfo,
   opt_options?: ChangeStatusesOptions
-) {
+): ChangeStates[] {
   const states = [];
   if (change.status === ChangeStatus.MERGED) {
-    states.push('Merged');
+    states.push(ChangeStates.MERGED);
   } else if (change.status === ChangeStatus.ABANDONED) {
-    states.push('Abandoned');
+    states.push(ChangeStates.ABANDONED);
   } else if (
     change.mergeable === false ||
     (opt_options && opt_options.mergeable === false)
   ) {
     // 'mergeable' prop may not always exist (@see Issue 6819)
-    states.push('Merge Conflict');
+    states.push(ChangeStates.MERGE_CONFLICT);
   }
   if (change.work_in_progress) {
-    states.push('WIP');
+    states.push(ChangeStates.WIP);
   }
   if (change.is_private) {
-    states.push('Private');
+    states.push(ChangeStates.PRIVATE);
   }
 
   // If there are any pre-defined statuses, only return those. Otherwise,
@@ -166,29 +176,36 @@
 
   // If no missing requirements, either active or ready to submit.
   if (change.submittable && opt_options.submitEnabled) {
-    states.push('Ready to submit');
+    states.push(ChangeStates.READY_TO_SUBMIT);
   } else {
     // Otherwise it is active.
-    states.push('Active');
+    states.push(ChangeStates.ACTIVE);
   }
   return states;
 }
 
-export function isOwner(change?: ChangeInfo, account?: AccountInfo): boolean {
+export function isOwner(
+  change?: ChangeInfo | ParsedChangeInfo,
+  account?: AccountInfo
+): boolean {
   if (!change || !account) return false;
   return change.owner?._account_id === account._account_id;
 }
 
 export function isReviewer(
-  change?: ChangeInfo,
+  change?: ChangeInfo | ParsedChangeInfo,
   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/change-util_test.js b/polygerrit-ui/app/utils/change-util_test.js
deleted file mode 100644
index f348239..0000000
--- a/polygerrit-ui/app/utils/change-util_test.js
+++ /dev/null
@@ -1,202 +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 {
-  changeBaseURL,
-  changePath,
-  changeStatuses,
-  isRemovableReviewer,
-} from './change-util.js';
-
-suite('change-util tests', () => {
-  let originalCanonicalPath;
-
-  suiteSetup(() => {
-    originalCanonicalPath = window.CANONICAL_PATH;
-    window.CANONICAL_PATH = '/r';
-  });
-
-  suiteTeardown(() => {
-    window.CANONICAL_PATH = originalCanonicalPath;
-  });
-
-  test('changeBaseURL', () => {
-    assert.deepEqual(
-        changeBaseURL('test/project', '1', '2'),
-        '/r/changes/test%2Fproject~1/revisions/2'
-    );
-  });
-
-  test('changePath', () => {
-    assert.deepEqual(changePath('1'), '/r/c/1');
-  });
-
-  test('Open status', () => {
-    const change = {
-      change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
-      revisions: {
-        rev1: {_number: 1},
-      },
-      current_revision: 'rev1',
-      status: 'NEW',
-      labels: {},
-      mergeable: true,
-    };
-    let statuses = changeStatuses(change);
-    assert.deepEqual(statuses, []);
-
-    change.submittable = false;
-    statuses = changeStatuses(change,
-        {includeDerived: true});
-    assert.deepEqual(statuses, ['Active']);
-
-    // With no missing labels but no submitEnabled option.
-    change.submittable = true;
-    statuses = changeStatuses(change,
-        {includeDerived: true});
-    assert.deepEqual(statuses, ['Active']);
-
-    // Without missing labels and enabled submit
-    statuses = changeStatuses(change,
-        {includeDerived: true, submitEnabled: true});
-    assert.deepEqual(statuses, ['Ready to submit']);
-
-    change.mergeable = false;
-    change.submittable = true;
-    statuses = changeStatuses(change,
-        {includeDerived: true});
-    assert.deepEqual(statuses, ['Merge Conflict']);
-
-    delete change.mergeable;
-    change.submittable = true;
-    statuses = changeStatuses(change,
-        {includeDerived: true, mergeable: true, submitEnabled: true});
-    assert.deepEqual(statuses, ['Ready to submit']);
-
-    change.submittable = true;
-    statuses = changeStatuses(change,
-        {includeDerived: true, mergeable: false});
-    assert.deepEqual(statuses, ['Merge Conflict']);
-  });
-
-  test('Merge conflict', () => {
-    const change = {
-      change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
-      revisions: {
-        rev1: {_number: 1},
-      },
-      current_revision: 'rev1',
-      status: 'NEW',
-      labels: {},
-      mergeable: false,
-    };
-    const statuses = changeStatuses(change);
-    assert.deepEqual(statuses, ['Merge Conflict']);
-  });
-
-  test('mergeable prop undefined', () => {
-    const change = {
-      change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
-      revisions: {
-        rev1: {_number: 1},
-      },
-      current_revision: 'rev1',
-      status: 'NEW',
-      labels: {},
-    };
-    const statuses = changeStatuses(change);
-    assert.deepEqual(statuses, []);
-  });
-
-  test('Merged status', () => {
-    const change = {
-      change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
-      revisions: {
-        rev1: {_number: 1},
-      },
-      current_revision: 'rev1',
-      status: 'MERGED',
-      labels: {},
-    };
-    const statuses = changeStatuses(change);
-    assert.deepEqual(statuses, ['Merged']);
-  });
-
-  test('Abandoned status', () => {
-    const change = {
-      change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
-      revisions: {
-        rev1: {_number: 1},
-      },
-      current_revision: 'rev1',
-      status: 'ABANDONED',
-      labels: {},
-    };
-    const statuses = changeStatuses(change);
-    assert.deepEqual(statuses, ['Abandoned']);
-  });
-
-  test('Open status with private and wip', () => {
-    const change = {
-      change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
-      revisions: {
-        rev1: {_number: 1},
-      },
-      current_revision: 'rev1',
-      status: 'NEW',
-      is_private: true,
-      work_in_progress: true,
-      labels: {},
-      mergeable: true,
-    };
-    const statuses = changeStatuses(change);
-    assert.deepEqual(statuses, ['WIP', 'Private']);
-  });
-
-  test('Merge conflict with private and wip', () => {
-    const change = {
-      change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
-      revisions: {
-        rev1: {_number: 1},
-      },
-      current_revision: 'rev1',
-      status: 'NEW',
-      is_private: true,
-      work_in_progress: true,
-      labels: {},
-      mergeable: false,
-    };
-    const statuses = changeStatuses(change);
-    assert.deepEqual(statuses, ['Merge Conflict', 'WIP', 'Private']);
-  });
-
-  test('isRemovableReviewer', () => {
-    let change = {
-      removable_reviewers: [{_account_id: 1}],
-    };
-    const reviewer = {_account_id: 1};
-
-    assert.equal(isRemovableReviewer(change, reviewer), true);
-
-    change = {
-      removable_reviewers: [{_account_id: 2}],
-    };
-    assert.equal(isRemovableReviewer(change, reviewer), false);
-  });
-});
-
diff --git a/polygerrit-ui/app/utils/change-util_test.ts b/polygerrit-ui/app/utils/change-util_test.ts
new file mode 100644
index 0000000..ccec27e
--- /dev/null
+++ b/polygerrit-ui/app/utils/change-util_test.ts
@@ -0,0 +1,240 @@
+/**
+ * @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 {ChangeStatus} from '../constants/constants';
+import {ChangeStates} from '../elements/shared/gr-change-status/gr-change-status';
+import '../test/common-test-setup-karma';
+import {createChange, createRevisions} from '../test/test-data-generators';
+import {
+  AccountId,
+  CommitId,
+  NumericChangeId,
+  PatchSetNum,
+} from '../types/common';
+import {
+  changeBaseURL,
+  changeIsOpen,
+  changeIsMerged,
+  changeIsAbandoned,
+  changePath,
+  changeStatuses,
+  isRemovableReviewer,
+} from './change-util';
+
+suite('change-util tests', () => {
+  let originalCanonicalPath: string | undefined;
+
+  suiteSetup(() => {
+    originalCanonicalPath = window.CANONICAL_PATH;
+    window.CANONICAL_PATH = '/r';
+  });
+
+  suiteTeardown(() => {
+    window.CANONICAL_PATH = originalCanonicalPath;
+  });
+
+  test('changeBaseURL', () => {
+    assert.deepEqual(
+      changeBaseURL('test/project', 1 as NumericChangeId, '2' as PatchSetNum),
+      '/r/changes/test%2Fproject~1/revisions/2'
+    );
+  });
+
+  test('changePath', () => {
+    assert.deepEqual(changePath(1 as NumericChangeId), '/r/c/1');
+  });
+
+  test('Open status', () => {
+    const change = {
+      ...createChange(),
+      revisions: createRevisions(1),
+      current_revision: 'rev1' as CommitId,
+      mergeable: true,
+    };
+    let statuses = changeStatuses(change);
+    assert.deepEqual(statuses, []);
+
+    change.submittable = false;
+    statuses = changeStatuses(change, {mergeable: true, submitEnabled: false});
+    assert.deepEqual(statuses, [ChangeStates.ACTIVE]);
+
+    // With no missing labels but no submitEnabled option.
+    change.submittable = true;
+    statuses = changeStatuses(change, {mergeable: true, submitEnabled: false});
+    assert.deepEqual(statuses, [ChangeStates.ACTIVE]);
+
+    // Without missing labels and enabled submit
+    statuses = changeStatuses(change, {mergeable: true, submitEnabled: true});
+    assert.deepEqual(statuses, [ChangeStates.READY_TO_SUBMIT]);
+
+    change.mergeable = false;
+    change.submittable = true;
+    statuses = changeStatuses(change, {mergeable: false, submitEnabled: false});
+    assert.deepEqual(statuses, [ChangeStates.MERGE_CONFLICT]);
+
+    change.mergeable = true;
+    statuses = changeStatuses(change, {mergeable: true, submitEnabled: true});
+    assert.deepEqual(statuses, [ChangeStates.READY_TO_SUBMIT]);
+
+    change.submittable = true;
+    statuses = changeStatuses(change, {mergeable: false, submitEnabled: false});
+    assert.deepEqual(statuses, [ChangeStates.MERGE_CONFLICT]);
+  });
+
+  test('Merge conflict', () => {
+    const change = {
+      ...createChange(),
+      revisions: createRevisions(1),
+      current_revision: 'rev1' as CommitId,
+      status: ChangeStatus.NEW,
+      mergeable: false,
+    };
+    const statuses = changeStatuses(change);
+    assert.deepEqual(statuses, [ChangeStates.MERGE_CONFLICT]);
+  });
+
+  test('mergeable prop undefined', () => {
+    const change = {
+      ...createChange(),
+      revisions: createRevisions(1),
+      current_revision: 'rev1' as CommitId,
+      status: ChangeStatus.NEW,
+    };
+    const statuses = changeStatuses(change);
+    assert.deepEqual(statuses, []);
+  });
+
+  test('Merged status', () => {
+    const change = {
+      ...createChange(),
+      revisions: createRevisions(1),
+      current_revision: 'rev1' as CommitId,
+      status: ChangeStatus.MERGED,
+    };
+    const statuses = changeStatuses(change);
+    assert.deepEqual(statuses, [ChangeStates.MERGED]);
+  });
+
+  test('Abandoned status', () => {
+    const change = {
+      ...createChange(),
+      revisions: createRevisions(1),
+      current_revision: 'rev1' as CommitId,
+      status: ChangeStatus.ABANDONED,
+      mergeable: false,
+    };
+    const statuses = changeStatuses(change);
+    assert.deepEqual(statuses, [ChangeStates.ABANDONED]);
+  });
+
+  test('Open status with private and wip', () => {
+    const change = {
+      ...createChange(),
+      revisions: createRevisions(1),
+      current_revision: 'rev1' as CommitId,
+      status: ChangeStatus.NEW,
+      mergeable: true,
+      is_private: true,
+      work_in_progress: true,
+      labels: {},
+    };
+    const statuses = changeStatuses(change);
+    assert.deepEqual(statuses, [ChangeStates.WIP, ChangeStates.PRIVATE]);
+  });
+
+  test('Merge conflict with private and wip', () => {
+    const change = {
+      ...createChange(),
+      revisions: createRevisions(1),
+      current_revision: 'rev1' as CommitId,
+      status: ChangeStatus.NEW,
+      mergeable: false,
+      is_private: true,
+      work_in_progress: true,
+      labels: {},
+    };
+    const statuses = changeStatuses(change);
+    assert.deepEqual(statuses, [
+      ChangeStates.MERGE_CONFLICT,
+      ChangeStates.WIP,
+      ChangeStates.PRIVATE,
+    ]);
+  });
+
+  test('isRemovableReviewer', () => {
+    let change = {
+      ...createChange(),
+      revisions: createRevisions(1),
+      current_revision: 'rev1' as CommitId,
+      status: ChangeStatus.NEW,
+      mergeable: false,
+      removable_reviewers: [{_account_id: 1 as AccountId}],
+    };
+    const reviewer = {_account_id: 1 as AccountId};
+
+    assert.equal(isRemovableReviewer(change, reviewer), true);
+
+    change = {
+      ...createChange(),
+      revisions: createRevisions(1),
+      current_revision: 'rev1' as CommitId,
+      status: ChangeStatus.NEW,
+      mergeable: false,
+      removable_reviewers: [{_account_id: 2 as AccountId}],
+    };
+    assert.equal(isRemovableReviewer(change, reviewer), false);
+  });
+
+  test('changeIsOpen', () => {
+    const change = {
+      ...createChange(),
+      revisions: createRevisions(1),
+      current_revision: 'rev1' as CommitId,
+      status: ChangeStatus.NEW,
+      mergeable: false,
+    };
+    assert.isTrue(changeIsOpen(change));
+    change.status = ChangeStatus.MERGED;
+    assert.isFalse(changeIsOpen(change));
+  });
+
+  test('changeIsMerged', () => {
+    const change = {
+      ...createChange(),
+      revisions: createRevisions(1),
+      current_revision: 'rev1' as CommitId,
+      status: ChangeStatus.MERGED,
+      mergeable: false,
+    };
+    assert.isTrue(changeIsMerged(change));
+    change.status = ChangeStatus.NEW;
+    assert.isFalse(changeIsMerged(change));
+  });
+
+  test('changeIsAbandoned', () => {
+    const change = {
+      ...createChange(),
+      revisions: createRevisions(1),
+      current_revision: 'rev1' as CommitId,
+      status: ChangeStatus.ABANDONED,
+      mergeable: false,
+    };
+    assert.isTrue(changeIsAbandoned(change));
+    change.status = ChangeStatus.NEW;
+    assert.isFalse(changeIsAbandoned(change));
+  });
+});
diff --git a/polygerrit-ui/app/utils/comment-util.ts b/polygerrit-ui/app/utils/comment-util.ts
index 2d5f66e..3e85b48 100644
--- a/polygerrit-ui/app/utils/comment-util.ts
+++ b/polygerrit-ui/app/utils/comment-util.ts
@@ -27,6 +27,8 @@
   ContextLine,
   BasePatchSetNum,
   RevisionPatchSetNum,
+  AccountInfo,
+  AccountDetailInfo,
 } from '../types/common';
 import {CommentSide, Side, SpecialFilePath} from '../constants/constants';
 import {parseDate} from './date-util';
@@ -330,3 +332,54 @@
   };
   return diff;
 }
+
+export function getCommentAuthors(
+  threads?: CommentThread[],
+  user?: AccountDetailInfo
+) {
+  if (!threads || !user) return [];
+  const ids = new Set();
+  const authors: AccountInfo[] = [];
+  threads.forEach(t =>
+    t.comments.forEach(c => {
+      if (isDraft(c) && !ids.has(user._account_id)) {
+        ids.add(user._account_id);
+        authors.push(user);
+        return;
+      }
+      if (c.author && !ids.has(c.author._account_id)) {
+        ids.add(c.author._account_id);
+        authors.push(c.author);
+      }
+    })
+  );
+  return authors;
+}
+
+export function computeId(comment: UIComment) {
+  if (comment.id) return comment.id;
+  if (isDraft(comment)) return comment.__draftID;
+  throw new Error('Missing id in root comment.');
+}
+
+/**
+ * Add path info to every comment as CommentInfo returned
+ * from server does not have that.
+ *
+ * TODO(taoalpha): should consider changing BE to send path
+ * back within CommentInfo
+ */
+export function addPath<T>(
+  comments: {[path: string]: T[]} = {}
+): {[path: string]: Array<T & {path: string}>} {
+  const updatedComments: {[path: string]: Array<T & {path: string}>} = {};
+  for (const filePath of Object.keys(comments)) {
+    const allCommentsForPath = comments[filePath] || [];
+    if (allCommentsForPath.length) {
+      updatedComments[filePath] = allCommentsForPath.map(comment => {
+        return {...comment, path: filePath};
+      });
+    }
+  }
+  return updatedComments;
+}
diff --git a/polygerrit-ui/app/utils/common-util.ts b/polygerrit-ui/app/utils/common-util.ts
index 08b5e49..36c3657 100644
--- a/polygerrit-ui/app/utils/common-util.ts
+++ b/polygerrit-ui/app/utils/common-util.ts
@@ -90,6 +90,24 @@
   }
 }
 
+function query<E extends Element = Element>(
+  el: Element | undefined,
+  selector: string
+): E | undefined {
+  if (!el) return undefined;
+  const root = el.shadowRoot ?? el;
+  return root.querySelector<E>(selector) ?? undefined;
+}
+
+export function queryAndAssert<E extends Element = Element>(
+  el: Element | undefined,
+  selector: string
+): E {
+  const found = query<E>(el, selector);
+  if (!found) throw new Error(`selector '${selector}' did not match anything'`);
+  return found;
+}
+
 /**
  * Returns true, if both sets contain the same members.
  */
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/event-util.ts b/polygerrit-ui/app/utils/event-util.ts
index 4ba27bc..c356893 100644
--- a/polygerrit-ui/app/utils/event-util.ts
+++ b/polygerrit-ui/app/utils/event-util.ts
@@ -15,7 +15,6 @@
  * limitations under the License.
  */
 
-import {UrlEncodedCommentId} from '../types/common';
 import {FetchRequest} from '../types/types';
 import {
   DialogChangeEventDetail,
@@ -108,17 +107,6 @@
   fire(target, EventType.IRON_ANNOUNCE, {text});
 }
 
-export function fireThreadListModifiedEvent(
-  target: EventTarget,
-  rootId: UrlEncodedCommentId,
-  path: string
-) {
-  fire(target, EventType.THREAD_LIST_MODIFIED, {
-    rootId,
-    path,
-  });
-}
-
 export function fireShowPrimaryTab(
   target: EventTarget,
   tab: string,
diff --git a/polygerrit-ui/app/utils/patch-set-util_test.js b/polygerrit-ui/app/utils/patch-set-util_test.js
deleted file mode 100644
index 93073fa..0000000
--- a/polygerrit-ui/app/utils/patch-set-util_test.js
+++ /dev/null
@@ -1,211 +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 {
-  _testOnly_computeWipForPatchSets, computeAllPatchSets,
-  findEditParentPatchNum, findEditParentRevision,
-  getParentIndex, getRevisionByPatchNum,
-  isMergeParent,
-  sortRevisions,
-} from './patch-set-util.js';
-
-suite('gr-patch-set-util tests', () => {
-  test('getRevisionByPatchNum', () => {
-    const revisions = [
-      {_number: 0},
-      {_number: 1},
-      {_number: 2},
-    ];
-    assert.deepEqual(getRevisionByPatchNum(revisions, 1), revisions[1]);
-    assert.deepEqual(getRevisionByPatchNum(revisions, 2), revisions[2]);
-    assert.equal(getRevisionByPatchNum(revisions, 3), undefined);
-  });
-
-  test('_computeWipForPatchSets', () => {
-    // Compute patch sets for a given timeline on a change. The initial WIP
-    // property of the change can be true or false. The map of tags by
-    // revision is keyed by patch set number. Each value is a list of change
-    // message tags in the order that they occurred in the timeline. These
-    // indicate actions that modify the WIP property of the change and/or
-    // create new patch sets.
-    //
-    // Returns the actual results with an assertWip method that can be used
-    // to compare against an expected value for a particular patch set.
-    const compute = (initialWip, tagsByRevision) => {
-      const change = {
-        messages: [],
-        work_in_progress: initialWip,
-      };
-      const revs = Object.keys(tagsByRevision).sort((a, b) => a - b);
-      for (const rev of revs) {
-        for (const tag of tagsByRevision[rev]) {
-          change.messages.push({
-            tag,
-            _revision_number: rev,
-          });
-        }
-      }
-      let patchNums = revs.map(rev => { return {num: rev}; });
-      patchNums = _testOnly_computeWipForPatchSets(
-          change, patchNums);
-      const actualWipsByRevision = {};
-      for (const patchNum of patchNums) {
-        actualWipsByRevision[patchNum.num] = patchNum.wip;
-      }
-      const verifier = {
-        assertWip(revision, expectedWip) {
-          const patchNum = patchNums.find(patchNum => patchNum.num == revision);
-          if (!patchNum) {
-            assert.fail('revision ' + revision + ' not found');
-          }
-          assert.equal(patchNum.wip, expectedWip,
-              'wip state for ' + revision + ' is ' +
-            patchNum.wip + '; expected ' + expectedWip);
-          return verifier;
-        },
-      };
-      return verifier;
-    };
-
-    compute(false, {1: ['upload']}).assertWip(1, false);
-    compute(true, {1: ['upload']}).assertWip(1, true);
-
-    const setWip = 'autogenerated:gerrit:setWorkInProgress';
-    const uploadInWip = 'autogenerated:gerrit:newWipPatchSet';
-    const clearWip = 'autogenerated:gerrit:setReadyForReview';
-
-    compute(false, {
-      1: ['upload', setWip],
-      2: ['upload'],
-      3: ['upload', clearWip],
-      4: ['upload', setWip],
-    }).assertWip(1, false) // Change was created with PS1 ready for review
-        .assertWip(2, true) // PS2 was uploaded during WIP
-        .assertWip(3, false) // PS3 was marked ready for review after upload
-        .assertWip(4, false); // PS4 was uploaded ready for review
-
-    compute(false, {
-      1: [uploadInWip, null, 'addReviewer'],
-      2: ['upload'],
-      3: ['upload', clearWip, setWip],
-      4: ['upload'],
-      5: ['upload', clearWip],
-      6: [uploadInWip],
-    }).assertWip(1, true) // Change was created in WIP
-        .assertWip(2, true) // PS2 was uploaded during WIP
-        .assertWip(3, false) // PS3 was marked ready for review
-        .assertWip(4, true) // PS4 was uploaded during WIP
-        .assertWip(5, false) // PS5 was marked ready for review
-        .assertWip(6, true); // PS6 was uploaded with WIP option
-  });
-
-  test('isMergeParent', () => {
-    assert.isFalse(isMergeParent(1));
-    assert.isFalse(isMergeParent(4321));
-    assert.isFalse(isMergeParent('52'));
-    assert.isFalse(isMergeParent('edit'));
-    assert.isFalse(isMergeParent('PARENT'));
-    assert.isFalse(isMergeParent(0));
-
-    assert.isTrue(isMergeParent(-23));
-    assert.isTrue(isMergeParent(-1));
-    assert.isTrue(isMergeParent('-42'));
-  });
-
-  test('findEditParentRevision', () => {
-    let revisions = [
-      {_number: 0},
-      {_number: 1},
-      {_number: 2},
-    ];
-    assert.strictEqual(findEditParentRevision(revisions), null);
-
-    revisions = [...revisions, {_number: 'edit', basePatchNum: 3}];
-    assert.strictEqual(findEditParentRevision(revisions), null);
-
-    revisions = [...revisions, {_number: 3}];
-    assert.deepEqual(findEditParentRevision(revisions), {_number: 3});
-  });
-
-  test('findEditParentPatchNum', () => {
-    let revisions = [
-      {_number: 0},
-      {_number: 1},
-      {_number: 2},
-    ];
-    assert.equal(findEditParentPatchNum(revisions), -1);
-
-    revisions =
-        [...revisions, {_number: 'edit', basePatchNum: 3}, {_number: 3}];
-    assert.deepEqual(findEditParentPatchNum(revisions), 3);
-  });
-
-  test('sortRevisions', () => {
-    const revisions = [
-      {_number: 0},
-      {_number: 2},
-      {_number: 1},
-    ];
-    const sorted = [
-      {_number: 2},
-      {_number: 1},
-      {_number: 0},
-    ];
-
-    assert.deepEqual(sortRevisions(revisions), sorted);
-
-    // Edit patchset should follow directly after its basePatchNum.
-    revisions.push({_number: 'edit', basePatchNum: 2});
-    sorted.unshift({_number: 'edit', basePatchNum: 2});
-    assert.deepEqual(sortRevisions(revisions), sorted);
-
-    revisions[0].basePatchNum = 0;
-    const edit = sorted.shift();
-    edit.basePatchNum = 0;
-    // Edit patchset should be at index 2.
-    sorted.splice(2, 0, edit);
-    assert.deepEqual(sortRevisions(revisions), sorted);
-  });
-
-  test('getParentIndex', () => {
-    assert.equal(getParentIndex('-13'), 13);
-    assert.equal(getParentIndex(-4), 4);
-  });
-
-  test('computeAllPatchSets', () => {
-    const expected = [
-      {num: 4, desc: 'test', sha: 'rev4'},
-      {num: 3, desc: 'test', sha: 'rev3'},
-      {num: 2, desc: 'test', sha: 'rev2'},
-      {num: 1, desc: 'test', sha: 'rev1'},
-    ];
-    const patchNums = computeAllPatchSets({
-      revisions: {
-        rev3: {_number: 3, description: 'test', date: 3},
-        rev1: {_number: 1, description: 'test', date: 1},
-        rev4: {_number: 4, description: 'test', date: 4},
-        rev2: {_number: 2, description: 'test', date: 2},
-      },
-    });
-    assert.equal(patchNums.length, expected.length);
-    for (let i = 0; i < expected.length; i++) {
-      assert.deepEqual(patchNums[i], expected[i]);
-    }
-  });
-});
-
diff --git a/polygerrit-ui/app/utils/patch-set-util_test.ts b/polygerrit-ui/app/utils/patch-set-util_test.ts
new file mode 100644
index 0000000..a9d9549
--- /dev/null
+++ b/polygerrit-ui/app/utils/patch-set-util_test.ts
@@ -0,0 +1,246 @@
+/**
+ * @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,
+  createChangeMessageInfo,
+  createRevision,
+} from '../test/test-data-generators';
+import {
+  BasePatchSetNum,
+  ChangeInfo,
+  EditPatchSetNum,
+  PatchSetNum,
+  ReviewInputTag,
+} from '../types/common';
+import {
+  _testOnly_computeWipForPatchSets,
+  computeAllPatchSets,
+  findEditParentPatchNum,
+  findEditParentRevision,
+  getParentIndex,
+  getRevisionByPatchNum,
+  isMergeParent,
+  sortRevisions,
+} from './patch-set-util';
+
+suite('gr-patch-set-util tests', () => {
+  test('getRevisionByPatchNum', () => {
+    const revisions = [createRevision(0), createRevision(1), createRevision(2)];
+    assert.deepEqual(
+      getRevisionByPatchNum(revisions, 1 as PatchSetNum),
+      revisions[1]
+    );
+    assert.deepEqual(
+      getRevisionByPatchNum(revisions, 2 as PatchSetNum),
+      revisions[2]
+    );
+    assert.equal(getRevisionByPatchNum(revisions, 3 as PatchSetNum), undefined);
+  });
+
+  test('_computeWipForPatchSets', () => {
+    // Compute patch sets for a given timeline on a change. The initial WIP
+    // property of the change can be true or false. The map of tags by
+    // revision is keyed by patch set number. Each value is a list of change
+    // message tags in the order that they occurred in the timeline. These
+    // indicate actions that modify the WIP property of the change and/or
+    // create new patch sets.
+    //
+    // Returns the actual results with an assertWip method that can be used
+    // to compare against an expected value for a particular patch set.
+    const compute = (
+      initialWip: boolean,
+      tagsByRevision: Map<
+        number | 'edit' | 'PARENT',
+        (ReviewInputTag | undefined)[]
+      >
+    ) => {
+      const change: ChangeInfo = {
+        ...createChange(),
+        messages: [],
+        work_in_progress: initialWip,
+      };
+      for (const rev of tagsByRevision.keys()) {
+        for (const tag of tagsByRevision.get(rev)!) {
+          change.messages!.push({
+            ...createChangeMessageInfo(),
+            tag,
+            _revision_number: rev as PatchSetNum,
+          });
+        }
+      }
+      const patchSets = Array.from(tagsByRevision.keys()).map(rev => {
+        return {num: rev as PatchSetNum, desc: 'test', sha: `rev${rev}`};
+      });
+      const patchNums = _testOnly_computeWipForPatchSets(change, patchSets);
+      const verifier = {
+        assertWip(revision: number, expectedWip: boolean) {
+          const patchNum = patchNums.find(
+            patchNum => patchNum.num === (revision as PatchSetNum)
+          );
+          if (!patchNum) {
+            assert.fail(`revision ${revision} not found`);
+          }
+          assert.equal(
+            patchNum.wip,
+            expectedWip,
+            `wip state for ${revision} ` +
+              `is ${patchNum.wip}; expected ${expectedWip}`
+          );
+          return verifier;
+        },
+      };
+      return verifier;
+    };
+
+    const upload = 'upload' as ReviewInputTag;
+
+    compute(false, new Map([[1, [upload]]])).assertWip(1, false);
+    compute(true, new Map([[1, [upload]]])).assertWip(1, true);
+
+    const setWip = 'autogenerated:gerrit:setWorkInProgress' as ReviewInputTag;
+    const uploadInWip = 'autogenerated:gerrit:newWipPatchSet' as ReviewInputTag;
+    const clearWip = 'autogenerated:gerrit:setReadyForReview' as ReviewInputTag;
+
+    compute(
+      false,
+      new Map([
+        [1, [upload, setWip]],
+        [2, [upload]],
+        [3, [upload, clearWip]],
+        [4, [upload, setWip]],
+      ])
+    )
+      .assertWip(1, false) // Change was created with PS1 ready for review
+      .assertWip(2, true) // PS2 was uploaded during WIP
+      .assertWip(3, false) // PS3 was marked ready for review after upload
+      .assertWip(4, false); // PS4 was uploaded ready for review
+
+    compute(
+      false,
+      new Map([
+        [1, [uploadInWip, undefined, 'addReviewer' as ReviewInputTag]],
+        [2, [upload]],
+        [3, [upload, clearWip, setWip]],
+        [4, [upload]],
+        [5, [upload, clearWip]],
+        [6, [uploadInWip]],
+      ])
+    )
+      .assertWip(1, true) // Change was created in WIP
+      .assertWip(2, true) // PS2 was uploaded during WIP
+      .assertWip(3, false) // PS3 was marked ready for review
+      .assertWip(4, true) // PS4 was uploaded during WIP
+      .assertWip(5, false) // PS5 was marked ready for review
+      .assertWip(6, true); // PS6 was uploaded with WIP option
+  });
+
+  test('isMergeParent', () => {
+    assert.isFalse(isMergeParent(1 as PatchSetNum));
+    assert.isFalse(isMergeParent(4321 as PatchSetNum));
+    assert.isFalse(isMergeParent('edit' as PatchSetNum));
+    assert.isFalse(isMergeParent('PARENT' as PatchSetNum));
+    assert.isFalse(isMergeParent(0 as PatchSetNum));
+
+    assert.isTrue(isMergeParent(-23 as PatchSetNum));
+    assert.isTrue(isMergeParent(-1 as PatchSetNum));
+  });
+
+  test('findEditParentRevision', () => {
+    const revisions = [createRevision(0), createRevision(1), createRevision(2)];
+    assert.strictEqual(findEditParentRevision(revisions), null);
+
+    revisions.push({
+      ...createRevision(),
+      _number: EditPatchSetNum,
+      basePatchNum: 3 as BasePatchSetNum,
+    });
+    assert.strictEqual(findEditParentRevision(revisions), null);
+
+    revisions.push(createRevision(3));
+    assert.deepEqual(findEditParentRevision(revisions), createRevision(3));
+  });
+
+  test('findEditParentPatchNum', () => {
+    const revisions = [createRevision(0), createRevision(1), createRevision(2)];
+    assert.equal(findEditParentPatchNum(revisions), -1);
+
+    revisions.push(
+      {
+        ...createRevision(),
+        _number: EditPatchSetNum,
+        basePatchNum: 3 as BasePatchSetNum,
+      },
+      createRevision(3)
+    );
+    assert.deepEqual(findEditParentPatchNum(revisions), 3);
+  });
+
+  test('sortRevisions', () => {
+    const revisions = [createRevision(0), createRevision(2), createRevision(1)];
+    const sorted = [createRevision(2), createRevision(1), createRevision(0)];
+
+    assert.deepEqual(sortRevisions(revisions), sorted);
+
+    // Edit patchset should follow directly after its basePatchNum.
+    revisions.push({
+      ...createRevision(),
+      _number: EditPatchSetNum,
+      basePatchNum: 2 as BasePatchSetNum,
+    });
+    sorted.unshift({
+      ...createRevision(),
+      _number: EditPatchSetNum,
+      basePatchNum: 2 as BasePatchSetNum,
+    });
+    assert.deepEqual(sortRevisions(revisions), sorted);
+
+    revisions[0].basePatchNum = 0 as BasePatchSetNum;
+    const edit = sorted.shift()!;
+    edit.basePatchNum = 0 as BasePatchSetNum;
+    // Edit patchset should be at index 2.
+    sorted.splice(2, 0, edit);
+    assert.deepEqual(sortRevisions(revisions), sorted);
+  });
+
+  test('getParentIndex', () => {
+    assert.equal(getParentIndex(-4 as PatchSetNum), 4);
+  });
+
+  test('computeAllPatchSets', () => {
+    const expected = [
+      {num: 4 as PatchSetNum, desc: 'test', sha: 'rev4'},
+      {num: 3 as PatchSetNum, desc: 'test', sha: 'rev3'},
+      {num: 2 as PatchSetNum, desc: 'test', sha: 'rev2'},
+      {num: 1 as PatchSetNum, desc: 'test', sha: 'rev1'},
+    ];
+    const patchNums = computeAllPatchSets({
+      ...createChange(),
+      revisions: {
+        rev1: {...createRevision(1), description: 'test'},
+        rev2: {...createRevision(2), description: 'test'},
+        rev3: {...createRevision(3), description: 'test'},
+        rev4: {...createRevision(4), description: 'test'},
+      },
+    });
+    assert.equal(patchNums.length, expected.length);
+    for (let i = 0; i < expected.length; i++) {
+      assert.deepEqual(patchNums[i], expected[i]);
+    }
+  });
+});
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');
+  });
 });
diff --git a/polygerrit-ui/app/utils/url-util.ts b/polygerrit-ui/app/utils/url-util.ts
index 4115062..de6462f 100644
--- a/polygerrit-ui/app/utils/url-util.ts
+++ b/polygerrit-ui/app/utils/url-util.ts
@@ -108,3 +108,10 @@
   const middle = paramString ? '?' : '';
   return pathname + middle + paramString;
 }
+
+/**
+ * Primary use case is to copy the absolute comments url to clipboard.
+ */
+export function generateAbsoluteUrl(url: string) {
+  return new URL(url, window.location.href).toString();
+}
diff --git a/polygerrit-ui/app/yarn.lock b/polygerrit-ui/app/yarn.lock
index fed42a4..4fb98dd 100644
--- a/polygerrit-ui/app/yarn.lock
+++ b/polygerrit-ui/app/yarn.lock
@@ -2,6 +2,21 @@
 # yarn lockfile v1
 
 
+"@mapbox/node-pre-gyp@^1.0.0":
+  version "1.0.5"
+  resolved "https://registry.yarnpkg.com/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.5.tgz#2a0b32fcb416fb3f2250fd24cb2a81421a4f5950"
+  integrity sha512-4srsKPXWlIxp5Vbqz5uLfBN+du2fJChBoYn/f2h991WLdk7jUvcSk/McVLSv/X+xQIPI8eGD5GjrnygdyHnhPA==
+  dependencies:
+    detect-libc "^1.0.3"
+    https-proxy-agent "^5.0.0"
+    make-dir "^3.1.0"
+    node-fetch "^2.6.1"
+    nopt "^5.0.0"
+    npmlog "^4.1.2"
+    rimraf "^3.0.2"
+    semver "^7.3.4"
+    tar "^6.1.0"
+
 "@polymer/decorators@^3.0.0":
   version "3.0.0"
   resolved "https://registry.yarnpkg.com/@polymer/decorators/-/decorators-3.0.0.tgz#e4212ac976d9abd1210f560b6e1be4165c1c0183"
@@ -395,6 +410,11 @@
   dependencies:
     "@webcomponents/shadycss" "^1.9.1"
 
+"@types/resemblejs@^3.2.0":
+  version "3.2.0"
+  resolved "https://registry.yarnpkg.com/@types/resemblejs/-/resemblejs-3.2.0.tgz#a2093fd6ae027d39b56ae279f362a4d83e00788f"
+  integrity sha512-YUBCCipw3DG0/FUswHAiamZcs+JBZlRr1aNs1T19AkfLZNtzV4VphmRLy6wJ3m1i9QxIfiBe3RnzVjHbjRqLaA==
+
 "@types/resize-observer-browser@^0.1.5":
   version "0.1.5"
   resolved "https://registry.yarnpkg.com/@types/resize-observer-browser/-/resize-observer-browser-0.1.5.tgz#36d897708172ac2380cd486da7a3daf1161c1e23"
@@ -415,19 +435,211 @@
   resolved "https://registry.yarnpkg.com/@webcomponents/webcomponentsjs/-/webcomponentsjs-2.5.0.tgz#61b27785a6ad5bfd68fa018201fe418b118cb38d"
   integrity sha512-C0l51MWQZ9kLzcxOZtniOMohpIFdCLZum7/TEHv3XWFc1Fvt5HCpbSX84x8ltka/JuNKcuiDnxXFkiB2gaePcg==
 
+abbrev@1:
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-1.1.1.tgz#f8f2c887ad10bf67f634f005b6987fed3179aac8"
+  integrity sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==
+
+agent-base@6:
+  version "6.0.2"
+  resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-6.0.2.tgz#49fff58577cfee3f37176feab4c22e00f86d7f77"
+  integrity sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==
+  dependencies:
+    debug "4"
+
+ansi-regex@^2.0.0:
+  version "2.1.1"
+  resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-2.1.1.tgz#c3b33ab5ee360d86e0e628f0468ae7ef27d654df"
+  integrity sha1-w7M6te42DYbg5ijwRorn7yfWVN8=
+
+ansi-regex@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-3.0.0.tgz#ed0317c322064f79466c02966bddb605ab37d998"
+  integrity sha1-7QMXwyIGT3lGbAKWa922Bas32Zg=
+
+aproba@^1.0.3:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/aproba/-/aproba-1.2.0.tgz#6802e6264efd18c790a1b0d517f0f2627bf2c94a"
+  integrity sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw==
+
+are-we-there-yet@~1.1.2:
+  version "1.1.5"
+  resolved "https://registry.yarnpkg.com/are-we-there-yet/-/are-we-there-yet-1.1.5.tgz#4b35c2944f062a8bfcda66410760350fe9ddfc21"
+  integrity sha512-5hYdAkZlcG8tOLujVDTgCT+uPX0VnpAH28gWsLfzpXYm7wP6mp5Q/gYyR7YQ0cKVJcXJnl3j2kpBan13PtQf6w==
+  dependencies:
+    delegates "^1.0.0"
+    readable-stream "^2.0.6"
+
 "ba-linkify@file:../../lib/ba-linkify/src":
   version "1.0.0"
 
-codemirror-minified@^5.60.0:
-  version "5.60.0"
-  resolved "https://registry.yarnpkg.com/codemirror-minified/-/codemirror-minified-5.60.0.tgz#d0d8b62ab6d50864903f812cd203b97193b1fb75"
-  integrity sha512-Ru9aChh07DwYrUEfI+LznD3l8GxOPlYKAqcG8qxIlxRZTyw4BPfZsV5m1oUz1y6knxaPxa23FwM/R5rWggRnKg==
+balanced-match@^1.0.0:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee"
+  integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==
+
+brace-expansion@^1.1.7:
+  version "1.1.11"
+  resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd"
+  integrity sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==
+  dependencies:
+    balanced-match "^1.0.0"
+    concat-map "0.0.1"
+
+canvas@2.8.0:
+  version "2.8.0"
+  resolved "https://registry.yarnpkg.com/canvas/-/canvas-2.8.0.tgz#f99ca7f25e6e26686661ffa4fec1239bbef74461"
+  integrity sha512-gLTi17X8WY9Cf5GZ2Yns8T5lfBOcGgFehDFb+JQwDqdOoBOcECS9ZWMEAqMSVcMYwXD659J8NyzjRY/2aE+C2Q==
+  dependencies:
+    "@mapbox/node-pre-gyp" "^1.0.0"
+    nan "^2.14.0"
+    simple-get "^3.0.3"
+
+chownr@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/chownr/-/chownr-2.0.0.tgz#15bfbe53d2eab4cf70f18a8cd68ebe5b3cb1dece"
+  integrity sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==
+
+code-point-at@^1.0.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/code-point-at/-/code-point-at-1.1.0.tgz#0d070b4d043a5bea33a2f1a40e2edb3d9a4ccf77"
+  integrity sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c=
+
+codemirror-minified@^5.62.0:
+  version "5.62.0"
+  resolved "https://registry.yarnpkg.com/codemirror-minified/-/codemirror-minified-5.62.0.tgz#1d5bc5fc2c2baddebe54afefc90371d462be5f05"
+  integrity sha512-p4ALY/Lz5y4ftS5Q34rCBwLcupeATA5h4nBP2CZQgMWr+kQGnVDJxOCtC5KAYNk6Yo0jyKBvrsvr0ZxzuEuDow==
+
+concat-map@0.0.1:
+  version "0.0.1"
+  resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b"
+  integrity sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=
+
+console-control-strings@^1.0.0, console-control-strings@~1.1.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/console-control-strings/-/console-control-strings-1.1.0.tgz#3d7cf4464db6446ea644bf4b39507f9851008e8e"
+  integrity sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4=
+
+core-util-is@~1.0.0:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7"
+  integrity sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=
+
+debug@4:
+  version "4.3.1"
+  resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.1.tgz#f0d229c505e0c6d8c49ac553d1b13dc183f6b2ee"
+  integrity sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ==
+  dependencies:
+    ms "2.1.2"
+
+decompress-response@^4.2.0:
+  version "4.2.1"
+  resolved "https://registry.yarnpkg.com/decompress-response/-/decompress-response-4.2.1.tgz#414023cc7a302da25ce2ec82d0d5238ccafd8986"
+  integrity sha512-jOSne2qbyE+/r8G1VU+G/82LBs2Fs4LAsTiLSHOCOMZQl2OKZ6i8i4IyHemTe+/yIXOtTcRQMzPcgyhoFlqPkw==
+  dependencies:
+    mimic-response "^2.0.0"
+
+delegates@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/delegates/-/delegates-1.0.0.tgz#84c6e159b81904fdca59a0ef44cd870d31250f9a"
+  integrity sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o=
+
+detect-libc@^1.0.3:
+  version "1.0.3"
+  resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-1.0.3.tgz#fa137c4bd698edf55cd5cd02ac559f91a4c4ba9b"
+  integrity sha1-+hN8S9aY7fVc1c0CrFWfkaTEups=
+
+fs-minipass@^2.0.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/fs-minipass/-/fs-minipass-2.1.0.tgz#7f5036fdbf12c63c169190cbe4199c852271f9fb"
+  integrity sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==
+  dependencies:
+    minipass "^3.0.0"
+
+fs.realpath@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f"
+  integrity sha1-FQStJSMVjKpA20onh8sBQRmU6k8=
+
+gauge@~2.7.3:
+  version "2.7.4"
+  resolved "https://registry.yarnpkg.com/gauge/-/gauge-2.7.4.tgz#2c03405c7538c39d7eb37b317022e325fb018bf7"
+  integrity sha1-LANAXHU4w51+s3sxcCLjJfsBi/c=
+  dependencies:
+    aproba "^1.0.3"
+    console-control-strings "^1.0.0"
+    has-unicode "^2.0.0"
+    object-assign "^4.1.0"
+    signal-exit "^3.0.0"
+    string-width "^1.0.1"
+    strip-ansi "^3.0.1"
+    wide-align "^1.1.0"
+
+glob@^7.1.3:
+  version "7.1.7"
+  resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.7.tgz#3b193e9233f01d42d0b3f78294bbeeb418f94a90"
+  integrity sha512-OvD9ENzPLbegENnYP5UUfJIirTg4+XwMWGaQfQTY0JenxNvvIKP3U3/tAQSPIu/lHxXYSZmpXlUHeqAIdKzBLQ==
+  dependencies:
+    fs.realpath "^1.0.0"
+    inflight "^1.0.4"
+    inherits "2"
+    minimatch "^3.0.4"
+    once "^1.3.0"
+    path-is-absolute "^1.0.0"
+
+has-unicode@^2.0.0:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/has-unicode/-/has-unicode-2.0.1.tgz#e0e6fe6a28cf51138855e086d1691e771de2a8b9"
+  integrity sha1-4Ob+aijPUROIVeCG0Wkedx3iqLk=
+
+https-proxy-agent@^5.0.0:
+  version "5.0.0"
+  resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-5.0.0.tgz#e2a90542abb68a762e0a0850f6c9edadfd8506b2"
+  integrity sha512-EkYm5BcKUGiduxzSt3Eppko+PiNWNEpa4ySk9vTC6wDsQJW9rHSa+UhGNJoRYp7bz6Ht1eaRIa6QaJqO5rCFbA==
+  dependencies:
+    agent-base "6"
+    debug "4"
+
+immer@^9.0.5:
+  version "9.0.5"
+  resolved "https://registry.yarnpkg.com/immer/-/immer-9.0.5.tgz#a7154f34fe7064f15f00554cc94c66cc0bf453ec"
+  integrity sha512-2WuIehr2y4lmYz9gaQzetPR2ECniCifk4ORaQbU3g5EalLt+0IVTosEPJ5BoYl/75ky2mivzdRzV8wWgQGOSYQ==
+
+inflight@^1.0.4:
+  version "1.0.6"
+  resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9"
+  integrity sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=
+  dependencies:
+    once "^1.3.0"
+    wrappy "1"
+
+inherits@2, inherits@~2.0.3:
+  version "2.0.4"
+  resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c"
+  integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==
+
+is-fullwidth-code-point@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz#ef9e31386f031a7f0d643af82fde50c457ef00cb"
+  integrity sha1-754xOG8DGn8NZDr4L95QxFfvAMs=
+  dependencies:
+    number-is-nan "^1.0.0"
+
+is-fullwidth-code-point@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz#a3b30a5c4f199183167aaab93beefae3ddfb654f"
+  integrity sha1-o7MKXE8ZkYMWeqq5O+764937ZU8=
 
 isarray@0.0.1:
   version "0.0.1"
   resolved "https://registry.yarnpkg.com/isarray/-/isarray-0.0.1.tgz#8a18acfca9a8f4177e09abfc6038939b05d1eedf"
   integrity sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8=
 
+isarray@~1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11"
+  integrity sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=
+
 lit-element@^2.5.1:
   version "2.5.1"
   resolved "https://registry.yarnpkg.com/lit-element/-/lit-element-2.5.1.tgz#3fa74b121a6cd22902409ae3859b7847d01aa6b6"
@@ -440,6 +652,101 @@
   resolved "https://registry.yarnpkg.com/lit-html/-/lit-html-1.4.1.tgz#0c6f3ee4ad4eb610a49831787f0478ad8e9ae5e0"
   integrity sha512-B9btcSgPYb1q4oSOb/PrOT6Z/H+r6xuNzfH4lFli/AWhYwdtrgQkQWBbIc6mdnf6E2IL3gDXdkkqNktpU0OZQA==
 
+lru-cache@^6.0.0:
+  version "6.0.0"
+  resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-6.0.0.tgz#6d6fe6570ebd96aaf90fcad1dafa3b2566db3a94"
+  integrity sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==
+  dependencies:
+    yallist "^4.0.0"
+
+make-dir@^3.1.0:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-3.1.0.tgz#415e967046b3a7f1d185277d84aa58203726a13f"
+  integrity sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==
+  dependencies:
+    semver "^6.0.0"
+
+mimic-response@^2.0.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/mimic-response/-/mimic-response-2.1.0.tgz#d13763d35f613d09ec37ebb30bac0469c0ee8f43"
+  integrity sha512-wXqjST+SLt7R009ySCglWBCFpjUygmCIfD790/kVbiGmUgfYGuB14PiTd5DwVxSV4NcYHjzMkoj5LjQZwTQLEA==
+
+minimatch@^3.0.4:
+  version "3.0.4"
+  resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.4.tgz#5166e286457f03306064be5497e8dbb0c3d32083"
+  integrity sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==
+  dependencies:
+    brace-expansion "^1.1.7"
+
+minipass@^3.0.0:
+  version "3.1.3"
+  resolved "https://registry.yarnpkg.com/minipass/-/minipass-3.1.3.tgz#7d42ff1f39635482e15f9cdb53184deebd5815fd"
+  integrity sha512-Mgd2GdMVzY+x3IJ+oHnVM+KG3lA5c8tnabyJKmHSaG2kAGpudxuOf8ToDkhumF7UzME7DecbQE9uOZhNm7PuJg==
+  dependencies:
+    yallist "^4.0.0"
+
+minizlib@^2.1.1:
+  version "2.1.2"
+  resolved "https://registry.yarnpkg.com/minizlib/-/minizlib-2.1.2.tgz#e90d3466ba209b932451508a11ce3d3632145931"
+  integrity sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==
+  dependencies:
+    minipass "^3.0.0"
+    yallist "^4.0.0"
+
+mkdirp@^1.0.3:
+  version "1.0.4"
+  resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.4.tgz#3eb5ed62622756d79a5f0e2a221dfebad75c2f7e"
+  integrity sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==
+
+ms@2.1.2:
+  version "2.1.2"
+  resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009"
+  integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==
+
+nan@^2.14.0:
+  version "2.14.2"
+  resolved "https://registry.yarnpkg.com/nan/-/nan-2.14.2.tgz#f5376400695168f4cc694ac9393d0c9585eeea19"
+  integrity sha512-M2ufzIiINKCuDfBSAUr1vWQ+vuVcA9kqx8JJUsbQi6yf1uGRyb7HfpdfUr5qLXf3B/t8dPvcjhKMmlfnP47EzQ==
+
+node-fetch@^2.6.1:
+  version "2.6.1"
+  resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.1.tgz#045bd323631f76ed2e2b55573394416b639a0052"
+  integrity sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw==
+
+nopt@^5.0.0:
+  version "5.0.0"
+  resolved "https://registry.yarnpkg.com/nopt/-/nopt-5.0.0.tgz#530942bb58a512fccafe53fe210f13a25355dc88"
+  integrity sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==
+  dependencies:
+    abbrev "1"
+
+npmlog@^4.1.2:
+  version "4.1.2"
+  resolved "https://registry.yarnpkg.com/npmlog/-/npmlog-4.1.2.tgz#08a7f2a8bf734604779a9efa4ad5cc717abb954b"
+  integrity sha512-2uUqazuKlTaSI/dC8AzicUck7+IrEaOnN/e0jd3Xtt1KcGpwx30v50mL7oPyr/h9bL3E4aZccVwpwP+5W9Vjkg==
+  dependencies:
+    are-we-there-yet "~1.1.2"
+    console-control-strings "~1.1.0"
+    gauge "~2.7.3"
+    set-blocking "~2.0.0"
+
+number-is-nan@^1.0.0:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/number-is-nan/-/number-is-nan-1.0.1.tgz#097b602b53422a522c1afb8790318336941a011d"
+  integrity sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0=
+
+object-assign@^4.1.0:
+  version "4.1.1"
+  resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863"
+  integrity sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=
+
+once@^1.3.0, once@^1.3.1:
+  version "1.4.0"
+  resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1"
+  integrity sha1-WDsap3WWHUsROsF9nFC6753Xa9E=
+  dependencies:
+    wrappy "1"
+
 page@^1.11.6:
   version "1.11.6"
   resolved "https://registry.yarnpkg.com/page/-/page-1.11.6.tgz#5ef4efc7073749b8085ccdaa0dcd7c9e0de12fe3"
@@ -447,6 +754,11 @@
   dependencies:
     path-to-regexp "~1.2.1"
 
+path-is-absolute@^1.0.0:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f"
+  integrity sha1-F0uSaHNVNP+8es5r9TpanhtcX18=
+
 path-to-regexp@~1.2.1:
   version "1.2.1"
   resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-1.2.1.tgz#b33705c140234d873c8721c7b9fd8b541ed3aff9"
@@ -465,6 +777,38 @@
     "@polymer/polymer" "^3.0.2"
     "@webcomponents/webcomponentsjs" "^2.0.3"
 
+process-nextick-args@~2.0.0:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2"
+  integrity sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==
+
+readable-stream@^2.0.6:
+  version "2.3.7"
+  resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.7.tgz#1eca1cf711aef814c04f62252a36a62f6cb23b57"
+  integrity sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==
+  dependencies:
+    core-util-is "~1.0.0"
+    inherits "~2.0.3"
+    isarray "~1.0.0"
+    process-nextick-args "~2.0.0"
+    safe-buffer "~5.1.1"
+    string_decoder "~1.1.1"
+    util-deprecate "~1.0.1"
+
+resemblejs@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/resemblejs/-/resemblejs-4.0.0.tgz#5382f0484430d826ed293433833b9fc4e06e5496"
+  integrity sha512-vaGs/hFVx/941+RS4UJtd8DQvx5RuB61tPLOQCxPso3JpmjfDb6odH5HViT17S0d8DaZsexD01nRJI12giCz/A==
+  optionalDependencies:
+    canvas "2.8.0"
+
+rimraf@^3.0.2:
+  version "3.0.2"
+  resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-3.0.2.tgz#f1a5402ba6220ad52cc1282bac1ae3aa49fd061a"
+  integrity sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==
+  dependencies:
+    glob "^7.1.3"
+
 rxjs@^6.6.7:
   version "6.6.7"
   resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-6.6.7.tgz#90ac018acabf491bf65044235d5863c4dab804c9"
@@ -472,7 +816,120 @@
   dependencies:
     tslib "^1.9.0"
 
+safe-buffer@~5.1.0, safe-buffer@~5.1.1:
+  version "5.1.2"
+  resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d"
+  integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==
+
+semver@^6.0.0:
+  version "6.3.0"
+  resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.0.tgz#ee0a64c8af5e8ceea67687b133761e1becbd1d3d"
+  integrity sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==
+
+semver@^7.3.4:
+  version "7.3.5"
+  resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.5.tgz#0b621c879348d8998e4b0e4be94b3f12e6018ef7"
+  integrity sha512-PoeGJYh8HK4BTO/a9Tf6ZG3veo/A7ZVsYrSA6J8ny9nb3B1VrpkuN+z9OE5wfE5p6H4LchYZsegiQgbJD94ZFQ==
+  dependencies:
+    lru-cache "^6.0.0"
+
+set-blocking@~2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7"
+  integrity sha1-BF+XgtARrppoA93TgrJDkrPYkPc=
+
+signal-exit@^3.0.0:
+  version "3.0.3"
+  resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.3.tgz#a1410c2edd8f077b08b4e253c8eacfcaf057461c"
+  integrity sha512-VUJ49FC8U1OxwZLxIbTTrDvLnf/6TDgxZcK8wxR8zs13xpx7xbG60ndBlhNrFi2EMuFRoeDoJO7wthSLq42EjA==
+
+simple-concat@^1.0.0:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/simple-concat/-/simple-concat-1.0.1.tgz#f46976082ba35c2263f1c8ab5edfe26c41c9552f"
+  integrity sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==
+
+simple-get@^3.0.3:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/simple-get/-/simple-get-3.1.0.tgz#b45be062435e50d159540b576202ceec40b9c6b3"
+  integrity sha512-bCR6cP+aTdScaQCnQKbPKtJOKDp/hj9EDLJo3Nw4y1QksqaovlW/bnptB6/c1e+qmNIDHRK+oXFDdEqBT8WzUA==
+  dependencies:
+    decompress-response "^4.2.0"
+    once "^1.3.1"
+    simple-concat "^1.0.0"
+
+string-width@^1.0.1:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/string-width/-/string-width-1.0.2.tgz#118bdf5b8cdc51a2a7e70d211e07e2b0b9b107d3"
+  integrity sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M=
+  dependencies:
+    code-point-at "^1.0.0"
+    is-fullwidth-code-point "^1.0.0"
+    strip-ansi "^3.0.0"
+
+"string-width@^1.0.2 || 2":
+  version "2.1.1"
+  resolved "https://registry.yarnpkg.com/string-width/-/string-width-2.1.1.tgz#ab93f27a8dc13d28cac815c462143a6d9012ae9e"
+  integrity sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw==
+  dependencies:
+    is-fullwidth-code-point "^2.0.0"
+    strip-ansi "^4.0.0"
+
+string_decoder@~1.1.1:
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.1.1.tgz#9cf1611ba62685d7030ae9e4ba34149c3af03fc8"
+  integrity sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==
+  dependencies:
+    safe-buffer "~5.1.0"
+
+strip-ansi@^3.0.0, strip-ansi@^3.0.1:
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-3.0.1.tgz#6a385fb8853d952d5ff05d0e8aaf94278dc63dcf"
+  integrity sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=
+  dependencies:
+    ansi-regex "^2.0.0"
+
+strip-ansi@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-4.0.0.tgz#a8479022eb1ac368a871389b635262c505ee368f"
+  integrity sha1-qEeQIusaw2iocTibY1JixQXuNo8=
+  dependencies:
+    ansi-regex "^3.0.0"
+
+tar@^6.1.0:
+  version "6.1.0"
+  resolved "https://registry.yarnpkg.com/tar/-/tar-6.1.0.tgz#d1724e9bcc04b977b18d5c573b333a2207229a83"
+  integrity sha512-DUCttfhsnLCjwoDoFcI+B2iJgYa93vBnDUATYEeRx6sntCTdN01VnqsIuTlALXla/LWooNg0yEGeB+Y8WdFxGA==
+  dependencies:
+    chownr "^2.0.0"
+    fs-minipass "^2.0.0"
+    minipass "^3.0.0"
+    minizlib "^2.1.1"
+    mkdirp "^1.0.3"
+    yallist "^4.0.0"
+
 tslib@^1.9.0:
   version "1.14.1"
   resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00"
   integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==
+
+util-deprecate@~1.0.1:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf"
+  integrity sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=
+
+wide-align@^1.1.0:
+  version "1.1.3"
+  resolved "https://registry.yarnpkg.com/wide-align/-/wide-align-1.1.3.tgz#ae074e6bdc0c14a431e804e624549c633b000457"
+  integrity sha512-QGkOQc8XL6Bt5PwnsExKBPuMKBxnGxWWW3fU55Xt4feHozMUhdUMaBCk290qpm/wG5u/RSKzwdAC4i51YigihA==
+  dependencies:
+    string-width "^1.0.2 || 2"
+
+wrappy@1:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f"
+  integrity sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=
+
+yallist@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/yallist/-/yallist-4.0.0.tgz#9bb92790d9c0effec63be73519e11a35019a3a72"
+  integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==
diff --git a/polygerrit-ui/server.go b/polygerrit-ui/server.go
index e030878..ddfaeb4 100644
--- a/polygerrit-ui/server.go
+++ b/polygerrit-ui/server.go
@@ -202,6 +202,10 @@
 		moduleImportRegexp = regexp.MustCompile("(?m)^((import|export).*'/node_modules/)lit-(element|html).js';$")
 		data = moduleImportRegexp.ReplaceAll(data, []byte("${1}lit-${3}/lit-${3}.js';"))
 
+		// 'immer' imports and exports have to be resolved to 'immer/dist/immer.esm.js'.
+		moduleImportRegexp = regexp.MustCompile("(?m)^((import|export).*'/node_modules/)immer.js';$")
+		data = moduleImportRegexp.ReplaceAll(data, []byte("${1}/immer/dist/immer.esm.js';"))
+
 		if strings.HasSuffix(normalizedContentPath, "/node_modules/page/page.js") {
 			// Can't import page.js directly, because this is undefined.
 			// Replace it with window
diff --git a/proto/cache.proto b/proto/cache.proto
index b1722b4..aa04555 100644
--- a/proto/cache.proto
+++ b/proto/cache.proto
@@ -78,7 +78,7 @@
 // Instead, we just take the tedious yet simple approach of having a "has_foo"
 // field for each nullable field "foo", indicating whether or not foo is null.
 //
-// Next ID: 27
+// Next ID: 28
 message ChangeNotesStateProto {
   // Effectively required, even though the corresponding ChangeNotesState field
   // is optional, since the field is only absent when NoteDb is disabled, in
@@ -226,6 +226,8 @@
   // Epoch millis.
   int64 merged_on_millis = 25;
   bool has_merged_on = 26;
+
+  repeated SubmitRequirementResultProto submit_requirement_result = 27;
 }
 
 // Serialized form of com.google.gerrit.server.query.change.ConflictKey
@@ -445,7 +447,7 @@
 }
 
 // Serialized form of com.google.gerrit.common.data.LabelType.
-// Next ID: 19
+// Next ID: 21
 message LabelTypeProto {
   string name = 1;
   string function = 2; // ENUM as String
@@ -466,6 +468,7 @@
   bool can_override = 17;
   repeated string ref_patterns = 18;
   bool copy_all_scores_if_list_of_files_did_not_change = 19;
+  string copy_condition = 20;
 }
 
 // Serialized form of com.google.gerrit.entities.SubmitRequirement.
@@ -474,11 +477,33 @@
   string name = 1;
   string description = 2;
   string applicability_expression = 3;
-  string blocking_expression = 4;
+  string submittability_expression = 4;
   string override_expression = 5;
   bool allow_override_in_child_projects = 6;
 }
 
+// Serialized form of com.google.gerrit.entities.SubmitRequirementResult.
+// Next ID: 6
+message SubmitRequirementResultProto {
+  SubmitRequirementProto submit_requirement = 1;
+  SubmitRequirementExpressionResultProto applicability_expression_result = 2;
+  SubmitRequirementExpressionResultProto submittability_expression_result = 3;
+  SubmitRequirementExpressionResultProto override_expression_result = 4;
+
+  // Patchset commit ID at which the submit requirements are evaluated.
+  bytes commit = 5;
+}
+
+// Serialized form of com.google.gerrit.entities.SubmitRequirementExpressionResult.
+// Next ID: 6
+message SubmitRequirementExpressionResultProto {
+  string expression = 1;
+  string status = 2; // enum as string
+  string error_message = 3;
+  repeated string passing_atoms = 4;
+  repeated string failing_atoms = 5;
+}
+
 // Serialized form of com.google.gerrit.server.project.ConfiguredMimeTypes.
 // Next ID: 4
 message ConfiguredMimeTypeProto {
@@ -606,7 +631,7 @@
 
 // Serialized form of a collection of
 // com.google.gerrit.server.patch.gitfilediff.GitFileDiffCacheImpl.Key
-// Next ID: 8
+// Next ID: 9
 message GitFileDiffKeyProto {
   string project = 1;
   bytes a_tree = 2;
@@ -615,10 +640,11 @@
   int32 rename_score = 5;
   string diff_algorithm = 6; // ENUM as string
   string whitepsace = 7; // ENUM as string
+  bool useTimeout = 8;
 }
 
 // Serialized form of com.google.gerrit.server.patch.gitfilediff.GitFileDiff
-// Next ID: 11
+// Next ID: 12
 message GitFileDiffProto {
   message Edit {
     int32 begin_a = 1;
@@ -636,11 +662,12 @@
   string new_mode = 8; // ENUM as string
   string change_type = 9; // ENUM as string
   string patch_type = 10; // ENUM as string
+  bool negative = 11;
 }
 
 // Serialized form of
 // com.google.gerrit.server.patch.fileDiff.FileDiffCacheKey
-// Next ID: 8
+// Next ID: 9
 message FileDiffKeyProto {
   string project = 1;
   bytes old_commit = 2;
@@ -649,11 +676,12 @@
   int32 rename_score = 5;
   string diff_algorithm = 6;
   string whitespace = 7;
+  bool useTimeout = 8;
 }
 
 // Serialized form of
 // com.google.gerrit.server.patch.filediff.FileDiffOutput
-// Next ID: 12
+// Next ID: 13
 message FileDiffOutputProto {
   // Next ID: 5
   message Edit {
@@ -684,4 +712,20 @@
   bytes old_commit = 9;
   bytes new_commit = 10;
   ComparisonType comparison_type = 11;
+  bool negative = 12;
+}
+
+// Serialized form of com.google.gerrit.server.approval.ApprovalCacheImpl.Key.
+// Next ID: 5
+message PatchSetApprovalsKeyProto {
+  string project = 1;
+  int32 change_id = 2;
+  int32 patch_set_id = 3;
+  bytes id = 4;
+}
+
+// Repeated version of PatchSetApprovalProto
+// Next ID: 2
+message AllPatchSetApprovalsProto {
+  repeated devtools.gerritcodereview.PatchSetApproval approval = 1;
 }
diff --git a/resources/com/google/gerrit/pgm/init/gerrit.sh b/resources/com/google/gerrit/pgm/init/gerrit.sh
index 87a6c05..0f99202 100755
--- a/resources/com/google/gerrit/pgm/init/gerrit.sh
+++ b/resources/com/google/gerrit/pgm/init/gerrit.sh
@@ -96,6 +96,36 @@
   fi
 }
 
+# Limited support for Gerrit's getTimeUnit() limited from seconds to days
+# because having gerrit startup/shutdown that wait for weeks or years would
+# not make so much sense.
+get_time_unit_sec() {
+  TIME_LC=`echo $1 | tr '[:upper:]' '[:lower:]'`
+  if [[ "$TIME_LC" =~ ^(0|[1-9][0-9]*)$ ]]
+  then
+    echo $TIME_LC
+  elif [[ "$TIME_LC" =~ ^[1-9][0-9]*\ *(s|sec|second|seconds)$ ]]
+  then
+    echo "$TIME_LC" | tr -d -c 0-9
+  elif [[ "$TIME_LC" =~ ^[1-9][0-9]*\ *(m|min|minute|minutes)$ ]]
+  then
+    expr `echo "$TIME_LC" | tr -d -c 0-9` '*' 60
+  elif [[ "$TIME_LC" =~ ^[1-9][0-9]*\ *(h|hr|hour|hours)$ ]]
+  then
+    expr `echo "$TIME_LC" | tr -d -c 0-9` '*' 3600
+  elif [[ "$TIME_LC" =~ ^[1-9][0-9]*\ *(d|day|days)$ ]]
+  then
+    expr `echo "$TIME_LC" | tr -d -c 0-9` '*' 86400
+  else
+    >&2 echo "Unsupported time format $1"
+    exit 1
+  fi
+}
+
+max() {
+  echo $(( $1 > $2 ? $1 : $2 ))
+}
+
 ##################################################
 # Get the action and options
 ##################################################
@@ -321,6 +351,15 @@
 ulimit -x >/dev/null 2>&1 && ulimit -x unlimited  ; # file locks
 
 #####################################################
+# Configure the maximum wait time for shutdown
+#####################################################
+EXTRA_STOP_TIMEOUT=30
+HTTPD_STOP_TIMEOUT=$(get_time_unit_sec "$(get_config --get httpd.gracefulStopTimeout || echo 0)")
+SSHD_STOP_TIMEOUT=$(get_time_unit_sec "$(get_config --get sshd.gracefulStopTimeout || echo 0)")
+
+STOP_TIMEOUT=`expr $(max $HTTPD_STOP_TIMEOUT $SSHD_STOP_TIMEOUT) '+' $EXTRA_STOP_TIMEOUT`
+
+#####################################################
 # This is how the Gerrit server will be started
 #####################################################
 
@@ -482,7 +521,7 @@
       if running "$GERRIT_PID" ; then
         sleep 3
         if running "$GERRIT_PID" ; then
-          sleep 30
+          sleep $STOP_TIMEOUT
           if running "$GERRIT_PID" ; then
             start-stop-daemon -K -p "$GERRIT_PID" -s KILL
           fi
@@ -492,7 +531,7 @@
       echo OK
     else
       PID=`cat "$GERRIT_PID" 2>/dev/null`
-      TIMEOUT=30
+      TIMEOUT=$STOP_TIMEOUT
       while running "$GERRIT_PID" && test $TIMEOUT -gt 0 ; do
         kill $PID 2>/dev/null
         sleep 1
diff --git a/resources/com/google/gerrit/server/tools/root/hooks/commit-msg b/resources/com/google/gerrit/server/tools/root/hooks/commit-msg
index 3a40d22..e1d6f22 100755
--- a/resources/com/google/gerrit/server/tools/root/hooks/commit-msg
+++ b/resources/com/google/gerrit/server/tools/root/hooks/commit-msg
@@ -16,6 +16,8 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
+set -u
+
 # avoid [[ which is not POSIX sh.
 if test "$#" != 1 ; then
   echo "$0 requires an argument."
@@ -28,12 +30,17 @@
 fi
 
 # Do not create a change id if requested
-if test "false" = "`git config --bool --get gerrit.createChangeId`" ; then
+if test "false" = "$(git config --bool --get gerrit.createChangeId)" ; then
   exit 0
 fi
 
-# $RANDOM will be undefined if not using bash, so don't use set -u
-random=$( (whoami ; hostname ; date; cat $1 ; echo $RANDOM) | git hash-object --stdin)
+if git rev-parse --verify HEAD >/dev/null 2>&1; then
+  refhash="$(git rev-parse HEAD)"
+else
+  refhash="$(git hash-object -t tree /dev/null)"
+fi
+
+random=$({ git var GIT_COMMITTER_IDENT ; echo "$refhash" ; cat "$1"; } | git hash-object --stdin)
 dest="$1.tmp.${random}"
 
 trap 'rm -f "${dest}"' EXIT
diff --git a/tools/bzl/license-map.py b/tools/bzl/license-map.py
index 555aa17..3e4fc92 100644
--- a/tools/bzl/license-map.py
+++ b/tools/bzl/license-map.py
@@ -24,7 +24,7 @@
 
 def read_file(filename):
     "Reads file and returns its content"
-    with open(filename) as fd:
+    with open(filename, encoding='utf-8') as fd:
         return fd.read()
 
 # List of files in package to which license is applied.
@@ -56,6 +56,8 @@
 LicenseMapItem = namedtuple("LicenseMapItem",
                             ["name", "safename", "packages", "license_text"])
 
+def print_utf8(str=""):
+  stdout.buffer.write((str + "\n").encode('utf-8'))
 
 def load_xmls(xml_filenames):
     """Load xml files produced by bazel query
@@ -134,7 +136,7 @@
 
     if args.asciidoctor:
         # We don't want any blank line before "= Gerrit Code Review - Licenses"
-        print("""= Gerrit Code Review - Licenses
+        print_utf8("""= Gerrit Code Review - Licenses
 
 // DO NOT EDIT - GENERATED AUTOMATICALLY.
 
@@ -178,10 +180,10 @@
     for data in xml_data + json_map_data:
         name = data.name
         safename = data.safename
-        print()
-        print("[[%s]]" % safename)
-        print(name)
-        print()
+        print_utf8()
+        print_utf8("[[%s]]" % safename)
+        print_utf8(name)
+        print_utf8()
         for p in data.packages:
             package_notice = ""
             if p.licensed_files.kind == "OnlySpecificFiles":
@@ -189,20 +191,20 @@
             elif p.licensed_files.kind == "AllFilesExceptSpecific":
                 package_notice = " - except the following file(s):"
 
-            print("* " + get_package_display_name(p) + package_notice)
+            print_utf8("* " + get_package_display_name(p) + package_notice)
             for file in p.licensed_files.files:
-                print("** " + file)
-        print()
-        print("[[%s_license]]" % safename)
-        print("----")
+                print_utf8("** " + file)
+        print_utf8()
+        print_utf8("[[%s_license]]" % safename)
+        print_utf8("----")
         license_text = data.license_text
-        print(data.license_text.rstrip("\r\n"))
-        print()
-        print("----")
-        print()
+        print_utf8(data.license_text.rstrip("\r\n"))
+        print_utf8()
+        print_utf8("----")
+        print_utf8()
 
     if args.asciidoctor:
-        print("""
+        print_utf8("""
 GERRIT
 ------
 Part of link:index.html[Gerrit Code Review]
@@ -219,7 +221,7 @@
     """
     result = []
     for json_map in json_filenames:
-        with open(json_map, 'r') as f:
+        with open(json_map, 'r', encoding='utf-8') as f:
             licenses_list = json.load(f)
         for license_id, license in licenses_list.items():
             name = license["licenseName"]
diff --git a/tools/js/template_checker.bzl b/tools/js/template_checker.bzl
new file mode 100644
index 0000000..da77234
--- /dev/null
+++ b/tools/js/template_checker.bzl
@@ -0,0 +1,136 @@
+# 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.
+
+"""This file contains macro to run polymer templates check."""
+
+load("@build_bazel_rules_nodejs//:index.bzl", "nodejs_binary", "nodejs_test", "npm_package_bin", "params_file")
+load("@rules_pkg//:pkg.bzl", "pkg_tar")
+
+def _get_generated_files(outdir, srcs):
+    result = []
+    for f in srcs:
+        result.append(outdir + "/" + f)
+    return result
+
+def _generate_transformed_templates(name, srcs, tsconfig, deps, out_tsconfig, outdir, dev_run):
+    """Generates typescript code from polymer templates. It uses twinkie package
+    for generation.
+
+    Args:
+      name: rule name
+      srcs: all files in a project project
+      tsconfig: the original typescript project file
+      deps: dependencies
+      out_tsconfig: where to store the generated TS project.
+      outdir: where to store generated .ts files
+      dev_run: if True, the generator uses different file paths in generated
+        import statements. Later, generated files can be copied into workspace
+        for future debugging\\investigation templates issues.
+
+    Returns:
+      The list of generated files
+    """
+    generated_files = _get_generated_files(outdir, srcs)
+
+    # There is a limitation on the command-line length. Put all source files
+    # into a .params file (this is a text file, where each argument is placed
+    # on a new line)
+    params_file(
+        name = name + "_params",
+        out = name + ".params",
+        args = ["$(execpath {})".format(src) for src in srcs],
+        data = srcs,
+    )
+
+    # Arguments for twinkie
+    args = [
+        "$(location //tools/node_tools:twinkie-bin)",
+        "--tsconfig $(location {})".format(tsconfig),
+        "--out-dir $(RULEDIR)/{} ".format(outdir),
+        "--files $(location {})".format(name + ".params"),
+    ]
+    if dev_run:
+        args.append("--dev-run")
+    if out_tsconfig:
+        args.append("--out-ts-config $(location {})".format(out_tsconfig))
+
+    # Execute twinkie.
+    native.genrule(
+        name = name + "_npm_bin",
+        srcs = srcs + deps + [name + ".params"],
+        outs = generated_files + ([out_tsconfig] if out_tsconfig else []),
+        cmd = " ".join(args),
+        tools = ["//tools/node_tools:twinkie-bin"],
+        # Should not run sandboxed.
+        tags = [
+            "local",
+            "manual",
+        ],
+    )
+    return generated_files
+
+def transform_polymer_templates(name, srcs, tsconfig, deps, out_tsconfig):
+    """Transforms polymer templates into typescript code.
+    Additionally, the macro defines name+"_tar" package that contains
+    generated code with slightly different import paths.
+    Note, that polygerrit template tests don't depend on the tar package, so
+    bazel doesn't generate the tar package with the bazel test command.
+    The tar package must be build explicitly with the bazel build command.
+
+    Args:
+      name: rule name
+      srcs: all files in a project project
+      tsconfig: the original typescript project file
+      deps: dependencies
+      out_tsconfig: where to store the generated TS project.
+
+    Returns:
+      list of generated files
+    """
+
+    # Transformed templates for tests
+    generated_files = _generate_transformed_templates(
+        name = name,
+        srcs = srcs,
+        tsconfig = tsconfig,
+        deps = deps,
+        out_tsconfig = out_tsconfig,
+        dev_run = False,
+        outdir = name + "_out",
+    )
+
+    # Transformed templates for developers. Only the tar package depends
+    # on it and it never runs during tests.
+    generated_dev_files = _generate_transformed_templates(
+        name = name + "_dev",
+        srcs = srcs,
+        tsconfig = tsconfig,
+        deps = deps,
+        dev_run = True,
+        outdir = name + "_dev_out",
+        out_tsconfig = None,
+    )
+
+    # Pack all transformed files. Later files can be materialized in the
+    # WORKSPACE/polygerrit-ui/app/tmpl_out dir. The following command do it
+    # automatically
+    # npm run polytest:dev
+    pkg_tar(
+        name = name + "_tar",
+        srcs = generated_dev_files,
+        # Set strip_prefix to keep directory hierarchy in the .tar
+        # https://github.com/bazelbuild/rules_pkg/issues/82
+        strip_prefix = name + "_dev_out",
+    )
+    return generated_files
diff --git a/tools/node_tools/BUILD b/tools/node_tools/BUILD
index 03e3a13..0f836a0 100644
--- a/tools/node_tools/BUILD
+++ b/tools/node_tools/BUILD
@@ -45,3 +45,16 @@
     data = ["@tools_npm//:node_modules"],
     entry_point = "@tools_npm//:node_modules/typescript/lib/tsc.js",
 )
+
+# Wrap twinkie into a twinkie-bin binary.
+nodejs_binary(
+    name = "twinkie-bin",
+    # Point bazel to your node_modules to find the entry point
+    data = ["@npm//:node_modules"],
+    entry_point = "@npm//:node_modules/twinkie/src/app/index.js",
+    # Should not run sandboxed.
+    tags = [
+        "local",
+        "manual",
+    ],
+)
diff --git a/tools/nongoogle.bzl b/tools/nongoogle.bzl
index 3d04592..434196f 100644
--- a/tools/nongoogle.bzl
+++ b/tools/nongoogle.bzl
@@ -6,7 +6,7 @@
 
 GUAVA_DOC_URL = "https://google.github.io/guava/releases/" + GUAVA_VERSION + "/api/docs/"
 
-TESTCONTAINERS_VERSION = "1.15.1"
+TESTCONTAINERS_VERSION = "1.15.3"
 
 def declare_nongoogle_deps():
     """loads dependencies that are not used at Google.
@@ -187,21 +187,21 @@
         sha1 = "dc13ae4faca6df981fc7aeb5a522d9db446d5d50",
     )
 
-    DOCKER_JAVA_VERS = "3.2.7"
+    DOCKER_JAVA_VERS = "3.2.8"
 
     maven_jar(
         name = "docker-java-api",
         artifact = "com.github.docker-java:docker-java-api:" + DOCKER_JAVA_VERS,
-        sha1 = "81408fc988c229ea11354fee9902c47842343f04",
+        sha1 = "4ac22a72d546a9f3523cd4b5fabffa77c4a6ec7c",
     )
 
     maven_jar(
         name = "docker-java-transport",
         artifact = "com.github.docker-java:docker-java-transport:" + DOCKER_JAVA_VERS,
-        sha1 = "315903a129f530422747efc163dd255f0fa2555e",
+        sha1 = "c3b5598c67d0a5e2e780bf48f520da26b9915eab",
     )
 
-    # https://github.com/docker-java/docker-java/blob/3.2.7/pom.xml#L61
+    # https://github.com/docker-java/docker-java/blob/3.2.8/pom.xml#L61
     # <=> DOCKER_JAVA_VERS
     maven_jar(
         name = "jackson-annotations",
@@ -212,7 +212,7 @@
     maven_jar(
         name = "testcontainers",
         artifact = "org.testcontainers:testcontainers:" + TESTCONTAINERS_VERSION,
-        sha1 = "91e6dfab8f141f77c6a0dd147a94bd186993a22c",
+        sha1 = "95c6cfde71c2209f0c29cb14e432471e0b111880",
     )
 
     maven_jar(
diff --git a/twinkie.patch b/twinkie.patch
new file mode 100644
index 0000000..0a61243
--- /dev/null
+++ b/twinkie.patch
@@ -0,0 +1,11 @@
+--- a/node_modules/twinkie/src/app/index.js
++++ b/node_modules/twinkie/src/app/index.js
+@@ -250,7 +250,7 @@ twinkie --tsconfig tsconfig.json --outdir output_dir [--files file_list] [--outt
+                 incremental: false,
+                 noEmit: true,
+             },
+-            files: [...allProgramFilesNames, generatedFiles],
++            files: [...allProgramFilesNames, ...generatedFiles],
+         };
+         fs.writeFileSync(cmdLineOptions.outputTsConfig, JSON.stringify(tsconfigContent, null, 2));
+     }
diff --git a/yarn.lock b/yarn.lock
index 45b4f2c..faa1e99 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1082,6 +1082,11 @@
   resolved "https://registry.yarnpkg.com/@types/minimatch/-/minimatch-3.0.4.tgz#f0ec25dbf2f0e4b18647313ac031134ca5b24b21"
   integrity sha512-1z8k4wzFnNjVK/tlxvrWuK5WMt6mydWWP7+zvH5eFep4oj+UkrfiJTRtjCeBXNpwaA/FYqqtb4/QS4ianFpIRA==
 
+"@types/minimatch@3.0.3":
+  version "3.0.3"
+  resolved "https://registry.yarnpkg.com/@types/minimatch/-/minimatch-3.0.3.tgz#3dca0e3f33b200fc7d1139c0cd96c1268cadfd9d"
+  integrity sha512-tHq6qdbT9U1IRSGf14CL0pUlULksvY9OZ+5eEgl1N7t+OA3tGvNpxJCzuKQlsNgCVwbAs670L1vcVQi8j9HjnA==
+
 "@types/minimist@^1.2.0":
   version "1.2.1"
   resolved "https://registry.npmjs.org/@types/minimist/-/minimist-1.2.1.tgz"
@@ -2356,6 +2361,11 @@
     raw-body "2.4.0"
     type-is "~1.6.17"
 
+boolbase@~1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/boolbase/-/boolbase-1.0.0.tgz#68dff5fbe60c51eb37725ea9e3ed310dcc1e776e"
+  integrity sha1-aN/1++YMUes3cl6p4+0xDcwed24=
+
 bower-config@^1.4.0, bower-config@^1.4.1:
   version "1.4.3"
   resolved "https://registry.yarnpkg.com/bower-config/-/bower-config-1.4.3.tgz#3454fecdc5f08e7aa9cc6d556e492be0669689ae"
@@ -2432,7 +2442,7 @@
     widest-line "^3.1.0"
     wrap-ansi "^7.0.0"
 
-brace-expansion@^1.1.7:
+brace-expansion@^1.0.0, brace-expansion@^1.1.7:
   version "1.1.11"
   resolved "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz"
   integrity sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==
@@ -2742,6 +2752,18 @@
   resolved "https://registry.yarnpkg.com/charenc/-/charenc-0.0.2.tgz#c0a1d2f3a7092e03774bfa83f14c0fc5790a8667"
   integrity sha1-wKHS86cJLgN3S/qD8UwPxXkKhmc=
 
+cheerio@1.0.0-rc.2:
+  version "1.0.0-rc.2"
+  resolved "https://registry.yarnpkg.com/cheerio/-/cheerio-1.0.0-rc.2.tgz#4b9f53a81b27e4d5dac31c0ffd0cfa03cc6830db"
+  integrity sha1-S59TqBsn5NXawxwP/Qz6A8xoMNs=
+  dependencies:
+    css-select "~1.2.0"
+    dom-serializer "~0.1.0"
+    entities "~1.1.1"
+    htmlparser2 "^3.9.1"
+    lodash "^4.15.0"
+    parse5 "^3.0.1"
+
 chokidar@^1.7.0:
   version "1.7.0"
   resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-1.7.0.tgz#798e689778151c8076b4b360e5edd28cda2bb468"
@@ -3253,6 +3275,16 @@
   resolved "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-2.0.0.tgz"
   integrity sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA==
 
+css-select@~1.2.0:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/css-select/-/css-select-1.2.0.tgz#2b3a110539c5355f1cd8d314623e870b121ec858"
+  integrity sha1-KzoRBTnFNV8c2NMUYj6HCxIeyFg=
+  dependencies:
+    boolbase "~1.0.0"
+    css-what "2.1"
+    domutils "1.5.1"
+    nth-check "~1.0.1"
+
 css-slam@^2.1.2:
   version "2.1.2"
   resolved "https://registry.yarnpkg.com/css-slam/-/css-slam-2.1.2.tgz#3d35b1922cb3e0002a45c89ab189492508c493e5"
@@ -3264,7 +3296,7 @@
     parse5 "^4.0.0"
     shady-css-parser "^0.1.0"
 
-css-what@^2.1.0:
+css-what@2.1, css-what@^2.1.0:
   version "2.1.3"
   resolved "https://registry.yarnpkg.com/css-what/-/css-what-2.1.3.tgz#a6d7604573365fe74686c3f311c56513d88285f2"
   integrity sha512-a+EPoD+uZiNfh+5fxw2nO9QwFa6nJe2Or35fGY6Ipw1R3R4AGz1d1TEZrCegvw2YTmZ0jXirGYlzxxpYSHwpEg==
@@ -3547,6 +3579,14 @@
   dependencies:
     esutils "^2.0.2"
 
+dom-serializer@0:
+  version "0.2.2"
+  resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-0.2.2.tgz#1afb81f533717175d478655debc5e332d9f9bb51"
+  integrity sha512-2/xPb3ORsQ42nHYiSunXkDjPLBaEj/xTwUO4B7XCZQTRk7EBtTOPaygh10YAAh2OI1Qrp6NWfpAhzswj0ydt9g==
+  dependencies:
+    domelementtype "^2.0.1"
+    entities "^2.0.0"
+
 dom-serializer@^1.0.1:
   version "1.3.1"
   resolved "https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.3.1.tgz"
@@ -3556,6 +3596,14 @@
     domhandler "^4.0.0"
     entities "^2.0.0"
 
+dom-serializer@~0.1.0:
+  version "0.1.1"
+  resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-0.1.1.tgz#1ec4059e284babed36eec2941d4a970a189ce7c0"
+  integrity sha512-l0IU0pPzLWSHBcieZbpOKgkIn3ts3vAh7ZuFyXNwJxJXk/c4Gwj9xaTJwIDVQCXawWD0qb3IzMGH5rglQaO0XA==
+  dependencies:
+    domelementtype "^1.3.0"
+    entities "^1.1.1"
+
 dom-urls@^1.1.0:
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/dom-urls/-/dom-urls-1.1.0.tgz#001ddf81628cd1e706125c7176f53ccec55d918e"
@@ -3572,11 +3620,23 @@
     clone "^2.1.0"
     parse5 "^4.0.0"
 
+domelementtype@1, domelementtype@^1.3.0, domelementtype@^1.3.1:
+  version "1.3.1"
+  resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-1.3.1.tgz#d048c44b37b0d10a7f2a3d5fee3f4333d790481f"
+  integrity sha512-BSKB+TSpMpFI/HOxCNr1O8aMOTZ8hT3pM3GQ0w/mWRmkhEDSFJkkyzz4XQsBV44BChwGkrDfMyjVD0eA2aFV3w==
+
 domelementtype@^2.0.1, domelementtype@^2.2.0:
   version "2.2.0"
   resolved "https://registry.npmjs.org/domelementtype/-/domelementtype-2.2.0.tgz"
   integrity sha512-DtBMo82pv1dFtUmHyr48beiuq792Sxohr+8Hm9zoxklYPfa6n0Z3Byjj2IV7bmr2IyqClnqEQhfgHJJ5QF0R5A==
 
+domhandler@^2.3.0:
+  version "2.4.2"
+  resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-2.4.2.tgz#8805097e933d65e85546f726d60f5eb88b44f803"
+  integrity sha512-JiK04h0Ht5u/80fdLMCEmV4zkNh2BcoMFBmZ/91WtYZ8qVXSKjiw7fXMgFPnHcSZgOo3XdinHvmnDUeMf5R4wA==
+  dependencies:
+    domelementtype "1"
+
 domhandler@^4.0.0, domhandler@^4.1.0:
   version "4.1.0"
   resolved "https://registry.npmjs.org/domhandler/-/domhandler-4.1.0.tgz"
@@ -3584,6 +3644,22 @@
   dependencies:
     domelementtype "^2.2.0"
 
+domutils@1.5.1:
+  version "1.5.1"
+  resolved "https://registry.yarnpkg.com/domutils/-/domutils-1.5.1.tgz#dcd8488a26f563d61079e48c9f7b7e32373682cf"
+  integrity sha1-3NhIiib1Y9YQeeSMn3t+Mjc2gs8=
+  dependencies:
+    dom-serializer "0"
+    domelementtype "1"
+
+domutils@^1.5.1:
+  version "1.7.0"
+  resolved "https://registry.yarnpkg.com/domutils/-/domutils-1.7.0.tgz#56ea341e834e06e6748af7a1cb25da67ea9f8c2a"
+  integrity sha512-Lgd2XcJ/NjEw+7tFvfKxOzCYKZsdct5lczQ2ZaQY8Djz7pfAD3Gbp8ySJWtreII/vDlMVmxwa6pHmdxIYgttDg==
+  dependencies:
+    dom-serializer "0"
+    domelementtype "1"
+
 domutils@^2.5.2:
   version "2.5.2"
   resolved "https://registry.npmjs.org/domutils/-/domutils-2.5.2.tgz"
@@ -3767,6 +3843,11 @@
   dependencies:
     ansi-colors "^4.1.1"
 
+entities@^1.1.1, entities@~1.1.1:
+  version "1.1.2"
+  resolved "https://registry.yarnpkg.com/entities/-/entities-1.1.2.tgz#bdfa735299664dfafd34529ed4f8522a275fea56"
+  integrity sha512-f2LZMYl1Fzu7YSBKg+RoROelpOaNrcGmE9AZubeDfrCEia483oW4MI4VyFd5VNHIgQ/7qm1I0wUHK1eJnn2y2w==
+
 entities@^2.0.0:
   version "2.2.0"
   resolved "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz"
@@ -5355,6 +5436,18 @@
     relateurl "0.2.x"
     uglify-js "3.4.x"
 
+htmlparser2@^3.9.1:
+  version "3.10.1"
+  resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-3.10.1.tgz#bd679dc3f59897b6a34bb10749c855bb53a9392f"
+  integrity sha512-IgieNijUMbkDovyoKObU1DUhm1iwNYE/fuifEoEHfd1oZKZDaONBSkal7Y01shxsM49R4XaMdGez3WnF9UfiCQ==
+  dependencies:
+    domelementtype "^1.3.1"
+    domhandler "^2.3.0"
+    domutils "^1.5.1"
+    entities "^1.1.1"
+    inherits "^2.0.1"
+    readable-stream "^3.1.1"
+
 htmlparser2@^6.0.1:
   version "6.1.0"
   resolved "https://registry.npmjs.org/htmlparser2/-/htmlparser2-6.1.0.tgz"
@@ -6498,7 +6591,7 @@
   resolved "https://registry.yarnpkg.com/lodash/-/lodash-3.10.1.tgz#5bf45e8e49ba4189e17d482789dfd15bd140b7b6"
   integrity sha1-W/Rejkm6QYnhfUgnid/RW9FAt7Y=
 
-lodash@^4.0.0, lodash@^4.11.1, lodash@^4.16.6, lodash@^4.17.10, lodash@^4.17.11, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.2, lodash@^4.17.20, lodash@^4.17.21, lodash@^4.17.4, lodash@^4.3.0:
+lodash@^4.0.0, lodash@^4.11.1, lodash@^4.15.0, lodash@^4.16.6, lodash@^4.17.10, lodash@^4.17.11, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.2, lodash@^4.17.20, lodash@^4.17.21, lodash@^4.17.4, lodash@^4.3.0:
   version "4.17.21"
   resolved "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz"
   integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==
@@ -6901,6 +6994,13 @@
   dependencies:
     brace-expansion "^1.1.7"
 
+minimatch@3.0.3:
+  version "3.0.3"
+  resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.3.tgz#2a4e4090b96b2db06a9d7df01055a62a77c9b774"
+  integrity sha1-Kk5AkLlrLbBqnX3wEFWmKnfJt3Q=
+  dependencies:
+    brace-expansion "^1.0.0"
+
 minimist-options@4.1.0:
   version "4.1.0"
   resolved "https://registry.npmjs.org/minimist-options/-/minimist-options-4.1.0.tgz"
@@ -7172,6 +7272,13 @@
   dependencies:
     path-key "^3.0.0"
 
+nth-check@~1.0.1:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/nth-check/-/nth-check-1.0.2.tgz#b2bd295c37e3dd58a3bf0700376663ba4d9cf05c"
+  integrity sha512-WeBOdju8SnzPN5vTUJYxYUxLeXpCaVP5i5e0LF8fg7WORF2Wd7wFX/pk0tYZk7s8T+J7VLy0Da6J1+wCT0AtHg==
+  dependencies:
+    boolbase "~1.0.0"
+
 number-is-nan@^1.0.0:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/number-is-nan/-/number-is-nan-1.0.1.tgz#097b602b53422a522c1afb8790318336941a011d"
@@ -7527,6 +7634,13 @@
   resolved "https://registry.yarnpkg.com/parse-passwd/-/parse-passwd-1.0.0.tgz#6d5b934a456993b23d37f40a382d6f1666a8e5c6"
   integrity sha1-bVuTSkVpk7I9N/QKOC1vFmao5cY=
 
+parse5@^3.0.1:
+  version "3.0.3"
+  resolved "https://registry.yarnpkg.com/parse5/-/parse5-3.0.3.tgz#042f792ffdd36851551cf4e9e066b3874ab45b5c"
+  integrity sha512-rgO9Zg5LLLkfJF9E6CCmXlSE4UVceloys8JrFqCcHloC3usd/kJCyPDwH2SOlzix2j3xaP9sUX3e8+kvkuleAA==
+  dependencies:
+    "@types/node" "*"
+
 parse5@^4.0.0:
   version "4.0.0"
   resolved "https://registry.yarnpkg.com/parse5/-/parse5-4.0.0.tgz#6d78656e3da8d78b4ec0b906f7c08ef1dfe3f608"
@@ -9882,6 +9996,16 @@
   resolved "https://registry.yarnpkg.com/tweetnacl/-/tweetnacl-0.14.5.tgz#5ae68177f192d4456269d108afa93ff8743f4f64"
   integrity sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q=
 
+twinkie@^1.1.2:
+  version "1.1.2"
+  resolved "https://registry.yarnpkg.com/twinkie/-/twinkie-1.1.2.tgz#c301e4fc26d00d61d3d7e5be030dc6a2264271da"
+  integrity sha512-4KwhyrcrRb0WWJKMX/aT+npmMZC0h+sA//+bLhNupmuKvesrH2vEZDe6yIr48FMWKEsdA2xNdQqw/3MapZ5qXQ==
+  dependencies:
+    "@types/minimatch" "3.0.3"
+    cheerio "1.0.0-rc.2"
+    minimatch "3.0.3"
+    typescript "4.0.5"
+
 type-check@^0.4.0, type-check@~0.4.0:
   version "0.4.0"
   resolved "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz"
@@ -9939,6 +10063,11 @@
   resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777"
   integrity sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c=
 
+typescript@4.0.5:
+  version "4.0.5"
+  resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.0.5.tgz#ae9dddfd1069f1cb5beb3ef3b2170dd7c1332389"
+  integrity sha512-ywmr/VrTVCmNTJ6iV2LwIrfG1P+lv6luD8sUJs+2eI9NLGigaN+nUQc13iHqisq7bra9lnmUSYqbJvegraBOPQ==
+
 typescript@4.1.4:
   version "4.1.4"
   resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.1.4.tgz#f058636e2f4f83f94ddaae07b20fd5e14598432f"