Merge branch 'stable-3.4'

* stable-3.4:
  Add a test for case insensitive repo search
  Documentation/linux-quickstart: Remove outdated note
  Update git submodules
  AS ops shouldn't count towards change.maxUpdates limit
  Fix regression - enable insensitive search for repo search

Change-Id: Id139d0561c636b3c821ac4ee8897fa3d1fe74d03
diff --git a/.bazelrc b/.bazelrc
index 4d30086..6ccd56a 100644
--- a/.bazelrc
+++ b/.bazelrc
@@ -1,4 +1,4 @@
-build --workspace_status_command="python ./tools/workspace_status.py"
+build --workspace_status_command="python3 ./tools/workspace_status.py"
 build --repository_cache=~/.gerritcodereview/bazel-cache/repository
 build --action_env=PATH
 build --disk_cache=~/.gerritcodereview/bazel-cache/cas
diff --git a/Documentation/config-gerrit.txt b/Documentation/config-gerrit.txt
index 1c98b9af..82e15d9 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
@@ -1969,7 +1975,7 @@
   scheme = http
   scheme = anon_http
   scheme = anon_git
-  scheme = repo_download
+  scheme = repo
 ----
 
 The download section configures the allowed download methods.
@@ -2026,12 +2032,13 @@
 necessary to set <<gerrit.canonicalGitUrl,gerrit.canonicalGitUrl>>
 variable.
 +
-* `repo_download`
+* `repo`
 +
 Gerrit advertises patch set downloads with the `repo download`
 command, assuming that all projects managed by this instance are
-generally worked on with the repo multi-repository tool.  This is
-not default, as not all instances will deploy repo.
+generally worked on with the
+[repo multi-repository tool](https://gerrit.googlesource.com/git-repo).
+This is not default, as not all instances will deploy repo.
 
 +
 If `download.scheme` is not specified, SSH, HTTP and Anonymous HTTP
diff --git a/Documentation/config-labels.txt b/Documentation/config-labels.txt
index b6184d7..f5346c1 100644
--- a/Documentation/config-labels.txt
+++ b/Documentation/config-labels.txt
@@ -268,6 +268,46 @@
 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:
+
+==== change-kind:{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:`groupUUID`
+
+Matches votes granted by a user who is a member of `groupUUID`.
+
+==== uploaderin:`groupUUID`
+
+Matches votes where the new patch set was uploaded by a member of `groupUUID`.
+
+==== 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`
 
@@ -293,9 +333,9 @@
 patch-set is uploaded that has the same list of files as the previous
 patch-set.
 
-Renames are considered the same file when computing whether new files
-were added or old files were deleted. Hence, if there are only renames,
-scores will still be copied over.
+Renames are considered different files when computing whether new files
+were added or old files were deleted. Hence, if there are renames, scores will
+*NOT* be copied over.
 
 Defaults to false.
 
diff --git a/Documentation/config-robot-comments.txt b/Documentation/config-robot-comments.txt
index f5185a4..04309e5 100644
--- a/Documentation/config-robot-comments.txt
+++ b/Documentation/config-robot-comments.txt
@@ -13,9 +13,8 @@
 It is planned to visualize robot comments differently in the web UI so
 that they can be easily distinguished from human comments. Users should
 also be able to use filtering on robot comments, so that only part of
-the robot comments or no robot comments are shown. In addition it is
-planned that robot comments can contain fixes, that users can apply by
-a single click.
+the robot comments or no robot comments are shown. In addition robot
+comments can contain fixes, that users can apply by a single click.
 
 == REST endpoints
 
diff --git a/Documentation/dev-bazel.txt b/Documentation/dev-bazel.txt
index cfe0ffb..61565f8 100644
--- a/Documentation/dev-bazel.txt
+++ b/Documentation/dev-bazel.txt
@@ -19,7 +19,7 @@
 
 * A Linux or macOS system (Windows is not supported at this time)
 * A JDK for Java 8|11|...
-* Python 2 or 3
+* Python 3
 * link:https://github.com/nodesource/distributions/blob/master/README.md[Node.js (including npm),role=external,window=_blank]
 * Bower (`npm install -g bower`)
 * link:https://docs.bazel.build/versions/master/install.html[Bazel,role=external,window=_blank] -launched with
diff --git a/Documentation/dev-contributing.txt b/Documentation/dev-contributing.txt
index 01857da..fcc8b7e 100644
--- a/Documentation/dev-contributing.txt
+++ b/Documentation/dev-contributing.txt
@@ -174,7 +174,8 @@
 While the design doc is still in review, contributors may already start
 with the implementation (e.g. do some prototyping to demonstrate parts
 of the proposed design), but those changes should not be submitted
-while the design wasn't approved yet.
+while the design wasn't approved yet. Another way to demonstrate the
+design can be to add screenshots or the like, early enough in the doc.
 
 By approving a design, the steering committee commits to:
 
diff --git a/Documentation/dev-release.txt b/Documentation/dev-release.txt
index a7240e2..0849c56 100644
--- a/Documentation/dev-release.txt
+++ b/Documentation/dev-release.txt
@@ -153,7 +153,7 @@
 Tag the plugins:
 
 ----
-  git submodule foreach '[ "$path" == "modules/jgit" ] || git tag -s -m "v$version" "v$version"'
+  git submodule foreach '[ "$sm_path" == "modules/jgit" ] || git tag -s -m "v$version" "v$version"'
 ----
 
 [[build-gerrit]]
@@ -324,7 +324,7 @@
 Push the new Release Tag on the plugins:
 
 ----
-  git submodule foreach git push gerrit-review tag v$version
+  git submodule foreach '[ "$sm_path" == "modules/jgit" ] || git push gerrit-review tag "v$version"'
 ----
 
 [[upload-documentation]]
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/intro-user.txt b/Documentation/intro-user.txt
index 0408d5d..3aeeccb 100644
--- a/Documentation/intro-user.txt
+++ b/Documentation/intro-user.txt
@@ -686,6 +686,45 @@
 It is also possible to link:user-inline-edit.html#create-change[create
 new changes inline].
 
+[[roles]]
+== Roles
+
+Making and reviewing changes usually involves multiple users that
+assume different roles:
+
+- Author:
++
+The person who wrote the code change. Recorded as author in the Git
+commit.
+
+- Committer:
++
+The person who created the Git commit, e.g. the person that executed
+the `git commit` command. Recorded as committer in the Git commit.
+
+- Uploader:
++
+The user that uploaded the commit as a patch set to Gerrit, e.g. the
+user that executed the `git push` command.
++
+The uploader of the first patch set is the change owner.
++
+The uploader of the latest patch set, the user that uploaded the
+current patch set, is relevant when [self approvals on labels are
+ignored](config-labels.html#label_ignoreSelfApproval), as in this case
+approvals from the uploader of the latest patch set are ignored.
+
+- Change Owner:
++
+The user that created the change, e.g. uploaded the first patch set.
+
+- Reviewer:
++
+A user that has reviewed the change or has been asked to review the change.
+
+Often one user assumes several of these roles, but it's possible that each role
+is assumed by a different user.
+
 [[project-administration]]
 == Project Administration
 
diff --git a/Documentation/js_licenses.txt b/Documentation/js_licenses.txt
index e611ff8..9ac438a 100644
--- a/Documentation/js_licenses.txt
+++ b/Documentation/js_licenses.txt
@@ -367,6 +367,7 @@
 * @polymer/paper-dialog-behavior
 * @polymer/paper-dialog-scrollable
 * @polymer/paper-dropdown-menu
+* @polymer/paper-fab
 * @polymer/paper-icon-button
 * @polymer/paper-input
 * @polymer/paper-item
@@ -589,6 +590,39 @@
 ----
 
 
+[[codemirror-minified]]
+codemirror-minified
+
+* codemirror-minified
+
+[[codemirror-minified_license]]
+----
+The MIT License (MIT)
+
+Copyright (c) 2016 Marijn Haverbeke <marijnh@gmail.com> and others
+Copyright (c) 2016 Michael Zhou <zhoumotongxue008@gmail.com>
+
+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.
+
+----
+
+
 [[font-roboto-local-fonts-roboto]]
 font-roboto-local-fonts-roboto
 
diff --git a/Documentation/licenses.txt b/Documentation/licenses.txt
index 0df8415..1bff25c 100644
--- a/Documentation/licenses.txt
+++ b/Documentation/licenses.txt
@@ -3326,6 +3326,7 @@
 * @polymer/paper-dialog-behavior
 * @polymer/paper-dialog-scrollable
 * @polymer/paper-dropdown-menu
+* @polymer/paper-fab
 * @polymer/paper-icon-button
 * @polymer/paper-input
 * @polymer/paper-item
@@ -3548,6 +3549,39 @@
 ----
 
 
+[[codemirror-minified]]
+codemirror-minified
+
+* codemirror-minified
+
+[[codemirror-minified_license]]
+----
+The MIT License (MIT)
+
+Copyright (c) 2016 Marijn Haverbeke <marijnh@gmail.com> and others
+Copyright (c) 2016 Michael Zhou <zhoumotongxue008@gmail.com>
+
+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.
+
+----
+
+
 [[font-roboto-local-fonts-roboto]]
 font-roboto-local-fonts-roboto
 
diff --git a/Documentation/note-db.txt b/Documentation/note-db.txt
index a13cbfb..b376d6e 100644
--- a/Documentation/note-db.txt
+++ b/Documentation/note-db.txt
@@ -36,6 +36,67 @@
 not available in 3.0, so any upgrade from Gerrit 2.x to 3.x must go through
 2.16 to effect the NoteDb upgrade.
 
+== Format
+
+Each review ("change") in Gerrit is numbered. The different revisions
+("patchsets") of a change 12345 are stored under
+----
+  refs/changes/45/12345/${PATCHSET_NUMBER}
+----
+
+The revisions are stored as commits to the main project, ie. if you
+fetch this ref, you can check out the proposed change.
+
+A change 12345 has its review metadata under
+----
+  refs/changes/45/12345/meta
+----
+The metadata is a notes branch. The commit messages on the branch hold
+modifications to global data of the change (votes, global comments). The inline
+comments are in a
+link:https://git.eclipse.org/r/plugins/gitiles/jgit/jgit/\+/master/org.eclipse.jgit/src/org/eclipse/jgit/notes/NoteMap.java[NoteMap],
+where the key is the commit SHA-1 of the patchset
+that the comment refers to, and the value is JSON data. The format of the
+JSON is in the
+link:https://gerrit.googlesource.com/gerrit/\+/master/java/com/google/gerrit/server/notedb/RevisionNoteData.java[RevisionNoteData]
+which contains 
+link:https://gerrit.googlesource.com/gerrit/\+/master/java/com/google/gerrit/entities/Comment.java[Comment] entities.
+
+For example:
+----
+   {
+      "key": {
+        "uuid": "c7be1334_47885e36",
+        "filename":
+"java/com/google/gerrit/server/restapi/project/CommitsCollection.java",
+        "patchSetId": 7
+      },
+      "lineNbr": 158,
+      "author": {
+        "id": 1026112
+      },
+      "writtenOn": "2019-11-06T09:00:50Z",
+      "side": 1,
+      "message": "nit: factor this out in a variable, use
+toImmutableList as collector",
+      "range": {
+        "startLine": 156,
+        "startChar": 32,
+        "endLine": 158,
+        "endChar": 66
+      },
+      "revId": "071c601d6ee1a2a9f520415fd9efef8e00f9cf60",
+      "serverId": "173816e5-2b9a-37c3-8a2e-48639d4f1153",
+      "unresolved": true
+    },
+----
+
+Automated systems may post "robot comments" instead of normal
+comments, which are an extension of the previous comment, defined in
+the
+link:https://gerrit.googlesource.com/gerrit/\+/master/java/com/google/gerrit/entities/RobotComment.java[RobotComment]
+class.
+
 [[migration]]
 == Migration
 
diff --git a/Documentation/prolog-cookbook.txt b/Documentation/prolog-cookbook.txt
index 189ccfc..c083f28 100644
--- a/Documentation/prolog-cookbook.txt
+++ b/Documentation/prolog-cookbook.txt
@@ -23,6 +23,11 @@
 `rules.enable=false` in the Gerrit config file (see
 link:config-gerrit.html#_a_id_rules_a_section_rules[rules section])
 
+[NOTE]
+Gerrit's default submit rule is skipped if a project contains prolog rules.
+The prolog submit rules are responsible for returning the necessary labels in
+this case.
+
 link:https://groups.google.com/d/topic/repo-discuss/wJxTGhlHZMM/discussion[This
 discussion thread,role=external,window=_blank] explains why Prolog was chosen for the purpose of writing
 project specific submit rules.
diff --git a/Documentation/replace_macros.py b/Documentation/replace_macros.py
index aaa9223..f270231 100755
--- a/Documentation/replace_macros.py
+++ b/Documentation/replace_macros.py
@@ -1,4 +1,4 @@
-#!/usr/bin/env python
+#!/usr/bin/env python3
 # coding=utf-8
 # Copyright (C) 2013 The Android Open Source Project
 #
diff --git a/Documentation/rest-api-changes.txt b/Documentation/rest-api-changes.txt
index cd80d3d..9c3c8b6 100644
--- a/Documentation/rest-api-changes.txt
+++ b/Documentation/rest-api-changes.txt
@@ -1741,6 +1741,18 @@
 As response a link:#change-info[ChangeInfo] entity is returned that
 describes the submitted/merged change.
 
+Submission may submit multiple changes, but we still only return one ChangeInfo
+object. To query for all submitted changes, please use the submission_id that is
+part of the response.
+
+Changes that will also be submitted:
+1. All changes of the same topic if
+link:config-gerrit.html#change.submitWholeTopic[`change.submitWholeTopic`]
+configuration is set to true.
+2. All dependent changes.
+3. The closure of the above (e.g if a dependent change has a topic, all changes
+of *that* topic will also be submitted).
+
 .Response
 ----
   HTTP/1.1 200 OK
@@ -3360,7 +3372,7 @@
   }
 ----
 
-As response an link:#add-reviewer-result[AddReviewerResult] entity is
+As response an link:#reviewer-result[ReviewerResult] entity is
 returned that describes the newly added reviewers.
 
 .Response
@@ -3573,6 +3585,9 @@
 Deletes a single vote from a change. Note, that even when the last vote of
 a reviewer is removed the reviewer itself is still listed on the change.
 
+If another user removed a user's vote, the user with the deleted vote will be
+added to the attention set.
+
 .Request
 ----
   DELETE /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/reviewers/John%20Doe/votes/Code-Review HTTP/1.0
@@ -4163,7 +4178,7 @@
 Each element of the `reviewers` list is an instance of
 link:#reviewer-input[ReviewerInput]. The corresponding result of
 adding each reviewer will be returned in a map of inputs to
-link:#add-reviewer-result[AddReviewerResult]s.
+link:#reviewer-result[ReviewerResult]s.
 
 .Response
 ----
@@ -4403,14 +4418,27 @@
 --
 
 Submits a revision.
+Submitting a change also removes all users from the link:#attention-set[attention set].
 
 .Request
 ----
   POST /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/revisions/674ac754f91e64a0efb8087e59a176484bd534d1/submit HTTP/1.0
 ----
 
-As response a link:#submit-info[SubmitInfo] entity is returned that
-describes the status of the submitted change.
+As response a link:#change-info[ChangeInfo] entity is returned that
+describes the submitted/merged change.
+
+Submission may submit multiple changes, but we still only return one ChangeInfo
+object. To query for all submitted changes, please use the submission_id that is
+part of the response.
+
+Changes that will also be submitted:
+1. All changes of the same topic if
+link:config-gerrit.html#change.submitWholeTopic[`change.submitWholeTopic`]
+configuration is set to true.
+2. All dependent changes.
+3. The closure of the above (e.g if a dependent change has a topic, all changes
+of *that* topic will also be submitted).
 
 .Response
 ----
@@ -4420,7 +4448,19 @@
 
   )]}'
   {
-    "status": "MERGED"
+    "id": "myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940",
+    "project": "myProject",
+    "branch": "master",
+    "change_id": "I8473b95934b5732ac55d26311a706c9c2bde9940",
+    "subject": "Implementing Feature X",
+    "status": "MERGED",
+    "created": "2013-02-01 09:59:32.126000000",
+    "updated": "2013-02-21 11:16:36.775000000",
+    "submitted": "2013-02-21 11:16:36.615000000",
+    "_number": 3965,
+    "owner": {
+      "name": "John Doe"
+    }
   }
 ----
 
@@ -5800,6 +5840,11 @@
 link:#cherrypick-input[CherryPickInput] entity.  If the commit message
 does not specify a Change-Id, a new one is picked for the destination change.
 
+When cherry-picking a change into a branch that already contains the Change-Id
+that we want to cherry-pick, the cherry-pick will create a new patch-set on the
+destination's branch's appropriate Change-Id. If the change is closed on the
+destination branch, the cherry-pick will fail.
+
 .Request
 ----
   POST /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/revisions/674ac754f91e64a0efb8087e59a176484bd534d1/cherrypick HTTP/1.0
@@ -5937,6 +5982,9 @@
 Note, that even when the last vote of a reviewer is removed the reviewer itself
 is still listed on the change.
 
+If another user removed a user's vote, the user with the deleted vote will be
+added to the attention set.
+
 .Request
 ----
   DELETE /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/revisions/674ac754f91e64a0efb8087e59a176484bd534d1/reviewers/John%20Doe/votes/Code-Review HTTP/1.0
@@ -6115,6 +6163,9 @@
 * The change is marked ready for review.
 * As an owner/uploader, when someone replies on your change.
 * As a reviewer, when the owner/uploader replies.
+* When the user's vote is deleted by another user.
+* The rules above (except manually adding to the attention set) don't apply
+ for changes that are work in progress.
 
 Users are removed from the attention set if one the following apply:
 
@@ -6258,33 +6309,6 @@
 at the server or permissions are modified. Not present if false.
 |====================================
 
-[[add-reviewer-result]]
-=== AddReviewerResult
-The `AddReviewerResult` entity describes the result of adding a
-reviewer to a change.
-
-[options="header",cols="1,^1,5"]
-|===========================
-|Field Name    ||Description
-|`input`    ||
-Value of the `reviewer` field from link:#reviewer-input[ReviewerInput]
-set while adding the reviewer.
-|`reviewers`   |optional|
-The newly added reviewers as a list of link:#reviewer-info[
-ReviewerInfo] entities.
-|`ccs`         |optional|
-The newly CCed accounts as a list of link:#reviewer-info[
-ReviewerInfo] entities. This field will only appear if the requested
-`state` for the reviewer was `CC` *and* NoteDb is enabled on the
-server.
-|`error`       |optional|
-Error message explaining why the reviewer could not be added. +
-If a group was specified in the input and an error is returned, it
-means that none of the members were added as reviewer.
-|`confirm`     |`false` if not set|
-Whether adding the reviewer requires confirmation.
-|===========================
-
 [[approval-info]]
 === ApprovalInfo
 The `ApprovalInfo` entity contains information about an approval from a
@@ -6429,8 +6453,7 @@
 The assignee of the change as an link:rest-api-accounts.html#account-info[
 AccountInfo] entity.
 |`hashtags`           |optional|
-List of hashtags that are set on the change (only populated when NoteDb
-is enabled).
+List of hashtags that are set on the change.
 |`change_id`          ||The Change-Id of the change.
 |`subject`            ||
 The subject of the change (header line of the commit message).
@@ -6664,7 +6687,12 @@
 Set if the message was posted on behalf of another user.
 |`date`            ||
 The link:rest-api.html#timestamp[timestamp] this message was posted.
-|`message`            ||The text left by the user.
+|`message`            ||
+The text left by the user or Gerrit system. Accounts are served as account IDs
+inlined in the text as `<GERRIT_ACCOUNT_18419>`.
+All accounts, used in message, can be found in `accountsInMessage`
+field.
+|`accountsInMessage`            ||Accounts, used in `message`.
 |`tag`                 |optional|
 Value of the `tag` field from link:#review-input[ReviewInput] set
 while posting the review. Votes/comments that contain `tag` with
@@ -6893,7 +6921,10 @@
 The subject of the commit (header line of the commit message).
 |`message`     ||The commit message.
 |`web_links`   |optional|
-Links to the commit in external sites as a list of
+Links to the patch set in external sites as a list of
+link:#web-link-info[WebLinkInfo] entities.
+|`resolve_conflicts_web_links`   |optional|
+Links to the commit in external sites for resolving conflicts as a list of
 link:#web-link-info[WebLinkInfo] entities.
 |===========================
 
@@ -7069,6 +7100,9 @@
 |`web_links`       |optional|
 Links to the file diff in external sites as a list of
 link:rest-api-changes.html#diff-web-link-info[DiffWebLinkInfo] entries.
+|`edit_web_links`   |optional|
+Links to edit the file in external sites as a list of
+link:rest-api-changes.html#web-link-info[WebLinkInfo] entries.
 |`binary`          |not set if `false`|Whether the file is binary.
 |==========================
 
@@ -7756,6 +7790,13 @@
 Only `KEEP` is allowed when used in conjunction with `on_behalf_of`. +
 If not set, the default is `KEEP`. If `on_behalf_of` is set, then no other value
 besides `KEEP` is allowed.
+|`draft_ids_to_publish`                |optional|
+List of draft IDs to be published. The draft IDs should belong to the current
+user and be valid. If `drafts` is set to `PUBLISH`, then draft IDs should
+contain drafts for the same revision that is requested for review. If `drafts`
+is set to `KEEP`, then `draft_ids_to_publish` will be ignored and no draft
+comments will be published. +
+If not set, the default is to publish all drafts according to the `drafts` field.
 |`notify`                              |optional|
 Notify handling that defines to whom email notifications should be sent
 after the review is stored. +
@@ -7773,7 +7814,7 @@
 have been granted `labelAs-NAME` permission for all keys of labels.
 |`reviewers`                           |optional|
 A list of link:rest-api-changes.html#reviewer-input[ReviewerInput]
-representing reviewers that should be added to the change.
+representing reviewers that should be added/removed to/from the change.
 |`ready`                               |optional|
 If true, and if the change is work in progress, then start review.
 It is an error for both `ready` and `work_in_progress` to be true.
@@ -7806,8 +7847,8 @@
 additions were rejected.
 |`reviewers`              |optional|
 Map of account or group identifier to
-link:rest-api-changes.html#add-reviewer-result[AddReviewerResult]
-representing the outcome of adding as a reviewer.
+link:rest-api-changes.html#reviewer-result[ReviewerResult]
+representing the outcome of adding/removing a reviewer.
 Absent if no reviewer additions were requested.
 |`ready`                  |optional|
 If true, the change was moved from WIP to ready for review as a result of this
@@ -7838,24 +7879,25 @@
 
 [[reviewer-input]]
 === ReviewerInput
-The `ReviewerInput` entity contains information for adding a reviewer
-to a change.
+The `ReviewerInput` entity contains information for adding or removing
+reviewers to/from the change.
 
 [options="header",cols="1,^1,5"]
 |=============================
 |Field Name      ||Description
 |`reviewer`      ||
 The link:rest-api-accounts.html#account-id[ID] of one account that
-should be added as reviewer or the link:rest-api-groups.html#group-id[
+should be added/removed as reviewer or the link:rest-api-groups.html#group-id[
 ID] of one internal group for which all members should be added as reviewers. +
 If an ID identifies both an account and a group, only the account is
 added as reviewer to the change.
 External groups, such as LDAP groups, will be silently omitted from a
 link:#set-review[set-review] or
-link:rest-api-changes.html#add-reviewer[add-reviewer] call.
+link:rest-api-changes.html#add-reviewer[add-reviewer] call. A group can only be
+specified for adding reviewers, not for removing them.
 |`state`         |optional|
-Add reviewer in this state. Possible reviewer states are `REVIEWER`
-and `CC`. If not given, defaults to `REVIEWER`.
+Add reviewer in this state. Possible reviewer states are `REVIEWER`,
+`CC` and `REMOVED`. If not given, defaults to `REVIEWER`.
 |`confirmed`     |optional|
 Whether adding the reviewer is confirmed. +
 The Gerrit server may be configured to
@@ -7872,6 +7914,37 @@
 link:#notify-info[NotifyInfo] entity.
 |=============================
 
+[[reviewer-result]]
+=== ReviewerResult
+The `ReviewerResult` entity describes the result of modifying reviewers of
+a change.
+
+[options="header",cols="1,^1,5"]
+|===========================
+|Field Name    ||Description
+|`input`    ||
+Value of the `reviewer` field from link:#reviewer-input[ReviewerInput]
+set while adding the reviewer.
+|`reviewers`   |optional|
+The newly added reviewers as a list of link:#reviewer-info[
+ReviewerInfo] entities.
+|`ccs`         |optional|
+The newly CCed accounts as a list of
+link:rest-api-accounts.html#account-info[AccountInfo] entities. This field will
+only appear if the requested `state` for the reviewer was `CC`.
+|`removed`      |optional|
+The newly removed accounts as a list of
+link:rest-api-accounts.html#account-info[AccountInfo] entities.
+This field will only appear if the requested `state` for the reviewer was
+`REMOVED`.
+|`error`       |optional|
+Error message explaining why the reviewer could not be added. +
+If a group was specified in the input and an error is returned, it
+means that none of the members were added as reviewer.
+|`confirm`     |`false` if not set|
+Whether adding the reviewer requires confirmation.
+|===========================
+
 [[revision-info]]
 === RevisionInfo
 The `RevisionInfo` entity contains information about a patch set.
@@ -8003,27 +8076,6 @@
 to return results from the input rule.
 |===========================
 
-[[submit-info]]
-=== SubmitInfo
-The `SubmitInfo` entity contains information about the change status
-after submitting.
-
-[options="header",cols="1,^1,5"]
-|==========================
-|Field Name    ||Description
-|`status`      ||
-The status of the change after submitting is `MERGED`.
-|`on_behalf_of`|optional|
-The link:rest-api-accounts.html#account-id[\{account-id\}] of the user on
-whose behalf the action should be done. To use this option the caller must
-have been granted both `Submit` and `Submit (On Behalf Of)` permissions.
-The user named by `on_behalf_of` does not need to be granted the `Submit`
-permission. This feature is aimed for CI solutions: the CI account can be
-granted both permissions, so individual users don't need `Submit` permission
-themselves. Still the changes can be submitted on behalf of real users and
-not with the identity of the CI account.
-|==========================
-
 [[submit-input]]
 === SubmitInput
 The `SubmitInput` entity contains information for submitting a change.
diff --git a/Documentation/rest-api-projects.txt b/Documentation/rest-api-projects.txt
index e30ce3a..eb38434 100644
--- a/Documentation/rest-api-projects.txt
+++ b/Documentation/rest-api-projects.txt
@@ -2621,6 +2621,11 @@
 If the commit message is not set, the commit message of the source
 commit will be used.
 
+When cherry-picking a commit into a branch that already contains the Change-Id
+that we want to cherry-pick, the cherry-pick will create a new patch-set on the
+destination's branch's appropriate Change-Id. If the change is closed on the
+destination branch, the cherry-pick will fail.
+
 .Request
 ----
   POST /projects/work%2Fmy-project/commits/a8a477efffbbf3b44169bb9a1d3a334cbbd9aa96/cherrypick HTTP/1.0
@@ -3959,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.
@@ -4029,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/user-attention-set.txt b/Documentation/user-attention-set.txt
index 95e1258..1f67fc7 100644
--- a/Documentation/user-attention-set.txt
+++ b/Documentation/user-attention-set.txt
@@ -47,8 +47,11 @@
   attention set.
 * For merged and abandoned changes the owner is added only when a human creates
   an unresolved comment.
+* If another user removed a user's vote, the user with the deleted vote will be
+  added to the attention set.
 * Only owner, uploader, reviewers and ccs can be in the attention set.
 * The rules for service accounts are different, see link:#bots[Bots].
+* Users are not added by automatic rules when the change is work in progress.
 
 *!IMPORTANT!* These rules are not meant to be super smart and to always do the
 right thing, e.g. if the change owner sends a reply, then they are often
diff --git a/Documentation/user-review-ui.txt b/Documentation/user-review-ui.txt
index b33bea8..c5feaee 100644
--- a/Documentation/user-review-ui.txt
+++ b/Documentation/user-review-ui.txt
@@ -251,6 +251,7 @@
 The list of commits that are being integrated into the destination
 branch by submitting the merge commit.
 
+
 [[patch-sets]]
 === Patch Sets
 
diff --git a/Documentation/user-search.txt b/Documentation/user-search.txt
index 377012a..a2dc31f 100644
--- a/Documentation/user-search.txt
+++ b/Documentation/user-search.txt
@@ -251,6 +251,16 @@
 often combined with 'branch:' and 'project:' operators to select
 all related changes in a series.
 
+[[inhashtag]]
+inhashtag:'HASHTAG'::
++
+Changes where any hashtag contains 'HASHTAG', using a full-text search.
++
+If 'HASHTAG' starts with `^` it matches hashtag names by regular
+expression patterns.  The
+link:http://www.brics.dk/automaton/[dk.brics.automaton
+library,role=external,window=_blank] is used for evaluation of such patterns.
+
 [[hashtag]]
 hashtag:'HASHTAG'::
 +
diff --git a/WORKSPACE b/WORKSPACE
index 8dad0f9..428a6a4 100644
--- a/WORKSPACE
+++ b/WORKSPACE
@@ -66,8 +66,8 @@
 
 http_archive(
     name = "build_bazel_rules_nodejs",
-    sha256 = "dd7ea7efda7655c218ca707f55c3e1b9c68055a70c31a98f264b3445bc8f4cb1",
-    urls = ["https://github.com/bazelbuild/rules_nodejs/releases/download/3.2.3/rules_nodejs-3.2.3.tar.gz"],
+    sha256 = "10f534e1c80f795cffe1f2822becd4897754d18564612510c59b3c73544ae7c6",
+    urls = ["https://github.com/bazelbuild/rules_nodejs/releases/download/3.5.0/rules_nodejs-3.5.0.tar.gz"],
 )
 
 # Golang support for PolyGerrit local dev server.
@@ -976,197 +976,6 @@
     yarn_lock = "//:plugins/yarn.lock",
 )
 
-load("//tools/bzl:js.bzl", "bower_archive", "npm_binary")
-
-# NPM binaries bundled along with their dependencies.
-#
-# For full instructions on adding new binaries to the build, see
-# http://gerrit-review.googlesource.com/Documentation/dev-bazel.html#npm-binary
-npm_binary(
-    name = "bower",
-)
-
-npm_binary(
-    name = "polymer-bundler",
-    repository = GERRIT,
-)
-
-npm_binary(
-    name = "crisper",
-    repository = GERRIT,
-)
-
-# bower_archive() seed components.
-bower_archive(
-    name = "iron-autogrow-textarea",
-    package = "polymerelements/iron-autogrow-textarea",
-    sha1 = "2f04c7e2a72d462de36093ab2b4889db20f699f6",
-    version = "2.2.0",
-)
-
-bower_archive(
-    name = "es6-promise",
-    package = "stefanpenner/es6-promise",
-    sha1 = "a3a797bb22132f1ef75f9a2556173f81870c2e53",
-    version = "3.3.0",
-)
-
-bower_archive(
-    name = "fetch",
-    package = "fetch",
-    sha1 = "1b05a2bb40c73232c2909dc196de7519fe4db7a9",
-    version = "1.0.0",
-)
-
-bower_archive(
-    name = "iron-dropdown",
-    package = "polymerelements/iron-dropdown",
-    sha1 = "3902ba164552b1bfc59e6fa692efa4a1fd8dd4ea",
-    version = "2.2.1",
-)
-
-bower_archive(
-    name = "iron-input",
-    package = "polymerelements/iron-input",
-    sha1 = "f79952ff4f6f103c0a2cbd3dacf25935257ff392",
-    version = "2.1.3",
-)
-
-bower_archive(
-    name = "iron-overlay-behavior",
-    package = "polymerelements/iron-overlay-behavior",
-    sha1 = "c2d2eac1b162420d9475ade2f16d5db8959b93fc",
-    version = "2.3.4",
-)
-
-bower_archive(
-    name = "iron-selector",
-    package = "polymerelements/iron-selector",
-    sha1 = "3f3fcb55f6bd606ea493f99eab9daae21f7a6139",
-    version = "2.1.0",
-)
-
-bower_archive(
-    name = "paper-button",
-    package = "polymerelements/paper-button",
-    sha1 = "bcb783d74e1177c1d0836340e7c0280699d1438c",
-    version = "2.1.3",
-)
-
-bower_archive(
-    name = "paper-input",
-    package = "polymerelements/paper-input",
-    sha1 = "c1a81a4173d22e72e8ab609eb3715a75273396b3",
-    version = "2.2.3",
-)
-
-bower_archive(
-    name = "paper-tabs",
-    package = "polymerelements/paper-tabs",
-    sha1 = "589b8e6efa0f171c93233137c8ea013dcea0ffc7",
-    version = "2.1.1",
-)
-
-bower_archive(
-    name = "iron-icon",
-    package = "polymerelements/iron-icon",
-    sha1 = "d21e7d4f1bdc6de881390f888e28d53155eeb551",
-    version = "2.1.0",
-)
-
-bower_archive(
-    name = "iron-iconset-svg",
-    package = "polymerelements/iron-iconset-svg",
-    sha1 = "07c0ce02ce6479856758893416a3709009db7f22",
-    version = "2.2.1",
-)
-
-bower_archive(
-    name = "moment",
-    package = "moment/moment",
-    sha1 = "fc8ce2c799bab21f6ced7aff928244f4ca8880aa",
-    version = "2.13.0",
-)
-
-bower_archive(
-    name = "page",
-    package = "visionmedia/page.js",
-    sha1 = "4a31889cd75cc5e7f68a4c7f256eecaf27102eee",
-    version = "1.11.4",
-)
-
-bower_archive(
-    name = "paper-item",
-    package = "polymerelements/paper-item",
-    sha1 = "c3bad022cf182d2bf1c8a44374c7fcb1409afbfa",
-    version = "2.1.1",
-)
-
-bower_archive(
-    name = "paper-listbox",
-    package = "polymerelements/paper-listbox",
-    sha1 = "78247cc32bb776f204efef17cff3095878036a40",
-    version = "2.1.1",
-)
-
-bower_archive(
-    name = "paper-toggle-button",
-    package = "polymerelements/paper-toggle-button",
-    sha1 = "9927960afb0062726ec1b585ef3e32764c3bbac9",
-    version = "2.1.1",
-)
-
-bower_archive(
-    name = "polymer",
-    package = "polymer/polymer",
-    sha1 = "d06e17a1d8dc6187ee5aa8c5b3501da10901c82f",
-    version = "2.7.2",
-)
-
-bower_archive(
-    name = "polymer-resin",
-    package = "polymer/polymer-resin",
-    sha1 = "94c29926c20ea3a9b636f26b3e0d689ead8137e5",
-    version = "2.0.1",
-)
-
-bower_archive(
-    name = "resemblejs",
-    package = "rsmbl/Resemble.js",
-    sha1 = "49d5f022417c389b630d6f7ee667aa9540075c42",
-    version = "2.10.1",
-)
-
-bower_archive(
-    name = "codemirror-minified",
-    package = "Dominator008/codemirror-minified",
-    sha1 = "a1ddf3a6dcc6817597eacc52688cfe5083ded4cd",
-    version = "5.59.1",
-)
-
-# bower test stuff
-
-bower_archive(
-    name = "iron-test-helpers",
-    package = "polymerelements/iron-test-helpers",
-    sha1 = "882be2d4c8714b39299b5f7bf25253c4e8a40761",
-    version = "2.0.1",
-)
-
-bower_archive(
-    name = "test-fixture",
-    package = "polymerelements/test-fixture",
-    sha1 = "7d72ddfebf555a2dd1fc60a85427d9026b509723",
-    version = "3.0.0",
-)
-
-bower_archive(
-    name = "web-component-tester",
-    package = "polymer/web-component-tester",
-    sha1 = "d84f6a13bde5f8fd39ee208d43f33925410530d7",
-    version = "6.5.1",
-)
-
 external_plugin_deps()
 
 # When upgrading elasticsearch-rest-client, also upgrade httpcore-nio
@@ -1182,5 +991,5 @@
 maven_jar(
     name = "testcontainers-elasticsearch",
     artifact = "org.testcontainers:elasticsearch:" + TESTCONTAINERS_VERSION,
-    sha1 = "6b778a270b7529fcb9b7a6f62f3ae9d38544ce2f",
+    sha1 = "595e3a50f59cd3c1d281ca6c1bc4037e277a1353",
 )
diff --git a/contrib/check-valid-commit.py b/contrib/check-valid-commit.py
index 763ae3e..bb018f9 100755
--- a/contrib/check-valid-commit.py
+++ b/contrib/check-valid-commit.py
@@ -1,4 +1,4 @@
-#!/usr/bin/env python
+#!/usr/bin/env python3
 
 from __future__ import print_function
 
diff --git a/contrib/git-push-review b/contrib/git-push-review
index b995fc2..5a7f664 100755
--- a/contrib/git-push-review
+++ b/contrib/git-push-review
@@ -1,4 +1,4 @@
-#!/usr/bin/env python
+#!/usr/bin/env python3
 # Copyright (C) 2014 The Android Open Source Project
 #
 # Licensed under the Apache License, Version 2.0 (the "License");
diff --git a/contrib/populate-fixture-data.py b/contrib/populate-fixture-data.py
index 2341f6c..774a382 100755
--- a/contrib/populate-fixture-data.py
+++ b/contrib/populate-fixture-data.py
@@ -1,4 +1,4 @@
-#!/usr/bin/env python
+#!/usr/bin/env python3
 # Copyright (C) 2016 The Android Open Source Project
 #
 # Licensed under the Apache License, Version 2.0 (the "License");
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 b05050d..fd78bd8 100644
--- a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
+++ b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.acceptance;
 
 import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.common.truth.OptionalSubject.optionals;
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assertWithMessage;
@@ -103,6 +104,7 @@
 import com.google.gerrit.server.account.Accounts;
 import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.account.GroupCache;
+import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.change.BatchAbandon;
 import com.google.gerrit.server.change.ChangeFinder;
 import com.google.gerrit.server.change.ChangeResource;
@@ -127,6 +129,8 @@
 import com.google.gerrit.server.notedb.AbstractChangeNotes;
 import com.google.gerrit.server.notedb.ChangeNoteUtil;
 import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.notedb.ChangeNotesCommit;
+import com.google.gerrit.server.notedb.ChangeNotesCommit.ChangeNotesRevWalk;
 import com.google.gerrit.server.plugins.PluginGuiceEnvironment;
 import com.google.gerrit.server.plugins.TestServerPlugin;
 import com.google.gerrit.server.project.ProjectCache;
@@ -168,6 +172,7 @@
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.Objects;
 import java.util.Optional;
 import java.util.regex.Pattern;
 import java.util.stream.Collectors;
@@ -297,6 +302,8 @@
   protected TestRepository<InMemoryRepository> testRepo;
   protected String resourcePrefix;
   protected Description description;
+  protected GerritServer.Description testMethodDescription;
+
   protected boolean testRequiresSsh;
   protected BlockStrategy noSleepBlockStrategy = t -> {}; // Don't sleep in tests.
 
@@ -343,6 +350,13 @@
   }
 
   @After
+  public void verifyNoPiiInChangeNotes() throws RestApiException, IOException {
+    if (testMethodDescription.verifyNoPiiInChangeNotes()) {
+      verifyNoAccountDetailsInChangeNotes();
+    }
+  }
+
+  @After
   public void closeEventRecorder() {
     if (eventRecorder != null) {
       eventRecorder.close();
@@ -430,6 +444,7 @@
         GerritServer.Description.forTestClass(description, configName);
     GerritServer.Description methodDesc =
         GerritServer.Description.forTestMethod(description, configName);
+    testMethodDescription = methodDesc;
 
     testRequiresSsh = classDesc.useSshAnnotation() || methodDesc.useSshAnnotation();
     if (!testRequiresSsh) {
@@ -463,7 +478,7 @@
     toClose = Collections.synchronizedList(new ArrayList<>());
 
     admin = accountCreator.admin();
-    user = accountCreator.user();
+    user = accountCreator.user1();
 
     // Evict and reindex accounts in case tests modify them.
     reindexAccount(admin.id());
@@ -699,6 +714,58 @@
     }
   }
 
+  /**
+   * Verify that NoteDB commits do not persist user-sensitive information, by running checks for all
+   * commits in {@link RefNames#changeMetaRef} for all changes, created during the test.
+   *
+   * <p>These tests prevent regression, assuming appropriate test coverage for new features. The
+   * verification is disabled by default and can be enabled using {@link VerifyNoPiiInChangeNotes}
+   * annotation either on test class or method.
+   */
+  protected void verifyNoAccountDetailsInChangeNotes() throws RestApiException, IOException {
+    List<ChangeInfo> allChanges = gApi.changes().query().get();
+
+    List<AccountState> allAccounts = accounts.all();
+    for (ChangeInfo change : allChanges) {
+      try (Repository repo = repoManager.openRepository(Project.nameKey(change.project))) {
+        String metaRefName =
+            RefNames.changeMetaRef(Change.Id.tryParse(change._number.toString()).get());
+        ObjectId metaTip = repo.getRefDatabase().exactRef(metaRefName).getObjectId();
+        ChangeNotesRevWalk revWalk = ChangeNotesCommit.newRevWalk(repo);
+        revWalk.reset();
+        revWalk.markStart(revWalk.parseCommit(metaTip));
+        ChangeNotesCommit commit;
+        while ((commit = revWalk.next()) != null) {
+          String fullMessage = commit.getFullMessage();
+          for (AccountState accountState : allAccounts) {
+            Account account = accountState.account();
+            assertThat(fullMessage).doesNotContain(account.getName());
+            if (account.fullName() != null) {
+              assertThat(fullMessage).doesNotContain(account.fullName());
+            }
+            if (account.displayName() != null) {
+              assertThat(fullMessage).doesNotContain(account.displayName());
+            }
+            if (account.preferredEmail() != null) {
+              assertThat(fullMessage).doesNotContain(account.preferredEmail());
+            }
+            if (accountState.userName().isPresent()) {
+              assertThat(fullMessage).doesNotContain(accountState.userName().get());
+            }
+            List<String> allEmails =
+                accountState.externalIds().stream()
+                    .map(ExternalId::email)
+                    .filter(Objects::nonNull)
+                    .collect(toImmutableList());
+            for (String email : allEmails) {
+              assertThat(fullMessage).doesNotContain(email);
+            }
+          }
+        }
+      }
+    }
+  }
+
   protected TestRepository<?>.CommitBuilder commitBuilder() throws Exception {
     return testRepo.branch("HEAD").commit().insertChangeId();
   }
@@ -1268,6 +1335,7 @@
     assertThat(diff.diffHeader).isNotNull();
     assertThat(diff.intralineStatus).isNull();
     assertThat(diff.webLinks).isNull();
+    assertThat(diff.editWebLinks).isNull();
 
     assertThat(diff.metaA).isNull();
     assertThat(diff.metaB).isNotNull();
diff --git a/java/com/google/gerrit/acceptance/AccountCreator.java b/java/com/google/gerrit/acceptance/AccountCreator.java
index 1b0954e..aa13339 100644
--- a/java/com/google/gerrit/acceptance/AccountCreator.java
+++ b/java/com/google/gerrit/acceptance/AccountCreator.java
@@ -29,8 +29,8 @@
 import com.google.gerrit.server.account.GroupCache;
 import com.google.gerrit.server.account.ServiceUserClassifier;
 import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.group.db.GroupDelta;
 import com.google.gerrit.server.group.db.GroupsUpdate;
-import com.google.gerrit.server.group.db.InternalGroupUpdate;
 import com.google.gerrit.server.notedb.Sequences;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -145,8 +145,8 @@
     return create("admin2", "admin2@example.com", "Administrator2", null, "Administrators");
   }
 
-  public TestAccount user() throws Exception {
-    return create("user", "user@example.com", "User", null);
+  public TestAccount user1() throws Exception {
+    return create("user1", "user1@example.com", "User1", null);
   }
 
   public TestAccount user2() throws Exception {
@@ -168,10 +168,10 @@
 
   private void addGroupMember(AccountGroup.UUID groupUuid, Account.Id accountId)
       throws IOException, NoSuchGroupException, ConfigInvalidException {
-    InternalGroupUpdate groupUpdate =
-        InternalGroupUpdate.builder()
+    GroupDelta groupDelta =
+        GroupDelta.builder()
             .setMemberModification(memberIds -> Sets.union(memberIds, ImmutableSet.of(accountId)))
             .build();
-    groupsUpdateProvider.get().updateGroup(groupUuid, groupUpdate);
+    groupsUpdateProvider.get().updateGroup(groupUuid, groupDelta);
   }
 }
diff --git a/java/com/google/gerrit/acceptance/ExtensionRegistry.java b/java/com/google/gerrit/acceptance/ExtensionRegistry.java
index 35f8ce6..85c4c13 100644
--- a/java/com/google/gerrit/acceptance/ExtensionRegistry.java
+++ b/java/com/google/gerrit/acceptance/ExtensionRegistry.java
@@ -32,8 +32,10 @@
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.extensions.registration.PrivateInternals_DynamicMapImpl;
 import com.google.gerrit.extensions.registration.RegistrationHandle;
+import com.google.gerrit.extensions.webui.EditWebLink;
 import com.google.gerrit.extensions.webui.FileHistoryWebLink;
 import com.google.gerrit.extensions.webui.PatchSetWebLink;
+import com.google.gerrit.extensions.webui.ResolveConflictsWebLink;
 import com.google.gerrit.server.ExceptionHook;
 import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.change.ChangeETagComputation;
@@ -75,6 +77,8 @@
   private final DynamicSet<GitReferenceUpdatedListener> refUpdatedListeners;
   private final DynamicSet<FileHistoryWebLink> fileHistoryWebLinks;
   private final DynamicSet<PatchSetWebLink> patchSetWebLinks;
+  private final DynamicSet<ResolveConflictsWebLink> resolveConflictsWebLinks;
+  private final DynamicSet<EditWebLink> editWebLinks;
   private final DynamicSet<RevisionCreatedListener> revisionCreatedListeners;
   private final DynamicSet<GroupBackend> groupBackends;
   private final DynamicSet<AccountActivationValidationListener>
@@ -109,6 +113,8 @@
       DynamicSet<GitReferenceUpdatedListener> refUpdatedListeners,
       DynamicSet<FileHistoryWebLink> fileHistoryWebLinks,
       DynamicSet<PatchSetWebLink> patchSetWebLinks,
+      DynamicSet<ResolveConflictsWebLink> resolveConflictsWebLinks,
+      DynamicSet<EditWebLink> editWebLinks,
       DynamicSet<RevisionCreatedListener> revisionCreatedListeners,
       DynamicSet<GroupBackend> groupBackends,
       DynamicSet<AccountActivationValidationListener> accountActivationValidationListeners,
@@ -139,6 +145,8 @@
     this.refUpdatedListeners = refUpdatedListeners;
     this.fileHistoryWebLinks = fileHistoryWebLinks;
     this.patchSetWebLinks = patchSetWebLinks;
+    this.editWebLinks = editWebLinks;
+    this.resolveConflictsWebLinks = resolveConflictsWebLinks;
     this.revisionCreatedListeners = revisionCreatedListeners;
     this.groupBackends = groupBackends;
     this.accountActivationValidationListeners = accountActivationValidationListeners;
@@ -240,6 +248,14 @@
       return add(patchSetWebLinks, patchSetWebLink);
     }
 
+    public Registration add(ResolveConflictsWebLink resolveConflictsWebLink) {
+      return add(resolveConflictsWebLinks, resolveConflictsWebLink);
+    }
+
+    public Registration add(EditWebLink editWebLink) {
+      return add(editWebLinks, editWebLink);
+    }
+
     public Registration add(RevisionCreatedListener revisionCreatedListener) {
       return add(revisionCreatedListeners, revisionCreatedListener);
     }
diff --git a/java/com/google/gerrit/acceptance/GerritServer.java b/java/com/google/gerrit/acceptance/GerritServer.java
index f2cc9d1..93c1237 100644
--- a/java/com/google/gerrit/acceptance/GerritServer.java
+++ b/java/com/google/gerrit/acceptance/GerritServer.java
@@ -43,6 +43,7 @@
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperationsImpl;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.annotations.Exports;
 import com.google.gerrit.extensions.config.FactoryModule;
 import com.google.gerrit.lucene.LuceneIndexModule;
 import com.google.gerrit.pgm.Daemon;
@@ -52,6 +53,7 @@
 import com.google.gerrit.server.config.SitePath;
 import com.google.gerrit.server.experiments.ConfigExperimentFeatures;
 import com.google.gerrit.server.git.receive.AsyncReceiveCommits;
+import com.google.gerrit.server.git.validators.CommitValidationListener;
 import com.google.gerrit.server.schema.JdbcAccountPatchReviewStore;
 import com.google.gerrit.server.ssh.NoSshModule;
 import com.google.gerrit.server.util.ReplicaUtil;
@@ -109,6 +111,8 @@
   public abstract static class Description {
     public static Description forTestClass(
         org.junit.runner.Description testDesc, String configName) {
+      VerifyNoPiiInChangeNotes verifyNoPiiInChangeNotes =
+          get(VerifyNoPiiInChangeNotes.class, testDesc.getTestClass());
       return new AutoValue_GerritServer_Description(
           testDesc,
           configName,
@@ -117,6 +121,7 @@
           has(Sandboxed.class, testDesc.getTestClass()),
           has(SkipProjectClone.class, testDesc.getTestClass()),
           has(UseSsh.class, testDesc.getTestClass()),
+          verifyNoPiiInChangeNotes != null && verifyNoPiiInChangeNotes.value(),
           false, // @UseSystemTime is only valid on methods.
           get(UseClockStep.class, testDesc.getTestClass()),
           get(UseTimezone.class, testDesc.getTestClass()),
@@ -136,6 +141,11 @@
         // on class level.
         useClockStep = get(UseClockStep.class, testDesc.getTestClass());
       }
+      VerifyNoPiiInChangeNotes verifyNoPiiInChangeNotes =
+          testDesc.getAnnotation(VerifyNoPiiInChangeNotes.class);
+      if (verifyNoPiiInChangeNotes == null) {
+        verifyNoPiiInChangeNotes = get(VerifyNoPiiInChangeNotes.class, testDesc.getTestClass());
+      }
 
       return new AutoValue_GerritServer_Description(
           testDesc,
@@ -151,6 +161,7 @@
               || has(SkipProjectClone.class, testDesc.getTestClass()),
           testDesc.getAnnotation(UseSsh.class) != null
               || has(UseSsh.class, testDesc.getTestClass()),
+          verifyNoPiiInChangeNotes != null && verifyNoPiiInChangeNotes.value(),
           testDesc.getAnnotation(UseSystemTime.class) != null,
           useClockStep,
           testDesc.getAnnotation(UseTimezone.class) != null
@@ -196,6 +207,8 @@
 
     abstract boolean useSshAnnotation();
 
+    abstract boolean verifyNoPiiInChangeNotes();
+
     boolean useSsh() {
       return useSshAnnotation() && SshMode.useSsh();
     }
@@ -399,6 +412,15 @@
       daemon.addAdditionalSshModuleForTesting(testSshModule);
     }
     daemon.setEnableSshd(desc.useSsh());
+    daemon.addAdditionalSysModuleForTesting(
+        new AbstractModule() {
+          @Override
+          protected void configure() {
+            bind(CommitValidationListener.class)
+                .annotatedWith(Exports.named("object-visibility-listener"))
+                .to(GitObjectVisibilityChecker.class);
+          }
+        });
 
     if (desc.memory()) {
       checkArgument(additionalArgs.length == 0, "cannot pass args to in-memory server");
diff --git a/java/com/google/gerrit/acceptance/GitObjectVisibilityChecker.java b/java/com/google/gerrit/acceptance/GitObjectVisibilityChecker.java
new file mode 100644
index 0000000..afa1b1d
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/GitObjectVisibilityChecker.java
@@ -0,0 +1,71 @@
+// 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;
+
+import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.server.events.CommitReceivedEvent;
+import com.google.gerrit.server.git.GitRepositoryManager;
+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.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.Collections;
+import java.util.List;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.lib.Repository;
+
+/**
+ * Checker that ensures that all Git commits that should be validated are readable using any {@link
+ * ObjectReader} on the repo. It is easy for users of the JGit API to forget to call {@code flush}
+ * on ObjectInserter which creates an illegal state for CommitValidators. This safeguard makes sure
+ * that any functionality tested in acceptance tests got this right.
+ */
+@Singleton
+public class GitObjectVisibilityChecker implements CommitValidationListener {
+  private final GitRepositoryManager gitRepositoryManager;
+
+  @Inject
+  GitObjectVisibilityChecker(GitRepositoryManager gitRepositoryManager) {
+    this.gitRepositoryManager = gitRepositoryManager;
+  }
+
+  @Override
+  public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent)
+      throws CommitValidationException {
+    try {
+      try (Repository repo = gitRepositoryManager.openRepository(receiveEvent.getProjectNameKey());
+          ObjectReader reader = repo.newObjectReader()) {
+        if (!reader.has(receiveEvent.commit)) {
+          throw new IllegalStateException(
+              String.format(
+                  "Commit %s was not visible using a new object reader in the repo. "
+                      + "This creates an illegal state for commit validators. You must flush any ObjectReaders "
+                      + "before performing the ref transaction.",
+                  receiveEvent.commit));
+        }
+      }
+    } catch (IOException e) {
+      throw new StorageException(e);
+    }
+    return Collections.emptyList();
+  }
+
+  @Override
+  public boolean shouldValidateAllCommits() {
+    return true;
+  }
+}
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/VerifyNoPiiInChangeNotes.java b/java/com/google/gerrit/acceptance/VerifyNoPiiInChangeNotes.java
new file mode 100644
index 0000000..1bdaa6e
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/VerifyNoPiiInChangeNotes.java
@@ -0,0 +1,35 @@
+// 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;
+
+import static java.lang.annotation.ElementType.METHOD;
+import static java.lang.annotation.ElementType.TYPE;
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.Target;
+
+/**
+ * Annotation for the acceptance tests, inherited from {@link AbstractDaemonTest}, to enable
+ * verification that NoteDB commits do not persist user-sensitive information. See {@link
+ * AbstractDaemonTest#verifyNoAccountDetailsInChangeNotes}.
+ *
+ * <p>Disabled by default, can be enabled per test class/test method.
+ */
+@Target({TYPE, METHOD})
+@Retention(RUNTIME)
+public @interface VerifyNoPiiInChangeNotes {
+  boolean value() default false;
+}
diff --git a/java/com/google/gerrit/acceptance/testsuite/account/AccountOperationsImpl.java b/java/com/google/gerrit/acceptance/testsuite/account/AccountOperationsImpl.java
index 8c1eebd..3763f9a 100644
--- a/java/com/google/gerrit/acceptance/testsuite/account/AccountOperationsImpl.java
+++ b/java/com/google/gerrit/acceptance/testsuite/account/AccountOperationsImpl.java
@@ -21,15 +21,17 @@
 import com.google.common.collect.Sets;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.server.ServerInitiated;
+import com.google.gerrit.server.account.AccountDelta;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.account.Accounts;
 import com.google.gerrit.server.account.AccountsUpdate;
-import com.google.gerrit.server.account.InternalAccountUpdate;
+import com.google.gerrit.server.account.AccountsUpdate.ConfigureDeltaFromState;
 import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.notedb.Sequences;
 import com.google.inject.Inject;
 import java.io.IOException;
 import java.util.Optional;
+import java.util.function.Consumer;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 
 /**
@@ -61,24 +63,17 @@
     return TestAccountCreation.builder(this::createAccount);
   }
 
-  private Account.Id createAccount(TestAccountCreation accountCreation) throws Exception {
-    AccountsUpdate.AccountUpdater accountUpdater =
-        (accountState, updateBuilder) ->
-            fillBuilder(updateBuilder, accountCreation, accountState.account().id());
-    AccountState createdAccount = createAccount(accountUpdater);
+  private Account.Id createAccount(TestAccountCreation testAccountCreation) throws Exception {
+    Account.Id accountId = Account.id(seq.nextAccountId());
+    Consumer<AccountDelta.Builder> accountCreation =
+        deltaBuilder -> initAccountDelta(deltaBuilder, testAccountCreation, accountId);
+    AccountState createdAccount =
+        accountsUpdate.insert("Create Test Account", accountId, accountCreation);
     return createdAccount.account().id();
   }
 
-  private AccountState createAccount(AccountsUpdate.AccountUpdater accountUpdater)
-      throws IOException, ConfigInvalidException {
-    Account.Id accountId = Account.id(seq.nextAccountId());
-    return accountsUpdate.insert("Create Test Account", accountId, accountUpdater);
-  }
-
-  private static void fillBuilder(
-      InternalAccountUpdate.Builder builder,
-      TestAccountCreation accountCreation,
-      Account.Id accountId) {
+  private static void initAccountDelta(
+      AccountDelta.Builder builder, TestAccountCreation accountCreation, Account.Id accountId) {
     accountCreation.fullname().ifPresent(builder::setFullName);
     accountCreation.preferredEmail().ifPresent(e -> setPreferredEmail(builder, accountId, e));
     String httpPassword = accountCreation.httpPassword().orElse(null);
@@ -92,19 +87,16 @@
                 builder.addExternalId(ExternalId.createEmail(accountId, secondaryEmail)));
   }
 
-  private static InternalAccountUpdate.Builder setPreferredEmail(
-      InternalAccountUpdate.Builder builder, Account.Id accountId, String preferredEmail) {
-    return builder
+  private static void setPreferredEmail(
+      AccountDelta.Builder builder, Account.Id accountId, String preferredEmail) {
+    builder
         .setPreferredEmail(preferredEmail)
         .addExternalId(ExternalId.createEmail(accountId, preferredEmail));
   }
 
-  private static InternalAccountUpdate.Builder setUsername(
-      InternalAccountUpdate.Builder builder,
-      Account.Id accountId,
-      String username,
-      String httpPassword) {
-    return builder.addExternalId(ExternalId.createUsername(username, accountId, httpPassword));
+  private static void setUsername(
+      AccountDelta.Builder builder, Account.Id accountId, String username, String httpPassword) {
+    builder.addExternalId(ExternalId.createUsername(username, accountId, httpPassword));
   }
 
   private class PerAccountOperationsImpl implements PerAccountOperations {
@@ -155,21 +147,19 @@
 
     private void updateAccount(TestAccountUpdate accountUpdate)
         throws IOException, ConfigInvalidException {
-      AccountsUpdate.AccountUpdater accountUpdater =
-          (accountState, updateBuilder) -> fillBuilder(updateBuilder, accountUpdate, accountState);
-      Optional<AccountState> updatedAccount = updateAccount(accountUpdater);
+      ConfigureDeltaFromState configureDeltaFromState =
+          (accountState, deltaBuilder) -> fillBuilder(deltaBuilder, accountUpdate, accountState);
+      Optional<AccountState> updatedAccount = updateAccount(configureDeltaFromState);
       checkState(updatedAccount.isPresent(), "Tried to update non-existing test account");
     }
 
-    private Optional<AccountState> updateAccount(AccountsUpdate.AccountUpdater accountUpdater)
+    private Optional<AccountState> updateAccount(ConfigureDeltaFromState configureDeltaFromState)
         throws IOException, ConfigInvalidException {
-      return accountsUpdate.update("Update Test Account", accountId, accountUpdater);
+      return accountsUpdate.update("Update Test Account", accountId, configureDeltaFromState);
     }
 
     private void fillBuilder(
-        InternalAccountUpdate.Builder builder,
-        TestAccountUpdate accountUpdate,
-        AccountState accountState) {
+        AccountDelta.Builder builder, TestAccountUpdate accountUpdate, AccountState accountState) {
       accountUpdate.fullname().ifPresent(builder::setFullName);
       accountUpdate.preferredEmail().ifPresent(e -> setPreferredEmail(builder, accountId, e));
       String httpPassword = accountUpdate.httpPassword().orElse(null);
@@ -200,7 +190,7 @@
     }
 
     private void setSecondaryEmails(
-        InternalAccountUpdate.Builder builder,
+        AccountDelta.Builder builder,
         TestAccountUpdate accountUpdate,
         AccountState accountState,
         ImmutableSet<String> newSecondaryEmails) {
@@ -235,8 +225,8 @@
 
       if (testAccountInvalidation.preferredEmailWithoutExternalId().isPresent()) {
         updateAccount(
-            (account, updateBuilder) ->
-                updateBuilder.setPreferredEmail(
+            (account, deltaBuilder) ->
+                deltaBuilder.setPreferredEmail(
                     testAccountInvalidation.preferredEmailWithoutExternalId().get()));
       }
     }
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..cb987da
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/testsuite/change/ChangeKindCreator.java
@@ -0,0 +1,348 @@
+// 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, project);
+        return;
+      case REWORK:
+        rework(changeId, testRepo, user, project);
+        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, project);
+        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,
+      Project.NameKey project)
+      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,
+      Project.NameKey project)
+      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,
+      Project.NameKey project)
+      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/acceptance/testsuite/group/GroupOperationsImpl.java b/java/com/google/gerrit/acceptance/testsuite/group/GroupOperationsImpl.java
index cde5134..dcf1158 100644
--- a/java/com/google/gerrit/acceptance/testsuite/group/GroupOperationsImpl.java
+++ b/java/com/google/gerrit/acceptance/testsuite/group/GroupOperationsImpl.java
@@ -23,10 +23,10 @@
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.ServerInitiated;
 import com.google.gerrit.server.account.GroupUuid;
+import com.google.gerrit.server.group.db.GroupDelta;
 import com.google.gerrit.server.group.db.Groups;
 import com.google.gerrit.server.group.db.GroupsUpdate;
 import com.google.gerrit.server.group.db.InternalGroupCreation;
-import com.google.gerrit.server.group.db.InternalGroupUpdate;
 import com.google.gerrit.server.notedb.Sequences;
 import com.google.inject.Inject;
 import java.io.IOException;
@@ -71,9 +71,8 @@
   private AccountGroup.UUID createNewGroup(TestGroupCreation groupCreation)
       throws ConfigInvalidException, IOException {
     InternalGroupCreation internalGroupCreation = toInternalGroupCreation(groupCreation);
-    InternalGroupUpdate internalGroupUpdate = toInternalGroupUpdate(groupCreation);
-    InternalGroup internalGroup =
-        groupsUpdate.createGroup(internalGroupCreation, internalGroupUpdate);
+    GroupDelta groupDelta = toGroupDelta(groupCreation);
+    InternalGroup internalGroup = groupsUpdate.createGroup(internalGroupCreation, groupDelta);
     return internalGroup.getGroupUUID();
   }
 
@@ -89,8 +88,8 @@
         .build();
   }
 
-  private static InternalGroupUpdate toInternalGroupUpdate(TestGroupCreation groupCreation) {
-    InternalGroupUpdate.Builder builder = InternalGroupUpdate.builder();
+  private static GroupDelta toGroupDelta(TestGroupCreation groupCreation) {
+    GroupDelta.Builder builder = GroupDelta.builder();
     groupCreation.description().ifPresent(builder::setDescription);
     groupCreation.ownerGroupUuid().ifPresent(builder::setOwnerGroupUUID);
     groupCreation.visibleToAll().ifPresent(builder::setVisibleToAll);
@@ -147,12 +146,12 @@
 
     private void updateGroup(TestGroupUpdate groupUpdate)
         throws DuplicateKeyException, NoSuchGroupException, ConfigInvalidException, IOException {
-      InternalGroupUpdate internalGroupUpdate = toInternalGroupUpdate(groupUpdate);
-      groupsUpdate.updateGroup(groupUuid, internalGroupUpdate);
+      GroupDelta groupDelta = toGroupDelta(groupUpdate);
+      groupsUpdate.updateGroup(groupUuid, groupDelta);
     }
 
-    private InternalGroupUpdate toInternalGroupUpdate(TestGroupUpdate groupUpdate) {
-      InternalGroupUpdate.Builder builder = InternalGroupUpdate.builder();
+    private GroupDelta toGroupDelta(TestGroupUpdate groupUpdate) {
+      GroupDelta.Builder builder = GroupDelta.builder();
       groupUpdate.name().map(AccountGroup::nameKey).ifPresent(builder::setName);
       groupUpdate.description().ifPresent(builder::setDescription);
       groupUpdate.ownerGroupUuid().ifPresent(builder::setOwnerGroupUUID);
diff --git a/java/com/google/gerrit/common/PluginData.java b/java/com/google/gerrit/common/PluginData.java
index c440de1..289d93a 100644
--- a/java/com/google/gerrit/common/PluginData.java
+++ b/java/com/google/gerrit/common/PluginData.java
@@ -10,7 +10,7 @@
 // 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.common;
+// limitations under the License.
 
 package com.google.gerrit.common;
 
diff --git a/java/com/google/gerrit/common/data/GlobalCapability.java b/java/com/google/gerrit/common/data/GlobalCapability.java
index 51d9ecd..8bfd960 100644
--- a/java/com/google/gerrit/common/data/GlobalCapability.java
+++ b/java/com/google/gerrit/common/data/GlobalCapability.java
@@ -109,6 +109,9 @@
   /** Can perform streaming of Gerrit events. */
   public static final String STREAM_EVENTS = "streamEvents";
 
+  /** Can query permissions for any (project, user) pair */
+  public static final String VIEW_ACCESS = "viewAccess";
+
   /** Can view all accounts, regardless of {@code accounts.visibility}. */
   public static final String VIEW_ALL_ACCOUNTS = "viewAllAccounts";
 
@@ -124,9 +127,6 @@
   /** Can view all pending tasks in the queue (not just the filtered set). */
   public static final String VIEW_QUEUE = "viewQueue";
 
-  /** Can query permissions for any (project, user) pair */
-  public static final String VIEW_ACCESS = "viewAccess";
-
   private static final List<String> NAMES_ALL;
   private static final List<String> NAMES_LC;
   private static final String[] RANGE_NAMES = {
@@ -152,12 +152,12 @@
     NAMES_ALL.add(RUN_AS);
     NAMES_ALL.add(RUN_GC);
     NAMES_ALL.add(STREAM_EVENTS);
+    NAMES_ALL.add(VIEW_ACCESS);
     NAMES_ALL.add(VIEW_ALL_ACCOUNTS);
     NAMES_ALL.add(VIEW_CACHES);
     NAMES_ALL.add(VIEW_CONNECTIONS);
     NAMES_ALL.add(VIEW_PLUGINS);
     NAMES_ALL.add(VIEW_QUEUE);
-    NAMES_ALL.add(VIEW_ACCESS);
 
     NAMES_LC = new ArrayList<>(NAMES_ALL.size());
     for (String name : NAMES_ALL) {
diff --git a/java/com/google/gerrit/elasticsearch/AbstractElasticIndex.java b/java/com/google/gerrit/elasticsearch/AbstractElasticIndex.java
index 44a377a..562464d 100644
--- a/java/com/google/gerrit/elasticsearch/AbstractElasticIndex.java
+++ b/java/com/google/gerrit/elasticsearch/AbstractElasticIndex.java
@@ -90,7 +90,7 @@
   protected static final String SEARCH = "_search";
   protected static final String SETTINGS = "settings";
 
-  protected static byte[] decodeBase64(String base64String) {
+  static byte[] decodeBase64(String base64String) {
     return BaseEncoding.base64().decode(base64String);
   }
 
diff --git a/java/com/google/gerrit/elasticsearch/ElasticChangeIndex.java b/java/com/google/gerrit/elasticsearch/ElasticChangeIndex.java
index 162654d..7d4e0c7 100644
--- a/java/com/google/gerrit/elasticsearch/ElasticChangeIndex.java
+++ b/java/com/google/gerrit/elasticsearch/ElasticChangeIndex.java
@@ -14,55 +14,35 @@
 
 package com.google.gerrit.elasticsearch;
 
-import static java.nio.charset.StandardCharsets.UTF_8;
 import static java.util.Objects.requireNonNull;
 
-import com.google.common.collect.FluentIterable;
-import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.collect.ImmutableSet;
-import com.google.common.collect.Iterables;
-import com.google.common.collect.ListMultimap;
-import com.google.common.collect.MultimapBuilder;
-import com.google.common.collect.Sets;
 import com.google.gerrit.elasticsearch.ElasticMapping.MappingProperties;
 import com.google.gerrit.elasticsearch.bulk.BulkRequest;
 import com.google.gerrit.elasticsearch.bulk.IndexRequest;
 import com.google.gerrit.elasticsearch.bulk.UpdateRequest;
-import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.converter.ChangeProtoConverter;
-import com.google.gerrit.entities.converter.PatchSetApprovalProtoConverter;
-import com.google.gerrit.entities.converter.PatchSetProtoConverter;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.index.FieldDef;
 import com.google.gerrit.index.QueryOptions;
-import com.google.gerrit.index.RefState;
 import com.google.gerrit.index.Schema;
 import com.google.gerrit.index.query.DataSource;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.QueryParseException;
-import com.google.gerrit.server.ReviewerByEmailSet;
-import com.google.gerrit.server.ReviewerSet;
-import com.google.gerrit.server.StarredChangesUtil;
 import com.google.gerrit.server.change.MergeabilityComputationBehavior;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.index.IndexUtils;
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gerrit.server.index.change.ChangeIndex;
-import com.google.gerrit.server.project.SubmitRuleOptions;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gson.JsonArray;
 import com.google.gson.JsonElement;
 import com.google.gson.JsonObject;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
-import java.sql.Timestamp;
-import java.time.Instant;
-import java.time.format.DateTimeFormatter;
-import java.util.Collections;
-import java.util.Optional;
 import java.util.Set;
 import org.apache.http.HttpStatus;
 import org.eclipse.jgit.lib.Config;
@@ -190,242 +170,12 @@
         changeDataFactory.create(
             parseProtoFrom(decodeBase64(c.getAsString()), ChangeProtoConverter.INSTANCE));
 
-    // Any decoding that is done here must also be done in {@link LuceneChangeIndex}.
-
-    // Patch sets.
-    cd.setPatchSets(
-        decodeProtos(source, ChangeField.PATCH_SET.getName(), PatchSetProtoConverter.INSTANCE));
-
-    // Approvals.
-    if (source.get(ChangeField.APPROVAL.getName()) != null) {
-      cd.setCurrentApprovals(
-          decodeProtos(
-              source, ChangeField.APPROVAL.getName(), PatchSetApprovalProtoConverter.INSTANCE));
-    } else if (fields.contains(ChangeField.APPROVAL.getName())) {
-      cd.setCurrentApprovals(Collections.emptyList());
-    }
-
-    // Added & Deleted.
-    JsonElement addedElement = source.get(ChangeField.ADDED.getName());
-    JsonElement deletedElement = source.get(ChangeField.DELETED.getName());
-    if (addedElement != null && deletedElement != null) {
-      // Changed lines.
-      int added = addedElement.getAsInt();
-      int deleted = deletedElement.getAsInt();
-      cd.setChangedLines(added, deleted);
-    }
-
-    // Star.
-    JsonElement starredElement = source.get(ChangeField.STAR.getName());
-    if (starredElement != null) {
-      ListMultimap<Account.Id, String> stars = MultimapBuilder.hashKeys().arrayListValues().build();
-      JsonArray starBy = starredElement.getAsJsonArray();
-      if (starBy.size() > 0) {
-        for (int i = 0; i < starBy.size(); i++) {
-          String[] indexableFields = starBy.get(i).getAsString().split(":");
-          Optional<Account.Id> id = Account.Id.tryParse(indexableFields[0]);
-          if (id.isPresent()) {
-            stars.put(id.get(), indexableFields[1]);
-          }
-        }
+    for (FieldDef<ChangeData, ?> field : getSchema().getFields().values()) {
+      if (fields.contains(field.getName()) && source.get(field.getName()) != null) {
+        field.setIfPossible(cd, new ElasticStoredValue(source.get(field.getName())));
       }
-      cd.setStars(stars);
-    }
-
-    // Mergeable.
-    JsonElement mergeableElement = source.get(ChangeField.MERGEABLE.getName());
-    if (mergeableElement != null && !skipFields.contains(ChangeField.MERGEABLE.getName())) {
-      String mergeable = mergeableElement.getAsString();
-      if ("1".equals(mergeable)) {
-        cd.setMergeable(true);
-      } else if ("0".equals(mergeable)) {
-        cd.setMergeable(false);
-      }
-    }
-
-    // Reviewed-by.
-    if (source.get(ChangeField.REVIEWEDBY.getName()) != null) {
-      JsonArray reviewedBy = source.get(ChangeField.REVIEWEDBY.getName()).getAsJsonArray();
-      if (reviewedBy.size() > 0) {
-        Set<Account.Id> accounts = Sets.newHashSetWithExpectedSize(reviewedBy.size());
-        for (int i = 0; i < reviewedBy.size(); i++) {
-          int aId = reviewedBy.get(i).getAsInt();
-          if (reviewedBy.size() == 1 && aId == ChangeField.NOT_REVIEWED) {
-            break;
-          }
-          accounts.add(Account.id(aId));
-        }
-        cd.setReviewedBy(accounts);
-      }
-    } else if (fields.contains(ChangeField.REVIEWEDBY.getName())) {
-      cd.setReviewedBy(Collections.emptySet());
-    }
-
-    // Hashtag.
-    if (source.get(ChangeField.HASHTAG.getName()) != null) {
-      JsonArray hashtagArray = source.get(ChangeField.HASHTAG.getName()).getAsJsonArray();
-      if (hashtagArray.size() > 0) {
-        Set<String> hashtags = Sets.newHashSetWithExpectedSize(hashtagArray.size());
-        for (int i = 0; i < hashtagArray.size(); i++) {
-          hashtags.add(hashtagArray.get(i).getAsString());
-        }
-        cd.setHashtags(hashtags);
-      }
-    } else if (fields.contains(ChangeField.HASHTAG.getName())) {
-      cd.setHashtags(Collections.emptySet());
-    }
-
-    // Star.
-    if (source.get(ChangeField.STAR.getName()) != null) {
-      JsonArray starArray = source.get(ChangeField.STAR.getName()).getAsJsonArray();
-      if (starArray.size() > 0) {
-        ListMultimap<Account.Id, String> stars =
-            MultimapBuilder.hashKeys().arrayListValues().build();
-        for (int i = 0; i < starArray.size(); i++) {
-          StarredChangesUtil.StarField starField =
-              StarredChangesUtil.StarField.parse(starArray.get(i).getAsString());
-          stars.put(starField.accountId(), starField.label());
-        }
-        cd.setStars(stars);
-      }
-    } else if (fields.contains(ChangeField.STAR.getName())) {
-      cd.setStars(ImmutableListMultimap.of());
-    }
-
-    // Reviewer.
-    if (source.get(ChangeField.REVIEWER.getName()) != null) {
-      cd.setReviewers(
-          ChangeField.parseReviewerFieldValues(
-              cd.getId(),
-              FluentIterable.from(source.get(ChangeField.REVIEWER.getName()).getAsJsonArray())
-                  .transform(JsonElement::getAsString)));
-    } else if (fields.contains(ChangeField.REVIEWER.getName())) {
-      cd.setReviewers(ReviewerSet.empty());
-    }
-
-    // Reviewer-by-email.
-    if (source.get(ChangeField.REVIEWER_BY_EMAIL.getName()) != null) {
-      cd.setReviewersByEmail(
-          ChangeField.parseReviewerByEmailFieldValues(
-              cd.getId(),
-              FluentIterable.from(
-                      source.get(ChangeField.REVIEWER_BY_EMAIL.getName()).getAsJsonArray())
-                  .transform(JsonElement::getAsString)));
-    } else if (fields.contains(ChangeField.REVIEWER_BY_EMAIL.getName())) {
-      cd.setReviewersByEmail(ReviewerByEmailSet.empty());
-    }
-
-    // Pending-reviewer.
-    if (source.get(ChangeField.PENDING_REVIEWER.getName()) != null) {
-      cd.setPendingReviewers(
-          ChangeField.parseReviewerFieldValues(
-              cd.getId(),
-              FluentIterable.from(
-                      source.get(ChangeField.PENDING_REVIEWER.getName()).getAsJsonArray())
-                  .transform(JsonElement::getAsString)));
-    } else if (fields.contains(ChangeField.PENDING_REVIEWER.getName())) {
-      cd.setPendingReviewers(ReviewerSet.empty());
-    }
-
-    // Pending-reviewer-by-email.
-    if (source.get(ChangeField.PENDING_REVIEWER_BY_EMAIL.getName()) != null) {
-      cd.setPendingReviewersByEmail(
-          ChangeField.parseReviewerByEmailFieldValues(
-              cd.getId(),
-              FluentIterable.from(
-                      source.get(ChangeField.PENDING_REVIEWER_BY_EMAIL.getName()).getAsJsonArray())
-                  .transform(JsonElement::getAsString)));
-    } else if (fields.contains(ChangeField.PENDING_REVIEWER_BY_EMAIL.getName())) {
-      cd.setPendingReviewersByEmail(ReviewerByEmailSet.empty());
-    }
-
-    // Stored-submit-record-strict.
-    decodeSubmitRecords(
-        source,
-        ChangeField.STORED_SUBMIT_RECORD_STRICT.getName(),
-        ChangeField.SUBMIT_RULE_OPTIONS_STRICT,
-        cd);
-
-    // Stored-submit-record-lenient.
-    decodeSubmitRecords(
-        source,
-        ChangeField.STORED_SUBMIT_RECORD_LENIENT.getName(),
-        ChangeField.SUBMIT_RULE_OPTIONS_LENIENT,
-        cd);
-
-    // Ref-state.
-    if (fields.contains(ChangeField.REF_STATE.getName())) {
-      cd.setRefStates(RefState.parseStates(getByteArray(source, ChangeField.REF_STATE.getName())));
-    }
-
-    // Ref-state-pattern.
-    if (fields.contains(ChangeField.REF_STATE_PATTERN.getName())) {
-      cd.setRefStatePatterns(getByteArray(source, ChangeField.REF_STATE_PATTERN.getName()));
-    }
-
-    // Unresolved-comment-count.
-    decodeUnresolvedCommentCount(source, ChangeField.UNRESOLVED_COMMENT_COUNT.getName(), cd);
-
-    // Attention set.
-    if (fields.contains(ChangeField.ATTENTION_SET_FULL.getName())) {
-      ChangeField.parseAttentionSet(
-          FluentIterable.from(source.getAsJsonArray(ChangeField.ATTENTION_SET_FULL.getName()))
-              .transform(ElasticChangeIndex::decodeBase64JsonElement)
-              .toSet(),
-          cd);
-    }
-
-    if (fields.contains(ChangeField.MERGED_ON.getName())) {
-      decodeMergedOn(source, cd);
     }
 
     return cd;
   }
-
-  private Iterable<byte[]> getByteArray(JsonObject source, String name) {
-    JsonElement element = source.get(name);
-    return element != null
-        ? Iterables.transform(element.getAsJsonArray(), e -> decodeBase64(e.getAsString()))
-        : Collections.emptyList();
-  }
-
-  private void decodeSubmitRecords(
-      JsonObject doc, String fieldName, SubmitRuleOptions opts, ChangeData out) {
-    JsonArray records = doc.getAsJsonArray(fieldName);
-    if (records == null) {
-      return;
-    }
-    ChangeField.parseSubmitRecords(
-        FluentIterable.from(records)
-            .transform(ElasticChangeIndex::decodeBase64JsonElement)
-            .toList(),
-        opts,
-        out);
-  }
-
-  private static String decodeBase64JsonElement(JsonElement input) {
-    return new String(decodeBase64(input.getAsString()), UTF_8);
-  }
-
-  private void decodeUnresolvedCommentCount(JsonObject doc, String fieldName, ChangeData out) {
-    JsonElement count = doc.get(fieldName);
-    if (count == null) {
-      return;
-    }
-    out.setUnresolvedCommentCount(count.getAsInt());
-  }
-
-  private void decodeMergedOn(JsonObject doc, ChangeData out) {
-    JsonElement mergedOnField = doc.get(ChangeField.MERGED_ON.getName());
-
-    Timestamp mergedOn = null;
-    if (mergedOnField != null) {
-      // Parse from ElasticMapping.TIMESTAMP_FIELD_FORMAT.
-      // We currently use built-in ISO-based dateOptionalTime.
-      // https://www.elastic.co/guide/en/elasticsearch/reference/current/mapping-date-format.html#built-in-date-formats
-      DateTimeFormatter isoFormatter = DateTimeFormatter.ISO_INSTANT;
-      mergedOn = Timestamp.from(Instant.from(isoFormatter.parse(mergedOnField.getAsString())));
-    }
-    out.setMergedOn(mergedOn);
-  }
 }
diff --git a/java/com/google/gerrit/elasticsearch/ElasticStoredValue.java b/java/com/google/gerrit/elasticsearch/ElasticStoredValue.java
new file mode 100644
index 0000000..a02a715
--- /dev/null
+++ b/java/com/google/gerrit/elasticsearch/ElasticStoredValue.java
@@ -0,0 +1,86 @@
+// 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.elasticsearch;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+
+import com.google.gerrit.index.StoredValue;
+import com.google.gson.JsonElement;
+import java.sql.Timestamp;
+import java.time.Instant;
+import java.time.format.DateTimeFormatter;
+import java.util.stream.StreamSupport;
+
+/** Bridge to recover fields from the elastic index. */
+public class ElasticStoredValue implements StoredValue {
+  private final JsonElement field;
+
+  ElasticStoredValue(JsonElement field) {
+    this.field = field;
+  }
+
+  @Override
+  public String asString() {
+    return field.getAsString();
+  }
+
+  @Override
+  public Iterable<String> asStrings() {
+    return StreamSupport.stream(field.getAsJsonArray().spliterator(), false)
+        .map(f -> f.getAsString())
+        .collect(toImmutableList());
+  }
+
+  @Override
+  public Integer asInteger() {
+    return field.getAsInt();
+  }
+
+  @Override
+  public Iterable<Integer> asIntegers() {
+    return StreamSupport.stream(field.getAsJsonArray().spliterator(), false)
+        .map(f -> f.getAsInt())
+        .collect(toImmutableList());
+  }
+
+  @Override
+  public Long asLong() {
+    return field.getAsLong();
+  }
+
+  @Override
+  public Iterable<Long> asLongs() {
+    return StreamSupport.stream(field.getAsJsonArray().spliterator(), false)
+        .map(f -> f.getAsLong())
+        .collect(toImmutableList());
+  }
+
+  @Override
+  public Timestamp asTimestamp() {
+    return Timestamp.from(Instant.from(DateTimeFormatter.ISO_INSTANT.parse(field.getAsString())));
+  }
+
+  @Override
+  public byte[] asByteArray() {
+    return AbstractElasticIndex.decodeBase64(field.getAsString());
+  }
+
+  @Override
+  public Iterable<byte[]> asByteArrays() {
+    return StreamSupport.stream(field.getAsJsonArray().spliterator(), false)
+        .map(f -> AbstractElasticIndex.decodeBase64(f.getAsString()))
+        .collect(toImmutableList());
+  }
+}
diff --git a/java/com/google/gerrit/entities/CachedProjectConfig.java b/java/com/google/gerrit/entities/CachedProjectConfig.java
index 2a94bc8..8740235 100644
--- a/java/com/google/gerrit/entities/CachedProjectConfig.java
+++ b/java/com/google/gerrit/entities/CachedProjectConfig.java
@@ -95,6 +95,9 @@
   /** Returns the {@link LabelType}s keyed by their name. */
   public abstract ImmutableMap<String, LabelType> getLabelSections();
 
+  /** Returns the {@link SubmitRequirement}s keyed by their name. */
+  public abstract ImmutableMap<String, SubmitRequirement> getSubmitRequirementSections();
+
   /** Returns configured {@link ConfiguredMimeTypes}s. */
   public abstract ConfiguredMimeTypes getMimeTypes();
 
@@ -169,6 +172,11 @@
       return this;
     }
 
+    public Builder addSubmitRequirementSection(SubmitRequirement submitRequirement) {
+      submitRequirementSectionsBuilder().put(submitRequirement.name(), submitRequirement);
+      return this;
+    }
+
     public abstract Builder setMimeTypes(ConfiguredMimeTypes value);
 
     public Builder addSubscribeSection(SubscribeSection subscribeSection) {
@@ -236,6 +244,9 @@
 
     protected abstract ImmutableMap.Builder<String, LabelType> labelSectionsBuilder();
 
+    protected abstract ImmutableMap.Builder<String, SubmitRequirement>
+        submitRequirementSectionsBuilder();
+
     protected abstract ImmutableMap.Builder<Project.NameKey, SubscribeSection>
         subscribeSectionsBuilder();
 
diff --git a/java/com/google/gerrit/entities/ChangeMessage.java b/java/com/google/gerrit/entities/ChangeMessage.java
index f34cc7d..e10d002 100644
--- a/java/com/google/gerrit/entities/ChangeMessage.java
+++ b/java/com/google/gerrit/entities/ChangeMessage.java
@@ -15,12 +15,35 @@
 package com.google.gerrit.entities;
 
 import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
 import java.sql.Timestamp;
+import java.util.HashSet;
 import java.util.Objects;
+import java.util.Optional;
+import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
 
-/** A message attached to a {@link Change}. */
+/**
+ * A message attached to a {@link Change}. This message is persisted in data storage, that is why it
+ * must have template form that does not contain Gerrit user identifiable information. Hence, it
+ * requires processing to convert it to user-facing form.
+ *
+ * <p>These messages are normally auto-generated by gerrit operations, but might also incorporate
+ * user input.
+ */
 public final class ChangeMessage {
+
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  /** 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 Key key(Change.Id changeId, String uuid) {
     return new AutoValue_ChangeMessage_Key(changeId, uuid);
   }
@@ -40,9 +63,15 @@
   /** When this comment was drafted. */
   protected Timestamp writtenOn;
 
-  /** The text left by the user. */
+  /**
+   * The text left by the user or Gerrit system in template form, that is free of Gerrit User
+   * Identifiable Information and can be persisted in data storage.
+   */
   @Nullable protected String message;
 
+  /** {@link Account.Id}s that are used in {@link #message} template. */
+  protected ImmutableSet<Account.Id> accountsInMessage;
+
   /** Which patchset (if any) was this message generated from? */
   @Nullable protected PatchSet.Id patchset;
 
@@ -54,11 +83,47 @@
 
   protected ChangeMessage() {}
 
-  public ChangeMessage(final ChangeMessage.Key k, Account.Id a, Timestamp wo, PatchSet.Id psid) {
-    key = k;
-    author = a;
-    writtenOn = wo;
-    patchset = psid;
+  public static ChangeMessage create(
+      final ChangeMessage.Key k, @Nullable Account.Id a, Timestamp wo, @Nullable PatchSet.Id psid) {
+    return create(k, a, wo, psid, /*messageTemplate=*/ null, /*realAuthor=*/ null, /*tag=*/ null);
+  }
+
+  public static ChangeMessage create(
+      final ChangeMessage.Key k,
+      @Nullable Account.Id a,
+      Timestamp wo,
+      @Nullable PatchSet.Id psid,
+      @Nullable String messageTemplate,
+      @Nullable Account.Id realAuthor,
+      @Nullable String tag) {
+    ChangeMessage message = new ChangeMessage();
+    message.key = k;
+    message.author = a;
+    message.writtenOn = wo;
+    message.patchset = psid;
+    message.message = messageTemplate;
+    message.accountsInMessage =
+        messageTemplate == null ? ImmutableSet.of() : parseTemplates(messageTemplate);
+    // Use null for same real author, as before the column was added.
+    message.realAuthor = Objects.equals(a, realAuthor) ? null : realAuthor;
+    message.tag = tag;
+    return message;
+  }
+
+  /* Returns account ids that are used in {@code messageTemplate}. */
+  public static ImmutableSet<Account.Id> parseTemplates(String messageTemplate) {
+    Matcher matcher = ACCOUNT_TEMPLATE_PATTERN.matcher(messageTemplate);
+    Set<Account.Id> accountsInTemplate = new HashSet<>();
+    while (matcher.find()) {
+      String accountId = matcher.group(1);
+      Optional<Account.Id> parsedAccountId = Account.Id.tryParse(accountId);
+      if (parsedAccountId.isPresent()) {
+        accountsInTemplate.add(parsedAccountId.get());
+      } else {
+        logger.atFine().log("Failed to parse accountId from template %s", matcher.group());
+      }
+    }
+    return ImmutableSet.copyOf(accountsInTemplate);
   }
 
   public ChangeMessage.Key getKey() {
@@ -70,54 +135,32 @@
     return author;
   }
 
-  public void setAuthor(Account.Id accountId) {
-    if (author != null) {
-      throw new IllegalStateException("Cannot modify author once assigned");
-    }
-    author = accountId;
-  }
-
   public Account.Id getRealAuthor() {
     return realAuthor != null ? realAuthor : getAuthor();
   }
 
-  public void setRealAuthor(Account.Id id) {
-    // Use null for same real author, as before the column was added.
-    realAuthor = Objects.equals(getAuthor(), id) ? null : id;
-  }
-
   public Timestamp getWrittenOn() {
     return writtenOn;
   }
 
-  public void setWrittenOn(Timestamp ts) {
-    writtenOn = ts;
-  }
-
+  /** Message template, as persisted in data storage. */
   public String getMessage() {
     return message;
   }
 
-  public void setMessage(String s) {
-    message = s;
+  /** Account ids, used in {@link #message} template. */
+  public ImmutableSet<Account.Id> getAccountsInMessage() {
+    return accountsInMessage == null ? ImmutableSet.of() : accountsInMessage;
   }
 
   public String getTag() {
     return tag;
   }
 
-  public void setTag(String tag) {
-    this.tag = tag;
-  }
-
   public PatchSet.Id getPatchSetId() {
     return patchset;
   }
 
-  public void setPatchSetId(PatchSet.Id id) {
-    patchset = id;
-  }
-
   @Override
   public boolean equals(Object o) {
     if (!(o instanceof ChangeMessage)) {
@@ -128,6 +171,7 @@
         && Objects.equals(author, m.author)
         && Objects.equals(writtenOn, m.writtenOn)
         && Objects.equals(message, m.message)
+        && Objects.equals(accountsInMessage, m.accountsInMessage)
         && Objects.equals(patchset, m.patchset)
         && Objects.equals(tag, m.tag)
         && Objects.equals(realAuthor, m.realAuthor);
@@ -135,7 +179,8 @@
 
   @Override
   public int hashCode() {
-    return Objects.hash(key, author, writtenOn, message, patchset, tag, realAuthor);
+    return Objects.hash(
+        key, author, writtenOn, message, accountsInMessage, patchset, tag, realAuthor);
   }
 
   @Override
@@ -155,6 +200,8 @@
         + tag
         + ", message=["
         + message
-        + "]}";
+        + "], accountsInMessage="
+        + accountsInMessage
+        + "}";
   }
 }
diff --git a/java/com/google/gerrit/entities/CoreDownloadSchemes.java b/java/com/google/gerrit/entities/CoreDownloadSchemes.java
index 9bcd365..85e55a0 100644
--- a/java/com/google/gerrit/entities/CoreDownloadSchemes.java
+++ b/java/com/google/gerrit/entities/CoreDownloadSchemes.java
@@ -20,7 +20,6 @@
   public static final String ANON_HTTP = "anonymous http";
   public static final String HTTP = "http";
   public static final String SSH = "ssh";
-  public static final String REPO_DOWNLOAD = "repo";
   public static final String REPO = "repo";
 
   private CoreDownloadSchemes() {}
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/PatchSet.java b/java/com/google/gerrit/entities/PatchSet.java
index 5c8f7eb..b26e5c3 100644
--- a/java/com/google/gerrit/entities/PatchSet.java
+++ b/java/com/google/gerrit/entities/PatchSet.java
@@ -93,6 +93,12 @@
       return PatchSet.id(Change.id(changeId), patchSetId);
     }
 
+    /** Parse a PatchSet.Id from an edit ref. */
+    public static PatchSet.Id fromEditRef(String ref) {
+      Change.Id changeId = Change.Id.fromEditRefPart(ref);
+      return PatchSet.id(changeId, Ints.tryParse(ref.substring(ref.lastIndexOf('/') + 1)));
+    }
+
     static int fromRef(String ref, int changeIdEnd) {
       // Patch set ID.
       int ps = changeIdEnd + 1;
diff --git a/java/com/google/gerrit/entities/SubmitRequirement.java b/java/com/google/gerrit/entities/SubmitRequirement.java
new file mode 100644
index 0000000..df03fd5
--- /dev/null
+++ b/java/com/google/gerrit/entities/SubmitRequirement.java
@@ -0,0 +1,85 @@
+//  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 java.util.Optional;
+
+/** Entity describing a requirement that should be met for a change to become submittable. */
+@AutoValue
+public abstract class SubmitRequirement {
+  /** Requirement name. */
+  public abstract String name();
+
+  /** Description of what this requirement means. */
+  public abstract Optional<String> description();
+
+  /**
+   * 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 #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 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 submittabilityExpression();
+
+  /**
+   * Expression that, if evaluated to true, causes the submit requirement to be fulfilled,
+   * 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.
+   */
+  public abstract Optional<SubmitRequirementExpression> overrideExpression();
+
+  /**
+   * Boolean value indicating if the {@link SubmitRequirement} definition can be overridden in child
+   * projects. Default is false.
+   */
+  public abstract boolean allowOverrideInChildProjects();
+
+  public static SubmitRequirement.Builder builder() {
+    return new AutoValue_SubmitRequirement.Builder();
+  }
+
+  @AutoValue.Builder
+  public abstract static class Builder {
+
+    public abstract Builder setName(String name);
+
+    public abstract Builder setDescription(Optional<String> description);
+
+    public abstract Builder setApplicabilityExpression(
+        Optional<SubmitRequirementExpression> applicabilityExpression);
+
+    public abstract Builder setSubmittabilityExpression(
+        SubmitRequirementExpression submittabilityExpression);
+
+    public abstract Builder setOverrideExpression(
+        Optional<SubmitRequirementExpression> overrideExpression);
+
+    public abstract Builder setAllowOverrideInChildProjects(boolean allowOverrideInChildProjects);
+
+    public abstract SubmitRequirement build();
+  }
+}
diff --git a/java/com/google/gerrit/entities/SubmitRequirementExpression.java b/java/com/google/gerrit/entities/SubmitRequirementExpression.java
new file mode 100644
index 0000000..c978347
--- /dev/null
+++ b/java/com/google/gerrit/entities/SubmitRequirementExpression.java
@@ -0,0 +1,44 @@
+// 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.base.Strings;
+import com.google.gerrit.common.Nullable;
+import java.util.Optional;
+
+/** Describe a applicability, blocking or override expression of a {@link SubmitRequirement}. */
+@AutoValue
+public abstract class SubmitRequirementExpression {
+
+  public static SubmitRequirementExpression create(String expression) {
+    return new AutoValue_SubmitRequirementExpression(expression);
+  }
+
+  /**
+   * Creates a new {@link SubmitRequirementExpression}.
+   *
+   * @param expression String representation of the expression
+   * @return empty {@link Optional} if the input expression is null or empty, or an Optional
+   *     containing the expression otherwise.
+   */
+  public static Optional<SubmitRequirementExpression> of(@Nullable String expression) {
+    return Optional.ofNullable(Strings.emptyToNull(expression))
+        .map(SubmitRequirementExpression::create);
+  }
+
+  /** Returns the underlying String representing this {@link SubmitRequirementExpression}. */
+  public abstract String expressionString();
+}
diff --git a/java/com/google/gerrit/entities/SubmitRequirementExpressionResult.java b/java/com/google/gerrit/entities/SubmitRequirementExpressionResult.java
new file mode 100644
index 0000000..94c0e91
--- /dev/null
+++ b/java/com/google/gerrit/entities/SubmitRequirementExpressionResult.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.common.collect.ImmutableList;
+import java.util.Optional;
+
+/** Result of evaluating a submit requirement expression on a given Change. */
+@AutoValue
+public abstract class SubmitRequirementExpressionResult {
+
+  /**
+   * Entity detailing the result of evaluating a Submit requirement expression. Contains an empty
+   * {@link Optional} if {@link #status()} is equal to {@link Status#ERROR}.
+   */
+  public abstract Optional<PredicateResult> predicateResult();
+
+  public abstract Optional<String> errorMessage();
+
+  public Status status() {
+    if (predicateResult().isPresent()) {
+      return predicateResult().get().status() ? Status.PASS : Status.FAIL;
+    }
+    return Status.ERROR;
+  }
+
+  public static SubmitRequirementExpressionResult create(PredicateResult predicateResult) {
+    return new AutoValue_SubmitRequirementExpressionResult(
+        Optional.of(predicateResult), Optional.empty());
+  }
+
+  public static SubmitRequirementExpressionResult error(String errorMessage) {
+    return new AutoValue_SubmitRequirementExpressionResult(
+        Optional.empty(), Optional.of(errorMessage));
+  }
+
+  /**
+   * Returns a list of leaf predicate results whose {@link PredicateResult#status()} is true. If
+   * {@link #status()} is equal to {@link Status#ERROR}, an empty list is returned.
+   */
+  public ImmutableList<PredicateResult> getPassingAtoms() {
+    if (predicateResult().isPresent()) {
+      return predicateResult().get().getAtoms(/* status= */ true);
+    }
+    return ImmutableList.of();
+  }
+
+  /**
+   * Returns a list of leaf predicate results whose {@link PredicateResult#status()} is false. If
+   * {@link #status()} is equal to {@link Status#ERROR}, an empty list is returned.
+   */
+  public ImmutableList<PredicateResult> getFailingAtoms() {
+    if (predicateResult().isPresent()) {
+      return predicateResult().get().getAtoms(/* status= */ false);
+    }
+    return ImmutableList.of();
+  }
+
+  public 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();
+
+    abstract String predicateString();
+
+    /** true if the predicate is passing for a given change. */
+    abstract boolean status();
+
+    /**
+     * Returns the list of leaf {@link PredicateResult} whose {@link #status()} is equal to the
+     * {@code status} parameter.
+     */
+    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..7b4d609
--- /dev/null
+++ b/java/com/google/gerrit/entities/SubmitRequirementResult.java
@@ -0,0 +1,131 @@
+// 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.gerrit.entities.SubmitRequirementExpressionResult.Status;
+import java.util.Optional;
+
+/** Result of evaluating a {@link SubmitRequirement} on a given Change. */
+@AutoValue
+public abstract class SubmitRequirementResult {
+  /** 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();
+
+  @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 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 applicabilityExpressionResult(
+        Optional<SubmitRequirementExpressionResult> value);
+
+    public abstract Builder submittabilityExpressionResult(SubmitRequirementExpressionResult value);
+
+    public abstract Builder overrideExpressionResult(
+        Optional<SubmitRequirementExpressionResult> 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/converter/ChangeMessageProtoConverter.java b/java/com/google/gerrit/entities/converter/ChangeMessageProtoConverter.java
index 19c121249..eb2a381 100644
--- a/java/com/google/gerrit/entities/converter/ChangeMessageProtoConverter.java
+++ b/java/com/google/gerrit/entities/converter/ChangeMessageProtoConverter.java
@@ -48,6 +48,8 @@
     if (writtenOn != null) {
       builder.setWrittenOn(writtenOn.getTime());
     }
+    // Build proto with template representation of the message. Templates are parsed when message is
+    // extracted from cache.
     String message = changeMessage.getMessage();
     if (message != null) {
       builder.setMessage(message);
@@ -79,16 +81,15 @@
     Timestamp writtenOn = proto.hasWrittenOn() ? new Timestamp(proto.getWrittenOn()) : null;
     PatchSet.Id patchSetId =
         proto.hasPatchset() ? patchSetIdConverter.fromProto(proto.getPatchset()) : null;
-    ChangeMessage changeMessage = new ChangeMessage(key, author, writtenOn, patchSetId);
-    if (proto.hasMessage()) {
-      changeMessage.setMessage(proto.getMessage());
-    }
-    if (proto.hasTag()) {
-      changeMessage.setTag(proto.getTag());
-    }
-    if (proto.hasRealAuthor()) {
-      changeMessage.setRealAuthor(accountIdConverter.fromProto(proto.getRealAuthor()));
-    }
+    // Only template representation of the message is stored in entity. Templates should be replaced
+    // before being served to the users.
+    String messageTemplate = proto.hasMessage() ? proto.getMessage() : null;
+    String tag = proto.hasTag() ? proto.getTag() : null;
+    Account.Id realAuthor =
+        proto.hasRealAuthor() ? accountIdConverter.fromProto(proto.getRealAuthor()) : null;
+    ChangeMessage changeMessage =
+        ChangeMessage.create(key, author, writtenOn, patchSetId, messageTemplate, realAuthor, tag);
+
     return changeMessage;
   }
 
diff --git a/java/com/google/gerrit/extensions/api/changes/ChangeApi.java b/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
index 7cbfebd..04f5bd2 100644
--- a/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
+++ b/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
@@ -205,13 +205,13 @@
 
   IncludedInInfo includedIn() throws RestApiException;
 
-  default AddReviewerResult addReviewer(String reviewer) throws RestApiException {
-    AddReviewerInput in = new AddReviewerInput();
+  default ReviewerResult addReviewer(String reviewer) throws RestApiException {
+    ReviewerInput in = new ReviewerInput();
     in.reviewer = reviewer;
     return addReviewer(in);
   }
 
-  AddReviewerResult addReviewer(AddReviewerInput in) throws RestApiException;
+  ReviewerResult addReviewer(ReviewerInput in) throws RestApiException;
 
   SuggestedReviewersRequest suggestReviewers() throws RestApiException;
 
@@ -643,7 +643,7 @@
     }
 
     @Override
-    public AddReviewerResult addReviewer(AddReviewerInput in) throws RestApiException {
+    public ReviewerResult addReviewer(ReviewerInput in) throws RestApiException {
       throw new NotImplementedException();
     }
 
diff --git a/java/com/google/gerrit/extensions/api/changes/ReviewInput.java b/java/com/google/gerrit/extensions/api/changes/ReviewInput.java
index fd445b6..11999ab 100644
--- a/java/com/google/gerrit/extensions/api/changes/ReviewInput.java
+++ b/java/com/google/gerrit/extensions/api/changes/ReviewInput.java
@@ -46,6 +46,9 @@
    */
   public DraftHandling drafts;
 
+  /** A list of draft IDs that should be published. */
+  public List<String> draftIdsToPublish;
+
   /** Who to send email notifications to after review is stored. */
   public NotifyHandling notify;
 
@@ -62,8 +65,8 @@
    */
   public String onBehalfOf;
 
-  /** Reviewers that should be added to this change. */
-  public List<AddReviewerInput> reviewers;
+  /** Reviewers that should be added to this change or removed from it. */
+  public List<ReviewerInput> reviewers;
 
   /**
    * If true mark the change as work in progress. It is an error for both {@link #workInProgress}
@@ -155,7 +158,7 @@
   }
 
   public ReviewInput reviewer(String reviewer, ReviewerState state, boolean confirmed) {
-    AddReviewerInput input = new AddReviewerInput();
+    ReviewerInput input = new ReviewerInput();
     input.reviewer = reviewer;
     input.state = state;
     input.confirmed = confirmed;
diff --git a/java/com/google/gerrit/extensions/api/changes/ReviewResult.java b/java/com/google/gerrit/extensions/api/changes/ReviewResult.java
index ff88bbe..95bea5b 100644
--- a/java/com/google/gerrit/extensions/api/changes/ReviewResult.java
+++ b/java/com/google/gerrit/extensions/api/changes/ReviewResult.java
@@ -29,7 +29,7 @@
    * Map of account or group identifier to outcome of adding as a reviewer. Null if no reviewer
    * additions were requested.
    */
-  @Nullable public Map<String, AddReviewerResult> reviewers;
+  @Nullable public Map<String, ReviewerResult> reviewers;
 
   /**
    * Boolean indicating whether the change was moved out of WIP by this review. Either true or null.
diff --git a/java/com/google/gerrit/extensions/api/changes/AddReviewerInput.java b/java/com/google/gerrit/extensions/api/changes/ReviewerInput.java
similarity index 97%
rename from java/com/google/gerrit/extensions/api/changes/AddReviewerInput.java
rename to java/com/google/gerrit/extensions/api/changes/ReviewerInput.java
index bc8b28a..a7b511b 100644
--- a/java/com/google/gerrit/extensions/api/changes/AddReviewerInput.java
+++ b/java/com/google/gerrit/extensions/api/changes/ReviewerInput.java
@@ -20,7 +20,7 @@
 import com.google.gerrit.extensions.restapi.DefaultInput;
 import java.util.Map;
 
-public class AddReviewerInput {
+public class ReviewerInput {
   @DefaultInput public String reviewer;
   public Boolean confirmed;
   public ReviewerState state;
diff --git a/java/com/google/gerrit/extensions/api/changes/AddReviewerResult.java b/java/com/google/gerrit/extensions/api/changes/ReviewerResult.java
similarity index 74%
rename from java/com/google/gerrit/extensions/api/changes/AddReviewerResult.java
rename to java/com/google/gerrit/extensions/api/changes/ReviewerResult.java
index a23281a..1713674 100644
--- a/java/com/google/gerrit/extensions/api/changes/AddReviewerResult.java
+++ b/java/com/google/gerrit/extensions/api/changes/ReviewerResult.java
@@ -18,17 +18,17 @@
 import com.google.gerrit.extensions.common.AccountInfo;
 import java.util.List;
 
-/** Result object representing the outcome of a request to add a reviewer. */
-public class AddReviewerResult {
-  /** The identifier of an account or group that was to be added as a reviewer. */
+/** Result object representing the outcome of a request to add/remove a reviewer. */
+public class ReviewerResult {
+  /** The identifier of an account or group that was to be added/removed as a reviewer. */
   public String input;
 
-  /** If non-null, a string describing why the reviewer could not be added. */
+  /** If non-null, a string describing why the reviewer could not be added/removed. */
   @Nullable public String error;
 
   /**
    * Non-null and true if the reviewer cannot be added without explicit confirmation. This may be
-   * the case for groups of a certain size.
+   * the case for groups of a certain size. For removals, it's always false.
    */
   @Nullable public Boolean confirm;
 
@@ -39,17 +39,20 @@
   @Nullable public List<ReviewerInfo> reviewers;
 
   /**
-   * List of accounts CCed on the change. The size of this list may be greater than one (e.g. when a
-   * group is CCed). Null if no accounts were CCed or if reviewers is non-null.
+   * List of new accounts CCed on the change. The size of this list may be greater than one (e.g.
+   * when a group is CCed). Null if no accounts were CCed.
    */
   @Nullable public List<AccountInfo> ccs;
 
+  /** An account removed from the change. Null if no accounts were removed. */
+  @Nullable public AccountInfo removed;
+
   /**
    * Constructs a partially initialized result for the given reviewer.
    *
    * @param input String identifier of an account or group, from user request
    */
-  public AddReviewerResult(String input) {
+  public ReviewerResult(String input) {
     this.input = input;
   }
 
@@ -59,7 +62,7 @@
    * @param reviewer String identifier of an account or group
    * @param error Error message
    */
-  public AddReviewerResult(String reviewer, String error) {
+  public ReviewerResult(String reviewer, String error) {
     this(reviewer);
     this.error = error;
   }
@@ -69,7 +72,7 @@
    *
    * @param confirm Whether confirmation is needed.
    */
-  public AddReviewerResult(String reviewer, boolean confirm) {
+  public ReviewerResult(String reviewer, boolean confirm) {
     this(reviewer);
     this.confirm = confirm;
   }
diff --git a/java/com/google/gerrit/extensions/api/changes/RevisionApi.java b/java/com/google/gerrit/extensions/api/changes/RevisionApi.java
index 73e6a4e..229b9d4 100644
--- a/java/com/google/gerrit/extensions/api/changes/RevisionApi.java
+++ b/java/com/google/gerrit/extensions/api/changes/RevisionApi.java
@@ -44,12 +44,12 @@
 
   ReviewResult review(ReviewInput in) throws RestApiException;
 
-  default void submit() throws RestApiException {
+  default ChangeInfo submit() throws RestApiException {
     SubmitInput in = new SubmitInput();
-    submit(in);
+    return submit(in);
   }
 
-  void submit(SubmitInput in) throws RestApiException;
+  ChangeInfo submit(SubmitInput in) throws RestApiException;
 
   default BinaryResult submitPreview() throws RestApiException {
     return submitPreview("zip");
@@ -200,7 +200,7 @@
     }
 
     @Override
-    public void submit(SubmitInput in) throws RestApiException {
+    public ChangeInfo submit(SubmitInput in) throws RestApiException {
       throw new NotImplementedException();
     }
 
diff --git a/java/com/google/gerrit/extensions/client/ListOption.java b/java/com/google/gerrit/extensions/client/ListOption.java
index dba2eee..098966a 100644
--- a/java/com/google/gerrit/extensions/client/ListOption.java
+++ b/java/com/google/gerrit/extensions/client/ListOption.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.extensions.client;
 
+import com.google.gerrit.extensions.restapi.BadRequestException;
 import java.lang.reflect.InvocationTargetException;
 import java.util.EnumSet;
 import java.util.Set;
@@ -22,6 +23,22 @@
 public interface ListOption {
   int getValue();
 
+  static <T extends Enum<T> & ListOption> EnumSet<T> fromHexString(Class<T> clazz, String hex)
+      throws BadRequestException {
+    int parsed;
+    try {
+      parsed = Integer.parseInt(hex, 16);
+    } catch (IllegalArgumentException e) {
+      throw new BadRequestException("not a hex-encoded 32-bit integer: " + hex, e);
+    }
+
+    try {
+      return fromBits(clazz, parsed);
+    } catch (IllegalArgumentException e) {
+      throw new BadRequestException(e.getMessage());
+    }
+  }
+
   static <T extends Enum<T> & ListOption> EnumSet<T> fromBits(Class<T> clazz, int v) {
     EnumSet<T> r = EnumSet.noneOf(clazz);
     T[] values;
@@ -43,7 +60,7 @@
     }
     if (v != 0) {
       throw new IllegalArgumentException(
-          "unknown " + clazz.getName() + ": " + Integer.toHexString(v));
+          "unknown " + clazz.getSimpleName() + ": " + Integer.toHexString(v));
     }
     return r;
   }
diff --git a/java/com/google/gerrit/extensions/common/ChangeMessageInfo.java b/java/com/google/gerrit/extensions/common/ChangeMessageInfo.java
index 10456ff..c1cb1627 100644
--- a/java/com/google/gerrit/extensions/common/ChangeMessageInfo.java
+++ b/java/com/google/gerrit/extensions/common/ChangeMessageInfo.java
@@ -15,8 +15,10 @@
 package com.google.gerrit.extensions.common;
 
 import java.sql.Timestamp;
+import java.util.Collection;
 import java.util.Objects;
 
+/** Represent {@link com.google.gerrit.entities.ChangeMessage} in the REST API. */
 public class ChangeMessageInfo {
   public String id;
   public String tag;
@@ -24,6 +26,7 @@
   public AccountInfo realAuthor;
   public Timestamp date;
   public String message;
+  public Collection<AccountInfo> accountsInMessage;
   public Integer _revisionNumber;
 
   public ChangeMessageInfo() {}
@@ -42,6 +45,7 @@
           && Objects.equals(realAuthor, cmi.realAuthor)
           && Objects.equals(date, cmi.date)
           && Objects.equals(message, cmi.message)
+          && Objects.equals(accountsInMessage, cmi.accountsInMessage)
           && Objects.equals(_revisionNumber, cmi._revisionNumber);
     }
     return false;
@@ -49,7 +53,8 @@
 
   @Override
   public int hashCode() {
-    return Objects.hash(id, tag, author, realAuthor, date, message, _revisionNumber);
+    return Objects.hash(
+        id, tag, author, realAuthor, date, message, accountsInMessage, _revisionNumber);
   }
 
   @Override
@@ -69,6 +74,8 @@
         + _revisionNumber
         + ", message=["
         + message
-        + "]}";
+        + "], accountsForTemplate="
+        + accountsInMessage
+        + "}";
   }
 }
diff --git a/java/com/google/gerrit/extensions/common/CommitInfo.java b/java/com/google/gerrit/extensions/common/CommitInfo.java
index 1fd8755..202b829 100644
--- a/java/com/google/gerrit/extensions/common/CommitInfo.java
+++ b/java/com/google/gerrit/extensions/common/CommitInfo.java
@@ -29,6 +29,7 @@
   public String subject;
   public String message;
   public List<WebLinkInfo> webLinks;
+  public List<WebLinkInfo> resolveConflictsWebLinks;
 
   @Override
   public boolean equals(Object o) {
@@ -42,12 +43,14 @@
         && Objects.equals(committer, c.committer)
         && Objects.equals(subject, c.subject)
         && Objects.equals(message, c.message)
-        && Objects.equals(webLinks, c.webLinks);
+        && Objects.equals(webLinks, c.webLinks)
+        && Objects.equals(resolveConflictsWebLinks, c.resolveConflictsWebLinks);
   }
 
   @Override
   public int hashCode() {
-    return Objects.hash(commit, parents, author, committer, subject, message, webLinks);
+    return Objects.hash(
+        commit, parents, author, committer, subject, message, webLinks, resolveConflictsWebLinks);
   }
 
   @Override
@@ -64,6 +67,9 @@
     if (webLinks != null) {
       helper.add("webLinks", webLinks);
     }
+    if (resolveConflictsWebLinks != null) {
+      helper.add("resolveConflictsWebLinks", resolveConflictsWebLinks);
+    }
     return helper.toString();
   }
 }
diff --git a/java/com/google/gerrit/extensions/common/DiffInfo.java b/java/com/google/gerrit/extensions/common/DiffInfo.java
index 2511e96..5a9b82b 100644
--- a/java/com/google/gerrit/extensions/common/DiffInfo.java
+++ b/java/com/google/gerrit/extensions/common/DiffInfo.java
@@ -32,6 +32,8 @@
   public List<ContentEntry> content;
   // Links to the file diff in external sites
   public List<DiffWebLinkInfo> webLinks;
+  // Links to edit the file in external sites
+  public List<WebLinkInfo> editWebLinks;
   // Binary file
   public Boolean binary;
 
diff --git a/java/com/google/gerrit/extensions/common/FileInfo.java b/java/com/google/gerrit/extensions/common/FileInfo.java
index 510c2ad..c732663 100644
--- a/java/com/google/gerrit/extensions/common/FileInfo.java
+++ b/java/com/google/gerrit/extensions/common/FileInfo.java
@@ -44,4 +44,24 @@
   public int hashCode() {
     return Objects.hash(status, binary, oldPath, linesInserted, linesDeleted, sizeDelta, size);
   }
+
+  @Override
+  public String toString() {
+    return "FileInfo{"
+        + "status="
+        + status
+        + ", binary="
+        + binary
+        + ", oldPath="
+        + oldPath
+        + ", linesInserted="
+        + linesInserted
+        + ", linesDeleted="
+        + linesDeleted
+        + ", sizeDelta="
+        + sizeDelta
+        + ", size="
+        + size
+        + "}";
+  }
 }
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/testing/CommitInfoSubject.java b/java/com/google/gerrit/extensions/common/testing/CommitInfoSubject.java
index d344e18..71fc564 100644
--- a/java/com/google/gerrit/extensions/common/testing/CommitInfoSubject.java
+++ b/java/com/google/gerrit/extensions/common/testing/CommitInfoSubject.java
@@ -20,6 +20,7 @@
 
 import com.google.common.truth.Correspondence;
 import com.google.common.truth.FailureMetadata;
+import com.google.common.truth.IterableSubject;
 import com.google.common.truth.StringSubject;
 import com.google.common.truth.Subject;
 import com.google.gerrit.extensions.common.CommitInfo;
@@ -68,6 +69,16 @@
     return check("message").that(commitInfo.message);
   }
 
+  public IterableSubject webLinks() {
+    isNotNull();
+    return check("webLinks").that(commitInfo.webLinks);
+  }
+
+  public IterableSubject resolveConflictsWebLinks() {
+    isNotNull();
+    return check("resolveConflictsWebLinks").that(commitInfo.resolveConflictsWebLinks);
+  }
+
   public static Correspondence<CommitInfo, String> hasCommit() {
     return NullAwareCorrespondence.transforming(commitInfo -> commitInfo.commit, "hasCommit");
   }
diff --git a/java/com/google/gerrit/extensions/common/testing/DiffInfoSubject.java b/java/com/google/gerrit/extensions/common/testing/DiffInfoSubject.java
index e258134..b800d17 100644
--- a/java/com/google/gerrit/extensions/common/testing/DiffInfoSubject.java
+++ b/java/com/google/gerrit/extensions/common/testing/DiffInfoSubject.java
@@ -74,6 +74,11 @@
     return check("webLinks").that(diffInfo.webLinks);
   }
 
+  public IterableSubject editWebLinks() {
+    isNotNull();
+    return check("editWebLinks").that(diffInfo.editWebLinks);
+  }
+
   public BooleanSubject binary() {
     isNotNull();
     return check("binary").that(diffInfo.binary);
diff --git a/java/com/google/gerrit/extensions/webui/EditWebLink.java b/java/com/google/gerrit/extensions/webui/EditWebLink.java
new file mode 100644
index 0000000..cd70feb
--- /dev/null
+++ b/java/com/google/gerrit/extensions/webui/EditWebLink.java
@@ -0,0 +1,36 @@
+// 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.webui;
+
+import com.google.gerrit.extensions.annotations.ExtensionPoint;
+import com.google.gerrit.extensions.common.WebLinkInfo;
+
+@ExtensionPoint
+public interface EditWebLink extends WebLink {
+
+  /**
+   * {@link com.google.gerrit.extensions.common.WebLinkInfo} describing a link from a file to an
+   * external service for editing.
+   *
+   * <p>In order for the web link to be visible {@link WebLinkInfo#url} and {@link WebLinkInfo#name}
+   * must be set.
+   *
+   * @param projectName name of the project
+   * @param revision name of the revision (e.g. branch or commit ID)
+   * @param fileName name of the file
+   * @return WebLinkInfo that links to project in external service, null if there should be no link.
+   */
+  WebLinkInfo getEditWebLink(String projectName, String revision, String fileName);
+}
diff --git a/java/com/google/gerrit/extensions/webui/ResolveConflictsWebLink.java b/java/com/google/gerrit/extensions/webui/ResolveConflictsWebLink.java
new file mode 100644
index 0000000..19402a9
--- /dev/null
+++ b/java/com/google/gerrit/extensions/webui/ResolveConflictsWebLink.java
@@ -0,0 +1,40 @@
+// 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.webui;
+
+import com.google.gerrit.extensions.annotations.ExtensionPoint;
+import com.google.gerrit.extensions.common.WebLinkInfo;
+
+@ExtensionPoint
+public interface ResolveConflictsWebLink extends WebLink {
+
+  /**
+   * {@link com.google.gerrit.extensions.common.WebLinkInfo} describing a link from a patch set to
+   * an external service for the purpose of resolving merge conflicts.
+   *
+   * <p>In order for the web link to be visible {@link
+   * com.google.gerrit.extensions.common.WebLinkInfo#url} and {@link
+   * com.google.gerrit.extensions.common.WebLinkInfo#name} must be set.
+   *
+   * @param projectName name of the project
+   * @param commit commit of the patch set
+   * @param commitMessage the commit message of the change
+   * @param branchName target branch of the change
+   * @return WebLinkInfo that links to patch set in external service, {@code null} if there should
+   *     be no link.
+   */
+  WebLinkInfo getResolveConflictsWebLink(
+      String projectName, String commit, String commitMessage, String branchName);
+}
diff --git a/java/com/google/gerrit/extensions/webui/UiAction.java b/java/com/google/gerrit/extensions/webui/UiAction.java
index b9d15d2..2f21bf3 100644
--- a/java/com/google/gerrit/extensions/webui/UiAction.java
+++ b/java/com/google/gerrit/extensions/webui/UiAction.java
@@ -30,7 +30,7 @@
    *     the same as {@code setVisible(false)}.
    */
   @Nullable
-  Description getDescription(R resource);
+  Description getDescription(R resource) throws Exception;
 
   /** Describes an action invokable through the web interface. */
   class Description {
diff --git a/java/com/google/gerrit/httpd/init/WebAppInitializer.java b/java/com/google/gerrit/httpd/init/WebAppInitializer.java
index d03340b..3a77a8a 100644
--- a/java/com/google/gerrit/httpd/init/WebAppInitializer.java
+++ b/java/com/google/gerrit/httpd/init/WebAppInitializer.java
@@ -346,7 +346,7 @@
         new AbstractModule() {
           @Override
           protected void configure() {
-            bind(GerritOptions.class).toInstance(new GerritOptions(false, false, ""));
+            bind(GerritOptions.class).toInstance(new GerritOptions(false, false));
             bind(GerritRuntime.class).toInstance(GerritRuntime.DAEMON);
           }
         });
diff --git a/java/com/google/gerrit/httpd/raw/IndexHtmlUtil.java b/java/com/google/gerrit/httpd/raw/IndexHtmlUtil.java
index 8d52f5a..445a73a 100644
--- a/java/com/google/gerrit/httpd/raw/IndexHtmlUtil.java
+++ b/java/com/google/gerrit/httpd/raw/IndexHtmlUtil.java
@@ -27,6 +27,7 @@
 import com.google.gerrit.extensions.api.GerritApi;
 import com.google.gerrit.extensions.api.accounts.AccountApi;
 import com.google.gerrit.extensions.api.config.Server;
+import com.google.gerrit.extensions.client.ListOption;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.json.OutputFormat;
@@ -90,13 +91,13 @@
     switch (page) {
       case CHANGE:
         data.put(
-            "defaultChangeDetailHex", IndexPreloadingUtil.getDefaultChangeDetailOptionsAsHex());
+            "defaultChangeDetailHex", ListOption.toHex(IndexPreloadingUtil.CHANGE_DETAIL_OPTIONS));
         data.put(
             "changeRequestsPath",
             IndexPreloadingUtil.computeChangeRequestsPath(requestedPath, page).get());
         break;
       case DIFF:
-        data.put("defaultDiffDetailHex", IndexPreloadingUtil.getDefaultDiffDetailOptionsAsHex());
+        data.put("defaultDiffDetailHex", ListOption.toHex(IndexPreloadingUtil.DIFF_OPTIONS));
         data.put(
             "changeRequestsPath",
             IndexPreloadingUtil.computeChangeRequestsPath(requestedPath, page).get());
@@ -120,7 +121,7 @@
           serializeObject(GSON, accountApi.getEditPreferences()));
       data.put("userIsAuthenticated", true);
       if (page == RequestedPage.DASHBOARD) {
-        data.put("defaultDashboardHex", IndexPreloadingUtil.getDefaultDashboardHex(serverApi));
+        data.put("defaultDashboardHex", ListOption.toHex(IndexPreloadingUtil.DASHBOARD_OPTIONS));
         data.put("dashboardQuery", IndexPreloadingUtil.computeDashboardQueryList(serverApi));
       }
     } catch (AuthException e) {
diff --git a/java/com/google/gerrit/httpd/raw/IndexPreloadingUtil.java b/java/com/google/gerrit/httpd/raw/IndexPreloadingUtil.java
index f1da6b7..98e660c 100644
--- a/java/com/google/gerrit/httpd/raw/IndexPreloadingUtil.java
+++ b/java/com/google/gerrit/httpd/raw/IndexPreloadingUtil.java
@@ -24,16 +24,13 @@
 import com.google.gerrit.common.UsedAt.Project;
 import com.google.gerrit.extensions.api.config.Server;
 import com.google.gerrit.extensions.client.ListChangesOption;
-import com.google.gerrit.extensions.client.ListOption;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.Url;
 import java.net.URI;
 import java.net.URISyntaxException;
 import java.util.ArrayList;
-import java.util.EnumSet;
 import java.util.List;
 import java.util.Optional;
-import java.util.Set;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
 import java.util.stream.Stream;
@@ -91,43 +88,26 @@
               NEW_USER)
           .map(query -> query.replaceAll("\\$\\{user}", "self"))
           .collect(toImmutableList());
+  public static final ImmutableSet<ListChangesOption> DASHBOARD_OPTIONS =
+      ImmutableSet.of(ListChangesOption.LABELS, ListChangesOption.DETAILED_ACCOUNTS);
 
-  public static String getDefaultChangeDetailOptionsAsHex() {
-    Set<ListChangesOption> options =
-        ImmutableSet.of(
-            ListChangesOption.ALL_COMMITS,
-            ListChangesOption.ALL_REVISIONS,
-            ListChangesOption.CHANGE_ACTIONS,
-            ListChangesOption.DETAILED_LABELS,
-            ListChangesOption.DOWNLOAD_COMMANDS,
-            ListChangesOption.MESSAGES,
-            ListChangesOption.SUBMITTABLE,
-            ListChangesOption.WEB_LINKS,
-            ListChangesOption.SKIP_DIFFSTAT);
+  public static final ImmutableSet<ListChangesOption> CHANGE_DETAIL_OPTIONS =
+      ImmutableSet.of(
+          ListChangesOption.ALL_COMMITS,
+          ListChangesOption.ALL_REVISIONS,
+          ListChangesOption.CHANGE_ACTIONS,
+          ListChangesOption.DETAILED_LABELS,
+          ListChangesOption.DOWNLOAD_COMMANDS,
+          ListChangesOption.MESSAGES,
+          ListChangesOption.SUBMITTABLE,
+          ListChangesOption.WEB_LINKS,
+          ListChangesOption.SKIP_DIFFSTAT);
 
-    return ListOption.toHex(options);
-  }
-
-  public static String getDefaultDiffDetailOptionsAsHex() {
-    Set<ListChangesOption> options =
-        ImmutableSet.of(
-            ListChangesOption.ALL_COMMITS,
-            ListChangesOption.ALL_REVISIONS,
-            ListChangesOption.SKIP_DIFFSTAT);
-
-    return ListOption.toHex(options);
-  }
-
-  public static String getDefaultDashboardHex(Server serverApi) throws RestApiException {
-    Set<ListChangesOption> options = EnumSet.noneOf(ListChangesOption.class);
-    options.add(ListChangesOption.LABELS);
-    options.add(ListChangesOption.DETAILED_ACCOUNTS);
-
-    if (!isEnabledAttentionSet(serverApi)) {
-      options.add(ListChangesOption.REVIEWED);
-    }
-    return ListOption.toHex(options);
-  }
+  public static final ImmutableSet<ListChangesOption> DIFF_OPTIONS =
+      ImmutableSet.of(
+          ListChangesOption.ALL_COMMITS,
+          ListChangesOption.ALL_REVISIONS,
+          ListChangesOption.SKIP_DIFFSTAT);
 
   public static String getPath(@Nullable String requestedURL) throws URISyntaxException {
     if (requestedURL == null) {
diff --git a/java/com/google/gerrit/httpd/raw/StaticModule.java b/java/com/google/gerrit/httpd/raw/StaticModule.java
index bb1eb92..aa32169 100644
--- a/java/com/google/gerrit/httpd/raw/StaticModule.java
+++ b/java/com/google/gerrit/httpd/raw/StaticModule.java
@@ -225,8 +225,7 @@
         @GerritServerConfig Config cfg,
         GerritApi gerritApi,
         ExperimentFeatures experimentFeatures) {
-      String cdnPath =
-          options.useDevCdn() ? options.devCdn() : cfg.getString("gerrit", null, "cdnPath");
+      String cdnPath = options.devCdn().orElse(cfg.getString("gerrit", null, "cdnPath"));
       String faviconPath = cfg.getString("gerrit", null, "faviconPath");
       return new IndexServlet(canonicalUrl, cdnPath, faviconPath, gerritApi, experimentFeatures);
     }
@@ -273,7 +272,7 @@
         if (warFs == null) {
           unpackedWar = makeWarTempDir();
           development = true;
-        } else if (options.useDevCdn()) {
+        } else if (options.devCdn().isPresent()) {
           unpackedWar = null;
           development = true;
         } else {
diff --git a/java/com/google/gerrit/httpd/restapi/RestApiServlet.java b/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
index 9b86a4f..269d1c4 100644
--- a/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
+++ b/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
@@ -30,6 +30,7 @@
 import static com.google.common.net.HttpHeaders.CONTENT_TYPE;
 import static com.google.common.net.HttpHeaders.ORIGIN;
 import static com.google.common.net.HttpHeaders.VARY;
+import static com.google.gerrit.server.experiments.ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_REMOVE_REVISION_ETAG;
 import static java.math.RoundingMode.CEILING;
 import static java.nio.charset.StandardCharsets.ISO_8859_1;
 import static java.nio.charset.StandardCharsets.UTF_8;
@@ -110,7 +111,9 @@
 import com.google.gerrit.server.audit.ExtendedHttpAuditEvent;
 import com.google.gerrit.server.cache.PerThreadCache;
 import com.google.gerrit.server.change.ChangeFinder;
+import com.google.gerrit.server.change.RevisionResource;
 import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.experiments.ExperimentFeatures;
 import com.google.gerrit.server.group.GroupAuditService;
 import com.google.gerrit.server.logging.Metadata;
 import com.google.gerrit.server.logging.PerformanceLogContext;
@@ -252,6 +255,7 @@
     final PluginSetContext<ExceptionHook> exceptionHooks;
     final Injector injector;
     final DynamicMap<DynamicOptions.DynamicBean> dynamicBeans;
+    final ExperimentFeatures experimentFeatures;
 
     @Inject
     Globals(
@@ -269,7 +273,8 @@
         RetryHelper retryHelper,
         PluginSetContext<ExceptionHook> exceptionHooks,
         Injector injector,
-        DynamicMap<DynamicOptions.DynamicBean> dynamicBeans) {
+        DynamicMap<DynamicOptions.DynamicBean> dynamicBeans,
+        ExperimentFeatures experimentFeatures) {
       this.currentUser = currentUser;
       this.webSession = webSession;
       this.paramParser = paramParser;
@@ -286,6 +291,7 @@
       allowOrigin = makeAllowOrigin(config);
       this.injector = injector;
       this.dynamicBeans = dynamicBeans;
+      this.experimentFeatures = experimentFeatures;
     }
 
     private static Pattern makeAllowOrigin(Config cfg) {
@@ -775,6 +781,11 @@
         TraceContext.newTimer(
             "RestApiServlet#getEtagWithRetry:resource",
             Metadata.builder().restViewName(rsrc.getClass().getSimpleName()).build())) {
+      if (rsrc instanceof RevisionResource
+          && globals.experimentFeatures.isFeatureEnabled(
+              GERRIT_BACKEND_REQUEST_FEATURE_REMOVE_REVISION_ETAG)) {
+        return null;
+      }
       return invokeRestEndpointWithRetry(
           req,
           traceContext,
@@ -1056,7 +1067,7 @@
 
     if (rsrc instanceof RestResource.HasETag) {
       String have = req.getHeader(HttpHeaders.IF_NONE_MATCH);
-      if (have != null) {
+      if (!Strings.isNullOrEmpty(have)) {
         String eTag = getEtagWithRetry(req, traceContext, (RestResource.HasETag) rsrc);
         return have.equals(eTag);
       }
@@ -1134,7 +1145,9 @@
       res.setHeader(HttpHeaders.ETAG, eTag);
     } else if (rsrc instanceof RestResource.HasETag) {
       String eTag = getEtagWithRetry(req, traceContext, (RestResource.HasETag) rsrc);
-      res.setHeader(HttpHeaders.ETAG, eTag);
+      if (!Strings.isNullOrEmpty(eTag)) {
+        res.setHeader(HttpHeaders.ETAG, eTag);
+      }
     }
     if (rsrc instanceof RestResource.HasLastModified) {
       res.setDateHeader(
diff --git a/java/com/google/gerrit/index/FieldDef.java b/java/com/google/gerrit/index/FieldDef.java
index 63f6887..eb64c1d 100644
--- a/java/com/google/gerrit/index/FieldDef.java
+++ b/java/com/google/gerrit/index/FieldDef.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.index;
 
 import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkState;
 import static java.util.Objects.requireNonNull;
 
 import com.google.common.base.CharMatcher;
@@ -22,6 +23,7 @@
 import com.google.gerrit.exceptions.StorageException;
 import java.io.IOException;
 import java.sql.Timestamp;
+import java.util.Optional;
 
 /**
  * Definition of a field stored in the secondary index.
@@ -65,6 +67,11 @@
     T get(I input) throws IOException;
   }
 
+  @FunctionalInterface
+  public interface Setter<I, T> {
+    void set(I object, T value);
+  }
+
   public static class Builder<T> {
     private final FieldType<T> type;
     private final String name;
@@ -81,11 +88,20 @@
     }
 
     public <I> FieldDef<I, T> build(Getter<I, T> getter) {
-      return new FieldDef<>(name, type, stored, false, getter);
+      return new FieldDef<>(name, type, stored, false, getter, null);
+    }
+
+    public <I> FieldDef<I, T> build(Getter<I, T> getter, Setter<I, T> setter) {
+      return new FieldDef<>(name, type, stored, false, getter, setter);
     }
 
     public <I> FieldDef<I, Iterable<T>> buildRepeatable(Getter<I, Iterable<T>> getter) {
-      return new FieldDef<>(name, type, stored, true, getter);
+      return new FieldDef<>(name, type, stored, true, getter, null);
+    }
+
+    public <I> FieldDef<I, Iterable<T>> buildRepeatable(
+        Getter<I, Iterable<T>> getter, Setter<I, Iterable<T>> setter) {
+      return new FieldDef<>(name, type, stored, true, getter, setter);
     }
   }
 
@@ -96,9 +112,15 @@
 
   private final boolean repeatable;
   private final Getter<I, T> getter;
+  private final Optional<Setter<I, T>> setter;
 
   private FieldDef(
-      String name, FieldType<?> type, boolean stored, boolean repeatable, Getter<I, T> getter) {
+      String name,
+      FieldType<?> type,
+      boolean stored,
+      boolean repeatable,
+      Getter<I, T> getter,
+      @Nullable Setter<I, T> setter) {
     checkArgument(
         !(repeatable && type == FieldType.INTEGER_RANGE),
         "Range queries against repeated fields are unsupported");
@@ -107,6 +129,7 @@
     this.stored = stored;
     this.repeatable = repeatable;
     this.getter = requireNonNull(getter);
+    this.setter = Optional.ofNullable(setter);
   }
 
   private static String checkName(String name) {
@@ -145,6 +168,41 @@
     }
   }
 
+  /**
+   * Set the field contents back to an object. Used to reconstruct fields from indexed values. No-op
+   * if the field can't be reconstructed.
+   *
+   * @param object input object.
+   * @param doc indexed document
+   * @return {@code true} if the field was set, {@code false} otherwise
+   */
+  @SuppressWarnings("unchecked")
+  public boolean setIfPossible(I object, StoredValue doc) {
+    if (!setter.isPresent()) {
+      return false;
+    }
+
+    if (FieldType.STRING_TYPES.stream().anyMatch(t -> t.getName().equals(getType().getName()))) {
+      setter.get().set(object, (T) (isRepeatable() ? doc.asStrings() : doc.asString()));
+      return true;
+    } else if (FieldType.INTEGER_TYPES.stream()
+        .anyMatch(t -> t.getName().equals(getType().getName()))) {
+      setter.get().set(object, (T) (isRepeatable() ? doc.asIntegers() : doc.asInteger()));
+      return true;
+    } else if (FieldType.LONG.getName().equals(getType().getName())) {
+      setter.get().set(object, (T) (isRepeatable() ? doc.asLongs() : doc.asLong()));
+      return true;
+    } else if (FieldType.STORED_ONLY.getName().equals(getType().getName())) {
+      setter.get().set(object, (T) (isRepeatable() ? doc.asByteArrays() : doc.asByteArray()));
+      return true;
+    } else if (FieldType.TIMESTAMP.getName().equals(getType().getName())) {
+      checkState(!isRepeatable(), "can't repeat timestamp values");
+      setter.get().set(object, (T) doc.asTimestamp());
+      return true;
+    }
+    return false;
+  }
+
   /** @return whether the field is repeatable. */
   public boolean isRepeatable() {
     return repeatable;
diff --git a/java/com/google/gerrit/index/FieldType.java b/java/com/google/gerrit/index/FieldType.java
index 0db0284..c4c55f23 100644
--- a/java/com/google/gerrit/index/FieldType.java
+++ b/java/com/google/gerrit/index/FieldType.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.index;
 
+import com.google.common.collect.ImmutableList;
 import java.sql.Timestamp;
 
 /** Document field types supported by the secondary index system. */
@@ -42,6 +43,14 @@
   /** A field that is only stored as raw bytes and cannot be queried. */
   public static final FieldType<byte[]> STORED_ONLY = new FieldType<>("STORED_ONLY");
 
+  /** List of all types that are stored as {@link String} in the index. */
+  public static final ImmutableList<FieldType<String>> STRING_TYPES =
+      ImmutableList.of(EXACT, PREFIX, FULL_TEXT);
+
+  /** List of all types that are stored as {@link Integer} in the index. */
+  public static final ImmutableList<FieldType<Integer>> INTEGER_TYPES =
+      ImmutableList.of(INTEGER_RANGE, INTEGER);
+
   private final String name;
 
   private FieldType(String name) {
diff --git a/java/com/google/gerrit/index/Index.java b/java/com/google/gerrit/index/Index.java
index 44f8b42..529cd78 100644
--- a/java/com/google/gerrit/index/Index.java
+++ b/java/com/google/gerrit/index/Index.java
@@ -135,4 +135,12 @@
    * @param ready whether the index is ready
    */
   void markReady(boolean ready);
+
+  /**
+   * Returns whether the index is enabled. {@code true} by default, but could be overridden by
+   * implementations.
+   */
+  default boolean isEnabled() {
+    return true;
+  }
 }
diff --git a/java/com/google/gerrit/index/IndexType.java b/java/com/google/gerrit/index/IndexType.java
index ee44deb..cade439 100644
--- a/java/com/google/gerrit/index/IndexType.java
+++ b/java/com/google/gerrit/index/IndexType.java
@@ -20,7 +20,8 @@
 /**
  * Index types supported by the secondary index.
  *
- * <p>The explicitly known index types are Lucene (the default) and Elasticsearch.
+ * <p>The explicitly known index types are Lucene (the default), Elasticsearch and a fake index used
+ * in tests.
  *
  * <p>The third supported index type is any other type String value, deemed as custom. This is for
  * configuring index types that are internal or not to be disclosed. Supporting custom index types
@@ -29,6 +30,7 @@
 public class IndexType {
   private static final String LUCENE = "lucene";
   private static final String ELASTICSEARCH = "elasticsearch";
+  private static final String FAKE = "fake";
 
   private final String type;
 
@@ -41,7 +43,7 @@
   }
 
   public static ImmutableSet<String> getKnownTypes() {
-    return ImmutableSet.of(LUCENE, ELASTICSEARCH);
+    return ImmutableSet.of(LUCENE, ELASTICSEARCH, FAKE);
   }
 
   public boolean isLucene() {
@@ -52,6 +54,10 @@
     return type.equals(ELASTICSEARCH);
   }
 
+  public boolean isFake() {
+    return type.equals(FAKE);
+  }
+
   @Override
   public String toString() {
     return type;
diff --git a/java/com/google/gerrit/index/StoredValue.java b/java/com/google/gerrit/index/StoredValue.java
new file mode 100644
index 0000000..fe790c5
--- /dev/null
+++ b/java/com/google/gerrit/index/StoredValue.java
@@ -0,0 +1,50 @@
+// 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;
+
+import java.sql.Timestamp;
+
+/**
+ * Representation of a field stored on the index. Used to load field values from different index
+ * backends.
+ */
+public interface StoredValue {
+  /** Returns the {@link String} value of the field. */
+  String asString();
+
+  /** Returns the {@link String} values of the field. */
+  Iterable<String> asStrings();
+
+  /** Returns the {@link Integer} value of the field. */
+  Integer asInteger();
+
+  /** Returns the {@link Integer} values of the field. */
+  Iterable<Integer> asIntegers();
+
+  /** Returns the {@link Long} value of the field. */
+  Long asLong();
+
+  /** Returns the {@link Long} values of the field. */
+  Iterable<Long> asLongs();
+
+  /** Returns the {@link Timestamp} value of the field. */
+  Timestamp asTimestamp();
+
+  /** Returns the {@code byte[]} value of the field. */
+  byte[] asByteArray();
+
+  /** Returns the {@code byte[]} values of the field. */
+  Iterable<byte[]> asByteArrays();
+}
diff --git a/java/com/google/gerrit/index/query/IndexPredicate.java b/java/com/google/gerrit/index/query/IndexPredicate.java
index aac6682..7bbe70b 100644
--- a/java/com/google/gerrit/index/query/IndexPredicate.java
+++ b/java/com/google/gerrit/index/query/IndexPredicate.java
@@ -14,11 +14,30 @@
 
 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.collect.Sets;
+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 +57,70 @@
   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(I)} matches terms as true prefixes (prefix:foo ->
+   *     `foo bar` matches, but `baz foo bar` does not match). The index implementation at Google
+   *     tokenizes both the query and the indexed text and matches tokens individually (prefix:fo ba
+   *     -> `baz foo bar` matches).
+   * <li>Full text: Lucene uses a {@code PhraseQuery} to search for terms in full text fields
+   *     in-order. The index implementation at Google as well as {@link #match(I)} tokenizes both
+   *     the query and the indexed text and matches tokens individually.
+   *
+   * @return true if the predicate matches the provided {@link I}.
+   */
+  @Override
+  public boolean match(I doc) {
+    if (getField().isRepeatable()) {
+      Iterable<Object> values = (Iterable<Object>) getField().get(doc);
+      for (Object v : values) {
+        if (matchesSingleObject(v)) {
+          return true;
+        }
+      }
+      return false;
+    } else {
+      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 !Sets.intersection(tokenizedField, tokenizedValue).isEmpty();
+    } 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/query/InternalQuery.java b/java/com/google/gerrit/index/query/InternalQuery.java
index 48e214e..5c003bc 100644
--- a/java/com/google/gerrit/index/query/InternalQuery.java
+++ b/java/com/google/gerrit/index/query/InternalQuery.java
@@ -42,7 +42,7 @@
  */
 public class InternalQuery<T, Q extends InternalQuery<T, Q>> {
   private final QueryProcessor<T> queryProcessor;
-  private final IndexCollection<?, T, ? extends Index<?, T>> indexes;
+  protected final IndexCollection<?, T, ? extends Index<?, T>> indexes;
 
   protected final IndexConfig indexConfig;
 
diff --git a/java/com/google/gerrit/index/testing/AbstractFakeIndex.java b/java/com/google/gerrit/index/testing/AbstractFakeIndex.java
new file mode 100644
index 0000000..5cc8e3c
--- /dev/null
+++ b/java/com/google/gerrit/index/testing/AbstractFakeIndex.java
@@ -0,0 +1,319 @@
+// 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 static com.google.common.collect.ImmutableList.toImmutableList;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableListMultimap;
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.InternalGroup;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.index.FieldDef;
+import com.google.gerrit.index.Index;
+import com.google.gerrit.index.QueryOptions;
+import com.google.gerrit.index.Schema;
+import com.google.gerrit.index.project.ProjectData;
+import com.google.gerrit.index.project.ProjectIndex;
+import com.google.gerrit.index.query.DataSource;
+import com.google.gerrit.index.query.FieldBundle;
+import com.google.gerrit.index.query.ListResultSet;
+import com.google.gerrit.index.query.Predicate;
+import com.google.gerrit.index.query.ResultSet;
+import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.index.IndexUtils;
+import com.google.gerrit.server.index.account.AccountIndex;
+import com.google.gerrit.server.index.change.ChangeField;
+import com.google.gerrit.server.index.change.ChangeIndex;
+import com.google.gerrit.server.index.group.GroupIndex;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+import java.sql.Timestamp;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Fake secondary index implementation for usage in tests. All values are kept in-memory.
+ *
+ * <p>This class is thread-safe.
+ */
+public abstract class AbstractFakeIndex<K, V, D> implements Index<K, V> {
+  private final Schema<V> schema;
+  /**
+   * SitePaths (config files) are used to signal that an index is ready. This implementation is
+   * consistent with other index backends.
+   */
+  private final SitePaths sitePaths;
+
+  private final String indexName;
+  private final Map<K, D> indexedDocuments;
+
+  AbstractFakeIndex(Schema<V> schema, SitePaths sitePaths, String indexName) {
+    this.schema = schema;
+    this.sitePaths = sitePaths;
+    this.indexName = indexName;
+    this.indexedDocuments = new HashMap<>();
+  }
+
+  @Override
+  public Schema<V> getSchema() {
+    return schema;
+  }
+
+  @Override
+  public void close() {
+    // No-op
+  }
+
+  @Override
+  public void replace(V doc) {
+    synchronized (indexedDocuments) {
+      indexedDocuments.put(keyFor(doc), docFor(doc));
+    }
+  }
+
+  @Override
+  public void delete(K key) {
+    synchronized (indexedDocuments) {
+      indexedDocuments.remove(key);
+    }
+  }
+
+  @Override
+  public void deleteAll() {
+    synchronized (indexedDocuments) {
+      indexedDocuments.clear();
+    }
+  }
+
+  @Override
+  public DataSource<V> getSource(Predicate<V> p, QueryOptions opts) {
+    List<V> results;
+    synchronized (indexedDocuments) {
+      results =
+          indexedDocuments.values().stream()
+              .map(doc -> valueFor(doc))
+              .filter(doc -> p.asMatchable().match(doc))
+              .sorted(sortingComparator())
+              .skip(opts.start())
+              .limit(opts.limit())
+              .collect(toImmutableList());
+    }
+    return new DataSource<V>() {
+      @Override
+      public int getCardinality() {
+        return results.size();
+      }
+
+      @Override
+      public ResultSet<V> read() {
+        return new ListResultSet<>(results);
+      }
+
+      @Override
+      public ResultSet<FieldBundle> readRaw() {
+        ImmutableList.Builder<FieldBundle> fieldBundles = ImmutableList.builder();
+        for (V result : results) {
+          ImmutableListMultimap.Builder<String, Object> fields = ImmutableListMultimap.builder();
+          for (FieldDef<V, ?> field : getSchema().getFields().values()) {
+            if (field.get(result) == null) {
+              continue;
+            }
+            if (field.isRepeatable()) {
+              fields.putAll(field.getName(), (Iterable<?>) field.get(result));
+            } else {
+              fields.put(field.getName(), field.get(result));
+            }
+          }
+          fieldBundles.add(new FieldBundle(fields.build()));
+        }
+        return new ListResultSet<>(fieldBundles.build());
+      }
+    };
+  }
+
+  @Override
+  public void markReady(boolean ready) {
+    IndexUtils.setReady(sitePaths, indexName, schema.getVersion(), ready);
+  }
+
+  /** Method to get a key from a document. */
+  protected abstract K keyFor(V doc);
+
+  /** Method to get a document the index should hold on to from a Gerrit Java data type. */
+  protected abstract D docFor(V value);
+
+  /** Method to a Gerrit Java data type from a document that the index was holding on to. */
+  protected abstract V valueFor(D doc);
+
+  /** Comparator representing the default search order. */
+  protected abstract Comparator<V> sortingComparator();
+
+  /**
+   * Fake implementation of {@link ChangeIndex} where all filtering happens in-memory.
+   *
+   * <p>This index is special in that ChangeData is a mutable object. Therefore we can't just hold
+   * onto the object that the caller wanted us to index. We also can't just create a new ChangeData
+   * from scratch because there are tests that assert that certain computations (e.g. diffs) are
+   * only done once. So we do what the prod indices do: We read and write fields using FieldDef.
+   */
+  public static class FakeChangeIndex
+      extends AbstractFakeIndex<Change.Id, ChangeData, Map<String, Object>> implements ChangeIndex {
+    private final ChangeData.Factory changeDataFactory;
+
+    @Inject
+    FakeChangeIndex(
+        SitePaths sitePaths,
+        ChangeData.Factory changeDataFactory,
+        @Assisted Schema<ChangeData> schema) {
+      super(schema, sitePaths, "changes");
+      this.changeDataFactory = changeDataFactory;
+    }
+
+    @Override
+    protected Change.Id keyFor(ChangeData value) {
+      return value.getId();
+    }
+
+    @Override
+    protected Comparator<ChangeData> sortingComparator() {
+      Comparator<ChangeData> lastUpdated =
+          Comparator.comparing(cd -> cd.change().getLastUpdatedOn());
+      Comparator<ChangeData> merged =
+          Comparator.comparing(cd -> cd.getMergedOn().orElse(new Timestamp(0)));
+      Comparator<ChangeData> id = Comparator.comparing(cd -> cd.getId().get());
+      return lastUpdated.thenComparing(merged).thenComparing(id).reversed();
+    }
+
+    @Override
+    protected Map<String, Object> docFor(ChangeData value) {
+      ImmutableMap.Builder<String, Object> doc = ImmutableMap.builder();
+      for (FieldDef<ChangeData, ?> field : getSchema().getFields().values()) {
+        Object docifiedValue = field.get(value);
+        if (docifiedValue != null) {
+          doc.put(field.getName(), field.get(value));
+        }
+      }
+      return doc.build();
+    }
+
+    @Override
+    protected ChangeData valueFor(Map<String, Object> doc) {
+      ChangeData cd =
+          changeDataFactory.create(
+              Project.nameKey((String) doc.get(ChangeField.PROJECT.getName())),
+              Change.id(Integer.valueOf((String) doc.get(ChangeField.LEGACY_ID_STR.getName()))));
+      for (FieldDef<ChangeData, ?> field : getSchema().getFields().values()) {
+        field.setIfPossible(cd, new FakeStoredValue(doc.get(field.getName())));
+      }
+      return cd;
+    }
+  }
+
+  /** Fake implementation of {@link AccountIndex} where all filtering happens in-memory. */
+  public static class FakeAccountIndex
+      extends AbstractFakeIndex<Account.Id, AccountState, AccountState> implements AccountIndex {
+    @Inject
+    FakeAccountIndex(SitePaths sitePaths, @Assisted Schema<AccountState> schema) {
+      super(schema, sitePaths, "accounts");
+    }
+
+    @Override
+    protected Account.Id keyFor(AccountState value) {
+      return value.account().id();
+    }
+
+    @Override
+    protected AccountState docFor(AccountState value) {
+      return value;
+    }
+
+    @Override
+    protected AccountState valueFor(AccountState doc) {
+      return doc;
+    }
+
+    @Override
+    protected Comparator<AccountState> sortingComparator() {
+      return Comparator.comparing(a -> a.account().id().get());
+    }
+  }
+
+  /** Fake implementation of {@link GroupIndex} where all filtering happens in-memory. */
+  public static class FakeGroupIndex
+      extends AbstractFakeIndex<AccountGroup.UUID, InternalGroup, InternalGroup>
+      implements GroupIndex {
+    @Inject
+    FakeGroupIndex(SitePaths sitePaths, @Assisted Schema<InternalGroup> schema) {
+      super(schema, sitePaths, "groups");
+    }
+
+    @Override
+    protected AccountGroup.UUID keyFor(InternalGroup value) {
+      return value.getGroupUUID();
+    }
+
+    @Override
+    protected InternalGroup docFor(InternalGroup value) {
+      return value;
+    }
+
+    @Override
+    protected InternalGroup valueFor(InternalGroup doc) {
+      return doc;
+    }
+
+    @Override
+    protected Comparator<InternalGroup> sortingComparator() {
+      return Comparator.comparing(g -> g.getId().get());
+    }
+  }
+
+  /** Fake implementation of {@link ProjectIndex} where all filtering happens in-memory. */
+  public static class FakeProjectIndex
+      extends AbstractFakeIndex<Project.NameKey, ProjectData, ProjectData> implements ProjectIndex {
+    @Inject
+    FakeProjectIndex(SitePaths sitePaths, @Assisted Schema<ProjectData> schema) {
+      super(schema, sitePaths, "projects");
+    }
+
+    @Override
+    protected Project.NameKey keyFor(ProjectData value) {
+      return value.getProject().getNameKey();
+    }
+
+    @Override
+    protected ProjectData docFor(ProjectData value) {
+      return value;
+    }
+
+    @Override
+    protected ProjectData valueFor(ProjectData doc) {
+      return doc;
+    }
+
+    @Override
+    protected Comparator<ProjectData> sortingComparator() {
+      return Comparator.comparing(p -> p.getProject().getName());
+    }
+  }
+}
diff --git a/java/com/google/gerrit/index/testing/BUILD b/java/com/google/gerrit/index/testing/BUILD
new file mode 100644
index 0000000..a30eaca
--- /dev/null
+++ b/java/com/google/gerrit/index/testing/BUILD
@@ -0,0 +1,27 @@
+load("@rules_java//java:defs.bzl", "java_library")
+
+java_library(
+    name = "testing",
+    testonly = True,
+    srcs = glob(
+        ["**/*.java"],
+    ),
+    visibility = ["//visibility:public"],
+    deps = [
+        "//java/com/google/gerrit/common:annotations",
+        "//java/com/google/gerrit/entities",
+        "//java/com/google/gerrit/exceptions",
+        "//java/com/google/gerrit/index",
+        "//java/com/google/gerrit/index:query_exception",
+        "//java/com/google/gerrit/index/project",
+        "//java/com/google/gerrit/proto",
+        "//java/com/google/gerrit/server",
+        "//java/com/google/gerrit/server/logging",
+        "//lib:guava",
+        "//lib:jgit",
+        "//lib:protobuf",
+        "//lib/flogger:api",
+        "//lib/guice",
+        "//lib/guice:guice-assistedinject",
+    ],
+)
diff --git a/java/com/google/gerrit/index/testing/FakeIndexModule.java b/java/com/google/gerrit/index/testing/FakeIndexModule.java
new file mode 100644
index 0000000..126ff10
--- /dev/null
+++ b/java/com/google/gerrit/index/testing/FakeIndexModule.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.index.testing;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.index.project.ProjectIndex;
+import com.google.gerrit.server.index.AbstractIndexModule;
+import com.google.gerrit.server.index.VersionManager;
+import com.google.gerrit.server.index.account.AccountIndex;
+import com.google.gerrit.server.index.change.ChangeIndex;
+import com.google.gerrit.server.index.group.GroupIndex;
+import java.util.Map;
+
+/** Module to bind {@link FakeIndexModule}. */
+public class FakeIndexModule extends AbstractIndexModule {
+  public static FakeIndexModule singleVersionAllLatest(int threads, boolean secondary) {
+    return new FakeIndexModule(ImmutableMap.of(), threads, secondary);
+  }
+
+  public static FakeIndexModule singleVersionWithExplicitVersions(
+      Map<String, Integer> versions, int threads, boolean secondary) {
+    return new FakeIndexModule(versions, threads, secondary);
+  }
+
+  public static FakeIndexModule latestVersion(boolean secondary) {
+    return new FakeIndexModule(null, -1 /* direct executor */, secondary);
+  }
+
+  private FakeIndexModule(Map<String, Integer> singleVersions, int threads, boolean secondary) {
+    super(singleVersions, threads, secondary);
+  }
+
+  @Override
+  protected Class<? extends AccountIndex> getAccountIndex() {
+    return AbstractFakeIndex.FakeAccountIndex.class;
+  }
+
+  @Override
+  protected Class<? extends ChangeIndex> getChangeIndex() {
+    return AbstractFakeIndex.FakeChangeIndex.class;
+  }
+
+  @Override
+  protected Class<? extends GroupIndex> getGroupIndex() {
+    return AbstractFakeIndex.FakeGroupIndex.class;
+  }
+
+  @Override
+  protected Class<? extends ProjectIndex> getProjectIndex() {
+    return AbstractFakeIndex.FakeProjectIndex.class;
+  }
+
+  @Override
+  protected Class<? extends VersionManager> getVersionManager() {
+    return FakeIndexVersionManager.class;
+  }
+}
diff --git a/java/com/google/gerrit/index/testing/FakeIndexVersionManager.java b/java/com/google/gerrit/index/testing/FakeIndexVersionManager.java
new file mode 100644
index 0000000..5044e38
--- /dev/null
+++ b/java/com/google/gerrit/index/testing/FakeIndexVersionManager.java
@@ -0,0 +1,75 @@
+// 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.common.collect.Iterables;
+import com.google.gerrit.index.Index;
+import com.google.gerrit.index.IndexDefinition;
+import com.google.gerrit.index.Schema;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.index.GerritIndexStatus;
+import com.google.gerrit.server.index.OnlineUpgradeListener;
+import com.google.gerrit.server.index.VersionManager;
+import com.google.gerrit.server.plugincontext.PluginSetContext;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.util.Collection;
+import java.util.TreeMap;
+import org.eclipse.jgit.lib.Config;
+
+/** Fake version manager for {@link AbstractFakeIndex}. */
+@Singleton
+public class FakeIndexVersionManager extends VersionManager {
+
+  @Inject
+  FakeIndexVersionManager(
+      @GerritServerConfig Config cfg,
+      SitePaths sitePaths,
+      PluginSetContext<OnlineUpgradeListener> listeners,
+      Collection<IndexDefinition<?, ?, ?>> defs) {
+    super(sitePaths, listeners, defs, VersionManager.getOnlineUpgrade(cfg));
+  }
+
+  @Override
+  protected <K, V, I extends Index<K, V>> TreeMap<Integer, Version<V>> scanVersions(
+      IndexDefinition<K, V, I> def, GerritIndexStatus cfg) {
+    TreeMap<Integer, Version<V>> versions = new TreeMap<>();
+    for (Schema<V> schema : def.getSchemas().values()) {
+      int v = schema.getVersion();
+      boolean exists = versions.containsKey(v);
+      versions.put(v, new Version<>(schema, v, exists, cfg.getReady(def.getName(), v)));
+    }
+    return versions;
+  }
+
+  @Override
+  protected <K, V, I extends Index<K, V>> void initIndex(
+      IndexDefinition<K, V, I> def, GerritIndexStatus cfg) {
+    // Set latest versions ready.
+    if (def.getSchemas().isEmpty()) {
+      super.initIndex(def, cfg);
+      return;
+    }
+    Schema<V> schema = Iterables.getLast(def.getSchemas().values());
+    try {
+      cfg.setReady(def.getName(), schema.getVersion(), true);
+      cfg.save();
+    } catch (Exception e) {
+      throw new IllegalStateException(e);
+    }
+    super.initIndex(def, cfg);
+  }
+}
diff --git a/java/com/google/gerrit/index/testing/FakeStoredValue.java b/java/com/google/gerrit/index/testing/FakeStoredValue.java
new file mode 100644
index 0000000..ca46ed1
--- /dev/null
+++ b/java/com/google/gerrit/index/testing/FakeStoredValue.java
@@ -0,0 +1,76 @@
+// 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.StoredValue;
+import java.sql.Timestamp;
+
+/** Bridge to recover fields from the fake index. */
+public class FakeStoredValue implements StoredValue {
+  private final Object field;
+
+  FakeStoredValue(Object field) {
+    this.field = field;
+  }
+
+  @Override
+  public String asString() {
+    return (String) field;
+  }
+
+  @Override
+  @SuppressWarnings("unchecked")
+  public Iterable<String> asStrings() {
+    return (Iterable<String>) field;
+  }
+
+  @Override
+  public Integer asInteger() {
+    return (Integer) field;
+  }
+
+  @Override
+  @SuppressWarnings("unchecked")
+  public Iterable<Integer> asIntegers() {
+    return (Iterable<Integer>) field;
+  }
+
+  @Override
+  public Long asLong() {
+    return (Long) field;
+  }
+
+  @Override
+  @SuppressWarnings("unchecked")
+  public Iterable<Long> asLongs() {
+    return (Iterable<Long>) field;
+  }
+
+  @Override
+  public Timestamp asTimestamp() {
+    return (Timestamp) field;
+  }
+
+  @Override
+  public byte[] asByteArray() {
+    return (byte[]) field;
+  }
+
+  @Override
+  @SuppressWarnings("unchecked")
+  public Iterable<byte[]> asByteArrays() {
+    return (Iterable<byte[]>) field;
+  }
+}
diff --git a/java/com/google/gerrit/lucene/LuceneChangeIndex.java b/java/com/google/gerrit/lucene/LuceneChangeIndex.java
index c3d4440..ac616ca 100644
--- a/java/com/google/gerrit/lucene/LuceneChangeIndex.java
+++ b/java/com/google/gerrit/lucene/LuceneChangeIndex.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.lucene;
 
 import static com.google.common.collect.ImmutableList.toImmutableList;
-import static com.google.common.collect.ImmutableSet.toImmutableSet;
 import static com.google.gerrit.lucene.AbstractLuceneIndex.sortFieldName;
 import static com.google.gerrit.server.git.QueueProvider.QueueType.INTERACTIVE;
 import static com.google.gerrit.server.index.change.ChangeField.LEGACY_ID;
@@ -24,11 +23,8 @@
 import static com.google.gerrit.server.index.change.ChangeIndexRewriter.CLOSED_STATUSES;
 import static com.google.gerrit.server.index.change.ChangeIndexRewriter.OPEN_STATUSES;
 import static java.util.Objects.requireNonNull;
-import static java.util.stream.Collectors.toList;
 
 import com.google.common.base.Throwables;
-import com.google.common.collect.Collections2;
-import com.google.common.collect.FluentIterable;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
@@ -38,25 +34,19 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.common.util.concurrent.Futures;
 import com.google.common.util.concurrent.ListeningExecutorService;
-import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.converter.ChangeProtoConverter;
-import com.google.gerrit.entities.converter.PatchSetApprovalProtoConverter;
-import com.google.gerrit.entities.converter.PatchSetProtoConverter;
 import com.google.gerrit.entities.converter.ProtoConverter;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.index.FieldDef;
 import com.google.gerrit.index.QueryOptions;
-import com.google.gerrit.index.RefState;
 import com.google.gerrit.index.Schema;
 import com.google.gerrit.index.query.FieldBundle;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.QueryParseException;
 import com.google.gerrit.index.query.ResultSet;
 import com.google.gerrit.proto.Protos;
-import com.google.gerrit.server.StarredChangesUtil;
 import com.google.gerrit.server.change.MergeabilityComputationBehavior;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.SitePaths;
@@ -65,7 +55,6 @@
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gerrit.server.index.change.ChangeIndex;
 import com.google.gerrit.server.index.change.ChangeIndexRewriter;
-import com.google.gerrit.server.project.SubmitRuleOptions;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.ChangeDataSource;
 import com.google.inject.Inject;
@@ -73,16 +62,13 @@
 import com.google.protobuf.MessageLite;
 import java.io.IOException;
 import java.nio.file.Path;
-import java.sql.Timestamp;
 import java.util.ArrayList;
-import java.util.Collection;
 import java.util.Iterator;
 import java.util.List;
 import java.util.Set;
 import java.util.concurrent.Callable;
 import java.util.concurrent.ExecutionException;
 import java.util.concurrent.Future;
-import java.util.function.Consumer;
 import java.util.function.Function;
 import org.apache.lucene.document.Document;
 import org.apache.lucene.index.IndexWriter;
@@ -119,34 +105,10 @@
   private static final String CHANGES = "changes";
   private static final String CHANGES_OPEN = "open";
   private static final String CHANGES_CLOSED = "closed";
-  private static final String ADDED_FIELD = ChangeField.ADDED.getName();
-  private static final String APPROVAL_FIELD = ChangeField.APPROVAL.getName();
   private static final String CHANGE_FIELD = ChangeField.CHANGE.getName();
-  private static final String DELETED_FIELD = ChangeField.DELETED.getName();
-  private static final String MERGEABLE_FIELD = ChangeField.MERGEABLE.getName();
-  private static final String PATCH_SET_FIELD = ChangeField.PATCH_SET.getName();
-  private static final String PENDING_REVIEWER_FIELD = ChangeField.PENDING_REVIEWER.getName();
-  private static final String PENDING_REVIEWER_BY_EMAIL_FIELD =
-      ChangeField.PENDING_REVIEWER_BY_EMAIL.getName();
-  private static final String REF_STATE_FIELD = ChangeField.REF_STATE.getName();
-  private static final String REF_STATE_PATTERN_FIELD = ChangeField.REF_STATE_PATTERN.getName();
-  private static final String REVIEWEDBY_FIELD = ChangeField.REVIEWEDBY.getName();
-  private static final String REVIEWER_FIELD = ChangeField.REVIEWER.getName();
-  private static final String REVIEWER_BY_EMAIL_FIELD = ChangeField.REVIEWER_BY_EMAIL.getName();
-  private static final String HASHTAG_FIELD = ChangeField.HASHTAG_CASE_AWARE.getName();
-  private static final String STAR_FIELD = ChangeField.STAR.getName();
-  private static final String SUBMIT_RECORD_LENIENT_FIELD =
-      ChangeField.STORED_SUBMIT_RECORD_LENIENT.getName();
-  private static final String SUBMIT_RECORD_STRICT_FIELD =
-      ChangeField.STORED_SUBMIT_RECORD_STRICT.getName();
-  private static final String TOTAL_COMMENT_COUNT_FIELD = ChangeField.TOTAL_COMMENT_COUNT.getName();
-  private static final String UNRESOLVED_COMMENT_COUNT_FIELD =
-      ChangeField.UNRESOLVED_COMMENT_COUNT.getName();
-  private static final String ATTENTION_SET_FULL_FIELD = ChangeField.ATTENTION_SET_FULL.getName();
-  private static final String MERGED_ON_FIELD = ChangeField.MERGED_ON.getName();
 
   @FunctionalInterface
-  static interface IdTerm {
+  interface IdTerm {
     Term get(String name, int id);
   }
 
@@ -159,7 +121,7 @@
   }
 
   @FunctionalInterface
-  static interface ChangeIdExtractor {
+  interface ChangeIdExtractor {
     Change.Id extract(IndexableField f);
   }
 
@@ -520,231 +482,14 @@
       cd = changeDataFactory.create(Project.nameKey(project.stringValue()), extractor.extract(f));
     }
 
-    // Any decoding that is done here must also be done in {@link ElasticChangeIndex}.
-
-    if (fields.contains(PATCH_SET_FIELD)) {
-      decodePatchSets(doc, cd);
+    for (FieldDef<ChangeData, ?> field : getSchema().getFields().values()) {
+      if (fields.contains(field.getName()) && doc.get(field.getName()) != null) {
+        field.setIfPossible(cd, new LuceneStoredValue(doc.get(field.getName())));
+      }
     }
-    if (fields.contains(APPROVAL_FIELD)) {
-      decodeApprovals(doc, cd);
-    }
-    if (fields.contains(ADDED_FIELD) && fields.contains(DELETED_FIELD)) {
-      decodeChangedLines(doc, cd);
-    }
-    if (fields.contains(MERGEABLE_FIELD)) {
-      decodeMergeable(doc, cd);
-    }
-    if (fields.contains(REVIEWEDBY_FIELD)) {
-      decodeReviewedBy(doc, cd);
-    }
-    if (fields.contains(HASHTAG_FIELD)) {
-      decodeHashtags(doc, cd);
-    }
-    if (fields.contains(STAR_FIELD)) {
-      decodeStar(doc, cd);
-    }
-    if (fields.contains(REVIEWER_FIELD)) {
-      decodeReviewers(doc, cd);
-    }
-    if (fields.contains(REVIEWER_BY_EMAIL_FIELD)) {
-      decodeReviewersByEmail(doc, cd);
-    }
-    if (fields.contains(PENDING_REVIEWER_FIELD)) {
-      decodePendingReviewers(doc, cd);
-    }
-    if (fields.contains(PENDING_REVIEWER_BY_EMAIL_FIELD)) {
-      decodePendingReviewersByEmail(doc, cd);
-    }
-    if (fields.contains(ATTENTION_SET_FULL_FIELD)) {
-      decodeAttentionSet(doc, cd);
-    }
-    decodeSubmitRecords(
-        doc, SUBMIT_RECORD_STRICT_FIELD, ChangeField.SUBMIT_RULE_OPTIONS_STRICT, cd);
-    decodeSubmitRecords(
-        doc, SUBMIT_RECORD_LENIENT_FIELD, ChangeField.SUBMIT_RULE_OPTIONS_LENIENT, cd);
-    if (fields.contains(REF_STATE_FIELD)) {
-      decodeRefStates(doc, cd);
-    }
-    if (fields.contains(REF_STATE_PATTERN_FIELD)) {
-      decodeRefStatePatterns(doc, cd);
-    }
-    if (fields.contains(MERGED_ON_FIELD)) {
-      decodeMergedOn(doc, cd);
-    }
-
-    decodeUnresolvedCommentCount(doc, cd);
-    decodeTotalCommentCount(doc, cd);
     return cd;
   }
 
-  private void decodePatchSets(ListMultimap<String, IndexableField> doc, ChangeData cd) {
-    List<PatchSet> patchSets = decodeProtos(doc, PATCH_SET_FIELD, PatchSetProtoConverter.INSTANCE);
-    if (!patchSets.isEmpty()) {
-      // Will be an empty list for schemas prior to when this field was stored;
-      // this cannot be valid since a change needs at least one patch set.
-      cd.setPatchSets(patchSets);
-    }
-  }
-
-  private void decodeApprovals(ListMultimap<String, IndexableField> doc, ChangeData cd) {
-    cd.setCurrentApprovals(
-        decodeProtos(doc, APPROVAL_FIELD, PatchSetApprovalProtoConverter.INSTANCE));
-  }
-
-  private void decodeChangedLines(ListMultimap<String, IndexableField> doc, ChangeData cd) {
-    IndexableField added = Iterables.getFirst(doc.get(ADDED_FIELD), null);
-    IndexableField deleted = Iterables.getFirst(doc.get(DELETED_FIELD), null);
-    if (added != null && deleted != null) {
-      cd.setChangedLines(added.numericValue().intValue(), deleted.numericValue().intValue());
-    } else {
-      // No ChangedLines stored, likely due to failure during reindexing, for
-      // example due to LargeObjectException. But we know the field was
-      // requested, so update ChangeData to prevent callers from trying to
-      // lazily load it, as that would probably also fail.
-      cd.setNoChangedLines();
-    }
-  }
-
-  private void decodeMergeable(ListMultimap<String, IndexableField> doc, ChangeData cd) {
-    IndexableField f = Iterables.getFirst(doc.get(MERGEABLE_FIELD), null);
-    if (f != null && !skipFields.contains(MERGEABLE_FIELD)) {
-      String mergeable = f.stringValue();
-      if ("1".equals(mergeable)) {
-        cd.setMergeable(true);
-      } else if ("0".equals(mergeable)) {
-        cd.setMergeable(false);
-      }
-    }
-  }
-
-  private void decodeReviewedBy(ListMultimap<String, IndexableField> doc, ChangeData cd) {
-    Collection<IndexableField> reviewedBy = doc.get(REVIEWEDBY_FIELD);
-    if (!reviewedBy.isEmpty()) {
-      Set<Account.Id> accounts = Sets.newHashSetWithExpectedSize(reviewedBy.size());
-      for (IndexableField r : reviewedBy) {
-        int id = r.numericValue().intValue();
-        if (reviewedBy.size() == 1 && id == ChangeField.NOT_REVIEWED) {
-          break;
-        }
-        accounts.add(Account.id(id));
-      }
-      cd.setReviewedBy(accounts);
-    }
-  }
-
-  private void decodeHashtags(ListMultimap<String, IndexableField> doc, ChangeData cd) {
-    Collection<IndexableField> hashtag = doc.get(HASHTAG_FIELD);
-    Set<String> hashtags = Sets.newHashSetWithExpectedSize(hashtag.size());
-    for (IndexableField r : hashtag) {
-      hashtags.add(r.binaryValue().utf8ToString());
-    }
-    cd.setHashtags(hashtags);
-  }
-
-  private void decodeStar(ListMultimap<String, IndexableField> doc, ChangeData cd) {
-    Collection<IndexableField> star = doc.get(STAR_FIELD);
-    ListMultimap<Account.Id, String> stars = MultimapBuilder.hashKeys().arrayListValues().build();
-    for (IndexableField r : star) {
-      StarredChangesUtil.StarField starField = StarredChangesUtil.StarField.parse(r.stringValue());
-      if (starField != null) {
-        stars.put(starField.accountId(), starField.label());
-      }
-    }
-    cd.setStars(stars);
-  }
-
-  private void decodeReviewers(ListMultimap<String, IndexableField> doc, ChangeData cd) {
-    cd.setReviewers(
-        ChangeField.parseReviewerFieldValues(
-            cd.getId(),
-            FluentIterable.from(doc.get(REVIEWER_FIELD)).transform(IndexableField::stringValue)));
-  }
-
-  private void decodeReviewersByEmail(ListMultimap<String, IndexableField> doc, ChangeData cd) {
-    cd.setReviewersByEmail(
-        ChangeField.parseReviewerByEmailFieldValues(
-            cd.getId(),
-            FluentIterable.from(doc.get(REVIEWER_BY_EMAIL_FIELD))
-                .transform(IndexableField::stringValue)));
-  }
-
-  private void decodePendingReviewers(ListMultimap<String, IndexableField> doc, ChangeData cd) {
-    cd.setPendingReviewers(
-        ChangeField.parseReviewerFieldValues(
-            cd.getId(),
-            FluentIterable.from(doc.get(PENDING_REVIEWER_FIELD))
-                .transform(IndexableField::stringValue)));
-  }
-
-  private void decodePendingReviewersByEmail(
-      ListMultimap<String, IndexableField> doc, ChangeData cd) {
-    cd.setPendingReviewersByEmail(
-        ChangeField.parseReviewerByEmailFieldValues(
-            cd.getId(),
-            FluentIterable.from(doc.get(PENDING_REVIEWER_BY_EMAIL_FIELD))
-                .transform(IndexableField::stringValue)));
-  }
-
-  private void decodeAttentionSet(ListMultimap<String, IndexableField> doc, ChangeData cd) {
-    ChangeField.parseAttentionSet(
-        doc.get(ATTENTION_SET_FULL_FIELD).stream()
-            .map(field -> field.binaryValue().utf8ToString())
-            .collect(toImmutableSet()),
-        cd);
-  }
-
-  private void decodeSubmitRecords(
-      ListMultimap<String, IndexableField> doc,
-      String field,
-      SubmitRuleOptions opts,
-      ChangeData cd) {
-    ChangeField.parseSubmitRecords(
-        Collections2.transform(doc.get(field), f -> f.binaryValue().utf8ToString()), opts, cd);
-  }
-
-  private void decodeRefStates(ListMultimap<String, IndexableField> doc, ChangeData cd) {
-    cd.setRefStates(RefState.parseStates(copyAsBytes(doc.get(REF_STATE_FIELD))));
-  }
-
-  private void decodeRefStatePatterns(ListMultimap<String, IndexableField> doc, ChangeData cd) {
-    cd.setRefStatePatterns(copyAsBytes(doc.get(REF_STATE_PATTERN_FIELD)));
-  }
-
-  private void decodeUnresolvedCommentCount(
-      ListMultimap<String, IndexableField> doc, ChangeData cd) {
-    decodeIntField(doc, UNRESOLVED_COMMENT_COUNT_FIELD, cd::setUnresolvedCommentCount);
-  }
-
-  private void decodeTotalCommentCount(ListMultimap<String, IndexableField> doc, ChangeData cd) {
-    decodeIntField(doc, TOTAL_COMMENT_COUNT_FIELD, cd::setTotalCommentCount);
-  }
-
-  private static void decodeIntField(
-      ListMultimap<String, IndexableField> doc, String fieldName, Consumer<Integer> consumer) {
-    IndexableField f = Iterables.getFirst(doc.get(fieldName), null);
-    if (f != null && f.numericValue() != null) {
-      consumer.accept(f.numericValue().intValue());
-    }
-  }
-
-  private void decodeMergedOn(ListMultimap<String, IndexableField> doc, ChangeData cd) {
-    IndexableField mergedOnField =
-        Iterables.getFirst(doc.get(MERGED_ON_FIELD), /* defaultValue= */ null);
-    Timestamp mergedOn = null;
-    if (mergedOnField != null && mergedOnField.numericValue() != null) {
-      mergedOn = new Timestamp(mergedOnField.numericValue().longValue());
-    }
-    cd.setMergedOn(mergedOn);
-  }
-
-  private static <T> List<T> decodeProtos(
-      ListMultimap<String, IndexableField> doc, String fieldName, ProtoConverter<?, T> converter) {
-    return doc.get(fieldName).stream()
-        .map(IndexableField::binaryValue)
-        .map(bytesRef -> parseProtoFrom(bytesRef, converter))
-        .collect(toImmutableList());
-  }
-
   private static <P extends MessageLite, T> T parseProtoFrom(
       BytesRef bytesRef, ProtoConverter<P, T> converter) {
     P message =
@@ -752,16 +497,4 @@
             converter.getParser(), bytesRef.bytes, bytesRef.offset, bytesRef.length);
     return converter.fromProto(message);
   }
-
-  private static List<byte[]> copyAsBytes(Collection<IndexableField> fields) {
-    return fields.stream()
-        .map(
-            f -> {
-              BytesRef ref = f.binaryValue();
-              byte[] b = new byte[ref.length];
-              System.arraycopy(ref.bytes, ref.offset, b, 0, ref.length);
-              return b;
-            })
-        .collect(toList());
-  }
 }
diff --git a/java/com/google/gerrit/lucene/LuceneStoredValue.java b/java/com/google/gerrit/lucene/LuceneStoredValue.java
new file mode 100644
index 0000000..efe489b
--- /dev/null
+++ b/java/com/google/gerrit/lucene/LuceneStoredValue.java
@@ -0,0 +1,95 @@
+// 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.lucene;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static java.util.stream.Collectors.toList;
+
+import com.google.common.collect.Iterables;
+import com.google.gerrit.index.StoredValue;
+import java.sql.Timestamp;
+import java.util.List;
+import org.apache.lucene.index.IndexableField;
+import org.apache.lucene.util.BytesRef;
+
+/** Bridge to recover fields from the lucene index. */
+public class LuceneStoredValue implements StoredValue {
+  /**
+   * Lucene represents repeated fields as a list of {@link IndexableField}, so we hold onto a list
+   * here to cover both repeated and non-repeated fields.
+   */
+  private final List<IndexableField> field;
+
+  LuceneStoredValue(List<IndexableField> field) {
+    this.field = field;
+  }
+
+  @Override
+  public String asString() {
+    return Iterables.getFirst(asStrings(), null);
+  }
+
+  @Override
+  public Iterable<String> asStrings() {
+    return field.stream().map(f -> f.stringValue()).collect(toImmutableList());
+  }
+
+  @Override
+  public Integer asInteger() {
+    return Iterables.getFirst(asIntegers(), null);
+  }
+
+  @Override
+  public Iterable<Integer> asIntegers() {
+    return field.stream().map(f -> f.numericValue().intValue()).collect(toImmutableList());
+  }
+
+  @Override
+  public Long asLong() {
+    return Iterables.getFirst(asLongs(), null);
+  }
+
+  @Override
+  public Iterable<Long> asLongs() {
+    return field.stream().map(f -> f.numericValue().longValue()).collect(toImmutableList());
+  }
+
+  @Override
+  public Timestamp asTimestamp() {
+    return asLong() == null ? null : new Timestamp(asLong());
+  }
+
+  @Override
+  public byte[] asByteArray() {
+    return Iterables.getFirst(asByteArrays(), null);
+  }
+
+  @Override
+  public Iterable<byte[]> asByteArrays() {
+    return copyAsBytes(field);
+  }
+
+  private static List<byte[]> copyAsBytes(List<IndexableField> fields) {
+    return fields.stream()
+        .map(
+            f -> {
+              BytesRef ref = f.binaryValue();
+              byte[] b = new byte[ref.length];
+              System.arraycopy(ref.bytes, ref.offset, b, 0, ref.length);
+              return b;
+            })
+        .collect(toList());
+  }
+}
diff --git a/java/com/google/gerrit/pgm/init/AccountsOnInit.java b/java/com/google/gerrit/pgm/init/AccountsOnInit.java
index 536ddcd..d9e3a6a 100644
--- a/java/com/google/gerrit/pgm/init/AccountsOnInit.java
+++ b/java/com/google/gerrit/pgm/init/AccountsOnInit.java
@@ -22,9 +22,9 @@
 import com.google.gerrit.pgm.init.api.AllUsersNameOnInitProvider;
 import com.google.gerrit.pgm.init.api.InitFlags;
 import com.google.gerrit.server.GerritPersonIdentProvider;
+import com.google.gerrit.server.account.AccountDelta;
 import com.google.gerrit.server.account.AccountProperties;
 import com.google.gerrit.server.account.Accounts;
-import com.google.gerrit.server.account.InternalAccountUpdate;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.inject.Inject;
 import java.io.File;
@@ -69,7 +69,7 @@
 
       Config accountConfig = new Config();
       AccountProperties.writeToAccountConfig(
-          InternalAccountUpdate.builder()
+          AccountDelta.builder()
               .setActive(!account.inactive())
               .setFullName(account.fullName())
               .setPreferredEmail(account.preferredEmail())
diff --git a/java/com/google/gerrit/pgm/init/GroupsOnInit.java b/java/com/google/gerrit/pgm/init/GroupsOnInit.java
index 95572b6..2f12abb 100644
--- a/java/com/google/gerrit/pgm/init/GroupsOnInit.java
+++ b/java/com/google/gerrit/pgm/init/GroupsOnInit.java
@@ -34,8 +34,8 @@
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
 import com.google.gerrit.server.group.db.AuditLogFormatter;
 import com.google.gerrit.server.group.db.GroupConfig;
+import com.google.gerrit.server.group.db.GroupDelta;
 import com.google.gerrit.server.group.db.GroupNameNotes;
-import com.google.gerrit.server.group.db.InternalGroupUpdate;
 import com.google.inject.Inject;
 import java.io.File;
 import java.io.IOException;
@@ -139,9 +139,9 @@
     InternalGroup group =
         groupConfig.getLoadedGroup().orElseThrow(() -> new NoSuchGroupException(groupUuid));
 
-    InternalGroupUpdate groupUpdate = getMemberAdditionUpdate(account);
+    GroupDelta groupDelta = getMemberAdditionDelta(account);
     AuditLogFormatter auditLogFormatter = getAuditLogFormatter(account);
-    groupConfig.setGroupUpdate(groupUpdate, auditLogFormatter);
+    groupConfig.setGroupDelta(groupDelta, auditLogFormatter);
 
     commit(repository, groupConfig, group.getCreatedOn());
   }
@@ -153,8 +153,8 @@
     return RepositoryCache.FileKey.resolve(basePath.resolve(allUsers.get()).toFile(), FS.DETECTED);
   }
 
-  private static InternalGroupUpdate getMemberAdditionUpdate(Account account) {
-    return InternalGroupUpdate.builder()
+  private static GroupDelta getMemberAdditionDelta(Account account) {
+    return GroupDelta.builder()
         .setMemberModification(members -> Sets.union(members, ImmutableSet.of(account.id())))
         .build();
   }
diff --git a/java/com/google/gerrit/pgm/util/BatchProgramModule.java b/java/com/google/gerrit/pgm/util/BatchProgramModule.java
index 508f96c..30de2f5 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;
@@ -69,6 +70,7 @@
 import com.google.gerrit.server.mail.send.ReplacePatchSetSender;
 import com.google.gerrit.server.notedb.NoteDbModule;
 import com.google.gerrit.server.patch.DiffExecutorModule;
+import com.google.gerrit.server.patch.DiffOperationsImpl;
 import com.google.gerrit.server.patch.PatchListCacheImpl;
 import com.google.gerrit.server.permissions.DefaultPermissionBackendModule;
 import com.google.gerrit.server.permissions.SectionSortCache;
@@ -78,6 +80,7 @@
 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.restapi.group.GroupModule;
@@ -113,6 +116,7 @@
     modules.add(BatchUpdate.module());
     modules.add(PatchListCacheImpl.module());
     modules.add(new DefaultUrlFormatter.Module());
+    modules.add(DiffOperationsImpl.module());
 
     // There is the concept of LifecycleModule, in Gerrit's own extension to Guice, which has these:
     //  listener().to(SomeClassImplementingLifecycleListener.class);
@@ -169,6 +173,7 @@
     modules.add(new GroupModule());
     modules.add(new NoteDbModule());
     modules.add(AccountCacheImpl.module());
+    modules.add(ApprovalCacheImpl.module());
     modules.add(DefaultPreferencesCacheImpl.module());
     modules.add(GroupCacheImpl.module());
     modules.add(GroupIncludeCacheImpl.module());
@@ -179,6 +184,7 @@
     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);
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/ChangeMessagesUtil.java b/java/com/google/gerrit/server/ChangeMessagesUtil.java
index 32edadb..26424d2 100644
--- a/java/com/google/gerrit/server/ChangeMessagesUtil.java
+++ b/java/com/google/gerrit/server/ChangeMessagesUtil.java
@@ -14,24 +14,26 @@
 
 package com.google.gerrit.server;
 
-import static com.google.common.base.Preconditions.checkState;
-import static java.util.Objects.requireNonNull;
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
 
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.extensions.common.ChangeMessageInfo;
+import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AccountLoader;
+import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.update.ChangeContext;
+import com.google.inject.Inject;
 import com.google.inject.Singleton;
-import java.sql.Timestamp;
 import java.util.List;
-import java.util.Objects;
+import java.util.Optional;
+import java.util.regex.Matcher;
 
-/** Utility functions to manipulate ChangeMessages. */
+/** Utility functions to manipulate {@link ChangeMessage}. */
 @Singleton
 public class ChangeMessagesUtil {
   public static final String AUTOGENERATED_TAG_PREFIX = "autogenerated:";
@@ -68,39 +70,75 @@
   public static final String TAG_UPLOADED_WIP_PATCH_SET =
       AUTOGENERATED_BY_GERRIT_TAG_PREFIX + "newWipPatchSet";
 
-  public static ChangeMessage newMessage(ChangeContext ctx, String body, @Nullable String tag) {
-    return newMessage(ctx.getChange().currentPatchSetId(), ctx.getUser(), ctx.getWhen(), body, tag);
+  private final AccountCache accountCache;
+
+  @Inject
+  ChangeMessagesUtil(AccountCache accountCache) {
+    this.accountCache = accountCache;
   }
 
-  public static ChangeMessage newMessage(
-      PatchSet.Id psId, CurrentUser user, Timestamp when, String body, @Nullable String tag) {
-    requireNonNull(psId);
-    Account.Id accountId = user.isInternalUser() ? null : user.getAccountId();
-    ChangeMessage m =
-        new ChangeMessage(
-            ChangeMessage.key(psId.changeId(), ChangeUtil.messageUuid()), accountId, when, psId);
-    m.setMessage(body);
-    m.setTag(tag);
-    user.updateRealAccountId(m::setRealAuthor);
-    return m;
+  /**
+   * Sets {@code messageTemplate} and {@code tag}, that should be applied by the {@code update}.
+   *
+   * <p>The {@code messageTemplate} is persisted in storage and should not contain user identifiable
+   * information. See {@link ChangeMessage}.
+   *
+   * @param update update that sets {@code messageTemplate}.
+   * @param messageTemplate message in template form, that should be applied by the update.
+   * @param tag tag that should be applied by the update.
+   * @return message built from {@code messageTemplate}. Templates are replaced, so it might contain
+   *     user identifiable information.
+   */
+  public String setChangeMessage(
+      ChangeUpdate update, String messageTemplate, @Nullable String tag) {
+    update.setChangeMessage(messageTemplate);
+    update.setTag(tag);
+    return replaceTemplates(messageTemplate);
+  }
+
+  /** See {@link #setChangeMessage(ChangeUpdate, String, String)}. */
+  public String setChangeMessage(ChangeContext ctx, String messageTemplate, @Nullable String tag) {
+    return setChangeMessage(
+        ctx.getUpdate(ctx.getChange().currentPatchSetId()), messageTemplate, tag);
   }
 
   public static String uploadedPatchSetTag(boolean workInProgress) {
     return workInProgress ? TAG_UPLOADED_WIP_PATCH_SET : TAG_UPLOADED_PATCH_SET;
   }
 
-  public List<ChangeMessage> byChange(ChangeNotes notes) {
-    return notes.load().getChangeMessages();
+  public static String getAccountTemplate(Account.Id accountId) {
+    return String.format(ChangeMessage.ACCOUNT_TEMPLATE, accountId.get());
   }
 
-  public void addChangeMessage(ChangeUpdate update, ChangeMessage changeMessage) {
-    checkState(
-        Objects.equals(changeMessage.getAuthor(), update.getNullableAccountId()),
-        "cannot store change message by %s in update by %s",
-        changeMessage.getAuthor(),
-        update.getNullableAccountId());
-    update.setChangeMessage(changeMessage.getMessage());
-    update.setTag(changeMessage.getTag());
+  /** Builds user-readable message from {@code messageTemplate}. See {@link ChangeMessage}. */
+  public String replaceTemplates(String messageTemplate) {
+    Matcher matcher = ChangeMessage.ACCOUNT_TEMPLATE_PATTERN.matcher(messageTemplate);
+    StringBuffer out = new StringBuffer();
+    while (matcher.find()) {
+      String accountId = matcher.group(1);
+      String unrecognizedAccount = "Unrecognized Gerrit Account " + accountId;
+      Optional<Account.Id> parsedAccountId = Account.Id.tryParse(accountId);
+      if (!parsedAccountId.isPresent()) {
+        matcher.appendReplacement(out, unrecognizedAccount);
+        continue;
+      }
+      Optional<AccountState> account = accountCache.get(parsedAccountId.get());
+      if (!account.isPresent()) {
+        matcher.appendReplacement(out, unrecognizedAccount);
+        continue;
+      }
+      matcher.appendReplacement(out, account.get().account().getNameEmail(unrecognizedAccount));
+    }
+    matcher.appendTail(out);
+    return out.toString();
+  }
+
+  /**
+   * Returns {@link ChangeMessage}s from {@link ChangeNotes}, loads {@link ChangeNotes} from data
+   * storage (cache or NoteDB), if it was not loaded yet.
+   */
+  public List<ChangeMessage> byChange(ChangeNotes notes) {
+    return notes.load().getChangeMessages();
   }
 
   /**
@@ -144,6 +182,23 @@
     if (realAuthor != null) {
       cmi.realAuthor = accountLoader.get(realAuthor);
     }
+    cmi.accountsInMessage =
+        message.getAccountsInMessage().stream().map(accountLoader::get).collect(toImmutableSet());
     return cmi;
   }
+
+  /**
+   * {@link ChangeMessage} is served in template form to {@link
+   * com.google.gerrit.extensions.api.changes.ChangeApi}. Serve message with replaced templates to
+   * the legacy {@link com.google.gerrit.extensions.api.changes.ChangeMessageApi} endpoints.
+   * TODO(mariasavtchouk): remove this, after {@link
+   * com.google.gerrit.extensions.api.changes.ChangeMessageApi} is deprecated (gate with
+   * experiment).
+   */
+  public ChangeMessageInfo createChangeMessageInfoWithReplacedTemplates(
+      ChangeMessage message, AccountLoader accountLoader) {
+    ChangeMessageInfo changeMessageInfo = createChangeMessageInfo(message, accountLoader);
+    changeMessageInfo.message = replaceTemplates(message.getMessage());
+    return changeMessageInfo;
+  }
 }
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/PublishCommentsOp.java b/java/com/google/gerrit/server/PublishCommentsOp.java
index 358ce92..84afe8c 100644
--- a/java/com/google/gerrit/server/PublishCommentsOp.java
+++ b/java/com/google/gerrit/server/PublishCommentsOp.java
@@ -14,8 +14,8 @@
 
 package com.google.gerrit.server;
 
+import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableMap;
-import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
@@ -31,7 +31,7 @@
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.CommentsRejectedException;
-import com.google.gerrit.server.update.Context;
+import com.google.gerrit.server.update.PostUpdateContext;
 import com.google.gerrit.server.update.RepoView;
 import com.google.gerrit.server.util.LabelVote;
 import com.google.inject.Inject;
@@ -49,7 +49,6 @@
 public class PublishCommentsOp implements BatchUpdateOp {
   private final PatchSetUtil psUtil;
   private final ChangeNotes.Factory changeNotesFactory;
-  private final ChangeMessagesUtil cmUtil;
   private final CommentAdded commentAdded;
   private final CommentsUtil commentsUtil;
   private final EmailReviewComments.Factory email;
@@ -57,9 +56,10 @@
   private final Project.NameKey projectNameKey;
   private final PatchSet.Id psId;
   private final PublishCommentUtil publishCommentUtil;
+  private final ChangeMessagesUtil changeMessagesUtil;
 
   private List<HumanComment> comments = new ArrayList<>();
-  private ChangeMessage message;
+  private String mailMessage;
   private IdentifiedUser user;
 
   public interface Factory {
@@ -69,15 +69,14 @@
   @Inject
   public PublishCommentsOp(
       ChangeNotes.Factory changeNotesFactory,
-      ChangeMessagesUtil cmUtil,
       CommentAdded commentAdded,
       CommentsUtil commentsUtil,
       EmailReviewComments.Factory email,
       PatchSetUtil psUtil,
       PublishCommentUtil publishCommentUtil,
+      ChangeMessagesUtil changeMessagesUtil,
       @Assisted PatchSet.Id psId,
       @Assisted Project.NameKey projectNameKey) {
-    this.cmUtil = cmUtil;
     this.changeNotesFactory = changeNotesFactory;
     this.commentAdded = commentAdded;
     this.commentsUtil = commentsUtil;
@@ -86,6 +85,7 @@
     this.publishCommentUtil = publishCommentUtil;
     this.psUtil = psUtil;
     this.projectNameKey = projectNameKey;
+    this.changeMessagesUtil = changeMessagesUtil;
   }
 
   @Override
@@ -104,12 +104,12 @@
     // We do it this way so that the execution results in 2 different commits in NoteDb
     ChangeUpdate changeUpdate = ctx.getDistinctUpdate(psId);
     publishCommentUtil.publish(ctx, changeUpdate, comments, null);
-    return insertMessage(ctx, changeUpdate);
+    return insertMessage(changeUpdate);
   }
 
   @Override
-  public void postUpdate(Context ctx) {
-    if (message == null || comments.isEmpty()) {
+  public void postUpdate(PostUpdateContext ctx) {
+    if (Strings.isNullOrEmpty(mailMessage) || comments.isEmpty()) {
       return;
     }
     ChangeNotes changeNotes = changeNotesFactory.createChecked(projectNameKey, psId.changeId());
@@ -124,20 +124,30 @@
             String.format("Repository %s not found", ctx.getProject().get()), ex);
       }
       email
-          .create(notify, changeNotes, ps, user, message, comments, null, labelDelta, repoView)
+          .create(
+              notify,
+              changeNotes,
+              ps,
+              user,
+              mailMessage,
+              ctx.getWhen(),
+              comments,
+              null,
+              labelDelta,
+              repoView)
           .sendAsync();
     }
     commentAdded.fire(
-        changeNotes.getChange(),
+        ctx.getChangeData(changeNotes),
         ps,
         ctx.getAccount(),
-        message.getMessage(),
+        mailMessage,
         ImmutableMap.of(),
         ImmutableMap.of(),
         ctx.getWhen());
   }
 
-  private boolean insertMessage(ChangeContext ctx, ChangeUpdate changeUpdate) {
+  private boolean insertMessage(ChangeUpdate changeUpdate) {
     StringBuilder buf = new StringBuilder();
     if (comments.size() == 1) {
       buf.append("\n\n(1 comment)");
@@ -147,10 +157,9 @@
     if (buf.length() == 0) {
       return false;
     }
-    message =
-        ChangeMessagesUtil.newMessage(
-            psId, user, ctx.getWhen(), "Patch Set " + psId.get() + ":" + buf, null);
-    cmUtil.addChangeMessage(changeUpdate, message);
+    mailMessage =
+        changeMessagesUtil.setChangeMessage(
+            changeUpdate, "Patch Set " + psId.get() + ":" + buf, null);
     return true;
   }
 }
diff --git a/java/com/google/gerrit/server/WebLinks.java b/java/com/google/gerrit/server/WebLinks.java
index e66e7f5..53a7661 100644
--- a/java/com/google/gerrit/server/WebLinks.java
+++ b/java/com/google/gerrit/server/WebLinks.java
@@ -27,11 +27,13 @@
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.extensions.webui.BranchWebLink;
 import com.google.gerrit.extensions.webui.DiffWebLink;
+import com.google.gerrit.extensions.webui.EditWebLink;
 import com.google.gerrit.extensions.webui.FileHistoryWebLink;
 import com.google.gerrit.extensions.webui.FileWebLink;
 import com.google.gerrit.extensions.webui.ParentWebLink;
 import com.google.gerrit.extensions.webui.PatchSetWebLink;
 import com.google.gerrit.extensions.webui.ProjectWebLink;
+import com.google.gerrit.extensions.webui.ResolveConflictsWebLink;
 import com.google.gerrit.extensions.webui.TagWebLink;
 import com.google.gerrit.extensions.webui.WebLink;
 import com.google.inject.Inject;
@@ -55,7 +57,9 @@
       };
 
   private final DynamicSet<PatchSetWebLink> patchSetLinks;
+  private final DynamicSet<ResolveConflictsWebLink> resolveConflictsLinks;
   private final DynamicSet<ParentWebLink> parentLinks;
+  private final DynamicSet<EditWebLink> editLinks;
   private final DynamicSet<FileWebLink> fileLinks;
   private final DynamicSet<FileHistoryWebLink> fileHistoryLinks;
   private final DynamicSet<DiffWebLink> diffLinks;
@@ -66,7 +70,9 @@
   @Inject
   public WebLinks(
       DynamicSet<PatchSetWebLink> patchSetLinks,
+      DynamicSet<ResolveConflictsWebLink> resolveConflictsLinks,
       DynamicSet<ParentWebLink> parentLinks,
+      DynamicSet<EditWebLink> editLinks,
       DynamicSet<FileWebLink> fileLinks,
       DynamicSet<FileHistoryWebLink> fileLogLinks,
       DynamicSet<DiffWebLink> diffLinks,
@@ -74,7 +80,9 @@
       DynamicSet<BranchWebLink> branchLinks,
       DynamicSet<TagWebLink> tagLinks) {
     this.patchSetLinks = patchSetLinks;
+    this.resolveConflictsLinks = resolveConflictsLinks;
     this.parentLinks = parentLinks;
+    this.editLinks = editLinks;
     this.fileLinks = fileLinks;
     this.fileHistoryLinks = fileLogLinks;
     this.diffLinks = diffLinks;
@@ -99,6 +107,21 @@
 
   /**
    * @param project Project name.
+   * @param commit SHA1 of commit.
+   * @param commitMessage the commit message of the commit.
+   * @param branchName branch of the commit.
+   * @return Links for resolving comflicts.
+   */
+  public ImmutableList<WebLinkInfo> getResolveConflictsLinks(
+      Project.NameKey project, String commit, String commitMessage, String branchName) {
+    return filterLinks(
+        resolveConflictsLinks,
+        webLink ->
+            webLink.getResolveConflictsWebLink(project.get(), commit, commitMessage, branchName));
+  }
+
+  /**
+   * @param project Project name.
    * @param revision SHA1 of the parent revision.
    * @param commitMessage the commit message of the parent revision.
    * @param branchName branch of the revision (and parent revision).
@@ -115,6 +138,18 @@
    * @param project Project name.
    * @param revision SHA1 of revision.
    * @param file File name.
+   * @return Links for editing.
+   */
+  public ImmutableList<WebLinkInfo> getEditLinks(String project, String revision, String file) {
+    return Patch.isMagic(file)
+        ? ImmutableList.of()
+        : filterLinks(editLinks, webLink -> webLink.getEditWebLink(project, revision, file));
+  }
+
+  /**
+   * @param project Project name.
+   * @param revision SHA1 of revision.
+   * @param file File name.
    * @return Links for files.
    */
   public ImmutableList<WebLinkInfo> getFileLinks(String project, String revision, String file) {
diff --git a/java/com/google/gerrit/server/account/AccountConfig.java b/java/com/google/gerrit/server/account/AccountConfig.java
index e95bc1c..45f1f35 100644
--- a/java/com/google/gerrit/server/account/AccountConfig.java
+++ b/java/com/google/gerrit/server/account/AccountConfig.java
@@ -84,7 +84,7 @@
   private Optional<ObjectId> externalIdsRev;
   private ProjectWatches projectWatches;
   private StoredPreferences preferences;
-  private Optional<InternalAccountUpdate> accountUpdate = Optional.empty();
+  private Optional<AccountDelta> accountDelta = Optional.empty();
   private List<ValidationError> validationErrors;
 
   public AccountConfig(Account.Id accountId, AllUsersName allUsersName, Repository allUsersRepo) {
@@ -158,9 +158,9 @@
     this.loadedAccountProperties =
         Optional.of(
             new AccountProperties(account.id(), account.registeredOn(), new Config(), null));
-    this.accountUpdate =
+    this.accountDelta =
         Optional.of(
-            InternalAccountUpdate.builder()
+            AccountDelta.builder()
                 .setActive(account.isActive())
                 .setFullName(account.fullName())
                 .setDisplayName(account.displayName())
@@ -196,8 +196,8 @@
     return loadedAccountProperties.map(AccountProperties::getAccount).get();
   }
 
-  public AccountConfig setAccountUpdate(InternalAccountUpdate accountUpdate) {
-    this.accountUpdate = Optional.of(accountUpdate);
+  public AccountConfig setAccountDelta(AccountDelta accountDelta) {
+    this.accountDelta = Optional.of(accountDelta);
     return this;
   }
 
@@ -283,45 +283,44 @@
     saveProjectWatches();
     savePreferences();
 
-    accountUpdate = Optional.empty();
+    accountDelta = Optional.empty();
 
     return true;
   }
 
   private void saveAccount() throws IOException {
-    if (accountUpdate.isPresent()) {
+    if (accountDelta.isPresent()) {
       saveConfig(
-          AccountProperties.ACCOUNT_CONFIG,
-          loadedAccountProperties.get().save(accountUpdate.get()));
+          AccountProperties.ACCOUNT_CONFIG, loadedAccountProperties.get().save(accountDelta.get()));
     }
   }
 
   private void saveProjectWatches() throws IOException {
-    if (accountUpdate.isPresent()
-        && (!accountUpdate.get().getDeletedProjectWatches().isEmpty()
-            || !accountUpdate.get().getUpdatedProjectWatches().isEmpty())) {
+    if (accountDelta.isPresent()
+        && (!accountDelta.get().getDeletedProjectWatches().isEmpty()
+            || !accountDelta.get().getUpdatedProjectWatches().isEmpty())) {
       Map<ProjectWatchKey, Set<NotifyType>> newProjectWatches =
           new HashMap<>(projectWatches.getProjectWatches());
-      accountUpdate.get().getDeletedProjectWatches().forEach(newProjectWatches::remove);
-      accountUpdate.get().getUpdatedProjectWatches().forEach(newProjectWatches::put);
+      accountDelta.get().getDeletedProjectWatches().forEach(newProjectWatches::remove);
+      accountDelta.get().getUpdatedProjectWatches().forEach(newProjectWatches::put);
       saveConfig(ProjectWatches.WATCH_CONFIG, projectWatches.save(newProjectWatches));
     }
   }
 
   private void savePreferences() throws IOException, ConfigInvalidException {
-    if (!accountUpdate.isPresent()
-        || (!accountUpdate.get().getGeneralPreferences().isPresent()
-            && !accountUpdate.get().getDiffPreferences().isPresent()
-            && !accountUpdate.get().getEditPreferences().isPresent())) {
+    if (!accountDelta.isPresent()
+        || (!accountDelta.get().getGeneralPreferences().isPresent()
+            && !accountDelta.get().getDiffPreferences().isPresent()
+            && !accountDelta.get().getEditPreferences().isPresent())) {
       return;
     }
 
     saveConfig(
         StoredPreferences.PREFERENCES_CONFIG,
         preferences.saveGeneralPreferences(
-            accountUpdate.get().getGeneralPreferences(),
-            accountUpdate.get().getDiffPreferences(),
-            accountUpdate.get().getEditPreferences()));
+            accountDelta.get().getGeneralPreferences(),
+            accountDelta.get().getDiffPreferences(),
+            accountDelta.get().getEditPreferences()));
   }
 
   private void checkLoaded() {
diff --git a/java/com/google/gerrit/server/account/InternalAccountUpdate.java b/java/com/google/gerrit/server/account/AccountDelta.java
similarity index 96%
rename from java/com/google/gerrit/server/account/InternalAccountUpdate.java
rename to java/com/google/gerrit/server/account/AccountDelta.java
index 4f9202f..fac3233 100644
--- a/java/com/google/gerrit/server/account/InternalAccountUpdate.java
+++ b/java/com/google/gerrit/server/account/AccountDelta.java
@@ -33,23 +33,23 @@
 import java.util.Set;
 
 /**
- * Class to prepare updates to an account.
+ * Data holder for updates to be applied to an account.
  *
- * <p>Account updates are done through {@link AccountsUpdate}. This class should be used to tell
- * {@link AccountsUpdate} how an account should be modified.
+ * <p>Instances of this type are passed to {@link AccountsUpdate}, which modifies the account
+ * accordingly.
  *
- * <p>This class allows to prepare updates of account properties, external IDs, preferences
+ * <p>Updates can be applied to account properties (name, email etc.), external IDs, preferences
  * (general, diff and edit preferences) and project watches. The account ID and the registration
  * date cannot be updated.
  *
- * <p>For the account properties there are getters in this class and the setters in the {@link
- * Builder} that correspond to the fields in {@link Account}.
+ * <p>For the account properties there are getters in this class and setters in the {@link Builder}
+ * that correspond to the fields in {@link Account}.
  */
 @AutoValue
-public abstract class InternalAccountUpdate {
+public abstract class AccountDelta {
   public static Builder builder() {
     return new Builder.WrapperThatConvertsNullStringArgsToEmptyStrings(
-        new AutoValue_InternalAccountUpdate.Builder());
+        new AutoValue_AccountDelta.Builder());
   }
 
   /**
@@ -162,13 +162,13 @@
   public abstract Optional<EditPreferencesInfo> getEditPreferences();
 
   /**
-   * Class to build an account update.
+   * Class to build an {@link AccountDelta}.
    *
    * <p>Account data is only updated if the corresponding setter is invoked. If a setter is not
    * invoked the corresponding data stays unchanged. To unset string values the setter can be
    * invoked with either {@code null} or an empty string ({@code null} is converted to an empty
    * string by using the {@link WrapperThatConvertsNullStringArgsToEmptyStrings} wrapper, see {@link
-   * InternalAccountUpdate#builder()}).
+   * AccountDelta#builder()}).
    */
   @AutoValue.Builder
   public abstract static class Builder {
@@ -447,12 +447,8 @@
      */
     public abstract Builder setEditPreferences(EditPreferencesInfo editPreferences);
 
-    /**
-     * Builds the account update.
-     *
-     * @return the account update
-     */
-    public abstract InternalAccountUpdate build();
+    /** Builds the instance. */
+    public abstract AccountDelta build();
 
     /**
      * Wrapper for {@link Builder} that converts {@code null} string arguments to empty strings for
@@ -525,7 +521,7 @@
       }
 
       @Override
-      public InternalAccountUpdate build() {
+      public AccountDelta build() {
         return delegate.build();
       }
 
diff --git a/java/com/google/gerrit/server/account/AccountManager.java b/java/com/google/gerrit/server/account/AccountManager.java
index 47c6efb..2152e1e 100644
--- a/java/com/google/gerrit/server/account/AccountManager.java
+++ b/java/com/google/gerrit/server/account/AccountManager.java
@@ -34,14 +34,13 @@
 import com.google.gerrit.extensions.client.AccountFieldName;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.ServerInitiated;
-import com.google.gerrit.server.account.AccountsUpdate.AccountUpdater;
 import com.google.gerrit.server.account.externalids.DuplicateExternalIdKeyException;
 import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.account.externalids.ExternalIds;
 import com.google.gerrit.server.auth.NoSuchUserException;
 import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.group.db.GroupDelta;
 import com.google.gerrit.server.group.db.GroupsUpdate;
-import com.google.gerrit.server.group.db.InternalGroupUpdate;
 import com.google.gerrit.server.notedb.Sequences;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.ssh.SshKeyCache;
@@ -221,7 +220,7 @@
   private void update(AuthRequest who, ExternalId extId)
       throws IOException, ConfigInvalidException, AccountException {
     IdentifiedUser user = userFactory.create(extId.accountId());
-    List<Consumer<InternalAccountUpdate.Builder>> accountUpdates = new ArrayList<>();
+    List<Consumer<AccountDelta.Builder>> accountUpdates = new ArrayList<>();
 
     // If the email address was modified by the authentication provider,
     // update our records to match the changed email.
@@ -262,7 +261,7 @@
           .update(
               "Update Account on Login",
               user.getAccountId(),
-              AccountUpdater.joinConsumers(accountUpdates))
+              AccountsUpdate.joinConsumers(accountUpdates))
           .orElseThrow(
               () -> new StorageException("Account " + user.getAccountId() + " has been deleted"));
     }
@@ -382,13 +381,13 @@
       throws IOException, ConfigInvalidException, AccountException {
     // The user initiated this request by logging in. -> Attribute all modifications to that user.
     GroupsUpdate groupsUpdate = groupsUpdateFactory.create(user);
-    InternalGroupUpdate groupUpdate =
-        InternalGroupUpdate.builder()
+    GroupDelta groupDelta =
+        GroupDelta.builder()
             .setMemberModification(
                 memberIds -> Sets.union(memberIds, ImmutableSet.of(user.getAccountId())))
             .build();
     try {
-      groupsUpdate.updateGroup(groupUuid, groupUpdate);
+      groupsUpdate.updateGroup(groupUuid, groupDelta);
     } catch (NoSuchGroupException e) {
       throw new AccountException(String.format("Group %s not found", groupUuid), e);
     }
diff --git a/java/com/google/gerrit/server/account/AccountProperties.java b/java/com/google/gerrit/server/account/AccountProperties.java
index 5ae5567..9b7ca81 100644
--- a/java/com/google/gerrit/server/account/AccountProperties.java
+++ b/java/com/google/gerrit/server/account/AccountProperties.java
@@ -103,21 +103,19 @@
     account = accountBuilder.build();
   }
 
-  Config save(InternalAccountUpdate accountUpdate) {
-    writeToAccountConfig(accountUpdate, accountConfig);
+  Config save(AccountDelta accountDelta) {
+    writeToAccountConfig(accountDelta, accountConfig);
     return accountConfig;
   }
 
-  public static void writeToAccountConfig(InternalAccountUpdate accountUpdate, Config cfg) {
-    accountUpdate.getActive().ifPresent(active -> setActive(cfg, active));
-    accountUpdate.getFullName().ifPresent(fullName -> set(cfg, KEY_FULL_NAME, fullName));
-    accountUpdate
-        .getDisplayName()
-        .ifPresent(displayName -> set(cfg, KEY_DISPLAY_NAME, displayName));
-    accountUpdate
+  public static void writeToAccountConfig(AccountDelta accountDelta, Config cfg) {
+    accountDelta.getActive().ifPresent(active -> setActive(cfg, active));
+    accountDelta.getFullName().ifPresent(fullName -> set(cfg, KEY_FULL_NAME, fullName));
+    accountDelta.getDisplayName().ifPresent(displayName -> set(cfg, KEY_DISPLAY_NAME, displayName));
+    accountDelta
         .getPreferredEmail()
         .ifPresent(preferredEmail -> set(cfg, KEY_PREFERRED_EMAIL, preferredEmail));
-    accountUpdate.getStatus().ifPresent(status -> set(cfg, KEY_STATUS, status));
+    accountDelta.getStatus().ifPresent(status -> set(cfg, KEY_STATUS, status));
   }
 
   /**
diff --git a/java/com/google/gerrit/server/account/AccountResolver.java b/java/com/google/gerrit/server/account/AccountResolver.java
index 2665b9a..75a2b38 100644
--- a/java/com/google/gerrit/server/account/AccountResolver.java
+++ b/java/com/google/gerrit/server/account/AccountResolver.java
@@ -531,6 +531,19 @@
     return searchImpl(input, searchers, visibilitySupplierCanSee(), accountActivityPredicate);
   }
 
+  /**
+   * 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.
+   */
+  public Result resolveIncludeInactive(String input) throws ConfigInvalidException, IOException {
+    return searchImpl(input, searchers, visibilitySupplierCanSee(), all());
+  }
+
   public Result resolveIgnoreVisibility(String input) throws ConfigInvalidException, IOException {
     return searchImpl(input, searchers, visibilitySupplierAll(), accountActivityPredicate());
   }
diff --git a/java/com/google/gerrit/server/account/AccountsUpdate.java b/java/com/google/gerrit/server/account/AccountsUpdate.java
index 1b3aa96..5a74047 100644
--- a/java/com/google/gerrit/server/account/AccountsUpdate.java
+++ b/java/com/google/gerrit/server/account/AccountsUpdate.java
@@ -14,16 +14,17 @@
 
 package com.google.gerrit.server.account;
 
+import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.common.base.Preconditions.checkState;
 import static java.util.Objects.requireNonNull;
+import static java.util.stream.Collectors.toList;
 import static java.util.stream.Collectors.toSet;
 
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Strings;
 import com.google.common.base.Throwables;
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Iterables;
-import com.google.common.collect.Lists;
-import com.google.common.util.concurrent.Runnables;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.exceptions.DuplicateKeyException;
 import com.google.gerrit.exceptions.StorageException;
@@ -49,6 +50,7 @@
 import com.google.inject.assistedinject.AssistedInject;
 import java.io.IOException;
 import java.sql.Timestamp;
+import java.util.ArrayList;
 import java.util.List;
 import java.util.Objects;
 import java.util.Optional;
@@ -63,18 +65,17 @@
 /**
  * Creates and updates accounts.
  *
- * <p>This class should be used for all account updates. It supports updating account properties,
- * external IDs, preferences (general, diff and edit preferences) and project watches.
+ * <p>This class should be used for all account updates. See {@link AccountDelta} for what can be
+ * updated.
  *
- * <p>Updates to one account are always atomic. Batch updating several accounts within one
- * transaction is not supported.
+ * <p>Batch updates of multiple different accounts can be performed atomically, see {@link
+ * #updateBatch(List)}. Batch creation is not supported.
  *
  * <p>For any account update the caller must provide a commit message, the account ID and an {@link
- * AccountUpdater}. The account updater allows to read the current {@link AccountState} and to
- * prepare updates to the account by calling setters on the provided {@link
- * InternalAccountUpdate.Builder}. If the current account state is of no interest the caller may
- * also provide a {@link Consumer} for {@link InternalAccountUpdate.Builder} instead of the account
- * updater.
+ * ConfigureDeltaFromState}. The account updater reads the current {@link AccountState} and prepares
+ * updates to the account by calling setters on the provided {@link AccountDelta.Builder}. If the
+ * current account state is of no interest the caller may also provide a {@link Consumer} for {@link
+ * AccountDelta.Builder} instead of the account updater.
  *
  * <p>The provided commit message is used for the update of the user branch. Using a precise and
  * unique commit message allows to identify the code from which an update was made when looking at a
@@ -148,37 +149,35 @@
   }
 
   /**
-   * Updater for an account.
+   * Account updates are commonly performed by evaluating the current account state and creating a
+   * delta to be applied to it in a later step. This is done by implementing this interface.
    *
-   * <p>Allows to read the current state of an account and to prepare updates to it.
+   * <p>If the current account state is not needed, use a {@link Consumer} of {@link
+   * AccountDelta.Builder} instead.
    */
   @FunctionalInterface
-  public interface AccountUpdater {
+  public interface ConfigureDeltaFromState {
     /**
-     * Prepare updates to an account.
+     * Receives the current {@link AccountState} (which is immutable) and configures an {@link
+     * AccountDelta.Builder} with changes to the account.
      *
-     * <p>Use the provided account only to read the current state of the account. Don't do updates
-     * to the account. For updates use the provided account update builder.
-     *
-     * @param accountState the account that is being updated
-     * @param update account update builder
+     * @param accountState the state of the account that is being updated
+     * @param delta the changes to be applied
      */
-    void update(AccountState accountState, InternalAccountUpdate.Builder update) throws IOException;
+    void configure(AccountState accountState, AccountDelta.Builder delta) throws IOException;
+  }
 
-    static AccountUpdater join(List<AccountUpdater> updaters) {
-      return (accountState, update) -> {
-        for (AccountUpdater updater : updaters) {
-          updater.update(accountState, update);
-        }
-      };
-    }
+  /** Data holder for the set of arguments required to update an account. Used for batch updates. */
+  public static class UpdateArguments {
+    private final String message;
+    private final Account.Id accountId;
+    private final ConfigureDeltaFromState configureDeltaFromState;
 
-    static AccountUpdater joinConsumers(List<Consumer<InternalAccountUpdate.Builder>> consumers) {
-      return join(Lists.transform(consumers, AccountUpdater::fromConsumer));
-    }
-
-    static AccountUpdater fromConsumer(Consumer<InternalAccountUpdate.Builder> consumer) {
-      return (a, u) -> consumer.accept(u);
+    public UpdateArguments(
+        String message, Account.Id accountId, ConfigureDeltaFromState configureDeltaFromState) {
+      this.message = message;
+      this.accountId = accountId;
+      this.configureDeltaFromState = configureDeltaFromState;
     }
   }
 
@@ -193,13 +192,19 @@
   private final PersonIdent committerIdent;
   private final PersonIdent authorIdent;
 
-  // Invoked after reading the account config.
+  /** Invoked after reading the account config. */
   private final Runnable afterReadRevision;
 
-  // Invoked after updating the account but before committing the changes.
+  /** Invoked after updating the account but before committing the changes. */
   private final Runnable beforeCommit;
 
+  /** Single instance that accumulates updates from the batch. */
+  private ExternalIdNotes externalIdNotes;
+
+  private static final Runnable DO_NOTHING = () -> {};
+
   @AssistedInject
+  @SuppressWarnings("BindingAnnotationWithoutInject")
   AccountsUpdate(
       GitRepositoryManager repoManager,
       GitReferenceUpdated gitRefUpdated,
@@ -220,11 +225,12 @@
         extIdNotesLoader,
         serverIdent,
         createPersonIdent(serverIdent, Optional.empty()),
-        Runnables.doNothing(),
-        Runnables.doNothing());
+        DO_NOTHING,
+        DO_NOTHING);
   }
 
   @AssistedInject
+  @SuppressWarnings("BindingAnnotationWithoutInject")
   AccountsUpdate(
       GitRepositoryManager repoManager,
       GitReferenceUpdated gitRefUpdated,
@@ -246,8 +252,8 @@
         extIdNotesLoader,
         serverIdent,
         createPersonIdent(serverIdent, Optional.of(currentUser)),
-        Runnables.doNothing(),
-        Runnables.doNothing());
+        DO_NOTHING,
+        DO_NOTHING);
   }
 
   @VisibleForTesting
@@ -279,29 +285,32 @@
     this.beforeCommit = requireNonNull(beforeCommit, "beforeCommit");
   }
 
+  /** Returns an instance that runs all specified consumers. */
+  public static ConfigureDeltaFromState joinConsumers(
+      List<Consumer<AccountDelta.Builder>> consumers) {
+    return (accountStateIgnored, update) -> consumers.forEach(c -> c.accept(update));
+  }
+
+  private static ConfigureDeltaFromState fromConsumer(Consumer<AccountDelta.Builder> consumer) {
+    return (a, u) -> consumer.accept(u);
+  }
+
   private static PersonIdent createPersonIdent(
       PersonIdent serverIdent, Optional<IdentifiedUser> user) {
-    if (!user.isPresent()) {
-      return serverIdent;
-    }
-    return user.get().newCommitterIdent(serverIdent.getWhen(), serverIdent.getTimeZone());
+    return user.isPresent()
+        ? user.get().newCommitterIdent(serverIdent.getWhen(), serverIdent.getTimeZone())
+        : serverIdent;
   }
 
   /**
-   * Inserts a new account.
-   *
-   * @param message commit message for the account creation, must not be {@code null or empty}
-   * @param accountId ID of the new account
-   * @param init consumer to populate the new account
-   * @return the newly created account
-   * @throws DuplicateKeyException if the account already exists
-   * @throws IOException if creating the user branch fails due to an IO error
-   * @throws ConfigInvalidException if any of the account fields has an invalid value
+   * Like {@link #insert(String, Account.Id, ConfigureDeltaFromState)}, but using a {@link Consumer}
+   * instead, i.e. the update does not depend on the current account state (which, for insertion,
+   * would only contain the account ID).
    */
   public AccountState insert(
-      String message, Account.Id accountId, Consumer<InternalAccountUpdate.Builder> init)
+      String message, Account.Id accountId, Consumer<AccountDelta.Builder> init)
       throws IOException, ConfigInvalidException {
-    return insert(message, accountId, AccountUpdater.fromConsumer(init));
+    return insert(message, accountId, fromConsumer(init));
   }
 
   /**
@@ -309,57 +318,48 @@
    *
    * @param message commit message for the account creation, must not be {@code null or empty}
    * @param accountId ID of the new account
-   * @param updater updater to populate the new account
+   * @param init to populate the new account
    * @return the newly created account
    * @throws DuplicateKeyException if the account already exists
    * @throws IOException if creating the user branch fails due to an IO error
    * @throws ConfigInvalidException if any of the account fields has an invalid value
    */
-  public AccountState insert(String message, Account.Id accountId, AccountUpdater updater)
+  public AccountState insert(String message, Account.Id accountId, ConfigureDeltaFromState init)
       throws IOException, ConfigInvalidException {
-    return updateAccount(
-            r -> {
-              AccountConfig accountConfig = read(r, accountId);
-              Account account =
-                  accountConfig.getNewAccount(new Timestamp(committerIdent.getWhen().getTime()));
-              AccountState accountState = AccountState.forAccount(account);
-              InternalAccountUpdate.Builder updateBuilder = InternalAccountUpdate.builder();
-              updater.update(accountState, updateBuilder);
+    return execute(
+            ImmutableList.of(
+                repo -> {
+                  AccountConfig accountConfig = read(repo, accountId);
+                  Account account =
+                      accountConfig.getNewAccount(
+                          new Timestamp(committerIdent.getWhen().getTime()));
+                  AccountState accountState = AccountState.forAccount(account);
+                  AccountDelta.Builder deltaBuilder = AccountDelta.builder();
+                  init.configure(accountState, deltaBuilder);
 
-              InternalAccountUpdate update = updateBuilder.build();
-              accountConfig.setAccountUpdate(update);
-              ExternalIdNotes extIdNotes =
-                  createExternalIdNotes(r, accountConfig.getExternalIdsRev(), accountId, update);
-              CachedPreferences defaultPreferences =
-                  CachedPreferences.fromConfig(VersionedDefaultPreferences.get(r, allUsersName));
+                  AccountDelta accountDelta = deltaBuilder.build();
+                  accountConfig.setAccountDelta(accountDelta);
+                  externalIdNotes =
+                      createExternalIdNotes(
+                          repo, accountConfig.getExternalIdsRev(), accountId, accountDelta);
+                  CachedPreferences defaultPreferences =
+                      CachedPreferences.fromConfig(
+                          VersionedDefaultPreferences.get(repo, allUsersName));
 
-              UpdatedAccount updatedAccounts =
-                  new UpdatedAccount(
-                      externalIds, message, accountConfig, extIdNotes, defaultPreferences);
-              updatedAccounts.setCreated(true);
-              return updatedAccounts;
-            })
+                  return new UpdatedAccount(message, accountConfig, defaultPreferences, true);
+                }))
+        .get(0)
         .get();
   }
 
   /**
-   * Gets the account and updates it atomically.
-   *
-   * <p>Changing the registration date of an account is not supported.
-   *
-   * @param message commit message for the account update, must not be {@code null or empty}
-   * @param accountId ID of the account
-   * @param update consumer to update the account, only invoked if the account exists
-   * @return the updated account, {@link Optional#empty()} if the account doesn't exist
-   * @throws IOException if updating the user branch fails due to an IO error
-   * @throws LockFailureException if updating the user branch still fails due to concurrent updates
-   *     after the retry timeout exceeded
-   * @throws ConfigInvalidException if any of the account fields has an invalid value
+   * Like {@link #update(String, Account.Id, ConfigureDeltaFromState)}, but using a {@link Consumer}
+   * instead, i.e. the update does not depend on the current account state.
    */
   public Optional<AccountState> update(
-      String message, Account.Id accountId, Consumer<InternalAccountUpdate.Builder> update)
-      throws LockFailureException, IOException, ConfigInvalidException {
-    return update(message, accountId, AccountUpdater.fromConsumer(update));
+      String message, Account.Id accountId, Consumer<AccountDelta.Builder> update)
+      throws IOException, ConfigInvalidException {
+    return update(message, accountId, fromConsumer(update));
   }
 
   /**
@@ -369,41 +369,77 @@
    *
    * @param message commit message for the account update, must not be {@code null or empty}
    * @param accountId ID of the account
-   * @param updater updater to update the account, only invoked if the account exists
+   * @param configureDeltaFromState deltaBuilder to update the account, only invoked if the account
+   *     exists
    * @return the updated account, {@link Optional#empty} if the account doesn't exist
    * @throws IOException if updating the user branch fails due to an IO error
    * @throws LockFailureException if updating the user branch still fails due to concurrent updates
    *     after the retry timeout exceeded
    * @throws ConfigInvalidException if any of the account fields has an invalid value
    */
-  public Optional<AccountState> update(String message, Account.Id accountId, AccountUpdater updater)
+  public Optional<AccountState> update(
+      String message, Account.Id accountId, ConfigureDeltaFromState configureDeltaFromState)
       throws LockFailureException, IOException, ConfigInvalidException {
-    return updateAccount(
-        r -> {
-          AccountConfig accountConfig = read(r, accountId);
-          CachedPreferences defaultPreferences =
-              CachedPreferences.fromConfig(VersionedDefaultPreferences.get(r, allUsersName));
-          Optional<AccountState> account =
-              AccountState.fromAccountConfig(externalIds, accountConfig, defaultPreferences);
-          if (!account.isPresent()) {
-            return null;
-          }
+    return updateBatch(
+            ImmutableList.of(new UpdateArguments(message, accountId, configureDeltaFromState)))
+        .get(0);
+  }
 
-          InternalAccountUpdate.Builder updateBuilder = InternalAccountUpdate.builder();
-          updater.update(account.get(), updateBuilder);
+  private ExecutableUpdate createExecutableUpdate(UpdateArguments updateArguments) {
+    return repo -> {
+      AccountConfig accountConfig = read(repo, updateArguments.accountId);
+      CachedPreferences defaultPreferences =
+          CachedPreferences.fromConfig(VersionedDefaultPreferences.get(repo, allUsersName));
+      Optional<AccountState> accountState =
+          AccountState.fromAccountConfig(externalIds, accountConfig, defaultPreferences);
+      if (!accountState.isPresent()) {
+        return null;
+      }
 
-          InternalAccountUpdate update = updateBuilder.build();
-          accountConfig.setAccountUpdate(update);
-          ExternalIdNotes extIdNotes =
-              createExternalIdNotes(r, accountConfig.getExternalIdsRev(), accountId, update);
-          CachedPreferences cachedDefaultPreferences =
-              CachedPreferences.fromConfig(VersionedDefaultPreferences.get(r, allUsersName));
+      AccountDelta.Builder deltaBuilder = AccountDelta.builder();
+      updateArguments.configureDeltaFromState.configure(accountState.get(), deltaBuilder);
 
-          UpdatedAccount updatedAccounts =
-              new UpdatedAccount(
-                  externalIds, message, accountConfig, extIdNotes, cachedDefaultPreferences);
-          return updatedAccounts;
-        });
+      AccountDelta delta = deltaBuilder.build();
+      accountConfig.setAccountDelta(delta);
+      ExternalIdNotes.checkSameAccount(
+          Iterables.concat(
+              delta.getCreatedExternalIds(),
+              delta.getUpdatedExternalIds(),
+              delta.getDeletedExternalIds()),
+          updateArguments.accountId);
+
+      if (externalIdNotes == null) {
+        externalIdNotes =
+            extIdNotesLoader.load(
+                repo, accountConfig.getExternalIdsRev().orElse(ObjectId.zeroId()));
+      }
+      externalIdNotes.replace(delta.getDeletedExternalIds(), delta.getCreatedExternalIds());
+      externalIdNotes.upsert(delta.getUpdatedExternalIds());
+
+      CachedPreferences cachedDefaultPreferences =
+          CachedPreferences.fromConfig(VersionedDefaultPreferences.get(repo, allUsersName));
+
+      return new UpdatedAccount(
+          updateArguments.message, accountConfig, cachedDefaultPreferences, false);
+    };
+  }
+
+  /**
+   * Updates multiple different accounts atomically. This will only store a single new value (aka
+   * set of all external IDs of the host) in the external ID cache, which is important for storage
+   * economy. All {@code updates} must be for different accounts.
+   *
+   * <p>NOTE on error handling: Since updates are executed in multiple stages, with some stages
+   * resulting from the union of all individual updates, we cannot point to the update that caused
+   * the error. Callers should be aware that a single "update of death" (or a set of updates that
+   * together have this property) will always prevent the entire batch from being executed.
+   */
+  public ImmutableList<Optional<AccountState>> updateBatch(List<UpdateArguments> updates)
+      throws IOException, ConfigInvalidException {
+    checkArgument(
+        updates.stream().map(u -> u.accountId.get()).distinct().count() == updates.size(),
+        "updates must all be for different accounts");
+    return execute(updates.stream().map(this::createExecutableUpdate).collect(toList()));
   }
 
   private AccountConfig read(Repository allUsersRepo, Account.Id accountId)
@@ -413,26 +449,35 @@
     return accountConfig;
   }
 
-  private Optional<AccountState> updateAccount(AccountUpdate accountUpdate)
+  private ImmutableList<Optional<AccountState>> execute(List<ExecutableUpdate> executableUpdates)
       throws IOException, ConfigInvalidException {
-    return executeAccountUpdate(
+    List<Optional<AccountState>> accountState = new ArrayList<>();
+    List<UpdatedAccount> updatedAccounts = new ArrayList<>();
+    executeWithRetry(
         () -> {
-          try (Repository allUsersRepo = repoManager.openRepository(allUsersName)) {
-            UpdatedAccount updatedAccount = accountUpdate.update(allUsersRepo);
-            if (updatedAccount == null) {
-              return Optional.empty();
-            }
+          // Reset state for retry.
+          externalIdNotes = null;
+          accountState.clear();
+          updatedAccounts.clear();
 
-            commit(allUsersRepo, updatedAccount);
-            return Optional.of(updatedAccount.getAccount());
+          try (Repository allUsersRepo = repoManager.openRepository(allUsersName)) {
+            for (ExecutableUpdate executableUpdate : executableUpdates) {
+              updatedAccounts.add(executableUpdate.execute(allUsersRepo));
+            }
+            commit(
+                allUsersRepo, updatedAccounts.stream().filter(Objects::nonNull).collect(toList()));
+            for (UpdatedAccount ua : updatedAccounts) {
+              accountState.add(ua == null ? Optional.empty() : ua.getAccountState());
+            }
           }
+          return null;
         });
+    return ImmutableList.copyOf(accountState);
   }
 
-  private Optional<AccountState> executeAccountUpdate(Action<Optional<AccountState>> action)
-      throws IOException, ConfigInvalidException {
+  private void executeWithRetry(Action<Void> action) throws IOException, ConfigInvalidException {
     try {
-      return retryHelper.accountUpdate("updateAccount", action).call();
+      retryHelper.accountUpdate("updateAccount", action).call();
     } catch (Exception e) {
       Throwables.throwIfUnchecked(e);
       Throwables.throwIfInstanceOf(e, IOException.class);
@@ -442,10 +487,7 @@
   }
 
   private ExternalIdNotes createExternalIdNotes(
-      Repository allUsersRepo,
-      Optional<ObjectId> rev,
-      Account.Id accountId,
-      InternalAccountUpdate update)
+      Repository allUsersRepo, Optional<ObjectId> rev, Account.Id accountId, AccountDelta update)
       throws IOException, ConfigInvalidException, DuplicateKeyException {
     ExternalIdNotes.checkSameAccount(
         Iterables.concat(
@@ -460,73 +502,52 @@
     return extIdNotes;
   }
 
-  private void commit(Repository allUsersRepo, UpdatedAccount updatedAccount) throws IOException {
+  private void commit(Repository allUsersRepo, List<UpdatedAccount> updatedAccounts)
+      throws IOException {
+    if (updatedAccounts.isEmpty()) {
+      return;
+    }
+
     beforeCommit.run();
 
     BatchRefUpdate batchRefUpdate = allUsersRepo.getRefDatabase().newBatchUpdate();
 
-    if (updatedAccount.isCreated()) {
-      commitNewAccountConfig(
-          updatedAccount.getMessage(),
-          allUsersRepo,
-          batchRefUpdate,
-          updatedAccount.getAccountConfig());
-    } else {
+    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
+      // revision in their AccountConfig.
       commitAccountConfig(
-          updatedAccount.getMessage(),
+          updatedAccount.message,
           allUsersRepo,
           batchRefUpdate,
-          updatedAccount.getAccountConfig());
-    }
+          updatedAccount.accountConfig,
+          updatedAccount.created /* allowEmptyCommit */);
+      // When creating a new account we must allow empty commits so that the user branch gets
+      // created with an empty commit when no account properties are set and hence no
+      // 'account.config' file will be created.
 
-    commitExternalIdUpdates(
-        updatedAccount.getMessage(),
-        allUsersRepo,
-        batchRefUpdate,
-        updatedAccount.getExternalIdNotes());
+      // 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);
+    }
 
     RefUpdateUtil.executeChecked(batchRefUpdate, allUsersRepo);
 
-    // Skip accounts that are updated when evicting the account cache via ExternalIdNotes to avoid
-    // double reindexing. The updated accounts will already be reindexed by ReindexAfterRefUpdate.
-    Set<Account.Id> accountsThatWillBeReindexByReindexAfterRefUpdate =
-        getUpdatedAccounts(batchRefUpdate);
-    updatedAccount
-        .getExternalIdNotes()
-        .updateCaches(accountsThatWillBeReindexByReindexAfterRefUpdate);
+    Set<Account.Id> accountsToSkipForReindex = getUpdatedAccountIds(batchRefUpdate);
+    extIdNotesLoader.updateExternalIdCacheAndMaybeReindexAccounts(
+        externalIdNotes, accountsToSkipForReindex);
 
     gitRefUpdated.fire(
-        allUsersName, batchRefUpdate, currentUser.map(user -> user.state()).orElse(null));
+        allUsersName, batchRefUpdate, currentUser.map(IdentifiedUser::state).orElse(null));
   }
 
-  private static Set<Account.Id> getUpdatedAccounts(BatchRefUpdate batchRefUpdate) {
+  private static Set<Account.Id> getUpdatedAccountIds(BatchRefUpdate batchRefUpdate) {
     return batchRefUpdate.getCommands().stream()
         .map(c -> Account.Id.fromRef(c.getRefName()))
         .filter(Objects::nonNull)
         .collect(toSet());
   }
 
-  private void commitNewAccountConfig(
-      String message,
-      Repository allUsersRepo,
-      BatchRefUpdate batchRefUpdate,
-      AccountConfig accountConfig)
-      throws IOException {
-    // When creating a new account we must allow empty commits so that the user branch gets created
-    // with an empty commit when no account properties are set and hence no 'account.config' file
-    // will be created.
-    commitAccountConfig(message, allUsersRepo, batchRefUpdate, accountConfig, true);
-  }
-
-  private void commitAccountConfig(
-      String message,
-      Repository allUsersRepo,
-      BatchRefUpdate batchRefUpdate,
-      AccountConfig accountConfig)
-      throws IOException {
-    commitAccountConfig(message, allUsersRepo, batchRefUpdate, accountConfig, false);
-  }
-
   private void commitAccountConfig(
       String message,
       Repository allUsersRepo,
@@ -541,13 +562,9 @@
   }
 
   private void commitExternalIdUpdates(
-      String message,
-      Repository allUsersRepo,
-      BatchRefUpdate batchRefUpdate,
-      ExternalIdNotes extIdNotes)
-      throws IOException {
+      String message, Repository allUsersRepo, BatchRefUpdate batchRefUpdate) throws IOException {
     try (MetaDataUpdate md = createMetaDataUpdate(message, allUsersRepo, batchRefUpdate)) {
-      extIdNotes.commit(md);
+      externalIdNotes.commit(md);
     }
   }
 
@@ -566,57 +583,31 @@
   }
 
   @FunctionalInterface
-  private static interface AccountUpdate {
-    UpdatedAccount update(Repository allUsersRepo) throws IOException, ConfigInvalidException;
+  private interface ExecutableUpdate {
+    UpdatedAccount execute(Repository allUsersRepo) throws IOException, ConfigInvalidException;
   }
 
-  private static class UpdatedAccount {
-    private final ExternalIds externalIds;
-    private final String message;
-    private final AccountConfig accountConfig;
-    private final ExternalIdNotes extIdNotes;
-    private final CachedPreferences defaultPreferences;
+  private class UpdatedAccount {
+    final String message;
+    final AccountConfig accountConfig;
+    final CachedPreferences defaultPreferences;
+    final boolean created;
 
-    private boolean created;
-
-    private UpdatedAccount(
-        ExternalIds externalIds,
+    UpdatedAccount(
         String message,
         AccountConfig accountConfig,
-        ExternalIdNotes extIdNotes,
-        CachedPreferences defaultPreferences) {
+        CachedPreferences defaultPreferences,
+        boolean created) {
       checkState(!Strings.isNullOrEmpty(message), "message for account update must be set");
-      this.externalIds = requireNonNull(externalIds);
       this.message = requireNonNull(message);
       this.accountConfig = requireNonNull(accountConfig);
-      this.extIdNotes = requireNonNull(extIdNotes);
       this.defaultPreferences = defaultPreferences;
-    }
-
-    public String getMessage() {
-      return message;
-    }
-
-    public AccountConfig getAccountConfig() {
-      return accountConfig;
-    }
-
-    public AccountState getAccount() throws IOException {
-      return AccountState.fromAccountConfig(
-              externalIds, accountConfig, extIdNotes, defaultPreferences)
-          .get();
-    }
-
-    public ExternalIdNotes getExternalIdNotes() {
-      return extIdNotes;
-    }
-
-    public void setCreated(boolean created) {
       this.created = created;
     }
 
-    public boolean isCreated() {
-      return created;
+    Optional<AccountState> getAccountState() throws IOException {
+      return AccountState.fromAccountConfig(
+          externalIds, accountConfig, externalIdNotes, defaultPreferences);
     }
   }
 }
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalIdCache.java b/java/com/google/gerrit/server/account/externalids/ExternalIdCache.java
index 92e7c71..2231519 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalIdCache.java
+++ b/java/com/google/gerrit/server/account/externalids/ExternalIdCache.java
@@ -22,7 +22,8 @@
 import org.eclipse.jgit.lib.ObjectId;
 
 /**
- * Caches external IDs of all accounts.
+ * Caches external IDs of all accounts. Note that the granularity is "revision" only, so each update
+ * will cache a new value containing <b>all</b> external IDs.
  *
  * <p>On each cache access the SHA1 of the refs/meta/external-ids branch is read to verify that the
  * cache is up to date.
@@ -30,6 +31,15 @@
  * <p>All returned collections are unmodifiable.
  */
 interface ExternalIdCache {
+
+  /**
+   * Updates the cache.
+   *
+   * @param oldNotesRev current revision against which the below updates are applied
+   * @param newNotesRev key for the new cache revision
+   * @param toRemove external IDs to remove
+   * @param toAdd external IDs to add
+   */
   void onReplace(
       ObjectId oldNotesRev,
       ObjectId newNotesRev,
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalIdNotes.java b/java/com/google/gerrit/server/account/externalids/ExternalIdNotes.java
index e999c93..e403a5b 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalIdNotes.java
+++ b/java/com/google/gerrit/server/account/externalids/ExternalIdNotes.java
@@ -35,7 +35,6 @@
 import com.google.gerrit.metrics.Description;
 import com.google.gerrit.metrics.DisabledMetricMaker;
 import com.google.gerrit.metrics.MetricMaker;
-import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AccountsUpdate;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
@@ -86,21 +85,34 @@
  * <p>On save the staged external ID updates are performed (see {@link #onSave(CommitBuilder)}).
  *
  * <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 #updateCaches()}).
+ * accounts for which external IDs have been updated (see {@link
+ * ExternalIdNotesLoader#updateExternalIdCacheAndMaybeReindexAccounts)}).
  */
 public class ExternalIdNotes extends VersionedMetaData {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   private static final int MAX_NOTE_SZ = 1 << 19;
 
-  public interface ExternalIdNotesLoader {
+  public abstract static class ExternalIdNotesLoader {
+    protected final ExternalIdCache externalIdCache;
+    protected final MetricMaker metricMaker;
+    protected final AllUsersName allUsersName;
+
+    protected ExternalIdNotesLoader(
+        ExternalIdCache externalIdCache, MetricMaker metricMaker, AllUsersName allUsersName) {
+      this.externalIdCache = externalIdCache;
+      this.metricMaker = metricMaker;
+      this.allUsersName = allUsersName;
+    }
+
     /**
      * Loads the external ID notes from the current tip of the {@code refs/meta/external-ids}
      * branch.
      *
      * @param allUsersRepo the All-Users repository
      */
-    ExternalIdNotes load(Repository allUsersRepo) throws IOException, ConfigInvalidException;
+    public abstract ExternalIdNotes load(Repository allUsersRepo)
+        throws IOException, ConfigInvalidException;
 
     /**
      * Loads the external ID notes from the specified revision of the {@code refs/meta/external-ids}
@@ -112,107 +124,122 @@
      *     assumed that the {@code refs/meta/external-ids} branch doesn't exist and the loaded
      *     external IDs will be empty
      */
-    ExternalIdNotes load(Repository allUsersRepo, @Nullable ObjectId rev)
+    public abstract ExternalIdNotes load(Repository allUsersRepo, @Nullable ObjectId rev)
         throws IOException, ConfigInvalidException;
+
+    /**
+     * Updates the external ID cache. Subclasses of type {@link Factory} will also reindex the
+     * accounts for which external IDs were modified, while subclasses of type {@link
+     * FactoryNoReindex} will skip this.
+     *
+     * <p>Must only be called after committing changes.
+     *
+     * @param externalIdNotes the committed updates that should be applied to the cache. This first
+     *     and last element must be the updates commited first and last, respectively.
+     * @param accountsToSkipForReindex accounts that should not be reindexed. This is to avoid
+     *     double reindexing when updated accounts will already be reindexed by
+     *     ReindexAfterRefUpdate.
+     */
+    public void updateExternalIdCacheAndMaybeReindexAccounts(
+        ExternalIdNotes externalIdNotes, Collection<Account.Id> accountsToSkipForReindex)
+        throws IOException {
+      checkState(externalIdNotes.oldRev != null, "no changes committed yet");
+
+      // readOnly is ignored here (legacy behavior).
+
+      // Aggregate all updates.
+      ExternalIdCacheUpdates updates = new ExternalIdCacheUpdates();
+      for (CacheUpdate cacheUpdate : externalIdNotes.cacheUpdates) {
+        cacheUpdate.execute(updates);
+      }
+
+      // Perform the cache update.
+      if (!externalIdNotes.noCacheUpdate) {
+        // Regardless of noCacheUpdate it's still possible that the ExternalIdCache instance is of
+        // type DisabledExternalIdCache, making this call a no-op.
+        externalIdCache.onReplace(
+            externalIdNotes.oldRev,
+            externalIdNotes.getRevision(),
+            updates.getRemoved(),
+            updates.getAdded());
+      }
+
+      // Reindex accounts (if the subclass implements reindexAccount()).
+      if (!externalIdNotes.noReindex) {
+        Streams.concat(updates.getAdded().stream(), updates.getRemoved().stream())
+            .map(ExternalId::accountId)
+            .filter(i -> !accountsToSkipForReindex.contains(i))
+            .distinct()
+            .forEach(this::reindexAccount);
+      }
+
+      // Reset instance state.
+      externalIdNotes.cacheUpdates.clear();
+      externalIdNotes.keysToAdd.clear();
+      externalIdNotes.oldRev = null;
+    }
+
+    protected abstract void reindexAccount(Account.Id id);
   }
 
   @Singleton
-  public static class Factory implements ExternalIdNotesLoader {
-    private final ExternalIdCache externalIdCache;
-    private final AccountCache accountCache;
+  public static class Factory extends ExternalIdNotesLoader {
+
     private final Provider<AccountIndexer> accountIndexer;
-    private final MetricMaker metricMaker;
-    private final AllUsersName allUsersName;
 
     @Inject
     Factory(
         ExternalIdCache externalIdCache,
-        AccountCache accountCache,
         Provider<AccountIndexer> accountIndexer,
         MetricMaker metricMaker,
         AllUsersName allUsersName) {
-      this.externalIdCache = externalIdCache;
-      this.accountCache = accountCache;
+      super(externalIdCache, metricMaker, allUsersName);
       this.accountIndexer = accountIndexer;
-      this.metricMaker = metricMaker;
-      this.allUsersName = allUsersName;
     }
 
     @Override
     public ExternalIdNotes load(Repository allUsersRepo)
         throws IOException, ConfigInvalidException {
-      return new ExternalIdNotes(
-              externalIdCache,
-              accountCache,
-              accountIndexer,
-              metricMaker,
-              allUsersName,
-              allUsersRepo)
-          .load();
+      return new ExternalIdNotes(metricMaker, allUsersName, allUsersRepo).load();
     }
 
     @Override
     public ExternalIdNotes load(Repository allUsersRepo, @Nullable ObjectId rev)
         throws IOException, ConfigInvalidException {
-      return new ExternalIdNotes(
-              externalIdCache,
-              accountCache,
-              accountIndexer,
-              metricMaker,
-              allUsersName,
-              allUsersRepo)
-          .load(rev);
+      return new ExternalIdNotes(metricMaker, allUsersName, allUsersRepo).load(rev);
+    }
+
+    @Override
+    protected void reindexAccount(Account.Id id) {
+      accountIndexer.get().index(id);
     }
   }
 
   @Singleton
-  public static class FactoryNoReindex implements ExternalIdNotesLoader {
-    private final ExternalIdCache externalIdCache;
-    private final MetricMaker metricMaker;
-    private final AllUsersName allUsersName;
+  public static class FactoryNoReindex extends ExternalIdNotesLoader {
 
     @Inject
     FactoryNoReindex(
         ExternalIdCache externalIdCache, MetricMaker metricMaker, AllUsersName allUsersName) {
-      this.externalIdCache = externalIdCache;
-      this.metricMaker = metricMaker;
-      this.allUsersName = allUsersName;
+      super(externalIdCache, metricMaker, allUsersName);
     }
 
     @Override
     public ExternalIdNotes load(Repository allUsersRepo)
         throws IOException, ConfigInvalidException {
-      return new ExternalIdNotes(
-              externalIdCache, null, null, metricMaker, allUsersName, allUsersRepo)
-          .load();
+      return new ExternalIdNotes(metricMaker, allUsersName, allUsersRepo).setNoReindex().load();
     }
 
     @Override
     public ExternalIdNotes load(Repository allUsersRepo, @Nullable ObjectId rev)
         throws IOException, ConfigInvalidException {
-      return new ExternalIdNotes(
-              externalIdCache, null, null, metricMaker, allUsersName, allUsersRepo)
-          .load(rev);
+      return new ExternalIdNotes(metricMaker, allUsersName, allUsersRepo).setNoReindex().load(rev);
     }
-  }
 
-  /**
-   * Loads the external ID notes for reading only. The external ID notes are loaded from the current
-   * tip of the {@code refs/meta/external-ids} branch.
-   *
-   * @return read-only {@link ExternalIdNotes} instance
-   */
-  public static ExternalIdNotes loadReadOnly(AllUsersName allUsersName, Repository allUsersRepo)
-      throws IOException, ConfigInvalidException {
-    return new ExternalIdNotes(
-            new DisabledExternalIdCache(),
-            null,
-            null,
-            new DisabledMetricMaker(),
-            allUsersName,
-            allUsersRepo)
-        .setReadOnly()
-        .load();
+    @Override
+    protected void reindexAccount(Account.Id id) {
+      // Do not reindex.
+    }
   }
 
   /**
@@ -228,14 +255,10 @@
   public static ExternalIdNotes loadReadOnly(
       AllUsersName allUsersName, Repository allUsersRepo, @Nullable ObjectId rev)
       throws IOException, ConfigInvalidException {
-    return new ExternalIdNotes(
-            new DisabledExternalIdCache(),
-            null,
-            null,
-            new DisabledMetricMaker(),
-            allUsersName,
-            allUsersRepo)
+    return new ExternalIdNotes(new DisabledMetricMaker(), allUsersName, allUsersRepo)
         .setReadOnly()
+        .setNoCacheUpdate()
+        .setNoReindex()
         .load(rev);
   }
 
@@ -252,19 +275,12 @@
   public static ExternalIdNotes loadNoCacheUpdate(
       AllUsersName allUsersName, Repository allUsersRepo)
       throws IOException, ConfigInvalidException {
-    return new ExternalIdNotes(
-            new DisabledExternalIdCache(),
-            null,
-            null,
-            new DisabledMetricMaker(),
-            allUsersName,
-            allUsersRepo)
+    return new ExternalIdNotes(new DisabledMetricMaker(), allUsersName, allUsersRepo)
+        .setNoCacheUpdate()
+        .setNoReindex()
         .load();
   }
 
-  private final ExternalIdCache externalIdCache;
-  @Nullable private final AccountCache accountCache;
-  @Nullable private final Provider<AccountIndexer> accountIndexer;
   private final AllUsersName allUsersName;
   private final Counter0 updateCount;
   private final Repository repo;
@@ -273,25 +289,30 @@
   private NoteMap noteMap;
   private ObjectId oldRev;
 
-  // Staged note map updates that should be executed on save.
-  private List<NoteMapUpdate> noteMapUpdates = new ArrayList<>();
+  /** Staged note map updates that should be executed on save. */
+  private final List<NoteMapUpdate> noteMapUpdates = new ArrayList<>();
 
-  // Staged cache updates that should be executed after external ID changes have been committed.
-  private List<CacheUpdate> cacheUpdates = new ArrayList<>();
+  /** Staged cache updates that should be executed after external ID changes have been committed. */
+  private final List<CacheUpdate> cacheUpdates = new ArrayList<>();
+
+  /**
+   * When performing batch updates (cf. {@link AccountsUpdate#updateBatch(List)} we need to ensure
+   * the batch does not introduce duplicates. In addition to checking against the status quo in
+   * {@link #noteMap} (cf. {@link #checkExternalIdKeysDontExist(Collection)}), which is sufficient
+   * for single updates, we also need to check for duplicates among the batch updates. As the actual
+   * updates are computed lazily just before applying them, we unfortunately need to track keys
+   * explicitly here even though they are already implicit in the lambdas that constitute the
+   * updates.
+   */
+  private final Set<ExternalId.Key> keysToAdd = new HashSet<>();
 
   private Runnable afterReadRevision;
   private boolean readOnly = false;
+  private boolean noCacheUpdate = false;
+  private boolean noReindex = false;
 
   private ExternalIdNotes(
-      ExternalIdCache externalIdCache,
-      @Nullable AccountCache accountCache,
-      @Nullable Provider<AccountIndexer> accountIndexer,
-      MetricMaker metricMaker,
-      AllUsersName allUsersName,
-      Repository allUsersRepo) {
-    this.externalIdCache = requireNonNull(externalIdCache, "externalIdCache");
-    this.accountCache = accountCache;
-    this.accountIndexer = accountIndexer;
+      MetricMaker metricMaker, AllUsersName allUsersName, Repository allUsersRepo) {
     this.updateCount =
         metricMaker.newCounter(
             "notedb/external_id_update_count",
@@ -319,7 +340,17 @@
   }
 
   private ExternalIdNotes setReadOnly() {
-    this.readOnly = true;
+    readOnly = true;
+    return this;
+  }
+
+  private ExternalIdNotes setNoCacheUpdate() {
+    noCacheUpdate = true;
+    return this;
+  }
+
+  private ExternalIdNotes setNoReindex() {
+    noReindex = true;
     return this;
   }
 
@@ -463,6 +494,7 @@
           }
         });
     cacheUpdates.add(cu -> cu.add(newExtIds));
+    incrementalDuplicateDetection(extIds);
   }
 
   /**
@@ -491,6 +523,7 @@
           }
         });
     cacheUpdates.add(cu -> cu.remove(removedExtIds).add(updatedExtIds));
+    incrementalDuplicateDetection(extIds);
   }
 
   /**
@@ -605,6 +638,7 @@
           }
         });
     cacheUpdates.add(cu -> cu.add(updatedExtIds).remove(removedExtIds));
+    incrementalDuplicateDetection(toAdd);
   }
 
   /**
@@ -637,6 +671,7 @@
           }
         });
     cacheUpdates.add(cu -> cu.add(updatedExtIds).remove(removedExtIds));
+    incrementalDuplicateDetection(toAdd);
   }
 
   /**
@@ -695,66 +730,6 @@
     return commit;
   }
 
-  /**
-   * Updates the caches (external ID cache, account cache) and reindexes the accounts for which
-   * external IDs were modified.
-   *
-   * <p>Must only be called after committing changes.
-   *
-   * <p>No-op if this instance was created by {@link #loadNoCacheUpdate(AllUsersName, Repository)}.
-   *
-   * <p>No eviction from account cache and no reindex if this instance was created by {@link
-   * FactoryNoReindex}.
-   */
-  public void updateCaches() throws IOException {
-    updateCaches(ImmutableSet.of());
-  }
-
-  /**
-   * Updates the caches (external ID cache, account cache) and reindexes the accounts for which
-   * external IDs were modified.
-   *
-   * <p>Must only be called after committing changes.
-   *
-   * <p>No-op if this instance was created by {@link #loadNoCacheUpdate(AllUsersName, Repository)}.
-   *
-   * <p>No eviction from account cache if this instance was created by {@link FactoryNoReindex}.
-   *
-   * @param accountsToSkip set of accounts that should not be evicted from the account cache, in
-   *     this case the caller must take care to evict them otherwise
-   */
-  public void updateCaches(Collection<Account.Id> accountsToSkip) throws IOException {
-    checkState(oldRev != null, "no changes committed yet");
-
-    ExternalIdCacheUpdates externalIdCacheUpdates = new ExternalIdCacheUpdates();
-    for (CacheUpdate cacheUpdate : cacheUpdates) {
-      cacheUpdate.execute(externalIdCacheUpdates);
-    }
-
-    externalIdCache.onReplace(
-        oldRev,
-        getRevision(),
-        externalIdCacheUpdates.getRemoved(),
-        externalIdCacheUpdates.getAdded());
-
-    if (accountCache != null || accountIndexer != null) {
-      for (Account.Id id :
-          Streams.concat(
-                  externalIdCacheUpdates.getAdded().stream(),
-                  externalIdCacheUpdates.getRemoved().stream())
-              .map(ExternalId::accountId)
-              .filter(i -> !accountsToSkip.contains(i))
-              .collect(toSet())) {
-        if (accountIndexer != null) {
-          accountIndexer.get().index(id);
-        }
-      }
-    }
-
-    cacheUpdates.clear();
-    oldRev = null;
-  }
-
   @Override
   protected boolean onSave(CommitBuilder commit) throws IOException, ConfigInvalidException {
     checkState(!readOnly, "Updating external IDs is disabled");
@@ -829,6 +804,17 @@
     return accountId;
   }
 
+  private void incrementalDuplicateDetection(Collection<ExternalId> externalIds) {
+    externalIds.stream()
+        .map(ExternalId::key)
+        .forEach(
+            key -> {
+              if (!keysToAdd.add(key)) {
+                throw new DuplicateExternalIdKeyException(key);
+              }
+            });
+  }
+
   /**
    * Insert or updates an new external ID and sets it in the note map.
    *
@@ -866,12 +852,11 @@
    * @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 ExternalId remove(
-      RevWalk rw, NoteMap noteMap, Set<String> footers, ExternalId extId)
+  private static void remove(RevWalk rw, NoteMap noteMap, Set<String> footers, ExternalId extId)
       throws IOException, ConfigInvalidException {
     ObjectId noteId = extId.key().sha1();
     if (!noteMap.contains(noteId)) {
-      return null;
+      return;
     }
 
     ObjectId noteDataId = noteMap.get(noteId);
@@ -884,7 +869,6 @@
         actualExtId.toString());
     noteMap.remove(noteId);
     addFooters(footers, actualExtId);
-    return actualExtId;
   }
 
   /**
@@ -969,15 +953,15 @@
   }
 
   private static class ExternalIdCacheUpdates {
-    private final Set<ExternalId> added = new HashSet<>();
-    private final Set<ExternalId> removed = new HashSet<>();
+    final Set<ExternalId> added = new HashSet<>();
+    final Set<ExternalId> removed = new HashSet<>();
 
     ExternalIdCacheUpdates add(Collection<ExternalId> extIds) {
       this.added.addAll(extIds);
       return this;
     }
 
-    public Set<ExternalId> getAdded() {
+    Set<ExternalId> getAdded() {
       return ImmutableSet.copyOf(added);
     }
 
@@ -986,7 +970,7 @@
       return this;
     }
 
-    public Set<ExternalId> getRemoved() {
+    Set<ExternalId> getRemoved() {
       return ImmutableSet.copyOf(removed);
     }
   }
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalIdReader.java b/java/com/google/gerrit/server/account/externalids/ExternalIdReader.java
index c055313..f2505fa 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalIdReader.java
+++ b/java/com/google/gerrit/server/account/externalids/ExternalIdReader.java
@@ -106,7 +106,7 @@
 
     try (Timer0.Context ctx = readAllLatency.start();
         Repository repo = repoManager.openRepository(allUsersName)) {
-      return ExternalIdNotes.loadReadOnly(allUsersName, repo).all();
+      return ExternalIdNotes.loadReadOnly(allUsersName, repo, null).all();
     }
   }
 
@@ -135,7 +135,7 @@
 
     try (Timer0.Context ctx = readSingleLatency.start();
         Repository repo = repoManager.openRepository(allUsersName)) {
-      return ExternalIdNotes.loadReadOnly(allUsersName, repo).get(key);
+      return ExternalIdNotes.loadReadOnly(allUsersName, repo, null).get(key);
     }
   }
 
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalIdsConsistencyChecker.java b/java/com/google/gerrit/server/account/externalids/ExternalIdsConsistencyChecker.java
index 815f7d0..6a4da09 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalIdsConsistencyChecker.java
+++ b/java/com/google/gerrit/server/account/externalids/ExternalIdsConsistencyChecker.java
@@ -58,7 +58,7 @@
 
   public List<ConsistencyProblemInfo> check() throws IOException, ConfigInvalidException {
     try (Repository repo = repoManager.openRepository(allUsers)) {
-      return check(ExternalIdNotes.loadReadOnly(allUsers, repo));
+      return check(ExternalIdNotes.loadReadOnly(allUsers, repo, null));
     }
   }
 
diff --git a/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java b/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
index 0b340b8..6a26f53 100644
--- a/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
+++ b/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
@@ -21,8 +21,6 @@
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.AbandonInput;
-import com.google.gerrit.extensions.api.changes.AddReviewerInput;
-import com.google.gerrit.extensions.api.changes.AddReviewerResult;
 import com.google.gerrit.extensions.api.changes.AssigneeInput;
 import com.google.gerrit.extensions.api.changes.AttentionSetApi;
 import com.google.gerrit.extensions.api.changes.AttentionSetInput;
@@ -39,6 +37,8 @@
 import com.google.gerrit.extensions.api.changes.RevertInput;
 import com.google.gerrit.extensions.api.changes.ReviewerApi;
 import com.google.gerrit.extensions.api.changes.ReviewerInfo;
+import com.google.gerrit.extensions.api.changes.ReviewerInput;
+import com.google.gerrit.extensions.api.changes.ReviewerResult;
 import com.google.gerrit.extensions.api.changes.RevisionApi;
 import com.google.gerrit.extensions.api.changes.SubmittedTogetherInfo;
 import com.google.gerrit.extensions.api.changes.SubmittedTogetherOption;
@@ -467,7 +467,7 @@
   }
 
   @Override
-  public AddReviewerResult addReviewer(AddReviewerInput in) throws RestApiException {
+  public ReviewerResult addReviewer(ReviewerInput in) throws RestApiException {
     try {
       return postReviewers.apply(change, in).value();
     } catch (Exception e) {
diff --git a/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java b/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java
index 573f2f5..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;
@@ -261,9 +261,9 @@
   }
 
   @Override
-  public void submit(SubmitInput in) throws RestApiException {
+  public ChangeInfo submit(SubmitInput in) throws RestApiException {
     try {
-      submit.apply(revision, in);
+      return submit.apply(revision, in).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot submit change", e);
     }
@@ -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 87%
rename from java/com/google/gerrit/server/ApprovalInference.java
rename to java/com/google/gerrit/server/approval/ApprovalInference.java
index d77427a..0185598 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,12 +26,12 @@
 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;
@@ -44,6 +44,11 @@
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
 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;
@@ -61,24 +66,33 @@
  * submit time, or refreshed on demand, as when reading approvals from the NoteDb.
  */
 @Singleton
-public class ApprovalInference {
+class ApprovalInference {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   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(
       ProjectCache projectCache,
       ChangeKindCache changeKindCache,
       LabelNormalizer labelNormalizer,
-      PatchListCache patchListCache) {
+      PatchListCache patchListCache,
+      ApprovalQueryBuilder approvalQueryBuilder,
+      OneOffRequestContext requestContext,
+      ListOfFilesUnchangedPredicate listOfFilesUnchangedPredicate) {
     this.projectCache = projectCache;
     this.changeKindCache = changeKindCache;
     this.labelNormalizer = labelNormalizer;
     this.patchListCache = patchListCache;
+    this.approvalQueryBuilder = approvalQueryBuilder;
+    this.requestContext = requestContext;
+    this.listOfFilesUnchangedPredicate = listOfFilesUnchangedPredicate;
   }
 
   /**
@@ -105,7 +119,7 @@
     }
   }
 
-  private static boolean canCopy(
+  private boolean canCopyBasedOnBooleanLabelConfigs(
       ProjectState project,
       PatchSetApproval psa,
       PatchSet.Id psId,
@@ -172,11 +186,7 @@
           project.getName());
       return true;
     } else if (type.isCopyAllScoresIfListOfFilesDidNotChange()
-        && patchList.getPatches().stream()
-            .noneMatch(
-                p ->
-                    p.getChangeType() == ChangeType.ADDED
-                        || p.getChangeType() == ChangeType.DELETED)) {
+        && listOfFilesUnchangedPredicate.match(patchList)) {
       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 "
@@ -308,6 +318,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,
@@ -379,7 +414,8 @@
       if (patchList == null && type != null && type.isCopyAllScoresIfListOfFilesDidNotChange()) {
         patchList = getPatchList(project, ps, priorPatchSet);
       }
-      if (!canCopy(project, psa, ps.id(), kind, type, patchList)) {
+      if (!canCopyBasedOnBooleanLabelConfigs(project, psa, ps.id(), kind, type, patchList)
+          && !canCopyBasedOnCopyCondition(notes, psa, ps.id(), type, kind)) {
         continue;
       }
       resultByUser.put(psa.label(), psa.accountId(), psa.copyWithPatchSet(ps.id()));
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/serialize/entities/CachedProjectConfigSerializer.java b/java/com/google/gerrit/server/cache/serialize/entities/CachedProjectConfigSerializer.java
index 40ef794..28a5f98 100644
--- a/java/com/google/gerrit/server/cache/serialize/entities/CachedProjectConfigSerializer.java
+++ b/java/com/google/gerrit/server/cache/serialize/entities/CachedProjectConfigSerializer.java
@@ -82,6 +82,9 @@
     proto.getLabelSectionsList().stream()
         .map(LabelTypeSerializer::deserialize)
         .forEach(builder::addLabelSection);
+    proto.getSubmitRequirementSectionsList().stream()
+        .map(SubmitRequirementSerializer::deserialize)
+        .forEach(builder::addSubmitRequirementSection);
     proto.getSubscribeSectionsList().stream()
         .map(SubscribeSectionSerializer::deserialize)
         .forEach(builder::addSubscribeSection);
@@ -152,6 +155,9 @@
     autoValue.getLabelSections().values().stream()
         .map(LabelTypeSerializer::serialize)
         .forEach(builder::addLabelSections);
+    autoValue.getSubmitRequirementSections().values().stream()
+        .map(SubmitRequirementSerializer::serialize)
+        .forEach(builder::addSubmitRequirementSections);
     autoValue.getSubscribeSections().values().stream()
         .map(SubscribeSectionSerializer::serialize)
         .forEach(builder::addSubscribeSections);
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/SubmitRequirementSerializer.java b/java/com/google/gerrit/server/cache/serialize/entities/SubmitRequirementSerializer.java
new file mode 100644
index 0000000..47a377f
--- /dev/null
+++ b/java/com/google/gerrit/server/cache/serialize/entities/SubmitRequirementSerializer.java
@@ -0,0 +1,52 @@
+// 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.base.Strings;
+import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.entities.SubmitRequirementExpression;
+import com.google.gerrit.server.cache.proto.Cache;
+import java.util.Optional;
+
+/** Serializer for {@link com.google.gerrit.entities.SubmitRequirement}. */
+public class SubmitRequirementSerializer {
+  public static SubmitRequirement deserialize(Cache.SubmitRequirementProto proto) {
+    return SubmitRequirement.builder()
+        .setName(proto.getName())
+        .setDescription(Optional.ofNullable(Strings.emptyToNull(proto.getDescription())))
+        .setApplicabilityExpression(
+            SubmitRequirementExpression.of(proto.getApplicabilityExpression()))
+        .setSubmittabilityExpression(
+            SubmitRequirementExpression.create(proto.getSubmittabilityExpression()))
+        .setOverrideExpression(SubmitRequirementExpression.of(proto.getOverrideExpression()))
+        .setAllowOverrideInChildProjects(proto.getAllowOverrideInChildProjects())
+        .build();
+  }
+
+  public static Cache.SubmitRequirementProto serialize(SubmitRequirement submitRequirement) {
+    SubmitRequirementExpression emptyExpression = SubmitRequirementExpression.create("");
+    return Cache.SubmitRequirementProto.newBuilder()
+        .setName(submitRequirement.name())
+        .setDescription(submitRequirement.description().orElse(""))
+        .setApplicabilityExpression(
+            submitRequirement.applicabilityExpression().orElse(emptyExpression).expressionString())
+        .setSubmittabilityExpression(
+            submitRequirement.submittabilityExpression().expressionString())
+        .setOverrideExpression(
+            submitRequirement.overrideExpression().orElse(emptyExpression).expressionString())
+        .setAllowOverrideInChildProjects(submitRequirement.allowOverrideInChildProjects())
+        .build();
+  }
+}
diff --git a/java/com/google/gerrit/server/change/AbandonOp.java b/java/com/google/gerrit/server/change/AbandonOp.java
index 6c39ed0..10e1f92 100644
--- a/java/com/google/gerrit/server/change/AbandonOp.java
+++ b/java/com/google/gerrit/server/change/AbandonOp.java
@@ -18,7 +18,6 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.server.ChangeMessagesUtil;
@@ -32,7 +31,7 @@
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
-import com.google.gerrit.server.update.Context;
+import com.google.gerrit.server.update.PostUpdateContext;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 
@@ -50,7 +49,7 @@
 
   private Change change;
   private PatchSet patchSet;
-  private ChangeMessage message;
+  private String mailMessage;
 
   public interface Factory {
     AbandonOp create(
@@ -94,24 +93,22 @@
     change.setLastUpdatedOn(ctx.getWhen());
 
     update.setStatus(change.getStatus());
-    message = newMessage(ctx);
-    cmUtil.addChangeMessage(update, message);
+    mailMessage = cmUtil.setChangeMessage(ctx, commentMessage(), ChangeMessagesUtil.TAG_ABANDON);
     return true;
   }
 
-  private ChangeMessage newMessage(ChangeContext ctx) {
+  private String commentMessage() {
     StringBuilder msg = new StringBuilder();
     msg.append("Abandoned");
     if (!Strings.nullToEmpty(msgTxt).trim().isEmpty()) {
       msg.append("\n\n");
       msg.append(msgTxt.trim());
     }
-
-    return ChangeMessagesUtil.newMessage(ctx, msg.toString(), ChangeMessagesUtil.TAG_ABANDON);
+    return msg.toString();
   }
 
   @Override
-  public void postUpdate(Context ctx) {
+  public void postUpdate(PostUpdateContext ctx) {
     NotifyResolver.Result notify = ctx.getNotify(change.getId());
     try {
       ReplyToChangeSender emailSender =
@@ -119,7 +116,7 @@
       if (accountState != null) {
         emailSender.setFrom(accountState.account().id());
       }
-      emailSender.setChangeMessage(message.getMessage(), ctx.getWhen());
+      emailSender.setChangeMessage(mailMessage, ctx.getWhen());
       emailSender.setNotify(notify);
       emailSender.setMessageId(
           messageIdGenerator.fromChangeUpdate(ctx.getRepoView(), patchSet.id()));
@@ -127,6 +124,12 @@
     } catch (Exception e) {
       logger.atSevere().withCause(e).log("Cannot email update for change %s", change.getId());
     }
-    changeAbandoned.fire(change, patchSet, accountState, msgTxt, ctx.getWhen(), notify.handling());
+    changeAbandoned.fire(
+        ctx.getChangeData(change),
+        patchSet,
+        accountState,
+        msgTxt,
+        ctx.getWhen(),
+        notify.handling());
   }
 }
diff --git a/java/com/google/gerrit/server/change/AddReviewersOp.java b/java/com/google/gerrit/server/change/AddReviewersOp.java
index ff8e5c6..a333ce5 100644
--- a/java/com/google/gerrit/server/change/AddReviewersOp.java
+++ b/java/com/google/gerrit/server/change/AddReviewersOp.java
@@ -23,7 +23,6 @@
 import static java.util.Objects.requireNonNull;
 import static java.util.stream.Collectors.toList;
 
-import com.google.auto.value.AutoValue;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Lists;
@@ -31,20 +30,18 @@
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Address;
 import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.PatchSet;
 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;
-import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
-import com.google.gerrit.server.update.Context;
+import com.google.gerrit.server.update.PostUpdateContext;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 import java.io.IOException;
@@ -52,7 +49,7 @@
 import java.util.List;
 import java.util.Set;
 
-public class AddReviewersOp implements BatchUpdateOp {
+public class AddReviewersOp extends ReviewerOp {
   public interface Factory {
 
     /**
@@ -75,56 +72,25 @@
         boolean forGroup);
   }
 
-  @AutoValue
-  public abstract static class Result {
-    public abstract ImmutableList<PatchSetApproval> addedReviewers();
-
-    public abstract ImmutableList<Address> addedReviewersByEmail();
-
-    public abstract ImmutableList<Account.Id> addedCCs();
-
-    public abstract ImmutableList<Address> addedCCsByEmail();
-
-    static Builder builder() {
-      return new AutoValue_AddReviewersOp_Result.Builder();
-    }
-
-    @AutoValue.Builder
-    abstract static class Builder {
-      abstract Builder setAddedReviewers(Iterable<PatchSetApproval> addedReviewers);
-
-      abstract Builder setAddedReviewersByEmail(Iterable<Address> addedReviewersByEmail);
-
-      abstract Builder setAddedCCs(Iterable<Account.Id> addedCCs);
-
-      abstract Builder setAddedCCsByEmail(Iterable<Address> addedCCsByEmail);
-
-      abstract Result build();
-    }
-  }
-
   private final ApprovalsUtil approvalsUtil;
   private final PatchSetUtil psUtil;
   private final ReviewerAdded reviewerAdded;
   private final AccountCache accountCache;
   private final ProjectCache projectCache;
-  private final AddReviewersEmail addReviewersEmail;
+  private final ModifyReviewersEmail modifyReviewersEmail;
   private final Set<Account.Id> accountIds;
   private final Collection<Address> addresses;
   private final ReviewerState state;
   private final boolean forGroup;
 
-  // Unlike addedCCs, addedReviewers is a PatchSetApproval because the AddReviewerResult returned
+  // Unlike addedCCs, addedReviewers is a PatchSetApproval because the ReviewerResult returned
   // via the REST API is supposed to include vote information.
   private List<PatchSetApproval> addedReviewers = ImmutableList.of();
   private Collection<Address> addedReviewersByEmail = ImmutableList.of();
   private Collection<Account.Id> addedCCs = ImmutableList.of();
   private Collection<Address> addedCCsByEmail = ImmutableList.of();
 
-  private boolean sendEmail = true;
   private Change change;
-  private PatchSet patchSet;
-  private Result opResult;
 
   @Inject
   AddReviewersOp(
@@ -133,7 +99,7 @@
       ReviewerAdded reviewerAdded,
       AccountCache accountCache,
       ProjectCache projectCache,
-      AddReviewersEmail addReviewersEmail,
+      ModifyReviewersEmail modifyReviewersEmail,
       @Assisted Set<Account.Id> accountIds,
       @Assisted Collection<Address> addresses,
       @Assisted ReviewerState state,
@@ -144,7 +110,7 @@
     this.reviewerAdded = reviewerAdded;
     this.accountCache = accountCache;
     this.projectCache = projectCache;
-    this.addReviewersEmail = addReviewersEmail;
+    this.modifyReviewersEmail = modifyReviewersEmail;
 
     this.accountIds = accountIds;
     this.addresses = addresses;
@@ -152,17 +118,6 @@
     this.forGroup = forGroup;
   }
 
-  // TODO(dborowitz): This mutable setter is ugly, but a) it's less ugly than adding boolean args
-  // all the way through the constructor stack, and b) this class is slated to be completely
-  // rewritten.
-  public void suppressEmail() {
-    this.sendEmail = false;
-  }
-
-  void setPatchSet(PatchSet patchSet) {
-    this.patchSet = requireNonNull(patchSet);
-  }
-
   @Override
   public boolean updateChange(ChangeContext ctx) throws RestApiException, IOException {
     change = ctx.getChange();
@@ -238,7 +193,7 @@
   }
 
   @Override
-  public void postUpdate(Context ctx) throws Exception {
+  public void postUpdate(PostUpdateContext ctx) throws Exception {
     opResult =
         Result.builder()
             .setAddedReviewers(addedReviewers)
@@ -247,13 +202,15 @@
             .setAddedCCsByEmail(addedCCsByEmail)
             .build();
     if (sendEmail) {
-      addReviewersEmail.emailReviewersAsync(
+      modifyReviewersEmail.emailReviewersAsync(
           ctx.getUser().asIdentifiedUser(),
           change,
           Lists.transform(addedReviewers, PatchSetApproval::accountId),
           addedCCs,
+          ImmutableSet.of(),
           addedReviewersByEmail,
           addedCCsByEmail,
+          ImmutableSet.of(),
           ctx.getNotify(change.getId()));
     }
     if (!addedReviewers.isEmpty()) {
@@ -262,12 +219,8 @@
               .map(r -> accountCache.get(r.accountId()))
               .flatMap(Streams::stream)
               .collect(toList());
-      reviewerAdded.fire(change, patchSet, reviewers, ctx.getAccount(), ctx.getWhen());
+      reviewerAdded.fire(
+          ctx.getChangeData(change), patchSet, reviewers, ctx.getAccount(), ctx.getWhen());
     }
   }
-
-  public Result getResult() {
-    checkState(opResult != null, "Batch update wasn't executed yet");
-    return opResult;
-  }
 }
diff --git a/java/com/google/gerrit/server/change/AddToAttentionSetOp.java b/java/com/google/gerrit/server/change/AddToAttentionSetOp.java
index 8053b30..a980c32 100644
--- a/java/com/google/gerrit/server/change/AddToAttentionSetOp.java
+++ b/java/com/google/gerrit/server/change/AddToAttentionSetOp.java
@@ -27,7 +27,7 @@
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
-import com.google.gerrit.server.update.Context;
+import com.google.gerrit.server.update.PostUpdateContext;
 import com.google.gerrit.server.util.AttentionSetEmail;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
@@ -101,7 +101,7 @@
   }
 
   @Override
-  public void postUpdate(Context ctx) {
+  public void postUpdate(PostUpdateContext ctx) {
     if (!notify) {
       return;
     }
diff --git a/java/com/google/gerrit/server/change/ChangeInserter.java b/java/com/google/gerrit/server/change/ChangeInserter.java
index fb027bd..6728ba2 100644
--- a/java/com/google/gerrit/server/change/ChangeInserter.java
+++ b/java/com/google/gerrit/server/change/ChangeInserter.java
@@ -18,7 +18,7 @@
 import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.common.collect.ImmutableSet.toImmutableSet;
 import static com.google.gerrit.entities.Change.INITIAL_PATCH_SET_ID;
-import static com.google.gerrit.server.change.ReviewerAdder.newAddReviewerInputFromCommitIdentity;
+import static com.google.gerrit.server.change.ReviewerModifier.newReviewerInputFromCommitIdentity;
 import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
 import static java.util.Objects.requireNonNull;
@@ -32,7 +32,6 @@
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.LabelTypes;
 import com.google.gerrit.entities.PatchSet;
@@ -46,13 +45,13 @@
 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.change.ReviewerAdder.InternalAddReviewerInput;
-import com.google.gerrit.server.change.ReviewerAdder.ReviewerAddition;
-import com.google.gerrit.server.change.ReviewerAdder.ReviewerAdditionList;
+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;
 import com.google.gerrit.server.config.SendEmailExecutor;
 import com.google.gerrit.server.config.UrlFormatter;
 import com.google.gerrit.server.events.CommitReceivedEvent;
@@ -74,6 +73,7 @@
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.Context;
 import com.google.gerrit.server.update.InsertChangeOp;
+import com.google.gerrit.server.update.PostUpdateContext;
 import com.google.gerrit.server.update.RepoContext;
 import com.google.gerrit.server.util.CommitMessageUtil;
 import com.google.gerrit.server.util.RequestScopePropagator;
@@ -112,7 +112,7 @@
   private final CommitValidators.Factory commitValidatorsFactory;
   private final RevisionCreated revisionCreated;
   private final CommentAdded commentAdded;
-  private final ReviewerAdder reviewerAdder;
+  private final ReviewerModifier reviewerModifier;
   private final MessageIdGenerator messageIdGenerator;
   private final DynamicItem<UrlFormatter> urlFormatter;
   private final AutoMerger autoMerger;
@@ -138,17 +138,17 @@
   private boolean sendMail;
   private boolean updateRef;
   private Change.Id revertOf;
-  private ImmutableList<InternalAddReviewerInput> reviewerInputs;
+  private ImmutableList<InternalReviewerInput> reviewerInputs;
 
   // Fields set during the insertion process.
   private ReceiveCommand cmd;
   private Change change;
-  private ChangeMessage changeMessage;
+  private String changeMessage;
   private PatchSetInfo patchSetInfo;
   private PatchSet patchSet;
   private String pushCert;
   private ProjectState projectState;
-  private ReviewerAdditionList reviewerAdditions;
+  private ReviewerModificationList reviewerAdditions;
 
   @Inject
   ChangeInserter(
@@ -163,7 +163,7 @@
       CommitValidators.Factory commitValidatorsFactory,
       CommentAdded commentAdded,
       RevisionCreated revisionCreated,
-      ReviewerAdder reviewerAdder,
+      ReviewerModifier reviewerModifier,
       MessageIdGenerator messageIdGenerator,
       DynamicItem<UrlFormatter> urlFormatter,
       AutoMerger autoMerger,
@@ -181,7 +181,7 @@
     this.commitValidatorsFactory = commitValidatorsFactory;
     this.revisionCreated = revisionCreated;
     this.commentAdded = commentAdded;
-    this.reviewerAdder = reviewerAdder;
+    this.reviewerModifier = reviewerModifier;
     this.messageIdGenerator = messageIdGenerator;
     this.urlFormatter = urlFormatter;
     this.autoMerger = autoMerger;
@@ -280,8 +280,8 @@
         Streams.concat(
                 Streams.stream(reviewers)
                     .distinct()
-                    .map(id -> newAddReviewerInput(id, ReviewerState.REVIEWER)),
-                Streams.stream(ccs).distinct().map(id -> newAddReviewerInput(id, ReviewerState.CC)))
+                    .map(id -> newReviewerInput(id, ReviewerState.REVIEWER)),
+                Streams.stream(ccs).distinct().map(id -> newReviewerInput(id, ReviewerState.CC)))
             .collect(toImmutableList());
     return this;
   }
@@ -361,7 +361,7 @@
     return this;
   }
 
-  public ChangeMessage getChangeMessage() {
+  public String getChangeMessage() {
     if (message == null) {
       return null;
     }
@@ -443,8 +443,9 @@
     }
 
     reviewerAdditions =
-        reviewerAdder.prepare(ctx.getNotes(), ctx.getUser(), getReviewerInputs(), true);
-    Optional<ReviewerAddition> reviewerError = reviewerAdditions.getFailures().stream().findFirst();
+        reviewerModifier.prepare(ctx.getNotes(), ctx.getUser(), getReviewerInputs(), true);
+    Optional<ReviewerModification> reviewerError =
+        reviewerAdditions.getFailures().stream().findFirst();
     if (reviewerError.isPresent()) {
       throw new UnprocessableEntityException(reviewerError.get().result.error);
     }
@@ -463,19 +464,14 @@
     }
     if (message != null) {
       changeMessage =
-          ChangeMessagesUtil.newMessage(
-              patchSet.id(),
-              ctx.getUser(),
-              patchSet.createdOn(),
-              message,
-              ChangeMessagesUtil.uploadedPatchSetTag(workInProgress));
-      cmUtil.addChangeMessage(update, changeMessage);
+          cmUtil.setChangeMessage(
+              update, message, ChangeMessagesUtil.uploadedPatchSetTag(workInProgress));
     }
     return true;
   }
 
   @Override
-  public void postUpdate(Context ctx) throws Exception {
+  public void postUpdate(PostUpdateContext ctx) throws Exception {
     reviewerAdditions.postUpdate(ctx);
     NotifyResolver.Result notify = ctx.getNotify(change.getId());
     if (sendMail && notify.shouldNotify()) {
@@ -528,7 +524,8 @@
      * show a transition from an oldValue of 0 to the new value.
      */
     if (fireRevisionCreated) {
-      revisionCreated.fire(change, patchSet, ctx.getAccount(), ctx.getWhen(), notify);
+      revisionCreated.fire(
+          ctx.getChangeData(change), patchSet, ctx.getAccount(), ctx.getWhen(), notify);
       if (approvals != null && !approvals.isEmpty()) {
         List<LabelType> labels = projectState.getLabelTypes(change.getDest()).getLabelTypes();
         Map<String, Short> allApprovals = new HashMap<>();
@@ -544,7 +541,13 @@
           }
         }
         commentAdded.fire(
-            change, patchSet, ctx.getAccount(), null, allApprovals, oldApprovals, ctx.getWhen());
+            ctx.getChangeData(change),
+            patchSet,
+            ctx.getAccount(),
+            null,
+            allApprovals,
+            oldApprovals,
+            ctx.getWhen());
       }
     }
   }
@@ -580,35 +583,34 @@
     }
   }
 
-  private static InternalAddReviewerInput newAddReviewerInput(
-      String reviewer, ReviewerState state) {
+  private static InternalReviewerInput newReviewerInput(String reviewer, ReviewerState state) {
     // Disable individual emails when adding reviewers, as all reviewers will receive the single
     // bulk new change email.
-    InternalAddReviewerInput input =
-        ReviewerAdder.newAddReviewerInput(reviewer, state, NotifyHandling.NONE);
+    InternalReviewerInput input =
+        ReviewerModifier.newReviewerInput(reviewer, state, NotifyHandling.NONE);
 
     // Ignore failures for reasons like the reviewer being inactive or being unable to see the
     // change. This is required for the push path, where it automatically sets reviewers from
     // certain commit footers: putting a nonexistent user in a footer should not cause an error. In
     // theory we could provide finer control to do this for some reviewers and not others, but it's
     // not worth complicating the ChangeInserter interface further at this time.
-    input.otherFailureBehavior = ReviewerAdder.FailureBehavior.IGNORE;
+    input.otherFailureBehavior = ReviewerModifier.FailureBehavior.IGNORE;
 
     return input;
   }
 
-  private ImmutableList<InternalAddReviewerInput> getReviewerInputs() {
+  private ImmutableList<InternalReviewerInput> getReviewerInputs() {
     return Streams.concat(
             reviewerInputs.stream(),
             Streams.stream(
-                newAddReviewerInputFromCommitIdentity(
+                newReviewerInputFromCommitIdentity(
                     change,
                     patchSetInfo.getCommitId(),
                     patchSetInfo.getAuthor().getAccount(),
                     NotifyHandling.NONE,
                     change.getOwner())),
             Streams.stream(
-                newAddReviewerInputFromCommitIdentity(
+                newReviewerInputFromCommitIdentity(
                     change,
                     patchSetInfo.getCommitId(),
                     patchSetInfo.getCommitter().getAccount(),
diff --git a/java/com/google/gerrit/server/change/ChangeMessages.java b/java/com/google/gerrit/server/change/ChangeMessages.java
index 6f2e1ef..787f036 100644
--- a/java/com/google/gerrit/server/change/ChangeMessages.java
+++ b/java/com/google/gerrit/server/change/ChangeMessages.java
@@ -31,6 +31,7 @@
   public String reviewerInvalid;
   public String reviewerNotFoundUserOrGroup;
 
+  public String groupRemovalIsNotAllowed;
   public String groupIsNotAllowed;
   public String groupHasTooManyMembers;
   public String groupManyMembersConfirmation;
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/DeleteChangeOp.java b/java/com/google/gerrit/server/change/DeleteChangeOp.java
index 14298d5..c7ddf19 100644
--- a/java/com/google/gerrit/server/change/DeleteChangeOp.java
+++ b/java/com/google/gerrit/server/change/DeleteChangeOp.java
@@ -27,6 +27,7 @@
 import com.google.gerrit.server.StarredChangesUtil;
 import com.google.gerrit.server.extensions.events.ChangeDeleted;
 import com.google.gerrit.server.plugincontext.PluginItemContext;
+import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.RepoContext;
@@ -49,6 +50,7 @@
   private final PatchSetUtil psUtil;
   private final StarredChangesUtil starredChangesUtil;
   private final PluginItemContext<AccountPatchReviewStore> accountPatchReviewStore;
+  private final ChangeData.Factory changeDataFactory;
   private final ChangeDeleted changeDeleted;
   private final Change.Id id;
 
@@ -57,11 +59,13 @@
       PatchSetUtil psUtil,
       StarredChangesUtil starredChangesUtil,
       PluginItemContext<AccountPatchReviewStore> accountPatchReviewStore,
+      ChangeData.Factory changeDataFactory,
       ChangeDeleted changeDeleted,
       @Assisted Change.Id id) {
     this.psUtil = psUtil;
     this.starredChangesUtil = starredChangesUtil;
     this.accountPatchReviewStore = accountPatchReviewStore;
+    this.changeDataFactory = changeDataFactory;
     this.changeDeleted = changeDeleted;
     this.id = id;
   }
@@ -90,7 +94,7 @@
                     .map(p -> p.commitId().name())
                     .orElse("n/a")));
     ctx.deleteChange();
-    changeDeleted.fire(ctx.getChange(), ctx.getAccount(), ctx.getWhen());
+    changeDeleted.fire(changeDataFactory.create(ctx.getChange()), ctx.getAccount(), ctx.getWhen());
     return true;
   }
 
diff --git a/java/com/google/gerrit/server/change/DeleteReviewerByEmailOp.java b/java/com/google/gerrit/server/change/DeleteReviewerByEmailOp.java
index 255e13a..b512a2d 100644
--- a/java/com/google/gerrit/server/change/DeleteReviewerByEmailOp.java
+++ b/java/com/google/gerrit/server/change/DeleteReviewerByEmailOp.java
@@ -17,19 +17,18 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Address;
 import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.entities.PatchSet;
-import com.google.gerrit.server.ChangeUtil;
+import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.mail.send.DeleteReviewerSender;
 import com.google.gerrit.server.mail.send.MessageIdGenerator;
-import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.update.ChangeContext;
-import com.google.gerrit.server.update.Context;
+import com.google.gerrit.server.update.PostUpdateContext;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 import java.util.Collections;
 
-public class DeleteReviewerByEmailOp implements BatchUpdateOp {
+public class DeleteReviewerByEmailOp extends ReviewerOp {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   public interface Factory {
@@ -38,19 +37,21 @@
 
   private final DeleteReviewerSender.Factory deleteReviewerSenderFactory;
   private final MessageIdGenerator messageIdGenerator;
+  private final ChangeMessagesUtil changeMessagesUtil;
 
   private final Address reviewer;
-
-  private ChangeMessage changeMessage;
+  private String mailMessage;
   private Change change;
 
   @Inject
   DeleteReviewerByEmailOp(
       DeleteReviewerSender.Factory deleteReviewerSenderFactory,
       MessageIdGenerator messageIdGenerator,
+      ChangeMessagesUtil changeMessagesUtil,
       @Assisted Address reviewer) {
     this.deleteReviewerSenderFactory = deleteReviewerSenderFactory;
     this.messageIdGenerator = messageIdGenerator;
+    this.changeMessagesUtil = changeMessagesUtil;
     this.reviewer = reviewer;
   }
 
@@ -58,38 +59,38 @@
   public boolean updateChange(ChangeContext ctx) {
     change = ctx.getChange();
     PatchSet.Id psId = ctx.getChange().currentPatchSetId();
+    ChangeUpdate update = ctx.getUpdate(psId);
+    update.removeReviewerByEmail(reviewer);
+    // The reviewer is not a registered Gerrit user, thus the email address can be used in
+    // ChangeMessage without replacement (it does not classify as Gerrit user identifiable
+    // information).
     String msg = "Removed reviewer " + reviewer;
-    changeMessage =
-        new ChangeMessage(
-            ChangeMessage.key(change.getId(), ChangeUtil.messageUuid()),
-            ctx.getAccountId(),
-            ctx.getWhen(),
-            psId);
-    changeMessage.setMessage(msg);
-
-    ctx.getUpdate(psId).setChangeMessage(msg);
-    ctx.getUpdate(psId).removeReviewerByEmail(reviewer);
+    mailMessage =
+        changeMessagesUtil.setChangeMessage(ctx, msg, ChangeMessagesUtil.TAG_DELETE_REVIEWER);
     return true;
   }
 
   @Override
-  public void postUpdate(Context ctx) {
-    try {
-      NotifyResolver.Result notify = ctx.getNotify(change.getId());
-      if (!notify.shouldNotify()) {
-        return;
+  public void postUpdate(PostUpdateContext ctx) {
+    opResult = Result.builder().setDeletedReviewerByEmail(reviewer).build();
+    if (sendEmail) {
+      try {
+        NotifyResolver.Result notify = ctx.getNotify(change.getId());
+        if (!notify.shouldNotify()) {
+          return;
+        }
+        DeleteReviewerSender emailSender =
+            deleteReviewerSenderFactory.create(ctx.getProject(), change.getId());
+        emailSender.setFrom(ctx.getAccountId());
+        emailSender.addReviewersByEmail(Collections.singleton(reviewer));
+        emailSender.setChangeMessage(mailMessage, ctx.getWhen());
+        emailSender.setNotify(notify);
+        emailSender.setMessageId(
+            messageIdGenerator.fromChangeUpdate(ctx.getRepoView(), change.currentPatchSetId()));
+        emailSender.send();
+      } catch (Exception err) {
+        logger.atSevere().withCause(err).log("Cannot email update for change %s", change.getId());
       }
-      DeleteReviewerSender emailSender =
-          deleteReviewerSenderFactory.create(ctx.getProject(), change.getId());
-      emailSender.setFrom(ctx.getAccountId());
-      emailSender.addReviewersByEmail(Collections.singleton(reviewer));
-      emailSender.setChangeMessage(changeMessage.getMessage(), changeMessage.getWrittenOn());
-      emailSender.setNotify(notify);
-      emailSender.setMessageId(
-          messageIdGenerator.fromChangeUpdate(ctx.getRepoView(), change.currentPatchSetId()));
-      emailSender.send();
-    } catch (Exception err) {
-      logger.atSevere().withCause(err).log("Cannot email update for change %s", change.getId());
     }
   }
 }
diff --git a/java/com/google/gerrit/server/change/DeleteReviewerOp.java b/java/com/google/gerrit/server/change/DeleteReviewerOp.java
index bf00d27..3a12ad4 100644
--- a/java/com/google/gerrit/server/change/DeleteReviewerOp.java
+++ b/java/com/google/gerrit/server/change/DeleteReviewerOp.java
@@ -20,10 +20,8 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.LabelTypes;
-import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.exceptions.EmailException;
@@ -31,11 +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;
@@ -44,42 +43,41 @@
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.RemoveReviewerControl;
-import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
-import com.google.gerrit.server.update.Context;
+import com.google.gerrit.server.update.PostUpdateContext;
 import com.google.gerrit.server.update.RepoView;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
 import java.io.IOException;
+import java.sql.Timestamp;
 import java.util.Collections;
 import java.util.HashMap;
 import java.util.Map;
 
-public class DeleteReviewerOp implements BatchUpdateOp {
+public class DeleteReviewerOp extends ReviewerOp {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   public interface Factory {
-    DeleteReviewerOp create(AccountState reviewerAccount, DeleteReviewerInput input);
+    DeleteReviewerOp create(Account reviewerAccount, DeleteReviewerInput input);
   }
 
   private final ApprovalsUtil approvalsUtil;
   private final PatchSetUtil psUtil;
   private final ChangeMessagesUtil cmUtil;
-  private final IdentifiedUser.GenericFactory userFactory;
   private final ReviewerDeleted reviewerDeleted;
   private final Provider<IdentifiedUser> user;
   private final DeleteReviewerSender.Factory deleteReviewerSenderFactory;
   private final RemoveReviewerControl removeReviewerControl;
   private final ProjectCache projectCache;
   private final MessageIdGenerator messageIdGenerator;
+  private final AccountCache accountCache;
 
-  private final AccountState reviewer;
+  private final Account reviewer;
   private final DeleteReviewerInput input;
 
-  ChangeMessage changeMessage;
+  String mailMessage;
   Change currChange;
-  PatchSet currPs;
   Map<String, Short> newApprovals = new HashMap<>();
   Map<String, Short> oldApprovals = new HashMap<>();
 
@@ -88,25 +86,25 @@
       ApprovalsUtil approvalsUtil,
       PatchSetUtil psUtil,
       ChangeMessagesUtil cmUtil,
-      IdentifiedUser.GenericFactory userFactory,
       ReviewerDeleted reviewerDeleted,
       Provider<IdentifiedUser> user,
       DeleteReviewerSender.Factory deleteReviewerSenderFactory,
       RemoveReviewerControl removeReviewerControl,
       ProjectCache projectCache,
       MessageIdGenerator messageIdGenerator,
-      @Assisted AccountState reviewerAccount,
+      AccountCache accountCache,
+      @Assisted Account reviewerAccount,
       @Assisted DeleteReviewerInput input) {
     this.approvalsUtil = approvalsUtil;
     this.psUtil = psUtil;
     this.cmUtil = cmUtil;
-    this.userFactory = userFactory;
     this.reviewerDeleted = reviewerDeleted;
     this.user = user;
     this.deleteReviewerSenderFactory = deleteReviewerSenderFactory;
     this.removeReviewerControl = removeReviewerControl;
     this.projectCache = projectCache;
     this.messageIdGenerator = messageIdGenerator;
+    this.accountCache = accountCache;
     this.reviewer = reviewerAccount;
     this.input = input;
   }
@@ -114,15 +112,18 @@
   @Override
   public boolean updateChange(ChangeContext ctx)
       throws AuthException, ResourceNotFoundException, PermissionBackendException, IOException {
-    Account.Id reviewerId = reviewer.account().id();
+    Account.Id reviewerId = reviewer.id();
     // Check of removing this reviewer (even if there is no vote processed by the loop below) is OK
     removeReviewerControl.checkRemoveReviewer(ctx.getNotes(), ctx.getUser(), reviewerId);
 
     if (!approvalsUtil.getReviewers(ctx.getNotes()).all().contains(reviewerId)) {
-      throw new ResourceNotFoundException();
+      throw new ResourceNotFoundException(
+          String.format(
+              "Reviewer %s doesn't exist in the change, hence can't delete it",
+              reviewer.getName()));
     }
     currChange = ctx.getChange();
-    currPs = psUtil.current(ctx.getNotes());
+    setPatchSet(psUtil.current(ctx.getNotes()));
 
     LabelTypes labelTypes =
         projectCache
@@ -141,21 +142,23 @@
             ? "cc"
             : "reviewer";
     StringBuilder msg = new StringBuilder();
-    msg.append(String.format("Removed %s %s", ccOrReviewer, reviewer.account().fullName()));
+    msg.append(
+        String.format(
+            "Removed %s %s", ccOrReviewer, ChangeMessagesUtil.getAccountTemplate(reviewer.id())));
     StringBuilder removedVotesMsg = new StringBuilder();
     removedVotesMsg.append(" with the following votes:\n\n");
     boolean votesRemoved = false;
     for (PatchSetApproval a : approvals(ctx, reviewerId)) {
       // Check if removing this vote is OK
       removeReviewerControl.checkRemoveReviewer(ctx.getNotes(), ctx.getUser(), a);
-      if (a.patchSetId().equals(currPs.id()) && a.value() != 0) {
+      if (a.patchSetId().equals(patchSet.id()) && a.value() != 0) {
         oldApprovals.put(a.label(), a.value());
         removedVotesMsg
             .append("* ")
             .append(a.label())
             .append(formatLabelValue(a.value()))
             .append(" by ")
-            .append(userFactory.create(a.accountId()).getNameEmail())
+            .append(ChangeMessagesUtil.getAccountTemplate(a.accountId()))
             .append("\n");
         votesRemoved = true;
       }
@@ -166,40 +169,44 @@
     } else {
       msg.append(".");
     }
-    ChangeUpdate update = ctx.getUpdate(currPs.id());
+    ChangeUpdate update = ctx.getUpdate(patchSet.id());
     update.removeReviewer(reviewerId);
 
-    changeMessage =
-        ChangeMessagesUtil.newMessage(ctx, msg.toString(), ChangeMessagesUtil.TAG_DELETE_REVIEWER);
-    cmUtil.addChangeMessage(update, changeMessage);
-
+    mailMessage =
+        cmUtil.setChangeMessage(ctx, msg.toString(), ChangeMessagesUtil.TAG_DELETE_REVIEWER);
     return true;
   }
 
   @Override
-  public void postUpdate(Context ctx) {
+  public void postUpdate(PostUpdateContext ctx) {
+    opResult = Result.builder().setDeletedReviewer(reviewer.id()).build();
+
     NotifyResolver.Result notify = ctx.getNotify(currChange.getId());
-    if (input.notify == null
-        && currChange.isWorkInProgress()
-        && !oldApprovals.isEmpty()
-        && notify.handling().compareTo(NotifyHandling.OWNER) < 0) {
-      // Override NotifyHandling from the context to notify owner if votes were removed on a WIP
-      // change.
-      notify = notify.withHandling(NotifyHandling.OWNER);
-    }
-    try {
-      if (notify.shouldNotify()) {
-        emailReviewers(ctx.getProject(), currChange, changeMessage, notify, ctx.getRepoView());
+    if (sendEmail) {
+      if (input.notify == null
+          && currChange.isWorkInProgress()
+          && !oldApprovals.isEmpty()
+          && notify.handling().compareTo(NotifyHandling.OWNER) < 0) {
+        // Override NotifyHandling from the context to notify owner if votes were removed on a WIP
+        // change.
+        notify = notify.withHandling(NotifyHandling.OWNER);
       }
-    } catch (Exception err) {
-      logger.atSevere().withCause(err).log("Cannot email update for change %s", currChange.getId());
+      try {
+        if (notify.shouldNotify()) {
+          emailReviewers(
+              ctx.getProject(), currChange, mailMessage, ctx.getWhen(), notify, ctx.getRepoView());
+        }
+      } catch (Exception err) {
+        logger.atSevere().withCause(err).log(
+            "Cannot email update for change %s", currChange.getId());
+      }
     }
     reviewerDeleted.fire(
-        currChange,
-        currPs,
-        reviewer,
+        ctx.getChangeData(currChange),
+        patchSet,
+        accountCache.get(reviewer.id()).orElse(AccountState.forAccount(reviewer)),
         ctx.getAccount(),
-        changeMessage.getMessage(),
+        mailMessage,
         newApprovals,
         oldApprovals,
         notify.handling(),
@@ -222,20 +229,21 @@
   private void emailReviewers(
       Project.NameKey projectName,
       Change change,
-      ChangeMessage changeMessage,
+      String mailMessage,
+      Timestamp timestamp,
       NotifyResolver.Result notify,
       RepoView repoView)
       throws EmailException {
     Account.Id userId = user.get().getAccountId();
-    if (userId.equals(reviewer.account().id())) {
+    if (userId.equals(reviewer.id())) {
       // The user knows they removed themselves, don't bother emailing them.
       return;
     }
     DeleteReviewerSender emailSender =
         deleteReviewerSenderFactory.create(projectName, change.getId());
     emailSender.setFrom(userId);
-    emailSender.addReviewers(Collections.singleton(reviewer.account().id()));
-    emailSender.setChangeMessage(changeMessage.getMessage(), changeMessage.getWrittenOn());
+    emailSender.addReviewers(Collections.singleton(reviewer.id()));
+    emailSender.setChangeMessage(mailMessage, timestamp);
     emailSender.setNotify(notify);
     emailSender.setMessageId(
         messageIdGenerator.fromChangeUpdate(repoView, change.currentPatchSetId()));
diff --git a/java/com/google/gerrit/server/change/DeleteReviewersUtil.java b/java/com/google/gerrit/server/change/DeleteReviewersUtil.java
new file mode 100644
index 0000000..3212c8d
--- /dev/null
+++ b/java/com/google/gerrit/server/change/DeleteReviewersUtil.java
@@ -0,0 +1,85 @@
+// 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.entities.Account;
+import com.google.gerrit.entities.Address;
+import com.google.gerrit.extensions.api.changes.DeleteReviewerInput;
+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.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;
+import java.io.IOException;
+import java.util.Collection;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+
+public class DeleteReviewersUtil {
+  private final DeleteReviewerOp.Factory deleteReviewerOpFactory;
+  private final DeleteReviewerByEmailOp.Factory deleteReviewerByEmailOpFactory;
+  private final AccountResolver accountResolver;
+  private final ApprovalsUtil approvalsUtil;
+
+  @Inject
+  DeleteReviewersUtil(
+      DeleteReviewerOp.Factory deleteReviewerOpFactory,
+      DeleteReviewerByEmailOp.Factory deleteReviewerByEmailOpFactory,
+      AccountResolver accountResolver,
+      ApprovalsUtil approvalsUtil) {
+    this.deleteReviewerOpFactory = deleteReviewerOpFactory;
+    this.deleteReviewerByEmailOpFactory = deleteReviewerByEmailOpFactory;
+    this.accountResolver = accountResolver;
+    this.approvalsUtil = approvalsUtil;
+  }
+
+  public void addDeleteReviewerOpToBatchUpdate(
+      BatchUpdate batchUpdate, ChangeNotes changeNotes, ReviewerInput reviewerInput)
+      throws IOException, ConfigInvalidException, AuthException, ResourceNotFoundException {
+
+    try {
+      AccountResolver.Result result =
+          accountResolver.resolveIgnoreVisibility(reviewerInput.reviewer);
+      if (fetchAccountIds(changeNotes).contains(result.asUniqueUser().getAccountId())) {
+        DeleteReviewerInput deleteReviewerInput = new DeleteReviewerInput();
+        deleteReviewerInput.notify = reviewerInput.notify;
+        deleteReviewerInput.notifyDetails = reviewerInput.notifyDetails;
+        batchUpdate.addOp(
+            changeNotes.getChangeId(),
+            deleteReviewerOpFactory.create(result.asUnique().account(), deleteReviewerInput));
+        return;
+      } else {
+        return;
+      }
+    } catch (AccountResolver.UnresolvableAccountException e) {
+      if (e.isSelf()) {
+        throw new AuthException(e.getMessage(), e);
+      }
+    }
+    Address address = Address.tryParse(reviewerInput.reviewer);
+    if (address != null && changeNotes.getReviewersByEmail().all().contains(address)) {
+      batchUpdate.addOp(changeNotes.getChangeId(), deleteReviewerByEmailOpFactory.create(address));
+      return;
+    }
+
+    throw new ResourceNotFoundException(reviewerInput.reviewer);
+  }
+
+  private Collection<Account.Id> fetchAccountIds(ChangeNotes changeNotes) {
+    return approvalsUtil.getReviewers(changeNotes).all();
+  }
+}
diff --git a/java/com/google/gerrit/server/change/EmailReviewComments.java b/java/com/google/gerrit/server/change/EmailReviewComments.java
index cacfbe7..d433c4e 100644
--- a/java/com/google/gerrit/server/change/EmailReviewComments.java
+++ b/java/com/google/gerrit/server/change/EmailReviewComments.java
@@ -18,7 +18,6 @@
 
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.entities.Comment;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.server.CurrentUser;
@@ -34,6 +33,7 @@
 import com.google.gerrit.server.util.ThreadLocalRequestContext;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
+import java.sql.Timestamp;
 import java.util.List;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Future;
@@ -49,9 +49,9 @@
      * @param notes change notes.
      * @param patchSet patch set corresponding to the top-level op
      * @param user user the email should come from.
-     * @param message used by text template only: the full ChangeMessage that will go in the
-     *     database. The contents of this message typically include the "Patch set N" header and "(M
-     *     comments)".
+     * @param message used by text template only. The contents of this message typically include the
+     *     "Patch set N" header and "(M comments)".
+     * @param timestamp timestamp when the comments were added.
      * @param comments inline comments.
      * @param patchSetComment used by HTML template only: some quasi-human-generated text. The
      *     contents should *not* include a "Patch set N" header or "(M comments)" footer, as these
@@ -64,9 +64,10 @@
         ChangeNotes notes,
         PatchSet patchSet,
         IdentifiedUser user,
-        ChangeMessage message,
+        @Assisted("message") String message,
+        Timestamp timestamp,
         List<? extends Comment> comments,
-        String patchSetComment,
+        @Assisted("patchSetComment") String patchSetComment,
         List<LabelVote> labels,
         RepoView repoView);
   }
@@ -81,7 +82,8 @@
   private final ChangeNotes notes;
   private final PatchSet patchSet;
   private final IdentifiedUser user;
-  private final ChangeMessage message;
+  private final String message;
+  private final Timestamp timestamp;
   private final List<? extends Comment> comments;
   private final String patchSetComment;
   private final List<LabelVote> labels;
@@ -98,9 +100,10 @@
       @Assisted ChangeNotes notes,
       @Assisted PatchSet patchSet,
       @Assisted IdentifiedUser user,
-      @Assisted ChangeMessage message,
+      @Assisted("message") String message,
+      @Assisted Timestamp timestamp,
       @Assisted List<? extends Comment> comments,
-      @Nullable @Assisted String patchSetComment,
+      @Nullable @Assisted("patchSetComment") String patchSetComment,
       @Assisted List<LabelVote> labels,
       @Assisted RepoView repoView) {
     this.sendEmailsExecutor = executor;
@@ -113,6 +116,7 @@
     this.patchSet = patchSet;
     this.user = user;
     this.message = message;
+    this.timestamp = timestamp;
     this.comments = COMMENT_ORDER.sortedCopy(comments);
     this.patchSetComment = patchSetComment;
     this.labels = labels;
@@ -132,7 +136,7 @@
           commentSenderFactory.create(notes.getProjectName(), notes.getChangeId());
       emailSender.setFrom(user.getAccountId());
       emailSender.setPatchSet(patchSet, patchSetInfoFactory.get(notes.getProjectName(), patchSet));
-      emailSender.setChangeMessage(message.getMessage(), message.getWrittenOn());
+      emailSender.setChangeMessage(message, timestamp);
       emailSender.setComments(comments);
       emailSender.setPatchSetComment(patchSetComment);
       emailSender.setLabels(labels);
diff --git a/java/com/google/gerrit/server/change/FileInfoJsonComparingImpl.java b/java/com/google/gerrit/server/change/FileInfoJsonComparingImpl.java
index 228d631..a926147 100644
--- a/java/com/google/gerrit/server/change/FileInfoJsonComparingImpl.java
+++ b/java/com/google/gerrit/server/change/FileInfoJsonComparingImpl.java
@@ -155,6 +155,13 @@
       return;
     }
     metrics.diffs.increment(Metrics.Status.MISMATCH);
-    logger.atWarning().log(warningMessage);
+    logger.atWarning().log(
+        warningMessage
+            + "\n"
+            + "Result using old impl: "
+            + fileInfoMapOld
+            + "\n"
+            + "Result using new impl: "
+            + fileInfoMapNew);
   }
 }
diff --git a/java/com/google/gerrit/server/change/AddReviewersEmail.java b/java/com/google/gerrit/server/change/ModifyReviewersEmail.java
similarity index 80%
rename from java/com/google/gerrit/server/change/AddReviewersEmail.java
rename to java/com/google/gerrit/server/change/ModifyReviewersEmail.java
index 4a3f638..cb747f6 100644
--- a/java/com/google/gerrit/server/change/AddReviewersEmail.java
+++ b/java/com/google/gerrit/server/change/ModifyReviewersEmail.java
@@ -24,8 +24,8 @@
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.config.SendEmailExecutor;
-import com.google.gerrit.server.mail.send.AddReviewerSender;
 import com.google.gerrit.server.mail.send.MessageIdGenerator;
+import com.google.gerrit.server.mail.send.ModifyReviewerSender;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.util.Collection;
@@ -33,16 +33,16 @@
 import java.util.concurrent.Future;
 
 @Singleton
-public class AddReviewersEmail {
+public class ModifyReviewersEmail {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  private final AddReviewerSender.Factory addReviewerSenderFactory;
+  private final ModifyReviewerSender.Factory addReviewerSenderFactory;
   private final ExecutorService sendEmailsExecutor;
   private final MessageIdGenerator messageIdGenerator;
 
   @Inject
-  AddReviewersEmail(
-      AddReviewerSender.Factory addReviewerSenderFactory,
+  ModifyReviewersEmail(
+      ModifyReviewerSender.Factory addReviewerSenderFactory,
       @SendEmailExecutor ExecutorService sendEmailsExecutor,
       MessageIdGenerator messageIdGenerator) {
     this.addReviewerSenderFactory = addReviewerSenderFactory;
@@ -55,19 +55,25 @@
       Change change,
       Collection<Account.Id> added,
       Collection<Account.Id> copied,
+      Collection<Account.Id> removed,
       Collection<Address> addedByEmail,
       Collection<Address> copiedByEmail,
+      Collection<Address> removedByEmail,
       NotifyResolver.Result notify) {
-    // The user knows they added themselves, don't bother emailing them.
+    // The user knows they added/removed themselves, don't bother emailing them.
     Account.Id userId = user.getAccountId();
     ImmutableList<Account.Id> immutableToMail =
         added.stream().filter(id -> !id.equals(userId)).collect(toImmutableList());
     ImmutableList<Account.Id> immutableToCopy =
         copied.stream().filter(id -> !id.equals(userId)).collect(toImmutableList());
+    ImmutableList<Account.Id> immutableToRemove =
+        removed.stream().filter(id -> !id.equals(userId)).collect(toImmutableList());
     if (immutableToMail.isEmpty()
         && immutableToCopy.isEmpty()
+        && immutableToRemove.isEmpty()
         && addedByEmail.isEmpty()
-        && copiedByEmail.isEmpty()) {
+        && copiedByEmail.isEmpty()
+        && removedByEmail.isEmpty()) {
       return;
     }
 
@@ -77,13 +83,14 @@
     Project.NameKey projectNameKey = change.getProject();
     ImmutableList<Address> immutableAddedByEmail = ImmutableList.copyOf(addedByEmail);
     ImmutableList<Address> immutableCopiedByEmail = ImmutableList.copyOf(copiedByEmail);
+    ImmutableList<Address> immutableRemovedByEmail = ImmutableList.copyOf(removedByEmail);
 
     @SuppressWarnings("unused")
     Future<?> possiblyIgnoredError =
         sendEmailsExecutor.submit(
             () -> {
               try {
-                AddReviewerSender emailSender =
+                ModifyReviewerSender emailSender =
                     addReviewerSenderFactory.create(projectNameKey, cId);
                 emailSender.setNotify(notify);
                 emailSender.setFrom(userId);
@@ -91,6 +98,8 @@
                 emailSender.addReviewersByEmail(immutableAddedByEmail);
                 emailSender.addExtraCC(immutableToCopy);
                 emailSender.addExtraCCByEmail(immutableCopiedByEmail);
+                emailSender.addRemovedReviewers(immutableToRemove);
+                emailSender.addRemovedByEmailReviewers(immutableRemovedByEmail);
                 emailSender.setMessageId(
                     messageIdGenerator.fromChangeUpdate(
                         change.getProject(), change.currentPatchSetId()));
diff --git a/java/com/google/gerrit/server/change/PatchSetInserter.java b/java/com/google/gerrit/server/change/PatchSetInserter.java
index d2bf3fe..d25dba0 100644
--- a/java/com/google/gerrit/server/change/PatchSetInserter.java
+++ b/java/com/google/gerrit/server/change/PatchSetInserter.java
@@ -23,18 +23,17 @@
 import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetInfo;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 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;
@@ -53,7 +52,7 @@
 import com.google.gerrit.server.ssh.NoSshInfo;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
-import com.google.gerrit.server.update.Context;
+import com.google.gerrit.server.update.PostUpdateContext;
 import com.google.gerrit.server.update.RepoContext;
 import com.google.gerrit.server.validators.ValidationException;
 import com.google.inject.Inject;
@@ -110,7 +109,7 @@
   private Change change;
   private PatchSet patchSet;
   private PatchSetInfo patchSetInfo;
-  private ChangeMessage changeMessage;
+  private String mailMessage;
   private ReviewerSet oldReviewers;
   private boolean oldWorkInProgressState;
 
@@ -260,14 +259,9 @@
     }
 
     if (message != null) {
-      changeMessage =
-          ChangeMessagesUtil.newMessage(
-              patchSet.id(),
-              ctx.getUser(),
-              ctx.getWhen(),
-              message,
-              ChangeMessagesUtil.uploadedPatchSetTag(change.isWorkInProgress()));
-      changeMessage.setMessage(message);
+      mailMessage =
+          cmUtil.setChangeMessage(
+              update, message, ChangeMessagesUtil.uploadedPatchSetTag(change.isWorkInProgress()));
     }
 
     oldWorkInProgressState = change.isWorkInProgress();
@@ -283,9 +277,6 @@
       change.setStatus(Change.Status.NEW);
     }
     change.setCurrentPatchSet(patchSetInfo);
-    if (changeMessage != null) {
-      cmUtil.addChangeMessage(update, changeMessage);
-    }
     if (topic != null) {
       change.setTopic(topic);
       try {
@@ -298,16 +289,16 @@
   }
 
   @Override
-  public void postUpdate(Context ctx) {
+  public void postUpdate(PostUpdateContext ctx) {
     NotifyResolver.Result notify = ctx.getNotify(change.getId());
     if (notify.shouldNotify() && sendEmail) {
-      requireNonNull(changeMessage);
+      requireNonNull(mailMessage);
       try {
         ReplacePatchSetSender emailSender =
             replacePatchSetFactory.create(ctx.getProject(), change.getId());
         emailSender.setFrom(ctx.getAccountId());
         emailSender.setPatchSet(patchSet, patchSetInfo);
-        emailSender.setChangeMessage(changeMessage.getMessage(), ctx.getWhen());
+        emailSender.setChangeMessage(mailMessage, ctx.getWhen());
         emailSender.addReviewers(oldReviewers.byState(REVIEWER));
         emailSender.addExtraCC(oldReviewers.byState(CC));
         emailSender.setNotify(notify);
@@ -321,11 +312,12 @@
     }
 
     if (fireRevisionCreated) {
-      revisionCreated.fire(change, patchSet, ctx.getAccount(), ctx.getWhen(), notify);
+      revisionCreated.fire(
+          ctx.getChangeData(change), patchSet, ctx.getAccount(), ctx.getWhen(), notify);
     }
 
     if (workInProgress != null && oldWorkInProgressState != workInProgress) {
-      wipStateChanged.fire(change, patchSet, ctx.getAccount(), ctx.getWhen());
+      wipStateChanged.fire(ctx.getChangeData(change), patchSet, ctx.getAccount(), ctx.getWhen());
     }
   }
 
diff --git a/java/com/google/gerrit/server/change/RebaseChangeOp.java b/java/com/google/gerrit/server/change/RebaseChangeOp.java
index b43996e..4952464 100644
--- a/java/com/google/gerrit/server/change/RebaseChangeOp.java
+++ b/java/com/google/gerrit/server/change/RebaseChangeOp.java
@@ -41,7 +41,7 @@
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
-import com.google.gerrit.server.update.Context;
+import com.google.gerrit.server.update.PostUpdateContext;
 import com.google.gerrit.server.update.RepoContext;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
@@ -240,6 +240,8 @@
         patchSetInserter.setGroups(GroupCollector.getDefaultGroups(rebasedCommit));
       }
     }
+
+    ctx.getRevWalk().getObjectReader().getCreatedFromInserter().flush();
     patchSetInserter.updateRepo(ctx);
   }
 
@@ -270,7 +272,7 @@
   }
 
   @Override
-  public void postUpdate(Context ctx) {
+  public void postUpdate(PostUpdateContext ctx) {
     patchSetInserter.postUpdate(ctx);
   }
 
diff --git a/java/com/google/gerrit/server/change/RemoveFromAttentionSetOp.java b/java/com/google/gerrit/server/change/RemoveFromAttentionSetOp.java
index e532409..50ee9d4 100644
--- a/java/com/google/gerrit/server/change/RemoveFromAttentionSetOp.java
+++ b/java/com/google/gerrit/server/change/RemoveFromAttentionSetOp.java
@@ -28,7 +28,7 @@
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
-import com.google.gerrit.server.update.Context;
+import com.google.gerrit.server.update.PostUpdateContext;
 import com.google.gerrit.server.util.AttentionSetEmail;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
@@ -101,7 +101,7 @@
   }
 
   @Override
-  public void postUpdate(Context ctx) {
+  public void postUpdate(PostUpdateContext ctx) {
     if (!notify) {
       return;
     }
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/change/ReviewerAdder.java b/java/com/google/gerrit/server/change/ReviewerModifier.java
similarity index 69%
rename from java/com/google/gerrit/server/change/ReviewerAdder.java
rename to java/com/google/gerrit/server/change/ReviewerModifier.java
index 5d55b4d..f3c5193 100644
--- a/java/com/google/gerrit/server/change/ReviewerAdder.java
+++ b/java/com/google/gerrit/server/change/ReviewerModifier.java
@@ -19,12 +19,14 @@
 import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.common.collect.ImmutableSet.toImmutableSet;
 import static com.google.gerrit.extensions.client.ReviewerState.CC;
+import static com.google.gerrit.extensions.client.ReviewerState.REMOVED;
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
 import static java.util.Comparator.comparing;
 import static java.util.Objects.requireNonNull;
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Iterables;
 import com.google.common.collect.Lists;
 import com.google.common.collect.Ordering;
 import com.google.common.collect.Streams;
@@ -39,10 +41,11 @@
 import com.google.gerrit.entities.GroupDescription;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetApproval;
-import com.google.gerrit.extensions.api.changes.AddReviewerInput;
-import com.google.gerrit.extensions.api.changes.AddReviewerResult;
+import com.google.gerrit.extensions.api.changes.DeleteReviewerInput;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.api.changes.ReviewerInfo;
+import com.google.gerrit.extensions.api.changes.ReviewerInput;
+import com.google.gerrit.extensions.api.changes.ReviewerResult;
 import com.google.gerrit.extensions.client.ReviewerState;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
@@ -69,7 +72,7 @@
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.update.ChangeContext;
-import com.google.gerrit.server.update.Context;
+import com.google.gerrit.server.update.PostUpdateContext;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import java.io.IOException;
@@ -85,7 +88,7 @@
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
 
-public class ReviewerAdder {
+public class ReviewerModifier {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   public static final int DEFAULT_MAX_REVIEWERS_WITHOUT_CHECK = 10;
@@ -102,9 +105,9 @@
   }
 
   // TODO(dborowitz): Subclassing is not the right way to do this. We should instead use an internal
-  // type in the public interfaces of ReviewerAdder, rather than passing around the REST API type
+  // type in the public interfaces of ReviewerModifier, rather than passing around the REST API type
   // internally.
-  public static class InternalAddReviewerInput extends AddReviewerInput {
+  public static class InternalReviewerInput extends ReviewerInput {
     /**
      * Behavior when identifying reviewers fails for any reason <em>besides</em> the input not
      * resolving to an account/group/email.
@@ -112,22 +115,16 @@
     public FailureBehavior otherFailureBehavior = FailureBehavior.FAIL;
   }
 
-  public static InternalAddReviewerInput newAddReviewerInput(
-      Account.Id reviewer, ReviewerState state, NotifyHandling notify) {
-    // AccountResolver always resolves by ID if the input string is numeric.
-    return newAddReviewerInput(reviewer.toString(), state, notify);
-  }
-
-  public static InternalAddReviewerInput newAddReviewerInput(
+  public static InternalReviewerInput newReviewerInput(
       String reviewer, ReviewerState state, NotifyHandling notify) {
-    InternalAddReviewerInput in = new InternalAddReviewerInput();
+    InternalReviewerInput in = new InternalReviewerInput();
     in.reviewer = reviewer;
     in.state = state;
     in.notify = notify;
     return in;
   }
 
-  public static Optional<InternalAddReviewerInput> newAddReviewerInputFromCommitIdentity(
+  public static Optional<InternalReviewerInput> newReviewerInputFromCommitIdentity(
       Change change,
       ObjectId commitId,
       @Nullable Account.Id accountId,
@@ -142,7 +139,7 @@
         "Adding account %d from author/committer identity of commit %s as cc to change %d",
         accountId.get(), commitId.name(), change.getChangeId());
 
-    InternalAddReviewerInput in = new InternalAddReviewerInput();
+    InternalReviewerInput in = new InternalReviewerInput();
     in.reviewer = accountId.toString();
     in.state = CC;
     in.notify = notify;
@@ -161,9 +158,11 @@
   private final Provider<AnonymousUser> anonymousProvider;
   private final AddReviewersOp.Factory addReviewersOpFactory;
   private final OutgoingEmailValidator validator;
+  private final DeleteReviewerOp.Factory deleteReviewerOpFactory;
+  private final DeleteReviewerByEmailOp.Factory deleteReviewerByEmailOpFactory;
 
   @Inject
-  ReviewerAdder(
+  ReviewerModifier(
       AccountResolver accountResolver,
       PermissionBackend permissionBackend,
       GroupResolver groupResolver,
@@ -174,7 +173,9 @@
       ProjectCache projectCache,
       Provider<AnonymousUser> anonymousProvider,
       AddReviewersOp.Factory addReviewersOpFactory,
-      OutgoingEmailValidator validator) {
+      OutgoingEmailValidator validator,
+      DeleteReviewerOp.Factory deleteReviewerOpFactory,
+      DeleteReviewerByEmailOp.Factory deleteReviewerByEmailOpFactory) {
     this.accountResolver = accountResolver;
     this.permissionBackend = permissionBackend;
     this.groupResolver = groupResolver;
@@ -186,10 +187,12 @@
     this.anonymousProvider = anonymousProvider;
     this.addReviewersOpFactory = addReviewersOpFactory;
     this.validator = validator;
+    this.deleteReviewerOpFactory = deleteReviewerOpFactory;
+    this.deleteReviewerByEmailOpFactory = deleteReviewerByEmailOpFactory;
   }
 
   /**
-   * Prepare application of a single {@link AddReviewerInput}.
+   * Prepare application of a single {@link ReviewerInput}.
    *
    * @param notes change notes.
    * @param user user performing the reviewer addition.
@@ -202,8 +205,8 @@
    * @throws PermissionBackendException
    * @throws ConfigInvalidException
    */
-  public ReviewerAddition prepare(
-      ChangeNotes notes, CurrentUser user, AddReviewerInput input, boolean allowGroup)
+  public ReviewerModification prepare(
+      ChangeNotes notes, CurrentUser user, ReviewerInput input, boolean allowGroup)
       throws IOException, PermissionBackendException, ConfigInvalidException {
     try (TraceContext.TraceTimer ignored =
         TraceContext.newTimer(getClass().getSimpleName() + "#prepare", Metadata.empty())) {
@@ -215,9 +218,9 @@
               .orElseThrow(illegalState(notes.getProjectName()))
               .is(BooleanProjectConfig.ENABLE_REVIEWER_BY_EMAIL);
 
-      ReviewerAddition byAccountId = addByAccountId(input, notes, user);
+      ReviewerModification byAccountId = byAccountId(input, notes, user);
 
-      ReviewerAddition wholeGroup = null;
+      ReviewerModification wholeGroup = null;
       if (!byAccountId.exactMatchFound) {
         wholeGroup = addWholeGroup(input, notes, user, confirmed, allowGroup, allowByEmail);
         if (wholeGroup != null && wholeGroup.exactMatchFound) {
@@ -245,25 +248,24 @@
     }
   }
 
-  public ReviewerAddition ccCurrentUser(CurrentUser user, RevisionResource revision) {
-    return new ReviewerAddition(
-        newAddReviewerInput(user.getUserName().orElse(null), CC, NotifyHandling.NONE),
+  public ReviewerModification ccCurrentUser(CurrentUser user, RevisionResource revision) {
+    return new ReviewerModification(
+        newReviewerInput(user.getUserName().orElse(null), CC, NotifyHandling.NONE),
         revision.getNotes(),
         revision.getUser(),
-        ImmutableSet.of(user.getAccountId()),
+        ImmutableSet.of(user.asIdentifiedUser().getAccount()),
         null,
         true,
         false);
   }
 
   @Nullable
-  private ReviewerAddition addByAccountId(
-      AddReviewerInput input, ChangeNotes notes, CurrentUser user)
+  private ReviewerModification byAccountId(ReviewerInput input, ChangeNotes notes, CurrentUser user)
       throws PermissionBackendException, IOException, ConfigInvalidException {
     IdentifiedUser reviewerUser;
     boolean exactMatchFound = false;
     try {
-      reviewerUser = accountResolver.resolve(input.reviewer).asUniqueUser();
+      reviewerUser = accountResolver.resolveIncludeInactive(input.reviewer).asUniqueUser();
       if (input.reviewer.equalsIgnoreCase(reviewerUser.getName())
           || input.reviewer.equals(String.valueOf(reviewerUser.getAccountId()))) {
         exactMatchFound = true;
@@ -275,11 +277,11 @@
     }
 
     if (isValidReviewer(notes.getChange().getDest(), reviewerUser.getAccount())) {
-      return new ReviewerAddition(
+      return new ReviewerModification(
           input,
           notes,
           user,
-          ImmutableSet.of(reviewerUser.getAccountId()),
+          ImmutableSet.of(reviewerUser.getAccount()),
           null,
           exactMatchFound,
           false);
@@ -291,8 +293,8 @@
   }
 
   @Nullable
-  private ReviewerAddition addWholeGroup(
-      AddReviewerInput input,
+  private ReviewerModification addWholeGroup(
+      ReviewerInput input,
       ChangeNotes notes,
       CurrentUser user,
       boolean confirmed,
@@ -325,7 +327,14 @@
           MessageFormat.format(ChangeMessages.get().groupIsNotAllowed, group.getName()));
     }
 
-    Set<Account.Id> reviewers = new HashSet<>();
+    if (input.state().equals(REMOVED)) {
+      return fail(
+          input,
+          FailureType.OTHER,
+          MessageFormat.format(ChangeMessages.get().groupRemovalIsNotAllowed, group.getName()));
+    }
+
+    Set<Account> reviewers = new HashSet<>();
     Set<Account> members;
     try {
       members = groupMembers.listAccounts(group.getGroupUUID(), notes.getProjectName());
@@ -362,15 +371,15 @@
 
     for (Account member : members) {
       if (isValidReviewer(notes.getChange().getDest(), member)) {
-        reviewers.add(member.id());
+        reviewers.add(member);
       }
     }
 
-    return new ReviewerAddition(input, notes, user, reviewers, null, true, true);
+    return new ReviewerModification(input, notes, user, reviewers, null, true, true);
   }
 
   @Nullable
-  private ReviewerAddition addByEmail(AddReviewerInput input, ChangeNotes notes, CurrentUser user)
+  private ReviewerModification addByEmail(ReviewerInput input, ChangeNotes notes, CurrentUser user)
       throws PermissionBackendException {
     try {
       permissionBackend.user(anonymousProvider.get()).change(notes).check(ChangePermission.READ);
@@ -388,7 +397,7 @@
           FailureType.NOT_FOUND,
           MessageFormat.format(ChangeMessages.get().reviewerInvalid, input.reviewer));
     }
-    return new ReviewerAddition(input, notes, user, null, ImmutableList.of(adr), true, false);
+    return new ReviewerModification(input, notes, user, null, ImmutableList.of(adr), true, false);
   }
 
   private boolean isValidReviewer(BranchNameKey branch, Account member)
@@ -404,32 +413,32 @@
     }
   }
 
-  private ReviewerAddition fail(AddReviewerInput input, FailureType failureType, String error) {
+  private ReviewerModification fail(ReviewerInput input, FailureType failureType, String error) {
     return fail(input, failureType, false, error);
   }
 
-  private ReviewerAddition fail(
-      AddReviewerInput input, FailureType failureType, boolean confirm, String error) {
-    ReviewerAddition addition = new ReviewerAddition(input, failureType);
+  private ReviewerModification fail(
+      ReviewerInput input, FailureType failureType, boolean confirm, String error) {
+    ReviewerModification addition = new ReviewerModification(input, failureType);
     addition.result.confirm = confirm ? true : null;
     addition.result.error = error;
     return addition;
   }
 
-  public class ReviewerAddition {
-    public final AddReviewerResult result;
-    @Nullable public final AddReviewersOp op;
-    public final ImmutableSet<Account.Id> reviewers;
+  public class ReviewerModification {
+    public final ReviewerResult result;
+    @Nullable public final ReviewerOp op;
+    public final ImmutableSet<Account> reviewers;
     public final ImmutableSet<Address> reviewersByEmail;
     @Nullable final IdentifiedUser caller;
     final boolean exactMatchFound;
-    private final AddReviewerInput input;
+    private final ReviewerInput input;
     @Nullable private final FailureType failureType;
 
-    private ReviewerAddition(AddReviewerInput input, FailureType failureType) {
+    private ReviewerModification(ReviewerInput input, FailureType failureType) {
       this.input = input;
       this.failureType = requireNonNull(failureType);
-      result = new AddReviewerResult(input.reviewer);
+      result = new ReviewerResult(input.reviewer);
       op = null;
       reviewers = ImmutableSet.of();
       reviewersByEmail = ImmutableSet.of();
@@ -437,11 +446,11 @@
       exactMatchFound = false;
     }
 
-    private ReviewerAddition(
-        AddReviewerInput input,
+    private ReviewerModification(
+        ReviewerInput input,
         ChangeNotes notes,
         CurrentUser caller,
-        @Nullable Iterable<Account.Id> reviewers,
+        @Nullable Iterable<Account> reviewers,
         @Nullable Iterable<Address> reviewersByEmail,
         boolean exactMatchFound,
         boolean forGroup) {
@@ -451,21 +460,47 @@
 
       this.input = input;
       this.failureType = null;
-      result = new AddReviewerResult(input.reviewer);
+      result = new ReviewerResult(input.reviewer);
       // Always silently ignore adding the owner as any type of reviewer on their own change. They
       // may still be implicitly added as a reviewer if they vote, but not via the reviewer API.
       this.reviewers = omitOwner(notes, reviewers);
       this.reviewersByEmail =
           reviewersByEmail == null ? ImmutableSet.of() : ImmutableSet.copyOf(reviewersByEmail);
       this.caller = caller.asIdentifiedUser();
-      op = addReviewersOpFactory.create(this.reviewers, this.reviewersByEmail, state(), forGroup);
+      if (state().equals(REMOVED)) {
+        // only one is set.
+        checkState(
+            (this.reviewers.size() == 1 && this.reviewersByEmail.isEmpty())
+                || (this.reviewers.isEmpty() && this.reviewersByEmail.size() == 1));
+        if (this.reviewers.size() >= 1) {
+          checkState(this.reviewers.size() == 1);
+          DeleteReviewerInput deleteReviewerInput = new DeleteReviewerInput();
+          deleteReviewerInput.notify = input.notify;
+          deleteReviewerInput.notifyDetails = input.notifyDetails;
+          op =
+              deleteReviewerOpFactory.create(
+                  Iterables.getOnlyElement(this.reviewers.asList()), deleteReviewerInput);
+        } else {
+          checkState(this.reviewersByEmail.size() == 1);
+          op =
+              deleteReviewerByEmailOpFactory.create(
+                  Iterables.getOnlyElement(this.reviewersByEmail.asList()));
+        }
+      } else {
+        op =
+            addReviewersOpFactory.create(
+                this.reviewers.stream().map(Account::id).collect(toImmutableSet()),
+                this.reviewersByEmail,
+                state(),
+                forGroup);
+      }
       this.exactMatchFound = exactMatchFound;
     }
 
-    private ImmutableSet<Account.Id> omitOwner(ChangeNotes notes, Iterable<Account.Id> reviewers) {
+    private ImmutableSet<Account> omitOwner(ChangeNotes notes, Iterable<Account> reviewers) {
       return reviewers != null
           ? Streams.stream(reviewers)
-              .filter(id -> !id.equals(notes.getChange().getOwner()))
+              .filter(account -> !account.id().equals(notes.getChange().getOwner()))
               .collect(toImmutableSet())
           : ImmutableSet.of();
     }
@@ -476,31 +511,52 @@
 
       // Generate result details and fill AccountLoader. This occurs outside
       // the Op because the accounts are in a different table.
-      AddReviewersOp.Result opResult = op.getResult();
-      if (state() == CC) {
-        result.ccs = Lists.newArrayListWithCapacity(opResult.addedCCs().size());
-        for (Account.Id accountId : opResult.addedCCs()) {
-          result.ccs.add(json.format(new ReviewerInfo(accountId.get()), accountId, cd));
-        }
-        accountLoaderFactory.create(true).fill(result.ccs);
-        for (Address a : opResult.addedCCsByEmail()) {
-          result.ccs.add(new AccountInfo(a.name(), a.email()));
-        }
-      } else {
-        result.reviewers = Lists.newArrayListWithCapacity(opResult.addedReviewers().size());
-        for (PatchSetApproval psa : opResult.addedReviewers()) {
-          // New reviewers have value 0, don't bother normalizing.
-          result.reviewers.add(
-              json.format(
-                  new ReviewerInfo(psa.accountId().get()),
-                  psa.accountId(),
-                  cd,
-                  ImmutableList.of(psa)));
-        }
-        accountLoaderFactory.create(true).fill(result.reviewers);
-        for (Address a : opResult.addedReviewersByEmail()) {
-          result.reviewers.add(ReviewerInfo.byEmail(a.name(), a.email()));
-        }
+      ReviewerOp.Result opResult = op.getResult();
+      switch (state()) {
+        case CC:
+          result.ccs = Lists.newArrayListWithCapacity(opResult.addedCCs().size());
+          for (Account.Id accountId : opResult.addedCCs()) {
+            result.ccs.add(json.format(new ReviewerInfo(accountId.get()), accountId, cd));
+          }
+          accountLoaderFactory.create(true).fill(result.ccs);
+          for (Address a : opResult.addedCCsByEmail()) {
+            result.ccs.add(new AccountInfo(a.name(), a.email()));
+          }
+          break;
+        case REVIEWER:
+          result.reviewers = Lists.newArrayListWithCapacity(opResult.addedReviewers().size());
+          for (PatchSetApproval psa : opResult.addedReviewers()) {
+            // New reviewers have value 0, don't bother normalizing.
+            result.reviewers.add(
+                json.format(
+                    new ReviewerInfo(psa.accountId().get()),
+                    psa.accountId(),
+                    cd,
+                    ImmutableList.of(psa)));
+          }
+          accountLoaderFactory.create(true).fill(result.reviewers);
+          for (Address a : opResult.addedReviewersByEmail()) {
+            result.reviewers.add(ReviewerInfo.byEmail(a.name(), a.email()));
+          }
+          break;
+        case REMOVED:
+          if (opResult.deletedReviewer().isPresent()) {
+            result.removed =
+                json.format(
+                    new ReviewerInfo(opResult.deletedReviewer().get().get()),
+                    opResult.deletedReviewer().get(),
+                    cd);
+            accountLoaderFactory.create(true).fill(ImmutableList.of(result.removed));
+          } else if (opResult.deletedReviewerByEmail().isPresent()) {
+            result.removed =
+                new AccountInfo(
+                    opResult.deletedReviewerByEmail().get().name(),
+                    opResult.deletedReviewerByEmail().get().email());
+          }
+          break;
+        default:
+          throw new IllegalStateException(
+              String.format("Illegal ReviewerState argument is %s", state().name()));
       }
     }
 
@@ -515,8 +571,8 @@
     public boolean isIgnorableFailure() {
       checkState(failureType != null);
       FailureBehavior behavior =
-          (input instanceof InternalAddReviewerInput)
-              ? ((InternalAddReviewerInput) input).otherFailureBehavior
+          (input instanceof InternalReviewerInput)
+              ? ((InternalReviewerInput) input).otherFailureBehavior
               : FailureBehavior.FAIL;
       return failureType == FailureType.OTHER && behavior == FailureBehavior.IGNORE;
     }
@@ -526,10 +582,10 @@
     return !SystemGroupBackend.isSystemGroup(groupUUID);
   }
 
-  public ReviewerAdditionList prepare(
+  public ReviewerModificationList prepare(
       ChangeNotes notes,
       CurrentUser user,
-      Iterable<? extends AddReviewerInput> inputs,
+      Iterable<? extends ReviewerInput> inputs,
       boolean allowGroup)
       throws IOException, PermissionBackendException, ConfigInvalidException {
     // Process CC ops before reviewer ops, so a user that appears in both lists ends up as a
@@ -539,39 +595,39 @@
     // TODO(dborowitz): Consider changing interface to allow excluding reviewers that were
     // previously processed, to proactively prevent overlap so we don't have to rely on this subtle
     // behavior.
-    ImmutableList<AddReviewerInput> sorted =
+    ImmutableList<ReviewerInput> sorted =
         Streams.stream(inputs)
             .sorted(
                 comparing(
-                    AddReviewerInput::state,
+                    ReviewerInput::state,
                     Ordering.explicit(ReviewerState.CC, ReviewerState.REVIEWER)))
             .collect(toImmutableList());
-    List<ReviewerAddition> additions = new ArrayList<>();
-    for (AddReviewerInput input : sorted) {
-      ReviewerAddition addition = prepare(notes, user, input, allowGroup);
+    List<ReviewerModification> additions = new ArrayList<>();
+    for (ReviewerInput input : sorted) {
+      ReviewerModification addition = prepare(notes, user, input, allowGroup);
       if (addition.op != null) {
         // Assume any callers preparing a list of batch insertions are handling their own email.
         addition.op.suppressEmail();
       }
       additions.add(addition);
     }
-    return new ReviewerAdditionList(additions);
+    return new ReviewerModificationList(additions);
   }
 
   // TODO(dborowitz): This class works, but ultimately feels wrong. It seems like an op but isn't
   // really an op, it's a collection of ops, and it's only called from the body of other ops. We
   // could make this class an op, but we would still have AddReviewersOp. Better would probably be
-  // to design a single op that supports combining multiple AddReviewerInputs together. That would
+  // to design a single op that supports combining multiple ReviewerInputs together. That would
   // probably also subsume the Addition class itself, which would be a good thing.
-  public static class ReviewerAdditionList {
-    private final ImmutableList<ReviewerAddition> additions;
+  public static class ReviewerModificationList {
+    private final ImmutableList<ReviewerModification> modifications;
 
-    private ReviewerAdditionList(List<ReviewerAddition> additions) {
-      this.additions = ImmutableList.copyOf(additions);
+    private ReviewerModificationList(List<ReviewerModification> modifications) {
+      this.modifications = ImmutableList.copyOf(modifications);
     }
 
-    public ImmutableList<ReviewerAddition> getFailures() {
-      return additions.stream()
+    public ImmutableList<ReviewerModification> getFailures() {
+      return modifications.stream()
           .filter(a -> a.isFailure() && !a.isIgnorableFailure())
           .collect(toImmutableList());
     }
@@ -579,15 +635,15 @@
     // We never call updateRepo on the addition ops, which is only ok because it's a no-op.
 
     public void updateChange(ChangeContext ctx, PatchSet patchSet)
-        throws RestApiException, IOException {
-      for (ReviewerAddition addition : additions()) {
+        throws RestApiException, IOException, PermissionBackendException {
+      for (ReviewerModification addition : modifications()) {
         addition.op.setPatchSet(patchSet);
         addition.op.updateChange(ctx);
       }
     }
 
-    public void postUpdate(Context ctx) throws Exception {
-      for (ReviewerAddition addition : additions()) {
+    public void postUpdate(PostUpdateContext ctx) throws Exception {
+      for (ReviewerModification addition : modifications()) {
         if (addition.op != null) {
           addition.op.postUpdate(ctx);
         }
@@ -596,20 +652,20 @@
 
     public <T> ImmutableSet<T> flattenResults(
         Function<AddReviewersOp.Result, ? extends Collection<T>> func) {
-      additions()
+      modifications()
           .forEach(
               a ->
                   checkArgument(
                       a.op != null && a.op.getResult() != null, "missing result on %s", a));
-      return additions().stream()
+      return modifications().stream()
           .map(a -> a.op.getResult())
           .map(func)
           .flatMap(Collection::stream)
           .collect(toImmutableSet());
     }
 
-    private ImmutableList<ReviewerAddition> additions() {
-      return additions.stream()
+    private ImmutableList<ReviewerModification> modifications() {
+      return modifications.stream()
           .filter(
               a -> {
                 if (a.isFailure()) {
diff --git a/java/com/google/gerrit/server/change/ReviewerOp.java b/java/com/google/gerrit/server/change/ReviewerOp.java
new file mode 100644
index 0000000..716ac5e
--- /dev/null
+++ b/java/com/google/gerrit/server/change/ReviewerOp.java
@@ -0,0 +1,99 @@
+// 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 static com.google.common.base.Preconditions.checkState;
+import static java.util.Objects.requireNonNull;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Address;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.PatchSetApproval;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.ChangeContext;
+import java.io.IOException;
+import java.util.Optional;
+
+public class ReviewerOp implements BatchUpdateOp {
+  protected boolean sendEmail = true;
+  protected PatchSet patchSet;
+  protected Result opResult;
+
+  // TODO(dborowitz): This mutable setter is ugly, but a) it's less ugly than adding boolean args
+  // all the way through the constructor stack, and b) this class is slated to be completely
+  // rewritten.
+  public void suppressEmail() {
+    this.sendEmail = false;
+  }
+
+  void setPatchSet(PatchSet patchSet) {
+    this.patchSet = requireNonNull(patchSet);
+  }
+
+  @AutoValue
+  public abstract static class Result {
+    public abstract ImmutableList<PatchSetApproval> addedReviewers();
+
+    public abstract ImmutableList<Address> addedReviewersByEmail();
+
+    public abstract ImmutableList<Account.Id> addedCCs();
+
+    public abstract ImmutableList<Address> addedCCsByEmail();
+
+    public abstract Optional<Account.Id> deletedReviewer();
+
+    public abstract Optional<Address> deletedReviewerByEmail();
+
+    static Builder builder() {
+      return new AutoValue_ReviewerOp_Result.Builder()
+          .setAddedReviewers(ImmutableList.of())
+          .setAddedReviewersByEmail(ImmutableList.of())
+          .setAddedCCs(ImmutableList.of())
+          .setAddedCCsByEmail(ImmutableList.of());
+    }
+
+    @AutoValue.Builder
+    abstract static class Builder {
+      abstract Builder setAddedReviewers(Iterable<PatchSetApproval> addedReviewers);
+
+      abstract Builder setAddedReviewersByEmail(Iterable<Address> addedReviewersByEmail);
+
+      abstract Builder setAddedCCs(Iterable<Account.Id> addedCCs);
+
+      abstract Builder setAddedCCsByEmail(Iterable<Address> addedCCsByEmail);
+
+      abstract Builder setDeletedReviewerByEmail(Address deletedReviewerByEmail);
+
+      abstract Builder setDeletedReviewer(Account.Id deletedReviewer);
+
+      abstract Result build();
+    }
+  }
+
+  public Result getResult() {
+    checkState(opResult != null, "Batch update wasn't executed yet");
+    return opResult;
+  }
+
+  @Override
+  public boolean updateChange(ChangeContext ctx)
+      throws RestApiException, IOException, PermissionBackendException {
+    return false;
+  }
+}
diff --git a/java/com/google/gerrit/server/change/RevisionJson.java b/java/com/google/gerrit/server/change/RevisionJson.java
index b702440..33f3d4f 100644
--- a/java/com/google/gerrit/server/change/RevisionJson.java
+++ b/java/com/google/gerrit/server/change/RevisionJson.java
@@ -182,9 +182,14 @@
     info.message = commit.getFullMessage();
 
     if (addLinks) {
-      ImmutableList<WebLinkInfo> links =
+      ImmutableList<WebLinkInfo> patchSetLinks =
           webLinks.getPatchSetLinks(project, commit.name(), commit.getFullMessage(), branchName);
-      info.webLinks = links.isEmpty() ? null : links;
+      info.webLinks = patchSetLinks.isEmpty() ? null : patchSetLinks;
+      ImmutableList<WebLinkInfo> resolveConflictsLinks =
+          webLinks.getResolveConflictsLinks(
+              project, commit.name(), commit.getFullMessage(), branchName);
+      info.resolveConflictsWebLinks =
+          resolveConflictsLinks.isEmpty() ? null : resolveConflictsLinks;
     }
 
     for (RevCommit parent : commit.getParents()) {
diff --git a/java/com/google/gerrit/server/change/SetAssigneeOp.java b/java/com/google/gerrit/server/change/SetAssigneeOp.java
index 411c9b6..3e7d0bc 100644
--- a/java/com/google/gerrit/server/change/SetAssigneeOp.java
+++ b/java/com/google/gerrit/server/change/SetAssigneeOp.java
@@ -18,7 +18,6 @@
 
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.server.ChangeMessagesUtil;
@@ -30,7 +29,7 @@
 import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
-import com.google.gerrit.server.update.Context;
+import com.google.gerrit.server.update.PostUpdateContext;
 import com.google.gerrit.server.validators.AssigneeValidationListener;
 import com.google.gerrit.server.validators.ValidationException;
 import com.google.inject.Inject;
@@ -98,29 +97,27 @@
     update.setAssignee(newAssignee.getAccountId());
     // reviewdb
     change.setAssignee(newAssignee.getAccountId());
-    addMessage(ctx, update);
+    addMessage(ctx);
     return true;
   }
 
-  private void addMessage(ChangeContext ctx, ChangeUpdate update) {
+  private void addMessage(ChangeContext ctx) {
     StringBuilder msg = new StringBuilder();
     msg.append("Assignee ");
     if (oldAssignee == null) {
       msg.append("added: ");
-      msg.append(newAssignee.getNameEmail());
+      msg.append(ChangeMessagesUtil.getAccountTemplate(newAssignee.getAccountId()));
     } else {
       msg.append("changed from: ");
-      msg.append(oldAssignee.getNameEmail());
+      msg.append(ChangeMessagesUtil.getAccountTemplate(oldAssignee.getAccountId()));
       msg.append(" to: ");
-      msg.append(newAssignee.getNameEmail());
+      msg.append(ChangeMessagesUtil.getAccountTemplate(newAssignee.getAccountId()));
     }
-    ChangeMessage cmsg =
-        ChangeMessagesUtil.newMessage(ctx, msg.toString(), ChangeMessagesUtil.TAG_SET_ASSIGNEE);
-    cmUtil.addChangeMessage(update, cmsg);
+    cmUtil.setChangeMessage(ctx, msg.toString(), ChangeMessagesUtil.TAG_SET_ASSIGNEE);
   }
 
   @Override
-  public void postUpdate(Context ctx) {
+  public void postUpdate(PostUpdateContext ctx) {
     try {
       SetAssigneeSender emailSender =
           setAssigneeSenderFactory.create(
@@ -134,6 +131,9 @@
           "Cannot send email to new assignee of change %s", change.getId());
     }
     assigneeChanged.fire(
-        change, ctx.getAccount(), oldAssignee != null ? oldAssignee.state() : null, ctx.getWhen());
+        ctx.getChangeData(change),
+        ctx.getAccount(),
+        oldAssignee != null ? oldAssignee.state() : null,
+        ctx.getWhen());
   }
 }
diff --git a/java/com/google/gerrit/server/change/SetHashtagsOp.java b/java/com/google/gerrit/server/change/SetHashtagsOp.java
index 712e1f3..bfc4834 100644
--- a/java/com/google/gerrit/server/change/SetHashtagsOp.java
+++ b/java/com/google/gerrit/server/change/SetHashtagsOp.java
@@ -22,7 +22,6 @@
 import com.google.common.collect.Ordering;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.extensions.api.changes.HashtagsInput;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
@@ -35,7 +34,7 @@
 import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
-import com.google.gerrit.server.update.Context;
+import com.google.gerrit.server.update.PostUpdateContext;
 import com.google.gerrit.server.validators.HashtagValidationListener;
 import com.google.gerrit.server.validators.ValidationException;
 import com.google.inject.Inject;
@@ -106,7 +105,7 @@
         updated.addAll(toAdd);
         updated.removeAll(toRemove);
         update.setHashtags(updated);
-        addMessage(ctx, update);
+        addMessage(ctx);
       }
 
       updatedHashtags = ImmutableSortedSet.copyOf(updated);
@@ -116,13 +115,11 @@
     }
   }
 
-  private void addMessage(ChangeContext ctx, ChangeUpdate update) {
+  private void addMessage(ChangeContext ctx) {
     StringBuilder msg = new StringBuilder();
     appendHashtagMessage(msg, "added", toAdd);
     appendHashtagMessage(msg, "removed", toRemove);
-    ChangeMessage cmsg =
-        ChangeMessagesUtil.newMessage(ctx, msg.toString(), ChangeMessagesUtil.TAG_SET_HASHTAGS);
-    cmUtil.addChangeMessage(update, cmsg);
+    cmUtil.setChangeMessage(ctx, msg.toString(), ChangeMessagesUtil.TAG_SET_HASHTAGS);
   }
 
   private void appendHashtagMessage(StringBuilder b, String action, Set<String> hashtags) {
@@ -144,10 +141,15 @@
   }
 
   @Override
-  public void postUpdate(Context ctx) {
+  public void postUpdate(PostUpdateContext ctx) {
     if (updated() && fireEvent) {
       hashtagsEdited.fire(
-          change, ctx.getAccount(), updatedHashtags, toAdd, toRemove, ctx.getWhen());
+          ctx.getChangeData(change),
+          ctx.getAccount(),
+          updatedHashtags,
+          toAdd,
+          toRemove,
+          ctx.getWhen());
     }
   }
 
diff --git a/java/com/google/gerrit/server/change/SetPrivateOp.java b/java/com/google/gerrit/server/change/SetPrivateOp.java
index 382a4f6..1274a5ed 100644
--- a/java/com/google/gerrit/server/change/SetPrivateOp.java
+++ b/java/com/google/gerrit/server/change/SetPrivateOp.java
@@ -17,7 +17,6 @@
 import com.google.common.base.Strings;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.extensions.common.InputWithMessage;
 import com.google.gerrit.extensions.restapi.BadRequestException;
@@ -30,7 +29,7 @@
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
-import com.google.gerrit.server.update.Context;
+import com.google.gerrit.server.update.PostUpdateContext;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 
@@ -83,18 +82,18 @@
     change.setPrivate(isPrivate);
     change.setLastUpdatedOn(ctx.getWhen());
     update.setPrivate(isPrivate);
-    addMessage(ctx, update);
+    addMessage(ctx);
     return true;
   }
 
   @Override
-  public void postUpdate(Context ctx) {
+  public void postUpdate(PostUpdateContext ctx) {
     if (!isNoOp) {
-      privateStateChanged.fire(change, ps, ctx.getAccount(), ctx.getWhen());
+      privateStateChanged.fire(ctx.getChangeData(change), ps, ctx.getAccount(), ctx.getWhen());
     }
   }
 
-  private void addMessage(ChangeContext ctx, ChangeUpdate update) {
+  private void addMessage(ChangeContext ctx) {
     Change c = ctx.getChange();
     StringBuilder buf = new StringBuilder(c.isPrivate() ? "Set private" : "Unset private");
 
@@ -104,13 +103,9 @@
       buf.append(m);
     }
 
-    ChangeMessage cmsg =
-        ChangeMessagesUtil.newMessage(
-            ctx,
-            buf.toString(),
-            c.isPrivate()
-                ? ChangeMessagesUtil.TAG_SET_PRIVATE
-                : ChangeMessagesUtil.TAG_UNSET_PRIVATE);
-    cmUtil.addChangeMessage(update, cmsg);
+    cmUtil.setChangeMessage(
+        ctx,
+        buf.toString(),
+        c.isPrivate() ? ChangeMessagesUtil.TAG_SET_PRIVATE : ChangeMessagesUtil.TAG_UNSET_PRIVATE);
   }
 }
diff --git a/java/com/google/gerrit/server/change/SetTopicOp.java b/java/com/google/gerrit/server/change/SetTopicOp.java
index c4a49b0..ee35d1d 100644
--- a/java/com/google/gerrit/server/change/SetTopicOp.java
+++ b/java/com/google/gerrit/server/change/SetTopicOp.java
@@ -17,14 +17,13 @@
 import com.google.common.base.Strings;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.extensions.events.TopicEdited;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
-import com.google.gerrit.server.update.Context;
+import com.google.gerrit.server.update.PostUpdateContext;
 import com.google.gerrit.server.validators.ValidationException;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
@@ -74,16 +73,14 @@
     } catch (ValidationException ex) {
       throw new BadRequestException(ex.getMessage());
     }
-    ChangeMessage cmsg =
-        ChangeMessagesUtil.newMessage(ctx, summary, ChangeMessagesUtil.TAG_SET_TOPIC);
-    cmUtil.addChangeMessage(update, cmsg);
+    cmUtil.setChangeMessage(ctx, summary, ChangeMessagesUtil.TAG_SET_TOPIC);
     return true;
   }
 
   @Override
-  public void postUpdate(Context ctx) {
+  public void postUpdate(PostUpdateContext ctx) {
     if (change != null) {
-      topicEdited.fire(change, ctx.getAccount(), oldTopicName, ctx.getWhen());
+      topicEdited.fire(ctx.getChangeData(change), ctx.getAccount(), oldTopicName, ctx.getWhen());
     }
   }
 }
diff --git a/java/com/google/gerrit/server/change/WorkInProgressOp.java b/java/com/google/gerrit/server/change/WorkInProgressOp.java
index f0ebb80..1409170 100644
--- a/java/com/google/gerrit/server/change/WorkInProgressOp.java
+++ b/java/com/google/gerrit/server/change/WorkInProgressOp.java
@@ -18,7 +18,6 @@
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
@@ -30,7 +29,7 @@
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
-import com.google.gerrit.server.update.Context;
+import com.google.gerrit.server.update.PostUpdateContext;
 import com.google.gerrit.server.update.RepoView;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
@@ -65,7 +64,7 @@
   private Change change;
   private ChangeNotes notes;
   private PatchSet ps;
-  private ChangeMessage cmsg;
+  private String mailMessage;
 
   @Inject
   WorkInProgressOp(
@@ -99,11 +98,11 @@
     }
     change.setLastUpdatedOn(ctx.getWhen());
     update.setWorkInProgress(workInProgress);
-    addMessage(ctx, update);
+    addMessage(ctx);
     return true;
   }
 
-  private void addMessage(ChangeContext ctx, ChangeUpdate update) {
+  private void addMessage(ChangeContext ctx) {
     Change c = ctx.getChange();
     StringBuilder buf =
         new StringBuilder(c.isWorkInProgress() ? "Set Work In Progress" : "Set Ready For Review");
@@ -114,20 +113,18 @@
       buf.append(m);
     }
 
-    cmsg =
-        ChangeMessagesUtil.newMessage(
+    mailMessage =
+        cmUtil.setChangeMessage(
             ctx,
             buf.toString(),
             c.isWorkInProgress()
                 ? ChangeMessagesUtil.TAG_SET_WIP
                 : ChangeMessagesUtil.TAG_SET_READY);
-
-    cmUtil.addChangeMessage(update, cmsg);
   }
 
   @Override
-  public void postUpdate(Context ctx) {
-    stateChanged.fire(change, ps, ctx.getAccount(), ctx.getWhen());
+  public void postUpdate(PostUpdateContext ctx) {
+    stateChanged.fire(ctx.getChangeData(change), ps, ctx.getAccount(), ctx.getWhen());
     NotifyResolver.Result notify = ctx.getNotify(change.getId());
     if (workInProgress
         || notify.handling().compareTo(NotifyHandling.OWNER_REVIEWERS) < 0
@@ -147,9 +144,10 @@
             notes,
             ps,
             ctx.getIdentifiedUser(),
-            cmsg,
+            mailMessage,
+            ctx.getWhen(),
             ImmutableList.of(),
-            cmsg.getMessage(),
+            mailMessage,
             ImmutableList.of(),
             repoView)
         .sendAsync();
diff --git a/java/com/google/gerrit/server/config/CapabilityConstants.java b/java/com/google/gerrit/server/config/CapabilityConstants.java
index 4ab97f8..59819bb 100644
--- a/java/com/google/gerrit/server/config/CapabilityConstants.java
+++ b/java/com/google/gerrit/server/config/CapabilityConstants.java
@@ -39,10 +39,10 @@
   public String runAs;
   public String runGC;
   public String streamEvents;
+  public String viewAccess;
   public String viewAllAccounts;
   public String viewCaches;
   public String viewConnections;
   public String viewPlugins;
   public String viewQueue;
-  public String viewAccess;
 }
diff --git a/java/com/google/gerrit/server/config/DownloadConfig.java b/java/com/google/gerrit/server/config/DownloadConfig.java
index 58ce098..00df1e6 100644
--- a/java/com/google/gerrit/server/config/DownloadConfig.java
+++ b/java/com/google/gerrit/server/config/DownloadConfig.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.config;
 
 import com.google.common.collect.ImmutableSet;
+import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.CoreDownloadSchemes;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo.DownloadCommand;
 import com.google.gerrit.server.change.ArchiveFormatInternal;
@@ -35,6 +36,8 @@
  */
 @Singleton
 public class DownloadConfig {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
   private final ImmutableSet<String> downloadSchemes;
   private final ImmutableSet<DownloadCommand> downloadCommands;
   private final ImmutableSet<ArchiveFormatInternal> archiveFormats;
@@ -51,7 +54,8 @@
       for (String s : allSchemes) {
         String core = toCoreScheme(s);
         if (core == null) {
-          throw new IllegalArgumentException("not a core download scheme: " + s);
+          logger.atWarning().log("not a core download scheme: " + s);
+          continue;
         }
         normalized.add(core);
       }
diff --git a/java/com/google/gerrit/server/config/GerritGlobalModule.java b/java/com/google/gerrit/server/config/GerritGlobalModule.java
index bb851e2..5a74c78 100644
--- a/java/com/google/gerrit/server/config/GerritGlobalModule.java
+++ b/java/com/google/gerrit/server/config/GerritGlobalModule.java
@@ -66,16 +66,17 @@
 import com.google.gerrit.extensions.validators.CommentValidator;
 import com.google.gerrit.extensions.webui.BranchWebLink;
 import com.google.gerrit.extensions.webui.DiffWebLink;
+import com.google.gerrit.extensions.webui.EditWebLink;
 import com.google.gerrit.extensions.webui.FileHistoryWebLink;
 import com.google.gerrit.extensions.webui.FileWebLink;
 import com.google.gerrit.extensions.webui.ParentWebLink;
 import com.google.gerrit.extensions.webui.PatchSetWebLink;
 import com.google.gerrit.extensions.webui.ProjectWebLink;
+import com.google.gerrit.extensions.webui.ResolveConflictsWebLink;
 import com.google.gerrit.extensions.webui.TagWebLink;
 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;
@@ -99,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;
@@ -177,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;
@@ -235,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,6 +273,7 @@
     install(new SshAddressesModule());
     install(new FileInfoJsonModule(cfg));
     install(ThreadLocalRequestContext.module());
+    install(new ApprovalModule());
 
     factory(CapabilityCollection.Factory.class);
     factory(ChangeData.AssistedFactory.class);
@@ -391,10 +397,12 @@
     DynamicMap.mapOf(binder(), ProjectConfigEntry.class);
     DynamicSet.setOf(binder(), PluginPushOption.class);
     DynamicSet.setOf(binder(), PatchSetWebLink.class);
+    DynamicSet.setOf(binder(), ResolveConflictsWebLink.class);
     DynamicSet.setOf(binder(), ParentWebLink.class);
     DynamicSet.setOf(binder(), FileWebLink.class);
     DynamicSet.setOf(binder(), FileHistoryWebLink.class);
     DynamicSet.setOf(binder(), DiffWebLink.class);
+    DynamicSet.setOf(binder(), EditWebLink.class);
     DynamicSet.setOf(binder(), ProjectWebLink.class);
     DynamicSet.setOf(binder(), BranchWebLink.class);
     DynamicSet.setOf(binder(), TagWebLink.class);
diff --git a/java/com/google/gerrit/server/config/GerritOptions.java b/java/com/google/gerrit/server/config/GerritOptions.java
index d9edf23..0390620 100644
--- a/java/com/google/gerrit/server/config/GerritOptions.java
+++ b/java/com/google/gerrit/server/config/GerritOptions.java
@@ -14,15 +14,23 @@
 
 package com.google.gerrit.server.config;
 
+import com.google.common.base.Strings;
+import com.google.gerrit.common.Nullable;
+import java.util.Optional;
+
 public class GerritOptions {
   private final boolean headless;
   private final boolean slave;
-  private final String devCdn;
+  private final Optional<String> devCdn;
 
-  public GerritOptions(boolean headless, boolean slave, String devCdn) {
+  public GerritOptions(boolean headless, boolean slave) {
+    this(headless, slave, null);
+  }
+
+  public GerritOptions(boolean headless, boolean slave, @Nullable String devCdn) {
     this.headless = headless;
     this.slave = slave;
-    this.devCdn = devCdn;
+    this.devCdn = headless ? Optional.empty() : Optional.ofNullable(Strings.emptyToNull(devCdn));
   }
 
   public boolean headless() {
@@ -33,11 +41,7 @@
     return !slave;
   }
 
-  public String devCdn() {
+  public Optional<String> devCdn() {
     return devCdn;
   }
-
-  public boolean useDevCdn() {
-    return !headless && devCdn.length() > 0;
-  }
 }
diff --git a/java/com/google/gerrit/server/config/GerritServerConfigModule.java b/java/com/google/gerrit/server/config/GerritServerConfigModule.java
index 3777a55..27d1d58 100644
--- a/java/com/google/gerrit/server/config/GerritServerConfigModule.java
+++ b/java/com/google/gerrit/server/config/GerritServerConfigModule.java
@@ -20,8 +20,6 @@
 import com.google.gerrit.server.securestore.SecureStore;
 import com.google.gerrit.server.securestore.SecureStoreProvider;
 import com.google.inject.AbstractModule;
-import com.google.inject.Guice;
-import com.google.inject.Injector;
 import com.google.inject.ProvisionException;
 import java.io.IOException;
 import java.nio.file.Path;
@@ -42,22 +40,13 @@
   }
 
   private static String getSecureStoreFromGerritConfig(Path sitePath) {
-    AbstractModule m =
-        new AbstractModule() {
-          @Override
-          protected void configure() {
-            bind(Path.class).annotatedWith(SitePath.class).toInstance(sitePath);
-            bind(SitePaths.class);
-          }
-        };
-    Injector injector = Guice.createInjector(m);
-    SitePaths site = injector.getInstance(SitePaths.class);
-    FileBasedConfig cfg = new FileBasedConfig(site.gerrit_config.toFile(), FS.DETECTED);
-    if (!cfg.getFile().exists()) {
-      return DefaultSecureStore.class.getName();
-    }
-
     try {
+      SitePaths site = new SitePaths(sitePath);
+      FileBasedConfig cfg = new FileBasedConfig(site.gerrit_config.toFile(), FS.DETECTED);
+      if (!cfg.getFile().exists()) {
+        return DefaultSecureStore.class.getName();
+      }
+
       cfg.load();
       String className = cfg.getString("gerrit", null, "secureStoreClass");
       return nullToDefault(className);
diff --git a/java/com/google/gerrit/server/config/GitwebConfig.java b/java/com/google/gerrit/server/config/GitwebConfig.java
index 8214f03..f90a72e 100644
--- a/java/com/google/gerrit/server/config/GitwebConfig.java
+++ b/java/com/google/gerrit/server/config/GitwebConfig.java
@@ -27,11 +27,13 @@
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.extensions.restapi.Url;
 import com.google.gerrit.extensions.webui.BranchWebLink;
+import com.google.gerrit.extensions.webui.EditWebLink;
 import com.google.gerrit.extensions.webui.FileHistoryWebLink;
 import com.google.gerrit.extensions.webui.FileWebLink;
 import com.google.gerrit.extensions.webui.ParentWebLink;
 import com.google.gerrit.extensions.webui.PatchSetWebLink;
 import com.google.gerrit.extensions.webui.ProjectWebLink;
+import com.google.gerrit.extensions.webui.ResolveConflictsWebLink;
 import com.google.gerrit.extensions.webui.TagWebLink;
 import com.google.inject.AbstractModule;
 import com.google.inject.Inject;
@@ -71,6 +73,7 @@
         }
 
         if (!isNullOrEmpty(type.getFile()) || !isNullOrEmpty(type.getRootTree())) {
+          DynamicSet.bind(binder(), EditWebLink.class).to(GitwebLinks.class);
           DynamicSet.bind(binder(), FileWebLink.class).to(GitwebLinks.class);
         }
 
@@ -81,6 +84,7 @@
         if (!isNullOrEmpty(type.getRevision())) {
           DynamicSet.bind(binder(), PatchSetWebLink.class).to(GitwebLinks.class);
           DynamicSet.bind(binder(), ParentWebLink.class).to(GitwebLinks.class);
+          DynamicSet.bind(binder(), ResolveConflictsWebLink.class).to(GitwebLinks.class);
         }
 
         if (!isNullOrEmpty(type.getProject())) {
@@ -253,11 +257,13 @@
   @Singleton
   static class GitwebLinks
       implements BranchWebLink,
+          EditWebLink,
           FileHistoryWebLink,
           FileWebLink,
           PatchSetWebLink,
           ParentWebLink,
           ProjectWebLink,
+          ResolveConflictsWebLink,
           TagWebLink {
     private final String url;
     private final GitwebType type;
@@ -327,6 +333,12 @@
     }
 
     @Override
+    public WebLinkInfo getEditWebLink(String projectName, String revision, String fileName) {
+      // For Gitweb treat edit links the same as file links
+      return getFileWebLink(projectName, revision, fileName);
+    }
+
+    @Override
     public WebLinkInfo getPatchSetWebLink(
         String projectName, String commit, String commitMessage, String branchName) {
       if (revision != null) {
@@ -341,6 +353,13 @@
     }
 
     @Override
+    public WebLinkInfo getResolveConflictsWebLink(
+        String projectName, String commit, String commitMessage, String branchName) {
+      // For Gitweb treat resolve conflicts links the same as patch set links
+      return getPatchSetWebLink(projectName, commit, commitMessage, branchName);
+    }
+
+    @Override
     public WebLinkInfo getParentWebLink(
         String projectName, String commit, String commitMessage, String branchName) {
       // For Gitweb treat parent revision links the same as patch set links
diff --git a/java/com/google/gerrit/server/diff/DiffInfoCreator.java b/java/com/google/gerrit/server/diff/DiffInfoCreator.java
index c29ffc8..606e42b 100644
--- a/java/com/google/gerrit/server/diff/DiffInfoCreator.java
+++ b/java/com/google/gerrit/server/diff/DiffInfoCreator.java
@@ -73,6 +73,8 @@
 
     ImmutableList<DiffWebLinkInfo> links = webLinksProvider.getDiffLinks();
     result.webLinks = links.isEmpty() ? null : links;
+    ImmutableList<WebLinkInfo> editLinks = webLinksProvider.getEditWebLinks();
+    result.editWebLinks = editLinks.isEmpty() ? null : editLinks;
 
     if (ps.isBinary()) {
       result.binary = true;
@@ -156,8 +158,8 @@
         FileContentUtil.resolveContentType(
             state, side.fileName(), fileInfo.mode, fileInfo.mimeType);
     result.lines = fileInfo.content.getSize();
-    ImmutableList<WebLinkInfo> links = webLinksProvider.getFileWebLinks(side.type());
-    result.webLinks = links.isEmpty() ? null : links;
+    ImmutableList<WebLinkInfo> fileLinks = webLinksProvider.getFileWebLinks(side.type());
+    result.webLinks = fileLinks.isEmpty() ? null : fileLinks;
     result.commitId = fileInfo.commitId;
     return Optional.of(result);
   }
diff --git a/java/com/google/gerrit/server/diff/DiffWebLinksProvider.java b/java/com/google/gerrit/server/diff/DiffWebLinksProvider.java
index 0f71b17..2590ebc 100644
--- a/java/com/google/gerrit/server/diff/DiffWebLinksProvider.java
+++ b/java/com/google/gerrit/server/diff/DiffWebLinksProvider.java
@@ -24,6 +24,9 @@
   /** Returns links associated with the diff view */
   ImmutableList<DiffWebLinkInfo> getDiffLinks();
 
-  /** Returns links associated with the diff side */
+  /** Returns edit links associated with the diff view */
+  ImmutableList<WebLinkInfo> getEditWebLinks();
+
+  /** Returns file links associated with the diff side */
   ImmutableList<WebLinkInfo> getFileWebLinks(DiffSide.Type fileInfoType);
 }
diff --git a/java/com/google/gerrit/server/events/EventFactory.java b/java/com/google/gerrit/server/events/EventFactory.java
index b648255..d3faac1 100644
--- a/java/com/google/gerrit/server/events/EventFactory.java
+++ b/java/com/google/gerrit/server/events/EventFactory.java
@@ -35,11 +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;
@@ -91,6 +92,7 @@
   private final ChangeKindCache changeKindCache;
   private final Provider<InternalChangeQuery> queryProvider;
   private final IndexConfig indexConfig;
+  private final ChangeMessagesUtil changeMessagesUtil;
 
   @Inject
   EventFactory(
@@ -103,7 +105,8 @@
       ApprovalsUtil approvalsUtil,
       ChangeKindCache changeKindCache,
       Provider<InternalChangeQuery> queryProvider,
-      IndexConfig indexConfig) {
+      IndexConfig indexConfig,
+      ChangeMessagesUtil changeMessagesUtil) {
     this.accountCache = accountCache;
     this.urlFormatter = urlFormatter;
     this.emails = emails;
@@ -114,6 +117,7 @@
     this.changeKindCache = changeKindCache;
     this.queryProvider = queryProvider;
     this.indexConfig = indexConfig;
+    this.changeMessagesUtil = changeMessagesUtil;
   }
 
   public ChangeAttribute asChangeAttribute(Change change) {
@@ -543,7 +547,7 @@
         message.getAuthor() != null
             ? asAccountAttribute(message.getAuthor())
             : asAccountAttribute(myIdent.get());
-    a.message = message.getMessage();
+    a.message = changeMessagesUtil.replaceTemplates(message.getMessage());
     return a;
   }
 
diff --git a/java/com/google/gerrit/server/experiments/ExperimentFeaturesConstants.java b/java/com/google/gerrit/server/experiments/ExperimentFeaturesConstants.java
index af49438..0f85578 100644
--- a/java/com/google/gerrit/server/experiments/ExperimentFeaturesConstants.java
+++ b/java/com/google/gerrit/server/experiments/ExperimentFeaturesConstants.java
@@ -22,6 +22,9 @@
   /** Features that are known experiments and can be referenced in the code. */
   public static String UI_FEATURE_PATCHSET_COMMENTS = "UiFeature__patchset_comments";
 
+  public static String GERRIT_BACKEND_REQUEST_FEATURE_REMOVE_REVISION_ETAG =
+      "GerritBackendRequestFeature__remove_revision_etag";
+
   /** Features, enabled by default in the current release. */
   public static final ImmutableSet<String> DEFAULT_ENABLED_FEATURES =
       ImmutableSet.of(UI_FEATURE_PATCHSET_COMMENTS);
diff --git a/java/com/google/gerrit/server/extensions/events/AssigneeChanged.java b/java/com/google/gerrit/server/extensions/events/AssigneeChanged.java
index 2189690..e31a1b5 100644
--- a/java/com/google/gerrit/server/extensions/events/AssigneeChanged.java
+++ b/java/com/google/gerrit/server/extensions/events/AssigneeChanged.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server.extensions.events;
 
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.entities.Change;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.common.AccountInfo;
@@ -23,6 +22,7 @@
 import com.google.gerrit.extensions.events.AssigneeChangedListener;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.plugincontext.PluginSetContext;
+import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.sql.Timestamp;
@@ -42,14 +42,14 @@
   }
 
   public void fire(
-      Change change, AccountState accountState, AccountState oldAssignee, Timestamp when) {
+      ChangeData changeData, AccountState accountState, AccountState oldAssignee, Timestamp when) {
     if (listeners.isEmpty()) {
       return;
     }
     try {
       Event event =
           new Event(
-              util.changeInfo(change),
+              util.changeInfo(changeData),
               util.accountInfo(accountState),
               util.accountInfo(oldAssignee),
               when);
diff --git a/java/com/google/gerrit/server/extensions/events/ChangeAbandoned.java b/java/com/google/gerrit/server/extensions/events/ChangeAbandoned.java
index c7a9283..cbe7c6b 100644
--- a/java/com/google/gerrit/server/extensions/events/ChangeAbandoned.java
+++ b/java/com/google/gerrit/server/extensions/events/ChangeAbandoned.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server.extensions.events;
 
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
@@ -29,6 +28,7 @@
 import com.google.gerrit.server.patch.PatchListObjectTooLargeException;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.plugincontext.PluginSetContext;
+import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
@@ -49,7 +49,7 @@
   }
 
   public void fire(
-      Change change,
+      ChangeData changeData,
       PatchSet ps,
       AccountState abandoner,
       String reason,
@@ -61,8 +61,8 @@
     try {
       Event event =
           new Event(
-              util.changeInfo(change),
-              util.revisionInfo(change.getProject(), ps),
+              util.changeInfo(changeData),
+              util.revisionInfo(changeData.project(), ps),
               util.accountInfo(abandoner),
               reason,
               when,
diff --git a/java/com/google/gerrit/server/extensions/events/ChangeDeleted.java b/java/com/google/gerrit/server/extensions/events/ChangeDeleted.java
index 1ed6209..23a4583 100644
--- a/java/com/google/gerrit/server/extensions/events/ChangeDeleted.java
+++ b/java/com/google/gerrit/server/extensions/events/ChangeDeleted.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server.extensions.events;
 
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.entities.Change;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.common.AccountInfo;
@@ -23,6 +22,7 @@
 import com.google.gerrit.extensions.events.ChangeDeletedListener;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.plugincontext.PluginSetContext;
+import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.sql.Timestamp;
@@ -41,12 +41,12 @@
     this.util = util;
   }
 
-  public void fire(Change change, AccountState deleter, Timestamp when) {
+  public void fire(ChangeData changeData, AccountState deleter, Timestamp when) {
     if (listeners.isEmpty()) {
       return;
     }
     try {
-      Event event = new Event(util.changeInfo(change), util.accountInfo(deleter), when);
+      Event event = new Event(util.changeInfo(changeData), util.accountInfo(deleter), when);
       listeners.runEach(l -> l.onChangeDeleted(event));
     } catch (StorageException e) {
       logger.atSevere().withCause(e).log("Couldn't fire event");
diff --git a/java/com/google/gerrit/server/extensions/events/ChangeMerged.java b/java/com/google/gerrit/server/extensions/events/ChangeMerged.java
index 06d0008..e4896df 100644
--- a/java/com/google/gerrit/server/extensions/events/ChangeMerged.java
+++ b/java/com/google/gerrit/server/extensions/events/ChangeMerged.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server.extensions.events;
 
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
@@ -29,6 +28,7 @@
 import com.google.gerrit.server.patch.PatchListObjectTooLargeException;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.plugincontext.PluginSetContext;
+import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
@@ -49,15 +49,19 @@
   }
 
   public void fire(
-      Change change, PatchSet ps, AccountState merger, String newRevisionId, Timestamp when) {
+      ChangeData changeData,
+      PatchSet ps,
+      AccountState merger,
+      String newRevisionId,
+      Timestamp when) {
     if (listeners.isEmpty()) {
       return;
     }
     try {
       Event event =
           new Event(
-              util.changeInfo(change),
-              util.revisionInfo(change.getProject(), ps),
+              util.changeInfo(changeData),
+              util.revisionInfo(changeData.project(), ps),
               util.accountInfo(merger),
               newRevisionId,
               when);
diff --git a/java/com/google/gerrit/server/extensions/events/ChangeRestored.java b/java/com/google/gerrit/server/extensions/events/ChangeRestored.java
index 1af56d0..8bd222a 100644
--- a/java/com/google/gerrit/server/extensions/events/ChangeRestored.java
+++ b/java/com/google/gerrit/server/extensions/events/ChangeRestored.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server.extensions.events;
 
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
@@ -29,6 +28,7 @@
 import com.google.gerrit.server.patch.PatchListObjectTooLargeException;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.plugincontext.PluginSetContext;
+import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
@@ -49,15 +49,15 @@
   }
 
   public void fire(
-      Change change, PatchSet ps, AccountState restorer, String reason, Timestamp when) {
+      ChangeData changeData, PatchSet ps, AccountState restorer, String reason, Timestamp when) {
     if (listeners.isEmpty()) {
       return;
     }
     try {
       Event event =
           new Event(
-              util.changeInfo(change),
-              util.revisionInfo(change.getProject(), ps),
+              util.changeInfo(changeData),
+              util.revisionInfo(changeData.project(), ps),
               util.accountInfo(restorer),
               reason,
               when);
diff --git a/java/com/google/gerrit/server/extensions/events/ChangeReverted.java b/java/com/google/gerrit/server/extensions/events/ChangeReverted.java
index d608c52..4a46eb0 100644
--- a/java/com/google/gerrit/server/extensions/events/ChangeReverted.java
+++ b/java/com/google/gerrit/server/extensions/events/ChangeReverted.java
@@ -15,12 +15,12 @@
 package com.google.gerrit.server.extensions.events;
 
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.entities.Change;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.events.ChangeRevertedListener;
 import com.google.gerrit.server.plugincontext.PluginSetContext;
+import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.sql.Timestamp;
@@ -39,12 +39,12 @@
     this.util = util;
   }
 
-  public void fire(Change change, Change revertChange, Timestamp when) {
+  public void fire(ChangeData changeData, ChangeData revertChangeData, Timestamp when) {
     if (listeners.isEmpty()) {
       return;
     }
     try {
-      Event event = new Event(util.changeInfo(change), util.changeInfo(revertChange), when);
+      Event event = new Event(util.changeInfo(changeData), util.changeInfo(revertChangeData), when);
       listeners.runEach(l -> l.onChangeReverted(event));
     } catch (StorageException e) {
       logger.atSevere().withCause(e).log("Couldn't fire event");
diff --git a/java/com/google/gerrit/server/extensions/events/CommentAdded.java b/java/com/google/gerrit/server/extensions/events/CommentAdded.java
index 151298c..20c54cf 100644
--- a/java/com/google/gerrit/server/extensions/events/CommentAdded.java
+++ b/java/com/google/gerrit/server/extensions/events/CommentAdded.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server.extensions.events;
 
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
@@ -30,6 +29,7 @@
 import com.google.gerrit.server.patch.PatchListObjectTooLargeException;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.plugincontext.PluginSetContext;
+import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
@@ -51,7 +51,7 @@
   }
 
   public void fire(
-      Change change,
+      ChangeData changeData,
       PatchSet ps,
       AccountState author,
       String comment,
@@ -64,8 +64,8 @@
     try {
       Event event =
           new Event(
-              util.changeInfo(change),
-              util.revisionInfo(change.getProject(), ps),
+              util.changeInfo(changeData),
+              util.revisionInfo(changeData.project(), ps),
               util.accountInfo(author),
               comment,
               util.approvals(author, approvals, when),
diff --git a/java/com/google/gerrit/server/extensions/events/EventUtil.java b/java/com/google/gerrit/server/extensions/events/EventUtil.java
index a35140a..f0d038a 100644
--- a/java/com/google/gerrit/server/extensions/events/EventUtil.java
+++ b/java/com/google/gerrit/server/extensions/events/EventUtil.java
@@ -18,7 +18,6 @@
 import com.google.common.collect.Sets;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Account;
-import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.client.ListChangesOption;
@@ -84,8 +83,8 @@
     this.changeOptions = parseChangeListOptions(gerritConfig);
   }
 
-  public ChangeInfo changeInfo(Change change) {
-    return changeJsonFactory.create(changeOptions).format(change);
+  public ChangeInfo changeInfo(ChangeData changeData) {
+    return changeJsonFactory.create(changeOptions).format(changeData);
   }
 
   public RevisionInfo revisionInfo(Project project, PatchSet ps)
diff --git a/java/com/google/gerrit/server/extensions/events/HashtagsEdited.java b/java/com/google/gerrit/server/extensions/events/HashtagsEdited.java
index 5d9c5c2..846257c 100644
--- a/java/com/google/gerrit/server/extensions/events/HashtagsEdited.java
+++ b/java/com/google/gerrit/server/extensions/events/HashtagsEdited.java
@@ -16,7 +16,6 @@
 
 import com.google.common.collect.ImmutableSortedSet;
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.entities.Change;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.common.AccountInfo;
@@ -24,6 +23,7 @@
 import com.google.gerrit.extensions.events.HashtagsEditedListener;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.plugincontext.PluginSetContext;
+import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.sql.Timestamp;
@@ -45,7 +45,7 @@
   }
 
   public void fire(
-      Change change,
+      ChangeData changeData,
       AccountState editor,
       ImmutableSortedSet<String> hashtags,
       Set<String> added,
@@ -57,7 +57,12 @@
     try {
       Event event =
           new Event(
-              util.changeInfo(change), util.accountInfo(editor), hashtags, added, removed, when);
+              util.changeInfo(changeData),
+              util.accountInfo(editor),
+              hashtags,
+              added,
+              removed,
+              when);
       listeners.runEach(l -> l.onHashtagsEdited(event));
     } catch (StorageException e) {
       logger.atSevere().withCause(e).log("Couldn't fire event");
diff --git a/java/com/google/gerrit/server/extensions/events/PrivateStateChanged.java b/java/com/google/gerrit/server/extensions/events/PrivateStateChanged.java
index bcc6b8e..d81068c 100644
--- a/java/com/google/gerrit/server/extensions/events/PrivateStateChanged.java
+++ b/java/com/google/gerrit/server/extensions/events/PrivateStateChanged.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server.extensions.events;
 
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
@@ -28,6 +27,7 @@
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.plugincontext.PluginSetContext;
+import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
@@ -47,15 +47,15 @@
     this.util = util;
   }
 
-  public void fire(Change change, PatchSet patchSet, AccountState account, Timestamp when) {
+  public void fire(ChangeData changeData, PatchSet patchSet, AccountState account, Timestamp when) {
     if (listeners.isEmpty()) {
       return;
     }
     try {
       Event event =
           new Event(
-              util.changeInfo(change),
-              util.revisionInfo(change.getProject(), patchSet),
+              util.changeInfo(changeData),
+              util.revisionInfo(changeData.project(), patchSet),
               util.accountInfo(account),
               when);
       listeners.runEach(l -> l.onPrivateStateChanged(event));
diff --git a/java/com/google/gerrit/server/extensions/events/ReviewerAdded.java b/java/com/google/gerrit/server/extensions/events/ReviewerAdded.java
index 35e7828..ba73ca1 100644
--- a/java/com/google/gerrit/server/extensions/events/ReviewerAdded.java
+++ b/java/com/google/gerrit/server/extensions/events/ReviewerAdded.java
@@ -16,7 +16,6 @@
 
 import com.google.common.collect.Lists;
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
@@ -30,6 +29,7 @@
 import com.google.gerrit.server.patch.PatchListObjectTooLargeException;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.plugincontext.PluginSetContext;
+import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
@@ -51,7 +51,7 @@
   }
 
   public void fire(
-      Change change,
+      ChangeData changeData,
       PatchSet patchSet,
       List<AccountState> reviewers,
       AccountState adder,
@@ -63,8 +63,8 @@
     try {
       Event event =
           new Event(
-              util.changeInfo(change),
-              util.revisionInfo(change.getProject(), patchSet),
+              util.changeInfo(changeData),
+              util.revisionInfo(changeData.project(), patchSet),
               Lists.transform(reviewers, util::accountInfo),
               util.accountInfo(adder),
               when);
diff --git a/java/com/google/gerrit/server/extensions/events/ReviewerDeleted.java b/java/com/google/gerrit/server/extensions/events/ReviewerDeleted.java
index 147f980..80037bc 100644
--- a/java/com/google/gerrit/server/extensions/events/ReviewerDeleted.java
+++ b/java/com/google/gerrit/server/extensions/events/ReviewerDeleted.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server.extensions.events;
 
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
@@ -30,6 +29,7 @@
 import com.google.gerrit.server.patch.PatchListObjectTooLargeException;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.plugincontext.PluginSetContext;
+import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
@@ -51,7 +51,7 @@
   }
 
   public void fire(
-      Change change,
+      ChangeData changeData,
       PatchSet patchSet,
       AccountState reviewer,
       AccountState remover,
@@ -66,8 +66,8 @@
     try {
       Event event =
           new Event(
-              util.changeInfo(change),
-              util.revisionInfo(change.getProject(), patchSet),
+              util.changeInfo(changeData),
+              util.revisionInfo(changeData.project(), patchSet),
               util.accountInfo(reviewer),
               util.accountInfo(remover),
               message,
diff --git a/java/com/google/gerrit/server/extensions/events/RevisionCreated.java b/java/com/google/gerrit/server/extensions/events/RevisionCreated.java
index 8179e9a..4c78216 100644
--- a/java/com/google/gerrit/server/extensions/events/RevisionCreated.java
+++ b/java/com/google/gerrit/server/extensions/events/RevisionCreated.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server.extensions.events;
 
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
@@ -30,6 +29,7 @@
 import com.google.gerrit.server.patch.PatchListObjectTooLargeException;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.plugincontext.PluginSetContext;
+import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
@@ -44,7 +44,7 @@
       new RevisionCreated() {
         @Override
         public void fire(
-            Change change,
+            ChangeData changeData,
             PatchSet patchSet,
             AccountState uploader,
             Timestamp when,
@@ -66,7 +66,7 @@
   }
 
   public void fire(
-      Change change,
+      ChangeData changeData,
       PatchSet patchSet,
       AccountState uploader,
       Timestamp when,
@@ -77,8 +77,8 @@
     try {
       Event event =
           new Event(
-              util.changeInfo(change),
-              util.revisionInfo(change.getProject(), patchSet),
+              util.changeInfo(changeData),
+              util.revisionInfo(changeData.project(), patchSet),
               util.accountInfo(uploader),
               when,
               notify.handling());
diff --git a/java/com/google/gerrit/server/extensions/events/TopicEdited.java b/java/com/google/gerrit/server/extensions/events/TopicEdited.java
index e4089b1..08b47f1 100644
--- a/java/com/google/gerrit/server/extensions/events/TopicEdited.java
+++ b/java/com/google/gerrit/server/extensions/events/TopicEdited.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server.extensions.events;
 
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.entities.Change;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.common.AccountInfo;
@@ -23,6 +22,7 @@
 import com.google.gerrit.extensions.events.TopicEditedListener;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.plugincontext.PluginSetContext;
+import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.sql.Timestamp;
@@ -41,13 +41,14 @@
     this.util = util;
   }
 
-  public void fire(Change change, AccountState account, String oldTopicName, Timestamp when) {
+  public void fire(
+      ChangeData changeData, AccountState account, String oldTopicName, Timestamp when) {
     if (listeners.isEmpty()) {
       return;
     }
     try {
       Event event =
-          new Event(util.changeInfo(change), util.accountInfo(account), oldTopicName, when);
+          new Event(util.changeInfo(changeData), util.accountInfo(account), oldTopicName, when);
       listeners.runEach(l -> l.onTopicEdited(event));
     } catch (StorageException e) {
       logger.atSevere().withCause(e).log("Couldn't fire event");
diff --git a/java/com/google/gerrit/server/extensions/events/VoteDeleted.java b/java/com/google/gerrit/server/extensions/events/VoteDeleted.java
index ef4e461..244e46c 100644
--- a/java/com/google/gerrit/server/extensions/events/VoteDeleted.java
+++ b/java/com/google/gerrit/server/extensions/events/VoteDeleted.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server.extensions.events;
 
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
@@ -30,6 +29,7 @@
 import com.google.gerrit.server.patch.PatchListObjectTooLargeException;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.plugincontext.PluginSetContext;
+import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
@@ -51,7 +51,7 @@
   }
 
   public void fire(
-      Change change,
+      ChangeData changeData,
       PatchSet ps,
       AccountState reviewer,
       Map<String, Short> approvals,
@@ -66,8 +66,8 @@
     try {
       Event event =
           new Event(
-              util.changeInfo(change),
-              util.revisionInfo(change.getProject(), ps),
+              util.changeInfo(changeData),
+              util.revisionInfo(changeData.project(), ps),
               util.accountInfo(reviewer),
               util.approvals(remover, approvals, when),
               util.approvals(remover, oldApprovals, when),
diff --git a/java/com/google/gerrit/server/extensions/events/WorkInProgressStateChanged.java b/java/com/google/gerrit/server/extensions/events/WorkInProgressStateChanged.java
index 06b244b..bfc068d 100644
--- a/java/com/google/gerrit/server/extensions/events/WorkInProgressStateChanged.java
+++ b/java/com/google/gerrit/server/extensions/events/WorkInProgressStateChanged.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server.extensions.events;
 
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
@@ -28,6 +27,7 @@
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.plugincontext.PluginSetContext;
+import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
@@ -41,7 +41,8 @@
   public static final WorkInProgressStateChanged DISABLED =
       new WorkInProgressStateChanged() {
         @Override
-        public void fire(Change change, PatchSet patchSet, AccountState account, Timestamp when) {}
+        public void fire(
+            ChangeData changeData, PatchSet patchSet, AccountState account, Timestamp when) {}
       };
 
   private final PluginSetContext<WorkInProgressStateChangedListener> listeners;
@@ -59,15 +60,15 @@
     this.util = null;
   }
 
-  public void fire(Change change, PatchSet patchSet, AccountState account, Timestamp when) {
+  public void fire(ChangeData changeData, PatchSet patchSet, AccountState account, Timestamp when) {
     if (listeners.isEmpty()) {
       return;
     }
     try {
       Event event =
           new Event(
-              util.changeInfo(change),
-              util.revisionInfo(change.getProject(), patchSet),
+              util.changeInfo(changeData),
+              util.revisionInfo(changeData.project(), patchSet),
               util.accountInfo(account),
               when);
       listeners.runEach(l -> l.onWorkInProgressStateChanged(event));
diff --git a/java/com/google/gerrit/server/extensions/webui/UiActions.java b/java/com/google/gerrit/server/extensions/webui/UiActions.java
index 0bc3d5c..a7f6b48 100644
--- a/java/com/google/gerrit/server/extensions/webui/UiActions.java
+++ b/java/com/google/gerrit/server/extensions/webui/UiActions.java
@@ -143,11 +143,12 @@
     }
 
     String name = e.getExportName().substring(d + 1);
-    UiAction.Description dsc;
+    UiAction.Description dsc = null;
     try (Timer1.Context<String> ignored = uiActionLatency.start(name)) {
       dsc = ((UiAction<R>) view).getDescription(resource);
+    } catch (Exception ex) {
+      logger.atSevere().withCause(ex).log("Unable to render UIAction. Will omit from actions");
     }
-
     if (dsc == null) {
       return null;
     }
diff --git a/java/com/google/gerrit/server/git/CommitUtil.java b/java/com/google/gerrit/server/git/CommitUtil.java
index 47cbd60..0e0185a 100644
--- a/java/com/google/gerrit/server/git/CommitUtil.java
+++ b/java/com/google/gerrit/server/git/CommitUtil.java
@@ -22,7 +22,6 @@
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Account.Id;
 import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.api.changes.RevertInput;
@@ -30,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;
@@ -48,7 +47,7 @@
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
-import com.google.gerrit.server.update.Context;
+import com.google.gerrit.server.update.PostUpdateContext;
 import com.google.gerrit.server.update.UpdateException;
 import com.google.gerrit.server.util.CommitMessageUtil;
 import com.google.inject.Inject;
@@ -310,8 +309,9 @@
     }
 
     @Override
-    public void postUpdate(Context ctx) throws Exception {
-      changeReverted.fire(change, ins.getChange(), ctx.getWhen());
+    public void postUpdate(PostUpdateContext ctx) throws Exception {
+      changeReverted.fire(
+          ctx.getChangeData(change), ctx.getChangeData(ins.getChange()), ctx.getWhen());
       try {
         RevertedSender emailSender = revertedSenderFactory.create(ctx.getProject(), change.getId());
         emailSender.setFrom(ctx.getAccountId());
@@ -335,14 +335,10 @@
 
     @Override
     public boolean updateChange(ChangeContext ctx) {
-      Change change = ctx.getChange();
-      PatchSet.Id patchSetId = change.currentPatchSetId();
-      ChangeMessage changeMessage =
-          ChangeMessagesUtil.newMessage(
-              ctx,
-              "Created a revert of this change as I" + computedChangeId.name(),
-              ChangeMessagesUtil.TAG_REVERT);
-      cmUtil.addChangeMessage(ctx.getUpdate(patchSetId), changeMessage);
+      cmUtil.setChangeMessage(
+          ctx,
+          "Created a revert of this change as I" + computedChangeId.name(),
+          ChangeMessagesUtil.TAG_REVERT);
       return true;
     }
   }
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/MergedByPushOp.java b/java/com/google/gerrit/server/git/MergedByPushOp.java
index 78cb013..426f8db 100644
--- a/java/com/google/gerrit/server/git/MergedByPushOp.java
+++ b/java/com/google/gerrit/server/git/MergedByPushOp.java
@@ -18,7 +18,6 @@
 
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetInfo;
@@ -33,7 +32,7 @@
 import com.google.gerrit.server.patch.PatchSetInfoFactory;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
-import com.google.gerrit.server.update.Context;
+import com.google.gerrit.server.update.PostUpdateContext;
 import com.google.gerrit.server.util.RequestScopePropagator;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -169,16 +168,13 @@
       }
     }
     msgBuf.append(".");
-    ChangeMessage msg =
-        ChangeMessagesUtil.newMessage(
-            psId, ctx.getUser(), ctx.getWhen(), msgBuf.toString(), ChangeMessagesUtil.TAG_MERGED);
-    cmUtil.addChangeMessage(update, msg);
+    cmUtil.setChangeMessage(update, msgBuf.toString(), ChangeMessagesUtil.TAG_MERGED);
     update.putApproval(LabelId.legacySubmit().get(), (short) 1);
     return true;
   }
 
   @Override
-  public void postUpdate(Context ctx) {
+  public void postUpdate(PostUpdateContext ctx) {
     if (!correctBranch) {
       return;
     }
@@ -214,7 +210,8 @@
                   }
                 }));
 
-    changeMerged.fire(change, patchSet, ctx.getAccount(), mergeResultRevId, ctx.getWhen());
+    changeMerged.fire(
+        ctx.getChangeData(change), patchSet, ctx.getAccount(), mergeResultRevId, ctx.getWhen());
   }
 
   private PatchSetInfo getPatchSetInfo(ChangeContext ctx) throws IOException {
diff --git a/java/com/google/gerrit/server/git/TagSet.java b/java/com/google/gerrit/server/git/TagSet.java
index d6220a2..57fcf71 100644
--- a/java/com/google/gerrit/server/git/TagSet.java
+++ b/java/com/google/gerrit/server/git/TagSet.java
@@ -43,6 +43,17 @@
 import org.eclipse.jgit.revwalk.RevSort;
 import org.eclipse.jgit.revwalk.RevWalk;
 
+/**
+ * Builds a set of tags, and tracks which tags are reachable from which non-tag, non-special refs.
+ * An instance is constructed from a snapshot of the ref database. TagSets can be incrementally
+ * updated to newer states of the RefDatabase using the refresh method. The updateFastForward method
+ * can do partial updates based on individual refs moving forward.
+ *
+ * <p>This set is used to determine which tags should be advertised when only a subset of refs is
+ * visible to a user.
+ *
+ * <p>TagSets can be serialized for use in a persisted TagCache
+ */
 class TagSet {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
   private static final ImmutableSet<String> SKIPPABLE_REF_PREFIXES =
@@ -53,7 +64,14 @@
           RefNames.REFS_STARRED_CHANGES);
 
   private final Project.NameKey projectName;
+
+  /**
+   * refName => ref. CachedRef is a ref that has an integer identity, used for indexing into
+   * BitSets.
+   */
   private final Map<String, CachedRef> refs;
+
+  /** ObjectId-pointed-to-by-tag => Tag */
   private final ObjectIdOwnerMap<Tag> tags;
 
   TagSet(Project.NameKey projectName) {
@@ -86,13 +104,16 @@
     return tags;
   }
 
+  /** Record a fast-forward update of the given ref. This is called from multiple threads. */
   boolean updateFastForward(String refName, ObjectId oldValue, ObjectId newValue) {
+    // this looks fishy: refs is not a thread-safe data structure, but it is mutated in build() and
+    // rebuild(). TagSetHolder prohibits concurrent writes through the buildLock mutex, but it does
+    // not prohibit concurrent read/write.
     CachedRef ref = refs.get(refName);
     if (ref != null) {
       // compareAndSet works on reference equality, but this operation
       // wants to use object equality. Switch out oldValue with cur so the
       // compareAndSet will function correctly for this operation.
-      //
       ObjectId cur = ref.get();
       if (cur.equals(oldValue)) {
         return ref.compareAndSet(cur, newValue);
@@ -391,6 +412,9 @@
   }
 
   static final class Tag extends ObjectIdOwnerMap.Entry {
+
+    // a RefCache.flag => isVisible map. This reference is aliased to the
+    // bitset in TagCommit.refFlags.
     @VisibleForTesting final BitSet refFlags;
 
     Tag(AnyObjectId id, BitSet flags) {
@@ -407,11 +431,12 @@
       return MoreObjects.toStringHelper(this).addValue(name()).add("refFlags", refFlags).toString();
     }
   }
-
+  /** A ref along with its index into BitSet. */
   @VisibleForTesting
   static final class CachedRef extends AtomicReference<ObjectId> {
     private static final long serialVersionUID = 1L;
 
+    /** unique identifier for this ref within the TagSet. */
     final int flag;
 
     CachedRef(Ref ref, int flag) {
@@ -444,7 +469,9 @@
     }
   }
 
+  // TODO(hanwen): this would be better named as CommitWithReachability, as it also holds non-tags.
   private static final class TagCommit extends RevCommit {
+    /** CachedRef.flag => isVisible, indicating if this commit is reachable from the ref. */
     final BitSet refFlags;
 
     TagCommit(AnyObjectId id) {
diff --git a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
index f35b3dd..ec2ed4f 100644
--- a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
+++ b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
@@ -100,7 +100,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.ChangeUtil;
 import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.CreateGroupPermissionSyncer;
@@ -111,6 +110,7 @@
 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.change.ChangeInserter;
 import com.google.gerrit.server.change.NotifyResolver;
 import com.google.gerrit.server.change.SetHashtagsOp;
@@ -172,7 +172,7 @@
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
-import com.google.gerrit.server.update.Context;
+import com.google.gerrit.server.update.PostUpdateContext;
 import com.google.gerrit.server.update.RepoContext;
 import com.google.gerrit.server.update.RepoOnlyOp;
 import com.google.gerrit.server.update.RetryHelper;
@@ -1531,6 +1531,14 @@
     @Option(name = "--remove-private", usage = "remove privacy flag from updated change")
     boolean removePrivate;
 
+    /**
+     * The skip-validation option is defined to allow parsing it using the {@link #cmdLineParser}.
+     * However we do not allow this option for pushes to magic branches. This option is used to fail
+     * with a proper error message.
+     */
+    @Option(name = "--skip-validation", usage = "skips commit validation")
+    boolean skipValidation;
+
     @Option(
         name = "--wip",
         aliases = {"-work-in-progress"},
@@ -1675,7 +1683,7 @@
      * account IDs computed from the commit message itself.
      *
      * @param additionalRecipients recipients parsed from the commit.
-     * @return set of reviewer strings to pass to {@code ReviewerAdder}.
+     * @return set of reviewer strings to pass to {@code ReviewerModifier}.
      */
     ImmutableSet<String> getCombinedReviewers(MailRecipients additionalRecipients) {
       return getCombinedReviewers(reviewer, additionalRecipients.getReviewers());
@@ -1689,7 +1697,7 @@
      * account IDs computed from the commit message itself.
      *
      * @param additionalRecipients recipients parsed from the commit.
-     * @return set of CC strings to pass to {@code ReviewerAdder}.
+     * @return set of CC strings to pass to {@code ReviewerModifier}.
      */
     ImmutableSet<String> getCombinedCcs(MailRecipients additionalRecipients) {
       return getCombinedReviewers(cc, additionalRecipients.getCcOnly());
@@ -1814,6 +1822,14 @@
         ref = null; // never happens
       }
 
+      if (magicBranch.skipValidation) {
+        reject(
+            cmd,
+            String.format(
+                "\"--%s\" option is only supported for direct push", PUSH_OPTION_SKIP_VALIDATION));
+        return;
+      }
+
       if (magicBranch.topic != null && magicBranch.topic.length() > ChangeUtil.TOPIC_MAX_LENGTH) {
         reject(
             cmd, String.format("topic length exceeds the limit (%d)", ChangeUtil.TOPIC_MAX_LENGTH));
@@ -3143,7 +3159,7 @@
     }
 
     @Override
-    public void postUpdate(Context ctx) {
+    public void postUpdate(PostUpdateContext ctx) {
       String refName = cmd.getRefName();
       if (cmd.getType() == ReceiveCommand.Type.UPDATE) { // aka fast-forward
         logger.atFine().log("Updating tag cache on fast-forward of %s", cmd.getRefName());
diff --git a/java/com/google/gerrit/server/git/receive/ReceiveCommitsAdvertiseRefsHook.java b/java/com/google/gerrit/server/git/receive/ReceiveCommitsAdvertiseRefsHook.java
index 6c1f097..cc203ad 100644
--- a/java/com/google/gerrit/server/git/receive/ReceiveCommitsAdvertiseRefsHook.java
+++ b/java/com/google/gerrit/server/git/receive/ReceiveCommitsAdvertiseRefsHook.java
@@ -27,10 +27,9 @@
 import com.google.gerrit.server.git.HookUtil;
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.ChangePredicates;
 import com.google.gerrit.server.query.change.ChangeStatusPredicate;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
-import com.google.gerrit.server.query.change.OwnerPredicate;
-import com.google.gerrit.server.query.change.ProjectPredicate;
 import com.google.gerrit.server.util.MagicBranch;
 import com.google.inject.Provider;
 import java.io.IOException;
@@ -116,9 +115,9 @@
               .setLimit(limit)
               .query(
                   Predicate.and(
-                      new ProjectPredicate(projectName.get()),
+                      ChangePredicates.project(projectName),
                       ChangeStatusPredicate.open(),
-                      new OwnerPredicate(user)))) {
+                      ChangePredicates.owner(user)))) {
         PatchSet ps = cd.currentPatchSet();
         if (ps != null) {
           // Ensure we actually observed a patch set ref pointing to this
diff --git a/java/com/google/gerrit/server/git/receive/ReplaceOp.java b/java/com/google/gerrit/server/git/receive/ReplaceOp.java
index ce62d7a..b55e91b 100644
--- a/java/com/google/gerrit/server/git/receive/ReplaceOp.java
+++ b/java/com/google/gerrit/server/git/receive/ReplaceOp.java
@@ -16,7 +16,7 @@
 
 import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.common.collect.ImmutableSet.toImmutableSet;
-import static com.google.gerrit.server.change.ReviewerAdder.newAddReviewerInputFromCommitIdentity;
+import static com.google.gerrit.server.change.ReviewerModifier.newReviewerInputFromCommitIdentity;
 import static com.google.gerrit.server.mail.MailUtil.getRecipientsFromFooters;
 import static com.google.gerrit.server.mail.MailUtil.getRecipientsFromReviewers;
 import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
@@ -30,32 +30,31 @@
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.entities.PatchSetInfo;
 import com.google.gerrit.entities.SubmissionId;
-import com.google.gerrit.extensions.api.changes.AddReviewerInput;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.api.changes.ReviewerInput;
 import com.google.gerrit.extensions.client.ChangeKind;
 import com.google.gerrit.extensions.client.ReviewerState;
 import com.google.gerrit.extensions.registration.DynamicItem;
 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;
-import com.google.gerrit.server.change.ReviewerAdder;
-import com.google.gerrit.server.change.ReviewerAdder.InternalAddReviewerInput;
-import com.google.gerrit.server.change.ReviewerAdder.ReviewerAddition;
-import com.google.gerrit.server.change.ReviewerAdder.ReviewerAdditionList;
+import com.google.gerrit.server.change.ReviewerModifier;
+import com.google.gerrit.server.change.ReviewerModifier.InternalReviewerInput;
+import com.google.gerrit.server.change.ReviewerModifier.ReviewerModification;
+import com.google.gerrit.server.change.ReviewerModifier.ReviewerModificationList;
 import com.google.gerrit.server.config.SendEmailExecutor;
 import com.google.gerrit.server.config.UrlFormatter;
 import com.google.gerrit.server.extensions.events.CommentAdded;
@@ -74,6 +73,7 @@
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.Context;
+import com.google.gerrit.server.update.PostUpdateContext;
 import com.google.gerrit.server.update.RepoContext;
 import com.google.gerrit.server.util.RequestScopePropagator;
 import com.google.gerrit.server.validators.ValidationException;
@@ -130,7 +130,7 @@
   private final PatchSetUtil psUtil;
   private final ReplacePatchSetSender.Factory replacePatchSetFactory;
   private final ProjectCache projectCache;
-  private final ReviewerAdder reviewerAdder;
+  private final ReviewerModifier reviewerModifier;
   private final Change change;
   private final MessageIdGenerator messageIdGenerator;
   private final DynamicItem<UrlFormatter> urlFormatter;
@@ -154,11 +154,11 @@
   private ChangeNotes notes;
   private PatchSet newPatchSet;
   private ChangeKind changeKind;
-  private ChangeMessage msg;
+  private String mailMessage;
   private String rejectMessage;
   private MergedByPushOp mergedByPushOp;
   private RequestScopePropagator requestScopePropagator;
-  private ReviewerAdditionList reviewerAdditions;
+  private ReviewerModificationList reviewerAdditions;
   private MailRecipients oldRecipients;
 
   @Inject
@@ -175,7 +175,7 @@
       ReplacePatchSetSender.Factory replacePatchSetFactory,
       ProjectCache projectCache,
       @SendEmailExecutor ExecutorService sendEmailExecutor,
-      ReviewerAdder reviewerAdder,
+      ReviewerModifier reviewerModifier,
       Change change,
       MessageIdGenerator messageIdGenerator,
       DynamicItem<UrlFormatter> urlFormatter,
@@ -203,7 +203,7 @@
     this.replacePatchSetFactory = replacePatchSetFactory;
     this.projectCache = projectCache;
     this.sendEmailExecutor = sendEmailExecutor;
-    this.reviewerAdder = reviewerAdder;
+    this.reviewerModifier = reviewerModifier;
     this.change = change;
     this.messageIdGenerator = messageIdGenerator;
     this.urlFormatter = urlFormatter;
@@ -323,12 +323,13 @@
         update, projectState.getLabelTypes(), newPatchSet, ctx.getUser(), approvals);
 
     reviewerAdditions =
-        reviewerAdder.prepare(
+        reviewerModifier.prepare(
             ctx.getNotes(),
             ctx.getUser(),
             getReviewerInputs(magicBranch, fromFooters, ctx.getChange(), info),
             true);
-    Optional<ReviewerAddition> reviewerError = reviewerAdditions.getFailures().stream().findFirst();
+    Optional<ReviewerModification> reviewerError =
+        reviewerAdditions.getFailures().stream().findFirst();
     if (reviewerError.isPresent()) {
       throw new UnprocessableEntityException(reviewerError.get().result.error);
     }
@@ -341,8 +342,7 @@
       update.putReviewer(ctx.getAccountId(), REVIEWER);
     }
 
-    msg = createChangeMessage(ctx, reviewMessage);
-    cmUtil.addChangeMessage(update, msg);
+    mailMessage = insertChangeMessage(update, ctx, reviewMessage);
 
     if (mergedByPushOp == null) {
       resetChange(ctx);
@@ -353,24 +353,24 @@
     return true;
   }
 
-  private ImmutableList<AddReviewerInput> getReviewerInputs(
+  private ImmutableList<ReviewerInput> getReviewerInputs(
       @Nullable MagicBranchInput magicBranch,
       MailRecipients fromFooters,
       Change change,
       PatchSetInfo psInfo) {
     // Disable individual emails when adding reviewers, as all reviewers will receive the single
     // bulk new change email.
-    Stream<AddReviewerInput> inputs =
+    Stream<ReviewerInput> inputs =
         Streams.concat(
             Streams.stream(
-                newAddReviewerInputFromCommitIdentity(
+                newReviewerInputFromCommitIdentity(
                     change,
                     psInfo.getCommitId(),
                     psInfo.getAuthor().getAccount(),
                     NotifyHandling.NONE,
                     newPatchSet.uploader())),
             Streams.stream(
-                newAddReviewerInputFromCommitIdentity(
+                newReviewerInputFromCommitIdentity(
                     change,
                     psInfo.getCommitId(),
                     psInfo.getCommitter().getAccount(),
@@ -381,28 +381,27 @@
           Streams.concat(
               inputs,
               magicBranch.getCombinedReviewers(fromFooters).stream()
-                  .map(r -> newAddReviewerInput(r, ReviewerState.REVIEWER)),
+                  .map(r -> newReviewerInput(r, ReviewerState.REVIEWER)),
               magicBranch.getCombinedCcs(fromFooters).stream()
-                  .map(r -> newAddReviewerInput(r, ReviewerState.CC)));
+                  .map(r -> newReviewerInput(r, ReviewerState.CC)));
     }
     return inputs.collect(toImmutableList());
   }
 
-  private static InternalAddReviewerInput newAddReviewerInput(
-      String reviewer, ReviewerState state) {
+  private static InternalReviewerInput newReviewerInput(String reviewer, ReviewerState state) {
     // Disable individual emails when adding reviewers, as all reviewers will receive the single
     // bulk new patch set email.
-    InternalAddReviewerInput input =
-        ReviewerAdder.newAddReviewerInput(reviewer, state, NotifyHandling.NONE);
+    InternalReviewerInput input =
+        ReviewerModifier.newReviewerInput(reviewer, state, NotifyHandling.NONE);
 
     // Ignore failures for reasons like the reviewer being inactive or being unable to see the
     // change. See discussion in ChangeInserter.
-    input.otherFailureBehavior = ReviewerAdder.FailureBehavior.IGNORE;
+    input.otherFailureBehavior = ReviewerModifier.FailureBehavior.IGNORE;
 
     return input;
   }
 
-  private ChangeMessage createChangeMessage(ChangeContext ctx, String reviewMessage)
+  private String insertChangeMessage(ChangeUpdate update, ChangeContext ctx, String reviewMessage)
       throws IOException {
     String approvalMessage =
         ApprovalsUtil.renderMessageWithApprovals(
@@ -421,12 +420,8 @@
     if (magicBranch != null && magicBranch.workInProgress) {
       workInProgress = true;
     }
-    return ChangeMessagesUtil.newMessage(
-        patchSetId,
-        ctx.getUser(),
-        ctx.getWhen(),
-        message.toString(),
-        ChangeMessagesUtil.uploadedPatchSetTag(workInProgress));
+    return cmUtil.setChangeMessage(
+        update, message.toString(), ChangeMessagesUtil.uploadedPatchSetTag(workInProgress));
   }
 
   private String changeKindMessage(ChangeKind changeKind) {
@@ -493,7 +488,7 @@
   }
 
   @Override
-  public void postUpdate(Context ctx) throws Exception {
+  public void postUpdate(PostUpdateContext ctx) throws Exception {
     reviewerAdditions.postUpdate(ctx);
     if (changeKind != ChangeKind.TRIVIAL_REBASE) {
       // TODO(dborowitz): Merge email templates so we only have to send one.
@@ -506,7 +501,8 @@
       }
     }
     NotifyResolver.Result notify = ctx.getNotify(notes.getChangeId());
-    revisionCreated.fire(notes.getChange(), newPatchSet, ctx.getAccount(), ctx.getWhen(), notify);
+    revisionCreated.fire(
+        ctx.getChangeData(notes), newPatchSet, ctx.getAccount(), ctx.getWhen(), notify);
     try {
       fireApprovalsEvent(ctx);
     } catch (Exception e) {
@@ -531,7 +527,7 @@
             replacePatchSetFactory.create(projectState.getNameKey(), notes.getChangeId());
         emailSender.setFrom(ctx.getAccount().account().id());
         emailSender.setPatchSet(newPatchSet, info);
-        emailSender.setChangeMessage(msg.getMessage(), ctx.getWhen());
+        emailSender.setChangeMessage(mailMessage, ctx.getWhen());
         emailSender.setNotify(ctx.getNotify(notes.getChangeId()));
         emailSender.addReviewers(
             Streams.concat(
@@ -560,7 +556,7 @@
     }
   }
 
-  private void fireApprovalsEvent(Context ctx) {
+  private void fireApprovalsEvent(PostUpdateContext ctx) {
     if (approvals.isEmpty()) {
       return;
     }
@@ -588,7 +584,7 @@
       }
     }
     commentAdded.fire(
-        notes.getChange(),
+        ctx.getChangeData(notes),
         newPatchSet,
         ctx.getAccount(),
         null,
diff --git a/java/com/google/gerrit/server/git/validators/MergeValidators.java b/java/com/google/gerrit/server/git/validators/MergeValidators.java
index cbaa121..6b145ca 100644
--- a/java/com/google/gerrit/server/git/validators/MergeValidators.java
+++ b/java/com/google/gerrit/server/git/validators/MergeValidators.java
@@ -51,6 +51,7 @@
 import java.util.Objects;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
 
 /**
@@ -104,7 +105,8 @@
             new PluginMergeValidationListener(mergeValidationListeners),
             projectConfigValidatorFactory.create(),
             accountValidatorFactory.create(),
-            groupValidatorFactory.create());
+            groupValidatorFactory.create(),
+            new DestBranchRefValidator());
 
     for (MergeValidationListener validator : validators) {
       validator.onPreMerge(repo, revWalk, commit, destProject, destBranch, patchSetId, caller);
@@ -198,7 +200,7 @@
                   throw new MergeValidationException(SET_BY_ADMIN, e);
                 } catch (PermissionBackendException e) {
                   logger.atWarning().withCause(e).log("Cannot check ADMINISTRATE_SERVER");
-                  throw new MergeValidationException("validation unavailable");
+                  throw new MergeValidationException("validation unavailable", e);
                 }
               } else {
                 try {
@@ -210,7 +212,7 @@
                   throw new MergeValidationException(SET_BY_OWNER, e);
                 } catch (PermissionBackendException e) {
                   logger.atWarning().withCause(e).log("Cannot check WRITE_CONFIG");
-                  throw new MergeValidationException("validation unavailable");
+                  throw new MergeValidationException("validation unavailable", e);
                 }
               }
               if (allUsersName.equals(destProject.getNameKey())
@@ -317,7 +319,7 @@
         }
       } catch (StorageException e) {
         logger.atSevere().withCause(e).log("Cannot validate account update");
-        throw new MergeValidationException("account validation unavailable");
+        throw new MergeValidationException("account validation unavailable", e);
       }
 
       try {
@@ -329,7 +331,7 @@
         }
       } catch (IOException e) {
         logger.atSevere().withCause(e).log("Cannot validate account update");
-        throw new MergeValidationException("account validation unavailable");
+        throw new MergeValidationException("account validation unavailable", e);
       }
     }
   }
@@ -366,4 +368,34 @@
       throw new MergeValidationException("group update not allowed");
     }
   }
+
+  /**
+   * Validator to ensure that destBranch is not a symbolic reference (an attempt to merge into a
+   * symbolic ref branch leads to LOCK_FAILURE exception).
+   */
+  private static class DestBranchRefValidator implements MergeValidationListener {
+    @Override
+    public void onPreMerge(
+        Repository repo,
+        CodeReviewRevWalk revWalk,
+        CodeReviewCommit commit,
+        ProjectState destProject,
+        BranchNameKey destBranch,
+        PatchSet.Id patchSetId,
+        IdentifiedUser caller)
+        throws MergeValidationException {
+      try {
+        Ref ref = repo.exactRef(destBranch.branch());
+        // Usually the target branch exists, but there is an exception for some branches (see
+        // {@link com.google.gerrit.server.git.receive.ReceiveCommits} for details).
+        // Such non-existing branches should be ignored.
+        if (ref != null && ref.isSymbolic()) {
+          throw new MergeValidationException("the target branch is a symbolic ref");
+        }
+      } catch (IOException e) {
+        logger.atSevere().withCause(e).log("Cannot validate destination branch");
+        throw new MergeValidationException("symref validation unavailable", e);
+      }
+    }
+  }
 }
diff --git a/java/com/google/gerrit/server/group/db/GroupConfig.java b/java/com/google/gerrit/server/group/db/GroupConfig.java
index b2d1849b..c187186 100644
--- a/java/com/google/gerrit/server/group/db/GroupConfig.java
+++ b/java/com/google/gerrit/server/group/db/GroupConfig.java
@@ -95,19 +95,18 @@
   private static final Pattern LINE_SEPARATOR_PATTERN = Pattern.compile("\\R");
 
   /**
-   * Creates a {@code GroupConfig} for a new group from the {@code InternalGroupCreation} blueprint.
-   * Further, optional properties can be specified by setting an {@code InternalGroupUpdate} via
-   * {@link #setGroupUpdate(InternalGroupUpdate, AuditLogFormatter)} on the returned {@code
-   * GroupConfig}.
+   * Creates a {@link GroupConfig} for a new group from the {@link InternalGroupCreation} blueprint.
+   * Further, optional properties can be specified by setting a {@link GroupDelta} via {@link
+   * #setGroupDelta(GroupDelta, AuditLogFormatter)} on the returned {@link GroupConfig}.
    *
-   * <p><strong>Note:</strong> The returned {@code GroupConfig} has to be committed via {@link
+   * <p><strong>Note:</strong> The returned {@link GroupConfig} has to be committed via {@link
    * #commit(MetaDataUpdate)} in order to create the group for real.
    *
    * @param projectName the name of the project which holds the NoteDb commits for groups
    * @param repository the repository which holds the NoteDb commits for groups
-   * @param groupCreation an {@code InternalGroupCreation} specifying all properties which are
+   * @param groupCreation an {@link InternalGroupCreation} specifying all properties which are
    *     required for a new group
-   * @return a {@code GroupConfig} for a group creation
+   * @return a {@link GroupConfig} for a group creation
    * @throws IOException if the repository can't be accessed for some reason
    * @throws ConfigInvalidException if a group with the same UUID already exists but can't be read
    *     due to an invalid format
@@ -123,7 +122,7 @@
   }
 
   /**
-   * Creates a {@code GroupConfig} for an existing group.
+   * Creates a {@link GroupConfig} for an existing group.
    *
    * <p>The group is automatically loaded within this method and can be accessed via {@link
    * #getLoadedGroup()}.
@@ -131,14 +130,14 @@
    * <p>It's safe to call this method for non-existing groups. In that case, {@link
    * #getLoadedGroup()} won't return any group. Thus, the existence of a group can be easily tested.
    *
-   * <p>The group represented by the returned {@code GroupConfig} can be updated by setting an
-   * {@code InternalGroupUpdate} via {@link #setGroupUpdate(InternalGroupUpdate, AuditLogFormatter)}
-   * and committing the {@code GroupConfig} via {@link #commit(MetaDataUpdate)}.
+   * <p>The group represented by the returned {@link GroupConfig} can be updated by setting an
+   * {@link GroupDelta} via {@link #setGroupDelta(GroupDelta, AuditLogFormatter)} and committing the
+   * {@link GroupConfig} via {@link #commit(MetaDataUpdate)}.
    *
    * @param projectName the name of the project which holds the NoteDb commits for groups
    * @param repository the repository which holds the NoteDb commits for groups
    * @param groupUuid the UUID of the group
-   * @return a {@code GroupConfig} for the group with the specified UUID
+   * @return a {@link GroupConfig} for the group with the specified UUID
    * @throws IOException if the repository can't be accessed for some reason
    * @throws ConfigInvalidException if the group exists but can't be read due to an invalid format
    */
@@ -169,7 +168,7 @@
   }
 
   /**
-   * Creates a {@code GroupConfig} for an existing group at a specific revision of the repository.
+   * Creates a {@link GroupConfig} for an existing group at a specific revision of the repository.
    *
    * <p>This method behaves nearly the same as {@link #loadForGroup(Project.NameKey, Repository,
    * AccountGroup.UUID)}. The only difference is that {@link #loadForGroup(Project.NameKey,
@@ -180,7 +179,7 @@
    * @param repository the repository which holds the NoteDb commits for groups
    * @param groupUuid the UUID of the group
    * @param commitId the revision of the repository at which the group should be loaded
-   * @return a {@code GroupConfig} for the group with the specified UUID
+   * @return a {@link GroupConfig} for the group with the specified UUID
    * @throws IOException if the repository can't be accessed for some reason
    * @throws ConfigInvalidException if the group exists but can't be read due to an invalid format
    */
@@ -200,7 +199,7 @@
 
   private Optional<InternalGroup> loadedGroup = Optional.empty();
   private Optional<InternalGroupCreation> groupCreation = Optional.empty();
-  private Optional<InternalGroupUpdate> groupUpdate = Optional.empty();
+  private Optional<GroupDelta> groupDelta = Optional.empty();
   private AuditLogFormatter auditLogFormatter = AuditLogFormatter.createPartiallyWorkingFallBack();
   private boolean isLoaded = false;
   private boolean allowSaveEmptyName;
@@ -213,16 +212,16 @@
   /**
    * Returns the group loaded from NoteDb.
    *
-   * <p>If not any NoteDb commits exist for the group represented by this {@code GroupConfig}, no
+   * <p>If not any NoteDb commits exist for the group represented by this {@link GroupConfig}, no
    * group is returned.
    *
-   * <p>After {@link #commit(MetaDataUpdate)} was called on this {@code GroupConfig}, this method
+   * <p>After {@link #commit(MetaDataUpdate)} was called on this {@link GroupConfig}, this method
    * returns a group which is in line with the latest NoteDb commit for this group. So, after
-   * creating a {@code GroupConfig} for a new group and committing it, this method can be used to
+   * creating a {@link GroupConfig} for a new group and committing it, this method can be used to
    * retrieve a representation of the created group. The same holds for the representation of an
    * updated group.
    *
-   * @return the loaded group, or an empty {@code Optional} if the group doesn't exist
+   * @return the loaded group, or an empty {@link Optional} if the group doesn't exist
    */
   public Optional<InternalGroup> getLoadedGroup() {
     checkLoaded();
@@ -232,20 +231,19 @@
   /**
    * Specifies how the current group should be updated.
    *
-   * <p>If the group is newly created, the {@code InternalGroupUpdate} can be used to specify
-   * optional properties.
+   * <p>If the group is newly created, the {@link GroupDelta} can be used to specify optional
+   * properties.
    *
    * <p><strong>Note:</strong> This method doesn't perform the update. It only contains the
    * instructions for the update. To apply the update for real and write the result back to NoteDb,
-   * call {@link #commit(MetaDataUpdate)} on this {@code GroupConfig}.
+   * call {@link #commit(MetaDataUpdate)} on this {@link GroupConfig}.
    *
-   * @param groupUpdate an {@code InternalGroupUpdate} outlining the modifications which should be
-   *     applied
-   * @param auditLogFormatter an {@code AuditLogFormatter} for formatting the commit message in a
+   * @param groupDelta a {@link GroupDelta} with the modifications to be applied
+   * @param auditLogFormatter an {@link AuditLogFormatter} for formatting the commit message in a
    *     parsable way
    */
-  public void setGroupUpdate(InternalGroupUpdate groupUpdate, AuditLogFormatter auditLogFormatter) {
-    this.groupUpdate = Optional.of(groupUpdate);
+  public void setGroupDelta(GroupDelta groupDelta, AuditLogFormatter auditLogFormatter) {
+    this.groupDelta = Optional.of(groupDelta);
     this.auditLogFormatter = auditLogFormatter;
   }
 
@@ -304,7 +302,7 @@
   @Override
   protected boolean onSave(CommitBuilder commit) throws IOException, ConfigInvalidException {
     checkLoaded();
-    if (!groupCreation.isPresent() && !groupUpdate.isPresent()) {
+    if (!groupCreation.isPresent() && !groupDelta.isPresent()) {
       // Group was neither created nor changed. -> A new commit isn't necessary.
       return false;
     }
@@ -318,7 +316,7 @@
     // for new groups, we explicitly need to truncate the timestamp here.
     Timestamp commitTimestamp =
         TimeUtil.truncateToSecond(
-            groupUpdate.flatMap(InternalGroupUpdate::getUpdatedOn).orElseGet(TimeUtil::nowTs));
+            groupDelta.flatMap(GroupDelta::getUpdatedOn).orElseGet(TimeUtil::nowTs));
     commit.setAuthor(new PersonIdent(commit.getAuthor(), commitTimestamp));
     commit.setCommitter(new PersonIdent(commit.getCommitter(), commitTimestamp));
 
@@ -329,7 +327,7 @@
 
     loadedGroup = Optional.of(updatedGroup);
     groupCreation = Optional.empty();
-    groupUpdate = Optional.empty();
+    groupDelta = Optional.empty();
 
     return true;
   }
@@ -339,8 +337,8 @@
   }
 
   private Optional<String> getNewName() {
-    if (groupUpdate.isPresent()) {
-      return groupUpdate.get().getName().map(n -> Strings.nullToEmpty(n.get()));
+    if (groupDelta.isPresent()) {
+      return groupDelta.get().getName().map(n -> Strings.nullToEmpty(n.get()));
     }
     if (groupCreation.isPresent()) {
       return Optional.of(Strings.nullToEmpty(groupCreation.get().getNameKey().get()));
@@ -377,11 +375,10 @@
         internalGroupCreation ->
             Arrays.stream(GroupConfigEntry.values())
                 .forEach(configEntry -> configEntry.initNewConfig(config, internalGroupCreation)));
-    groupUpdate.ifPresent(
-        internalGroupUpdate ->
+    groupDelta.ifPresent(
+        delta ->
             Arrays.stream(GroupConfigEntry.values())
-                .forEach(
-                    configEntry -> configEntry.updateConfigValue(config, internalGroupUpdate)));
+                .forEach(configEntry -> configEntry.updateConfigValue(config, delta)));
     saveConfig(GROUP_CONFIG_FILE, config);
     return config;
   }
@@ -389,8 +386,8 @@
   private Optional<ImmutableSet<Account.Id>> updateMembers(ImmutableSet<Account.Id> originalMembers)
       throws IOException {
     Optional<ImmutableSet<Account.Id>> updatedMembers =
-        groupUpdate
-            .map(InternalGroupUpdate::getMemberModification)
+        groupDelta
+            .map(GroupDelta::getMemberModification)
             .map(memberModification -> memberModification.apply(originalMembers))
             .map(ImmutableSet::copyOf)
             .filter(members -> !originalMembers.equals(members));
@@ -403,8 +400,8 @@
   private Optional<ImmutableSet<AccountGroup.UUID>> updateSubgroups(
       ImmutableSet<AccountGroup.UUID> originalSubgroups) throws IOException {
     Optional<ImmutableSet<AccountGroup.UUID>> updatedSubgroups =
-        groupUpdate
-            .map(InternalGroupUpdate::getSubgroupModification)
+        groupDelta
+            .map(GroupDelta::getSubgroupModification)
             .map(subgroupModification -> subgroupModification.apply(originalSubgroups))
             .map(ImmutableSet::copyOf)
             .filter(subgroups -> !originalSubgroups.equals(subgroups));
diff --git a/java/com/google/gerrit/server/group/db/GroupConfigEntry.java b/java/com/google/gerrit/server/group/db/GroupConfigEntry.java
index be56344..687e3fb 100644
--- a/java/com/google/gerrit/server/group/db/GroupConfigEntry.java
+++ b/java/com/google/gerrit/server/group/db/GroupConfigEntry.java
@@ -26,8 +26,8 @@
  * <p>Each property knows how to read and write its value from/to a JGit {@link Config} file.
  *
  * <p><strong>Warning:</strong> This class is a low-level API for properties of groups in NoteDb. It
- * may only be used by {@link GroupConfig}. Other classes should use {@link InternalGroupUpdate} to
- * modify the properties of a group.
+ * may only be used by {@link GroupConfig}. Other classes should use {@link GroupDelta} to modify
+ * the properties of a group.
  */
 enum GroupConfigEntry {
   /**
@@ -59,7 +59,7 @@
     }
 
     @Override
-    void updateConfigValue(Config config, InternalGroupUpdate groupUpdate) {
+    void updateConfigValue(Config config, GroupDelta groupDelta) {
       // Updating the ID is not supported.
     }
   },
@@ -87,8 +87,8 @@
     }
 
     @Override
-    void updateConfigValue(Config config, InternalGroupUpdate groupUpdate) {
-      groupUpdate
+    void updateConfigValue(Config config, GroupDelta groupDelta) {
+      groupDelta
           .getName()
           .ifPresent(name -> config.setString(SECTION_NAME, null, super.keyName, name.get()));
     }
@@ -112,8 +112,8 @@
     }
 
     @Override
-    void updateConfigValue(Config config, InternalGroupUpdate groupUpdate) {
-      groupUpdate
+    void updateConfigValue(Config config, GroupDelta groupDelta) {
+      groupDelta
           .getDescription()
           .ifPresent(
               description ->
@@ -144,8 +144,8 @@
     }
 
     @Override
-    void updateConfigValue(Config config, InternalGroupUpdate groupUpdate) {
-      groupUpdate
+    void updateConfigValue(Config config, GroupDelta groupDelta) {
+      groupDelta
           .getOwnerGroupUUID()
           .ifPresent(
               ownerGroupUuid ->
@@ -171,8 +171,8 @@
     }
 
     @Override
-    void updateConfigValue(Config config, InternalGroupUpdate groupUpdate) {
-      groupUpdate
+    void updateConfigValue(Config config, GroupDelta groupDelta) {
+      groupDelta
           .getVisibleToAll()
           .ifPresent(
               visibleToAll -> config.setBoolean(SECTION_NAME, null, super.keyName, visibleToAll));
@@ -217,13 +217,13 @@
 
   /**
    * Updates the corresponding property of this {@code GroupConfigEntry} in the given {@code Config}
-   * if the {@code InternalGroupUpdate} mentions a modification.
+   * if the {@link GroupDelta} mentions a modification.
    *
-   * <p>This call is a no-op if the {@code InternalGroupUpdate} doesn't contain a modification for
-   * the property.
+   * <p>This call is a no-op if the {@link GroupDelta} doesn't contain a modification for the
+   * property.
    *
    * @param config a {@code Config} for which the property should be updated
-   * @param groupUpdate an {@code InternalGroupUpdate} detailing the modifications on a group
+   * @param groupDelta a {@link GroupDelta} detailing the modifications on a group
    */
-  abstract void updateConfigValue(Config config, InternalGroupUpdate groupUpdate);
+  abstract void updateConfigValue(Config config, GroupDelta groupDelta);
 }
diff --git a/java/com/google/gerrit/server/group/db/InternalGroupUpdate.java b/java/com/google/gerrit/server/group/db/GroupDelta.java
similarity index 81%
rename from java/com/google/gerrit/server/group/db/InternalGroupUpdate.java
rename to java/com/google/gerrit/server/group/db/GroupDelta.java
index 5c7408c..4ef2450 100644
--- a/java/com/google/gerrit/server/group/db/InternalGroupUpdate.java
+++ b/java/com/google/gerrit/server/group/db/GroupDelta.java
@@ -23,14 +23,13 @@
 import java.util.Set;
 
 /**
- * Definition of an update to a group.
+ * Data holder for updates to be applied to a group.
  *
- * <p>An {@code InternalGroupUpdate} only specifies the modifications which should be applied to a
- * group. Each of the modifications and hence each call on {@link InternalGroupUpdate.Builder} is
- * optional.
+ * <p>A {@link GroupDelta} specifies the modifications to be applied to a group. Only fields set via
+ * {@link GroupDelta.Builder} will be updated.
  */
 @AutoValue
-public abstract class InternalGroupUpdate {
+public abstract class GroupDelta {
 
   /** Representation of a member modification as defined by {@link #apply(ImmutableSet)}. */
   @FunctionalInterface
@@ -99,10 +98,10 @@
    * Defines the {@code Timestamp} to be used for the NoteDb commits of the update. If not
    * specified, the current {@code Timestamp} when creating the commit will be used.
    *
-   * <p>If this {@code InternalGroupUpdate} is passed next to an {@link InternalGroupCreation}
-   * during a group creation, this {@code Timestamp} is used for the NoteDb commits of the new
-   * group. Hence, the {@link com.google.gerrit.entities.InternalGroup#getCreatedOn()
-   * InternalGroup#getCreatedOn()} field will match this {@code Timestamp}.
+   * <p>If this {@link GroupDelta} is passed next to an {@link InternalGroupCreation} during a group
+   * creation, this {@code Timestamp} is used for the NoteDb commits of the new group. Hence, the
+   * {@link com.google.gerrit.entities.InternalGroup#getCreatedOn() InternalGroup#getCreatedOn()}
+   * field will match this {@code Timestamp}.
    *
    * <p><strong>Note: </strong>{@code Timestamp}s of NoteDb commits for groups are used for events
    * in the audit log. For this reason, specifying this field will have an effect on the resulting
@@ -113,12 +112,12 @@
   public abstract Builder toBuilder();
 
   public static Builder builder() {
-    return new AutoValue_InternalGroupUpdate.Builder()
+    return new AutoValue_GroupDelta.Builder()
         .setMemberModification(in -> in)
         .setSubgroupModification(in -> in);
   }
 
-  /** A builder for an {@link InternalGroupUpdate}. */
+  /** A builder for a {@link GroupDelta}. */
   @AutoValue.Builder
   public abstract static class Builder {
 
@@ -139,11 +138,11 @@
 
     /**
      * Returns the currently defined {@link MemberModification} for the prospective {@link
-     * InternalGroupUpdate}.
+     * GroupDelta}.
      *
      * <p>This modification can be tweaked further and passed to {@link
-     * #setMemberModification(InternalGroupUpdate.MemberModification)} in order to combine multiple
-     * member additions, deletions, or other modifications into one update.
+     * #setMemberModification(GroupDelta.MemberModification)} in order to combine multiple member
+     * additions, deletions, or other modifications into one update.
      */
     public abstract MemberModification getMemberModification();
 
@@ -152,17 +151,17 @@
 
     /**
      * Returns the currently defined {@link SubgroupModification} for the prospective {@link
-     * InternalGroupUpdate}.
+     * GroupDelta}.
      *
      * <p>This modification can be tweaked further and passed to {@link
-     * #setSubgroupModification(InternalGroupUpdate.SubgroupModification)} in order to combine
-     * multiple subgroup additions, deletions, or other modifications into one update.
+     * #setSubgroupModification(GroupDelta.SubgroupModification)} in order to combine multiple
+     * subgroup additions, deletions, or other modifications into one update.
      */
     public abstract SubgroupModification getSubgroupModification();
 
     /** @see #getUpdatedOn() */
     public abstract Builder setUpdatedOn(Timestamp timestamp);
 
-    public abstract InternalGroupUpdate build();
+    public abstract GroupDelta build();
   }
 }
diff --git a/java/com/google/gerrit/server/group/db/GroupsUpdate.java b/java/com/google/gerrit/server/group/db/GroupsUpdate.java
index 02d55eb..9aa5cfd 100644
--- a/java/com/google/gerrit/server/group/db/GroupsUpdate.java
+++ b/java/com/google/gerrit/server/group/db/GroupsUpdate.java
@@ -67,19 +67,19 @@
  * <p>All calls which write group related details to the database are gathered here. Other classes
  * should always use this class instead of accessing the database directly. There are a few
  * exceptions though: schema classes, wrapper classes, and classes executed during init. The latter
- * ones should use {@code GroupsOnInit} instead.
+ * ones should use {@link com.google.gerrit.pgm.init.GroupsOnInit} instead.
  *
  * <p>If not explicitly stated, all methods of this class refer to <em>internal</em> groups.
  */
 public class GroupsUpdate {
   public interface Factory {
     /**
-     * Creates a {@code GroupsUpdate} which uses the identity of the specified user to mark database
+     * Creates a {@link GroupsUpdate} which uses the identity of the specified user to mark database
      * modifications executed by it. For NoteDb, this identity is used as author and committer for
      * all related commits.
      *
      * <p><strong>Note</strong>: Please use this method with care and consider using the {@link
-     * com.google.gerrit.server.UserInitiated} annotation on the provider of a {@code GroupsUpdate}
+     * com.google.gerrit.server.UserInitiated} annotation on the provider of a {@link GroupsUpdate}
      * instead.
      *
      * @param currentUser the user to which modifications should be attributed
@@ -87,12 +87,12 @@
     GroupsUpdate create(IdentifiedUser currentUser);
 
     /**
-     * Creates a {@code GroupsUpdate} which uses the server identity to mark database modifications
+     * Creates a {@link GroupsUpdate} which uses the server identity to mark database modifications
      * executed by it. For NoteDb, this identity is used as author and committer for all related
      * commits.
      *
      * <p><strong>Note</strong>: Please use this method with care and consider using the {@link
-     * com.google.gerrit.server.ServerInitiated} annotation on the provider of a {@code
+     * com.google.gerrit.server.ServerInitiated} annotation on the provider of a {@link
      * GroupsUpdate} instead.
      */
     GroupsUpdate createWithServerIdent();
@@ -115,6 +115,7 @@
   private final RetryHelper retryHelper;
 
   @AssistedInject
+  @SuppressWarnings("BindingAnnotationWithoutInject")
   GroupsUpdate(
       GitRepositoryManager repoManager,
       AllUsersName allUsersName,
@@ -149,6 +150,7 @@
   }
 
   @AssistedInject
+  @SuppressWarnings("BindingAnnotationWithoutInject")
   GroupsUpdate(
       GitRepositoryManager repoManager,
       AllUsersName allUsersName,
@@ -183,6 +185,7 @@
         Optional.of(currentUser));
   }
 
+  @SuppressWarnings("BindingAnnotationWithoutInject")
   private GroupsUpdate(
       GitRepositoryManager repoManager,
       AllUsersName allUsersName,
@@ -251,25 +254,24 @@
   /**
    * Creates the specified group for the specified members (accounts).
    *
-   * @param groupCreation an {@code InternalGroupCreation} which specifies all mandatory properties
+   * @param groupCreation an {@link InternalGroupCreation} which specifies all mandatory properties
    *     of the group
-   * @param groupUpdate an {@code InternalGroupUpdate} which specifies optional properties of the
-   *     group. If this {@code InternalGroupUpdate} updates a property which was already specified
-   *     by the {@code InternalGroupCreation}, the value of this {@code InternalGroupUpdate} wins.
+   * @param groupDelta a {@link GroupDelta} which specifies optional properties of the group. If
+   *     this {@link GroupDelta} updates a property which was already specified by the {@link
+   *     InternalGroupCreation}, the value of this {@link GroupDelta} wins.
    * @throws DuplicateKeyException if a group with the chosen name already exists
    * @throws IOException if indexing fails, or an error occurs while reading/writing from/to NoteDb
-   * @return the created {@code InternalGroup}
+   * @return the created {@link InternalGroup}
    */
-  public InternalGroup createGroup(
-      InternalGroupCreation groupCreation, InternalGroupUpdate groupUpdate)
+  public InternalGroup createGroup(InternalGroupCreation groupCreation, GroupDelta groupDelta)
       throws DuplicateKeyException, IOException, ConfigInvalidException {
-    try (TraceTimer timer =
+    try (TraceTimer ignored =
         TraceContext.newTimer(
             "Creating group",
             Metadata.builder()
-                .groupName(groupUpdate.getName().orElseGet(groupCreation::getNameKey).get())
+                .groupName(groupDelta.getName().orElseGet(groupCreation::getNameKey).get())
                 .build())) {
-      InternalGroup createdGroup = createGroupInNoteDbWithRetry(groupCreation, groupUpdate);
+      InternalGroup createdGroup = createGroupInNoteDbWithRetry(groupCreation, groupDelta);
       evictCachesOnGroupCreation(createdGroup);
       dispatchAuditEventsOnGroupCreation(createdGroup);
       return createdGroup;
@@ -280,24 +282,23 @@
    * Updates the specified group.
    *
    * @param groupUuid the UUID of the group to update
-   * @param groupUpdate an {@code InternalGroupUpdate} which indicates the desired updates on the
-   *     group
+   * @param groupDelta a {@link GroupDelta} which indicates the desired updates on the group
    * @throws DuplicateKeyException if the new name of the group is used by another group
    * @throws IOException if indexing fails, or an error occurs while reading/writing from/to NoteDb
    * @throws NoSuchGroupException if the specified group doesn't exist
    */
-  public void updateGroup(AccountGroup.UUID groupUuid, InternalGroupUpdate groupUpdate)
+  public void updateGroup(AccountGroup.UUID groupUuid, GroupDelta groupDelta)
       throws DuplicateKeyException, IOException, NoSuchGroupException, ConfigInvalidException {
-    try (TraceTimer timer =
+    try (TraceTimer ignored =
         TraceContext.newTimer(
             "Updating group", Metadata.builder().groupUuid(groupUuid.get()).build())) {
-      Optional<Timestamp> updatedOn = groupUpdate.getUpdatedOn();
+      Optional<Timestamp> updatedOn = groupDelta.getUpdatedOn();
       if (!updatedOn.isPresent()) {
         updatedOn = Optional.of(TimeUtil.nowTs());
-        groupUpdate = groupUpdate.toBuilder().setUpdatedOn(updatedOn.get()).build();
+        groupDelta = groupDelta.toBuilder().setUpdatedOn(updatedOn.get()).build();
       }
 
-      UpdateResult result = updateGroupInNoteDbWithRetry(groupUuid, groupUpdate);
+      UpdateResult result = updateGroupInNoteDbWithRetry(groupUuid, groupDelta);
       updateNameInProjectConfigsIfNecessary(result);
       evictCachesOnGroupUpdate(result);
       dispatchAuditEventsOnGroupUpdate(result, updatedOn.get());
@@ -305,11 +306,11 @@
   }
 
   private InternalGroup createGroupInNoteDbWithRetry(
-      InternalGroupCreation groupCreation, InternalGroupUpdate groupUpdate)
+      InternalGroupCreation groupCreation, GroupDelta groupDelta)
       throws IOException, ConfigInvalidException, DuplicateKeyException {
     try {
       return retryHelper
-          .groupUpdate("createGroup", () -> createGroupInNoteDb(groupCreation, groupUpdate))
+          .groupUpdate("createGroup", () -> createGroupInNoteDb(groupCreation, groupDelta))
           .call();
     } catch (Exception e) {
       Throwables.throwIfUnchecked(e);
@@ -322,17 +323,17 @@
 
   @VisibleForTesting
   public InternalGroup createGroupInNoteDb(
-      InternalGroupCreation groupCreation, InternalGroupUpdate groupUpdate)
+      InternalGroupCreation groupCreation, GroupDelta groupDelta)
       throws IOException, ConfigInvalidException, DuplicateKeyException {
     try (Repository allUsersRepo = repoManager.openRepository(allUsersName)) {
-      AccountGroup.NameKey groupName = groupUpdate.getName().orElseGet(groupCreation::getNameKey);
+      AccountGroup.NameKey groupName = groupDelta.getName().orElseGet(groupCreation::getNameKey);
       GroupNameNotes groupNameNotes =
           GroupNameNotes.forNewGroup(
               allUsersName, allUsersRepo, groupCreation.getGroupUUID(), groupName);
 
       GroupConfig groupConfig =
           GroupConfig.createForNewGroup(allUsersName, allUsersRepo, groupCreation);
-      groupConfig.setGroupUpdate(groupUpdate, auditLogFormatter);
+      groupConfig.setGroupDelta(groupDelta, auditLogFormatter);
 
       commit(allUsersRepo, groupConfig, groupNameNotes);
 
@@ -344,11 +345,11 @@
   }
 
   private UpdateResult updateGroupInNoteDbWithRetry(
-      AccountGroup.UUID groupUuid, InternalGroupUpdate groupUpdate)
+      AccountGroup.UUID groupUuid, GroupDelta groupDelta)
       throws IOException, ConfigInvalidException, DuplicateKeyException, NoSuchGroupException {
     try {
       return retryHelper
-          .groupUpdate("updateGroup", () -> updateGroupInNoteDb(groupUuid, groupUpdate))
+          .groupUpdate("updateGroup", () -> updateGroupInNoteDb(groupUuid, groupDelta))
           .call();
     } catch (Exception e) {
       Throwables.throwIfUnchecked(e);
@@ -361,21 +362,20 @@
   }
 
   @VisibleForTesting
-  public UpdateResult updateGroupInNoteDb(
-      AccountGroup.UUID groupUuid, InternalGroupUpdate groupUpdate)
+  public UpdateResult updateGroupInNoteDb(AccountGroup.UUID groupUuid, GroupDelta groupDelta)
       throws IOException, ConfigInvalidException, DuplicateKeyException, NoSuchGroupException {
     try (Repository allUsersRepo = repoManager.openRepository(allUsersName)) {
       GroupConfig groupConfig = GroupConfig.loadForGroup(allUsersName, allUsersRepo, groupUuid);
-      groupConfig.setGroupUpdate(groupUpdate, auditLogFormatter);
+      groupConfig.setGroupDelta(groupDelta, auditLogFormatter);
       if (!groupConfig.getLoadedGroup().isPresent()) {
         throw new NoSuchGroupException(groupUuid);
       }
 
       InternalGroup originalGroup = groupConfig.getLoadedGroup().get();
       GroupNameNotes groupNameNotes = null;
-      if (groupUpdate.getName().isPresent()) {
+      if (groupDelta.getName().isPresent()) {
         AccountGroup.NameKey oldName = originalGroup.getNameKey();
-        AccountGroup.NameKey newName = groupUpdate.getName().get();
+        AccountGroup.NameKey newName = groupDelta.getName().get();
         groupNameNotes =
             GroupNameNotes.forRename(allUsersName, allUsersRepo, groupUuid, oldName, newName);
       }
diff --git a/java/com/google/gerrit/server/group/db/InternalGroupCreation.java b/java/com/google/gerrit/server/group/db/InternalGroupCreation.java
index 8988547..f4bf6e6 100644
--- a/java/com/google/gerrit/server/group/db/InternalGroupCreation.java
+++ b/java/com/google/gerrit/server/group/db/InternalGroupCreation.java
@@ -20,7 +20,7 @@
 /**
  * Definition of all properties necessary for a group creation.
  *
- * <p>An instance of {@code InternalGroupCreation} is a blueprint for a group which should be
+ * <p>An instance of {@link InternalGroupCreation} is a blueprint for a group which should be
  * created.
  */
 @AutoValue
diff --git a/java/com/google/gerrit/server/index/change/ChangeField.java b/java/com/google/gerrit/server/index/change/ChangeField.java
index ce748d1..8aab647a 100644
--- a/java/com/google/gerrit/server/index/change/ChangeField.java
+++ b/java/com/google/gerrit/server/index/change/ChangeField.java
@@ -16,6 +16,7 @@
 
 import static com.google.common.base.MoreObjects.firstNonNull;
 import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.common.collect.ImmutableListMultimap.toImmutableListMultimap;
 import static com.google.common.collect.ImmutableSet.toImmutableSet;
 import static com.google.gerrit.index.FieldDef.exact;
 import static com.google.gerrit.index.FieldDef.fullText;
@@ -57,6 +58,7 @@
 import com.google.gerrit.entities.converter.PatchSetProtoConverter;
 import com.google.gerrit.entities.converter.ProtoConverter;
 import com.google.gerrit.index.FieldDef;
+import com.google.gerrit.index.RefState;
 import com.google.gerrit.index.SchemaUtil;
 import com.google.gerrit.json.OutputFormat;
 import com.google.gerrit.proto.Protos;
@@ -71,6 +73,7 @@
 import com.google.gerrit.server.query.change.ChangeQueryBuilder;
 import com.google.gerrit.server.query.change.ChangeStatusPredicate;
 import com.google.gson.Gson;
+import com.google.protobuf.MessageLite;
 import java.sql.Timestamp;
 import java.time.Instant;
 import java.util.ArrayList;
@@ -84,6 +87,7 @@
 import java.util.Set;
 import java.util.function.Function;
 import java.util.stream.Stream;
+import java.util.stream.StreamSupport;
 import org.eclipse.jgit.lib.PersonIdent;
 
 /**
@@ -103,6 +107,7 @@
 
   private static final Gson GSON = OutputFormat.JSON_COMPACT.newGson();
 
+  // TODO: Rename LEGACY_ID to NUMERIC_ID
   /** Legacy change ID. */
   public static final FieldDef<ChangeData, Integer> LEGACY_ID =
       integer("legacy_id").stored().build(cd -> cd.getId().get());
@@ -153,7 +158,7 @@
   public static final FieldDef<ChangeData, Timestamp> MERGED_ON =
       timestamp(ChangeQueryBuilder.FIELD_MERGED_ON)
           .stored()
-          .build(cd -> cd.getMergedOn().orElse(null));
+          .build(cd -> cd.getMergedOn().orElse(null), (cd, field) -> cd.setMergedOn(field));
 
   /** List of full file paths modified in the current patch set. */
   public static final FieldDef<ChangeData, Iterable<String>> PATH =
@@ -179,11 +184,21 @@
       exact(ChangeQueryBuilder.FIELD_HASHTAG)
           .buildRepeatable(cd -> cd.hashtags().stream().map(String::toLowerCase).collect(toSet()));
 
+  /** Hashtags as fulltext field for in-string search. */
+  public static final FieldDef<ChangeData, Iterable<String>> FUZZY_HASHTAG =
+      fullText("hashtag2")
+          .buildRepeatable(cd -> cd.hashtags().stream().map(String::toLowerCase).collect(toSet()));
+
   /** Hashtags with original case. */
   public static final FieldDef<ChangeData, Iterable<byte[]>> HASHTAG_CASE_AWARE =
       storedOnly("_hashtag")
           .buildRepeatable(
-              cd -> cd.hashtags().stream().map(t -> t.getBytes(UTF_8)).collect(toSet()));
+              cd -> cd.hashtags().stream().map(t -> t.getBytes(UTF_8)).collect(toSet()),
+              (cd, field) ->
+                  cd.setHashtags(
+                      StreamSupport.stream(field.spliterator(), false)
+                          .map(f -> new String(f, UTF_8))
+                          .collect(toImmutableSet())));
 
   /** Components of each file path modified in the current patch set. */
   public static final FieldDef<ChangeData, Iterable<String>> FILE_PART =
@@ -330,7 +345,14 @@
    */
   public static final FieldDef<ChangeData, Iterable<byte[]>> ATTENTION_SET_FULL =
       storedOnly(ChangeQueryBuilder.FIELD_ATTENTION_SET_FULL)
-          .buildRepeatable(ChangeField::storedAttentionSet);
+          .buildRepeatable(
+              ChangeField::storedAttentionSet,
+              (cd, value) ->
+                  parseAttentionSet(
+                      StreamSupport.stream(value.spliterator(), false)
+                          .map(v -> new String(v, UTF_8))
+                          .collect(toImmutableSet()),
+                      cd));
 
   /** The user assigned to the change. */
   public static final FieldDef<ChangeData, Integer> ASSIGNEE =
@@ -339,25 +361,38 @@
 
   /** Reviewer(s) associated with the change. */
   public static final FieldDef<ChangeData, Iterable<String>> REVIEWER =
-      exact("reviewer2").stored().buildRepeatable(cd -> getReviewerFieldValues(cd.reviewers()));
+      exact("reviewer2")
+          .stored()
+          .buildRepeatable(
+              cd -> getReviewerFieldValues(cd.reviewers()),
+              (cd, field) -> cd.setReviewers(parseReviewerFieldValues(cd.getId(), field)));
 
   /** Reviewer(s) associated with the change that do not have a gerrit account. */
   public static final FieldDef<ChangeData, Iterable<String>> REVIEWER_BY_EMAIL =
       exact("reviewer_by_email")
           .stored()
-          .buildRepeatable(cd -> getReviewerByEmailFieldValues(cd.reviewersByEmail()));
+          .buildRepeatable(
+              cd -> getReviewerByEmailFieldValues(cd.reviewersByEmail()),
+              (cd, field) ->
+                  cd.setReviewersByEmail(parseReviewerByEmailFieldValues(cd.getId(), field)));
 
   /** Reviewer(s) modified during change's current WIP phase. */
   public static final FieldDef<ChangeData, Iterable<String>> PENDING_REVIEWER =
       exact(ChangeQueryBuilder.FIELD_PENDING_REVIEWER)
           .stored()
-          .buildRepeatable(cd -> getReviewerFieldValues(cd.pendingReviewers()));
+          .buildRepeatable(
+              cd -> getReviewerFieldValues(cd.pendingReviewers()),
+              (cd, field) -> cd.setPendingReviewers(parseReviewerFieldValues(cd.getId(), field)));
 
   /** Reviewer(s) by email modified during change's current WIP phase. */
   public static final FieldDef<ChangeData, Iterable<String>> PENDING_REVIEWER_BY_EMAIL =
       exact(ChangeQueryBuilder.FIELD_PENDING_REVIEWER_BY_EMAIL)
           .stored()
-          .buildRepeatable(cd -> getReviewerByEmailFieldValues(cd.pendingReviewersByEmail()));
+          .buildRepeatable(
+              cd -> getReviewerByEmailFieldValues(cd.pendingReviewersByEmail()),
+              (cd, field) ->
+                  cd.setPendingReviewersByEmail(
+                      parseReviewerByEmailFieldValues(cd.getId(), field)));
 
   /** References a change that this change reverts. */
   public static final FieldDef<ChangeData, Integer> REVERT_OF =
@@ -637,13 +672,18 @@
   /** Serialized change object, used for pre-populating results. */
   public static final FieldDef<ChangeData, byte[]> CHANGE =
       storedOnly("_change")
-          .build(changeGetter(change -> toProto(ChangeProtoConverter.INSTANCE, change)));
+          .build(
+              changeGetter(change -> toProto(ChangeProtoConverter.INSTANCE, change)),
+              (cd, field) -> cd.setChange(parseProtoFrom(field, ChangeProtoConverter.INSTANCE)));
 
   /** Serialized approvals for the current patch set, used for pre-populating results. */
   public static final FieldDef<ChangeData, Iterable<byte[]>> APPROVAL =
       storedOnly("_approval")
           .buildRepeatable(
-              cd -> toProtos(PatchSetApprovalProtoConverter.INSTANCE, cd.currentApprovals()));
+              cd -> toProtos(PatchSetApprovalProtoConverter.INSTANCE, cd.currentApprovals()),
+              (cd, field) ->
+                  cd.setCurrentApprovals(
+                      decodeProtos(field, PatchSetApprovalProtoConverter.INSTANCE)));
 
   public static String formatLabel(String label, int value) {
     return formatLabel(label, value, null);
@@ -674,17 +714,24 @@
               cd ->
                   Stream.concat(
                           cd.publishedComments().stream().map(c -> c.message),
+                          // Some endpoint allow passing user message in input, and we still want to
+                          // search by that. Index on message template with placeholders for user
+                          // data, so we don't
+                          // persist user identifiable information data in index.
                           cd.messages().stream().map(ChangeMessage::getMessage))
                       .collect(toSet()));
 
   /** Number of unresolved comment threads of the change, including robot comments. */
   public static final FieldDef<ChangeData, Integer> UNRESOLVED_COMMENT_COUNT =
       intRange(ChangeQueryBuilder.FIELD_UNRESOLVED_COMMENT_COUNT)
-          .build(ChangeData::unresolvedCommentCount);
+          .build(
+              ChangeData::unresolvedCommentCount,
+              (cd, field) -> cd.setUnresolvedCommentCount(field));
 
   /** Total number of published inline comments of the change, including robot comments. */
   public static final FieldDef<ChangeData, Integer> TOTAL_COMMENT_COUNT =
-      intRange("total_comments").build(ChangeData::totalCommentCount);
+      intRange("total_comments")
+          .build(ChangeData::totalCommentCount, (cd, field) -> cd.setTotalCommentCount(field));
 
   /** Whether the change is mergeable. */
   public static final FieldDef<ChangeData, String> MERGEABLE =
@@ -697,7 +744,8 @@
                   return null;
                 }
                 return m ? "1" : "0";
-              });
+              },
+              (cd, field) -> cd.setMergeable(field == null ? false : field.equals("1")));
 
   /** Whether the change is a merge commit. */
   public static final FieldDef<ChangeData, String> MERGE =
@@ -715,12 +763,16 @@
   /** The number of inserted lines in this change. */
   public static final FieldDef<ChangeData, Integer> ADDED =
       intRange(ChangeQueryBuilder.FIELD_ADDED)
-          .build(cd -> cd.changedLines().isPresent() ? cd.changedLines().get().insertions : null);
+          .build(
+              cd -> cd.changedLines().isPresent() ? cd.changedLines().get().insertions : null,
+              (cd, field) -> cd.setLinesInserted(field));
 
   /** The number of deleted lines in this change. */
   public static final FieldDef<ChangeData, Integer> DELETED =
       intRange(ChangeQueryBuilder.FIELD_DELETED)
-          .build(cd -> cd.changedLines().isPresent() ? cd.changedLines().get().deletions : null);
+          .build(
+              cd -> cd.changedLines().isPresent() ? cd.changedLines().get().deletions : null,
+              (cd, field) -> cd.setLinesDeleted(field));
 
   /** The total number of modified lines in this change. */
   public static final FieldDef<ChangeData, Integer> DELTA =
@@ -761,8 +813,12 @@
                   Iterables.transform(
                       cd.stars().entries(),
                       e ->
-                          StarredChangesUtil.StarField.create(e.getKey(), e.getValue())
-                              .toString()));
+                          StarredChangesUtil.StarField.create(e.getKey(), e.getValue()).toString()),
+              (cd, field) ->
+                  cd.setStars(
+                      StreamSupport.stream(field.spliterator(), false)
+                          .map(f -> StarredChangesUtil.StarField.parse(f))
+                          .collect(toImmutableListMultimap(e -> e.accountId(), e -> e.label()))));
 
   /** Users that have starred the change with any label. */
   public static final FieldDef<ChangeData, Iterable<Integer>> STARBY =
@@ -778,7 +834,9 @@
   /** Serialized patch set object, used for pre-populating results. */
   public static final FieldDef<ChangeData, Iterable<byte[]>> PATCH_SET =
       storedOnly("_patch_set")
-          .buildRepeatable(cd -> toProtos(PatchSetProtoConverter.INSTANCE, cd.patchSets()));
+          .buildRepeatable(
+              cd -> toProtos(PatchSetProtoConverter.INSTANCE, cd.patchSets()),
+              (cd, field) -> cd.setPatchSets(decodeProtos(field, PatchSetProtoConverter.INSTANCE)));
 
   /** Users who have edits on this change. */
   public static final FieldDef<ChangeData, Iterable<Integer>> EDITBY =
@@ -812,7 +870,12 @@
                   return ImmutableSet.of(NOT_REVIEWED);
                 }
                 return reviewedBy.stream().map(Account.Id::get).collect(toList());
-              });
+              },
+              (cd, field) ->
+                  cd.setReviewedBy(
+                      StreamSupport.stream(field.spliterator(), false)
+                          .map(Account::id)
+                          .collect(toImmutableSet())));
 
   public static final SubmitRuleOptions SUBMIT_RULE_OPTIONS_LENIENT =
       SubmitRuleOptions.builder().recomputeOnClosedChanges(true).build();
@@ -908,11 +971,27 @@
 
   public static final FieldDef<ChangeData, Iterable<byte[]>> STORED_SUBMIT_RECORD_STRICT =
       storedOnly("full_submit_record_strict")
-          .buildRepeatable(cd -> storedSubmitRecords(cd, SUBMIT_RULE_OPTIONS_STRICT));
+          .buildRepeatable(
+              cd -> storedSubmitRecords(cd, SUBMIT_RULE_OPTIONS_STRICT),
+              (cd, field) ->
+                  parseSubmitRecords(
+                      StreamSupport.stream(field.spliterator(), false)
+                          .map(f -> new String(f, UTF_8))
+                          .collect(toSet()),
+                      SUBMIT_RULE_OPTIONS_STRICT,
+                      cd));
 
   public static final FieldDef<ChangeData, Iterable<byte[]>> STORED_SUBMIT_RECORD_LENIENT =
       storedOnly("full_submit_record_lenient")
-          .buildRepeatable(cd -> storedSubmitRecords(cd, SUBMIT_RULE_OPTIONS_LENIENT));
+          .buildRepeatable(
+              cd -> storedSubmitRecords(cd, SUBMIT_RULE_OPTIONS_LENIENT),
+              (cd, field) ->
+                  parseSubmitRecords(
+                      StreamSupport.stream(field.spliterator(), false)
+                          .map(f -> new String(f, UTF_8))
+                          .collect(toSet()),
+                      SUBMIT_RULE_OPTIONS_LENIENT,
+                      cd));
 
   public static void parseSubmitRecords(
       Collection<String> values, SubmitRuleOptions opts, ChangeData out) {
@@ -978,7 +1057,8 @@
                     .entries()
                     .forEach(e -> result.add(e.getValue().toByteArray(e.getKey())));
                 return result;
-              });
+              },
+              (cd, field) -> cd.setRefStates(RefState.parseStates(field)));
 
   /**
    * All ref wildcard patterns that were used in the course of indexing this document.
@@ -1004,7 +1084,8 @@
                     RefStatePattern.create(RefNames.refsDraftCommentsPrefix(id) + "*")
                         .toByteArray(allUsers(cd)));
                 return result;
-              });
+              },
+              (cd, field) -> cd.setRefStatePatterns(field));
 
   private static String getTopic(ChangeData cd) {
     Change c = cd.change();
@@ -1022,6 +1103,18 @@
     return Protos.toByteArray(converter.toProto(object));
   }
 
+  private static <T> List<T> decodeProtos(Iterable<byte[]> raw, ProtoConverter<?, T> converter) {
+    return StreamSupport.stream(raw.spliterator(), false)
+        .map(bytes -> parseProtoFrom(bytes, converter))
+        .collect(toImmutableList());
+  }
+
+  private static <P extends MessageLite, T> T parseProtoFrom(
+      byte[] bytes, ProtoConverter<P, T> converter) {
+    P message = Protos.parseUnchecked(converter.getParser(), bytes, 0, bytes.length);
+    return converter.fromProto(message);
+  }
+
   private static <T> FieldDef.Getter<ChangeData, T> changeGetter(Function<Change, T> func) {
     return in -> in.change() != null ? func.apply(in.change()) : null;
   }
diff --git a/java/com/google/gerrit/server/index/change/ChangeIndex.java b/java/com/google/gerrit/server/index/change/ChangeIndex.java
index 49d0d4e..05c5c77 100644
--- a/java/com/google/gerrit/server/index/change/ChangeIndex.java
+++ b/java/com/google/gerrit/server/index/change/ChangeIndex.java
@@ -19,8 +19,7 @@
 import com.google.gerrit.index.IndexDefinition;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gerrit.server.query.change.LegacyChangeIdPredicate;
-import com.google.gerrit.server.query.change.LegacyChangeIdStrPredicate;
+import com.google.gerrit.server.query.change.ChangePredicates;
 
 /**
  * Index for Gerrit changes. This class is mainly used for typing the generic parent class that
@@ -32,7 +31,7 @@
   @Override
   default Predicate<ChangeData> keyPredicate(Change.Id id) {
     return getSchema().useLegacyNumericFields()
-        ? new LegacyChangeIdPredicate(id)
-        : new LegacyChangeIdStrPredicate(id);
+        ? ChangePredicates.id(id)
+        : ChangePredicates.idStr(id);
   }
 }
diff --git a/java/com/google/gerrit/server/index/change/ChangeIndexer.java b/java/com/google/gerrit/server/index/change/ChangeIndexer.java
index e0f6bec..f7f0f33 100644
--- a/java/com/google/gerrit/server/index/change/ChangeIndexer.java
+++ b/java/com/google/gerrit/server/index/change/ChangeIndexer.java
@@ -46,6 +46,7 @@
 import java.util.Collection;
 import java.util.Collections;
 import java.util.List;
+import java.util.Map;
 import java.util.Set;
 import java.util.concurrent.Callable;
 import java.util.concurrent.ConcurrentHashMap;
@@ -79,8 +80,7 @@
   private final StalenessChecker stalenessChecker;
   private final boolean autoReindexIfStale;
 
-  private final Set<IndexTask> queuedIndexTasks =
-      Collections.newSetFromMap(new ConcurrentHashMap<>());
+  private final Map<Change.Id, IndexTask> queuedIndexTasks = new ConcurrentHashMap<>();
   private final Set<ReindexIfStaleTask> queuedReindexIfStaleTasks =
       Collections.newSetFromMap(new ConcurrentHashMap<>());
 
@@ -137,16 +137,44 @@
   /**
    * Start indexing a change.
    *
-   * @param id change to index.
+   * @param changeId change to index.
    * @return future for the indexing task.
    */
-  public ListenableFuture<?> indexAsync(Project.NameKey project, Change.Id id) {
-    IndexTask task = new IndexTask(project, id);
-    if (queuedIndexTasks.add(task)) {
-      fireChangeScheduledForIndexingEvent(project.get(), id.get());
-      return submit(task);
-    }
-    return Futures.immediateFuture(null);
+  public ListenableFuture<ChangeData> indexAsync(Project.NameKey project, Change.Id changeId) {
+    // If the change was already scheduled for indexing, we do not need to schedule it again. Change
+    // updates that happened after the change was scheduled for indexing will automatically be taken
+    // into account when the index task is executed (as it reads the current change state).
+    // To skip duplicate index requests, queuedIndexTasks keeps track of the scheduled index tasks.
+    // Here we check if the change has already been scheduled for indexing, and only if not we
+    // create a new index task for the change.
+    // By using computeIfAbsent we ensure that the lookup and the insertion of a new task happens
+    // atomically. Some attempted update operations on this map by other threads may be blocked
+    // while the computation is in progress (but not all as ConcurrentHashMap doesn't lock the
+    // entire table on write, but only segments of the table).
+    IndexTask task =
+        queuedIndexTasks.computeIfAbsent(
+            changeId,
+            id -> {
+              fireChangeScheduledForIndexingEvent(project.get(), id.get());
+              return new IndexTask(project, id);
+            });
+    // Submitting the task to the executor must not happen from within the computeIfAbsent callback,
+    // as this could result in the task being executed before the computeIfAbsent method has
+    // finished (e.g. if a direct executor is used, but also if starting the task asynchronously is
+    // faster than finishing the computeIfAbsent method). This could lead to failures and unexpected
+    // behavior:
+    // * The first thing that IndexTask does is to remove itself from queuedIndexTasks.
+    //   This is done so that index requests which are received while an index task for the same
+    //   change is in progress, are not dropped but added to the queue. This is important since
+    //   the change state that is written to the index is read at the beginning of the index task
+    //   and change updates that happen after this read will not be considered when updating the
+    //   index.
+    // * Trying to remove the IndexTask from queuedIndexTasks at the beginning of the task doesn't
+    //   work if the computeIfAbsent method hasn't finished yet. Either the queuedIndexTasks doesn't
+    //   contain the new entry yet and the removal has no effect as it is done before the entry is
+    //   added to the map, or the removal fails with {@link IllegalStateException} as recursive
+    //   updates from within the computeIfAbsent callback are not allowed.
+    return task.submitIfNeeded();
   }
 
   /**
@@ -155,8 +183,9 @@
    * @param ids changes to index.
    * @return future for completing indexing of all changes.
    */
-  public ListenableFuture<?> indexAsync(Project.NameKey project, Collection<Change.Id> ids) {
-    List<ListenableFuture<?>> futures = new ArrayList<>(ids.size());
+  public ListenableFuture<List<ChangeData>> indexAsync(
+      Project.NameKey project, Collection<Change.Id> ids) {
+    List<ListenableFuture<ChangeData>> futures = new ArrayList<>(ids.size());
     for (Change.Id id : ids) {
       futures.add(indexAsync(project, id));
     }
@@ -259,9 +288,9 @@
    * Start deleting a change.
    *
    * @param id change to delete.
-   * @return future for the deleting task.
+   * @return future for the deleting task, the result of the future is always {@code null}
    */
-  public ListenableFuture<?> deleteAsync(Change.Id id) {
+  public ListenableFuture<ChangeData> deleteAsync(Change.Id id) {
     fireChangeScheduledForDeletionFromIndexEvent(id.get());
     return submit(new DeleteTask(id));
   }
@@ -359,17 +388,46 @@
     }
   }
 
-  private class IndexTask extends AbstractIndexTask<Void> {
+  private class IndexTask extends AbstractIndexTask<ChangeData> {
+    ListenableFuture<ChangeData> future;
+
     private IndexTask(Project.NameKey project, Change.Id id) {
       super(project, id);
     }
 
+    /**
+     * Submits this task to be executed, if it wasn't submitted yet.
+     *
+     * <p>Submits this task to the executor if it hasn't been submitted yet. The future is cached so
+     * that it can be returned if this method is called again.
+     *
+     * <p>This method must be synchronized so that concurrent calls do not submit this task to the
+     * executor multiple times.
+     *
+     * @return future from which the result of the index task (the {@link ChangeData} instance) can
+     *     be retrieved.
+     */
+    private synchronized ListenableFuture<ChangeData> submitIfNeeded() {
+      if (future == null) {
+        future = submit(this);
+      }
+      return future;
+    }
+
     @Override
-    public Void callImpl() throws Exception {
+    public ChangeData callImpl() throws Exception {
+      // Remove this task from queuedIndexTasks. This is done right at the beginning of this task so
+      // that index requests which are received for the same change while this index task is in
+      // progress, are not dropped but added to the queue. This is important since change updates
+      // that happen after reading the change notes below will not be considered when updating the
+      // index.
       remove();
+
       try {
         ChangeNotes changeNotes = notesFactory.createChecked(project, id);
-        doIndex(changeDataFactory.create(changeNotes));
+        ChangeData changeData = changeDataFactory.create(changeNotes);
+        doIndex(changeData);
+        return changeData;
       } catch (NoSuchChangeException e) {
         doDelete(id);
       }
@@ -397,12 +455,12 @@
 
     @Override
     protected void remove() {
-      queuedIndexTasks.remove(this);
+      queuedIndexTasks.remove(id);
     }
   }
 
   // Not AbstractIndexTask as it doesn't need a request context.
-  private class DeleteTask implements Callable<Void> {
+  private class DeleteTask implements Callable<ChangeData> {
     private final Change.Id id;
 
     private DeleteTask(Change.Id id) {
@@ -410,7 +468,7 @@
     }
 
     @Override
-    public Void call() {
+    public ChangeData call() {
       logger.atFine().log("Delete change %d from index.", id.get());
       // Don't bother setting a RequestContext to provide the DB.
       // Implementations should not need to access the DB in order to delete a
diff --git a/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java b/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
index 969b071..ffccb51 100644
--- a/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
+++ b/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
@@ -135,9 +135,14 @@
       new Schema.Builder<ChangeData>().add(V59).add(ChangeField.MERGE).build();
 
   /** Added new field {@link ChangeField#MERGED_ON} */
+  @Deprecated
   static final Schema<ChangeData> V61 =
       new Schema.Builder<ChangeData>().add(V60).add(ChangeField.MERGED_ON).build();
 
+  /** Added new field {@link ChangeField#FUZZY_HASHTAG} */
+  static final Schema<ChangeData> V62 =
+      new Schema.Builder<ChangeData>().add(V61).add(ChangeField.FUZZY_HASHTAG).build();
+
   /**
    * Name of the change index to be used when contacting index backends or loading configurations.
    */
diff --git a/java/com/google/gerrit/server/logging/Metadata.java b/java/com/google/gerrit/server/logging/Metadata.java
index 71ee01f..dc9af2b 100644
--- a/java/com/google/gerrit/server/logging/Metadata.java
+++ b/java/com/google/gerrit/server/logging/Metadata.java
@@ -66,6 +66,9 @@
   /** The cause of an error. */
   public abstract Optional<String> cause();
 
+  /** Side where the comment is written: <= 0 for parent, 1 for revision. */
+  public abstract Optional<Integer> commentSide();
+
   /** The SHA1 of a commit. */
   public abstract Optional<String> commit();
 
@@ -288,6 +291,8 @@
 
     public abstract Builder cause(@Nullable String cause);
 
+    public abstract Builder commentSide(int side);
+
     public abstract Builder commit(@Nullable String commit);
 
     public abstract Builder eventType(@Nullable String eventType);
diff --git a/java/com/google/gerrit/server/mail/EmailModule.java b/java/com/google/gerrit/server/mail/EmailModule.java
index ff166b1..c659b5f 100644
--- a/java/com/google/gerrit/server/mail/EmailModule.java
+++ b/java/com/google/gerrit/server/mail/EmailModule.java
@@ -17,7 +17,6 @@
 import com.google.gerrit.extensions.config.FactoryModule;
 import com.google.gerrit.server.mail.send.AbandonedSender;
 import com.google.gerrit.server.mail.send.AddKeySender;
-import com.google.gerrit.server.mail.send.AddReviewerSender;
 import com.google.gerrit.server.mail.send.AddToAttentionSetSender;
 import com.google.gerrit.server.mail.send.CommentSender;
 import com.google.gerrit.server.mail.send.CreateChangeSender;
@@ -26,6 +25,7 @@
 import com.google.gerrit.server.mail.send.DeleteVoteSender;
 import com.google.gerrit.server.mail.send.HttpPasswordUpdateSender;
 import com.google.gerrit.server.mail.send.MergedSender;
+import com.google.gerrit.server.mail.send.ModifyReviewerSender;
 import com.google.gerrit.server.mail.send.RegisterNewEmailSender;
 import com.google.gerrit.server.mail.send.RemoveFromAttentionSetSender;
 import com.google.gerrit.server.mail.send.ReplacePatchSetSender;
@@ -38,7 +38,7 @@
   protected void configure() {
     factory(AbandonedSender.Factory.class);
     factory(AddKeySender.Factory.class);
-    factory(AddReviewerSender.Factory.class);
+    factory(ModifyReviewerSender.Factory.class);
     factory(CommentSender.Factory.class);
     factory(CreateChangeSender.Factory.class);
     factory(DeleteKeySender.Factory.class);
diff --git a/java/com/google/gerrit/server/mail/receive/MailProcessor.java b/java/com/google/gerrit/server/mail/receive/MailProcessor.java
index df38118..d805e39 100644
--- a/java/com/google/gerrit/server/mail/receive/MailProcessor.java
+++ b/java/com/google/gerrit/server/mail/receive/MailProcessor.java
@@ -23,7 +23,6 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
@@ -44,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;
@@ -52,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;
@@ -65,7 +64,7 @@
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
-import com.google.gerrit.server.update.Context;
+import com.google.gerrit.server.update.PostUpdateContext;
 import com.google.gerrit.server.update.RetryHelper;
 import com.google.gerrit.server.update.UpdateException;
 import com.google.gerrit.server.util.ManualRequestContext;
@@ -233,7 +232,14 @@
       throws UpdateException, RestApiException {
     try (ManualRequestContext ctx = oneOffRequestContext.openAs(sender)) {
       List<ChangeData> changeDataList =
-          queryProvider.get().byLegacyChangeId(Change.id(metadata.changeNumber));
+          queryProvider
+              .get()
+              .enforceVisibility(true)
+              .byLegacyChangeId(Change.id(metadata.changeNumber));
+      if (changeDataList.isEmpty()) {
+        sendRejectionEmail(message, InboundEmailRejectionSender.Error.CHANGE_NOT_FOUND);
+        return;
+      }
       if (changeDataList.size() != 1) {
         logger.atSevere().log(
             "Message %s references unique change %s,"
@@ -313,7 +319,7 @@
     private final PatchSet.Id psId;
     private final List<MailComment> parsedComments;
     private final String tag;
-    private ChangeMessage changeMessage;
+    private String mailMessage;
     private List<HumanComment> comments;
     private PatchSet patchSet;
     private ChangeNotes notes;
@@ -332,9 +338,8 @@
         throw new StorageException("patch set not found: " + psId);
       }
 
-      changeMessage = generateChangeMessage(ctx);
-      changeMessagesUtil.addChangeMessage(ctx.getUpdate(psId), changeMessage);
-
+      mailMessage =
+          changeMessagesUtil.setChangeMessage(ctx.getUpdate(psId), generateChangeMessage(), tag);
       comments = new ArrayList<>();
       for (MailComment c : parsedComments) {
         if (c.getType() == MailComment.CommentType.CHANGE_MESSAGE) {
@@ -352,7 +357,7 @@
     }
 
     @Override
-    public void postUpdate(Context ctx) throws Exception {
+    public void postUpdate(PostUpdateContext ctx) throws Exception {
       String patchSetComment = null;
       if (parsedComments.get(0).getType() == MailComment.CommentType.CHANGE_MESSAGE) {
         patchSetComment = parsedComments.get(0).getMessage();
@@ -364,7 +369,8 @@
               notes,
               patchSet,
               ctx.getUser().asIdentifiedUser(),
-              changeMessage,
+              mailMessage,
+              ctx.getWhen(),
               comments,
               patchSetComment,
               ImmutableList.of(),
@@ -379,16 +385,16 @@
       // Fire Gerrit event. Note that approvals can't be granted via email, so old and new approvals
       // are always the same here.
       commentAdded.fire(
-          notes.getChange(),
+          ctx.getChangeData(notes),
           patchSet,
           ctx.getAccount(),
-          changeMessage.getMessage(),
+          mailMessage,
           approvals,
           approvals,
           ctx.getWhen());
     }
 
-    private ChangeMessage generateChangeMessage(ChangeContext ctx) {
+    private String generateChangeMessage() {
       String changeMsg = "Patch Set " + psId.get() + ":";
       if (parsedComments.get(0).getType() == MailComment.CommentType.CHANGE_MESSAGE) {
         // Add a blank line after Patch Set to follow the default format
@@ -399,7 +405,7 @@
       } else {
         changeMsg += "\n\n" + numComments(parsedComments.size());
       }
-      return ChangeMessagesUtil.newMessage(ctx, changeMsg, tag);
+      return changeMsg;
     }
 
     private PatchSet targetPatchSetForComment(
diff --git a/java/com/google/gerrit/server/mail/send/EmailArguments.java b/java/com/google/gerrit/server/mail/send/EmailArguments.java
index 808d6a4..7fff232 100644
--- a/java/com/google/gerrit/server/mail/send/EmailArguments.java
+++ b/java/com/google/gerrit/server/mail/send/EmailArguments.java
@@ -18,13 +18,13 @@
 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.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;
diff --git a/java/com/google/gerrit/server/mail/send/InboundEmailRejectionSender.java b/java/com/google/gerrit/server/mail/send/InboundEmailRejectionSender.java
index 709bf61..acdeb5a 100644
--- a/java/com/google/gerrit/server/mail/send/InboundEmailRejectionSender.java
+++ b/java/com/google/gerrit/server/mail/send/InboundEmailRejectionSender.java
@@ -33,7 +33,8 @@
     INACTIVE_ACCOUNT,
     UNKNOWN_ACCOUNT,
     INTERNAL_EXCEPTION,
-    COMMENT_REJECTED
+    COMMENT_REJECTED,
+    CHANGE_NOT_FOUND
   }
 
   public interface Factory {
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/AddReviewerSender.java b/java/com/google/gerrit/server/mail/send/ModifyReviewerSender.java
similarity index 87%
rename from java/com/google/gerrit/server/mail/send/AddReviewerSender.java
rename to java/com/google/gerrit/server/mail/send/ModifyReviewerSender.java
index 96d9483..dcf3b6c 100644
--- a/java/com/google/gerrit/server/mail/send/AddReviewerSender.java
+++ b/java/com/google/gerrit/server/mail/send/ModifyReviewerSender.java
@@ -21,13 +21,13 @@
 import com.google.inject.assistedinject.Assisted;
 
 /** Asks a user to review a change. */
-public class AddReviewerSender extends NewChangeSender {
+public class ModifyReviewerSender extends NewChangeSender {
   public interface Factory {
-    AddReviewerSender create(Project.NameKey project, Change.Id changeId);
+    ModifyReviewerSender create(Project.NameKey project, Change.Id changeId);
   }
 
   @Inject
-  public AddReviewerSender(
+  public ModifyReviewerSender(
       EmailArguments args, @Assisted Project.NameKey project, @Assisted Change.Id changeId) {
     super(args, newChangeData(args, project, changeId));
   }
diff --git a/java/com/google/gerrit/server/mail/send/NewChangeSender.java b/java/com/google/gerrit/server/mail/send/NewChangeSender.java
index ee9a328..001de52 100644
--- a/java/com/google/gerrit/server/mail/send/NewChangeSender.java
+++ b/java/com/google/gerrit/server/mail/send/NewChangeSender.java
@@ -31,6 +31,8 @@
   private final Set<Address> reviewersByEmail = new HashSet<>();
   private final Set<Account.Id> extraCC = new HashSet<>();
   private final Set<Address> extraCCByEmail = new HashSet<>();
+  private final Set<Account.Id> removedReviewers = new HashSet<>();
+  private final Set<Address> removedByEmailReviewers = new HashSet<>();
 
   protected NewChangeSender(EmailArguments args, ChangeData changeData) {
     super(args, "newchange", changeData);
@@ -52,10 +54,17 @@
     extraCCByEmail.addAll(cc);
   }
 
+  public void addRemovedReviewers(Collection<Account.Id> removed) {
+    removedReviewers.addAll(removed);
+  }
+
+  public void addRemovedByEmailReviewers(Collection<Address> removed) {
+    removedByEmailReviewers.addAll(removed);
+  }
+
   @Override
   protected void init() throws EmailException {
     super.init();
-
     String threadId = getChangeMessageThreadId();
     setHeader("References", threadId);
 
@@ -71,6 +80,8 @@
       case OWNER_REVIEWERS:
         reviewers.stream().forEach(r -> add(RecipientType.TO, r, true));
         addByEmail(RecipientType.TO, reviewersByEmail, true);
+        removedReviewers.stream().forEach(r -> add(RecipientType.TO, r, true));
+        addByEmail(RecipientType.TO, removedByEmailReviewers, true);
         break;
     }
 
@@ -96,10 +107,25 @@
     return names;
   }
 
+  public List<String> getRemovedReviewerNames() {
+    if (removedReviewers.isEmpty() && removedByEmailReviewers.isEmpty()) {
+      return null;
+    }
+    List<String> names = new ArrayList<>();
+    for (Account.Id id : removedReviewers) {
+      names.add(getNameFor(id));
+    }
+    for (Address address : removedByEmailReviewers) {
+      names.add(address.name());
+    }
+    return names;
+  }
+
   @Override
   protected void setupSoyContext() {
     super.setupSoyContext();
     soyContext.put("ownerName", getNameFor(change.getOwner()));
     soyContextEmailData.put("reviewerNames", getReviewerNames());
+    soyContextEmailData.put("removedReviewerNames", getRemovedReviewerNames());
   }
 }
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotesParser.java b/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
index a822fca..f12176b 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
@@ -735,10 +735,14 @@
     }
 
     ChangeMessage changeMessage =
-        new ChangeMessage(ChangeMessage.key(psId.changeId(), commit.name()), accountId, ts, psId);
-    changeMessage.setMessage(changeMsgString.get());
-    changeMessage.setTag(tag);
-    changeMessage.setRealAuthor(realAccountId);
+        ChangeMessage.create(
+            ChangeMessage.key(psId.changeId(), commit.name()),
+            accountId,
+            ts,
+            psId,
+            changeMsgString.get(),
+            realAccountId,
+            tag);
     return allChangeMessages.add(changeMessage);
   }
 
diff --git a/java/com/google/gerrit/server/patch/AutoMerger.java b/java/com/google/gerrit/server/patch/AutoMerger.java
index 3f17a2e..b99e2d2 100644
--- a/java/com/google/gerrit/server/patch/AutoMerger.java
+++ b/java/com/google/gerrit/server/patch/AutoMerger.java
@@ -162,6 +162,11 @@
       return Optional.empty();
     }
 
+    if (repoView.getRef(RefNames.refsCacheAutomerge(maybeMergeCommit.name())).isPresent()) {
+      logger.atFine().log("AutoMerge alredy exists");
+      return Optional.empty();
+    }
+
     ObjectId autoMerge;
     try (Timer1.Context ignored = latency.start(OperationType.ON_DISK_WRITE)) {
       autoMerge =
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 93aefff..7213581 100644
--- a/java/com/google/gerrit/server/patch/DiffOperations.java
+++ b/java/com/google/gerrit/server/patch/DiffOperations.java
@@ -16,6 +16,7 @@
 
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Patch;
+import com.google.gerrit.entities.Patch.ChangeType;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo;
 import com.google.gerrit.server.patch.filediff.FileDiffOutput;
@@ -47,7 +48,9 @@
    * @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.
-   * @return the list of modified files between the two commits.
+   * @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.
    * @throws DiffNotAvailableException if auto-merge is requested for a commit having more than two
    *     parents, if the {@code newCommit} could not be parsed for extracting the base commit, or if
    *     an internal error occurred in Git while evaluating the diff.
@@ -63,7 +66,9 @@
    * @param project a project name representing a git repository.
    * @param oldCommit 20 bytes SHA-1 of the old commit used in the diff.
    * @param newCommit 20 bytes SHA-1 of the new commit used in the diff.
-   * @return the list of modified files between the two commits.
+   * @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.
    * @throws DiffNotAvailableException if an internal error occurred in Git while evaluating the
    *     diff.
    */
diff --git a/java/com/google/gerrit/server/patch/DiffOperationsImpl.java b/java/com/google/gerrit/server/patch/DiffOperationsImpl.java
index 6217239..dbbb7a6 100644
--- a/java/com/google/gerrit/server/patch/DiffOperationsImpl.java
+++ b/java/com/google/gerrit/server/patch/DiffOperationsImpl.java
@@ -16,6 +16,7 @@
 
 import static com.google.gerrit.entities.Patch.COMMIT_MSG;
 import static com.google.gerrit.entities.Patch.MERGE_LIST;
+import static org.eclipse.jgit.lib.Constants.EMPTY_TREE_ID;
 
 import com.google.auto.value.AutoValue;
 import com.google.common.collect.ImmutableList;
@@ -341,6 +342,10 @@
 
     abstract ObjectId newCommit();
 
+    /**
+     * Base commit represents the old commit of the diff. For diffs against the root commit, this
+     * should be set to {@code EMPTY_TREE_ID}.
+     */
     abstract ObjectId baseCommit();
 
     abstract ComparisonType comparisonType();
@@ -386,6 +391,11 @@
       return result.build();
     }
     int numParents = baseCommitUtil.getNumParents(project, newCommit);
+    if (numParents == 0) {
+      result.baseCommit(EMPTY_TREE_ID);
+      result.comparisonType(ComparisonType.againstRoot());
+      return result.build();
+    }
     if (numParents == 1) {
       result.baseCommit(baseCommitUtil.getBaseCommit(project, newCommit, parent));
       result.comparisonType(ComparisonType.againstParent(1));
diff --git a/java/com/google/gerrit/server/patch/filediff/FileDiffCacheImpl.java b/java/com/google/gerrit/server/patch/filediff/FileDiffCacheImpl.java
index 395312f..2133474 100644
--- a/java/com/google/gerrit/server/patch/filediff/FileDiffCacheImpl.java
+++ b/java/com/google/gerrit/server/patch/filediff/FileDiffCacheImpl.java
@@ -26,6 +26,7 @@
 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;
@@ -93,7 +94,7 @@
         persist(DIFF, FileDiffCacheKey.class, FileDiffOutput.class)
             .maximumWeight(10 << 20)
             .weigher(FileDiffWeigher.class)
-            .version(4)
+            .version(5)
             .keySerializer(FileDiffCacheKey.Serializer.INSTANCE)
             .valueSerializer(FileDiffOutput.Serializer.INSTANCE)
             .loader(FileDiffLoader.class);
@@ -189,6 +190,9 @@
     private ComparisonType getComparisonType(
         RevWalk rw, ObjectReader reader, ObjectId oldCommitId, ObjectId newCommitId)
         throws IOException {
+      if (oldCommitId.equals(EMPTY_TREE_ID)) {
+        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 +215,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 +223,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(EMPTY_TREE_ID)
+                ? 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 +255,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 +282,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 +311,7 @@
     }
 
     private static FileDiffOutput createMagicFileDiffOutput(
-        ObjectId oldCommit,
+        @Nullable ObjectId oldCommit,
         ObjectId newCommit,
         ComparisonType comparisonType,
         RawTextComparator rawTextComparator,
@@ -317,7 +331,7 @@
       FileHeader fileHeader = new FileHeader(rawHdr, edits, PatchType.UNIFIED);
       Patch.ChangeType changeType = FileHeaderUtil.getChangeType(fileHeader);
       return FileDiffOutput.builder()
-          .oldCommitId(oldCommit)
+          .oldCommitId(oldCommit == null ? EMPTY_TREE_ID : oldCommit)
           .newCommitId(newCommit)
           .comparisonType(comparisonType)
           .oldPath(FileHeaderUtil.getOldPath(fileHeader))
diff --git a/java/com/google/gerrit/server/patch/filediff/FileDiffCacheKey.java b/java/com/google/gerrit/server/patch/filediff/FileDiffCacheKey.java
index a478fcf..7ac9343 100644
--- a/java/com/google/gerrit/server/patch/filediff/FileDiffCacheKey.java
+++ b/java/com/google/gerrit/server/patch/filediff/FileDiffCacheKey.java
@@ -26,6 +26,7 @@
 import com.google.gerrit.server.cache.serialize.CacheSerializer;
 import com.google.gerrit.server.cache.serialize.ObjectIdConverter;
 import com.google.gerrit.server.patch.gitfilediff.GitFileDiffCacheImpl.DiffAlgorithm;
+import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.ObjectId;
 
 /** Cache key for the {@link FileDiffCache}. */
@@ -35,7 +36,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
+   * Constants#EMPTY_TREE_ID}, the diff is performed against the root commit.
+   */
   public abstract ObjectId oldCommit();
 
   /** The 20 bytes SHA-1 commit ID of the new commit used in the diff. */
diff --git a/java/com/google/gerrit/server/permissions/DefaultPermissionMappings.java b/java/com/google/gerrit/server/permissions/DefaultPermissionMappings.java
index dcaf485..9d69d9b 100644
--- a/java/com/google/gerrit/server/permissions/DefaultPermissionMappings.java
+++ b/java/com/google/gerrit/server/permissions/DefaultPermissionMappings.java
@@ -55,12 +55,12 @@
           .put(GlobalPermission.RUN_AS, GlobalCapability.RUN_AS)
           .put(GlobalPermission.RUN_GC, GlobalCapability.RUN_GC)
           .put(GlobalPermission.STREAM_EVENTS, GlobalCapability.STREAM_EVENTS)
+          .put(GlobalPermission.VIEW_ACCESS, GlobalCapability.VIEW_ACCESS)
           .put(GlobalPermission.VIEW_ALL_ACCOUNTS, GlobalCapability.VIEW_ALL_ACCOUNTS)
           .put(GlobalPermission.VIEW_CACHES, GlobalCapability.VIEW_CACHES)
           .put(GlobalPermission.VIEW_CONNECTIONS, GlobalCapability.VIEW_CONNECTIONS)
           .put(GlobalPermission.VIEW_PLUGINS, GlobalCapability.VIEW_PLUGINS)
           .put(GlobalPermission.VIEW_QUEUE, GlobalCapability.VIEW_QUEUE)
-          .put(GlobalPermission.VIEW_ACCESS, GlobalCapability.VIEW_ACCESS)
           .build();
 
   static {
diff --git a/java/com/google/gerrit/server/permissions/GlobalPermission.java b/java/com/google/gerrit/server/permissions/GlobalPermission.java
index d4f22e6..c0b44e5 100644
--- a/java/com/google/gerrit/server/permissions/GlobalPermission.java
+++ b/java/com/google/gerrit/server/permissions/GlobalPermission.java
@@ -53,12 +53,12 @@
   RUN_AS,
   RUN_GC,
   STREAM_EVENTS,
+  VIEW_ACCESS,
   VIEW_ALL_ACCOUNTS,
   VIEW_CACHES,
   VIEW_CONNECTIONS,
   VIEW_PLUGINS,
-  VIEW_QUEUE,
-  VIEW_ACCESS;
+  VIEW_QUEUE;
 
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
diff --git a/java/com/google/gerrit/server/permissions/RefControl.java b/java/com/google/gerrit/server/permissions/RefControl.java
index dd00dca..f800207 100644
--- a/java/com/google/gerrit/server/permissions/RefControl.java
+++ b/java/com/google/gerrit/server/permissions/RefControl.java
@@ -523,7 +523,10 @@
                       + "who also have 'Push' rights on "
                       + RefNames.REFS_CONFIG);
             } else {
-              pde.setAdvice("To push into this reference you need 'Push' rights.");
+              pde.setAdvice(
+                  "Push to refs/for/"
+                      + RefNames.shortName(refName)
+                      + " to create a review, or get 'Push' rights to update the branch.");
             }
             break;
           case DELETE:
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/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 4a063a3..9f898d9 100644
--- a/java/com/google/gerrit/server/project/ProjectConfig.java
+++ b/java/com/google/gerrit/server/project/ProjectConfig.java
@@ -57,6 +57,8 @@
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.entities.StoredCommentLinkInfo;
+import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.entities.SubmitRequirementExpression;
 import com.google.gerrit.entities.SubscribeSection;
 import com.google.gerrit.exceptions.InvalidNameException;
 import com.google.gerrit.extensions.client.InheritableBoolean;
@@ -110,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";
@@ -123,6 +126,14 @@
   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 KEY_SR_NAME = "name";
+  public static final String KEY_SR_DESCRIPTION = "description";
+  public static final String KEY_SR_APPLICABILITY_EXPRESSION = "applicabilityExpression";
+  public static final String KEY_SR_SUBMITTABILITY_EXPRESSION = "submittabilityExpression";
+  public static final String KEY_SR_OVERRIDE_EXPRESSION = "overrideExpression";
+  public static final String KEY_SR_OVERRIDE_IN_CHILD_PROJECTS = "canOverrideInChildProjects";
+
   public static final String KEY_MATCH = "match";
   private static final String KEY_HTML = "html";
   public static final String KEY_LINK = "link";
@@ -248,6 +259,7 @@
   private Map<String, ContributorAgreement> contributorAgreements;
   private Map<String, NotifyConfig> notifySections;
   private Map<String, LabelType> labelSections;
+  private Map<String, SubmitRequirement> submitRequirementSections;
   private ConfiguredMimeTypes mimeTypes;
   private Map<Project.NameKey, SubscribeSection> subscribeSections;
   private Map<String, StoredCommentLinkInfo> commentLinkSections;
@@ -281,6 +293,7 @@
     subscribeSections.values().forEach(s -> builder.addSubscribeSection(s));
     commentLinkSections.values().forEach(c -> builder.addCommentLinkSection(c));
     labelSections.values().forEach(l -> builder.addLabelSection(l));
+    submitRequirementSections.values().forEach(sr -> builder.addSubmitRequirementSection(sr));
     pluginConfigs
         .entrySet()
         .forEach(c -> builder.addPluginConfig(c.getKey(), c.getValue().toText()));
@@ -512,6 +525,10 @@
     return labelSections;
   }
 
+  public Map<String, SubmitRequirement> getSubmitRequirementSections() {
+    return submitRequirementSections;
+  }
+
   /** Adds or replaces the given {@link LabelType} in this config. */
   public void upsertLabelType(LabelType labelType) {
     labelSections.put(labelType.getName(), labelType);
@@ -661,6 +678,7 @@
     loadBranchOrderSection(rc);
     loadNotifySections(rc);
     loadLabelSections(rc);
+    loadSubmitRequirementSections(rc);
     loadCommentLinkSections(rc);
     loadSubscribeSections(rc);
     mimeTypes = ConfiguredMimeTypes.create(projectName.get(), rc);
@@ -950,6 +968,56 @@
     return LabelValue.create(Shorts.checkedCast(PermissionRule.parseInt(parts.get(0))), valueText);
   }
 
+  private void loadSubmitRequirementSections(Config rc) {
+    Map<String, String> lowerNames = new HashMap<>();
+    submitRequirementSections = new LinkedHashMap<>();
+    for (String name : rc.getSubsections(SUBMIT_REQUIREMENT)) {
+      String lower = name.toLowerCase();
+      if (lowerNames.containsKey(lower)) {
+        error(
+            ValidationError.create(
+                PROJECT_CONFIG,
+                String.format(
+                    "Submit requirement \"%s\" conflicts with \"%s\". Skipping the former.",
+                    name, lowerNames.get(lower))));
+        continue;
+      }
+      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_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);
+
+      if (blockExpr == null) {
+        error(
+            ValidationError.create(
+                PROJECT_CONFIG,
+                (String.format(
+                    "Submit requirement \"%s\" does not define a submittability expression."
+                        + " Skipping this requirement.",
+                    name))));
+        continue;
+      }
+
+      // TODO(SR): add expressions validation. Expressions are stored as strings so we need to
+      // validate their syntax.
+
+      SubmitRequirement submitRequirement =
+          SubmitRequirement.builder()
+              .setName(name)
+              .setDescription(Optional.ofNullable(description))
+              .setApplicabilityExpression(SubmitRequirementExpression.of(appExpr))
+              .setSubmittabilityExpression(SubmitRequirementExpression.create(blockExpr))
+              .setOverrideExpression(SubmitRequirementExpression.of(overrideExpr))
+              .setAllowOverrideInChildProjects(canInherit)
+              .build();
+
+      submitRequirementSections.put(name, submitRequirement);
+    }
+  }
+
   private void loadLabelSections(Config rc) {
     Map<String, String> lowerNames = Maps.newHashMapWithExpectedSize(2);
     labelSections = new LinkedHashMap<>();
@@ -1008,6 +1076,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);
@@ -1264,6 +1333,7 @@
     savePluginSections(rc, keepGroups);
     groupList.retainUUIDs(keepGroups);
     saveLabelSections(rc);
+    saveSubmitRequirementSections(rc);
     saveCommentLinkSections(rc);
     saveSubscribeSections(rc);
     saveBranchOrderSection(rc);
@@ -1596,6 +1666,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()) {
@@ -1610,6 +1685,45 @@
     }
   }
 
+  private void saveSubmitRequirementSections(Config rc) {
+    unsetSection(rc, SUBMIT_REQUIREMENT);
+
+    if (submitRequirementSections != null) {
+      for (Map.Entry<String, SubmitRequirement> entry : submitRequirementSections.entrySet()) {
+        String name = entry.getKey();
+        SubmitRequirement sr = entry.getValue();
+
+        if (sr.description().isPresent()) {
+          rc.setString(SUBMIT_REQUIREMENT, name, KEY_SR_DESCRIPTION, sr.description().get());
+        }
+        if (sr.applicabilityExpression().isPresent()) {
+          rc.setString(
+              SUBMIT_REQUIREMENT,
+              name,
+              KEY_SR_APPLICABILITY_EXPRESSION,
+              sr.applicabilityExpression().get().expressionString());
+        }
+        rc.setString(
+            SUBMIT_REQUIREMENT,
+            name,
+            KEY_SR_SUBMITTABILITY_EXPRESSION,
+            sr.submittabilityExpression().expressionString());
+        if (sr.overrideExpression().isPresent()) {
+          rc.setString(
+              SUBMIT_REQUIREMENT,
+              name,
+              KEY_SR_OVERRIDE_EXPRESSION,
+              sr.overrideExpression().get().expressionString());
+        }
+        rc.setBoolean(
+            SUBMIT_REQUIREMENT,
+            name,
+            KEY_SR_OVERRIDE_IN_CHILD_PROJECTS,
+            sr.allowOverrideInChildProjects());
+      }
+    }
+  }
+
   private static void setBooleanConfigKey(
       Config rc, String section, String name, String key, boolean value, boolean defaultValue) {
     if (value == defaultValue) {
diff --git a/java/com/google/gerrit/server/project/ProjectState.java b/java/com/google/gerrit/server/project/ProjectState.java
index 249eb35..c3ae141 100644
--- a/java/com/google/gerrit/server/project/ProjectState.java
+++ b/java/com/google/gerrit/server/project/ProjectState.java
@@ -63,7 +63,7 @@
 import org.eclipse.jgit.lib.Config;
 
 /**
- * Cached information on a project. Must not contain any data derived from parents other than it's
+ * Cached information on a project. Must not contain any data derived from parents other than its
  * immediate parent's {@link com.google.gerrit.entities.Project.NameKey}.
  */
 public class ProjectState {
diff --git a/java/com/google/gerrit/server/project/ProjectsConsistencyChecker.java b/java/com/google/gerrit/server/project/ProjectsConsistencyChecker.java
index 812d98d..fca1b36 100644
--- a/java/com/google/gerrit/server/project/ProjectsConsistencyChecker.java
+++ b/java/com/google/gerrit/server/project/ProjectsConsistencyChecker.java
@@ -48,10 +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.CommitPredicate;
-import com.google.gerrit.server.query.change.ProjectPredicate;
-import com.google.gerrit.server.query.change.RefPredicate;
+import com.google.gerrit.server.query.change.ChangePredicates;
 import com.google.gerrit.server.update.RetryHelper;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -151,7 +148,7 @@
 
       // Base predicate which is fixed for every change query.
       Predicate<ChangeData> basePredicate =
-          and(new ProjectPredicate(projectName.get()), new RefPredicate(branch), open());
+          and(ChangePredicates.project(projectName), ChangePredicates.ref(branch), open());
 
       int maxLeafPredicates = indexConfig.maxTerms() - basePredicate.getLeafCount();
 
@@ -231,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/SubmitRequirementsEvaluator.java b/java/com/google/gerrit/server/project/SubmitRequirementsEvaluator.java
new file mode 100644
index 0000000..1d154dd
--- /dev/null
+++ b/java/com/google/gerrit/server/project/SubmitRequirementsEvaluator.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.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()
+        .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(predicateResult);
+    } catch (QueryParseException e) {
+      return SubmitRequirementExpressionResult.error(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/project/SubmitRuleEvaluator.java b/java/com/google/gerrit/server/project/SubmitRuleEvaluator.java
index a7659d4..6345cdb 100644
--- a/java/com/google/gerrit/server/project/SubmitRuleEvaluator.java
+++ b/java/com/google/gerrit/server/project/SubmitRuleEvaluator.java
@@ -29,6 +29,7 @@
 import com.google.gerrit.server.index.OnlineReindexMode;
 import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.rules.DefaultSubmitRule;
 import com.google.gerrit.server.rules.PrologRule;
 import com.google.gerrit.server.rules.SubmitRule;
 import com.google.inject.Inject;
@@ -89,13 +90,14 @@
   public List<SubmitRecord> evaluate(ChangeData cd) {
     try (Timer0.Context ignored = submitRuleEvaluationLatency.start()) {
       Change change;
+      ProjectState projectState;
       try {
         change = cd.change();
         if (change == null) {
           throw new StorageException("Change not found");
         }
 
-        projectCache.get(cd.project()).orElseThrow(noSuchProject(cd.project()));
+        projectState = projectCache.get(cd.project()).orElseThrow(noSuchProject(cd.project()));
       } catch (NoSuchProjectException e) {
         throw new IllegalStateException("Unable to find project while evaluating submit rule", e);
       }
@@ -117,6 +119,12 @@
       // We evaluate all the plugin-defined evaluators,
       // and then we collect the results in one list.
       return Streams.stream(submitRules)
+          // Skip evaluating the default submit rule if the project has prolog rules.
+          // Note that in this case, the prolog submit rule will handle labels for us
+          .filter(
+              projectState.hasPrologRules()
+                  ? rule -> !(rule.get() instanceof DefaultSubmitRule)
+                  : rule -> true)
           .map(c -> c.call(s -> s.evaluate(cd)))
           .filter(Optional::isPresent)
           .map(Optional::get)
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..4c2c7e8
--- /dev/null
+++ b/java/com/google/gerrit/server/query/approval/ApprovalContext.java
@@ -0,0 +1,54 @@
+// 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);
+    checkState(
+        psa.patchSetId().get() + 1 == id.get(),
+        "approvals can only be copied to the next consecutive patch set. got: %s, %s",
+        psa.patchSetId(),
+        id);
+    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/java/com/google/gerrit/server/query/approval/ApprovalPredicate.java b/java/com/google/gerrit/server/query/approval/ApprovalPredicate.java
new file mode 100644
index 0000000..a6f8153
--- /dev/null
+++ b/java/com/google/gerrit/server/query/approval/ApprovalPredicate.java
@@ -0,0 +1,26 @@
+// 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.index.query.Matchable;
+import com.google.gerrit.index.query.Predicate;
+
+public abstract class ApprovalPredicate extends Predicate<ApprovalContext>
+    implements Matchable<ApprovalContext> {
+  @Override
+  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..c3594f5
--- /dev/null
+++ b/java/com/google/gerrit/server/query/approval/ApprovalQueryBuilder.java
@@ -0,0 +1,105 @@
+// 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 (
+        @SuppressWarnings("UnusedException")
+        IllegalArgumentException unused) {
+      throw new QueryParseException(
+          String.format(
+              "%s is not a valid term. valid options: %s",
+              term, Arrays.asList(clazz.getEnumConstants())));
+    }
+  }
+
+  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..30097d8
--- /dev/null
+++ b/java/com/google/gerrit/server/query/approval/ListOfFilesUnchangedPredicate.java
@@ -0,0 +1,91 @@
+// 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.extensions.client.DiffPreferencesInfo;
+import com.google.gerrit.index.query.Predicate;
+import com.google.gerrit.server.patch.PatchList;
+import com.google.gerrit.server.patch.PatchListCache;
+import com.google.gerrit.server.patch.PatchListKey;
+import com.google.gerrit.server.patch.PatchListNotAvailableException;
+import com.google.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 PatchListCache patchListCache;
+
+  @Inject
+  public ListOfFilesUnchangedPredicate(PatchListCache patchListCache) {
+    this.patchListCache = patchListCache;
+  }
+
+  @Override
+  public boolean match(ApprovalContext ctx) {
+    PatchSet currentPatchset = ctx.changeNotes().getCurrentPatchSet();
+    Map.Entry<PatchSet.Id, PatchSet> priorPatchSet =
+        ctx.changeNotes().getPatchSets().lowerEntry(currentPatchset.id());
+    PatchListKey key =
+        PatchListKey.againstCommit(
+            priorPatchSet.getValue().commitId(),
+            currentPatchset.commitId(),
+            DiffPreferencesInfo.Whitespace.IGNORE_NONE);
+    try {
+      return match(patchListCache.get(key, ctx.changeNotes().getProjectName()));
+    } catch (PatchListNotAvailableException ex) {
+      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(PatchList patchList) {
+    return patchList.getPatches().stream()
+        .noneMatch(
+            p ->
+                p.getChangeType() == ChangeType.ADDED
+                    || p.getChangeType() == ChangeType.DELETED
+                    || p.getChangeType() == ChangeType.RENAMED);
+  }
+
+  @Override
+  public Predicate<ApprovalContext> copy(
+      Collection<? extends Predicate<ApprovalContext>> children) {
+    return new ListOfFilesUnchangedPredicate(patchListCache);
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(patchListCache);
+  }
+
+  @Override
+  public boolean equals(Object other) {
+    if (!(other instanceof ListOfFilesUnchangedPredicate)) {
+      return false;
+    }
+    ListOfFilesUnchangedPredicate o = (ListOfFilesUnchangedPredicate) other;
+    return Objects.equals(o.patchListCache, patchListCache);
+  }
+}
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/AssigneePredicate.java b/java/com/google/gerrit/server/query/change/AssigneePredicate.java
deleted file mode 100644
index 35a91c9..0000000
--- a/java/com/google/gerrit/server/query/change/AssigneePredicate.java
+++ /dev/null
@@ -1,41 +0,0 @@
-// 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.
-
-package com.google.gerrit.server.query.change;
-
-import com.google.gerrit.entities.Account;
-import com.google.gerrit.server.index.change.ChangeField;
-
-public class AssigneePredicate extends ChangeIndexPredicate {
-  protected final Account.Id id;
-
-  public AssigneePredicate(Account.Id id) {
-    super(ChangeField.ASSIGNEE, id.toString());
-    this.id = id;
-  }
-
-  @Override
-  public boolean match(ChangeData object) {
-    if (id.get() == ChangeField.NO_ASSIGNEE) {
-      Account.Id assignee = object.change().getAssignee();
-      return assignee == null;
-    }
-    return id.equals(object.change().getAssignee());
-  }
-
-  @Override
-  public int getCost() {
-    return 1;
-  }
-}
diff --git a/java/com/google/gerrit/server/query/change/AttentionSetPredicate.java b/java/com/google/gerrit/server/query/change/AttentionSetPredicate.java
deleted file mode 100644
index 2b18767..0000000
--- a/java/com/google/gerrit/server/query/change/AttentionSetPredicate.java
+++ /dev/null
@@ -1,41 +0,0 @@
-// Copyright (C) 2020 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.query.change;
-
-import static com.google.gerrit.server.util.AttentionSetUtil.additionsOnly;
-
-import com.google.gerrit.entities.Account;
-import com.google.gerrit.server.index.change.ChangeField;
-
-/** Simple predicate for searching by attention set. */
-public class AttentionSetPredicate extends ChangeIndexPredicate {
-  protected final Account.Id id;
-
-  AttentionSetPredicate(Account.Id id) {
-    super(ChangeField.ATTENTION_SET_USERS, id.toString());
-    this.id = id;
-  }
-
-  @Override
-  public boolean match(ChangeData changeData) {
-    return additionsOnly(changeData.attentionSet()).stream()
-        .anyMatch(update -> update.account().equals(id));
-  }
-
-  @Override
-  public int getCost() {
-    return 1;
-  }
-}
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 79914a3..0000000
--- a/java/com/google/gerrit/server/query/change/AuthorPredicate.java
+++ /dev/null
@@ -1,36 +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());
-  }
-
-  @Override
-  public int getCost() {
-    return 1;
-  }
-}
diff --git a/java/com/google/gerrit/server/query/change/BooleanPredicate.java b/java/com/google/gerrit/server/query/change/BooleanPredicate.java
index 68f83e8..6ca3acc 100644
--- a/java/com/google/gerrit/server/query/change/BooleanPredicate.java
+++ b/java/com/google/gerrit/server/query/change/BooleanPredicate.java
@@ -25,9 +25,4 @@
   public boolean match(ChangeData object) {
     return getValue().equals(getField().get(object));
   }
-
-  @Override
-  public int getCost() {
-    return 1;
-  }
 }
diff --git a/java/com/google/gerrit/server/query/change/ChangeData.java b/java/com/google/gerrit/server/query/change/ChangeData.java
index f7167cd..6f8b097 100644
--- a/java/com/google/gerrit/server/query/change/ChangeData.java
+++ b/java/com/google/gerrit/server/query/change/ChangeData.java
@@ -22,6 +22,7 @@
 import com.google.auto.value.AutoValue;
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.MoreObjects;
+import com.google.common.collect.HashBasedTable;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.collect.ImmutableMap;
@@ -33,6 +34,7 @@
 import com.google.common.collect.Lists;
 import com.google.common.collect.Maps;
 import com.google.common.collect.SetMultimap;
+import com.google.common.collect.Table;
 import com.google.common.flogger.FluentLogger;
 import com.google.common.primitives.Ints;
 import com.google.gerrit.common.Nullable;
@@ -56,7 +58,6 @@
 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;
@@ -66,6 +67,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;
@@ -318,11 +320,16 @@
   private Optional<ChangedLines> changedLines;
   private SubmitTypeRecord submitTypeRecord;
   private Boolean mergeable;
-  private Boolean merge;
   private Set<String> hashtags;
-  private Map<Account.Id, Ref> editsByUser;
+  /** Map from {@link 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;
-  private Map<Account.Id, Ref> draftsByUser;
+  /**
+   * Map from {@link Account.Id} to the tip of the draft comments ref for this change and the user.
+   */
+  private Map<Account.Id, ObjectId> draftsByUser;
+
   private ImmutableListMultimap<Account.Id, String> stars;
   private StarsOf starsOf;
   private ImmutableMap<Account.Id, StarRef> starRefs;
@@ -334,7 +341,7 @@
   private PersonIdent author;
   private PersonIdent committer;
   private ImmutableSet<AttentionSetUpdate> attentionSet;
-  private int parentCount;
+  private Integer parentCount;
   private Integer unresolvedCommentCount;
   private Integer totalCommentCount;
   private LabelTypes labelTypes;
@@ -472,6 +479,26 @@
     changedLines = Optional.of(new ChangedLines(insertions, deletions));
   }
 
+  public void setLinesInserted(int insertions) {
+    changedLines =
+        Optional.of(
+            new ChangedLines(
+                insertions,
+                changedLines != null && changedLines.isPresent()
+                    ? changedLines.get().deletions
+                    : -1));
+  }
+
+  public void setLinesDeleted(int deletions) {
+    changedLines =
+        Optional.of(
+            new ChangedLines(
+                changedLines != null && changedLines.isPresent()
+                    ? changedLines.get().insertions
+                    : -1,
+                deletions));
+  }
+
   public void setNoChangedLines() {
     changedLines = Optional.empty();
   }
@@ -559,8 +586,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();
@@ -631,7 +657,6 @@
       author = c.getAuthorIdent();
       committer = c.getCommitterIdent();
       parentCount = c.getParentCount();
-      merge = parentCount > 1;
     } catch (IOException e) {
       throw new StorageException(
           String.format(
@@ -987,36 +1012,39 @@
 
   @Nullable
   public Boolean isMerge() {
-    if (merge == null) {
+    if (parentCount == null) {
       if (!loadCommitData()) {
         return null;
       }
     }
-    return merge;
+    return parentCount > 1;
   }
 
   public Set<Account.Id> editsByUser() {
-    return editRefs().keySet();
+    return editRefs().rowKeySet();
   }
 
-  public Map<Account.Id, Ref> editRefs() {
+  public Table<Account.Id, PatchSet.Id, ObjectId> editRefs() {
     if (editsByUser == null) {
       if (!lazyload()) {
-        return Collections.emptyMap();
+        return HashBasedTable.create();
       }
       Change c = change();
       if (c == null) {
-        return Collections.emptyMap();
+        return HashBasedTable.create();
       }
-      editsByUser = new HashMap<>();
+      editsByUser = HashBasedTable.create();
       Change.Id id = requireNonNull(change.getId());
       try (Repository repo = repoManager.openRepository(project())) {
         for (Ref ref : repo.getRefDatabase().getRefsByPrefix(RefNames.REFS_USERS)) {
-          String name = ref.getName().substring(RefNames.REFS_USERS.length());
-          if (id.equals(Change.Id.fromEditRefPart(name))) {
-            Account.Id accountId = Account.Id.fromRefPart(name);
+          if (!RefNames.isRefsEdit(ref.getName())) {
+            continue;
+          }
+          PatchSet.Id ps = PatchSet.Id.fromEditRef(ref.getName());
+          if (id.equals(ps.changeId())) {
+            Account.Id accountId = Account.Id.fromRef(ref.getName());
             if (accountId != null) {
-              editsByUser.put(accountId, ref);
+              editsByUser.put(accountId, ps, ref.getObjectId());
             }
           }
         }
@@ -1031,34 +1059,6 @@
     return draftRefs().keySet();
   }
 
-  public Map<Account.Id, Ref> draftRefs() {
-    if (draftsByUser == null) {
-      if (!lazyload()) {
-        return Collections.emptyMap();
-      }
-      Change c = change();
-      if (c == null) {
-        return Collections.emptyMap();
-      }
-
-      draftsByUser = new HashMap<>();
-      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
-            // filtering out zombies. If some but not all drafts in the ref
-            // were zombies, the returned Ref still includes those zombies;
-            // this is suboptimal, but is ok for the purposes of
-            // draftsByUser(), and easier than trying to rebuild the change at
-            // this point.
-            && !notes().getDraftComments(account, ref).isEmpty()) {
-          draftsByUser.put(account, ref);
-        }
-      }
-    }
-    return draftsByUser;
-  }
-
   public boolean isReviewedBy(Account.Id accountId) {
     Collection<String> stars = stars(accountId);
 
@@ -1213,7 +1213,14 @@
       }
 
       ImmutableSetMultimap.Builder<NameKey, RefState> result = ImmutableSetMultimap.builder();
-      editRefs().values().forEach(r -> result.put(project, RefState.of(r)));
+      for (Table.Cell<Account.Id, PatchSet.Id, ObjectId> edit : editRefs().cellSet()) {
+        result.put(
+            project,
+            RefState.create(
+                RefNames.refsEdit(
+                    edit.getRowKey(), edit.getColumnKey().changeId(), edit.getColumnKey()),
+                edit.getValue()));
+      }
       starRefs().values().forEach(r -> result.put(allUsersName, RefState.of(r.ref())));
 
       // TODO: instantiating the notes is too much. We don't want to parse NoteDb, we just want the
@@ -1222,7 +1229,14 @@
       notes().getRobotComments(); // Force loading robot comments.
       RobotCommentNotes robotNotes = notes().getRobotCommentNotes();
       result.put(project, RefState.create(robotNotes.getRefName(), robotNotes.getMetaId()));
-      draftRefs().values().forEach(r -> result.put(allUsersName, RefState.of(r)));
+      draftRefs()
+          .entrySet()
+          .forEach(
+              r ->
+                  result.put(
+                      allUsersName,
+                      RefState.create(
+                          RefNames.refsDraftComments(getId(), r.getKey()), r.getValue())));
 
       refStates = result.build();
     }
@@ -1238,6 +1252,33 @@
 
   public void setRefStates(ImmutableSetMultimap<Project.NameKey, RefState> refStates) {
     this.refStates = refStates;
+    if (draftsByUser == null) {
+      // Recover draft refs as well. Draft comments are represented as refs in the repository.
+      // ChangeData exposes #draftsByUser which just provides a Set of Account.Ids of users who
+      // have drafts comments on this change. Recovering this list from RefStates makes it
+      // available even on ChangeData instances retrieved from the index.
+      draftsByUser = new HashMap<>();
+      if (refStates.containsKey(allUsersName)) {
+        refStates.get(allUsersName).stream()
+            .filter(r -> RefNames.isRefsDraftsComments(r.ref()))
+            .forEach(r -> draftsByUser.put(Account.Id.fromRef(r.ref()), r.id()));
+      }
+    }
+    if (editsByUser == null) {
+      // Recover edit refs as well. Edits are represented as refs in the repository.
+      // ChangeData exposes #editsByUser which just provides a Set of Account.Ids of users who
+      // have edits on this change. Recovering this list from RefStates makes it available even
+      // on ChangeData instances retrieved from the index.
+      editsByUser = HashBasedTable.create();
+      if (refStates.containsKey(project())) {
+        refStates.get(project()).stream()
+            .filter(r -> RefNames.isRefsEdit(r.ref()))
+            .forEach(
+                r ->
+                    editsByUser.put(
+                        Account.Id.fromRef(r.ref()), PatchSet.Id.fromEditRef(r.ref()), r.id()));
+      }
+    }
   }
 
   public ImmutableList<byte[]> getRefStatePatterns() {
@@ -1269,4 +1310,32 @@
 
     public abstract ImmutableSortedSet<String> stars();
   }
+
+  private Map<Account.Id, ObjectId> draftRefs() {
+    if (draftsByUser == null) {
+      if (!lazyload()) {
+        return Collections.emptyMap();
+      }
+      Change c = change();
+      if (c == null) {
+        return Collections.emptyMap();
+      }
+
+      draftsByUser = new HashMap<>();
+      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
+            // filtering out zombies. If some but not all drafts in the ref
+            // were zombies, the returned Ref still includes those zombies;
+            // this is suboptimal, but is ok for the purposes of
+            // draftsByUser(), and easier than trying to rebuild the change at
+            // this point.
+            && !notes().getDraftComments(account, ref).isEmpty()) {
+          draftsByUser.put(account, ref.getObjectId());
+        }
+      }
+    }
+    return draftsByUser;
+  }
 }
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 05cc6ca..0000000
--- a/java/com/google/gerrit/server/query/change/ChangeIdPredicate.java
+++ /dev/null
@@ -1,44 +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;
-  }
-
-  @Override
-  public int getCost() {
-    return 1;
-  }
-}
diff --git a/java/com/google/gerrit/server/query/change/ChangeIndexPredicate.java b/java/com/google/gerrit/server/query/change/ChangeIndexPredicate.java
index a176a58..ccd4109 100644
--- a/java/com/google/gerrit/server/query/change/ChangeIndexPredicate.java
+++ b/java/com/google/gerrit/server/query/change/ChangeIndexPredicate.java
@@ -16,12 +16,10 @@
 
 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;
 
 /** Predicate that is mapped to a field in the change index. */
-public abstract 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.
    *
diff --git a/java/com/google/gerrit/server/query/change/ChangePredicates.java b/java/com/google/gerrit/server/query/change/ChangePredicates.java
new file mode 100644
index 0000000..617002d
--- /dev/null
+++ b/java/com/google/gerrit/server/query/change/ChangePredicates.java
@@ -0,0 +1,281 @@
+// 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.common.base.CharMatcher;
+import com.google.gerrit.entities.Account;
+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;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+import java.util.Locale;
+
+/** Predicates that match against {@link ChangeData}. */
+public class ChangePredicates {
+  private ChangePredicates() {}
+
+  /**
+   * Returns a predicate that matches changes where the provided {@link 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}.
+   */
+  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}.
+   */
+  public static Predicate<ChangeData> revertOf(Change.Id revertOf) {
+    return new ChangeIndexPredicate(ChangeField.REVERT_OF, revertOf.toString());
+  }
+
+  /**
+   * Returns a predicate that matches changes that have a comment authored by the provided {@link
+   * 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.
+   */
+  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.
+   */
+  public static Predicate<ChangeData> draftBy(Account.Id id) {
+    return new ChangeIndexPredicate(ChangeField.DRAFTBY, id.toString());
+  }
+
+  /**
+   * Returns a predicate that matches changes that were reviewed by any of the provided {@link
+   * Account.Id}.
+   */
+  public static Predicate<ChangeData> reviewedBy(Collection<Account.Id> ids) {
+    List<Predicate<ChangeData>> predicates = new ArrayList<>(ids.size());
+    for (Account.Id id : ids) {
+      predicates.add(new ChangeIndexPredicate(ChangeField.REVIEWEDBY, id.toString()));
+    }
+    return Predicate.or(predicates);
+  }
+
+  /** Returns a predicate that matches changes that were not yet reviewed. */
+  public static Predicate<ChangeData> unreviewed() {
+    return Predicate.not(
+        new ChangeIndexPredicate(ChangeField.REVIEWEDBY, ChangeField.NOT_REVIEWED.toString()));
+  }
+
+  /** Returns a predicate that matches the change with the provided {@link 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}. */
+  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}. */
+  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}.
+   */
+  public static Predicate<ChangeData> cherryPickOf(Change.Id id) {
+    return new ChangeIndexPredicate(ChangeField.CHERRY_PICK_OF_CHANGE, id.toString());
+  }
+
+  /**
+   * Returns a predicate that matches changes that are a cherry pick of the provided {@link
+   * PatchSet.Id}.
+   */
+  public static Predicate<ChangeData> cherryPickOf(PatchSet.Id psId) {
+    return Predicate.and(
+        cherryPickOf(psId.changeId()),
+        new ChangeIndexPredicate(ChangeField.CHERRY_PICK_OF_PATCHSET, String.valueOf(psId.get())));
+  }
+
+  /** Returns a predicate that matches changes in the provided {@link Project.NameKey}. */
+  public static Predicate<ChangeData> project(Project.NameKey id) {
+    return new ChangeIndexPredicate(ChangeField.PROJECT, id.get());
+  }
+
+  /** Returns a predicate that matches changes targeted at the provided {@code refName}. */
+  public static Predicate<ChangeData> ref(String refName) {
+    return new ChangeIndexPredicate(ChangeField.REF, refName);
+  }
+
+  /** Returns a predicate that matches changes in the provided {@code topic}. */
+  public static Predicate<ChangeData> exactTopic(String topic) {
+    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);
+  }
+
+  /** Returns a predicate that matches changes that modified the provided {@code path}. */
+  public static Predicate<ChangeData> path(String path) {
+    return new ChangeIndexPredicate(ChangeField.PATH, path);
+  }
+
+  /** Returns a predicate that matches changes tagged with the provided {@code hashtag}. */
+  public static Predicate<ChangeData> hashtag(String hashtag) {
+    // Use toLowerCase without locale to match behavior in ChangeField.
+    return new ChangeIndexPredicate(
+        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);
+    if (!args.getSchema().hasField(ChangeField.FILE_PART)) {
+      return eqPath;
+    }
+    return Predicate.or(eqPath, new ChangeIndexPredicate(ChangeField.FILE_PART, file));
+  }
+
+  /**
+   * Returns a predicate that matches changes with the provided {@code footer} in their commit
+   * message.
+   */
+  public static Predicate<ChangeData> footer(String footer) {
+    int indexEquals = footer.indexOf('=');
+    int indexColon = footer.indexOf(':');
+
+    // footer key cannot contain '='
+    if (indexEquals > 0 && (indexEquals < indexColon || indexColon < 0)) {
+      footer = footer.substring(0, indexEquals) + ": " + footer.substring(indexEquals + 1);
+    }
+    return new ChangeIndexPredicate(ChangeField.FOOTER, footer.toLowerCase(Locale.US));
+  }
+
+  /**
+   * Returns a predicate that matches changes that modified files in the provided {@code directory}.
+   */
+  public static Predicate<ChangeData> directory(String directory) {
+    return new ChangeIndexPredicate(
+        ChangeField.DIRECTORY, CharMatcher.is('/').trimFrom(directory).toLowerCase(Locale.US));
+  }
+
+  /** Returns a predicate that matches changes with the provided {@code trackingId}. */
+  public static Predicate<ChangeData> trackingId(String trackingId) {
+    return new ChangeIndexPredicate(ChangeField.TR, trackingId);
+  }
+
+  /** Returns a predicate that matches changes authored by the provided {@code exactAuthor}. */
+  public static Predicate<ChangeData> exactAuthor(String exactAuthor) {
+    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}.
+   */
+  public static Predicate<ChangeData> exactCommitter(String exactCommitter) {
+    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 4e3edcd..94b5442 100644
--- a/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
+++ b/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
@@ -36,6 +36,7 @@
 import com.google.gerrit.entities.GroupDescription;
 import com.google.gerrit.entities.GroupReference;
 import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.entities.SubmitRecord;
 import com.google.gerrit.exceptions.NotSignedInException;
@@ -527,17 +528,17 @@
       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);
       if (id != null) {
         return args.getSchema().useLegacyNumericFields()
-            ? new LegacyChangeIdPredicate(Change.id(id))
-            : new LegacyChangeIdStrPredicate(Change.id(id));
+            ? ChangePredicates.id(Change.id(id))
+            : 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");
@@ -545,13 +546,13 @@
 
   @Operator
   public Predicate<ChangeData> comment(String value) {
-    return new CommentPredicate(args.index, value);
+    return ChangePredicates.comment(value);
   }
 
   @Operator
   public Predicate<ChangeData> status(String statusName) {
     if ("reviewed".equalsIgnoreCase(statusName)) {
-      return IsReviewedPredicate.create();
+      return ChangePredicates.unreviewed();
     }
     return ChangeStatusPredicate.parse(statusName);
   }
@@ -576,7 +577,7 @@
     }
 
     if ("edit".equalsIgnoreCase(value)) {
-      return new EditByPredicate(self());
+      return ChangePredicates.editBy(self());
     }
 
     if ("unresolved".equalsIgnoreCase(value)) {
@@ -610,11 +611,11 @@
     }
 
     if ("reviewed".equalsIgnoreCase(value)) {
-      return IsReviewedPredicate.create();
+      return ChangePredicates.unreviewed();
     }
 
     if ("owner".equalsIgnoreCase(value)) {
-      return new OwnerPredicate(self());
+      return ChangePredicates.owner(self());
     }
 
     if ("reviewer".equalsIgnoreCase(value)) {
@@ -653,11 +654,11 @@
     }
 
     if ("assigned".equalsIgnoreCase(value)) {
-      return Predicate.not(new AssigneePredicate(Account.id(ChangeField.NO_ASSIGNEE)));
+      return Predicate.not(ChangePredicates.assignee(Account.id(ChangeField.NO_ASSIGNEE)));
     }
 
     if ("unassigned".equalsIgnoreCase(value)) {
-      return new AssigneePredicate(Account.id(ChangeField.NO_ASSIGNEE));
+      return ChangePredicates.assignee(Account.id(ChangeField.NO_ASSIGNEE));
     }
 
     if ("submittable".equalsIgnoreCase(value)) {
@@ -704,7 +705,7 @@
 
   @Operator
   public Predicate<ChangeData> commit(String id) {
-    return new CommitPredicate(id);
+    return ChangePredicates.commitPrefix(id);
   }
 
   @Operator
@@ -727,12 +728,12 @@
     if (name.startsWith("^")) {
       return new RegexProjectPredicate(name);
     }
-    return new ProjectPredicate(name);
+    return ChangePredicates.project(Project.nameKey(name));
   }
 
   @Operator
   public Predicate<ChangeData> projects(String name) {
-    return new ProjectPrefixPredicate(name);
+    return ChangePredicates.projectPrefix(name);
   }
 
   @Operator
@@ -790,12 +791,28 @@
 
   @Operator
   public Predicate<ChangeData> hashtag(String hashtag) {
-    return new HashtagPredicate(hashtag);
+    return ChangePredicates.hashtag(hashtag);
+  }
+
+  @Operator
+  public Predicate<ChangeData> inhashtag(String hashtag) throws QueryParseException {
+    if (hashtag.startsWith("^")) {
+      return new RegexHashtagPredicate(hashtag);
+    }
+    if (hashtag.isEmpty()) {
+      return ChangePredicates.hashtag(hashtag);
+    }
+
+    if (!args.index.getSchema().hasField(ChangeField.FUZZY_HASHTAG)) {
+      throw new QueryParseException(
+          "'inhashtag' operator is not supported by change index version");
+    }
+    return ChangePredicates.fuzzyHashtag(hashtag);
   }
 
   @Operator
   public Predicate<ChangeData> topic(String name) {
-    return new ExactTopicPredicate(name);
+    return ChangePredicates.exactTopic(name);
   }
 
   @Operator
@@ -804,9 +821,9 @@
       return new RegexTopicPredicate(name);
     }
     if (name.isEmpty()) {
-      return new ExactTopicPredicate(name);
+      return ChangePredicates.exactTopic(name);
     }
-    return new FuzzyTopicPredicate(name, args.index);
+    return ChangePredicates.fuzzyTopic(name);
   }
 
   @Operator
@@ -814,7 +831,7 @@
     if (ref.startsWith("^")) {
       return new RegexRefPredicate(ref);
     }
-    return new RefPredicate(ref);
+    return ChangePredicates.ref(ref);
   }
 
   @Operator
@@ -827,7 +844,7 @@
     if (file.startsWith("^")) {
       return new RegexPathPredicate(file);
     }
-    return EqualsFilePredicate.create(args, file);
+    return ChangePredicates.file(args, file);
   }
 
   @Operator
@@ -835,7 +852,7 @@
     if (path.startsWith("^")) {
       return new RegexPathPredicate(path);
     }
-    return new EqualsPathPredicate(FIELD_PATH, path);
+    return ChangePredicates.path(path);
   }
 
   @Operator
@@ -868,7 +885,7 @@
   @Operator
   public Predicate<ChangeData> footer(String footer) throws QueryParseException {
     if (args.getSchema().hasField(ChangeField.FOOTER)) {
-      return new FooterPredicate(footer);
+      return ChangePredicates.footer(footer);
     }
     throw new QueryParseException("'footer' operator is not supported by change index version");
   }
@@ -884,7 +901,7 @@
       if (directory.startsWith("^")) {
         return new RegexDirectoryPredicate(directory);
       }
-      return new DirectoryPredicate(directory);
+      return ChangePredicates.directory(directory);
     }
     throw new QueryParseException("'directory' operator is not supported by change index version");
   }
@@ -981,7 +998,7 @@
 
   @Operator
   public Predicate<ChangeData> message(String text) {
-    return new MessagePredicate(args.index, text);
+    return ChangePredicates.message(text);
   }
 
   @Operator
@@ -1044,7 +1061,7 @@
   }
 
   private Predicate<ChangeData> draftby(Account.Id who) {
-    return new HasDraftByPredicate(who);
+    return ChangePredicates.draftBy(who);
   }
 
   @Operator
@@ -1103,9 +1120,9 @@
   }
 
   private Predicate<ChangeData> owner(Set<Account.Id> who) {
-    List<OwnerPredicate> p = Lists.newArrayListWithCapacity(who.size());
+    List<Predicate<ChangeData>> p = Lists.newArrayListWithCapacity(who.size());
     for (Account.Id id : who) {
-      p.add(new OwnerPredicate(id));
+      p.add(ChangePredicates.owner(id));
     }
     return Predicate.or(p);
   }
@@ -1130,7 +1147,7 @@
   }
 
   private Predicate<ChangeData> attention(Set<Account.Id> who) {
-    return Predicate.or(who.stream().map(AttentionSetPredicate::new).collect(toImmutableSet()));
+    return Predicate.or(who.stream().map(ChangePredicates::attentionSet).collect(toImmutableSet()));
   }
 
   @Operator
@@ -1140,9 +1157,9 @@
   }
 
   private Predicate<ChangeData> assignee(Set<Account.Id> who) {
-    List<AssigneePredicate> p = Lists.newArrayListWithCapacity(who.size());
+    List<Predicate<ChangeData>> p = Lists.newArrayListWithCapacity(who.size());
     for (Account.Id id : who) {
-      p.add(new AssigneePredicate(id));
+      p.add(ChangePredicates.assignee(id));
     }
     return Predicate.or(p);
   }
@@ -1161,9 +1178,9 @@
     }
 
     Set<Account.Id> accounts = getMembers(groupId);
-    List<OwnerPredicate> p = Lists.newArrayListWithCapacity(accounts.size());
+    List<Predicate<ChangeData>> p = Lists.newArrayListWithCapacity(accounts.size());
     for (Account.Id id : accounts) {
-      p.add(new OwnerPredicate(id));
+      p.add(ChangePredicates.owner(id));
     }
     return Predicate.or(p);
   }
@@ -1215,7 +1232,7 @@
 
   @Operator
   public Predicate<ChangeData> tr(String trackingId) {
-    return new TrackingIdPredicate(trackingId);
+    return ChangePredicates.trackingId(trackingId);
   }
 
   @Operator
@@ -1259,9 +1276,9 @@
   }
 
   private Predicate<ChangeData> commentby(Set<Account.Id> who) {
-    List<CommentByPredicate> p = Lists.newArrayListWithCapacity(who.size());
+    List<Predicate<ChangeData>> p = Lists.newArrayListWithCapacity(who.size());
     for (Account.Id id : who) {
-      p.add(new CommentByPredicate(id));
+      p.add(ChangePredicates.commentBy(id));
     }
     return Predicate.or(p);
   }
@@ -1321,7 +1338,7 @@
   @Operator
   public Predicate<ChangeData> reviewedby(String who)
       throws QueryParseException, IOException, ConfigInvalidException {
-    return IsReviewedPredicate.create(parseAccount(who));
+    return ChangePredicates.reviewedBy(parseAccount(who));
   }
 
   @Operator
@@ -1373,18 +1390,18 @@
   public Predicate<ChangeData> author(String who) throws QueryParseException {
     if (args.getSchema().hasField(ChangeField.EXACT_AUTHOR)) {
       return getAuthorOrCommitterPredicate(
-          who.trim(), ExactAuthorPredicate::new, 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(), ExactCommitterPredicate::new, CommitterPredicate::new);
+          who.trim(), ChangePredicates::exactCommitter, ChangePredicates::committer);
     }
-    return getAuthorOrCommitterFullTextPredicate(who.trim(), CommitterPredicate::new);
+    return getAuthorOrCommitterFullTextPredicate(who.trim(), ChangePredicates::committer);
   }
 
   @Operator
@@ -1404,8 +1421,11 @@
 
   @Operator
   public Predicate<ChangeData> revertof(String value) throws QueryParseException {
+    if (value == null || Ints.tryParse(value) == null) {
+      throw new QueryParseException("'revertof' must be an integer");
+    }
     if (args.getSchema().hasField(ChangeField.REVERT_OF)) {
-      return new RevertOfPredicate(value);
+      return ChangePredicates.revertOf(Change.id(Ints.tryParse(value)));
     }
     throw new QueryParseException("'revertof' operator is not supported by change index version");
   }
@@ -1413,7 +1433,7 @@
   @Operator
   public Predicate<ChangeData> submissionId(String value) throws QueryParseException {
     if (args.getSchema().hasField(ChangeField.SUBMISSIONID)) {
-      return new SubmissionIdPredicate(value);
+      return ChangePredicates.submissionId(value);
     }
     throw new QueryParseException(
         "'submissionid' operator is not supported by change index version");
@@ -1424,13 +1444,11 @@
     if (args.getSchema().hasField(ChangeField.CHERRY_PICK_OF_CHANGE)
         && args.getSchema().hasField(ChangeField.CHERRY_PICK_OF_PATCHSET)) {
       if (Ints.tryParse(value) != null) {
-        return new CherryPickOfChangePredicate(value);
+        return ChangePredicates.cherryPickOf(Change.id(Ints.tryParse(value)));
       }
       try {
         PatchSet.Id patchSetId = PatchSet.Id.parse(value);
-        return Predicate.and(
-            new CherryPickOfChangePredicate(patchSetId.changeId().toString()),
-            new CherryPickOfPatchSetPredicate(patchSetId.getId()));
+        return ChangePredicates.cherryPickOf(patchSetId);
       } catch (IllegalArgumentException e) {
         throw new QueryParseException(
             "'"
diff --git a/java/com/google/gerrit/server/query/change/ChangeStatusPredicate.java b/java/com/google/gerrit/server/query/change/ChangeStatusPredicate.java
index 88e93d9..0721433 100644
--- a/java/com/google/gerrit/server/query/change/ChangeStatusPredicate.java
+++ b/java/com/google/gerrit/server/query/change/ChangeStatusPredicate.java
@@ -124,11 +124,6 @@
   }
 
   @Override
-  public int getCost() {
-    return 0;
-  }
-
-  @Override
   public int hashCode() {
     return Objects.hashCode(status);
   }
diff --git a/java/com/google/gerrit/server/query/change/CherryPickOfChangePredicate.java b/java/com/google/gerrit/server/query/change/CherryPickOfChangePredicate.java
deleted file mode 100644
index d452017..0000000
--- a/java/com/google/gerrit/server/query/change/CherryPickOfChangePredicate.java
+++ /dev/null
@@ -1,36 +0,0 @@
-// Copyright (C) 2019 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.query.change;
-
-import com.google.gerrit.server.index.change.ChangeField;
-
-public class CherryPickOfChangePredicate extends ChangeIndexPredicate {
-  public CherryPickOfChangePredicate(String cherryPickOfChange) {
-    super(ChangeField.CHERRY_PICK_OF_CHANGE, cherryPickOfChange);
-  }
-
-  @Override
-  public boolean match(ChangeData cd) {
-    if (cd.change().getCherryPickOf() == null) {
-      return false;
-    }
-    return Integer.toString(cd.change().getCherryPickOf().changeId().get()).equals(value);
-  }
-
-  @Override
-  public int getCost() {
-    return 1;
-  }
-}
diff --git a/java/com/google/gerrit/server/query/change/CherryPickOfPatchSetPredicate.java b/java/com/google/gerrit/server/query/change/CherryPickOfPatchSetPredicate.java
deleted file mode 100644
index 888f45d..0000000
--- a/java/com/google/gerrit/server/query/change/CherryPickOfPatchSetPredicate.java
+++ /dev/null
@@ -1,36 +0,0 @@
-// Copyright (C) 2019 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.query.change;
-
-import com.google.gerrit.server.index.change.ChangeField;
-
-public class CherryPickOfPatchSetPredicate extends ChangeIndexPredicate {
-  public CherryPickOfPatchSetPredicate(String cherryPickOfPatchSet) {
-    super(ChangeField.CHERRY_PICK_OF_PATCHSET, cherryPickOfPatchSet);
-  }
-
-  @Override
-  public boolean match(ChangeData cd) {
-    if (cd.change().getCherryPickOf() == null) {
-      return false;
-    }
-    return cd.change().getCherryPickOf().getId().equals(value);
-  }
-
-  @Override
-  public int getCost() {
-    return 1;
-  }
-}
diff --git a/java/com/google/gerrit/server/query/change/CommentByPredicate.java b/java/com/google/gerrit/server/query/change/CommentByPredicate.java
deleted file mode 100644
index b8cf100..0000000
--- a/java/com/google/gerrit/server/query/change/CommentByPredicate.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 com.google.gerrit.entities.Account;
-import com.google.gerrit.entities.ChangeMessage;
-import com.google.gerrit.entities.HumanComment;
-import com.google.gerrit.server.index.change.ChangeField;
-import java.util.Objects;
-
-public class CommentByPredicate extends ChangeIndexPredicate {
-  protected final Account.Id id;
-
-  public CommentByPredicate(Account.Id id) {
-    super(ChangeField.COMMENTBY, id.toString());
-    this.id = id;
-  }
-
-  Account.Id getAccountId() {
-    return id;
-  }
-
-  @Override
-  public boolean match(ChangeData cd) {
-    for (ChangeMessage m : cd.messages()) {
-      if (Objects.equals(m.getAuthor(), id)) {
-        return true;
-      }
-    }
-    for (HumanComment c : cd.publishedComments()) {
-      if (Objects.equals(c.author.getId(), id)) {
-        return true;
-      }
-    }
-    return false;
-  }
-
-  @Override
-  public int getCost() {
-    return 1;
-  }
-}
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 4b14f08..0000000
--- a/java/com/google/gerrit/server/query/change/CommentPredicate.java
+++ /dev/null
@@ -1,59 +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()
-                  ? new LegacyChangeIdPredicate(id)
-                  : new LegacyChangeIdStrPredicate(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;
-  }
-
-  @Override
-  public int getCost() {
-    return 1;
-  }
-}
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 b54ee64..0000000
--- a/java/com/google/gerrit/server/query/change/CommitPredicate.java
+++ /dev/null
@@ -1,59 +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);
-  }
-
-  @Override
-  public int getCost() {
-    return 1;
-  }
-}
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 1dcf97f..0000000
--- a/java/com/google/gerrit/server/query/change/CommitterPredicate.java
+++ /dev/null
@@ -1,36 +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());
-  }
-
-  @Override
-  public int getCost() {
-    return 1;
-  }
-}
diff --git a/java/com/google/gerrit/server/query/change/ConflictsPredicate.java b/java/com/google/gerrit/server/query/change/ConflictsPredicate.java
index 16f85b1..1ad9dba 100644
--- a/java/com/google/gerrit/server/query/change/ConflictsPredicate.java
+++ b/java/com/google/gerrit/server/query/change/ConflictsPredicate.java
@@ -81,17 +81,17 @@
 
     List<Predicate<ChangeData>> filePredicates = new ArrayList<>(files.size());
     for (String file : files) {
-      filePredicates.add(new EqualsPathPredicate(ChangeQueryBuilder.FIELD_PATH, file));
+      filePredicates.add(ChangePredicates.path(file));
     }
 
     List<Predicate<ChangeData>> and = new ArrayList<>(5);
-    and.add(new ProjectPredicate(c.getProject().get()));
-    and.add(new RefPredicate(c.getDest().branch()));
+    and.add(ChangePredicates.project(c.getProject()));
+    and.add(ChangePredicates.ref(c.getDest().branch()));
     and.add(
         Predicate.not(
             args.getSchema().useLegacyNumericFields()
-                ? new LegacyChangeIdPredicate(c.getId())
-                : new LegacyChangeIdStrPredicate(c.getId())));
+                ? ChangePredicates.id(c.getId())
+                : ChangePredicates.idStr(c.getId())));
     and.add(Predicate.or(filePredicates));
 
     ChangeDataCache changeDataCache = new ChangeDataCache(cd, args.projectCache);
diff --git a/java/com/google/gerrit/server/query/change/DirectoryPredicate.java b/java/com/google/gerrit/server/query/change/DirectoryPredicate.java
deleted file mode 100644
index 3ab3e26..0000000
--- a/java/com/google/gerrit/server/query/change/DirectoryPredicate.java
+++ /dev/null
@@ -1,39 +0,0 @@
-// Copyright (C) 2019 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.query.change;
-
-import com.google.common.base.CharMatcher;
-import com.google.gerrit.server.index.change.ChangeField;
-import java.util.Locale;
-
-public class DirectoryPredicate extends ChangeIndexPredicate {
-  private static String clean(String directory) {
-    return CharMatcher.is('/').trimFrom(directory).toLowerCase(Locale.US);
-  }
-
-  DirectoryPredicate(String value) {
-    super(ChangeField.DIRECTORY, clean(value));
-  }
-
-  @Override
-  public boolean match(ChangeData cd) {
-    return ChangeField.getDirectories(cd).contains(value);
-  }
-
-  @Override
-  public int getCost() {
-    return 0;
-  }
-}
diff --git a/java/com/google/gerrit/server/query/change/EditByPredicate.java b/java/com/google/gerrit/server/query/change/EditByPredicate.java
deleted file mode 100644
index 0fd66f8..0000000
--- a/java/com/google/gerrit/server/query/change/EditByPredicate.java
+++ /dev/null
@@ -1,37 +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 com.google.gerrit.entities.Account;
-import com.google.gerrit.server.index.change.ChangeField;
-
-public class EditByPredicate extends ChangeIndexPredicate {
-  protected final Account.Id id;
-
-  public EditByPredicate(Account.Id id) {
-    super(ChangeField.EDITBY, id.toString());
-    this.id = id;
-  }
-
-  @Override
-  public boolean match(ChangeData cd) {
-    return cd.editsByUser().contains(id);
-  }
-
-  @Override
-  public int getCost() {
-    return 1;
-  }
-}
diff --git a/java/com/google/gerrit/server/query/change/EqualsFilePredicate.java b/java/com/google/gerrit/server/query/change/EqualsFilePredicate.java
deleted file mode 100644
index 9c033b6..0000000
--- a/java/com/google/gerrit/server/query/change/EqualsFilePredicate.java
+++ /dev/null
@@ -1,43 +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.index.query.Predicate;
-import com.google.gerrit.server.index.change.ChangeField;
-import com.google.gerrit.server.query.change.ChangeQueryBuilder.Arguments;
-
-public class EqualsFilePredicate extends ChangeIndexPredicate {
-  public static Predicate<ChangeData> create(Arguments args, String value) {
-    Predicate<ChangeData> eqPath = new EqualsPathPredicate(ChangeQueryBuilder.FIELD_FILE, value);
-    if (!args.getSchema().hasField(ChangeField.FILE_PART)) {
-      return eqPath;
-    }
-    return Predicate.or(eqPath, new EqualsFilePredicate(value));
-  }
-
-  private EqualsFilePredicate(String value) {
-    super(ChangeField.FILE_PART, ChangeQueryBuilder.FIELD_FILE, value);
-  }
-
-  @Override
-  public boolean match(ChangeData object) {
-    return ChangeField.getFileParts(object).contains(value);
-  }
-
-  @Override
-  public int getCost() {
-    return 1;
-  }
-}
diff --git a/java/com/google/gerrit/server/query/change/EqualsPathPredicate.java b/java/com/google/gerrit/server/query/change/EqualsPathPredicate.java
deleted file mode 100644
index 76936fa..0000000
--- a/java/com/google/gerrit/server/query/change/EqualsPathPredicate.java
+++ /dev/null
@@ -1,34 +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.server.index.change.ChangeField;
-import java.util.Collections;
-
-public class EqualsPathPredicate extends ChangeIndexPredicate {
-  public EqualsPathPredicate(String fieldName, String value) {
-    super(ChangeField.PATH, fieldName, value);
-  }
-
-  @Override
-  public boolean match(ChangeData object) {
-    return Collections.binarySearch(object.currentFilePaths(), value) >= 0;
-  }
-
-  @Override
-  public int getCost() {
-    return 1;
-  }
-}
diff --git a/java/com/google/gerrit/server/query/change/ExactAuthorPredicate.java b/java/com/google/gerrit/server/query/change/ExactAuthorPredicate.java
deleted file mode 100644
index c1b6928..0000000
--- a/java/com/google/gerrit/server/query/change/ExactAuthorPredicate.java
+++ /dev/null
@@ -1,37 +0,0 @@
-// 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.
-
-package com.google.gerrit.server.query.change;
-
-import static com.google.gerrit.server.index.change.ChangeField.EXACT_AUTHOR;
-import static com.google.gerrit.server.query.change.ChangeQueryBuilder.FIELD_EXACTAUTHOR;
-
-import com.google.gerrit.server.index.change.ChangeField;
-import java.util.Locale;
-
-public class ExactAuthorPredicate extends ChangeIndexPredicate {
-  public ExactAuthorPredicate(String value) {
-    super(EXACT_AUTHOR, FIELD_EXACTAUTHOR, value.toLowerCase(Locale.US));
-  }
-
-  @Override
-  public boolean match(ChangeData object) {
-    return ChangeField.getAuthorNameAndEmail(object).contains(getValue());
-  }
-
-  @Override
-  public int getCost() {
-    return 1;
-  }
-}
diff --git a/java/com/google/gerrit/server/query/change/ExactCommitterPredicate.java b/java/com/google/gerrit/server/query/change/ExactCommitterPredicate.java
deleted file mode 100644
index dac63af..0000000
--- a/java/com/google/gerrit/server/query/change/ExactCommitterPredicate.java
+++ /dev/null
@@ -1,37 +0,0 @@
-// 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.
-
-package com.google.gerrit.server.query.change;
-
-import static com.google.gerrit.server.index.change.ChangeField.EXACT_COMMITTER;
-import static com.google.gerrit.server.query.change.ChangeQueryBuilder.FIELD_EXACTCOMMITTER;
-
-import com.google.gerrit.server.index.change.ChangeField;
-import java.util.Locale;
-
-public class ExactCommitterPredicate extends ChangeIndexPredicate {
-  public ExactCommitterPredicate(String value) {
-    super(EXACT_COMMITTER, FIELD_EXACTCOMMITTER, value.toLowerCase(Locale.US));
-  }
-
-  @Override
-  public boolean match(ChangeData object) {
-    return ChangeField.getCommitterNameAndEmail(object).contains(getValue());
-  }
-
-  @Override
-  public int getCost() {
-    return 1;
-  }
-}
diff --git a/java/com/google/gerrit/server/query/change/ExactTopicPredicate.java b/java/com/google/gerrit/server/query/change/ExactTopicPredicate.java
deleted file mode 100644
index 6683c91..0000000
--- a/java/com/google/gerrit/server/query/change/ExactTopicPredicate.java
+++ /dev/null
@@ -1,39 +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.EXACT_TOPIC;
-
-import com.google.gerrit.entities.Change;
-
-public class ExactTopicPredicate extends ChangeIndexPredicate {
-  public ExactTopicPredicate(String topic) {
-    super(EXACT_TOPIC, topic);
-  }
-
-  @Override
-  public boolean match(ChangeData object) {
-    Change change = object.change();
-    if (change == null) {
-      return false;
-    }
-    return getValue().equals(change.getTopic());
-  }
-
-  @Override
-  public int getCost() {
-    return 1;
-  }
-}
diff --git a/java/com/google/gerrit/server/query/change/FileExtensionListPredicate.java b/java/com/google/gerrit/server/query/change/FileExtensionListPredicate.java
index bddd2ec..c16bc83 100644
--- a/java/com/google/gerrit/server/query/change/FileExtensionListPredicate.java
+++ b/java/com/google/gerrit/server/query/change/FileExtensionListPredicate.java
@@ -36,9 +36,4 @@
   public boolean match(ChangeData cd) {
     return ChangeField.getAllExtensionsAsList(cd).equals(value);
   }
-
-  @Override
-  public int getCost() {
-    return 0;
-  }
 }
diff --git a/java/com/google/gerrit/server/query/change/FileExtensionPredicate.java b/java/com/google/gerrit/server/query/change/FileExtensionPredicate.java
index ee573a7..39715cf 100644
--- a/java/com/google/gerrit/server/query/change/FileExtensionPredicate.java
+++ b/java/com/google/gerrit/server/query/change/FileExtensionPredicate.java
@@ -33,9 +33,4 @@
   public boolean match(ChangeData object) {
     return ChangeField.getExtensions(object).contains(value);
   }
-
-  @Override
-  public int getCost() {
-    return 0;
-  }
 }
diff --git a/java/com/google/gerrit/server/query/change/FooterPredicate.java b/java/com/google/gerrit/server/query/change/FooterPredicate.java
deleted file mode 100644
index 4d7588c..0000000
--- a/java/com/google/gerrit/server/query/change/FooterPredicate.java
+++ /dev/null
@@ -1,45 +0,0 @@
-// Copyright (C) 2019 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.query.change;
-
-import com.google.gerrit.server.index.change.ChangeField;
-import java.util.Locale;
-
-public class FooterPredicate extends ChangeIndexPredicate {
-  private static String clean(String value) {
-    int indexEquals = value.indexOf('=');
-    int indexColon = value.indexOf(':');
-
-    // footer key cannot contain '='
-    if (indexEquals > 0 && (indexEquals < indexColon || indexColon < 0)) {
-      value = value.substring(0, indexEquals) + ": " + value.substring(indexEquals + 1);
-    }
-    return value.toLowerCase(Locale.US);
-  }
-
-  FooterPredicate(String value) {
-    super(ChangeField.FOOTER, clean(value));
-  }
-
-  @Override
-  public boolean match(ChangeData cd) {
-    return ChangeField.getFooters(cd).contains(value);
-  }
-
-  @Override
-  public int getCost() {
-    return 0;
-  }
-}
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 d558b0f..0000000
--- a/java/com/google/gerrit/server/query/change/FuzzyTopicPredicate.java
+++ /dev/null
@@ -1,62 +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()
-              ? new LegacyChangeIdPredicate(cd.getId())
-              : new LegacyChangeIdStrPredicate(cd.getId());
-      Iterable<ChangeData> results =
-          index.getSource(and(thisId, this), IndexedChangeQuery.oneResult()).read();
-      return !Iterables.isEmpty(results);
-    } catch (QueryParseException e) {
-      throw new StorageException(e);
-    }
-  }
-
-  @Override
-  public int getCost() {
-    return 1;
-  }
-}
diff --git a/java/com/google/gerrit/server/query/change/GroupPredicate.java b/java/com/google/gerrit/server/query/change/GroupPredicate.java
index 99f37d6..f470cf9 100644
--- a/java/com/google/gerrit/server/query/change/GroupPredicate.java
+++ b/java/com/google/gerrit/server/query/change/GroupPredicate.java
@@ -33,9 +33,4 @@
     }
     return false;
   }
-
-  @Override
-  public int getCost() {
-    return 1;
-  }
 }
diff --git a/java/com/google/gerrit/server/query/change/HasDraftByPredicate.java b/java/com/google/gerrit/server/query/change/HasDraftByPredicate.java
deleted file mode 100644
index 6d1576f..0000000
--- a/java/com/google/gerrit/server/query/change/HasDraftByPredicate.java
+++ /dev/null
@@ -1,37 +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.Account;
-import com.google.gerrit.server.index.change.ChangeField;
-
-public class HasDraftByPredicate extends ChangeIndexPredicate {
-  protected final Account.Id accountId;
-
-  public HasDraftByPredicate(Account.Id accountId) {
-    super(ChangeField.DRAFTBY, accountId.toString());
-    this.accountId = accountId;
-  }
-
-  @Override
-  public boolean match(ChangeData cd) {
-    return cd.draftsByUser().contains(accountId);
-  }
-
-  @Override
-  public int getCost() {
-    return 1;
-  }
-}
diff --git a/java/com/google/gerrit/server/query/change/HasStarsPredicate.java b/java/com/google/gerrit/server/query/change/HasStarsPredicate.java
index 2fbd1e8..6482a19 100644
--- a/java/com/google/gerrit/server/query/change/HasStarsPredicate.java
+++ b/java/com/google/gerrit/server/query/change/HasStarsPredicate.java
@@ -31,11 +31,6 @@
   }
 
   @Override
-  public int getCost() {
-    return 1;
-  }
-
-  @Override
   public String toString() {
     return ChangeQueryBuilder.FIELD_STARBY + ":" + accountId;
   }
diff --git a/java/com/google/gerrit/server/query/change/HashtagPredicate.java b/java/com/google/gerrit/server/query/change/HashtagPredicate.java
deleted file mode 100644
index 1fe4af4..0000000
--- a/java/com/google/gerrit/server/query/change/HashtagPredicate.java
+++ /dev/null
@@ -1,41 +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.server.change.HashtagsUtil;
-import com.google.gerrit.server.index.change.ChangeField;
-
-public class HashtagPredicate extends ChangeIndexPredicate {
-  public HashtagPredicate(String hashtag) {
-    // Use toLowerCase without locale to match behavior in ChangeField.
-    // TODO(dborowitz): Change both.
-    super(ChangeField.HASHTAG, HashtagsUtil.cleanupHashtag(hashtag).toLowerCase());
-  }
-
-  @Override
-  public boolean match(ChangeData object) {
-    for (String hashtag : object.notes().load().getHashtags()) {
-      if (hashtag.equalsIgnoreCase(getValue())) {
-        return true;
-      }
-    }
-    return false;
-  }
-
-  @Override
-  public int getCost() {
-    return 1;
-  }
-}
diff --git a/java/com/google/gerrit/server/query/change/InternalChangeQuery.java b/java/com/google/gerrit/server/query/change/InternalChangeQuery.java
index 6605c23..76ebd81 100644
--- a/java/com/google/gerrit/server/query/change/InternalChangeQuery.java
+++ b/java/com/google/gerrit/server/query/change/InternalChangeQuery.java
@@ -61,15 +61,15 @@
   }
 
   private static Predicate<ChangeData> ref(BranchNameKey branch) {
-    return new RefPredicate(branch.branch());
+    return ChangePredicates.ref(branch.branch());
   }
 
   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) {
-    return new ProjectPredicate(project.get());
+    return ChangePredicates.project(project);
   }
 
   private static Predicate<ChangeData> status(Change.Status status) {
@@ -77,7 +77,7 @@
   }
 
   private static Predicate<ChangeData> commit(String id) {
-    return new CommitPredicate(id);
+    return ChangePredicates.commitPrefix(id);
   }
 
   private final ChangeData.Factory changeDataFactory;
@@ -99,8 +99,8 @@
     predicateFactory =
         (id) ->
             schema().useLegacyNumericFields()
-                ? new LegacyChangeIdPredicate(id)
-                : new LegacyChangeIdStrPredicate(id);
+                ? ChangePredicates.id(id)
+                : ChangePredicates.idStr(id);
   }
 
   public List<ChangeData> byKey(Change.Key key) {
@@ -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) {
@@ -166,7 +166,7 @@
   Iterable<ChangeData> byCommitsOnBranchNotMerged(
       Repository repo, BranchNameKey branch, Collection<String> hashes, int indexLimit)
       throws IOException {
-    if (hashes.size() > indexLimit) {
+    if (hashes.size() > indexLimit || !indexes.getSearchIndex().isEnabled()) {
       return byCommitsOnBranchNotMergedFromDatabase(repo, branch, hashes);
     }
     return byCommitsOnBranchNotMergedFromIndex(branch, hashes);
@@ -225,7 +225,7 @@
   }
 
   public List<ChangeData> byTopicOpen(String topic) {
-    return query(and(new ExactTopicPredicate(topic), open()));
+    return query(and(ChangePredicates.exactTopic(topic), open()));
   }
 
   public List<ChangeData> byCommit(ObjectId id) {
@@ -269,14 +269,17 @@
 
   private static Predicate<ChangeData> byBranchCommitPred(
       String project, String branch, String hash) {
-    return and(new ProjectPredicate(project), new RefPredicate(branch), commit(hash));
+    return and(
+        ChangePredicates.project(Project.nameKey(project)),
+        ChangePredicates.ref(branch),
+        commit(hash));
   }
 
   public List<ChangeData> bySubmissionId(String cs) {
     if (Strings.isNullOrEmpty(cs)) {
       return Collections.emptyList();
     }
-    return query(new SubmissionIdPredicate(cs));
+    return query(ChangePredicates.submissionId(cs));
   }
 
   private static Predicate<ChangeData> byProjectGroupsPredicate(
diff --git a/java/com/google/gerrit/server/query/change/IsReviewedPredicate.java b/java/com/google/gerrit/server/query/change/IsReviewedPredicate.java
deleted file mode 100644
index 4b32b06..0000000
--- a/java/com/google/gerrit/server/query/change/IsReviewedPredicate.java
+++ /dev/null
@@ -1,59 +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.REVIEWEDBY;
-
-import com.google.gerrit.entities.Account;
-import com.google.gerrit.index.query.Predicate;
-import com.google.gerrit.server.index.change.ChangeField;
-import java.util.ArrayList;
-import java.util.Collection;
-import java.util.List;
-import java.util.Set;
-
-public class IsReviewedPredicate extends ChangeIndexPredicate {
-  protected static final Account.Id NOT_REVIEWED = Account.id(ChangeField.NOT_REVIEWED);
-
-  public static Predicate<ChangeData> create() {
-    return Predicate.not(new IsReviewedPredicate(NOT_REVIEWED));
-  }
-
-  public static Predicate<ChangeData> create(Collection<Account.Id> ids) {
-    List<Predicate<ChangeData>> predicates = new ArrayList<>(ids.size());
-    for (Account.Id id : ids) {
-      predicates.add(new IsReviewedPredicate(id));
-    }
-    return Predicate.or(predicates);
-  }
-
-  protected final Account.Id id;
-
-  private IsReviewedPredicate(Account.Id id) {
-    super(REVIEWEDBY, Integer.toString(id.get()));
-    this.id = id;
-  }
-
-  @Override
-  public boolean match(ChangeData cd) {
-    Set<Account.Id> reviewedBy = cd.reviewedBy();
-    return !reviewedBy.isEmpty() ? reviewedBy.contains(id) : id.equals(NOT_REVIEWED);
-  }
-
-  @Override
-  public int getCost() {
-    return 1;
-  }
-}
diff --git a/java/com/google/gerrit/server/query/change/LegacyChangeIdPredicate.java b/java/com/google/gerrit/server/query/change/LegacyChangeIdPredicate.java
deleted file mode 100644
index d531236..0000000
--- a/java/com/google/gerrit/server/query/change/LegacyChangeIdPredicate.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 static com.google.gerrit.server.index.change.ChangeField.LEGACY_ID;
-
-import com.google.gerrit.entities.Change;
-
-/** Predicate over change number (aka legacy ID or Change.Id). */
-public class LegacyChangeIdPredicate extends ChangeIndexPredicate {
-  protected final Change.Id id;
-
-  public LegacyChangeIdPredicate(Change.Id id) {
-    super(LEGACY_ID, ChangeQueryBuilder.FIELD_CHANGE, id.toString());
-    this.id = id;
-  }
-
-  @Override
-  public boolean match(ChangeData object) {
-    return id.equals(object.getId());
-  }
-
-  @Override
-  public int getCost() {
-    return 1;
-  }
-}
diff --git a/java/com/google/gerrit/server/query/change/LegacyChangeIdStrPredicate.java b/java/com/google/gerrit/server/query/change/LegacyChangeIdStrPredicate.java
deleted file mode 100644
index bae9c0d..0000000
--- a/java/com/google/gerrit/server/query/change/LegacyChangeIdStrPredicate.java
+++ /dev/null
@@ -1,39 +0,0 @@
-// Copyright (C) 2019 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.query.change;
-
-import static com.google.gerrit.server.index.change.ChangeField.LEGACY_ID_STR;
-
-import com.google.gerrit.entities.Change;
-
-/** Predicate over change number (aka legacy ID or Change.Id). */
-public class LegacyChangeIdStrPredicate extends ChangeIndexPredicate {
-  protected final Change.Id id;
-
-  public LegacyChangeIdStrPredicate(Change.Id id) {
-    super(LEGACY_ID_STR, ChangeQueryBuilder.FIELD_CHANGE, id.toString());
-    this.id = id;
-  }
-
-  @Override
-  public boolean match(ChangeData object) {
-    return id.equals(object.getId());
-  }
-
-  @Override
-  public int getCost() {
-    return 1;
-  }
-}
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 44cbd8e..0000000
--- a/java/com/google/gerrit/server/query/change/MessagePredicate.java
+++ /dev/null
@@ -1,58 +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()
-                  ? new LegacyChangeIdPredicate(object.getId())
-                  : new LegacyChangeIdStrPredicate(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;
-  }
-
-  @Override
-  public int getCost() {
-    return 1;
-  }
-}
diff --git a/java/com/google/gerrit/server/query/change/OwnerPredicate.java b/java/com/google/gerrit/server/query/change/OwnerPredicate.java
deleted file mode 100644
index 923a9ca..0000000
--- a/java/com/google/gerrit/server/query/change/OwnerPredicate.java
+++ /dev/null
@@ -1,43 +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.Account;
-import com.google.gerrit.entities.Change;
-import com.google.gerrit.server.index.change.ChangeField;
-
-public class OwnerPredicate extends ChangeIndexPredicate {
-  protected final Account.Id id;
-
-  public OwnerPredicate(Account.Id id) {
-    super(ChangeField.OWNER, id.toString());
-    this.id = id;
-  }
-
-  protected Account.Id getAccountId() {
-    return id;
-  }
-
-  @Override
-  public boolean match(ChangeData object) {
-    Change change = object.change();
-    return change != null && id.equals(change.getOwner());
-  }
-
-  @Override
-  public int getCost() {
-    return 1;
-  }
-}
diff --git a/java/com/google/gerrit/server/query/change/ParentProjectPredicate.java b/java/com/google/gerrit/server/query/change/ParentProjectPredicate.java
index 2c82075..4a54c03 100644
--- a/java/com/google/gerrit/server/query/change/ParentProjectPredicate.java
+++ b/java/com/google/gerrit/server/query/change/ParentProjectPredicate.java
@@ -47,10 +47,10 @@
     }
 
     List<Predicate<ChangeData>> r = new ArrayList<>();
-    r.add(new ProjectPredicate(projectState.get().getName()));
+    r.add(ChangePredicates.project(projectState.get().getNameKey()));
     try {
       for (ProjectInfo p : childProjects.list(projectState.get().getNameKey())) {
-        r.add(new ProjectPredicate(p.name));
+        r.add(ChangePredicates.project(Project.nameKey(p.name)));
       }
     } catch (PermissionBackendException e) {
       logger.atWarning().withCause(e).log("cannot check permissions to expand child projects");
diff --git a/java/com/google/gerrit/server/query/change/ProjectPredicate.java b/java/com/google/gerrit/server/query/change/ProjectPredicate.java
deleted file mode 100644
index db5a932..0000000
--- a/java/com/google/gerrit/server/query/change/ProjectPredicate.java
+++ /dev/null
@@ -1,45 +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.entities.Project;
-import com.google.gerrit.server.index.change.ChangeField;
-
-public class ProjectPredicate extends ChangeIndexPredicate {
-  public ProjectPredicate(String id) {
-    super(ChangeField.PROJECT, id);
-  }
-
-  protected Project.NameKey getValueKey() {
-    return Project.nameKey(getValue());
-  }
-
-  @Override
-  public boolean match(ChangeData object) {
-    Change change = object.change();
-    if (change == null) {
-      return false;
-    }
-
-    Project.NameKey p = change.getDest().project();
-    return p.equals(getValueKey());
-  }
-
-  @Override
-  public int getCost() {
-    return 1;
-  }
-}
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 c23a175..0000000
--- a/java/com/google/gerrit/server/query/change/ProjectPrefixPredicate.java
+++ /dev/null
@@ -1,35 +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());
-  }
-
-  @Override
-  public int getCost() {
-    return 1;
-  }
-}
diff --git a/java/com/google/gerrit/server/query/change/RefPredicate.java b/java/com/google/gerrit/server/query/change/RefPredicate.java
deleted file mode 100644
index 1c70a62..0000000
--- a/java/com/google/gerrit/server/query/change/RefPredicate.java
+++ /dev/null
@@ -1,38 +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;
-
-public class RefPredicate extends ChangeIndexPredicate {
-  public RefPredicate(String ref) {
-    super(ChangeField.REF, ref);
-  }
-
-  @Override
-  public boolean match(ChangeData object) {
-    Change change = object.change();
-    if (change == null) {
-      return false;
-    }
-    return getValue().equals(change.getDest().branch());
-  }
-
-  @Override
-  public int getCost() {
-    return 1;
-  }
-}
diff --git a/java/com/google/gerrit/server/query/change/RegexHashtagPredicate.java b/java/com/google/gerrit/server/query/change/RegexHashtagPredicate.java
new file mode 100644
index 0000000..24efa6a
--- /dev/null
+++ b/java/com/google/gerrit/server/query/change/RegexHashtagPredicate.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.server.query.change;
+
+import static com.google.gerrit.server.index.change.ChangeField.HASHTAG;
+
+import dk.brics.automaton.RegExp;
+import dk.brics.automaton.RunAutomaton;
+
+public class RegexHashtagPredicate extends ChangeRegexPredicate {
+  protected final RunAutomaton pattern;
+
+  public RegexHashtagPredicate(String re) {
+    super(HASHTAG, re);
+
+    if (re.startsWith("^")) {
+      re = re.substring(1);
+    }
+
+    if (re.endsWith("$") && !re.endsWith("\\$")) {
+      re = re.substring(0, re.length() - 1);
+    }
+
+    this.pattern = new RunAutomaton(new RegExp(re).toAutomaton());
+  }
+
+  @Override
+  public boolean match(ChangeData cd) {
+    if (cd.hashtags().isEmpty()) {
+      return false;
+    }
+    return cd.hashtags().stream().anyMatch(ht -> pattern.run(ht));
+  }
+
+  @Override
+  public int getCost() {
+    return 1;
+  }
+}
diff --git a/java/com/google/gerrit/server/query/change/RevertOfPredicate.java b/java/com/google/gerrit/server/query/change/RevertOfPredicate.java
deleted file mode 100644
index eea1b1e..0000000
--- a/java/com/google/gerrit/server/query/change/RevertOfPredicate.java
+++ /dev/null
@@ -1,36 +0,0 @@
-// 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.
-
-package com.google.gerrit.server.query.change;
-
-import com.google.gerrit.server.index.change.ChangeField;
-
-public class RevertOfPredicate extends ChangeIndexPredicate {
-  public RevertOfPredicate(String revertOf) {
-    super(ChangeField.REVERT_OF, revertOf);
-  }
-
-  @Override
-  public boolean match(ChangeData cd) {
-    if (cd.change().getRevertOf() == null) {
-      return false;
-    }
-    return cd.change().getRevertOf().toString().equals(value);
-  }
-
-  @Override
-  public int getCost() {
-    return 1;
-  }
-}
diff --git a/java/com/google/gerrit/server/query/change/ReviewerByEmailPredicate.java b/java/com/google/gerrit/server/query/change/ReviewerByEmailPredicate.java
index 62fe9e8..3a51972 100644
--- a/java/com/google/gerrit/server/query/change/ReviewerByEmailPredicate.java
+++ b/java/com/google/gerrit/server/query/change/ReviewerByEmailPredicate.java
@@ -45,9 +45,4 @@
   public boolean match(ChangeData cd) {
     return cd.reviewersByEmail().asTable().get(state, adr) != null;
   }
-
-  @Override
-  public int getCost() {
-    return 1;
-  }
 }
diff --git a/java/com/google/gerrit/server/query/change/ReviewerPredicate.java b/java/com/google/gerrit/server/query/change/ReviewerPredicate.java
index d783f76..142e956 100644
--- a/java/com/google/gerrit/server/query/change/ReviewerPredicate.java
+++ b/java/com/google/gerrit/server/query/change/ReviewerPredicate.java
@@ -52,9 +52,4 @@
   public boolean match(ChangeData cd) {
     return cd.reviewers().asTable().get(state, id) != null;
   }
-
-  @Override
-  public int getCost() {
-    return 1;
-  }
 }
diff --git a/java/com/google/gerrit/server/query/change/StarPredicate.java b/java/com/google/gerrit/server/query/change/StarPredicate.java
index 788f1a3..4a8fe43 100644
--- a/java/com/google/gerrit/server/query/change/StarPredicate.java
+++ b/java/com/google/gerrit/server/query/change/StarPredicate.java
@@ -34,11 +34,6 @@
   }
 
   @Override
-  public int getCost() {
-    return 1;
-  }
-
-  @Override
   public String toString() {
     return ChangeQueryBuilder.FIELD_STAR + ":" + label;
   }
diff --git a/java/com/google/gerrit/server/query/change/SubmissionIdPredicate.java b/java/com/google/gerrit/server/query/change/SubmissionIdPredicate.java
deleted file mode 100644
index 093447e..0000000
--- a/java/com/google/gerrit/server/query/change/SubmissionIdPredicate.java
+++ /dev/null
@@ -1,41 +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 com.google.gerrit.entities.Change;
-import com.google.gerrit.server.index.change.ChangeField;
-
-public class SubmissionIdPredicate extends ChangeIndexPredicate {
-  public SubmissionIdPredicate(String changeSet) {
-    super(ChangeField.SUBMISSIONID, changeSet);
-  }
-
-  @Override
-  public boolean match(ChangeData object) {
-    Change change = object.change();
-    if (change == null) {
-      return false;
-    }
-    if (change.getSubmissionId() == null) {
-      return false;
-    }
-    return getValue().equals(change.getSubmissionId());
-  }
-
-  @Override
-  public int getCost() {
-    return 1;
-  }
-}
diff --git a/java/com/google/gerrit/server/query/change/SubmitRecordPredicate.java b/java/com/google/gerrit/server/query/change/SubmitRecordPredicate.java
index 4ca684a..ecddbb6 100644
--- a/java/com/google/gerrit/server/query/change/SubmitRecordPredicate.java
+++ b/java/com/google/gerrit/server/query/change/SubmitRecordPredicate.java
@@ -43,9 +43,4 @@
   public boolean match(ChangeData in) {
     return ChangeField.formatSubmitRecordValues(in).contains(getValue());
   }
-
-  @Override
-  public int getCost() {
-    return 1;
-  }
 }
diff --git a/java/com/google/gerrit/server/query/change/SubmittablePredicate.java b/java/com/google/gerrit/server/query/change/SubmittablePredicate.java
index 2018fbc..060a92e 100644
--- a/java/com/google/gerrit/server/query/change/SubmittablePredicate.java
+++ b/java/com/google/gerrit/server/query/change/SubmittablePredicate.java
@@ -30,9 +30,4 @@
     return cd.submitRecords(ChangeField.SUBMIT_RULE_OPTIONS_STRICT).stream()
         .anyMatch(r -> r.status == status);
   }
-
-  @Override
-  public int getCost() {
-    return 1;
-  }
 }
diff --git a/java/com/google/gerrit/server/query/change/TrackingIdPredicate.java b/java/com/google/gerrit/server/query/change/TrackingIdPredicate.java
deleted file mode 100644
index 622fa2c..0000000
--- a/java/com/google/gerrit/server/query/change/TrackingIdPredicate.java
+++ /dev/null
@@ -1,33 +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.server.index.change.ChangeField;
-
-public class TrackingIdPredicate extends ChangeIndexPredicate {
-  public TrackingIdPredicate(String trackingId) {
-    super(ChangeField.TR, trackingId);
-  }
-
-  @Override
-  public boolean match(ChangeData cd) {
-    return cd.trackingFooters().containsValue(getValue());
-  }
-
-  @Override
-  public int getCost() {
-    return 1;
-  }
-}
diff --git a/java/com/google/gerrit/server/query/group/GroupPredicates.java b/java/com/google/gerrit/server/query/group/GroupPredicates.java
index 992f60d..8a2dc8d 100644
--- a/java/com/google/gerrit/server/query/group/GroupPredicates.java
+++ b/java/com/google/gerrit/server/query/group/GroupPredicates.java
@@ -44,7 +44,7 @@
   }
 
   public static Predicate<InternalGroup> name(String name) {
-    return new GroupPredicate(GroupField.NAME, GroupQueryBuilder.FIELD_NAME, name);
+    return new GroupPredicate(GroupField.NAME, name);
   }
 
   public static Predicate<InternalGroup> owner(AccountGroup.UUID ownerUuid) {
diff --git a/java/com/google/gerrit/server/restapi/access/ListAccess.java b/java/com/google/gerrit/server/restapi/access/ListAccess.java
index 1e1bade..c3b4ff2 100644
--- a/java/com/google/gerrit/server/restapi/access/ListAccess.java
+++ b/java/com/google/gerrit/server/restapi/access/ListAccess.java
@@ -14,11 +14,18 @@
 
 package com.google.gerrit.server.restapi.access;
 
+import com.google.common.base.Strings;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.access.ProjectAccessInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.ProjectPermission;
+import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.restapi.project.GetAccess;
 import com.google.inject.Inject;
 import java.util.ArrayList;
@@ -41,10 +48,15 @@
       usage = "projects for which the access rights should be returned")
   private List<String> projects = new ArrayList<>();
 
+  private final PermissionBackend permissionBackend;
+  private final ProjectCache projectCache;
   private final GetAccess getAccess;
 
   @Inject
-  public ListAccess(GetAccess getAccess) {
+  public ListAccess(
+      PermissionBackend permissionBackend, ProjectCache projectCache, GetAccess getAccess) {
+    this.permissionBackend = permissionBackend;
+    this.projectCache = projectCache;
     this.getAccess = getAccess;
   }
 
@@ -53,7 +65,23 @@
       throws Exception {
     Map<String, ProjectAccessInfo> access = new TreeMap<>();
     for (String p : projects) {
-      access.put(p, getAccess.apply(Project.nameKey(p)));
+      if (Strings.nullToEmpty(p).isEmpty()) {
+        continue;
+      }
+
+      Project.NameKey projectName = Project.nameKey(IdString.fromUrl(p).get().trim());
+
+      if (!projectCache.get(projectName).isPresent()) {
+        throw new ResourceNotFoundException(projectName.get());
+      }
+
+      try {
+        permissionBackend.currentUser().project(projectName).check(ProjectPermission.ACCESS);
+      } catch (AuthException e) {
+        throw new ResourceNotFoundException(projectName.get(), e);
+      }
+
+      access.put(projectName.get(), getAccess.apply(projectName));
     }
     return Response.ok(access);
   }
diff --git a/java/com/google/gerrit/server/restapi/account/CreateAccount.java b/java/com/google/gerrit/server/restapi/account/CreateAccount.java
index b65f4ee..baa2951 100644
--- a/java/com/google/gerrit/server/restapi/account/CreateAccount.java
+++ b/java/com/google/gerrit/server/restapi/account/CreateAccount.java
@@ -46,8 +46,8 @@
 import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.config.AuthConfig;
 import com.google.gerrit.server.group.GroupResolver;
+import com.google.gerrit.server.group.db.GroupDelta;
 import com.google.gerrit.server.group.db.GroupsUpdate;
-import com.google.gerrit.server.group.db.InternalGroupUpdate;
 import com.google.gerrit.server.mail.send.OutgoingEmailValidator;
 import com.google.gerrit.server.notedb.Sequences;
 import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -209,10 +209,10 @@
 
   private void addGroupMember(AccountGroup.UUID groupUuid, Account.Id accountId)
       throws IOException, NoSuchGroupException, ConfigInvalidException {
-    InternalGroupUpdate groupUpdate =
-        InternalGroupUpdate.builder()
+    GroupDelta groupDelta =
+        GroupDelta.builder()
             .setMemberModification(memberIds -> Sets.union(memberIds, ImmutableSet.of(accountId)))
             .build();
-    groupsUpdate.get().updateGroup(groupUuid, groupUpdate);
+    groupsUpdate.get().updateGroup(groupUuid, groupDelta);
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/account/DeleteDraftComments.java b/java/com/google/gerrit/server/restapi/account/DeleteDraftComments.java
index ec82e1a..d225798 100644
--- a/java/com/google/gerrit/server/restapi/account/DeleteDraftComments.java
+++ b/java/com/google/gerrit/server/restapi/account/DeleteDraftComments.java
@@ -41,8 +41,8 @@
 import com.google.gerrit.server.change.ChangeJson;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.ChangePredicates;
 import com.google.gerrit.server.query.change.ChangeQueryBuilder;
-import com.google.gerrit.server.query.change.HasDraftByPredicate;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
 import com.google.gerrit.server.restapi.change.CommentJson;
 import com.google.gerrit.server.restapi.change.CommentJson.HumanCommentFormatter;
@@ -147,7 +147,7 @@
 
   private Predicate<ChangeData> predicate(Account.Id accountId, DeleteDraftCommentsInput input)
       throws BadRequestException {
-    Predicate<ChangeData> hasDraft = new HasDraftByPredicate(accountId);
+    Predicate<ChangeData> hasDraft = ChangePredicates.draftBy(accountId);
     if (CharMatcher.whitespace().trimFrom(Strings.nullToEmpty(input.query)).isEmpty()) {
       return hasDraft;
     }
diff --git a/java/com/google/gerrit/server/restapi/account/QueryAccounts.java b/java/com/google/gerrit/server/restapi/account/QueryAccounts.java
index 0d12fd4..e6b4eee 100644
--- a/java/com/google/gerrit/server/restapi/account/QueryAccounts.java
+++ b/java/com/google/gerrit/server/restapi/account/QueryAccounts.java
@@ -107,8 +107,8 @@
   }
 
   @Option(name = "-O", usage = "Output option flags, in hex")
-  void setOptionFlagsHex(String hex) {
-    options.addAll(ListOption.fromBits(ListAccountsOption.class, Integer.parseInt(hex, 16)));
+  void setOptionFlagsHex(String hex) throws BadRequestException {
+    options.addAll(ListOption.fromHexString(ListAccountsOption.class, hex));
   }
 
   @Option(
diff --git a/java/com/google/gerrit/server/restapi/change/Abandon.java b/java/com/google/gerrit/server/restapi/change/Abandon.java
index b207390..826c89d 100644
--- a/java/com/google/gerrit/server/restapi/change/Abandon.java
+++ b/java/com/google/gerrit/server/restapi/change/Abandon.java
@@ -16,7 +16,6 @@
 
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Change;
-import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.AbandonInput;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.common.ChangeInfo;
@@ -129,7 +128,7 @@
   }
 
   @Override
-  public UiAction.Description getDescription(ChangeResource rsrc) {
+  public UiAction.Description getDescription(ChangeResource rsrc) throws IOException {
     UiAction.Description description =
         new UiAction.Description()
             .setLabel("Abandon")
@@ -140,17 +139,9 @@
     if (!change.isNew()) {
       return description;
     }
-
-    try {
-      if (patchSetUtil.isPatchSetLocked(rsrc.getNotes())) {
-        return description;
-      }
-    } catch (StorageException e) {
-      logger.atSevere().withCause(e).log(
-          "Failed to check if the current patch set of change %s is locked", change.getId());
+    if (patchSetUtil.isPatchSetLocked(rsrc.getNotes())) {
       return description;
     }
-
     return description.setVisible(rsrc.permissions().testOrFalse(ChangePermission.ABANDON));
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/CherryPickChange.java b/java/com/google/gerrit/server/restapi/change/CherryPickChange.java
index ee6484c..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;
@@ -328,6 +328,7 @@
                 input.parent - 1,
                 input.allowEmpty,
                 input.allowConflicts);
+        oi.flush();
       } catch (MergeIdenticalTreeException | MergeConflictException e) {
         throw new IntegrationConflictException("Cherry pick failed: " + e.getMessage(), e);
       }
@@ -448,6 +449,9 @@
     if (workInProgress != null) {
       inserter.setWorkInProgress(workInProgress);
     }
+    if (shouldSetToReady(cherryPickCommit, destNotes, workInProgress)) {
+      inserter.setWorkInProgress(false);
+    }
     bu.addOp(destChange.getId(), inserter);
     PatchSet.Id sourcePatchSetId = sourceChange == null ? null : sourceChange.currentPatchSetId();
     // If sourceChange is not provided, reset cherryPickOf to avoid stale value.
@@ -461,6 +465,20 @@
     return destChange.getId();
   }
 
+  /**
+   * We should set the change to be "ready for review" if: 1. workInProgress is not already set on
+   * this request. 2. The patch-set doesn't have any git conflict markers. 3. The change used to be
+   * work in progress (because of a previous patch-set).
+   */
+  private boolean shouldSetToReady(
+      CodeReviewCommit cherryPickCommit,
+      ChangeNotes destChangeNotes,
+      @Nullable Boolean workInProgress) {
+    return workInProgress == null
+        && cherryPickCommit.getFilesWithGitConflicts().isEmpty()
+        && destChangeNotes.getChange().isWorkInProgress();
+  }
+
   private Change.Id createNewChange(
       BatchUpdate bu,
       CodeReviewCommit cherryPickCommit,
diff --git a/java/com/google/gerrit/server/restapi/change/CommentJson.java b/java/com/google/gerrit/server/restapi/change/CommentJson.java
index edc8fcf..81b6fb3 100644
--- a/java/com/google/gerrit/server/restapi/change/CommentJson.java
+++ b/java/com/google/gerrit/server/restapi/change/CommentJson.java
@@ -190,7 +190,7 @@
       return CommentContextKey.builder()
           .project(project)
           .changeId(changeId)
-          .id(r.id)
+          .id(Url.decode(r.id)) // We reverse the encoding done while filling comment info
           .path(r.path)
           .patchset(r.patchSet)
           .contextPadding(contextPadding)
diff --git a/java/com/google/gerrit/server/restapi/change/CommentPorter.java b/java/com/google/gerrit/server/restapi/change/CommentPorter.java
index 34af285..d1d4544 100644
--- a/java/com/google/gerrit/server/restapi/change/CommentPorter.java
+++ b/java/com/google/gerrit/server/restapi/change/CommentPorter.java
@@ -34,6 +34,9 @@
 import com.google.gerrit.metrics.Description;
 import com.google.gerrit.metrics.MetricMaker;
 import com.google.gerrit.server.CommentsUtil;
+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.DiffMappings;
 import com.google.gerrit.server.patch.GitPositionTransformer;
@@ -142,10 +145,13 @@
       PatchSet targetPatchset,
       List<HumanComment> comments,
       List<HumanCommentFilter> filters) {
-
-    ImmutableList<HumanCommentFilter> allFilters = addDefaultFilters(filters, targetPatchset);
-    ImmutableList<HumanComment> relevantComments = filter(comments, allFilters);
-    return port(changeNotes, targetPatchset, relevantComments);
+    try (TraceTimer ignored =
+        TraceContext.newTimer(
+            "Porting comments", Metadata.builder().patchSetId(targetPatchset.number()).build())) {
+      ImmutableList<HumanCommentFilter> allFilters = addDefaultFilters(filters, targetPatchset);
+      ImmutableList<HumanComment> relevantComments = filter(comments, allFilters);
+      return port(changeNotes, targetPatchset, relevantComments);
+    }
   }
 
   private ImmutableList<HumanCommentFilter> addDefaultFilters(
@@ -203,20 +209,29 @@
       PatchSet originalPatchset,
       PatchSet targetPatchset,
       ImmutableList<HumanComment> comments) {
-    Map<Short, List<HumanComment>> commentsPerSide =
-        comments.stream().collect(groupingBy(comment -> comment.side));
-    ImmutableList.Builder<HumanComment> portedComments = ImmutableList.builder();
-    for (Entry<Short, List<HumanComment>> sideAndComments : commentsPerSide.entrySet()) {
-      portedComments.addAll(
-          portSamePatchsetAndSide(
-              project,
-              change,
-              originalPatchset,
-              targetPatchset,
-              sideAndComments.getValue(),
-              sideAndComments.getKey()));
+    try (TraceTimer ignored =
+        TraceContext.newTimer(
+            "Porting comments same patchset",
+            Metadata.builder()
+                .projectName(project.get())
+                .changeId(change.getChangeId())
+                .patchSetId(originalPatchset.number())
+                .build())) {
+      Map<Short, List<HumanComment>> commentsPerSide =
+          comments.stream().collect(groupingBy(comment -> comment.side));
+      ImmutableList.Builder<HumanComment> portedComments = ImmutableList.builder();
+      for (Entry<Short, List<HumanComment>> sideAndComments : commentsPerSide.entrySet()) {
+        portedComments.addAll(
+            portSamePatchsetAndSide(
+                project,
+                change,
+                originalPatchset,
+                targetPatchset,
+                sideAndComments.getValue(),
+                sideAndComments.getKey()));
+      }
+      return portedComments.build();
     }
-    return portedComments.build();
   }
 
   private ImmutableList<HumanComment> portSamePatchsetAndSide(
@@ -226,30 +241,40 @@
       PatchSet targetPatchset,
       List<HumanComment> comments,
       short side) {
-    ImmutableSet<Mapping> mappings;
-    try {
-      mappings = loadMappings(project, change, originalPatchset, targetPatchset, side);
-    } catch (Exception e) {
-      logger.atWarning().withCause(e).log(
-          "Could not determine some necessary diff mappings for porting comments on change %s from"
-              + " patchset %s to patchset %s. Mapping %d affected comments to the fallback"
-              + " destination.",
-          change.getChangeId(),
-          originalPatchset.id().getId(),
-          targetPatchset.id().getId(),
-          comments.size());
-      mappings = getFallbackMappings(comments);
-    }
+    try (TraceTimer ignored =
+        TraceContext.newTimer(
+            "Porting comments same patchset and side",
+            Metadata.builder()
+                .projectName(project.get())
+                .changeId(change.getChangeId())
+                .patchSetId(originalPatchset.number())
+                .commentSide(side)
+                .build())) {
+      ImmutableSet<Mapping> mappings;
+      try {
+        mappings = loadMappings(project, change, originalPatchset, targetPatchset, side);
+      } catch (Exception e) {
+        logger.atWarning().withCause(e).log(
+            "Could not determine some necessary diff mappings for porting comments on change %s from"
+                + " patchset %s to patchset %s. Mapping %d affected comments to the fallback"
+                + " destination.",
+            change.getChangeId(),
+            originalPatchset.id().getId(),
+            targetPatchset.id().getId(),
+            comments.size());
+        mappings = getFallbackMappings(comments);
+      }
 
-    ImmutableList<PositionedEntity<HumanComment>> positionedComments =
-        comments.stream().map(this::toPositionedEntity).collect(toImmutableList());
-    ImmutableMap<PositionedEntity<HumanComment>, HumanComment> origToPortedMap =
-        positionTransformer.transform(positionedComments, mappings).stream()
-            .collect(
-                ImmutableMap.toImmutableMap(
-                    Function.identity(), PositionedEntity::getEntityAtUpdatedPosition));
-    collectMetrics(origToPortedMap);
-    return ImmutableList.copyOf(origToPortedMap.values());
+      ImmutableList<PositionedEntity<HumanComment>> positionedComments =
+          comments.stream().map(this::toPositionedEntity).collect(toImmutableList());
+      ImmutableMap<PositionedEntity<HumanComment>, HumanComment> origToPortedMap =
+          positionTransformer.transform(positionedComments, mappings).stream()
+              .collect(
+                  ImmutableMap.toImmutableMap(
+                      Function.identity(), PositionedEntity::getEntityAtUpdatedPosition));
+      collectMetrics(origToPortedMap);
+      return ImmutableList.copyOf(origToPortedMap.values());
+    }
   }
 
   private ImmutableSet<Mapping> loadMappings(
@@ -259,9 +284,18 @@
       PatchSet targetPatchset,
       short side)
       throws PatchListNotAvailableException {
-    ObjectId originalCommit = determineCommitId(change, originalPatchset, side);
-    ObjectId targetCommit = determineCommitId(change, targetPatchset, side);
-    return loadCommitMappings(project, originalCommit, targetCommit);
+    try (TraceTimer ignored =
+        TraceContext.newTimer(
+            "Loading commit mappings",
+            Metadata.builder()
+                .projectName(project.get())
+                .changeId(change.getChangeId())
+                .patchSetId(originalPatchset.number())
+                .build())) {
+      ObjectId originalCommit = determineCommitId(change, originalPatchset, side);
+      ObjectId targetCommit = determineCommitId(change, targetPatchset, side);
+      return loadCommitMappings(project, originalCommit, targetCommit);
+    }
   }
 
   private ObjectId determineCommitId(Change change, PatchSet patchset, short side) {
@@ -278,11 +312,15 @@
   private ImmutableSet<Mapping> loadCommitMappings(
       Project.NameKey project, ObjectId originalCommit, ObjectId targetCommit)
       throws PatchListNotAvailableException {
-    PatchList patchList =
-        patchListCache.get(
-            PatchListKey.againstCommit(originalCommit, targetCommit, Whitespace.IGNORE_NONE),
-            project);
-    return patchList.getPatches().stream().map(DiffMappings::toMapping).collect(toImmutableSet());
+    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());
+    }
   }
 
   private ImmutableSet<Mapping> getFallbackMappings(List<HumanComment> comments) {
diff --git a/java/com/google/gerrit/server/restapi/change/CreateChange.java b/java/com/google/gerrit/server/restapi/change/CreateChange.java
index c392bd1..f48c7b8 100644
--- a/java/com/google/gerrit/server/restapi/change/CreateChange.java
+++ b/java/com/google/gerrit/server/restapi/change/CreateChange.java
@@ -377,6 +377,8 @@
         // create an empty commit
         c = newCommit(oi, rw, author, committer, mergeTip, commitMessage);
       }
+      // Flush inserter so that commit becomes visible to validators
+      oi.flush();
 
       Change.Id changeId = Change.id(seq.nextChangeId());
       ChangeInserter ins = changeInserterFactory.create(changeId, c, input.branch);
diff --git a/java/com/google/gerrit/server/restapi/change/CreateMergePatchSet.java b/java/com/google/gerrit/server/restapi/change/CreateMergePatchSet.java
index af4bf69..e943e47 100644
--- a/java/com/google/gerrit/server/restapi/change/CreateMergePatchSet.java
+++ b/java/com/google/gerrit/server/restapi/change/CreateMergePatchSet.java
@@ -194,6 +194,7 @@
               sourceCommit,
               author,
               ObjectId.fromString(change.getKey().get().substring(1)));
+      oi.flush();
 
       PatchSet.Id nextPsId = ChangeUtil.nextPatchSetId(ps.id());
       PatchSetInserter psInserter =
diff --git a/java/com/google/gerrit/server/restapi/change/DeleteAssignee.java b/java/com/google/gerrit/server/restapi/change/DeleteAssignee.java
index 842ed2a..c602214 100644
--- a/java/com/google/gerrit/server/restapi/change/DeleteAssignee.java
+++ b/java/com/google/gerrit/server/restapi/change/DeleteAssignee.java
@@ -16,7 +16,6 @@
 
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.Input;
 import com.google.gerrit.extensions.restapi.Response;
@@ -34,7 +33,7 @@
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
-import com.google.gerrit.server.update.Context;
+import com.google.gerrit.server.update.PostUpdateContext;
 import com.google.gerrit.server.update.UpdateException;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
@@ -94,7 +93,7 @@
       IdentifiedUser deletedAssigneeUser = userFactory.create(currentAssigneeId);
       deletedAssignee = deletedAssigneeUser.state();
       update.removeAssignee();
-      addMessage(ctx, update, deletedAssigneeUser);
+      addMessage(ctx, deletedAssigneeUser);
       return true;
     }
 
@@ -102,19 +101,18 @@
       return deletedAssignee != null ? deletedAssignee.account().id() : null;
     }
 
-    private void addMessage(
-        ChangeContext ctx, ChangeUpdate update, IdentifiedUser deletedAssignee) {
-      ChangeMessage cmsg =
-          ChangeMessagesUtil.newMessage(
-              ctx,
-              "Assignee deleted: " + deletedAssignee.getNameEmail(),
-              ChangeMessagesUtil.TAG_DELETE_ASSIGNEE);
-      cmUtil.addChangeMessage(update, cmsg);
+    private void addMessage(ChangeContext ctx, IdentifiedUser deletedAssignee) {
+      cmUtil.setChangeMessage(
+          ctx,
+          "Assignee deleted: "
+              + ChangeMessagesUtil.getAccountTemplate(deletedAssignee.getAccountId()),
+          ChangeMessagesUtil.TAG_DELETE_ASSIGNEE);
     }
 
     @Override
-    public void postUpdate(Context ctx) {
-      assigneeChanged.fire(change, ctx.getAccount(), deletedAssignee, ctx.getWhen());
+    public void postUpdate(PostUpdateContext ctx) {
+      assigneeChanged.fire(
+          ctx.getChangeData(change), ctx.getAccount(), deletedAssignee, ctx.getWhen());
     }
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/DeleteChangeMessage.java b/java/com/google/gerrit/server/restapi/change/DeleteChangeMessage.java
index 5b44957..0f280db 100644
--- a/java/com/google/gerrit/server/restapi/change/DeleteChangeMessage.java
+++ b/java/com/google/gerrit/server/restapi/change/DeleteChangeMessage.java
@@ -14,12 +14,11 @@
 
 package com.google.gerrit.server.restapi.change;
 
-import static com.google.gerrit.server.ChangeMessagesUtil.createChangeMessageInfo;
 import static java.util.Objects.requireNonNull;
 
-import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Strings;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.entities.PatchSet;
@@ -85,7 +84,7 @@
     permissionBackend.user(user).check(GlobalPermission.ADMINISTRATE_SERVER);
 
     String newChangeMessage =
-        createNewChangeMessage(user.asIdentifiedUser().getName(), input.reason);
+        createNewChangeMessage(user.asIdentifiedUser().getAccountId(), input.reason);
     DeleteChangeMessageOp deleteChangeMessageOp =
         new DeleteChangeMessageOp(resource.getChangeMessageId(), newChangeMessage);
     try (BatchUpdate batchUpdate =
@@ -107,26 +106,29 @@
         changeMessagesUtil.byChange(notesFactory.createChecked(project, cId));
     ChangeMessage updatedChangeMessage = messages.get(targetIdx);
     AccountLoader accountLoader = accountLoaderFactory.create(true);
-    ChangeMessageInfo info = createChangeMessageInfo(updatedChangeMessage, accountLoader);
+    ChangeMessageInfo info =
+        changeMessagesUtil.createChangeMessageInfoWithReplacedTemplates(
+            updatedChangeMessage, accountLoader);
     accountLoader.fill();
     return info;
   }
 
-  @VisibleForTesting
-  public static String createNewChangeMessage(String deletedBy, @Nullable String deletedReason) {
-    requireNonNull(deletedBy, "user name must not be null");
+  public static String createNewChangeMessage(
+      Account.Id deletedBy, @Nullable String deletedReason) {
+    requireNonNull(deletedBy, "user must not be null");
 
     if (Strings.isNullOrEmpty(deletedReason)) {
       return createNewChangeMessage(deletedBy);
     }
-    return String.format("Change message removed by: %s\nReason: %s", deletedBy, deletedReason);
+    return String.format(
+        "Change message removed by: %s\nReason: %s",
+        ChangeMessagesUtil.getAccountTemplate(deletedBy), deletedReason);
   }
 
-  @VisibleForTesting
-  public static String createNewChangeMessage(String deletedBy) {
-    requireNonNull(deletedBy, "user name must not be null");
+  public static String createNewChangeMessage(Account.Id deletedBy) {
+    requireNonNull(deletedBy, "user must not be null");
 
-    return "Change message removed by: " + deletedBy;
+    return "Change message removed by: " + ChangeMessagesUtil.getAccountTemplate(deletedBy);
   }
 
   private class DeleteChangeMessageOp implements BatchUpdateOp {
diff --git a/java/com/google/gerrit/server/restapi/change/DeleteReviewer.java b/java/com/google/gerrit/server/restapi/change/DeleteReviewer.java
index 3e4a483..db8e9de 100644
--- a/java/com/google/gerrit/server/restapi/change/DeleteReviewer.java
+++ b/java/com/google/gerrit/server/restapi/change/DeleteReviewer.java
@@ -64,7 +64,7 @@
       if (rsrc.isByEmail()) {
         op = deleteReviewerByEmailOpFactory.create(rsrc.getReviewerByEmail());
       } else {
-        op = deleteReviewerOpFactory.create(rsrc.getReviewerUser().state(), input);
+        op = deleteReviewerOpFactory.create(rsrc.getReviewerUser().getAccount(), input);
       }
       bu.addOp(rsrc.getChange().getId(), op);
       bu.execute();
diff --git a/java/com/google/gerrit/server/restapi/change/DeleteVote.java b/java/com/google/gerrit/server/restapi/change/DeleteVote.java
index 4b813df..35cadb7 100644
--- a/java/com/google/gerrit/server/restapi/change/DeleteVote.java
+++ b/java/com/google/gerrit/server/restapi/change/DeleteVote.java
@@ -21,7 +21,6 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.entities.LabelTypes;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetApproval;
@@ -34,11 +33,13 @@
 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;
 import com.google.gerrit.server.change.VoteResource;
@@ -53,11 +54,12 @@
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
-import com.google.gerrit.server.update.Context;
+import com.google.gerrit.server.update.PostUpdateContext;
 import com.google.gerrit.server.update.UpdateException;
 import com.google.gerrit.server.util.LabelVote;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
+import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
 import java.util.HashMap;
@@ -72,13 +74,14 @@
   private final ApprovalsUtil approvalsUtil;
   private final PatchSetUtil psUtil;
   private final ChangeMessagesUtil cmUtil;
-  private final IdentifiedUser.GenericFactory userFactory;
   private final VoteDeleted voteDeleted;
   private final DeleteVoteSender.Factory deleteVoteSenderFactory;
   private final NotifyResolver notifyResolver;
   private final RemoveReviewerControl removeReviewerControl;
   private final ProjectCache projectCache;
   private final MessageIdGenerator messageIdGenerator;
+  private final AddToAttentionSetOp.Factory attentionSetOpfactory;
+  private final Provider<CurrentUser> currentUserProvider;
 
   @Inject
   DeleteVote(
@@ -92,18 +95,21 @@
       NotifyResolver notifyResolver,
       RemoveReviewerControl removeReviewerControl,
       ProjectCache projectCache,
-      MessageIdGenerator messageIdGenerator) {
+      MessageIdGenerator messageIdGenerator,
+      AddToAttentionSetOp.Factory attentionSetOpFactory,
+      Provider<CurrentUser> currentUserProvider) {
     this.updateFactory = updateFactory;
     this.approvalsUtil = approvalsUtil;
     this.psUtil = psUtil;
     this.cmUtil = cmUtil;
-    this.userFactory = userFactory;
     this.voteDeleted = voteDeleted;
     this.deleteVoteSenderFactory = deleteVoteSenderFactory;
     this.notifyResolver = notifyResolver;
     this.removeReviewerControl = removeReviewerControl;
     this.projectCache = projectCache;
     this.messageIdGenerator = messageIdGenerator;
+    this.attentionSetOpfactory = attentionSetOpFactory;
+    this.currentUserProvider = currentUserProvider;
   }
 
   @Override
@@ -140,6 +146,14 @@
               r.getReviewerUser().state(),
               rsrc.getLabel(),
               input));
+      if (!r.getReviewerUser().getAccountId().equals(currentUserProvider.get().getAccountId())) {
+        bu.addOp(
+            change.getId(),
+            attentionSetOpfactory.create(
+                r.getReviewerUser().getAccountId(),
+                /* reason= */ "Their vote was deleted",
+                /* notify= */ false));
+      }
       bu.execute();
     }
 
@@ -152,7 +166,7 @@
     private final String label;
     private final DeleteVoteInput input;
 
-    private ChangeMessage changeMessage;
+    private String mailMessage;
     private Change change;
     private PatchSet ps;
     private Map<String, Short> newApprovals = new HashMap<>();
@@ -211,17 +225,15 @@
       StringBuilder msg = new StringBuilder();
       msg.append("Removed ");
       LabelVote.appendTo(msg, label, requireNonNull(oldApprovals.get(label)));
-      msg.append(" by ").append(userFactory.create(accountId).getNameEmail()).append("\n");
-      changeMessage =
-          ChangeMessagesUtil.newMessage(ctx, msg.toString(), ChangeMessagesUtil.TAG_DELETE_VOTE);
-      cmUtil.addChangeMessage(ctx.getUpdate(psId), changeMessage);
-
+      msg.append(" by ").append(ChangeMessagesUtil.getAccountTemplate(accountId)).append("\n");
+      mailMessage =
+          cmUtil.setChangeMessage(ctx, msg.toString(), ChangeMessagesUtil.TAG_DELETE_VOTE);
       return true;
     }
 
     @Override
-    public void postUpdate(Context ctx) {
-      if (changeMessage == null) {
+    public void postUpdate(PostUpdateContext ctx) {
+      if (mailMessage == null) {
         return;
       }
 
@@ -232,7 +244,7 @@
           ReplyToChangeSender emailSender =
               deleteVoteSenderFactory.create(ctx.getProject(), change.getId());
           emailSender.setFrom(user.getAccountId());
-          emailSender.setChangeMessage(changeMessage.getMessage(), ctx.getWhen());
+          emailSender.setChangeMessage(mailMessage, ctx.getWhen());
           emailSender.setNotify(notify);
           emailSender.setMessageId(
               messageIdGenerator.fromChangeUpdate(ctx.getRepoView(), change.currentPatchSetId()));
@@ -243,13 +255,13 @@
       }
 
       voteDeleted.fire(
-          change,
+          ctx.getChangeData(change),
           ps,
           accountState,
           newApprovals,
           oldApprovals,
           input.notify,
-          changeMessage.getMessage(),
+          mailMessage,
           user.state(),
           ctx.getWhen());
     }
diff --git a/java/com/google/gerrit/server/restapi/change/GetChange.java b/java/com/google/gerrit/server/restapi/change/GetChange.java
index c51bb91..740b8cb 100644
--- a/java/com/google/gerrit/server/restapi/change/GetChange.java
+++ b/java/com/google/gerrit/server/restapi/change/GetChange.java
@@ -67,8 +67,9 @@
   }
 
   @Option(name = "-O", usage = "Output option flags, in hex")
-  void setOptionFlagsHex(String hex) {
-    options.addAll(ListOption.fromBits(ListChangesOption.class, Integer.parseInt(hex, 16)));
+  void setOptionFlagsHex(String hex) throws BadRequestException {
+    EnumSet<ListChangesOption> optionSet = ListOption.fromHexString(ListChangesOption.class, hex);
+    options.addAll(optionSet);
   }
 
   @Inject
diff --git a/java/com/google/gerrit/server/restapi/change/GetDetail.java b/java/com/google/gerrit/server/restapi/change/GetDetail.java
index 15362d5..c6bbf53 100644
--- a/java/com/google/gerrit/server/restapi/change/GetDetail.java
+++ b/java/com/google/gerrit/server/restapi/change/GetDetail.java
@@ -35,7 +35,7 @@
   }
 
   @Option(name = "-O", usage = "Output option flags, in hex")
-  void setOptionFlagsHex(String hex) {
+  void setOptionFlagsHex(String hex) throws BadRequestException {
     delegate.setOptionFlagsHex(hex);
   }
 
diff --git a/java/com/google/gerrit/server/restapi/change/GetDiff.java b/java/com/google/gerrit/server/restapi/change/GetDiff.java
index b8902b7..2169d57 100644
--- a/java/com/google/gerrit/server/restapi/change/GetDiff.java
+++ b/java/com/google/gerrit/server/restapi/change/GetDiff.java
@@ -226,18 +226,24 @@
     }
 
     @Override
+    public ImmutableList<WebLinkInfo> getEditWebLinks() {
+      return webLinks.getEditLinks(projectName.get(), revB, sideB.fileName());
+    }
+
+    @Override
     public ImmutableList<WebLinkInfo> getFileWebLinks(DiffSide.Type type) {
-      String rev;
-      DiffSide side;
-      if (type == DiffSide.Type.SIDE_A) {
-        rev = revA;
-        side = sideA;
-      } else {
-        rev = revB;
-        side = sideB;
-      }
+      String rev = getSideRev(type);
+      DiffSide side = getDiffSide(type);
       return webLinks.getFileLinks(projectName.get(), rev, side.fileName());
     }
+
+    private String getSideRev(DiffSide.Type sideType) {
+      return DiffSide.Type.SIDE_A == sideType ? revA : revB;
+    }
+
+    private DiffSide getDiffSide(DiffSide.Type sideType) {
+      return DiffSide.Type.SIDE_A == sideType ? sideA : sideB;
+    }
   }
 
   public GetDiff setBase(String base) {
diff --git a/java/com/google/gerrit/server/restapi/change/GetFixPreview.java b/java/com/google/gerrit/server/restapi/change/GetFixPreview.java
index 6089778..99c8a0a 100644
--- a/java/com/google/gerrit/server/restapi/change/GetFixPreview.java
+++ b/java/com/google/gerrit/server/restapi/change/GetFixPreview.java
@@ -137,6 +137,11 @@
     }
 
     @Override
+    public ImmutableList<WebLinkInfo> getEditWebLinks() {
+      return ImmutableList.of();
+    }
+
+    @Override
     public ImmutableList<WebLinkInfo> getFileWebLinks(Type fileInfoType) {
       return ImmutableList.of();
     }
diff --git a/java/com/google/gerrit/server/restapi/change/GetMetaDiff.java b/java/com/google/gerrit/server/restapi/change/GetMetaDiff.java
index af23ba7..08d51e7 100644
--- a/java/com/google/gerrit/server/restapi/change/GetMetaDiff.java
+++ b/java/com/google/gerrit/server/restapi/change/GetMetaDiff.java
@@ -62,8 +62,8 @@
   }
 
   @Option(name = "-O", usage = "Output option flags, in hex")
-  void setOptionFlagsHex(String hex) {
-    options.addAll(ListOption.fromBits(ListChangesOption.class, Integer.parseInt(hex, 16)));
+  void setOptionFlagsHex(String hex) throws BadRequestException {
+    options.addAll(ListOption.fromHexString(ListChangesOption.class, hex));
   }
 
   @Option(name = "--old", usage = "old NoteDb meta SHA-1")
diff --git a/java/com/google/gerrit/server/restapi/change/GetRevisionActions.java b/java/com/google/gerrit/server/restapi/change/GetRevisionActions.java
index 527129c..f3c0fb8 100644
--- a/java/com/google/gerrit/server/restapi/change/GetRevisionActions.java
+++ b/java/com/google/gerrit/server/restapi/change/GetRevisionActions.java
@@ -34,6 +34,6 @@
 
   @Override
   public Response<Map<String, ActionInfo>> apply(RevisionResource rsrc) {
-    return Response.withMustRevalidate(delegate.format(rsrc));
+    return Response.ok(delegate.format(rsrc));
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/ListChangeDrafts.java b/java/com/google/gerrit/server/restapi/change/ListChangeDrafts.java
index 65f90ae..89ee399 100644
--- a/java/com/google/gerrit/server/restapi/change/ListChangeDrafts.java
+++ b/java/com/google/gerrit/server/restapi/change/ListChangeDrafts.java
@@ -27,12 +27,10 @@
 import com.google.gerrit.server.restapi.change.CommentJson.HumanCommentFormatter;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
-import com.google.inject.Singleton;
 import java.util.List;
 import java.util.Map;
 import org.kohsuke.args4j.Option;
 
-@Singleton
 public class ListChangeDrafts implements RestReadView<ChangeResource> {
   private final ChangeData.Factory changeDataFactory;
   private final Provider<CommentJson> commentJson;
diff --git a/java/com/google/gerrit/server/restapi/change/ListChangeMessages.java b/java/com/google/gerrit/server/restapi/change/ListChangeMessages.java
index 099d0a6..c881621 100644
--- a/java/com/google/gerrit/server/restapi/change/ListChangeMessages.java
+++ b/java/com/google/gerrit/server/restapi/change/ListChangeMessages.java
@@ -14,8 +14,6 @@
 
 package com.google.gerrit.server.restapi.change;
 
-import static com.google.gerrit.server.ChangeMessagesUtil.createChangeMessageInfo;
-
 import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.extensions.common.ChangeMessageInfo;
 import com.google.gerrit.extensions.restapi.Response;
@@ -48,7 +46,10 @@
     List<ChangeMessage> messages = changeMessagesUtil.byChange(resource.getNotes());
     List<ChangeMessageInfo> messageInfos =
         messages.stream()
-            .map(m -> createChangeMessageInfo(m, accountLoader))
+            .map(
+                m ->
+                    changeMessagesUtil.createChangeMessageInfoWithReplacedTemplates(
+                        m, accountLoader))
             .collect(Collectors.toList());
     accountLoader.fill();
     return Response.ok(messageInfos);
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 8ec394c..50b7516 100644
--- a/java/com/google/gerrit/server/restapi/change/Move.java
+++ b/java/com/google/gerrit/server/restapi/change/Move.java
@@ -25,13 +25,11 @@
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
-import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.MoveInput;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
@@ -42,11 +40,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;
@@ -252,9 +250,7 @@
         msgBuf.append("\n\n");
         msgBuf.append(input.message);
       }
-      ChangeMessage cmsg =
-          ChangeMessagesUtil.newMessage(ctx, msgBuf.toString(), ChangeMessagesUtil.TAG_MOVE);
-      cmUtil.addChangeMessage(update, cmsg);
+      cmUtil.setChangeMessage(ctx, msgBuf.toString(), ChangeMessagesUtil.TAG_MOVE);
 
       return true;
     }
@@ -287,7 +283,7 @@
   }
 
   @Override
-  public UiAction.Description getDescription(ChangeResource rsrc) {
+  public UiAction.Description getDescription(ChangeResource rsrc) throws IOException {
     UiAction.Description description =
         new UiAction.Description()
             .setLabel("Move Change")
@@ -298,30 +294,15 @@
     if (!change.isNew()) {
       return description;
     }
-
-    try {
-      if (!projectCache
-          .get(rsrc.getProject())
-          .orElseThrow(illegalState(rsrc.getProject()))
-          .statePermitsWrite()) {
-        return description;
-      }
-    } catch (StorageException e) {
-      logger.atSevere().withCause(e).log(
-          "Failed to check if project state permits write: %s", rsrc.getProject());
+    if (!projectCache
+        .get(rsrc.getProject())
+        .orElseThrow(illegalState(rsrc.getProject()))
+        .statePermitsWrite()) {
       return description;
     }
-
-    try {
-      if (psUtil.isPatchSetLocked(rsrc.getNotes())) {
-        return description;
-      }
-    } catch (StorageException e) {
-      logger.atSevere().withCause(e).log(
-          "Failed to check if the current patch set of change %s is locked", change.getId());
+    if (psUtil.isPatchSetLocked(rsrc.getNotes())) {
       return description;
     }
-
     return description.setVisible(
         and(
             permissionBackend.user(rsrc.getUser()).ref(change.getDest()).testCond(CREATE_CHANGE),
diff --git a/java/com/google/gerrit/server/restapi/change/PostReview.java b/java/com/google/gerrit/server/restapi/change/PostReview.java
index 58321e9..6816361 100644
--- a/java/com/google/gerrit/server/restapi/change/PostReview.java
+++ b/java/com/google/gerrit/server/restapi/change/PostReview.java
@@ -16,6 +16,7 @@
 
 import static com.google.common.base.MoreObjects.firstNonNull;
 import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
 import static com.google.gerrit.entities.Patch.PATCHSET_LEVEL;
 import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
 import static com.google.gerrit.server.permissions.LabelPermission.ForUser.ON_BEHALF_OF;
@@ -43,7 +44,6 @@
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Address;
 import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.entities.Comment;
 import com.google.gerrit.entities.FixReplacement;
 import com.google.gerrit.entities.FixSuggestion;
@@ -55,14 +55,14 @@
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.entities.RobotComment;
 import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.extensions.api.changes.AddReviewerInput;
-import com.google.gerrit.extensions.api.changes.AddReviewerResult;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput.CommentInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput.DraftHandling;
 import com.google.gerrit.extensions.api.changes.ReviewInput.RobotCommentInput;
 import com.google.gerrit.extensions.api.changes.ReviewResult;
+import com.google.gerrit.extensions.api.changes.ReviewerInput;
+import com.google.gerrit.extensions.api.changes.ReviewerResult;
 import com.google.gerrit.extensions.client.Comment.Range;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
 import com.google.gerrit.extensions.client.ReviewerState;
@@ -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,13 +90,14 @@
 import com.google.gerrit.server.PublishCommentUtil;
 import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.account.AccountResolver;
-import com.google.gerrit.server.change.AddReviewersEmail;
-import com.google.gerrit.server.change.AddReviewersOp.Result;
+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;
 import com.google.gerrit.server.change.NotifyResolver;
-import com.google.gerrit.server.change.ReviewerAdder;
-import com.google.gerrit.server.change.ReviewerAdder.ReviewerAddition;
+import com.google.gerrit.server.change.ReviewerModifier;
+import com.google.gerrit.server.change.ReviewerModifier.ReviewerModification;
+import com.google.gerrit.server.change.ReviewerOp.Result;
 import com.google.gerrit.server.change.RevisionResource;
 import com.google.gerrit.server.change.WorkInProgressOp;
 import com.google.gerrit.server.config.GerritServerConfig;
@@ -123,7 +123,7 @@
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.CommentsRejectedException;
-import com.google.gerrit.server.update.Context;
+import com.google.gerrit.server.update.PostUpdateContext;
 import com.google.gerrit.server.update.UpdateException;
 import com.google.gerrit.server.util.LabelVote;
 import com.google.gerrit.server.util.time.TimeUtil;
@@ -170,8 +170,8 @@
   private final AccountResolver accountResolver;
   private final EmailReviewComments.Factory email;
   private final CommentAdded commentAdded;
-  private final ReviewerAdder reviewerAdder;
-  private final AddReviewersEmail addReviewersEmail;
+  private final ReviewerModifier reviewerModifier;
+  private final ModifyReviewersEmail modifyReviewersEmail;
   private final NotifyResolver notifyResolver;
   private final WorkInProgressOp.Factory workInProgressOpFactory;
   private final ProjectCache projectCache;
@@ -196,8 +196,8 @@
       AccountResolver accountResolver,
       EmailReviewComments.Factory email,
       CommentAdded commentAdded,
-      ReviewerAdder reviewerAdder,
-      AddReviewersEmail addReviewersEmail,
+      ReviewerModifier reviewerModifier,
+      ModifyReviewersEmail modifyReviewersEmail,
       NotifyResolver notifyResolver,
       @GerritServerConfig Config gerritConfig,
       WorkInProgressOp.Factory workInProgressOpFactory,
@@ -218,8 +218,8 @@
     this.accountResolver = accountResolver;
     this.email = email;
     this.commentAdded = commentAdded;
-    this.reviewerAdder = reviewerAdder;
-    this.addReviewersEmail = addReviewersEmail;
+    this.reviewerModifier = reviewerModifier;
+    this.modifyReviewersEmail = modifyReviewersEmail;
     this.notifyResolver = notifyResolver;
     this.workInProgressOpFactory = workInProgressOpFactory;
     this.projectCache = projectCache;
@@ -266,6 +266,9 @@
       input.comments = cleanUpComments(input.comments);
       checkComments(revision, input.comments);
     }
+    if (input.draftIdsToPublish != null) {
+      checkDraftIds(revision, input.draftIdsToPublish, input.drafts);
+    }
     if (input.robotComments != null) {
       input.robotComments = cleanUpComments(input.robotComments);
       checkRobotComments(revision, input.robotComments);
@@ -276,15 +279,15 @@
     }
     logger.atFine().log("notify handling = %s", input.notify);
 
-    Map<String, AddReviewerResult> reviewerJsonResults = null;
-    List<ReviewerAddition> reviewerResults = Lists.newArrayList();
+    Map<String, ReviewerResult> reviewerJsonResults = null;
+    List<ReviewerModification> reviewerResults = Lists.newArrayList();
     boolean hasError = false;
     boolean confirm = false;
     if (input.reviewers != null) {
       reviewerJsonResults = Maps.newHashMap();
-      for (AddReviewerInput reviewerInput : input.reviewers) {
-        ReviewerAddition result =
-            reviewerAdder.prepare(revision.getNotes(), revision.getUser(), reviewerInput, true);
+      for (ReviewerInput reviewerInput : input.reviewers) {
+        ReviewerModification result =
+            reviewerModifier.prepare(revision.getNotes(), revision.getUser(), reviewerInput, true);
         reviewerJsonResults.put(reviewerInput.reviewer, result.result);
         if (result.result.error != null) {
           logger.atFine().log(
@@ -313,7 +316,7 @@
 
     try (BatchUpdate bu =
         updateFactory.create(revision.getChange().getProject(), revision.getUser(), ts)) {
-      Account.Id id = revision.getUser().getAccountId();
+      Account account = revision.getUser().asIdentifiedUser().getAccount();
       boolean ccOrReviewer = false;
       if (input.labels != null && !input.labels.isEmpty()) {
         ccOrReviewer = input.labels.values().stream().anyMatch(v -> v != 0);
@@ -326,7 +329,7 @@
         // Check if user was already CCed or reviewing prior to this review.
         ReviewerSet currentReviewers =
             approvalsUtil.getReviewers(revision.getChangeResource().getNotes());
-        ccOrReviewer = currentReviewers.all().contains(id);
+        ccOrReviewer = currentReviewers.all().contains(account.id());
         if (ccOrReviewer) {
           logger.atFine().log("calling user is already cc/reviewer on the change");
         }
@@ -336,10 +339,10 @@
       // updated set of reviewers. Also keep track of whether the user added
       // themselves as a reviewer or to the CC list.
       logger.atFine().log("adding reviewer additions");
-      for (ReviewerAddition reviewerResult : reviewerResults) {
+      for (ReviewerModification reviewerResult : reviewerResults) {
         reviewerResult.op.suppressEmail(); // Send a single batch email below.
         bu.addOp(revision.getChange().getId(), reviewerResult.op);
-        if (!ccOrReviewer && reviewerResult.reviewers.contains(id)) {
+        if (!ccOrReviewer && reviewerResult.reviewers.contains(account)) {
           logger.atFine().log("calling user is explicitly added as reviewer or CC");
           ccOrReviewer = true;
         }
@@ -350,7 +353,8 @@
         // isn't being explicitly added, and isn't voting on any label.
         // Automatically CC them on this change so they receive replies.
         logger.atFine().log("CCing calling user");
-        ReviewerAddition selfAddition = reviewerAdder.ccCurrentUser(revision.getUser(), revision);
+        ReviewerModification selfAddition =
+            reviewerModifier.ccCurrentUser(revision.getUser(), revision);
         selfAddition.op.suppressEmail();
         bu.addOp(revision.getChange().getId(), selfAddition.op);
       }
@@ -384,7 +388,7 @@
       bu.addOp(
           revision.getChange().getId(), new Op(projectState, revision.getPatchSet().id(), input));
 
-      // Notify based on ReviewInput, ignoring the notify settings from any AddReviewerInputs.
+      // Notify based on ReviewInput, ignoring the notify settings from any ReviewerInputs.
       NotifyResolver.Result notify = notifyResolver.resolve(input.notify, input.notifyDetails);
       bu.setNotify(notify);
 
@@ -395,7 +399,7 @@
 
       // Re-read change to take into account results of the update.
       ChangeData cd = changeDataFactory.create(revision.getProject(), revision.getChange().getId());
-      for (ReviewerAddition reviewerResult : reviewerResults) {
+      for (ReviewerModification reviewerResult : reviewerResults) {
         reviewerResult.gatherResults(cd);
       }
 
@@ -437,29 +441,44 @@
   private void batchEmailReviewers(
       CurrentUser user,
       Change change,
-      List<ReviewerAddition> reviewerAdditions,
+      List<ReviewerModification> reviewerModifications,
       NotifyResolver.Result notify) {
     try (TraceContext.TraceTimer ignored = newTimer("batchEmailReviewers")) {
       List<Account.Id> to = new ArrayList<>();
       List<Account.Id> cc = new ArrayList<>();
+      List<Account.Id> removed = new ArrayList<>();
       List<Address> toByEmail = new ArrayList<>();
       List<Address> ccByEmail = new ArrayList<>();
-      for (ReviewerAddition addition : reviewerAdditions) {
-        Result reviewAdditionResult = addition.op.getResult();
-        if (addition.state() == ReviewerState.REVIEWER
+      List<Address> removedByEmail = new ArrayList<>();
+      for (ReviewerModification modification : reviewerModifications) {
+        Result reviewAdditionResult = modification.op.getResult();
+        if (modification.state() == ReviewerState.REVIEWER
             && (!reviewAdditionResult.addedReviewers().isEmpty()
                 || !reviewAdditionResult.addedReviewersByEmail().isEmpty())) {
-          to.addAll(addition.reviewers);
-          toByEmail.addAll(addition.reviewersByEmail);
-        } else if (addition.state() == ReviewerState.CC
+          to.addAll(modification.reviewers.stream().map(Account::id).collect(toImmutableSet()));
+          toByEmail.addAll(modification.reviewersByEmail);
+        } else if (modification.state() == ReviewerState.CC
             && (!reviewAdditionResult.addedCCs().isEmpty()
                 || !reviewAdditionResult.addedCCsByEmail().isEmpty())) {
-          cc.addAll(addition.reviewers);
-          ccByEmail.addAll(addition.reviewersByEmail);
+          cc.addAll(modification.reviewers.stream().map(Account::id).collect(toImmutableSet()));
+          ccByEmail.addAll(modification.reviewersByEmail);
+        } else if (modification.state() == ReviewerState.REMOVED
+            && (reviewAdditionResult.deletedReviewer().isPresent()
+                || reviewAdditionResult.deletedReviewerByEmail().isPresent())) {
+          reviewAdditionResult.deletedReviewer().ifPresent(d -> removed.add(d));
+          reviewAdditionResult.deletedReviewerByEmail().ifPresent(d -> removedByEmail.add(d));
         }
       }
-      addReviewersEmail.emailReviewersAsync(
-          user.asIdentifiedUser(), change, to, cc, toByEmail, ccByEmail, notify);
+      modifyReviewersEmail.emailReviewersAsync(
+          user.asIdentifiedUser(),
+          change,
+          to,
+          cc,
+          removed,
+          toByEmail,
+          ccByEmail,
+          removedByEmail,
+          notify);
     }
   }
 
@@ -631,6 +650,41 @@
     }
   }
 
+  /**
+   * Asserts that the draft IDs to publish are valid, i.e. they exist and belong to the current
+   * user. If the {@code draftHandling} parameter is equal to {@link DraftHandling#PUBLISH}, then
+   * draft IDs should all correspond to the target revision, otherwise we throw a
+   * BadRequestException.
+   */
+  private void checkDraftIds(
+      RevisionResource resource, List<String> draftIds, DraftHandling draftHandling)
+      throws BadRequestException {
+    Map<String, HumanComment> draftsByUuid =
+        commentsUtil.draftByChangeAuthor(resource.getNotes(), resource.getUser().getAccountId())
+            .stream()
+            .collect(Collectors.toMap(c -> c.key.uuid, c -> c));
+    List<String> nonExistingDraftIds =
+        draftIds.stream().filter(id -> !draftsByUuid.containsKey(id)).collect(toList());
+    if (!nonExistingDraftIds.isEmpty()) {
+      throw new BadRequestException("Non-existing draft IDs: " + nonExistingDraftIds);
+    }
+    if (draftHandling == DraftHandling.PUBLISH_ALL_REVISIONS
+        || draftHandling == DraftHandling.KEEP) {
+      return;
+    }
+    List<String> draftsForOtherRevisions =
+        draftIds.stream()
+            .filter(id -> draftsByUuid.get(id).key.patchSetId != resource.getPatchSet().number())
+            .collect(toList());
+    if (!draftsForOtherRevisions.isEmpty()) {
+      throw new BadRequestException(
+          String.format(
+              "Draft comments for other revisions cannot be published when DraftHandling = PUBLISH."
+                  + " (draft IDs: %s)",
+              draftsForOtherRevisions));
+    }
+  }
+
   private Set<String> getAffectedFilePaths(RevisionResource revision)
       throws PatchListNotAvailableException {
     ObjectId newId = revision.getPatchSet().commitId();
@@ -890,7 +944,7 @@
     private IdentifiedUser user;
     private ChangeNotes notes;
     private PatchSet ps;
-    private ChangeMessage message;
+    private String mailMessage;
     private List<Comment> comments = new ArrayList<>();
     private List<LabelVote> labelDelta = new ArrayList<>();
     private Map<String, Short> approvals = new HashMap<>();
@@ -928,8 +982,8 @@
     }
 
     @Override
-    public void postUpdate(Context ctx) {
-      if (message == null) {
+    public void postUpdate(PostUpdateContext ctx) {
+      if (mailMessage == null) {
         return;
       }
       NotifyResolver.Result notify = ctx.getNotify(notes.getChangeId());
@@ -941,7 +995,8 @@
                   notes,
                   ps,
                   user,
-                  message,
+                  mailMessage,
+                  ctx.getWhen(),
                   comments,
                   in.message,
                   labelDelta,
@@ -952,7 +1007,7 @@
               String.format("Repository %s not found", ctx.getProject().get()), ex);
         }
       }
-      String comment = message.getMessage();
+      String comment = mailMessage;
       if (publishPatchSetLevelComment) {
         // TODO(davido): Remove this workaround when patch set level comments are exposed in comment
         // added event. For backwards compatibility, patchset level comment has a higher priority
@@ -968,9 +1023,23 @@
         }
       }
       commentAdded.fire(
-          notes.getChange(), ps, user.state(), comment, approvals, oldApprovals, ctx.getWhen());
+          ctx.getChangeData(notes),
+          ps,
+          user.state(),
+          comment,
+          approvals,
+          oldApprovals,
+          ctx.getWhen());
     }
 
+    /**
+     * Publishes draft and input comments. Input comments are those passed as input in the request
+     * body.
+     *
+     * @param ctx context for performing the change update.
+     * @param newRobotComments robot comments. Used only for validation in this method.
+     * @return true if any input comments where published.
+     */
     private boolean insertComments(ChangeContext ctx, List<RobotComment> newRobotComments)
         throws CommentsRejectedException {
       Map<String, List<CommentInput>> inputComments = in.comments;
@@ -978,28 +1047,71 @@
         inputComments = Collections.emptyMap();
       }
 
-      // HashMap instead of Collections.emptyMap() avoids warning about remove() on immutable
-      // object.
+      // Use HashMap to avoid warnings when calling remove() in resolveInputCommentsAndDrafts().
       Map<String, HumanComment> drafts = new HashMap<>();
-      // If there are inputComments we need the deduplication loop below, so we have to read (and
-      // publish) drafts here.
+
       if (!inputComments.isEmpty() || in.drafts != DraftHandling.KEEP) {
-        if (in.drafts == DraftHandling.PUBLISH_ALL_REVISIONS) {
-          drafts = changeDrafts(ctx);
-        } else {
-          drafts = patchSetDrafts(ctx);
-        }
+        drafts =
+            in.drafts == DraftHandling.PUBLISH_ALL_REVISIONS
+                ? changeDrafts(ctx)
+                : patchSetDrafts(ctx);
       }
 
-      // This will be populated with Comment-s created from inputComments.
-      List<HumanComment> toPublish = new ArrayList<>();
-
+      // Existing published comments
       Set<CommentSetEntry> existingComments =
           in.omitDuplicateComments ? readExistingComments(ctx) : Collections.emptySet();
 
-      // Deduplication:
-      // - Ignore drafts with the same ID as an inputComment here. These are deleted later.
-      // - Swallow comments that already exist.
+      // Input comments should be deduplicated from existing drafts
+      List<HumanComment> inputCommentsToPublish =
+          resolveInputCommentsAndDrafts(inputComments, existingComments, drafts, ctx);
+
+      switch (in.drafts) {
+        case PUBLISH:
+        case PUBLISH_ALL_REVISIONS:
+          Collection<HumanComment> filteredDrafts =
+              in.draftIdsToPublish == null
+                  ? drafts.values()
+                  : drafts.values().stream()
+                      .filter(draft -> in.draftIdsToPublish.contains(draft.key.uuid))
+                      .collect(Collectors.toList());
+
+          validateComments(
+              ctx,
+              Streams.concat(
+                  drafts.values().stream(),
+                  inputCommentsToPublish.stream(),
+                  newRobotComments.stream()));
+          publishCommentUtil.publish(ctx, ctx.getUpdate(psId), filteredDrafts, in.tag);
+          comments.addAll(drafts.values());
+          break;
+        case KEEP:
+          validateComments(
+              ctx, Streams.concat(inputCommentsToPublish.stream(), newRobotComments.stream()));
+          break;
+      }
+      commentsUtil.putHumanComments(
+          ctx.getUpdate(psId), HumanComment.Status.PUBLISHED, inputCommentsToPublish);
+      comments.addAll(inputCommentsToPublish);
+      return !inputCommentsToPublish.isEmpty();
+    }
+
+    /**
+     * Returns the subset of {@code inputComments} that do not have a matching comment (with same
+     * id) neither in {@code existingComments} nor in {@code drafts}.
+     *
+     * <p>Entries in {@code drafts} that have a matching entry in {@code inputComments} will be
+     * removed.
+     *
+     * @param inputComments new comments provided as {@link CommentInput} entries in the API.
+     * @param existingComments existing published comments in the database.
+     * @param drafts existing draft comments in the database. This map can be modified.
+     */
+    private List<HumanComment> resolveInputCommentsAndDrafts(
+        Map<String, List<CommentInput>> inputComments,
+        Set<CommentSetEntry> existingComments,
+        Map<String, HumanComment> drafts,
+        ChangeContext ctx) {
+      List<HumanComment> inputCommentsToPublish = new ArrayList<>();
       for (Map.Entry<String, List<CommentInput>> entry : inputComments.entrySet()) {
         String path = entry.getKey();
         for (CommentInput inputComment : entry.getValue()) {
@@ -1031,32 +1143,10 @@
           if (existingComments.contains(CommentSetEntry.create(comment))) {
             continue;
           }
-          toPublish.add(comment);
+          inputCommentsToPublish.add(comment);
         }
       }
-
-      CommentValidationContext commentValidationCtx =
-          CommentValidationContext.create(
-              ctx.getChange().getChangeId(), ctx.getChange().getProject().get());
-      switch (in.drafts) {
-        case PUBLISH:
-        case PUBLISH_ALL_REVISIONS:
-          validateComments(
-              commentValidationCtx,
-              Streams.concat(
-                  drafts.values().stream(), toPublish.stream(), newRobotComments.stream()));
-          publishCommentUtil.publish(ctx, ctx.getUpdate(psId), drafts.values(), in.tag);
-          comments.addAll(drafts.values());
-          break;
-        case KEEP:
-          validateComments(
-              commentValidationCtx, Stream.concat(toPublish.stream(), newRobotComments.stream()));
-          break;
-      }
-      ChangeUpdate changeUpdate = ctx.getUpdate(psId);
-      commentsUtil.putHumanComments(changeUpdate, HumanComment.Status.PUBLISHED, toPublish);
-      comments.addAll(toPublish);
-      return !toPublish.isEmpty();
+      return inputCommentsToPublish;
     }
 
     /**
@@ -1064,8 +1154,11 @@
      * contract of {@link CommentValidator#validateComments(CommentValidationContext,
      * ImmutableList)}.
      */
-    private void validateComments(CommentValidationContext ctx, Stream<Comment> comments)
+    private void validateComments(ChangeContext ctx, Stream<? extends Comment> comments)
         throws CommentsRejectedException {
+      CommentValidationContext commentValidationCtx =
+          CommentValidationContext.create(
+              ctx.getChange().getChangeId(), ctx.getChange().getProject().get());
       String changeMessage = Strings.nullToEmpty(in.message).trim();
       ImmutableList<CommentForValidation> draftsForValidation =
           Stream.concat(
@@ -1088,7 +1181,8 @@
                           changeMessage.length())))
               .collect(toImmutableList());
       ImmutableList<CommentValidationFailure> draftValidationFailures =
-          PublishCommentUtil.findInvalidComments(ctx, commentValidators, draftsForValidation);
+          PublishCommentUtil.findInvalidComments(
+              commentValidationCtx, commentValidators, draftsForValidation);
       if (!draftValidationFailures.isEmpty()) {
         throw new CommentsRejectedException(draftValidationFailures);
       }
@@ -1324,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()
@@ -1468,10 +1558,9 @@
         return false;
       }
 
-      message =
-          ChangeMessagesUtil.newMessage(
-              psId, user, ctx.getWhen(), "Patch Set " + psId.get() + ":" + buf, in.tag);
-      cmUtil.addChangeMessage(ctx.getUpdate(psId), message);
+      mailMessage =
+          cmUtil.setChangeMessage(
+              ctx.getUpdate(psId), "Patch Set " + psId.get() + ":" + buf, in.tag);
       return true;
     }
 
diff --git a/java/com/google/gerrit/server/restapi/change/PostReviewers.java b/java/com/google/gerrit/server/restapi/change/PostReviewers.java
index e6a87e9..4691550 100644
--- a/java/com/google/gerrit/server/restapi/change/PostReviewers.java
+++ b/java/com/google/gerrit/server/restapi/change/PostReviewers.java
@@ -15,17 +15,17 @@
 package com.google.gerrit.server.restapi.change;
 
 import com.google.gerrit.entities.Change;
-import com.google.gerrit.extensions.api.changes.AddReviewerInput;
-import com.google.gerrit.extensions.api.changes.AddReviewerResult;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.api.changes.ReviewerInput;
+import com.google.gerrit.extensions.api.changes.ReviewerResult;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestCollectionModifyView;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.NotifyResolver;
-import com.google.gerrit.server.change.ReviewerAdder;
-import com.google.gerrit.server.change.ReviewerAdder.ReviewerAddition;
+import com.google.gerrit.server.change.ReviewerModifier;
+import com.google.gerrit.server.change.ReviewerModifier.ReviewerModification;
 import com.google.gerrit.server.change.ReviewerResource;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.query.change.ChangeData;
@@ -39,50 +39,51 @@
 
 @Singleton
 public class PostReviewers
-    implements RestCollectionModifyView<ChangeResource, ReviewerResource, AddReviewerInput> {
+    implements RestCollectionModifyView<ChangeResource, ReviewerResource, ReviewerInput> {
   private final BatchUpdate.Factory updateFactory;
   private final ChangeData.Factory changeDataFactory;
   private final NotifyResolver notifyResolver;
-  private final ReviewerAdder reviewerAdder;
+  private final ReviewerModifier reviewerModifier;
 
   @Inject
   PostReviewers(
       BatchUpdate.Factory updateFactory,
       ChangeData.Factory changeDataFactory,
       NotifyResolver notifyResolver,
-      ReviewerAdder reviewerAdder) {
+      ReviewerModifier reviewerModifier) {
     this.updateFactory = updateFactory;
     this.changeDataFactory = changeDataFactory;
     this.notifyResolver = notifyResolver;
-    this.reviewerAdder = reviewerAdder;
+    this.reviewerModifier = reviewerModifier;
   }
 
   @Override
-  public Response<AddReviewerResult> apply(ChangeResource rsrc, AddReviewerInput input)
+  public Response<ReviewerResult> apply(ChangeResource rsrc, ReviewerInput input)
       throws IOException, RestApiException, UpdateException, PermissionBackendException,
           ConfigInvalidException {
     if (input.reviewer == null) {
       throw new BadRequestException("missing reviewer field");
     }
 
-    ReviewerAddition addition = reviewerAdder.prepare(rsrc.getNotes(), rsrc.getUser(), input, true);
-    if (addition.op == null) {
-      return Response.ok(addition.result);
+    ReviewerModification modification =
+        reviewerModifier.prepare(rsrc.getNotes(), rsrc.getUser(), input, true);
+    if (modification.op == null) {
+      return Response.ok(modification.result);
     }
     try (BatchUpdate bu =
         updateFactory.create(rsrc.getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
       bu.setNotify(resolveNotify(rsrc, input));
       Change.Id id = rsrc.getChange().getId();
-      bu.addOp(id, addition.op);
+      bu.addOp(id, modification.op);
       bu.execute();
     }
 
     // Re-read change to take into account results of the update.
-    addition.gatherResults(changeDataFactory.create(rsrc.getProject(), rsrc.getId()));
-    return Response.ok(addition.result);
+    modification.gatherResults(changeDataFactory.create(rsrc.getProject(), rsrc.getId()));
+    return Response.ok(modification.result);
   }
 
-  private NotifyResolver.Result resolveNotify(ChangeResource rsrc, AddReviewerInput input)
+  private NotifyResolver.Result resolveNotify(ChangeResource rsrc, ReviewerInput input)
       throws BadRequestException, ConfigInvalidException, IOException {
     NotifyHandling notifyHandling = input.notify;
     if (notifyHandling == null) {
diff --git a/java/com/google/gerrit/server/restapi/change/PutAssignee.java b/java/com/google/gerrit/server/restapi/change/PutAssignee.java
index 7b0d905..dcf616c 100644
--- a/java/com/google/gerrit/server/restapi/change/PutAssignee.java
+++ b/java/com/google/gerrit/server/restapi/change/PutAssignee.java
@@ -15,9 +15,9 @@
 package com.google.gerrit.server.restapi.change;
 
 import com.google.common.base.Strings;
-import com.google.gerrit.extensions.api.changes.AddReviewerInput;
 import com.google.gerrit.extensions.api.changes.AssigneeInput;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.api.changes.ReviewerInput;
 import com.google.gerrit.extensions.client.ReviewerState;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
@@ -26,14 +26,14 @@
 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.ReviewerAdder;
-import com.google.gerrit.server.change.ReviewerAdder.ReviewerAddition;
+import com.google.gerrit.server.change.ReviewerModifier;
+import com.google.gerrit.server.change.ReviewerModifier.ReviewerModification;
 import com.google.gerrit.server.change.SetAssigneeOp;
 import com.google.gerrit.server.permissions.ChangePermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
@@ -53,7 +53,7 @@
   private final BatchUpdate.Factory updateFactory;
   private final AccountResolver accountResolver;
   private final SetAssigneeOp.Factory assigneeFactory;
-  private final ReviewerAdder reviewerAdder;
+  private final ReviewerModifier reviewerModifier;
   private final AccountLoader.Factory accountLoaderFactory;
   private final PermissionBackend permissionBackend;
   private final ApprovalsUtil approvalsUtil;
@@ -63,14 +63,14 @@
       BatchUpdate.Factory updateFactory,
       AccountResolver accountResolver,
       SetAssigneeOp.Factory assigneeFactory,
-      ReviewerAdder reviewerAdder,
+      ReviewerModifier reviewerModifier,
       AccountLoader.Factory accountLoaderFactory,
       PermissionBackend permissionBackend,
       ApprovalsUtil approvalsUtil) {
     this.updateFactory = updateFactory;
     this.accountResolver = accountResolver;
     this.assigneeFactory = assigneeFactory;
-    this.reviewerAdder = reviewerAdder;
+    this.reviewerModifier = reviewerModifier;
     this.accountLoaderFactory = accountLoaderFactory;
     this.permissionBackend = permissionBackend;
     this.approvalsUtil = approvalsUtil;
@@ -104,7 +104,7 @@
 
       ReviewerSet currentReviewers = approvalsUtil.getReviewers(rsrc.getNotes());
       if (!currentReviewers.all().contains(assignee.getAccountId())) {
-        ReviewerAddition reviewersAddition = addAssigneeAsCC(rsrc, input.assignee);
+        ReviewerModification reviewersAddition = addAssigneeAsCC(rsrc, input.assignee);
         reviewersAddition.op.suppressEmail();
         bu.addOp(rsrc.getId(), reviewersAddition.op);
       }
@@ -114,14 +114,14 @@
     }
   }
 
-  private ReviewerAddition addAssigneeAsCC(ChangeResource rsrc, String assignee)
+  private ReviewerModification addAssigneeAsCC(ChangeResource rsrc, String assignee)
       throws IOException, PermissionBackendException, ConfigInvalidException {
-    AddReviewerInput reviewerInput = new AddReviewerInput();
+    ReviewerInput reviewerInput = new ReviewerInput();
     reviewerInput.reviewer = assignee;
     reviewerInput.state = ReviewerState.CC;
     reviewerInput.confirmed = true;
     reviewerInput.notify = NotifyHandling.NONE;
-    return reviewerAdder.prepare(rsrc.getNotes(), rsrc.getUser(), reviewerInput, false);
+    return reviewerModifier.prepare(rsrc.getNotes(), rsrc.getUser(), reviewerInput, false);
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/restapi/change/PutDescription.java b/java/com/google/gerrit/server/restapi/change/PutDescription.java
index f442a42..7c54074 100644
--- a/java/com/google/gerrit/server/restapi/change/PutDescription.java
+++ b/java/com/google/gerrit/server/restapi/change/PutDescription.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server.restapi.change;
 
 import com.google.common.base.Strings;
-import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.extensions.common.DescriptionInput;
 import com.google.gerrit.extensions.restapi.Response;
@@ -102,10 +101,7 @@
             String.format(
                 "Description of patch set %d changed to \"%s\"", psId.get(), newDescription);
       }
-      ChangeMessage cmsg =
-          ChangeMessagesUtil.newMessage(
-              psId, ctx.getUser(), ctx.getWhen(), summary, ChangeMessagesUtil.TAG_SET_DESCRIPTION);
-      cmUtil.addChangeMessage(update, cmsg);
+      cmUtil.setChangeMessage(update, summary, ChangeMessagesUtil.TAG_SET_DESCRIPTION);
       return true;
     }
   }
diff --git a/java/com/google/gerrit/server/restapi/change/QueryChanges.java b/java/com/google/gerrit/server/restapi/change/QueryChanges.java
index cf0d4cf..91fa2f0 100644
--- a/java/com/google/gerrit/server/restapi/change/QueryChanges.java
+++ b/java/com/google/gerrit/server/restapi/change/QueryChanges.java
@@ -82,8 +82,8 @@
   }
 
   @Option(name = "-O", usage = "Output option flags, in hex")
-  void setOptionFlagsHex(String hex) {
-    options.addAll(ListOption.fromBits(ListChangesOption.class, Integer.parseInt(hex, 16)));
+  void setOptionFlagsHex(String hex) throws BadRequestException {
+    options.addAll(ListOption.fromHexString(ListChangesOption.class, hex));
   }
 
   @Option(
diff --git a/java/com/google/gerrit/server/restapi/change/Rebase.java b/java/com/google/gerrit/server/restapi/change/Rebase.java
index cfdf04d..3e1f033 100644
--- a/java/com/google/gerrit/server/restapi/change/Rebase.java
+++ b/java/com/google/gerrit/server/restapi/change/Rebase.java
@@ -22,7 +22,6 @@
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
-import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.RebaseInput;
 import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.common.ChangeInfo;
@@ -215,7 +214,7 @@
   }
 
   @Override
-  public UiAction.Description getDescription(RevisionResource rsrc) {
+  public UiAction.Description getDescription(RevisionResource rsrc) throws IOException {
     UiAction.Description description =
         new UiAction.Description()
             .setLabel("Rebase")
@@ -226,27 +225,13 @@
     if (!(change.isNew() && rsrc.isCurrent())) {
       return description;
     }
-
-    try {
-      if (!projectCache
-          .get(rsrc.getProject())
-          .orElseThrow(illegalState(rsrc.getProject()))
-          .statePermitsWrite()) {
-        return description;
-      }
-    } catch (StorageException e) {
-      logger.atSevere().withCause(e).log(
-          "Failed to check if project state permits write: %s", rsrc.getProject());
+    if (!projectCache
+        .get(rsrc.getProject())
+        .orElseThrow(illegalState(rsrc.getProject()))
+        .statePermitsWrite()) {
       return description;
     }
-
-    try {
-      if (patchSetUtil.isPatchSetLocked(rsrc.getNotes())) {
-        return description;
-      }
-    } catch (StorageException e) {
-      logger.atSevere().withCause(e).log(
-          "Failed to check if the current patch set of change %s is locked", change.getId());
+    if (patchSetUtil.isPatchSetLocked(rsrc.getNotes())) {
       return description;
     }
 
@@ -256,14 +241,6 @@
       if (hasOneParent(rw, rsrc.getPatchSet())) {
         enabled = rebaseUtil.canRebase(rsrc.getPatchSet(), change.getDest(), repo, rw);
       }
-    } catch (Exception e) {
-      // Be generous here with the exceptions that we log and swallow. RebaseUtil#canRebase uses the
-      // change index and this UI action is on the critical path of rendering a change details page.
-      // If the index is broken, we log and disable the UI action, but still show the page to the
-      // user.
-      logger.atSevere().withCause(e).log(
-          "Failed to check if patch set can be rebased: %s", rsrc.getPatchSet());
-      return description;
     }
 
     if (rsrc.permissions().testOrFalse(ChangePermission.REBASE)) {
diff --git a/java/com/google/gerrit/server/restapi/change/ReplyAttentionSetUpdates.java b/java/com/google/gerrit/server/restapi/change/ReplyAttentionSetUpdates.java
index a1bd678..0356cdd 100644
--- a/java/com/google/gerrit/server/restapi/change/ReplyAttentionSetUpdates.java
+++ b/java/com/google/gerrit/server/restapi/change/ReplyAttentionSetUpdates.java
@@ -27,11 +27,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;
@@ -123,7 +123,9 @@
       bu.addOp(changeNotes.getChangeId(), new AttentionSetUnchangedOp());
       return;
     }
-    if (serviceUserClassifier.isServiceUser(currentUser.getAccountId())) {
+    boolean isReadyForReview = isReadyForReview(changeNotes, input);
+
+    if (isReadyForReview && serviceUserClassifier.isServiceUser(currentUser.getAccountId())) {
       botsWithNegativeLabelsAddOwnerAndUploader(bu, changeNotes, input);
       return;
     }
@@ -131,7 +133,7 @@
     processRules(
         bu,
         changeNotes,
-        isReadyForReview(changeNotes, input),
+        isReadyForReview,
         currentUser,
         getAllNewComments(changeNotes, input, currentUser));
   }
diff --git a/java/com/google/gerrit/server/restapi/change/Restore.java b/java/com/google/gerrit/server/restapi/change/Restore.java
index 7faf8e0..b2d1d3a 100644
--- a/java/com/google/gerrit/server/restapi/change/Restore.java
+++ b/java/com/google/gerrit/server/restapi/change/Restore.java
@@ -20,9 +20,7 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Change.Status;
-import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.entities.PatchSet;
-import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.RestoreInput;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
@@ -47,7 +45,7 @@
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
-import com.google.gerrit.server.update.Context;
+import com.google.gerrit.server.update.PostUpdateContext;
 import com.google.gerrit.server.update.UpdateException;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
@@ -113,7 +111,7 @@
 
     private Change change;
     private PatchSet patchSet;
-    private ChangeMessage message;
+    private String mailMessage;
 
     private Op(RestoreInput input) {
       this.input = input;
@@ -132,28 +130,27 @@
       change.setLastUpdatedOn(ctx.getWhen());
       update.setStatus(change.getStatus());
 
-      message = newMessage(ctx);
-      cmUtil.addChangeMessage(update, message);
+      mailMessage = cmUtil.setChangeMessage(ctx, commentMessage(), ChangeMessagesUtil.TAG_RESTORE);
       return true;
     }
 
-    private ChangeMessage newMessage(ChangeContext ctx) {
+    private String commentMessage() {
       StringBuilder msg = new StringBuilder();
       msg.append("Restored");
       if (!Strings.nullToEmpty(input.message).trim().isEmpty()) {
         msg.append("\n\n");
         msg.append(input.message.trim());
       }
-      return ChangeMessagesUtil.newMessage(ctx, msg.toString(), ChangeMessagesUtil.TAG_RESTORE);
+      return msg.toString();
     }
 
     @Override
-    public void postUpdate(Context ctx) {
+    public void postUpdate(PostUpdateContext ctx) {
       try {
         ReplyToChangeSender emailSender =
             restoredSenderFactory.create(ctx.getProject(), change.getId());
         emailSender.setFrom(ctx.getAccountId());
-        emailSender.setChangeMessage(message.getMessage(), ctx.getWhen());
+        emailSender.setChangeMessage(mailMessage, ctx.getWhen());
         emailSender.setMessageId(
             messageIdGenerator.fromChangeUpdate(ctx.getRepoView(), change.currentPatchSetId()));
         emailSender.send();
@@ -161,12 +158,16 @@
         logger.atSevere().withCause(e).log("Cannot email update for change %s", change.getId());
       }
       changeRestored.fire(
-          change, patchSet, ctx.getAccount(), Strings.emptyToNull(input.message), ctx.getWhen());
+          ctx.getChangeData(change),
+          patchSet,
+          ctx.getAccount(),
+          Strings.emptyToNull(input.message),
+          ctx.getWhen());
     }
   }
 
   @Override
-  public UiAction.Description getDescription(ChangeResource rsrc) {
+  public UiAction.Description getDescription(ChangeResource rsrc) throws IOException {
     UiAction.Description description =
         new UiAction.Description()
             .setLabel("Restore")
@@ -177,27 +178,12 @@
     if (!change.isAbandoned()) {
       return description;
     }
-
-    try {
-      if (!projectCache.get(rsrc.getProject()).map(ProjectState::statePermitsRead).orElse(false)) {
-        return description;
-      }
-    } catch (StorageException e) {
-      logger.atSevere().withCause(e).log(
-          "Failed to check if project state permits write: %s", rsrc.getProject());
+    if (!projectCache.get(rsrc.getProject()).map(ProjectState::statePermitsRead).orElse(false)) {
       return description;
     }
-
-    try {
-      if (psUtil.isPatchSetLocked(rsrc.getNotes())) {
-        return description;
-      }
-    } catch (StorageException e) {
-      logger.atSevere().withCause(e).log(
-          "Failed to check if the current patch set of change %s is locked", change.getId());
+    if (psUtil.isPatchSetLocked(rsrc.getNotes())) {
       return description;
     }
-
     boolean visible = rsrc.permissions().testOrFalse(ChangePermission.RESTORE);
     return description.setVisible(visible);
   }
diff --git a/java/com/google/gerrit/server/restapi/change/Revert.java b/java/com/google/gerrit/server/restapi/change/Revert.java
index fd4a13e..7bb43d2 100644
--- a/java/com/google/gerrit/server/restapi/change/Revert.java
+++ b/java/com/google/gerrit/server/restapi/change/Revert.java
@@ -22,7 +22,6 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
-import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.RevertInput;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
@@ -114,14 +113,8 @@
   @Override
   public UiAction.Description getDescription(ChangeResource rsrc) {
     Change change = rsrc.getChange();
-    boolean projectStatePermitsWrite = false;
-    try {
-      projectStatePermitsWrite =
-          projectCache.get(rsrc.getProject()).map(ProjectState::statePermitsWrite).orElse(false);
-    } catch (StorageException e) {
-      logger.atSevere().withCause(e).log(
-          "Failed to check if project state permits write: %s", rsrc.getProject());
-    }
+    boolean projectStatePermitsWrite =
+        projectCache.get(rsrc.getProject()).map(ProjectState::statePermitsWrite).orElse(false);
     return new UiAction.Description()
         .setLabel("Revert")
         .setTitle("Revert the change")
diff --git a/java/com/google/gerrit/server/restapi/change/RevertSubmission.java b/java/com/google/gerrit/server/restapi/change/RevertSubmission.java
index cb91faa..20249df 100644
--- a/java/com/google/gerrit/server/restapi/change/RevertSubmission.java
+++ b/java/com/google/gerrit/server/restapi/change/RevertSubmission.java
@@ -28,8 +28,6 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.ChangeMessage;
-import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.exceptions.StorageException;
@@ -75,7 +73,7 @@
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
-import com.google.gerrit.server.update.Context;
+import com.google.gerrit.server.update.PostUpdateContext;
 import com.google.gerrit.server.update.UpdateException;
 import com.google.gerrit.server.util.CommitMessageUtil;
 import com.google.gerrit.server.util.time.TimeUtil;
@@ -614,10 +612,10 @@
     }
 
     @Override
-    public void postUpdate(Context ctx) throws Exception {
+    public void postUpdate(PostUpdateContext ctx) throws Exception {
       changeReverted.fire(
-          change,
-          changeNotesFactory.createChecked(ctx.getProject(), revertChangeId).getChange(),
+          ctx.getChangeData(change),
+          ctx.getChangeData(changeNotesFactory.createChecked(ctx.getProject(), revertChangeId)),
           ctx.getWhen());
       try {
         RevertedSender emailSender = revertedSenderFactory.create(ctx.getProject(), change.getId());
@@ -647,14 +645,10 @@
 
     @Override
     public boolean updateChange(ChangeContext ctx) throws Exception {
-      Change change = ctx.getChange();
-      PatchSet.Id patchSetId = change.currentPatchSetId();
-      ChangeMessage changeMessage =
-          ChangeMessagesUtil.newMessage(
-              ctx,
-              "Created a revert of this change as I" + computedChangeId.getName(),
-              ChangeMessagesUtil.TAG_REVERT);
-      cmUtil.addChangeMessage(ctx.getUpdate(patchSetId), changeMessage);
+      cmUtil.setChangeMessage(
+          ctx,
+          "Created a revert of this change as I" + computedChangeId.getName(),
+          ChangeMessagesUtil.TAG_REVERT);
       return true;
     }
   }
diff --git a/java/com/google/gerrit/server/restapi/change/ReviewerRecommender.java b/java/com/google/gerrit/server/restapi/change/ReviewerRecommender.java
index d80ab696..9e74eec 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;
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/ReviewersUtil.java b/java/com/google/gerrit/server/restapi/change/ReviewersUtil.java
index 38be27e..d6c4c51 100644
--- a/java/com/google/gerrit/server/restapi/change/ReviewersUtil.java
+++ b/java/com/google/gerrit/server/restapi/change/ReviewersUtil.java
@@ -52,7 +52,7 @@
 import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.account.GroupMembers;
 import com.google.gerrit.server.account.ServiceUserClassifier;
-import com.google.gerrit.server.change.ReviewerAdder;
+import com.google.gerrit.server.change.ReviewerModifier;
 import com.google.gerrit.server.index.account.AccountField;
 import com.google.gerrit.server.index.account.AccountIndexCollection;
 import com.google.gerrit.server.index.account.AccountIndexRewriter;
@@ -415,7 +415,7 @@
     int maxAllowedWithoutConfirmation = suggestReviewers.getMaxAllowedWithoutConfirmation();
     logger.atFine().log("maxAllowedWithoutConfirmation: " + maxAllowedWithoutConfirmation);
 
-    if (!ReviewerAdder.isLegalReviewerGroup(group.getUUID())) {
+    if (!ReviewerModifier.isLegalReviewerGroup(group.getUUID())) {
       logger.atFine().log("Ignore group %s that is not legal as reviewer", group.getUUID());
       return result;
     }
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/Submit.java b/java/com/google/gerrit/server/restapi/change/Submit.java
index ba0720a..876c92c 100644
--- a/java/com/google/gerrit/server/restapi/change/Submit.java
+++ b/java/com/google/gerrit/server/restapi/change/Submit.java
@@ -102,14 +102,6 @@
   private static final String CLICK_FAILURE_TOOLTIP = "Clicking the button would fail";
   private static final String CHANGE_UNMERGEABLE = "Problems with integrating this change";
 
-  public static class Output {
-    transient Change change;
-
-    private Output(Change c) {
-      change = c;
-    }
-  }
-
   private final GitRepositoryManager repoManager;
   private final PermissionBackend permissionBackend;
   private final Provider<MergeOp> mergeOpProvider;
@@ -125,6 +117,7 @@
   private final Provider<InternalChangeQuery> queryProvider;
   private final PatchSetUtil psUtil;
   private final ProjectCache projectCache;
+  private final ChangeJson.Factory json;
 
   @Inject
   Submit(
@@ -136,7 +129,8 @@
       @GerritServerConfig Config cfg,
       Provider<InternalChangeQuery> queryProvider,
       PatchSetUtil psUtil,
-      ProjectCache projectCache) {
+      ProjectCache projectCache,
+      ChangeJson.Factory json) {
     this.repoManager = repoManager;
     this.permissionBackend = permissionBackend;
     this.mergeOpProvider = mergeOpProvider;
@@ -170,10 +164,11 @@
     this.queryProvider = queryProvider;
     this.psUtil = psUtil;
     this.projectCache = projectCache;
+    this.json = json;
   }
 
   @Override
-  public Response<Output> apply(RevisionResource rsrc, SubmitInput input)
+  public Response<ChangeInfo> apply(RevisionResource rsrc, SubmitInput input)
       throws RestApiException, RepositoryNotFoundException, IOException, PermissionBackendException,
           UpdateException, ConfigInvalidException {
     input.onBehalfOf = Strings.emptyToNull(input.onBehalfOf);
@@ -189,12 +184,11 @@
         .orElseThrow(illegalState(rsrc.getProject()))
         .checkStatePermitsWrite();
 
-    return mergeChange(rsrc, submitter, input);
+    return Response.ok(json.noOptions().format(mergeChange(rsrc, submitter, input)));
   }
 
   @UsedAt(UsedAt.Project.GOOGLE)
-  public Response<Output> mergeChange(
-      RevisionResource rsrc, IdentifiedUser submitter, SubmitInput input)
+  public Change mergeChange(RevisionResource rsrc, IdentifiedUser submitter, SubmitInput input)
       throws RestApiException, IOException, UpdateException, ConfigInvalidException,
           PermissionBackendException {
     Change change = rsrc.getChange();
@@ -215,7 +209,7 @@
 
       updatedChange = op.merge(change, submitter, true, input, false);
       if (updatedChange.isMerged()) {
-        return Response.ok(new Output(updatedChange));
+        return updatedChange;
       }
 
       throw new IllegalStateException(
@@ -290,22 +284,17 @@
   }
 
   @Override
-  public UiAction.Description getDescription(RevisionResource resource) {
+  public UiAction.Description getDescription(RevisionResource resource)
+      throws IOException, PermissionBackendException {
     Change change = resource.getChange();
     if (!change.isNew() || !resource.isCurrent()) {
       return null; // submit not visible
     }
-
-    try {
-      if (!projectCache
-          .get(resource.getProject())
-          .map(ProjectState::statePermitsWrite)
-          .orElse(false)) {
-        return null; // submit not visible
-      }
-    } catch (StorageException e) {
-      logger.atSevere().withCause(e).log("Error checking if change is submittable");
-      throw new StorageException("Could not determine problems for the change", e);
+    if (!projectCache
+        .get(resource.getProject())
+        .map(ProjectState::statePermitsWrite)
+        .orElse(false)) {
+      return null; // submit not visible
     }
 
     ChangeData cd = resource.getChangeResource().getChangeData();
@@ -315,13 +304,7 @@
       return null; // submit not visible
     }
 
-    ChangeSet cs;
-    try {
-      cs = mergeSuperSet.get().completeChangeSet(cd.change(), resource.getUser());
-    } catch (IOException | PermissionBackendException e) {
-      throw new StorageException("Could not determine complete set of changes to be submitted", e);
-    }
-
+    ChangeSet cs = mergeSuperSet.get().completeChangeSet(cd.change(), resource.getUser());
     String topic = change.getTopic();
     int topicSize = 0;
     if (!Strings.isNullOrEmpty(topic)) {
@@ -471,13 +454,11 @@
 
   public static class CurrentRevision implements RestModifyView<ChangeResource, SubmitInput> {
     private final Submit submit;
-    private final ChangeJson.Factory json;
     private final PatchSetUtil psUtil;
 
     @Inject
-    CurrentRevision(Submit submit, ChangeJson.Factory json, PatchSetUtil psUtil) {
+    CurrentRevision(Submit submit, PatchSetUtil psUtil) {
       this.submit = submit;
-      this.json = json;
       this.psUtil = psUtil;
     }
 
@@ -488,8 +469,7 @@
         throw new ResourceConflictException("current revision is missing");
       }
 
-      Response<Output> response = submit.apply(new RevisionResource(rsrc, ps), input);
-      return Response.ok(json.noOptions().format(response.value().change));
+      return submit.apply(new RevisionResource(rsrc, ps), input);
     }
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/SuggestReviewers.java b/java/com/google/gerrit/server/restapi/change/SuggestReviewers.java
index 71ff493..74f5290 100644
--- a/java/com/google/gerrit/server/restapi/change/SuggestReviewers.java
+++ b/java/com/google/gerrit/server/restapi/change/SuggestReviewers.java
@@ -18,7 +18,7 @@
 
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.extensions.common.AccountVisibility;
-import com.google.gerrit.server.change.ReviewerAdder;
+import com.google.gerrit.server.change.ReviewerModifier;
 import com.google.gerrit.server.config.ConfigKey;
 import com.google.gerrit.server.config.GerritConfigListener;
 import com.google.gerrit.server.config.GerritServerConfig;
@@ -99,12 +99,13 @@
       this.suggestAccounts = (av != AccountVisibility.NONE);
     }
 
-    this.maxAllowed = cfg.getInt("addreviewer", "maxAllowed", ReviewerAdder.DEFAULT_MAX_REVIEWERS);
+    this.maxAllowed =
+        cfg.getInt("addreviewer", "maxAllowed", ReviewerModifier.DEFAULT_MAX_REVIEWERS);
     this.maxAllowedWithoutConfirmation =
         cfg.getInt(
             "addreviewer",
             "maxWithoutConfirmation",
-            ReviewerAdder.DEFAULT_MAX_REVIEWERS_WITHOUT_CHECK);
+            ReviewerModifier.DEFAULT_MAX_REVIEWERS_WITHOUT_CHECK);
 
     logger.atFine().log("AccountVisibility: %s", av.name());
 
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/group/AddMembers.java b/java/com/google/gerrit/server/restapi/group/AddMembers.java
index 700a2ab..f44abec 100644
--- a/java/com/google/gerrit/server/restapi/group/AddMembers.java
+++ b/java/com/google/gerrit/server/restapi/group/AddMembers.java
@@ -46,8 +46,8 @@
 import com.google.gerrit.server.config.AuthConfig;
 import com.google.gerrit.server.group.GroupResource;
 import com.google.gerrit.server.group.MemberResource;
+import com.google.gerrit.server.group.db.GroupDelta;
 import com.google.gerrit.server.group.db.GroupsUpdate;
-import com.google.gerrit.server.group.db.InternalGroupUpdate;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.restapi.group.AddMembers.Input;
 import com.google.inject.Inject;
@@ -177,11 +177,11 @@
 
   public void addMembers(AccountGroup.UUID groupUuid, Set<Account.Id> newMemberIds)
       throws IOException, NoSuchGroupException, ConfigInvalidException {
-    InternalGroupUpdate groupUpdate =
-        InternalGroupUpdate.builder()
+    GroupDelta groupDelta =
+        GroupDelta.builder()
             .setMemberModification(memberIds -> Sets.union(memberIds, newMemberIds))
             .build();
-    groupsUpdateProvider.get().updateGroup(groupUuid, groupUpdate);
+    groupsUpdateProvider.get().updateGroup(groupUuid, groupDelta);
   }
 
   private Optional<Account> createAccountByLdap(String user) throws IOException {
diff --git a/java/com/google/gerrit/server/restapi/group/AddSubgroups.java b/java/com/google/gerrit/server/restapi/group/AddSubgroups.java
index 23fa73d..df854ecd2c 100644
--- a/java/com/google/gerrit/server/restapi/group/AddSubgroups.java
+++ b/java/com/google/gerrit/server/restapi/group/AddSubgroups.java
@@ -35,8 +35,8 @@
 import com.google.gerrit.server.group.GroupResolver;
 import com.google.gerrit.server.group.GroupResource;
 import com.google.gerrit.server.group.SubgroupResource;
+import com.google.gerrit.server.group.db.GroupDelta;
 import com.google.gerrit.server.group.db.GroupsUpdate;
-import com.google.gerrit.server.group.db.InternalGroupUpdate;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.restapi.group.AddSubgroups.Input;
 import com.google.inject.Inject;
@@ -124,11 +124,11 @@
   private void addSubgroups(
       AccountGroup.UUID parentGroupUuid, Set<AccountGroup.UUID> newSubgroupUuids)
       throws NoSuchGroupException, IOException, ConfigInvalidException {
-    InternalGroupUpdate groupUpdate =
-        InternalGroupUpdate.builder()
+    GroupDelta groupDelta =
+        GroupDelta.builder()
             .setSubgroupModification(subgroupUuids -> Sets.union(subgroupUuids, newSubgroupUuids))
             .build();
-    groupsUpdateProvider.get().updateGroup(parentGroupUuid, groupUpdate);
+    groupsUpdateProvider.get().updateGroup(parentGroupUuid, groupDelta);
   }
 
   @Singleton
diff --git a/java/com/google/gerrit/server/restapi/group/CreateGroup.java b/java/com/google/gerrit/server/restapi/group/CreateGroup.java
index 0ec63ba..ee86010 100644
--- a/java/com/google/gerrit/server/restapi/group/CreateGroup.java
+++ b/java/com/google/gerrit/server/restapi/group/CreateGroup.java
@@ -48,9 +48,9 @@
 import com.google.gerrit.server.group.GroupResource;
 import com.google.gerrit.server.group.InternalGroupDescription;
 import com.google.gerrit.server.group.SystemGroupBackend;
+import com.google.gerrit.server.group.db.GroupDelta;
 import com.google.gerrit.server.group.db.GroupsUpdate;
 import com.google.gerrit.server.group.db.InternalGroupCreation;
-import com.google.gerrit.server.group.db.InternalGroupUpdate;
 import com.google.gerrit.server.notedb.Sequences;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.plugincontext.PluginSetContext;
@@ -219,19 +219,19 @@
             .setNameKey(createGroupArgs.getGroup())
             .setId(groupId)
             .build();
-    InternalGroupUpdate.Builder groupUpdateBuilder =
-        InternalGroupUpdate.builder().setVisibleToAll(createGroupArgs.visibleToAll);
+    GroupDelta.Builder groupDeltaBuilder =
+        GroupDelta.builder().setVisibleToAll(createGroupArgs.visibleToAll);
     if (createGroupArgs.ownerGroupUuid != null) {
       Optional<InternalGroup> ownerGroup = groupCache.get(createGroupArgs.ownerGroupUuid);
-      ownerGroup.map(InternalGroup::getGroupUUID).ifPresent(groupUpdateBuilder::setOwnerGroupUUID);
+      ownerGroup.map(InternalGroup::getGroupUUID).ifPresent(groupDeltaBuilder::setOwnerGroupUUID);
     }
     if (createGroupArgs.groupDescription != null) {
-      groupUpdateBuilder.setDescription(createGroupArgs.groupDescription);
+      groupDeltaBuilder.setDescription(createGroupArgs.groupDescription);
     }
-    groupUpdateBuilder.setMemberModification(
+    groupDeltaBuilder.setMemberModification(
         members -> ImmutableSet.copyOf(createGroupArgs.initialMembers));
     try {
-      return groupsUpdateProvider.get().createGroup(groupCreation, groupUpdateBuilder.build());
+      return groupsUpdateProvider.get().createGroup(groupCreation, groupDeltaBuilder.build());
     } catch (DuplicateKeyException e) {
       throw new ResourceConflictException(
           "group '" + createGroupArgs.getGroupName() + "' already exists", e);
diff --git a/java/com/google/gerrit/server/restapi/group/DeleteMembers.java b/java/com/google/gerrit/server/restapi/group/DeleteMembers.java
index fa52a79..2386fce 100644
--- a/java/com/google/gerrit/server/restapi/group/DeleteMembers.java
+++ b/java/com/google/gerrit/server/restapi/group/DeleteMembers.java
@@ -31,8 +31,8 @@
 import com.google.gerrit.server.account.GroupControl;
 import com.google.gerrit.server.group.GroupResource;
 import com.google.gerrit.server.group.MemberResource;
+import com.google.gerrit.server.group.db.GroupDelta;
 import com.google.gerrit.server.group.db.GroupsUpdate;
-import com.google.gerrit.server.group.db.InternalGroupUpdate;
 import com.google.gerrit.server.restapi.group.AddMembers.Input;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -86,11 +86,11 @@
 
   private void removeGroupMembers(AccountGroup.UUID groupUuid, Set<Account.Id> accountIds)
       throws IOException, NoSuchGroupException, ConfigInvalidException {
-    InternalGroupUpdate groupUpdate =
-        InternalGroupUpdate.builder()
+    GroupDelta groupDelta =
+        GroupDelta.builder()
             .setMemberModification(memberIds -> Sets.difference(memberIds, accountIds))
             .build();
-    groupsUpdateProvider.get().updateGroup(groupUuid, groupUpdate);
+    groupsUpdateProvider.get().updateGroup(groupUuid, groupDelta);
   }
 
   @Singleton
diff --git a/java/com/google/gerrit/server/restapi/group/DeleteSubgroups.java b/java/com/google/gerrit/server/restapi/group/DeleteSubgroups.java
index fe67635..bc63035 100644
--- a/java/com/google/gerrit/server/restapi/group/DeleteSubgroups.java
+++ b/java/com/google/gerrit/server/restapi/group/DeleteSubgroups.java
@@ -30,8 +30,8 @@
 import com.google.gerrit.server.group.GroupResolver;
 import com.google.gerrit.server.group.GroupResource;
 import com.google.gerrit.server.group.SubgroupResource;
+import com.google.gerrit.server.group.db.GroupDelta;
 import com.google.gerrit.server.group.db.GroupsUpdate;
-import com.google.gerrit.server.group.db.InternalGroupUpdate;
 import com.google.gerrit.server.restapi.group.AddSubgroups.Input;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -86,12 +86,12 @@
   private void removeSubgroups(
       AccountGroup.UUID parentGroupUuid, Set<AccountGroup.UUID> removedSubgroupUuids)
       throws NoSuchGroupException, IOException, ConfigInvalidException {
-    InternalGroupUpdate groupUpdate =
-        InternalGroupUpdate.builder()
+    GroupDelta groupDelta =
+        GroupDelta.builder()
             .setSubgroupModification(
                 subgroupUuids -> Sets.difference(subgroupUuids, removedSubgroupUuids))
             .build();
-    groupsUpdateProvider.get().updateGroup(parentGroupUuid, groupUpdate);
+    groupsUpdateProvider.get().updateGroup(parentGroupUuid, groupDelta);
   }
 
   @Singleton
diff --git a/java/com/google/gerrit/server/restapi/group/ListGroups.java b/java/com/google/gerrit/server/restapi/group/ListGroups.java
index 96402be..854f091 100644
--- a/java/com/google/gerrit/server/restapi/group/ListGroups.java
+++ b/java/com/google/gerrit/server/restapi/group/ListGroups.java
@@ -186,8 +186,8 @@
   }
 
   @Option(name = "-O", usage = "Output option flags, in hex")
-  void setOptionFlagsHex(String hex) {
-    options.addAll(ListOption.fromBits(ListGroupsOption.class, Integer.parseInt(hex, 16)));
+  void setOptionFlagsHex(String hex) throws BadRequestException {
+    options.addAll(ListOption.fromHexString(ListGroupsOption.class, hex));
   }
 
   @Option(
diff --git a/java/com/google/gerrit/server/restapi/group/PutDescription.java b/java/com/google/gerrit/server/restapi/group/PutDescription.java
index 942e680..0c3ff06 100644
--- a/java/com/google/gerrit/server/restapi/group/PutDescription.java
+++ b/java/com/google/gerrit/server/restapi/group/PutDescription.java
@@ -25,8 +25,8 @@
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.server.UserInitiated;
 import com.google.gerrit.server.group.GroupResource;
+import com.google.gerrit.server.group.db.GroupDelta;
 import com.google.gerrit.server.group.db.GroupsUpdate;
-import com.google.gerrit.server.group.db.InternalGroupUpdate;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -61,10 +61,9 @@
     String newDescription = Strings.nullToEmpty(input.description);
     if (!Objects.equals(currentDescription, newDescription)) {
       AccountGroup.UUID groupUuid = internalGroup.getGroupUUID();
-      InternalGroupUpdate groupUpdate =
-          InternalGroupUpdate.builder().setDescription(newDescription).build();
+      GroupDelta groupDelta = GroupDelta.builder().setDescription(newDescription).build();
       try {
-        groupsUpdateProvider.get().updateGroup(groupUuid, groupUpdate);
+        groupsUpdateProvider.get().updateGroup(groupUuid, groupDelta);
       } catch (NoSuchGroupException e) {
         throw new ResourceNotFoundException(String.format("Group %s not found", groupUuid), e);
       }
diff --git a/java/com/google/gerrit/server/restapi/group/PutName.java b/java/com/google/gerrit/server/restapi/group/PutName.java
index acdae33..5246ade 100644
--- a/java/com/google/gerrit/server/restapi/group/PutName.java
+++ b/java/com/google/gerrit/server/restapi/group/PutName.java
@@ -28,8 +28,8 @@
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.server.UserInitiated;
 import com.google.gerrit.server.group.GroupResource;
+import com.google.gerrit.server.group.db.GroupDelta;
 import com.google.gerrit.server.group.db.GroupsUpdate;
-import com.google.gerrit.server.group.db.InternalGroupUpdate;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -74,10 +74,9 @@
       throws ResourceConflictException, ResourceNotFoundException, IOException,
           ConfigInvalidException {
     AccountGroup.UUID groupUuid = group.getGroupUUID();
-    InternalGroupUpdate groupUpdate =
-        InternalGroupUpdate.builder().setName(AccountGroup.nameKey(newName)).build();
+    GroupDelta groupDelta = GroupDelta.builder().setName(AccountGroup.nameKey(newName)).build();
     try {
-      groupsUpdateProvider.get().updateGroup(groupUuid, groupUpdate);
+      groupsUpdateProvider.get().updateGroup(groupUuid, groupDelta);
     } catch (NoSuchGroupException e) {
       throw new ResourceNotFoundException(String.format("Group %s not found", groupUuid), e);
     } catch (DuplicateKeyException e) {
diff --git a/java/com/google/gerrit/server/restapi/group/PutOptions.java b/java/com/google/gerrit/server/restapi/group/PutOptions.java
index 748861e..926203f 100644
--- a/java/com/google/gerrit/server/restapi/group/PutOptions.java
+++ b/java/com/google/gerrit/server/restapi/group/PutOptions.java
@@ -25,8 +25,8 @@
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.server.UserInitiated;
 import com.google.gerrit.server.group.GroupResource;
+import com.google.gerrit.server.group.db.GroupDelta;
 import com.google.gerrit.server.group.db.GroupsUpdate;
-import com.google.gerrit.server.group.db.InternalGroupUpdate;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -61,10 +61,9 @@
 
     if (internalGroup.isVisibleToAll() != input.visibleToAll) {
       AccountGroup.UUID groupUuid = internalGroup.getGroupUUID();
-      InternalGroupUpdate groupUpdate =
-          InternalGroupUpdate.builder().setVisibleToAll(input.visibleToAll).build();
+      GroupDelta groupDelta = GroupDelta.builder().setVisibleToAll(input.visibleToAll).build();
       try {
-        groupsUpdateProvider.get().updateGroup(groupUuid, groupUpdate);
+        groupsUpdateProvider.get().updateGroup(groupUuid, groupDelta);
       } catch (NoSuchGroupException e) {
         throw new ResourceNotFoundException(String.format("Group %s not found", groupUuid), e);
       }
diff --git a/java/com/google/gerrit/server/restapi/group/PutOwner.java b/java/com/google/gerrit/server/restapi/group/PutOwner.java
index 96ce9e4..45acd68 100644
--- a/java/com/google/gerrit/server/restapi/group/PutOwner.java
+++ b/java/com/google/gerrit/server/restapi/group/PutOwner.java
@@ -29,8 +29,8 @@
 import com.google.gerrit.server.UserInitiated;
 import com.google.gerrit.server.group.GroupResolver;
 import com.google.gerrit.server.group.GroupResource;
+import com.google.gerrit.server.group.db.GroupDelta;
 import com.google.gerrit.server.group.db.GroupsUpdate;
-import com.google.gerrit.server.group.db.InternalGroupUpdate;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -72,10 +72,9 @@
     GroupDescription.Basic owner = groupResolver.parse(input.owner);
     if (!internalGroup.getOwnerGroupUUID().equals(owner.getGroupUUID())) {
       AccountGroup.UUID groupUuid = internalGroup.getGroupUUID();
-      InternalGroupUpdate groupUpdate =
-          InternalGroupUpdate.builder().setOwnerGroupUUID(owner.getGroupUUID()).build();
+      GroupDelta groupDelta = GroupDelta.builder().setOwnerGroupUUID(owner.getGroupUUID()).build();
       try {
-        groupsUpdateProvider.get().updateGroup(groupUuid, groupUpdate);
+        groupsUpdateProvider.get().updateGroup(groupUuid, groupDelta);
       } catch (NoSuchGroupException e) {
         throw new ResourceNotFoundException(String.format("Group %s not found", groupUuid), e);
       }
diff --git a/java/com/google/gerrit/server/restapi/group/QueryGroups.java b/java/com/google/gerrit/server/restapi/group/QueryGroups.java
index 26e8459..befccfe 100644
--- a/java/com/google/gerrit/server/restapi/group/QueryGroups.java
+++ b/java/com/google/gerrit/server/restapi/group/QueryGroups.java
@@ -80,8 +80,8 @@
   }
 
   @Option(name = "-O", usage = "Output option flags, in hex")
-  public void setOptionFlagsHex(String hex) {
-    options.addAll(ListOption.fromBits(ListGroupsOption.class, Integer.parseInt(hex, 16)));
+  public void setOptionFlagsHex(String hex) throws BadRequestException {
+    options.addAll(ListOption.fromHexString(ListGroupsOption.class, hex));
   }
 
   @Inject
diff --git a/java/com/google/gerrit/server/restapi/project/CommitsCollection.java b/java/com/google/gerrit/server/restapi/project/CommitsCollection.java
index 033463c..ae7f540 100644
--- a/java/com/google/gerrit/server/restapi/project/CommitsCollection.java
+++ b/java/com/google/gerrit/server/restapi/project/CommitsCollection.java
@@ -34,8 +34,7 @@
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.project.Reachable;
 import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gerrit.server.query.change.CommitPredicate;
-import com.google.gerrit.server.query.change.ProjectPredicate;
+import com.google.gerrit.server.query.change.ChangePredicates;
 import com.google.gerrit.server.update.RetryHelper;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -149,10 +148,10 @@
     // branches to check, by seeing if its parents were associated to changes.
     Predicate<ChangeData> pred =
         Predicate.and(
-            new ProjectPredicate(project.get()),
+            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/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 e670dc2..f5709e4 100644
--- a/java/com/google/gerrit/server/rules/DefaultSubmitRule.java
+++ b/java/com/google/gerrit/server/rules/DefaultSubmitRule.java
@@ -16,7 +16,6 @@
 
 import static com.google.common.base.Preconditions.checkState;
 import static com.google.common.collect.ImmutableList.toImmutableList;
-import static com.google.gerrit.server.project.ProjectCache.illegalState;
 
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.LabelFunction;
@@ -26,7 +25,6 @@
 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.project.ProjectState;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -64,15 +62,6 @@
 
   @Override
   public Optional<SubmitRecord> evaluate(ChangeData cd) {
-    ProjectState projectState =
-        projectCache.get(cd.project()).orElseThrow(illegalState(cd.project()));
-
-    // In case at least one project has a rules.pl file, we let Prolog handle it.
-    // The Prolog rules engine will also handle the labels for us.
-    if (projectState.hasPrologRules()) {
-      return Optional.empty();
-    }
-
     SubmitRecord submitRecord = new SubmitRecord();
     submitRecord.status = SubmitRecord.Status.OK;
 
diff --git a/java/com/google/gerrit/server/schema/SchemaCreatorImpl.java b/java/com/google/gerrit/server/schema/SchemaCreatorImpl.java
index bda3dc4..26ae4a8 100644
--- a/java/com/google/gerrit/server/schema/SchemaCreatorImpl.java
+++ b/java/com/google/gerrit/server/schema/SchemaCreatorImpl.java
@@ -32,9 +32,9 @@
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
 import com.google.gerrit.server.group.db.AuditLogFormatter;
 import com.google.gerrit.server.group.db.GroupConfig;
+import com.google.gerrit.server.group.db.GroupDelta;
 import com.google.gerrit.server.group.db.GroupNameNotes;
 import com.google.gerrit.server.group.db.InternalGroupCreation;
-import com.google.gerrit.server.group.db.InternalGroupUpdate;
 import com.google.gerrit.server.index.group.GroupIndex;
 import com.google.gerrit.server.index.group.GroupIndexCollection;
 import com.google.gerrit.server.notedb.Sequences;
@@ -132,10 +132,10 @@
       Sequences seqs, Repository allUsersRepo, GroupReference groupReference)
       throws IOException, ConfigInvalidException {
     InternalGroupCreation groupCreation = getGroupCreation(seqs, groupReference);
-    InternalGroupUpdate groupUpdate =
-        InternalGroupUpdate.builder().setDescription("Gerrit Site Administrators").build();
+    GroupDelta groupDelta =
+        GroupDelta.builder().setDescription("Gerrit Site Administrators").build();
 
-    createGroup(allUsersRepo, groupCreation, groupUpdate);
+    createGroup(allUsersRepo, groupCreation, groupDelta);
   }
 
   private void createBatchUsersGroup(
@@ -145,24 +145,24 @@
       AccountGroup.UUID adminsGroupUuid)
       throws IOException, ConfigInvalidException {
     InternalGroupCreation groupCreation = getGroupCreation(seqs, groupReference);
-    InternalGroupUpdate groupUpdate =
-        InternalGroupUpdate.builder()
+    GroupDelta groupDelta =
+        GroupDelta.builder()
             .setDescription("Users who perform batch actions on Gerrit")
             .setOwnerGroupUUID(adminsGroupUuid)
             .build();
 
-    createGroup(allUsersRepo, groupCreation, groupUpdate);
+    createGroup(allUsersRepo, groupCreation, groupDelta);
   }
 
   private void createGroup(
-      Repository allUsersRepo, InternalGroupCreation groupCreation, InternalGroupUpdate groupUpdate)
+      Repository allUsersRepo, InternalGroupCreation groupCreation, GroupDelta groupDelta)
       throws ConfigInvalidException, IOException {
-    InternalGroup createdGroup = createGroupInNoteDb(allUsersRepo, groupCreation, groupUpdate);
+    InternalGroup createdGroup = createGroupInNoteDb(allUsersRepo, groupCreation, groupDelta);
     index(createdGroup);
   }
 
   private InternalGroup createGroupInNoteDb(
-      Repository allUsersRepo, InternalGroupCreation groupCreation, InternalGroupUpdate groupUpdate)
+      Repository allUsersRepo, InternalGroupCreation groupCreation, GroupDelta groupDelta)
       throws ConfigInvalidException, IOException, DuplicateKeyException {
     // This method is only executed on a new server which doesn't have any accounts or groups.
     AuditLogFormatter auditLogFormatter =
@@ -170,9 +170,9 @@
 
     GroupConfig groupConfig =
         GroupConfig.createForNewGroup(allUsersName, allUsersRepo, groupCreation);
-    groupConfig.setGroupUpdate(groupUpdate, auditLogFormatter);
+    groupConfig.setGroupDelta(groupDelta, auditLogFormatter);
 
-    AccountGroup.NameKey groupName = groupUpdate.getName().orElseGet(groupCreation::getNameKey);
+    AccountGroup.NameKey groupName = groupDelta.getName().orElseGet(groupCreation::getNameKey);
     GroupNameNotes groupNameNotes =
         GroupNameNotes.forNewGroup(
             allUsersName, allUsersRepo, groupCreation.getGroupUUID(), groupName);
diff --git a/java/com/google/gerrit/server/schema/Schema_184.java b/java/com/google/gerrit/server/schema/Schema_184.java
index 85d4740..436c57b 100644
--- a/java/com/google/gerrit/server/schema/Schema_184.java
+++ b/java/com/google/gerrit/server/schema/Schema_184.java
@@ -25,8 +25,8 @@
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
 import com.google.gerrit.server.group.db.AuditLogFormatter;
 import com.google.gerrit.server.group.db.GroupConfig;
+import com.google.gerrit.server.group.db.GroupDelta;
 import com.google.gerrit.server.group.db.GroupNameNotes;
-import com.google.gerrit.server.group.db.InternalGroupUpdate;
 import com.google.gerrit.server.index.group.GroupIndex;
 import com.google.gerrit.server.index.group.GroupIndexCollection;
 import java.io.IOException;
@@ -64,8 +64,8 @@
       GroupConfig groupConfig =
           GroupConfig.loadForGroup(
               args.allUsers, allUsersRepo, nonInteractiveUsers.get().getUUID());
-      groupConfig.setGroupUpdate(
-          InternalGroupUpdate.builder().setName(newName).build(),
+      groupConfig.setGroupDelta(
+          GroupDelta.builder().setName(newName).build(),
           AuditLogFormatter.createPartiallyWorkingFallBack());
       commit(args.allUsers, args.serverUser, allUsersRepo, groupConfig, newNameNotes);
       index(
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/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/LocalMergeSuperSetComputation.java b/java/com/google/gerrit/server/submit/LocalMergeSuperSetComputation.java
index 0b05607..547c946 100644
--- a/java/com/google/gerrit/server/submit/LocalMergeSuperSetComputation.java
+++ b/java/com/google/gerrit/server/submit/LocalMergeSuperSetComputation.java
@@ -22,20 +22,15 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.UsedAt;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.SubmitTypeRecord;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.registration.DynamicItem;
-import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.permissions.ChangePermission;
-import com.google.gerrit.server.permissions.PermissionBackend;
-import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.NoSuchProjectException;
-import com.google.gerrit.server.project.ProjectCache;
-import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.ChangeIsVisibleToPredicate;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
@@ -84,21 +79,15 @@
     abstract ImmutableSet<String> hashes();
   }
 
-  private final PermissionBackend permissionBackend;
   private final Provider<InternalChangeQuery> queryProvider;
   private final Map<QueryKey, ImmutableList<ChangeData>> queryCache;
   private final Map<BranchNameKey, Optional<RevCommit>> heads;
-  private final ProjectCache projectCache;
   private final ChangeIsVisibleToPredicate.Factory changeIsVisibleToPredicateFactory;
 
   @Inject
   LocalMergeSuperSetComputation(
-      PermissionBackend permissionBackend,
       Provider<InternalChangeQuery> queryProvider,
-      ProjectCache projectCache,
       ChangeIsVisibleToPredicate.Factory changeIsVisibleToPredicateFactory) {
-    this.projectCache = projectCache;
-    this.permissionBackend = permissionBackend;
     this.queryProvider = queryProvider;
     this.queryCache = new HashMap<>();
     this.heads = new HashMap<>();
@@ -107,51 +96,46 @@
 
   @Override
   public ChangeSet completeWithoutTopic(
-      MergeOpRepoManager orm, ChangeSet changeSet, CurrentUser user)
-      throws IOException, PermissionBackendException {
+      MergeOpRepoManager orm, ChangeSet changeSet, CurrentUser user) throws IOException {
     Collection<ChangeData> visibleChanges = new ArrayList<>();
     Collection<ChangeData> nonVisibleChanges = new ArrayList<>();
 
     // For each target branch we run a separate rev walk to find open changes
     // reachable from changes already in the merge super set.
-    ImmutableListMultimap<BranchNameKey, ChangeData> bc =
-        byBranch(Iterables.concat(changeSet.changes(), changeSet.nonVisibleChanges()));
-    for (BranchNameKey b : bc.keySet()) {
-      OpenRepo or = getRepo(orm, b.project());
+    ImmutableSet<BranchNameKey> branches =
+        byBranch(Iterables.concat(changeSet.changes(), changeSet.nonVisibleChanges())).keySet();
+    ImmutableListMultimap<BranchNameKey, ChangeData> visibleChangesPerBranch =
+        byBranch(changeSet.changes());
+    ImmutableListMultimap<BranchNameKey, ChangeData> nonVisibleChangesPerBranch =
+        byBranch(changeSet.nonVisibleChanges());
+
+    for (BranchNameKey branchNameKey : branches) {
+      OpenRepo or = getRepo(orm, branchNameKey.project());
       List<RevCommit> visibleCommits = new ArrayList<>();
       List<RevCommit> nonVisibleCommits = new ArrayList<>();
-      for (ChangeData cd : bc.get(b)) {
-        boolean visible = isVisible(changeSet, cd, user);
 
+      for (ChangeData cd : visibleChangesPerBranch.get(branchNameKey)) {
         if (submitType(cd) == SubmitType.CHERRY_PICK) {
-          if (visible) {
-            visibleChanges.add(cd);
-          } else {
-            nonVisibleChanges.add(cd);
-          }
-
-          continue;
-        }
-
-        // Get the underlying git commit object
-        RevCommit commit = or.rw.parseCommit(cd.currentPatchSet().commitId());
-
-        // Always include the input, even if merged. This allows
-        // SubmitStrategyOp to correct the situation later, assuming it gets
-        // returned by byCommitsOnBranchNotMerged below.
-        if (visible) {
-          visibleCommits.add(commit);
+          visibleChanges.add(cd);
         } else {
-          nonVisibleCommits.add(commit);
+          visibleCommits.add(or.rw.parseCommit(cd.currentPatchSet().commitId()));
+        }
+      }
+      for (ChangeData cd : nonVisibleChangesPerBranch.get(branchNameKey)) {
+        if (submitType(cd) == SubmitType.CHERRY_PICK) {
+          nonVisibleChanges.add(cd);
+        } else {
+          nonVisibleCommits.add(or.rw.parseCommit(cd.currentPatchSet().commitId()));
         }
       }
 
       Set<String> visibleHashes =
-          walkChangesByHashes(visibleCommits, Collections.emptySet(), or, b);
-      Set<String> nonVisibleHashes = walkChangesByHashes(nonVisibleCommits, visibleHashes, or, b);
+          walkChangesByHashes(visibleCommits, Collections.emptySet(), or, branchNameKey);
+      Set<String> nonVisibleHashes =
+          walkChangesByHashes(nonVisibleCommits, visibleHashes, or, branchNameKey);
 
       ChangeSet partialSet =
-          byCommitsOnBranchNotMerged(or, b, visibleHashes, nonVisibleHashes, user);
+          byCommitsOnBranchNotMerged(or, branchNameKey, visibleHashes, nonVisibleHashes, user);
       Iterables.addAll(visibleChanges, partialSet.changes());
       Iterables.addAll(nonVisibleChanges, partialSet.nonVisibleChanges());
     }
@@ -179,26 +163,6 @@
     }
   }
 
-  private boolean isVisible(ChangeSet changeSet, ChangeData cd, CurrentUser user)
-      throws PermissionBackendException {
-    boolean statePermitsRead =
-        projectCache.get(cd.project()).map(ProjectState::statePermitsRead).orElse(false);
-    boolean visible = statePermitsRead && changeSet.ids().contains(cd.getId());
-    if (!visible) {
-      return false;
-    }
-
-    try {
-      permissionBackend.user(user).change(cd).check(ChangePermission.READ);
-      return true;
-    } catch (AuthException e) {
-      // We thought the change was visible, but it isn't.
-      // This can happen if the ACL changes during the
-      // completeChangeSet computation, for example.
-      return false;
-    }
-  }
-
   private SubmitType submitType(ChangeData cd) {
     SubmitTypeRecord str = cd.submitTypeRecord();
     if (!str.isOk()) {
@@ -207,7 +171,8 @@
     return str.type;
   }
 
-  private ChangeSet byCommitsOnBranchNotMerged(
+  @UsedAt(UsedAt.Project.GOOGLE)
+  public ChangeSet byCommitsOnBranchNotMerged(
       OpenRepo or,
       BranchNameKey branch,
       Set<String> visibleHashes,
@@ -222,7 +187,9 @@
     ChangeIsVisibleToPredicate changeIsVisibleToPredicate =
         changeIsVisibleToPredicateFactory.forUser(user);
     for (ChangeData cd : potentiallyVisibleChanges) {
-      if (changeIsVisibleToPredicate.match(cd)) {
+      // short circuit permission checks for non-private changes, as we already checked all
+      // permissions (except for private changes).
+      if (!cd.change().isPrivate() || changeIsVisibleToPredicate.match(cd)) {
         visibleChanges.add(cd);
       } else {
         invisibleChanges.add(cd);
@@ -247,7 +214,8 @@
     return result;
   }
 
-  private Set<String> walkChangesByHashes(
+  @UsedAt(UsedAt.Project.GOOGLE)
+  public Set<String> walkChangesByHashes(
       Collection<RevCommit> sourceCommits, Set<String> ignoreHashes, OpenRepo or, BranchNameKey b)
       throws IOException {
     Set<String> destHashes = new HashSet<>();
diff --git a/java/com/google/gerrit/server/submit/MergeOp.java b/java/com/google/gerrit/server/submit/MergeOp.java
index a8a8675..2b4fb3b 100644
--- a/java/com/google/gerrit/server/submit/MergeOp.java
+++ b/java/com/google/gerrit/server/submit/MergeOp.java
@@ -36,7 +36,6 @@
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Change.Status;
-import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.entities.LegacySubmitRequirement;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
@@ -963,14 +962,8 @@
 
                   change.setStatus(Change.Status.ABANDONED);
 
-                  ChangeMessage msg =
-                      ChangeMessagesUtil.newMessage(
-                          change.currentPatchSetId(),
-                          internalUserFactory.create(),
-                          change.getLastUpdatedOn(),
-                          "Project was deleted.",
-                          ChangeMessagesUtil.TAG_MERGED);
-                  cmUtil.addChangeMessage(ctx.getUpdate(change.currentPatchSetId()), msg);
+                  cmUtil.setChangeMessage(
+                      ctx, "Project was deleted.", ChangeMessagesUtil.TAG_MERGED);
 
                   return true;
                 }
diff --git a/java/com/google/gerrit/server/submit/RebaseSubmitStrategy.java b/java/com/google/gerrit/server/submit/RebaseSubmitStrategy.java
index db48cce..cc3b75d 100644
--- a/java/com/google/gerrit/server/submit/RebaseSubmitStrategy.java
+++ b/java/com/google/gerrit/server/submit/RebaseSubmitStrategy.java
@@ -35,7 +35,7 @@
 import com.google.gerrit.server.project.InvalidChangeOperationException;
 import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.update.ChangeContext;
-import com.google.gerrit.server.update.Context;
+import com.google.gerrit.server.update.PostUpdateContext;
 import com.google.gerrit.server.update.RepoContext;
 import java.io.IOException;
 import java.util.ArrayList;
@@ -270,7 +270,7 @@
     }
 
     @Override
-    public void postUpdateImpl(Context ctx) {
+    public void postUpdateImpl(PostUpdateContext ctx) {
       if (rebaseOp != null) {
         rebaseOp.postUpdate(ctx);
       }
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 69207ac..09470d4 100644
--- a/java/com/google/gerrit/server/submit/SubmitStrategyOp.java
+++ b/java/com/google/gerrit/server/submit/SubmitStrategyOp.java
@@ -24,7 +24,6 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetApproval;
@@ -33,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;
@@ -48,7 +47,7 @@
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
-import com.google.gerrit.server.update.Context;
+import com.google.gerrit.server.update.PostUpdateContext;
 import com.google.gerrit.server.update.RepoContext;
 import java.io.IOException;
 import java.util.ArrayList;
@@ -284,7 +283,7 @@
             // ChangeMergedEvent in the fixup case, but we'll just live with that.
             : alreadyMergedCommit;
     try {
-      setMerged(ctx, message(ctx, commit, s));
+      setMerged(ctx, commit, message(ctx, commit, s));
     } catch (StorageException err) {
       String msg = "Error updating change status for " + id;
       logger.atSevere().withCause(err).log(msg);
@@ -395,17 +394,17 @@
     }
   }
 
-  private ChangeMessage message(ChangeContext ctx, CodeReviewCommit commit, CommitMergeStatus s)
+  private String message(ChangeContext ctx, CodeReviewCommit commit, CommitMergeStatus s)
       throws AuthException, IOException, PermissionBackendException,
           InvalidChangeOperationException {
     requireNonNull(s, "CommitMergeStatus may not be null");
     String txt = s.getDescription();
     if (s == CommitMergeStatus.CLEAN_MERGE) {
-      return message(ctx, commit.getPatchsetId(), txt);
+      return message(ctx, txt);
     } else if (s == CommitMergeStatus.CLEAN_REBASE || s == CommitMergeStatus.CLEAN_PICK) {
-      return message(ctx, commit.getPatchsetId(), txt + " as " + commit.name());
+      return message(ctx, txt + " as " + commit.name());
     } else if (s == CommitMergeStatus.SKIPPED_IDENTICAL_TREE) {
-      return message(ctx, commit.getPatchsetId(), txt);
+      return message(ctx, txt);
     } else if (s == CommitMergeStatus.ALREADY_MERGED) {
       // Best effort to mimic the message that would have happened had this
       // succeeded the first time around.
@@ -437,19 +436,14 @@
     }
   }
 
-  private ChangeMessage message(ChangeContext ctx, PatchSet.Id psId, String body)
+  private String message(ChangeContext ctx, String body)
       throws AuthException, IOException, PermissionBackendException,
           InvalidChangeOperationException {
     stickyApprovalDiff = args.submitWithStickyApprovalDiff.apply(ctx.getNotes(), ctx.getUser());
-    return ChangeMessagesUtil.newMessage(
-        psId,
-        ctx.getUser(),
-        ctx.getWhen(),
-        body + stickyApprovalDiff,
-        ChangeMessagesUtil.TAG_MERGED);
+    return body + stickyApprovalDiff;
   }
 
-  private void setMerged(ChangeContext ctx, ChangeMessage msg) {
+  private void setMerged(ChangeContext ctx, CodeReviewCommit commit, String msg) {
     Change c = ctx.getChange();
     logger.atFine().log("Setting change %s merged", c.getId());
     c.setStatus(Change.Status.MERGED);
@@ -458,12 +452,13 @@
     // which is not the user from the update context. addMergedMessage was able
     // to do this in the past.
     if (msg != null) {
-      args.cmUtil.addChangeMessage(ctx.getUpdate(msg.getPatchSetId()), msg);
+      args.cmUtil.setChangeMessage(
+          ctx.getUpdate(commit.getPatchsetId()), msg, ChangeMessagesUtil.TAG_MERGED);
     }
   }
 
   @Override
-  public final void postUpdate(Context ctx) throws Exception {
+  public final void postUpdate(PostUpdateContext ctx) throws Exception {
     if (changeAlreadyMerged) {
       // TODO(dborowitz): This is suboptimal behavior in the presence of retries: postUpdate steps
       // will never get run for changes that submitted successfully on any but the final attempt.
@@ -518,7 +513,7 @@
     }
     if (mergeResultRev != null && !args.dryrun) {
       args.changeMerged.fire(
-          updatedChange,
+          ctx.getChangeData(updatedChange),
           mergedPatchSet,
           args.accountCache.get(submitter.accountId()).orElse(null),
           args.mergeTip.getCurrentTip().name(),
@@ -542,10 +537,10 @@
   }
 
   /**
-   * @see #postUpdate(Context)
+   * @see #postUpdate(PostUpdateContext)
    * @param ctx
    */
-  protected void postUpdateImpl(Context ctx) throws Exception {}
+  protected void postUpdateImpl(PostUpdateContext ctx) throws Exception {}
 
   /**
    * Amend the commit with gitlink update
diff --git a/java/com/google/gerrit/server/update/BatchUpdate.java b/java/com/google/gerrit/server/update/BatchUpdate.java
index 7fdf833..3b0cd9a 100644
--- a/java/com/google/gerrit/server/update/BatchUpdate.java
+++ b/java/com/google/gerrit/server/update/BatchUpdate.java
@@ -64,6 +64,7 @@
 import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.project.NoSuchProjectException;
 import com.google.gerrit.server.project.NoSuchRefException;
+import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
 import com.google.inject.Module;
 import com.google.inject.assistedinject.Assisted;
@@ -74,9 +75,11 @@
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.Objects;
 import java.util.Optional;
 import java.util.TimeZone;
 import java.util.TreeMap;
+import java.util.function.Function;
 import org.eclipse.jgit.lib.BatchRefUpdate;
 import org.eclipse.jgit.lib.ObjectInserter;
 import org.eclipse.jgit.lib.PersonIdent;
@@ -134,7 +137,7 @@
     checkDifferentProject(updates);
 
     try {
-      List<ListenableFuture<?>> indexFutures = new ArrayList<>();
+      List<ListenableFuture<ChangeData>> indexFutures = new ArrayList<>();
       List<ChangesHandle> changesHandles = new ArrayList<>(updates.size());
       try {
         for (BatchUpdate u : updates) {
@@ -156,7 +159,11 @@
         }
       }
 
-      ((ListenableFuture<?>) Futures.allAsList(indexFutures)).get();
+      Map<Change.Id, ChangeData> changeDatas =
+          Futures.allAsList(indexFutures).get().stream()
+              // filter out null values that were returned for change deletions
+              .filter(Objects::nonNull)
+              .collect(toMap(cd -> cd.change().getId(), Function.identity()));
 
       // Fire ref update events only after all mutations are finished, since callers may assume a
       // patch set ref being created means the change was created, or a branch advancing meaning
@@ -165,7 +172,7 @@
 
       if (!dryrun) {
         for (BatchUpdate u : updates) {
-          u.executePostOps();
+          u.executePostOps(changeDatas);
         }
       }
     } catch (Exception e) {
@@ -340,6 +347,19 @@
     }
   }
 
+  private class PostUpdateContextImpl extends ContextImpl implements PostUpdateContext {
+    private final Map<Change.Id, ChangeData> changeDatas;
+
+    PostUpdateContextImpl(Map<Change.Id, ChangeData> changeDatas) {
+      this.changeDatas = changeDatas;
+    }
+
+    @Override
+    public ChangeData getChangeData(Change change) {
+      return changeDatas.computeIfAbsent(change.getId(), id -> changeDataFactory.create(change));
+    }
+  }
+
   /** Per-change result status from {@link #executeChangeOps}. */
   private enum ChangeResult {
     SKIPPED,
@@ -348,6 +368,7 @@
   }
 
   private final GitRepositoryManager repoManager;
+  private final ChangeData.Factory changeDataFactory;
   private final ChangeNotes.Factory changeNotesFactory;
   private final ChangeUpdate.Factory changeUpdateFactory;
   private final NoteDbUpdateManager.Factory updateManagerFactory;
@@ -377,6 +398,7 @@
   BatchUpdate(
       GitRepositoryManager repoManager,
       @GerritPersonIdent PersonIdent serverIdent,
+      ChangeData.Factory changeDataFactory,
       ChangeNotes.Factory changeNotesFactory,
       ChangeUpdate.Factory changeUpdateFactory,
       NoteDbUpdateManager.Factory updateManagerFactory,
@@ -386,6 +408,7 @@
       @Assisted CurrentUser user,
       @Assisted Timestamp when) {
     this.repoManager = repoManager;
+    this.changeDataFactory = changeDataFactory;
     this.changeNotesFactory = changeNotesFactory;
     this.changeUpdateFactory = changeUpdateFactory;
     this.updateManagerFactory = updateManagerFactory;
@@ -589,12 +612,12 @@
       BatchUpdate.this.executed = manager.isExecuted();
     }
 
-    List<ListenableFuture<?>> startIndexFutures() {
+    List<ListenableFuture<ChangeData>> startIndexFutures() {
       if (dryrun) {
         return ImmutableList.of();
       }
       logDebug("Reindexing %d changes", results.size());
-      List<ListenableFuture<?>> indexFutures = new ArrayList<>(results.size());
+      List<ListenableFuture<ChangeData>> indexFutures = new ArrayList<>(results.size());
       for (Map.Entry<Change.Id, ChangeResult> e : results.entrySet()) {
         Change.Id id = e.getKey();
         switch (e.getValue()) {
@@ -687,8 +710,8 @@
     return new ChangeContextImpl(notes);
   }
 
-  private void executePostOps() throws Exception {
-    ContextImpl ctx = new ContextImpl();
+  private void executePostOps(Map<Change.Id, ChangeData> changeDatas) throws Exception {
+    PostUpdateContextImpl ctx = new PostUpdateContextImpl(changeDatas);
     for (BatchUpdateOp op : ops.values()) {
       try (TraceContext.TraceTimer ignored =
           TraceContext.newTimer(op.getClass().getSimpleName() + "#postUpdate", Metadata.empty())) {
diff --git a/java/com/google/gerrit/server/update/PostUpdateContext.java b/java/com/google/gerrit/server/update/PostUpdateContext.java
new file mode 100644
index 0000000..d4d1f62
--- /dev/null
+++ b/java/com/google/gerrit/server/update/PostUpdateContext.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.server.update;
+
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.query.change.ChangeData;
+
+/** Context for performing the {@link BatchUpdateOp#postUpdate} phase. */
+public interface PostUpdateContext extends Context {
+  /**
+   * Get the change data for the specified change.
+   *
+   * <p>If the change data has been computed previously, because the change has been indexed after
+   * an update or because this method has been invoked before, the cached change data instance is
+   * returned.
+   *
+   * @param change the change for which the change data should be returned
+   */
+  ChangeData getChangeData(Change change);
+
+  default ChangeData getChangeData(ChangeNotes changeNotes) {
+    return getChangeData(changeNotes.getChange());
+  }
+}
diff --git a/java/com/google/gerrit/server/update/RepoOnlyOp.java b/java/com/google/gerrit/server/update/RepoOnlyOp.java
index 7e9c47e..c3e3948 100644
--- a/java/com/google/gerrit/server/update/RepoOnlyOp.java
+++ b/java/com/google/gerrit/server/update/RepoOnlyOp.java
@@ -35,5 +35,5 @@
    * @param ctx context
    */
   // TODO(dborowitz): Support async operations?
-  default void postUpdate(Context ctx) throws Exception {}
+  default void postUpdate(PostUpdateContext ctx) throws Exception {}
 }
diff --git a/java/com/google/gerrit/sshd/commands/SetReviewersCommand.java b/java/com/google/gerrit/sshd/commands/SetReviewersCommand.java
index 95627e1..4f23d1d 100644
--- a/java/com/google/gerrit/sshd/commands/SetReviewersCommand.java
+++ b/java/com/google/gerrit/sshd/commands/SetReviewersCommand.java
@@ -18,8 +18,8 @@
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.extensions.api.changes.AddReviewerInput;
 import com.google.gerrit.extensions.api.changes.DeleteReviewerInput;
+import com.google.gerrit.extensions.api.changes.ReviewerInput;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.ReviewerResource;
@@ -136,7 +136,7 @@
     // Add reviewers
     //
     for (String reviewer : toAdd) {
-      AddReviewerInput input = new AddReviewerInput();
+      ReviewerInput input = new ReviewerInput();
       input.reviewer = reviewer;
       input.confirmed = true;
       String error;
diff --git a/java/com/google/gerrit/testing/BUILD b/java/com/google/gerrit/testing/BUILD
index 93c996e8..be32138 100644
--- a/java/com/google/gerrit/testing/BUILD
+++ b/java/com/google/gerrit/testing/BUILD
@@ -12,6 +12,7 @@
         "//lib:junit",
         "//lib/mockito",
     ],
+    runtime_deps = ["//java/com/google/gerrit/index/testing"],
     deps = [
         "//java/com/google/gerrit/acceptance/config",
         "//java/com/google/gerrit/acceptance/testsuite/project",
diff --git a/java/com/google/gerrit/testing/InMemoryModule.java b/java/com/google/gerrit/testing/InMemoryModule.java
index 1c322b2..7ca763a1 100644
--- a/java/com/google/gerrit/testing/InMemoryModule.java
+++ b/java/com/google/gerrit/testing/InMemoryModule.java
@@ -193,7 +193,7 @@
     // support Path-based Configs, only FileBasedConfig.
     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(GerritOptions.class).toInstance(new GerritOptions(false, false));
 
     bind(GitRepositoryManager.class).to(InMemoryRepositoryManager.class);
     bind(InMemoryRepositoryManager.class).in(SINGLETON);
@@ -242,6 +242,8 @@
       install(luceneIndexModule());
     } else if (indexType.isElasticsearch()) {
       install(elasticIndexModule());
+    } else if (indexType.isFake()) {
+      install(fakeIndexModule());
     }
     bind(ServerInformationImpl.class);
     bind(ServerInformation.class).to(ServerInformationImpl.class);
@@ -317,6 +319,10 @@
     return indexModule("com.google.gerrit.elasticsearch.ElasticIndexModule");
   }
 
+  private Module fakeIndexModule() {
+    return indexModule("com.google.gerrit.index.testing.FakeIndexModule");
+  }
+
   private Module indexModule(String moduleClassName) {
     try {
       Class<?> clazz = Class.forName(moduleClassName);
diff --git a/java/com/google/gerrit/testing/IndexConfig.java b/java/com/google/gerrit/testing/IndexConfig.java
index a8ed5be..daef2b3 100644
--- a/java/com/google/gerrit/testing/IndexConfig.java
+++ b/java/com/google/gerrit/testing/IndexConfig.java
@@ -50,4 +50,8 @@
 
     return cfg;
   }
+
+  public static Config createForFake() {
+    return create();
+  }
 }
diff --git a/java/com/google/gerrit/testing/TestLoggingActivator.java b/java/com/google/gerrit/testing/TestLoggingActivator.java
index 6b5d8fd..b3ad862 100644
--- a/java/com/google/gerrit/testing/TestLoggingActivator.java
+++ b/java/com/google/gerrit/testing/TestLoggingActivator.java
@@ -48,11 +48,6 @@
           .put("org.openid4java.server.RealmVerifier", Level.ERROR)
           .put("org.openid4java.message.AuthSuccess", Level.ERROR)
 
-          // Silence non-critical messages from c3p0 (if used).
-          .put("com.mchange.v2.c3p0", Level.WARN)
-          .put("com.mchange.v2.resourcepool", Level.WARN)
-          .put("com.mchange.v2.sql", Level.WARN)
-
           // Silence non-critical messages from apache.http.
           .put("org.apache.http", Level.WARN)
 
diff --git a/javatests/com/google/gerrit/acceptance/OutgoingEmailIT.java b/javatests/com/google/gerrit/acceptance/OutgoingEmailIT.java
index 30f1dcb..5e85fae 100644
--- a/javatests/com/google/gerrit/acceptance/OutgoingEmailIT.java
+++ b/javatests/com/google/gerrit/acceptance/OutgoingEmailIT.java
@@ -22,8 +22,8 @@
 import com.google.gerrit.entities.EmailHeader;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.accounts.EmailInput;
-import com.google.gerrit.extensions.api.changes.AddReviewerInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.changes.ReviewerInput;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
 import com.google.gerrit.testing.FakeEmailSender;
 import java.net.URL;
@@ -36,9 +36,9 @@
   public void messageIdHeaderFromChangeUpdate() throws Exception {
     Repository repository = repoManager.openRepository(project);
     PushOneCommit.Result result = createChange();
-    AddReviewerInput addReviewerInput = new AddReviewerInput();
-    addReviewerInput.reviewer = user.email();
-    gApi.changes().id(result.getChangeId()).addReviewer(addReviewerInput);
+    ReviewerInput reviewerInput = new ReviewerInput();
+    reviewerInput.reviewer = user.email();
+    gApi.changes().id(result.getChangeId()).addReviewer(reviewerInput);
     sender.clear();
 
     gApi.changes().id(result.getChangeId()).abandon();
@@ -107,9 +107,9 @@
     GeneralPreferencesInfo generalPreferencesInfo = new GeneralPreferencesInfo();
     generalPreferencesInfo.emailFormat = GeneralPreferencesInfo.EmailFormat.PLAINTEXT;
     gApi.accounts().id(user.id().get()).setPreferences(generalPreferencesInfo);
-    AddReviewerInput addReviewerInput = new AddReviewerInput();
-    addReviewerInput.reviewer = user.email();
-    gApi.changes().id(result.getChangeId()).addReviewer(addReviewerInput);
+    ReviewerInput reviewerInput = new ReviewerInput();
+    reviewerInput.reviewer = user.email();
+    gApi.changes().id(result.getChangeId()).addReviewer(reviewerInput);
     sender.clear();
 
     gApi.changes().id(result.getChangeId()).current().review(ReviewInput.approve());
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
index 7495e63..1d50e82 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
@@ -95,9 +95,9 @@
 import com.google.gerrit.extensions.api.accounts.DeleteDraftCommentsInput;
 import com.google.gerrit.extensions.api.accounts.DeletedDraftCommentInfo;
 import com.google.gerrit.extensions.api.accounts.EmailInput;
-import com.google.gerrit.extensions.api.changes.AddReviewerInput;
 import com.google.gerrit.extensions.api.changes.DraftInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.changes.ReviewerInput;
 import com.google.gerrit.extensions.api.changes.StarsInput;
 import com.google.gerrit.extensions.api.config.ConsistencyCheckInfo;
 import com.google.gerrit.extensions.api.config.ConsistencyCheckInfo.ConsistencyProblemInfo;
@@ -131,6 +131,7 @@
 import com.google.gerrit.server.account.AccountsUpdate;
 import com.google.gerrit.server.account.Emails;
 import com.google.gerrit.server.account.VersionedAuthorizedKeys;
+import com.google.gerrit.server.account.externalids.DuplicateExternalIdKeyException;
 import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.account.externalids.ExternalIdNotes;
 import com.google.gerrit.server.account.externalids.ExternalIds;
@@ -575,7 +576,7 @@
               "Account 'user' only matches inactive accounts. To use an inactive account, retry"
                   + " with one of the following exact account IDs:\n"
                   + id
-                  + ": User <user@example.com>");
+                  + ": User1 <user1@example.com>");
       assertThat(gApi.accounts().id(id).getActive()).isFalse();
 
       gApi.accounts().id(id).setActive(true);
@@ -919,11 +920,11 @@
 
       PushOneCommit.Result r = createChange();
 
-      AddReviewerInput in = new AddReviewerInput();
+      ReviewerInput in = new ReviewerInput();
       in.reviewer = user.email();
       gApi.changes().id(r.getChangeId()).addReviewer(in);
 
-      in = new AddReviewerInput();
+      in = new ReviewerInput();
       in.reviewer = user2.email();
       gApi.changes().id(r.getChangeId()).addReviewer(in);
 
@@ -957,7 +958,7 @@
       sender.clear();
       requestScopeOperations.setApiUser(admin.id());
 
-      AddReviewerInput in = new AddReviewerInput();
+      ReviewerInput in = new ReviewerInput();
       in.reviewer = user.email();
       gApi.changes().id(r.getChangeId()).addReviewer(in);
       List<Message> messages = sender.getMessages();
@@ -976,9 +977,9 @@
     // First reviewer added to the change
     ReviewInput input = new ReviewInput();
     input.reviewers = new ArrayList<>(1);
-    AddReviewerInput addReviewerInput = new AddReviewerInput();
-    addReviewerInput.reviewer = user.email();
-    input.reviewers.add(addReviewerInput);
+    ReviewerInput reviewerInput = new ReviewerInput();
+    reviewerInput.reviewer = user.email();
+    input.reviewers.add(reviewerInput);
     gApi.changes().id(r.getChangeId()).current().review(input);
     List<Message> messages = sender.getMessages();
     assertThat(messages).hasSize(1);
@@ -991,14 +992,14 @@
     // Second reviewer and existing reviewer added to the change
     ReviewInput input2 = new ReviewInput();
     input2.reviewers = new ArrayList<>(2);
-    AddReviewerInput addReviewerInput2 = new AddReviewerInput();
-    addReviewerInput2.reviewer = user.email();
-    input2.reviewers.add(addReviewerInput2);
-    AddReviewerInput addReviewerInput3 = new AddReviewerInput();
+    ReviewerInput reviewerInput2 = new ReviewerInput();
+    reviewerInput2.reviewer = user.email();
+    input2.reviewers.add(reviewerInput2);
+    ReviewerInput reviewerInput3 = new ReviewerInput();
 
     TestAccount user2 = accountCreator.user2();
-    addReviewerInput3.reviewer = user2.email();
-    input2.reviewers.add(addReviewerInput3);
+    reviewerInput3.reviewer = user2.email();
+    input2.reviewers.add(reviewerInput3);
 
     gApi.changes().id(r.getChangeId()).current().review(input2);
     List<Message> messages2 = sender.getMessages();
@@ -1012,13 +1013,13 @@
     // Existing reviewers re-added to the change: no notifications
     ReviewInput input3 = new ReviewInput();
     input3.reviewers = new ArrayList<>(2);
-    AddReviewerInput addReviewerInput4 = new AddReviewerInput();
-    addReviewerInput4.reviewer = user.email();
-    input3.reviewers.add(addReviewerInput4);
-    AddReviewerInput addReviewerInput5 = new AddReviewerInput();
+    ReviewerInput reviewerInput4 = new ReviewerInput();
+    reviewerInput4.reviewer = user.email();
+    input3.reviewers.add(reviewerInput4);
+    ReviewerInput reviewerInput5 = new ReviewerInput();
 
-    addReviewerInput5.reviewer = user2.email();
-    input3.reviewers.add(addReviewerInput5);
+    reviewerInput5.reviewer = user2.email();
+    input3.reviewers.add(reviewerInput5);
 
     gApi.changes().id(r.getChangeId()).current().review(input3);
     List<Message> messages3 = sender.getMessages();
@@ -1030,9 +1031,9 @@
     PushOneCommit.Result r = createChange();
 
     // First reviewer added to the change
-    AddReviewerInput addReviewerInput = new AddReviewerInput();
-    addReviewerInput.reviewer = user.email();
-    gApi.changes().id(r.getChangeId()).addReviewer(addReviewerInput);
+    ReviewerInput reviewerInput = new ReviewerInput();
+    reviewerInput.reviewer = user.email();
+    gApi.changes().id(r.getChangeId()).addReviewer(reviewerInput);
     List<Message> messages = sender.getMessages();
     assertThat(messages).hasSize(1);
     Message message = messages.get(0);
@@ -1043,9 +1044,9 @@
 
     // Second reviewer added to the change
     TestAccount user2 = accountCreator.user2();
-    AddReviewerInput addReviewerInput2 = new AddReviewerInput();
-    addReviewerInput2.reviewer = user2.email();
-    gApi.changes().id(r.getChangeId()).addReviewer(addReviewerInput2);
+    ReviewerInput reviewerInput2 = new ReviewerInput();
+    reviewerInput2.reviewer = user2.email();
+    gApi.changes().id(r.getChangeId()).addReviewer(reviewerInput2);
     List<Message> messages2 = sender.getMessages();
     assertThat(messages2).hasSize(1);
     Message message2 = messages2.get(0);
@@ -1055,9 +1056,9 @@
     sender.clear();
 
     // Exiting reviewer re-added to the change: no notifications
-    AddReviewerInput addReviewerInput3 = new AddReviewerInput();
-    addReviewerInput3.reviewer = user2.email();
-    gApi.changes().id(r.getChangeId()).addReviewer(addReviewerInput3);
+    ReviewerInput reviewerInput3 = new ReviewerInput();
+    reviewerInput3.reviewer = user2.email();
+    gApi.changes().id(r.getChangeId()).addReviewer(reviewerInput3);
     List<Message> messages3 = sender.getMessages();
     assertThat(messages3).isEmpty();
   }
@@ -2625,12 +2626,14 @@
       }
       assertStaleAccountAndReindex(accountId);
 
+      extIdNotes = ExternalIdNotes.loadNoCacheUpdate(allUsers, repo);
       extIdNotes.upsert(ExternalId.createWithEmail(key, accountId, "foo@example.com"));
       try (MetaDataUpdate update = metaDataUpdateFactory.create(allUsers)) {
         extIdNotes.commit(update);
       }
       assertStaleAccountAndReindex(accountId);
 
+      extIdNotes = ExternalIdNotes.loadNoCacheUpdate(allUsers, repo);
       extIdNotes.delete(accountId, key);
       try (MetaDataUpdate update = metaDataUpdateFactory.create(allUsers)) {
         extIdNotes.commit(update);
@@ -2886,6 +2889,91 @@
     assertThat(thrown).hasMessageThat().contains("username");
   }
 
+  @Test
+  public void externalIdBatchUpdates() throws Exception {
+    String extId1String = "foo:bar";
+    String extId2String = "foo:baz";
+    ExternalId extId1 =
+        ExternalId.createWithEmail(ExternalId.Key.parse(extId1String), admin.id(), "1@foo.com");
+    ExternalId extId2 =
+        ExternalId.createWithEmail(ExternalId.Key.parse(extId2String), user.id(), "2@foo.com");
+
+    ObjectId revBefore;
+    try (Repository repo = repoManager.openRepository(allUsers)) {
+      revBefore = repo.exactRef(RefNames.REFS_EXTERNAL_IDS).getObjectId();
+    }
+
+    AccountsUpdate.UpdateArguments ua1 =
+        new AccountsUpdate.UpdateArguments(
+            "Add External ID", admin.id(), (a, u) -> u.addExternalId(extId1));
+    AccountsUpdate.UpdateArguments ua2 =
+        new AccountsUpdate.UpdateArguments(
+            "Add External ID", user.id(), (a, u) -> u.addExternalId(extId2));
+    ImmutableList<Optional<AccountState>> accountStates =
+        accountsUpdateProvider.get().updateBatch(ImmutableList.of(ua1, ua2));
+    assertThat(accountStates).hasSize(2);
+    assertThat(accountStates.get(0).get().externalIds()).contains(extId1);
+    assertThat(accountStates.get(1).get().externalIds()).contains(extId2);
+    assertThat(
+            gApi.accounts().id(admin.id().get()).getExternalIds().stream()
+                .map(e -> e.identity)
+                .collect(toSet()))
+        .contains(extId1String);
+    assertThat(
+            gApi.accounts().id(user.id().get()).getExternalIds().stream()
+                .map(e -> e.identity)
+                .collect(toSet()))
+        .contains(extId2String);
+
+    // Ensure that we only applied one single commit.
+    try (Repository repo = repoManager.openRepository(allUsers);
+        RevWalk rw = new RevWalk(repo)) {
+      RevCommit after = rw.parseCommit(repo.exactRef(RefNames.REFS_EXTERNAL_IDS).getObjectId());
+      assertThat(after.getParent(0).toObjectId()).isEqualTo(revBefore);
+    }
+  }
+
+  @Test
+  public void externalIdBatchUpdates_fail_sameAccount() {
+    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(
+            "Add External ID", admin.id(), (a, u) -> u.addExternalId(extId1));
+    // Another update for the same account is not allowed.
+    AccountsUpdate.UpdateArguments ua2 =
+        new AccountsUpdate.UpdateArguments(
+            "Add External ID", admin.id(), (a, u) -> u.addExternalId(extId2));
+    IllegalArgumentException e =
+        assertThrows(
+            IllegalArgumentException.class,
+            () -> accountsUpdateProvider.get().updateBatch(ImmutableList.of(ua1, ua2)));
+    assertThat(e).hasMessageThat().contains("updates must all be for different accounts");
+  }
+
+  @Test
+  public void externalIdBatchUpdates_fail_duplicateKey() {
+    ExternalId extIdAdmin =
+        ExternalId.createWithEmail(ExternalId.Key.parse("foo:bar"), admin.id(), "1@foo.com");
+    ExternalId extIdUser =
+        ExternalId.createWithEmail(ExternalId.Key.parse("foo:bar"), user.id(), "2@foo.com");
+
+    AccountsUpdate.UpdateArguments ua1 =
+        new AccountsUpdate.UpdateArguments(
+            "Add External ID", admin.id(), (a, u) -> u.addExternalId(extIdAdmin));
+    AccountsUpdate.UpdateArguments ua2 =
+        new AccountsUpdate.UpdateArguments(
+            "Add External ID", user.id(), (a, u) -> u.addExternalId(extIdUser));
+    DuplicateExternalIdKeyException e =
+        assertThrows(
+            DuplicateExternalIdKeyException.class,
+            () -> accountsUpdateProvider.get().updateBatch(ImmutableList.of(ua1, ua2)));
+    assertThat(e).hasMessageThat().contains("foo:bar");
+  }
+
   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/accounts/AccountIndexerIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/AccountIndexerIT.java
index f78cb9ab..d1258fc 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/AccountIndexerIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/AccountIndexerIT.java
@@ -24,8 +24,8 @@
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AccountConfig;
+import com.google.gerrit.server.account.AccountDelta;
 import com.google.gerrit.server.account.AccountState;
-import com.google.gerrit.server.account.InternalAccountUpdate;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.GitRepositoryManager;
@@ -59,7 +59,7 @@
     Account.Id accountId = createAccount("foo");
     String preferredEmail = "foo@example.com";
     updateAccountWithoutCacheOrIndex(
-        accountId, newAccountUpdate().setPreferredEmail(preferredEmail).build());
+        accountId, newAccountDelta().setPreferredEmail(preferredEmail).build());
     assertThat(accountQueryProvider.get().byPreferredEmail(preferredEmail)).isEmpty();
 
     accountIndexer.index(accountId);
@@ -75,7 +75,7 @@
     loadAccountToCache(accountId);
     String preferredEmail = "foo@example.com";
     updateAccountWithoutCacheOrIndex(
-        accountId, newAccountUpdate().setPreferredEmail(preferredEmail).build());
+        accountId, newAccountDelta().setPreferredEmail(preferredEmail).build());
     assertThat(accountQueryProvider.get().byPreferredEmail(preferredEmail)).isEmpty();
 
     accountIndexer.index(accountId);
@@ -90,7 +90,7 @@
     Account.Id accountId = createAccount("foo");
     String preferredEmail = "foo@example.com";
     updateAccountWithoutCacheOrIndex(
-        accountId, newAccountUpdate().setPreferredEmail(preferredEmail).build());
+        accountId, newAccountDelta().setPreferredEmail(preferredEmail).build());
     assertThat(accountQueryProvider.get().byPreferredEmail(preferredEmail)).isEmpty();
 
     accountIndexer.reindexIfStale(accountId);
@@ -104,7 +104,7 @@
   public void notStaleAccountIsNotReindexed() throws Exception {
     Account.Id accountId = createAccount("foo");
     updateAccountWithoutCacheOrIndex(
-        accountId, newAccountUpdate().setPreferredEmail("foo@example.com").build());
+        accountId, newAccountDelta().setPreferredEmail("foo@example.com").build());
     accountIndexer.index(accountId);
 
     boolean reindexed = accountIndexer.reindexIfStale(accountId);
@@ -115,7 +115,7 @@
   public void indexStalenessIsNotDerivedFromCacheStaleness() throws Exception {
     Account.Id accountId = createAccount("foo");
     updateAccountWithoutCacheOrIndex(
-        accountId, newAccountUpdate().setPreferredEmail("foo@example.com").build());
+        accountId, newAccountDelta().setPreferredEmail("foo@example.com").build());
     reloadAccountToCache(accountId);
 
     boolean reindexed = accountIndexer.reindexIfStale(accountId);
@@ -135,12 +135,11 @@
     accountCache.get(accountId);
   }
 
-  private static InternalAccountUpdate.Builder newAccountUpdate() {
-    return InternalAccountUpdate.builder();
+  private static AccountDelta.Builder newAccountDelta() {
+    return AccountDelta.builder();
   }
 
-  private void updateAccountWithoutCacheOrIndex(
-      Account.Id accountId, InternalAccountUpdate accountUpdate)
+  private void updateAccountWithoutCacheOrIndex(Account.Id accountId, AccountDelta accountDelta)
       throws IOException, ConfigInvalidException {
     try (Repository allUsersRepo = repoManager.openRepository(allUsersName);
         MetaDataUpdate md =
@@ -150,7 +149,7 @@
       md.getCommitBuilder().setCommitter(ident);
 
       AccountConfig accountConfig = new AccountConfig(accountId, allUsersName, allUsersRepo).load();
-      accountConfig.setAccountUpdate(accountUpdate);
+      accountConfig.setAccountDelta(accountDelta);
       accountConfig.commit(md);
     }
   }
diff --git a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
index a1668ef..529ce73 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
@@ -37,9 +37,7 @@
 import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_ACTIONS;
 import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_COMMIT;
 import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_REVISION;
-import static com.google.gerrit.extensions.client.ListChangesOption.DETAILED_ACCOUNTS;
 import static com.google.gerrit.extensions.client.ListChangesOption.DETAILED_LABELS;
-import static com.google.gerrit.extensions.client.ListChangesOption.LABELS;
 import static com.google.gerrit.extensions.client.ListChangesOption.MESSAGES;
 import static com.google.gerrit.extensions.client.ListChangesOption.PUSH_CERTIFICATES;
 import static com.google.gerrit.extensions.client.ListChangesOption.REVIEWED;
@@ -81,6 +79,7 @@
 import com.google.gerrit.acceptance.TestProjectInput;
 import com.google.gerrit.acceptance.UseClockStep;
 import com.google.gerrit.acceptance.UseTimezone;
+import com.google.gerrit.acceptance.VerifyNoPiiInChangeNotes;
 import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
 import com.google.gerrit.acceptance.testsuite.group.GroupOperations;
@@ -104,8 +103,6 @@
 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.AddReviewerInput;
-import com.google.gerrit.extensions.api.changes.AddReviewerResult;
 import com.google.gerrit.extensions.api.changes.DeleteReviewerInput;
 import com.google.gerrit.extensions.api.changes.DeleteVoteInput;
 import com.google.gerrit.extensions.api.changes.DraftApi;
@@ -119,6 +116,8 @@
 import com.google.gerrit.extensions.api.changes.ReviewInput.DraftHandling;
 import com.google.gerrit.extensions.api.changes.ReviewResult;
 import com.google.gerrit.extensions.api.changes.ReviewerInfo;
+import com.google.gerrit.extensions.api.changes.ReviewerInput;
+import com.google.gerrit.extensions.api.changes.ReviewerResult;
 import com.google.gerrit.extensions.api.changes.RevisionApi;
 import com.google.gerrit.extensions.api.changes.StarsInput;
 import com.google.gerrit.extensions.api.groups.GroupApi;
@@ -153,10 +152,12 @@
 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.httpd.raw.IndexPreloadingUtil;
 import com.google.gerrit.index.IndexConfig;
 import com.google.gerrit.index.query.PostFilterPredicate;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.StarredChangesUtil;
+import com.google.gerrit.server.change.ChangeMessages;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.testing.TestChangeETagComputation;
 import com.google.gerrit.server.git.ChangeMessageModifier;
@@ -185,6 +186,7 @@
 import java.io.ByteArrayOutputStream;
 import java.io.IOException;
 import java.sql.Timestamp;
+import java.text.MessageFormat;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collection;
@@ -201,6 +203,8 @@
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.RefUpdate;
+import org.eclipse.jgit.lib.RefUpdate.Result;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
@@ -209,6 +213,7 @@
 
 @NoHttpd
 @UseTimezone(timezone = "US/Eastern")
+@VerifyNoPiiInChangeNotes(true)
 public class ChangeIT extends AbstractDaemonTest {
 
   @Inject private AccountOperations accountOperations;
@@ -436,25 +441,25 @@
         .newAccount()
         .username(name("user1"))
         .preferredEmail(email1)
-        .fullname("User 1")
+        .fullname("User1")
         .create();
     accountOperations
         .newAccount()
         .username(name("user2"))
         .preferredEmail(email2)
-        .fullname("User 2")
+        .fullname("User2")
         .create();
     accountOperations
         .newAccount()
         .username(name("user3"))
         .preferredEmail(email3)
-        .fullname("User 3")
+        .fullname("User3")
         .create();
     accountOperations
         .newAccount()
         .username(name("user4"))
         .preferredEmail(email4)
-        .fullname("User 4")
+        .fullname("User4")
         .create();
     ReviewInput in =
         ReviewInput.noScore()
@@ -641,7 +646,7 @@
   }
 
   @Test
-  @TestProjectInput(cloneAs = "user")
+  @TestProjectInput(cloneAs = "user1")
   public void reviewWithWorkInProgressChangeOwner() throws Exception {
     PushOneCommit push = pushFactory.create(user.newIdent(), testRepo);
     PushOneCommit.Result r = push.to("refs/for/master");
@@ -656,7 +661,7 @@
   }
 
   @Test
-  @TestProjectInput(cloneAs = "user")
+  @TestProjectInput(cloneAs = "user1")
   public void reviewWithWithWorkInProgressAdmin() throws Exception {
     PushOneCommit push = pushFactory.create(user.newIdent(), testRepo);
     PushOneCommit.Result r = push.to("refs/for/master");
@@ -994,7 +999,7 @@
   }
 
   @Test
-  @TestProjectInput(cloneAs = "user")
+  @TestProjectInput(cloneAs = "user1")
   public void deleteNewChangeAsNormalUser() throws Exception {
     PushOneCommit.Result changeResult =
         pushFactory.create(user.newIdent(), testRepo).to("refs/for/master");
@@ -1019,7 +1024,7 @@
   @Test
   public void deleteNewChangeAsUserWithDeleteChangesPermissionForProjectOwners() throws Exception {
     GroupApi groupApi = gApi.groups().create(name("delete-change"));
-    groupApi.addMembers("user");
+    groupApi.addMembers("user1");
 
     Project.NameKey nameKey = Project.nameKey(name("delete-change"));
     ProjectInput in = new ProjectInput();
@@ -1148,7 +1153,7 @@
   }
 
   @Test
-  @TestProjectInput(cloneAs = "user")
+  @TestProjectInput(cloneAs = "user1")
   public void deleteAbandonedChangeAsNormalUser() throws Exception {
     PushOneCommit.Result changeResult =
         pushFactory.create(user.newIdent(), testRepo).to("refs/for/master");
@@ -1163,7 +1168,7 @@
   }
 
   @Test
-  @TestProjectInput(cloneAs = "user")
+  @TestProjectInput(cloneAs = "user1")
   public void deleteAbandonedChangeOfAnotherUserAsAdmin() throws Exception {
     PushOneCommit.Result changeResult =
         pushFactory.create(user.newIdent(), testRepo).to("refs/for/master");
@@ -1189,7 +1194,7 @@
   }
 
   @Test
-  @TestProjectInput(cloneAs = "user")
+  @TestProjectInput(cloneAs = "user1")
   public void deleteMergedChangeWithDeleteOwnChangesPermission() throws Exception {
     projectOperations
         .project(project)
@@ -1791,9 +1796,9 @@
 
     // try to add user as reviewer
     requestScopeOperations.setApiUser(admin.id());
-    AddReviewerInput in = new AddReviewerInput();
+    ReviewerInput in = new ReviewerInput();
     in.reviewer = user.email();
-    AddReviewerResult r = gApi.changes().id(result.getChangeId()).addReviewer(in);
+    ReviewerResult r = gApi.changes().id(result.getChangeId()).addReviewer(in);
 
     assertThat(r.input).isEqualTo(user.email());
     assertThat(r.error).contains("does not have permission to see this change");
@@ -1801,42 +1806,15 @@
   }
 
   @Test
-  public void addReviewerThatIsInactive() throws Exception {
+  public void addReviewerThatIsInactiveByUsername() throws Exception {
     PushOneCommit.Result result = createChange();
 
     String username = name("new-user");
     Account.Id id = accountOperations.newAccount().username(username).inactive().create();
 
-    AddReviewerInput in = new AddReviewerInput();
+    ReviewerInput in = new ReviewerInput();
     in.reviewer = username;
-    AddReviewerResult r = gApi.changes().id(result.getChangeId()).addReviewer(in);
-
-    assertThat(r.input).isEqualTo(in.reviewer);
-    assertThat(r.error)
-        .isEqualTo(
-            "Account '"
-                + username
-                + "' only matches inactive accounts. To use an inactive account, retry with one of"
-                + " the following exact account IDs:\n"
-                + id
-                + ": Name of user not set ("
-                + id
-                + ")\n"
-                + username
-                + " does not identify a registered user or group");
-    assertThat(r.reviewers).isNull();
-  }
-
-  @Test
-  public void addReviewerThatIsInactiveById() throws Exception {
-    PushOneCommit.Result result = createChange();
-
-    String username = name("new-user");
-    Account.Id id = accountOperations.newAccount().username(username).inactive().create();
-
-    AddReviewerInput in = new AddReviewerInput();
-    in.reviewer = Integer.toString(id.get());
-    AddReviewerResult r = gApi.changes().id(result.getChangeId()).addReviewer(in);
+    ReviewerResult r = gApi.changes().id(result.getChangeId()).addReviewer(in);
 
     assertThat(r.input).isEqualTo(in.reviewer);
     assertThat(r.error).isNull();
@@ -1847,26 +1825,44 @@
   }
 
   @Test
-  public void addReviewerThatIsInactiveEmailFallback() throws Exception {
+  public void addReviewerThatIsInactiveById() throws Exception {
+    PushOneCommit.Result result = createChange();
+
+    String username = name("new-user");
+    Account.Id id = accountOperations.newAccount().username(username).inactive().create();
+
+    ReviewerInput in = new ReviewerInput();
+    in.reviewer = Integer.toString(id.get());
+    ReviewerResult r = gApi.changes().id(result.getChangeId()).addReviewer(in);
+
+    assertThat(r.input).isEqualTo(in.reviewer);
+    assertThat(r.error).isNull();
+    assertThat(r.reviewers).hasSize(1);
+    ReviewerInfo reviewer = r.reviewers.get(0);
+    assertThat(reviewer._accountId).isEqualTo(id.get());
+    assertThat(reviewer.username).isEqualTo(username);
+  }
+
+  @Test
+  public void addReviewerThatIsInactiveByEmail() throws Exception {
     ConfigInput conf = new ConfigInput();
     conf.enableReviewerByEmail = InheritableBoolean.TRUE;
     gApi.projects().name(project.get()).config(conf);
-
     PushOneCommit.Result result = createChange();
-
     String username = "user@domain.com";
-    accountOperations.newAccount().username(username).inactive().create();
+    Account.Id id = accountOperations.newAccount().username(username).inactive().create();
 
-    AddReviewerInput in = new AddReviewerInput();
+    ReviewerInput in = new ReviewerInput();
     in.reviewer = username;
     in.state = ReviewerState.CC;
-    AddReviewerResult r = gApi.changes().id(result.getChangeId()).addReviewer(in);
+    ReviewerResult r = gApi.changes().id(result.getChangeId()).addReviewer(in);
 
     assertThat(r.input).isEqualTo(username);
     assertThat(r.error).isNull();
-    // When adding by email, the reviewers field is also empty because we can't
-    // render a ReviewerInfo object for a non-account.
-    assertThat(r.reviewers).isNull();
+    assertThat(r.ccs).hasSize(1);
+    AccountInfo reviewer = r.ccs.get(0);
+    assertThat(reviewer._accountId).isEqualTo(id.get());
+    assertThat(reviewer.username).isEqualTo(username);
   }
 
   @Test
@@ -1874,7 +1870,7 @@
   public void addReviewer() throws Exception {
     testAddReviewerViaPostReview(
         (changeId, reviewer) -> {
-          AddReviewerInput in = new AddReviewerInput();
+          ReviewerInput in = new ReviewerInput();
           in.reviewer = reviewer;
           gApi.changes().id(changeId).addReviewer(in);
         });
@@ -1885,10 +1881,10 @@
   public void addReviewerViaPostReview() throws Exception {
     testAddReviewerViaPostReview(
         (changeId, reviewer) -> {
-          AddReviewerInput addReviewerInput = new AddReviewerInput();
-          addReviewerInput.reviewer = reviewer;
+          ReviewerInput reviewerInput = new ReviewerInput();
+          reviewerInput.reviewer = reviewer;
           ReviewInput reviewInput = new ReviewInput();
-          reviewInput.reviewers = ImmutableList.of(addReviewerInput);
+          reviewInput.reviewers = ImmutableList.of(reviewerInput);
           gApi.changes().id(changeId).current().review(reviewInput);
         });
   }
@@ -1948,7 +1944,7 @@
   @Test
   public void listReviewers() throws Exception {
     PushOneCommit.Result r = createChange();
-    AddReviewerInput in = new AddReviewerInput();
+    ReviewerInput in = new ReviewerInput();
     in.reviewer = user.email();
     gApi.changes().id(r.getChangeId()).addReviewer(in);
     assertThat(gApi.changes().id(r.getChangeId()).reviewers()).hasSize(1);
@@ -1959,7 +1955,7 @@
         .newAccount()
         .username(username1)
         .preferredEmail(email1)
-        .fullname("User 1")
+        .fullname("User1")
         .create();
     in.reviewer = email1;
     in.state = ReviewerState.CC;
@@ -1970,7 +1966,7 @@
 
   @Test
   public void notificationsForAddedWorkInProgressReviewers() throws Exception {
-    AddReviewerInput in = new AddReviewerInput();
+    ReviewerInput in = new ReviewerInput();
     in.reviewer = user.email();
     ReviewInput batchIn = new ReviewInput();
     batchIn.reviewers = ImmutableList.of(in);
@@ -2025,7 +2021,7 @@
     groupApi.description("test group");
     groupApi.addMembers(user.fullName());
 
-    AddReviewerInput in = new AddReviewerInput();
+    ReviewerInput in = new ReviewerInput();
     in.reviewer = "abc";
     gApi.changes().id(r.getChangeId()).addReviewer(in.reviewer);
 
@@ -2086,7 +2082,7 @@
     // ensure that user "user" is not in the group
     groupApi.removeMembers(testUserFullname);
 
-    AddReviewerInput in = new AddReviewerInput();
+    ReviewerInput in = new ReviewerInput();
     in.reviewer = testGroup;
     gApi.changes().id(r.getChangeId()).addReviewer(in.reviewer);
 
@@ -2113,6 +2109,46 @@
   }
 
   @Test
+  public void deleteGroupFromReviewersFails() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    // create a group named "kobe" with one user: lee
+    String myGroupUserEmail = "lee@example.com";
+    String myGroupUserFullname = "lee";
+    accountOperations
+        .newAccount()
+        .username("lee")
+        .preferredEmail(myGroupUserEmail)
+        .fullname(myGroupUserFullname)
+        .create();
+
+    String groupName = "kobe";
+    String testGroup = groupOperations.newGroup().name(groupName).create().get();
+    GroupApi groupApi = gApi.groups().id(testGroup);
+    groupApi.description("test group");
+    groupApi.addMembers(myGroupUserFullname);
+
+    // add the user as reviewer.
+    gApi.changes().id(r.getChangeId()).addReviewer(myGroupUserFullname);
+
+    // fail to remove that user via group.
+    ReviewResult reviewResult =
+        gApi.changes()
+            .id(r.getChangeId())
+            .current()
+            .review(ReviewInput.create().reviewer(testGroup, REMOVED, /* confirmed= */ true));
+
+    assertThat(reviewResult.error).isEqualTo("error adding reviewer");
+
+    ReviewerInput in = new ReviewerInput();
+    in.reviewer = testGroup;
+    in.state = REMOVED;
+    ReviewerResult reviewerResult = gApi.changes().id(r.getChangeId()).addReviewer(in);
+    assertThat(reviewerResult.error)
+        .isEqualTo(MessageFormat.format(ChangeMessages.get().groupRemovalIsNotAllowed, groupName));
+  }
+
+  @Test
   @UseClockStep
   public void addSelfAsReviewer() throws Exception {
     PushOneCommit.Result r = createChange();
@@ -2120,7 +2156,7 @@
     String oldETag = rsrc.getETag();
     Timestamp oldTs = rsrc.getChange().getLastUpdatedOn();
 
-    AddReviewerInput in = new AddReviewerInput();
+    ReviewerInput in = new ReviewerInput();
     in.reviewer = user.email();
     requestScopeOperations.setApiUser(user.id());
     gApi.changes().id(r.getChangeId()).addReviewer(in);
@@ -2212,7 +2248,7 @@
     assertThat(reviewers.iterator().next()._accountId).isEqualTo(admin.id().get());
     assertThat(c.reviewers).doesNotContainKey(CC);
 
-    AddReviewerInput in = new AddReviewerInput();
+    ReviewerInput in = new ReviewerInput();
     in.reviewer = user.email();
     gApi.changes().id(r.getChangeId()).addReviewer(in);
 
@@ -2279,7 +2315,7 @@
   public void emailNotificationForFileLevelComment() throws Exception {
     String changeId = createChange().getChangeId();
 
-    AddReviewerInput in = new AddReviewerInput();
+    ReviewerInput in = new ReviewerInput();
     in.reviewer = user.email();
     gApi.changes().id(changeId).addReviewer(in);
     sender.clear();
@@ -2391,12 +2427,14 @@
 
     assertThat(sender.getMessages()).hasSize(1);
     Message message = sender.getMessages().get(0);
-    assertThat(message.body()).contains("Removed reviewer " + user.fullName() + ".");
+    assertThat(message.body()).contains("Removed reviewer " + user.getNameEmail() + ".");
     assertThat(message.body()).doesNotContain("with the following votes");
 
     // Make sure the change message for removing a reviewer is correct.
     assertThat(Iterables.getLast(gApi.changes().id(changeId).messages()).message)
-        .contains("Removed reviewer " + user.fullName());
+        .isEqualTo("Removed reviewer " + user.getNameEmail() + ".");
+    assertThat(Iterables.getLast(gApi.changes().id(changeId).get().messages).message)
+        .isEqualTo("Removed reviewer " + ChangeMessagesUtil.getAccountTemplate(user.id()) + ".");
 
     // Make sure the reviewer can still be added again.
     gApi.changes().id(changeId).addReviewer(user.id().toString());
@@ -2417,10 +2455,10 @@
     PushOneCommit.Result result = createChange();
     String changeId = result.getChangeId();
     // Add a cc
-    AddReviewerInput addReviewerInput = new AddReviewerInput();
-    addReviewerInput.state = CC;
-    addReviewerInput.reviewer = user.id().toString();
-    gApi.changes().id(changeId).addReviewer(addReviewerInput);
+    ReviewerInput reviewerInput = new ReviewerInput();
+    reviewerInput.state = CC;
+    reviewerInput.reviewer = user.id().toString();
+    gApi.changes().id(changeId).addReviewer(reviewerInput);
 
     // Remove a cc
     sender.clear();
@@ -2430,11 +2468,14 @@
     // Make sure the email for removing a cc is correct.
     assertThat(sender.getMessages()).hasSize(1);
     Message message = sender.getMessages().get(0);
-    assertThat(message.body()).contains("Removed cc " + user.fullName() + ".");
+    assertThat(message.body()).contains("Removed cc " + user.getNameEmail() + ".");
 
     // Make sure the change message for removing a reviewer is correct.
     assertThat(Iterables.getLast(gApi.changes().id(changeId).messages()).message)
-        .contains("Removed cc " + user.fullName());
+        .isEqualTo("Removed cc " + user.getNameEmail() + ".");
+
+    assertThat(Iterables.getLast(gApi.changes().id(changeId).get().messages).message)
+        .isEqualTo("Removed cc " + ChangeMessagesUtil.getAccountTemplate(user.id()) + ".");
   }
 
   @Test
@@ -2474,8 +2515,21 @@
       assertThat(sender.getMessages()).hasSize(1);
       Message message = sender.getMessages().get(0);
       assertThat(message.body())
-          .contains("Removed reviewer " + user.fullName() + " with the following votes");
-      assertThat(message.body()).contains("* Code-Review+1 by " + user.fullName());
+          .contains("Removed reviewer " + user.getNameEmail() + " with the following votes");
+      assertThat(message.body()).contains("* Code-Review+1 by " + user.getNameEmail());
+      ChangeMessageInfo changeMessageInfo =
+          Iterables.getLast(gApi.changes().id(changeId).messages());
+      assertThat(changeMessageInfo.message)
+          .contains("Removed reviewer " + user.getNameEmail() + " with the following votes");
+      assertThat(changeMessageInfo.message).contains("* Code-Review+1 by " + user.getNameEmail());
+      changeMessageInfo = Iterables.getLast(gApi.changes().id(changeId).get().messages);
+      assertThat(changeMessageInfo.message)
+          .contains(
+              "Removed reviewer "
+                  + ChangeMessagesUtil.getAccountTemplate(user.id())
+                  + " with the following votes");
+      assertThat(changeMessageInfo.message)
+          .contains("* Code-Review+1 by " + ChangeMessagesUtil.getAccountTemplate(user.id()));
     } else {
       assertThat(sender.getMessages()).isEmpty();
     }
@@ -2591,7 +2645,11 @@
 
     ChangeMessageInfo message = Iterables.getLast(c.messages);
     assertThat(message.author._accountId).isEqualTo(admin.id().get());
-    assertThat(message.message).isEqualTo("Removed Code-Review+1 by User <user@example.com>\n");
+    assertThat(message.message)
+        .isEqualTo(
+            "Removed Code-Review+1 by " + ChangeMessagesUtil.getAccountTemplate(user.id()) + "\n");
+    assertThat(gApi.changes().id(r.getChangeId()).message(message.id).get().message)
+        .isEqualTo("Removed Code-Review+1 by User1 <user1@example.com>\n");
     assertThat(getReviewers(c.reviewers.get(REVIEWER)))
         .containsExactlyElementsIn(ImmutableSet.of(admin.id(), user.id()));
   }
@@ -2708,7 +2766,7 @@
     assertThat(c.reviewers.get(CC)).isNull();
 
     // Add the user as reviewer
-    AddReviewerInput in = new AddReviewerInput();
+    ReviewerInput in = new ReviewerInput();
     in.reviewer = user.email();
     gApi.changes().id(changeId).addReviewer(in);
     c = gApi.changes().id(changeId).get();
@@ -2905,7 +2963,7 @@
   @Test
   public void checkReviewedFlagBeforeAndAfterReview() throws Exception {
     PushOneCommit.Result r = createChange();
-    AddReviewerInput in = new AddReviewerInput();
+    ReviewerInput in = new ReviewerInput();
     in.reviewer = user.email();
     gApi.changes().id(r.getChangeId()).addReviewer(in);
 
@@ -3013,6 +3071,24 @@
   }
 
   @Test
+  public void submitToSymref() throws Exception {
+    // Create symref in the origin repository (testRepo references to a local repository)
+    try (Repository repo = repoManager.openRepository(project)) {
+      RefUpdate u = repo.updateRef("refs/heads/master_symref");
+      assertThat(u.link("refs/heads/master")).isEqualTo(Result.NEW);
+    }
+
+    PushOneCommit.Result r = createChange("refs/for/master_symref");
+    String id = r.getChangeId();
+
+    gApi.changes().id(id).current().review(ReviewInput.approve());
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class, () -> gApi.changes().id(id).current().submit());
+    assertThat(thrown).hasMessageThat().contains("the target branch is a symbolic ref");
+  }
+
+  @Test
   public void check() throws Exception {
     PushOneCommit.Result r = createChange();
     assertThat(gApi.changes().id(r.getChangeId()).get().problems).isNull();
@@ -3144,10 +3220,7 @@
               gApi.changes()
                   .query()
                   .withQuery("project:{" + project.get() + "} (status:open OR status:closed)")
-                  // Options should match defaults in AccountDashboardScreen.
-                  .withOption(LABELS)
-                  .withOption(DETAILED_ACCOUNTS)
-                  .withOption(REVIEWED)
+                  .withOptions(IndexPreloadingUtil.DASHBOARD_OPTIONS)
                   .get())
           .hasSize(2);
     }
@@ -3806,7 +3879,7 @@
     for (com.google.gerrit.acceptance.TestAccount acc : ImmutableList.of(admin, user)) {
       requestScopeOperations.setApiUser(acc.id());
       String newMessage =
-          "modified commit by " + acc.username() + "\n\nChange-Id: " + r.getChangeId() + "\n";
+          "modified commit by " + acc.id() + "\n\nChange-Id: " + r.getChangeId() + "\n";
       gApi.changes().id(r.getChangeId()).setMessage(newMessage);
       RevisionApi rApi = gApi.changes().id(r.getChangeId()).current();
       assertThat(rApi.files().keySet()).containsExactly("/COMMIT_MSG", "a.txt");
@@ -3974,6 +4047,58 @@
     submittableAfterLosingPermissions("Label");
   }
 
+  @Test
+  @GerritConfig(name = "change.submitWholeTopic", value = "true")
+  public void cantSubmitWithInvisibleChangesWithTopic() throws Exception {
+    createBranch(BranchNameKey.create(project, "secret"));
+
+    // create two changes in the same topic.
+    String topic = "topic";
+    PushOneCommit.Result r1 = createChange();
+    PushOneCommit.Result r2 = createChange("refs/for/secret");
+    approve(r1.getChangeId());
+    approve(r2.getChangeId());
+    gApi.changes().id(r1.getChangeId()).topic(topic);
+    gApi.changes().id(r2.getChangeId()).topic(topic);
+
+    // make one of the changes invisible.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(Permission.READ).ref("refs/heads/secret").group(REGISTERED_USERS))
+        .update();
+
+    // can't submit with invisible changes.
+    requestScopeOperations.setApiUser(user.id());
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.SUBMIT).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+    assertThrows(AuthException.class, () -> gApi.changes().id(r1.getChangeId()).current().submit());
+  }
+
+  @Test
+  public void cantSubmitWithInvisibleDependentChange() throws Exception {
+    // create two dependent changes.
+    PushOneCommit.Result r1 = createChange();
+    PushOneCommit.Result r2 = createChange();
+    approve(r1.getChangeId());
+    approve(r2.getChangeId());
+
+    // make the dependent change invisible.
+    gApi.changes().id(r1.getChangeId()).setPrivate(true);
+
+    // can't submit with invisible changes.
+    requestScopeOperations.setApiUser(user.id());
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.SUBMIT).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+    assertThrows(AuthException.class, () -> gApi.changes().id(r2.getChangeId()).current().submit());
+  }
+
   private void submittableAfterLosingPermissions(String label) throws Exception {
     String codeReviewLabel = LabelId.CODE_REVIEW;
     AccountGroup.UUID registered = REGISTERED_USERS;
@@ -4218,11 +4343,11 @@
 
     PushOneCommit.Result r = createChange();
 
-    AddReviewerInput in = new AddReviewerInput();
+    ReviewerInput in = new ReviewerInput();
     in.reviewer = user.email();
     gApi.changes().id(r.getChangeId()).addReviewer(in);
 
-    in = new AddReviewerInput();
+    in = new ReviewerInput();
     in.reviewer = email;
     gApi.changes().id(r.getChangeId()).addReviewer(in);
 
@@ -4315,7 +4440,7 @@
 
     PushOneCommit.Result r = createChange();
 
-    AddReviewerInput in = new AddReviewerInput();
+    ReviewerInput in = new ReviewerInput();
     in.reviewer = user.email();
     gApi.changes().id(r.getChangeId()).addReviewer(in);
 
diff --git a/javatests/com/google/gerrit/acceptance/api/change/PostReviewIT.java b/javatests/com/google/gerrit/acceptance/api/change/PostReviewIT.java
index c8b1715..b79be80 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/PostReviewIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/PostReviewIT.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.acceptance.api.change;
 
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.util.stream.Collectors.toList;
@@ -40,12 +41,14 @@
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.SubmitRecord;
 import com.google.gerrit.extensions.annotations.Exports;
 import com.google.gerrit.extensions.api.changes.DraftInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput.CommentInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput.DraftHandling;
 import com.google.gerrit.extensions.api.changes.ReviewInput.RobotCommentInput;
+import com.google.gerrit.extensions.api.changes.ReviewResult;
 import com.google.gerrit.extensions.client.ReviewerState;
 import com.google.gerrit.extensions.client.Side;
 import com.google.gerrit.extensions.common.AccountInfo;
@@ -54,15 +57,19 @@
 import com.google.gerrit.extensions.config.FactoryModule;
 import com.google.gerrit.extensions.events.CommentAddedListener;
 import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.validators.CommentForValidation;
 import com.google.gerrit.extensions.validators.CommentValidationContext;
 import com.google.gerrit.extensions.validators.CommentValidator;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.restapi.change.OnPostReview;
 import com.google.gerrit.server.restapi.change.PostReview;
+import com.google.gerrit.server.rules.SubmitRule;
 import com.google.gerrit.server.update.CommentsRejectedException;
+import com.google.gerrit.testing.FakeEmailSender;
 import com.google.gerrit.testing.TestCommentHelper;
 import com.google.inject.Inject;
 import com.google.inject.Module;
@@ -71,6 +78,7 @@
 import java.util.List;
 import java.util.Map;
 import java.util.Optional;
+import java.util.regex.Pattern;
 import org.junit.Before;
 import org.junit.Test;
 import org.mockito.ArgumentCaptor;
@@ -677,6 +685,199 @@
     }
   }
 
+  @Test
+  public void submitRulesAreInvokedOnlyOnce() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    TestSubmitRule testSubmitRule = new TestSubmitRule();
+    try (Registration registration = extensionRegistry.newRegistration().add(testSubmitRule)) {
+      ReviewInput input = new ReviewInput().label(LabelId.CODE_REVIEW, 1);
+      gApi.changes().id(r.getChangeId()).current().review(input);
+    }
+
+    assertThat(testSubmitRule.count).isEqualTo(1);
+  }
+
+  @Test
+  public void deletingReviewers() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    TestAccount user2 = accountCreator.user2();
+
+    // add user and user2
+    gApi.changes()
+        .id(r.getChangeId())
+        .current()
+        .review(ReviewInput.create().reviewer(user.email()).reviewer(user2.email()));
+
+    sender.clear();
+
+    // remove user and user2
+    ReviewResult reviewResult =
+        gApi.changes()
+            .id(r.getChangeId())
+            .current()
+            .review(
+                ReviewInput.create()
+                    .reviewer(user.email(), ReviewerState.REMOVED, /* confirmed= */ true)
+                    .reviewer(user2.email(), ReviewerState.REMOVED, /* confirmed= */ true));
+
+    assertThat(
+            reviewResult.reviewers.values().stream()
+                .map(a -> a.removed.name)
+                .collect(toImmutableSet()))
+        .containsExactly(user.fullName(), user2.fullName());
+
+    assertThat(gApi.changes().id(r.getChangeId()).reviewers()).isEmpty();
+
+    // Ensure only one batch email was sent for this operation
+    FakeEmailSender.Message message = Iterables.getOnlyElement(sender.getMessages());
+    assertThat(message.body())
+        .containsMatch(
+            Pattern.quote("removed ")
+                + "("
+                + Pattern.quote(String.format("%s, %s", user.fullName(), user2.fullName()))
+                + "|"
+                + Pattern.quote(String.format("%s, %s", user2.fullName(), user.fullName()))
+                + ")");
+    assertThat(message.htmlBody())
+        .containsMatch(
+            Pattern.quote("removed ")
+                + "("
+                + Pattern.quote(String.format("%s and %s", user.fullName(), user2.fullName()))
+                + "|"
+                + Pattern.quote(String.format("%s and %s", user2.fullName(), user.fullName()))
+                + ")");
+  }
+
+  @Test
+  public void addingAndDeletingReviewers() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    TestAccount user2 = accountCreator.user2();
+    TestAccount user3 = accountCreator.create("user3", "user3@email.com", "user3", "user3");
+    TestAccount user4 = accountCreator.create("user4", "user4@email.com", "user4", "user4");
+
+    // add user and user2
+    gApi.changes()
+        .id(r.getChangeId())
+        .current()
+        .review(ReviewInput.create().reviewer(user.email()).reviewer(user2.email()));
+
+    sender.clear();
+
+    // remove user and user2 while adding user3 and user4
+    ReviewResult reviewResult =
+        gApi.changes()
+            .id(r.getChangeId())
+            .current()
+            .review(
+                ReviewInput.create()
+                    .reviewer(user.email(), ReviewerState.REMOVED, /* confirmed= */ true)
+                    .reviewer(user2.email(), ReviewerState.REMOVED, /* confirmed= */ true)
+                    .reviewer(user3.email())
+                    .reviewer(user4.email()));
+
+    assertThat(
+            reviewResult.reviewers.values().stream()
+                .filter(a -> a.removed != null)
+                .map(a -> a.removed.name)
+                .collect(toImmutableSet()))
+        .containsExactly(user.fullName(), user2.fullName());
+    assertThat(
+            reviewResult.reviewers.values().stream()
+                .filter(a -> a.reviewers != null)
+                .map(a -> Iterables.getOnlyElement(a.reviewers).name)
+                .collect(toImmutableSet()))
+        .containsExactly(user3.fullName(), user4.fullName());
+
+    assertThat(
+            gApi.changes().id(r.getChangeId()).reviewers().stream()
+                .map(a -> a.name)
+                .collect(toImmutableSet()))
+        .containsExactly(user3.fullName(), user4.fullName());
+
+    // Ensure only one batch email was sent for this operation
+    FakeEmailSender.Message message = Iterables.getOnlyElement(sender.getMessages());
+    assertThat(message.body())
+        .containsMatch(
+            Pattern.quote("Hello ")
+                + "("
+                + Pattern.quote(String.format("%s, %s", user3.fullName(), user4.fullName()))
+                + "|"
+                + Pattern.quote(String.format("%s, %s", user4.fullName(), user3.fullName()))
+                + ")");
+    assertThat(message.htmlBody())
+        .containsMatch(
+            "("
+                + Pattern.quote(String.format("%s and %s", user3.fullName(), user4.fullName()))
+                + "|"
+                + Pattern.quote(String.format("%s and %s", user4.fullName(), user3.fullName()))
+                + ")"
+                + Pattern.quote(" to <strong>review</strong> this change"));
+
+    assertThat(message.body())
+        .containsMatch(
+            Pattern.quote("removed ")
+                + "("
+                + Pattern.quote(String.format("%s, %s", user.fullName(), user2.fullName()))
+                + "|"
+                + Pattern.quote(String.format("%s, %s", user2.fullName(), user.fullName()))
+                + ")");
+    assertThat(message.htmlBody())
+        .containsMatch(
+            Pattern.quote("removed ")
+                + "("
+                + Pattern.quote(String.format("%s and %s", user.fullName(), user2.fullName()))
+                + "|"
+                + Pattern.quote(String.format("%s and %s", user2.fullName(), user.fullName()))
+                + ")");
+  }
+
+  @Test
+  public void deletingNonExistingReviewerFails() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    ResourceNotFoundException resourceNotFoundException =
+        assertThrows(
+            ResourceNotFoundException.class,
+            () ->
+                gApi.changes()
+                    .id(r.getChangeId())
+                    .current()
+                    .review(
+                        ReviewInput.create()
+                            .reviewer(user.email(), ReviewerState.REMOVED, /* confirmed= */ true)));
+    assertThat(resourceNotFoundException)
+        .hasMessageThat()
+        .isEqualTo(
+            String.format(
+                "Reviewer %s doesn't exist in the change, hence can't delete it", user.fullName()));
+  }
+
+  @Test
+  public void addingAndDeletingSameReviewerFails() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    ResourceNotFoundException resourceNotFoundException =
+        assertThrows(
+            ResourceNotFoundException.class,
+            () ->
+                gApi.changes()
+                    .id(r.getChangeId())
+                    .current()
+                    .review(
+                        ReviewInput.create()
+                            .reviewer(user.email())
+                            .reviewer(user.email(), ReviewerState.REMOVED, true)));
+    assertThat(resourceNotFoundException)
+        .hasMessageThat()
+        .isEqualTo(
+            String.format(
+                "Reviewer %s doesn't exist in the change," + " hence can't delete it",
+                user.fullName()));
+  }
+
   private static class TestListener implements CommentAddedListener {
     public CommentAddedListener.Event lastCommentAddedEvent;
 
@@ -753,4 +954,14 @@
       assertThat(approvals).containsExactly(labelName, (short) expectedNewValue);
     }
   }
+
+  private static class TestSubmitRule implements SubmitRule {
+    int count;
+
+    @Override
+    public Optional<SubmitRecord> evaluate(ChangeData changeData) {
+      count++;
+      return Optional.empty();
+    }
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/api/change/QueryChangesIT.java b/javatests/com/google/gerrit/acceptance/api/change/QueryChangesIT.java
index 14704ad..31381dd 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/QueryChangesIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/QueryChangesIT.java
@@ -24,8 +24,8 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.RestResponse;
 import com.google.gerrit.acceptance.UseClockStep;
 import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
@@ -51,7 +51,6 @@
 import org.eclipse.jgit.junit.TestRepository;
 import org.junit.Test;
 
-@NoHttpd
 public class QueryChangesIT extends AbstractDaemonTest {
   @Inject private AccountOperations accountOperations;
   @Inject private ProjectOperations projectOperations;
@@ -334,6 +333,13 @@
   }
 
   @Test
+  public void testInvalidListChangeOption() throws Exception {
+    PushOneCommit.Result r = createChange();
+    RestResponse rep = adminRestSession.get("/changes/" + r.getChange().getId() + "/?O=fffffff");
+    rep.assertBadRequest();
+  }
+
+  @Test
   @SuppressWarnings("unchecked")
   public void skipVisibility_privateChange() throws Exception {
     TestRepository<InMemoryRepository> userRepo = cloneProject(project, user);
diff --git a/javatests/com/google/gerrit/acceptance/api/change/StickyApprovalsIT.java b/javatests/com/google/gerrit/acceptance/api/change/StickyApprovalsIT.java
index 5c59129..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);
@@ -405,7 +466,8 @@
   }
 
   @Test
-  public void stickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsRenamed() throws Exception {
+  public void notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsRenamed()
+      throws Exception {
     try (ProjectConfigUpdate u = updateProject(project)) {
       u.getConfig()
           .updateLabelType(
@@ -420,10 +482,9 @@
     changeOperations.change(changeId).newPatchset().file("file").renameTo("new_file").create();
     ChangeInfo c = detailedChange(changeId.toString());
 
-    // only code review votes are copied since copyAllScoresIfListOfFilesDidNotChange is
-    // configured for that label, and list of files didn't change (rename is still the same file).
-    assertVotes(c, admin, 2, 0);
-    assertVotes(c, user, -2, 0);
+    // no votes are copied since the list of files changed (rename).
+    assertVotes(c, admin, 0, 0);
+    assertVotes(c, user, 0, 0);
   }
 
   @Test
@@ -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/group/BUILD b/javatests/com/google/gerrit/acceptance/api/group/BUILD
index 8530a92..29f532e 100644
--- a/javatests/com/google/gerrit/acceptance/api/group/BUILD
+++ b/javatests/com/google/gerrit/acceptance/api/group/BUILD
@@ -22,7 +22,6 @@
     deps = [
         "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/extensions:api",
-        "//java/com/google/gerrit/server",
         "//lib/truth",
     ],
 )
diff --git a/javatests/com/google/gerrit/acceptance/api/group/GroupIndexerIT.java b/javatests/com/google/gerrit/acceptance/api/group/GroupIndexerIT.java
index 87bdee4..ece46c5 100644
--- a/javatests/com/google/gerrit/acceptance/api/group/GroupIndexerIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/group/GroupIndexerIT.java
@@ -28,8 +28,8 @@
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.server.ServerInitiated;
 import com.google.gerrit.server.account.GroupCache;
+import com.google.gerrit.server.group.db.GroupDelta;
 import com.google.gerrit.server.group.db.GroupsUpdate;
-import com.google.gerrit.server.group.db.InternalGroupUpdate;
 import com.google.gerrit.server.group.testing.InternalGroupSubject;
 import com.google.gerrit.server.index.group.GroupIndexer;
 import com.google.gerrit.server.query.group.InternalGroupQuery;
@@ -60,7 +60,7 @@
     AccountGroup.UUID subgroupUuid = AccountGroup.uuid("contributors");
     updateGroupWithoutCacheOrIndex(
         groupUuid,
-        newGroupUpdate()
+        newGroupDelta()
             .setSubgroupModification(subgroups -> ImmutableSet.of(subgroupUuid))
             .build());
 
@@ -77,7 +77,7 @@
     AccountGroup.UUID subgroupUuid = AccountGroup.uuid("contributors");
     updateGroupWithoutCacheOrIndex(
         groupUuid,
-        newGroupUpdate()
+        newGroupDelta()
             .setSubgroupModification(subgroups -> ImmutableSet.of(subgroupUuid))
             .build());
 
@@ -91,7 +91,7 @@
   public void indexingUpdatesStaleUuidCache() throws Exception {
     AccountGroup.UUID groupUuid = createGroup("verifiers");
     loadGroupToCache(groupUuid);
-    updateGroupWithoutCacheOrIndex(groupUuid, newGroupUpdate().setDescription("Modified").build());
+    updateGroupWithoutCacheOrIndex(groupUuid, newGroupDelta().setDescription("Modified").build());
 
     groupIndexer.index(groupUuid);
 
@@ -105,7 +105,7 @@
     AccountGroup.UUID subgroupUuid = AccountGroup.uuid("contributors");
     updateGroupWithoutCacheOrIndex(
         groupUuid,
-        newGroupUpdate()
+        newGroupDelta()
             .setSubgroupModification(subgroups -> ImmutableSet.of(subgroupUuid))
             .build());
 
@@ -118,7 +118,7 @@
   @Test
   public void notStaleGroupIsNotReindexed() throws Exception {
     AccountGroup.UUID groupUuid = createGroup("verifiers");
-    updateGroupWithoutCacheOrIndex(groupUuid, newGroupUpdate().setDescription("Modified").build());
+    updateGroupWithoutCacheOrIndex(groupUuid, newGroupDelta().setDescription("Modified").build());
     groupIndexer.index(groupUuid);
 
     boolean reindexed = groupIndexer.reindexIfStale(groupUuid);
@@ -129,7 +129,7 @@
   @Test
   public void indexStalenessIsNotDerivedFromCacheStaleness() throws Exception {
     AccountGroup.UUID groupUuid = createGroup("verifiers");
-    updateGroupWithoutCacheOrIndex(groupUuid, newGroupUpdate().setDescription("Modified").build());
+    updateGroupWithoutCacheOrIndex(groupUuid, newGroupDelta().setDescription("Modified").build());
     reloadGroupToCache(groupUuid);
 
     boolean reindexed = groupIndexer.reindexIfStale(groupUuid);
@@ -151,14 +151,13 @@
     groupCache.get(groupUuid);
   }
 
-  private static InternalGroupUpdate.Builder newGroupUpdate() {
-    return InternalGroupUpdate.builder();
+  private static GroupDelta.Builder newGroupDelta() {
+    return GroupDelta.builder();
   }
 
-  private void updateGroupWithoutCacheOrIndex(
-      AccountGroup.UUID groupUuid, InternalGroupUpdate groupUpdate)
+  private void updateGroupWithoutCacheOrIndex(AccountGroup.UUID groupUuid, GroupDelta groupDelta)
       throws NoSuchGroupException, IOException, ConfigInvalidException {
-    groupsUpdate.updateGroupInNoteDb(groupUuid, groupUpdate);
+    groupsUpdate.updateGroupInNoteDb(groupUuid, groupDelta);
   }
 
   private static OptionalSubject<InternalGroupSubject, InternalGroup> assertThatGroup(
diff --git a/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java b/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java
index a277f26..81b9ba8 100644
--- a/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java
@@ -91,11 +91,11 @@
 import com.google.gerrit.server.account.ServiceUserClassifier;
 import com.google.gerrit.server.group.PeriodicGroupIndexer;
 import com.google.gerrit.server.group.SystemGroupBackend;
+import com.google.gerrit.server.group.db.GroupDelta;
 import com.google.gerrit.server.group.db.Groups;
 import com.google.gerrit.server.group.db.GroupsConsistencyChecker;
 import com.google.gerrit.server.group.db.GroupsUpdate;
 import com.google.gerrit.server.group.db.InternalGroupCreation;
-import com.google.gerrit.server.group.db.InternalGroupUpdate;
 import com.google.gerrit.server.index.group.GroupIndexer;
 import com.google.gerrit.server.index.group.StalenessChecker;
 import com.google.gerrit.server.notedb.Sequences;
@@ -201,10 +201,10 @@
   public void addRemoveMember() throws Exception {
     AccountGroup.UUID group = groupOperations.newGroup().create();
 
-    gApi.groups().id(group.get()).addMembers("user");
+    gApi.groups().id(group.get()).addMembers("user1");
     assertMembers(group.get(), user);
 
-    gApi.groups().id(group.get()).removeMembers("user");
+    gApi.groups().id(group.get()).removeMembers("user1");
     ImmutableSet<Account.Id> members = groupOperations.group(group).get().members();
     assertThat(members).isEmpty();
   }
@@ -1486,14 +1486,14 @@
               .setNameKey(AccountGroup.nameKey(groupName))
               .setId(AccountGroup.id(seq.nextGroupId()))
               .build(),
-          InternalGroupUpdate.builder().build());
+          GroupDelta.builder().build());
       slaveGroupIndexer.run();
       groupIndexedCounter.assertReindexOf(groupUuid);
 
       // Update a group without updating the cache or index,
       // then run the reindexer -> only the updated group is reindexed.
       groupsUpdate.updateGroupInNoteDb(
-          groupUuid, InternalGroupUpdate.builder().setDescription("bar").build());
+          groupUuid, GroupDelta.builder().setDescription("bar").build());
       slaveGroupIndexer.run();
       groupIndexedCounter.assertReindexOf(groupUuid);
 
diff --git a/javatests/com/google/gerrit/acceptance/api/group/GroupsUpdateIT.java b/javatests/com/google/gerrit/acceptance/api/group/GroupsUpdateIT.java
index c977d43..e57c82e 100644
--- a/javatests/com/google/gerrit/acceptance/api/group/GroupsUpdateIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/group/GroupsUpdateIT.java
@@ -24,10 +24,10 @@
 import com.google.gerrit.exceptions.NoSuchGroupException;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.server.ServerInitiated;
+import com.google.gerrit.server.group.db.GroupDelta;
 import com.google.gerrit.server.group.db.Groups;
 import com.google.gerrit.server.group.db.GroupsUpdate;
 import com.google.gerrit.server.group.db.InternalGroupCreation;
-import com.google.gerrit.server.group.db.InternalGroupUpdate;
 import com.google.gerrit.testing.InMemoryTestEnvironment;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -46,12 +46,12 @@
   @Test
   public void groupCreationIsRetriedWhenFailedDueToConcurrentNameModification() throws Exception {
     InternalGroupCreation groupCreation = getGroupCreation("users", "users-UUID");
-    InternalGroupUpdate groupUpdate =
-        InternalGroupUpdate.builder()
+    GroupDelta groupDelta =
+        GroupDelta.builder()
             .setMemberModification(
                 new CreateAnotherGroupOnceAsSideEffectOfMemberModification("verifiers"))
             .build();
-    createGroup(groupCreation, groupUpdate);
+    createGroup(groupCreation, groupDelta);
 
     Stream<String> allGroupNames = getAllGroupNames();
     assertThat(allGroupNames).containsAtLeast("users", "verifiers");
@@ -61,13 +61,13 @@
   public void groupRenameIsRetriedWhenFailedDueToConcurrentNameModification() throws Exception {
     createGroup("users", "users-UUID");
 
-    InternalGroupUpdate groupUpdate =
-        InternalGroupUpdate.builder()
+    GroupDelta groupDelta =
+        GroupDelta.builder()
             .setName(AccountGroup.nameKey("contributors"))
             .setMemberModification(
                 new CreateAnotherGroupOnceAsSideEffectOfMemberModification("verifiers"))
             .build();
-    updateGroup(AccountGroup.uuid("users-UUID"), groupUpdate);
+    updateGroup(AccountGroup.uuid("users-UUID"), groupDelta);
 
     Stream<String> allGroupNames = getAllGroupNames();
     assertThat(allGroupNames).containsAtLeast("contributors", "verifiers");
@@ -75,28 +75,27 @@
 
   @Test
   public void groupUpdateFailsWithExceptionForNotExistingGroup() throws Exception {
-    InternalGroupUpdate groupUpdate =
-        InternalGroupUpdate.builder().setDescription("A description for the group").build();
+    GroupDelta groupDelta =
+        GroupDelta.builder().setDescription("A description for the group").build();
     assertThrows(
         NoSuchGroupException.class,
-        () -> updateGroup(AccountGroup.uuid("nonexistent-group-UUID"), groupUpdate));
+        () -> updateGroup(AccountGroup.uuid("nonexistent-group-UUID"), groupDelta));
   }
 
   private void createGroup(String groupName, String groupUuid) throws Exception {
     InternalGroupCreation groupCreation = getGroupCreation(groupName, groupUuid);
-    InternalGroupUpdate groupUpdate = InternalGroupUpdate.builder().build();
+    GroupDelta groupDelta = GroupDelta.builder().build();
 
-    createGroup(groupCreation, groupUpdate);
+    createGroup(groupCreation, groupDelta);
   }
 
-  private void createGroup(InternalGroupCreation groupCreation, InternalGroupUpdate groupUpdate)
+  private void createGroup(InternalGroupCreation groupCreation, GroupDelta groupDelta)
       throws IOException, ConfigInvalidException {
-    groupsUpdateProvider.get().createGroup(groupCreation, groupUpdate);
+    groupsUpdateProvider.get().createGroup(groupCreation, groupDelta);
   }
 
-  private void updateGroup(AccountGroup.UUID groupUuid, InternalGroupUpdate groupUpdate)
-      throws Exception {
-    groupsUpdateProvider.get().updateGroup(groupUuid, groupUpdate);
+  private void updateGroup(AccountGroup.UUID groupUuid, GroupDelta groupDelta) throws Exception {
+    groupsUpdateProvider.get().updateGroup(groupUuid, groupDelta);
   }
 
   private Stream<String> getAllGroupNames() throws IOException, ConfigInvalidException {
@@ -112,7 +111,7 @@
   }
 
   private class CreateAnotherGroupOnceAsSideEffectOfMemberModification
-      implements InternalGroupUpdate.MemberModification {
+      implements GroupDelta.MemberModification {
 
     private boolean groupCreated = false;
     private String groupName;
@@ -133,9 +132,9 @@
 
     private void createGroup() {
       InternalGroupCreation groupCreation = getGroupCreation(groupName, groupName + "-UUID");
-      InternalGroupUpdate groupUpdate = InternalGroupUpdate.builder().build();
+      GroupDelta groupDelta = GroupDelta.builder().build();
       try {
-        groupsUpdateProvider.get().createGroup(groupCreation, groupUpdate);
+        groupsUpdateProvider.get().createGroup(groupCreation, groupDelta);
       } catch (StorageException | IOException | ConfigInvalidException e) {
         throw new IllegalStateException(e);
       }
diff --git a/javatests/com/google/gerrit/acceptance/api/project/AccessIT.java b/javatests/com/google/gerrit/acceptance/api/project/AccessIT.java
new file mode 100644
index 0000000..32e8232
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/project/AccessIT.java
@@ -0,0 +1,1015 @@
+// 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.
+
+package com.google.gerrit.acceptance.api.project;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth8.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.extensions.client.ListChangesOption.MESSAGES;
+import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.server.schema.AclUtil.grant;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static com.google.gerrit.truth.ConfigSubject.assertThat;
+import static com.google.gerrit.truth.MapSubject.assertThatMap;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.ExtensionRegistry;
+import com.google.gerrit.acceptance.ExtensionRegistry.Registration;
+import com.google.gerrit.acceptance.GitUtil;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.common.data.GlobalCapability;
+import com.google.gerrit.entities.AccessSection;
+import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.entities.LabelId;
+import com.google.gerrit.entities.Permission;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.extensions.api.access.AccessSectionInfo;
+import com.google.gerrit.extensions.api.access.PermissionInfo;
+import com.google.gerrit.extensions.api.access.PermissionRuleInfo;
+import com.google.gerrit.extensions.api.access.ProjectAccessInfo;
+import com.google.gerrit.extensions.api.access.ProjectAccessInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.projects.ProjectApi;
+import com.google.gerrit.extensions.client.ChangeStatus;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.GroupInfo;
+import com.google.gerrit.extensions.common.WebLinkInfo;
+import com.google.gerrit.extensions.config.CapabilityDefinition;
+import com.google.gerrit.extensions.config.PluginProjectPermissionDefinition;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.webui.FileHistoryWebLink;
+import com.google.gerrit.server.config.AllProjectsNameProvider;
+import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
+import com.google.gerrit.server.git.meta.MetaDataUpdate;
+import com.google.gerrit.server.group.SystemGroupBackend;
+import com.google.gerrit.server.project.ProjectConfig;
+import com.google.gerrit.server.schema.GrantRevertPermission;
+import com.google.inject.Inject;
+import java.util.HashMap;
+import java.util.Map;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.RefUpdate;
+import org.eclipse.jgit.lib.RefUpdate.Result;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.junit.Before;
+import org.junit.Test;
+
+public class AccessIT extends AbstractDaemonTest {
+
+  private static final String REFS_ALL = Constants.R_REFS + "*";
+  private static final String REFS_HEADS = Constants.R_HEADS + "*";
+  private static final String REFS_META_VERSION = "refs/meta/version";
+  private static final String REFS_DRAFTS = "refs/draft-comments/*";
+  private static final String REFS_STARRED_CHANGES = "refs/starred-changes/*";
+
+  @Inject private ProjectOperations projectOperations;
+  @Inject private RequestScopeOperations requestScopeOperations;
+  @Inject private ExtensionRegistry extensionRegistry;
+  @Inject private GrantRevertPermission grantRevertPermission;
+
+  private Project.NameKey newProjectName;
+
+  @Before
+  public void setUp() throws Exception {
+    newProjectName = projectOperations.newProject().create();
+  }
+
+  @Test
+  public void grantRevertPermission() throws Exception {
+    String ref = "refs/*";
+    String groupId = "global:Registered-Users";
+
+    grantRevertPermission.execute(newProjectName);
+
+    ProjectAccessInfo info = pApi().access();
+    assertThat(info.local.containsKey(ref)).isTrue();
+    AccessSectionInfo accessSectionInfo = info.local.get(ref);
+    assertThat(accessSectionInfo.permissions.containsKey(Permission.REVERT)).isTrue();
+    PermissionInfo permissionInfo = accessSectionInfo.permissions.get(Permission.REVERT);
+    assertThat(permissionInfo.rules.containsKey(groupId)).isTrue();
+    PermissionRuleInfo permissionRuleInfo = permissionInfo.rules.get(groupId);
+    assertThat(permissionRuleInfo.action).isEqualTo(PermissionRuleInfo.Action.ALLOW);
+  }
+
+  @Test
+  public void grantRevertPermissionByOnNewRefAndDeletingOnOldRef() throws Exception {
+    String refsHeads = "refs/heads/*";
+    String refsStar = "refs/*";
+    String groupId = "global:Registered-Users";
+    GroupReference registeredUsers = systemGroupBackend.getGroup(REGISTERED_USERS);
+
+    try (Repository repo = repoManager.openRepository(newProjectName)) {
+      MetaDataUpdate md = new MetaDataUpdate(GitReferenceUpdated.DISABLED, newProjectName, repo);
+      ProjectConfig projectConfig = projectConfigFactory.read(md);
+      projectConfig.upsertAccessSection(
+          AccessSection.HEADS,
+          heads -> {
+            grant(projectConfig, heads, Permission.REVERT, registeredUsers);
+          });
+      md.getCommitBuilder().setAuthor(admin.newIdent());
+      md.getCommitBuilder().setCommitter(admin.newIdent());
+      md.setMessage("Add revert permission for all registered users\n");
+
+      projectConfig.commit(md);
+    }
+    grantRevertPermission.execute(newProjectName);
+
+    ProjectAccessInfo info = pApi().access();
+
+    // Revert permission is removed on refs/heads/*.
+    assertThat(info.local.containsKey(refsHeads)).isTrue();
+    AccessSectionInfo accessSectionInfo = info.local.get(refsHeads);
+    assertThat(accessSectionInfo.permissions.containsKey(Permission.REVERT)).isFalse();
+
+    // new permission is added on refs/* with Registered-Users.
+    assertThat(info.local.containsKey(refsStar)).isTrue();
+    accessSectionInfo = info.local.get(refsStar);
+    assertThat(accessSectionInfo.permissions.containsKey(Permission.REVERT)).isTrue();
+    PermissionInfo permissionInfo = accessSectionInfo.permissions.get(Permission.REVERT);
+    assertThat(permissionInfo.rules.containsKey(groupId)).isTrue();
+    PermissionRuleInfo permissionRuleInfo = permissionInfo.rules.get(groupId);
+    assertThat(permissionRuleInfo.action).isEqualTo(PermissionRuleInfo.Action.ALLOW);
+  }
+
+  @Test
+  public void grantRevertPermissionDoesntDeleteAdminsPreferences() throws Exception {
+    GroupReference registeredUsers = systemGroupBackend.getGroup(REGISTERED_USERS);
+    GroupReference otherGroup = systemGroupBackend.getGroup(ANONYMOUS_USERS);
+
+    try (Repository repo = repoManager.openRepository(newProjectName)) {
+      MetaDataUpdate md = new MetaDataUpdate(GitReferenceUpdated.DISABLED, newProjectName, repo);
+      ProjectConfig projectConfig = projectConfigFactory.read(md);
+      projectConfig.upsertAccessSection(
+          AccessSection.HEADS,
+          heads -> {
+            grant(projectConfig, heads, Permission.REVERT, registeredUsers);
+            grant(projectConfig, heads, Permission.REVERT, otherGroup);
+          });
+      md.getCommitBuilder().setAuthor(admin.newIdent());
+      md.getCommitBuilder().setCommitter(admin.newIdent());
+      md.setMessage("Add revert permission for all registered users\n");
+
+      projectConfig.commit(md);
+    }
+    projectCache.evict(newProjectName);
+    ProjectAccessInfo expected = pApi().access();
+
+    grantRevertPermission.execute(newProjectName);
+    projectCache.evict(newProjectName);
+    ProjectAccessInfo actual = pApi().access();
+    // Permissions don't change
+    assertThat(expected.local).isEqualTo(actual.local);
+  }
+
+  @Test
+  public void grantRevertPermissionOnlyWorksOnce() throws Exception {
+    grantRevertPermission.execute(newProjectName);
+    grantRevertPermission.execute(newProjectName);
+
+    try (Repository repo = repoManager.openRepository(newProjectName)) {
+      MetaDataUpdate md = new MetaDataUpdate(GitReferenceUpdated.DISABLED, newProjectName, repo);
+      ProjectConfig projectConfig = projectConfigFactory.read(md);
+      AccessSection all = projectConfig.getAccessSection(AccessSection.ALL);
+
+      Permission permission = all.getPermission(Permission.REVERT);
+      assertThat(permission.getRules()).hasSize(1);
+    }
+  }
+
+  @Test
+  public void getDefaultInheritance() throws Exception {
+    String inheritedName = pApi().access().inheritsFrom.name;
+    assertThat(inheritedName).isEqualTo(AllProjectsNameProvider.DEFAULT);
+  }
+
+  private Registration newFileHistoryWebLink() {
+    FileHistoryWebLink weblink =
+        new FileHistoryWebLink() {
+          @Override
+          public WebLinkInfo getFileHistoryWebLink(
+              String projectName, String revision, String fileName) {
+            return new WebLinkInfo(
+                "name", "imageURL", "http://view/" + projectName + "/" + fileName);
+          }
+        };
+    return extensionRegistry.newRegistration().add(weblink);
+  }
+
+  @Test
+  public void webLink() throws Exception {
+    try (Registration registration = newFileHistoryWebLink()) {
+      ProjectAccessInfo info = pApi().access();
+      assertThat(info.configWebLinks).hasSize(1);
+      assertThat(info.configWebLinks.get(0).url)
+          .isEqualTo("http://view/" + newProjectName + "/project.config");
+    }
+  }
+
+  @Test
+  public void webLinkNoRefsMetaConfig() throws Exception {
+    try (Repository repo = repoManager.openRepository(newProjectName);
+        Registration registration = newFileHistoryWebLink()) {
+      RefUpdate u = repo.updateRef(RefNames.REFS_CONFIG);
+      u.setForceUpdate(true);
+      assertThat(u.delete()).isEqualTo(Result.FORCED);
+
+      // This should not crash.
+      pApi().access();
+    }
+  }
+
+  @Test
+  public void addAccessSection() throws Exception {
+    RevCommit initialHead = projectOperations.project(newProjectName).getHead(RefNames.REFS_CONFIG);
+
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    AccessSectionInfo accessSectionInfo = createDefaultAccessSectionInfo();
+
+    accessInput.add.put(REFS_HEADS, accessSectionInfo);
+    pApi().access(accessInput);
+
+    assertThat(pApi().access().local).isEqualTo(accessInput.add);
+
+    RevCommit updatedHead = projectOperations.project(newProjectName).getHead(RefNames.REFS_CONFIG);
+    eventRecorder.assertRefUpdatedEvents(
+        newProjectName.get(), RefNames.REFS_CONFIG, null, initialHead, initialHead, updatedHead);
+  }
+
+  @Test
+  public void addAccessSectionForPluginPermission() throws Exception {
+    try (Registration registration =
+        extensionRegistry
+            .newRegistration()
+            .add(
+                new PluginProjectPermissionDefinition() {
+                  @Override
+                  public String getDescription() {
+                    return "A Plugin Project Permission";
+                  }
+                },
+                "fooPermission")) {
+      ProjectAccessInput accessInput = newProjectAccessInput();
+      AccessSectionInfo accessSectionInfo = newAccessSectionInfo();
+
+      PermissionInfo foo = newPermissionInfo();
+      PermissionRuleInfo pri = new PermissionRuleInfo(PermissionRuleInfo.Action.ALLOW, false);
+      foo.rules.put(SystemGroupBackend.REGISTERED_USERS.get(), pri);
+      accessSectionInfo.permissions.put(
+          "plugin-" + ExtensionRegistry.PLUGIN_NAME + "-fooPermission", foo);
+
+      accessInput.add.put(REFS_HEADS, accessSectionInfo);
+      ProjectAccessInfo updatedAccessSectionInfo = pApi().access(accessInput);
+      assertThat(updatedAccessSectionInfo.local).isEqualTo(accessInput.add);
+
+      assertThat(pApi().access().local).isEqualTo(accessInput.add);
+    }
+  }
+
+  @Test
+  public void addAccessSectionWithInvalidPermission() throws Exception {
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    AccessSectionInfo accessSectionInfo = createDefaultAccessSectionInfo();
+
+    PermissionInfo push = newPermissionInfo();
+    PermissionRuleInfo pri = new PermissionRuleInfo(PermissionRuleInfo.Action.ALLOW, false);
+    push.rules.put(SystemGroupBackend.REGISTERED_USERS.get(), pri);
+    accessSectionInfo.permissions.put("Invalid Permission", push);
+
+    accessInput.add.put(REFS_HEADS, accessSectionInfo);
+    BadRequestException ex =
+        assertThrows(BadRequestException.class, () -> pApi().access(accessInput));
+    assertThat(ex).hasMessageThat().isEqualTo("Unknown permission: Invalid Permission");
+  }
+
+  @Test
+  public void addAccessSectionWithInvalidLabelPermission() throws Exception {
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    AccessSectionInfo accessSectionInfo = createDefaultAccessSectionInfo();
+
+    PermissionInfo push = newPermissionInfo();
+    PermissionRuleInfo pri = new PermissionRuleInfo(PermissionRuleInfo.Action.ALLOW, false);
+    push.rules.put(SystemGroupBackend.REGISTERED_USERS.get(), pri);
+    accessSectionInfo.permissions.put("label-Invalid Permission", push);
+
+    accessInput.add.put(REFS_HEADS, accessSectionInfo);
+    BadRequestException ex =
+        assertThrows(BadRequestException.class, () -> pApi().access(accessInput));
+    assertThat(ex).hasMessageThat().isEqualTo("Unknown permission: label-Invalid Permission");
+  }
+
+  @Test
+  public void createAccessChangeNop() throws Exception {
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    assertThrows(BadRequestException.class, () -> pApi().accessChange(accessInput));
+  }
+
+  @Test
+  public void createAccessChangeEmptyConfig() throws Exception {
+    try (Repository repo = repoManager.openRepository(newProjectName)) {
+      RefUpdate ru = repo.updateRef(RefNames.REFS_CONFIG);
+      ru.setForceUpdate(true);
+      assertThat(ru.delete()).isEqualTo(Result.FORCED);
+
+      ProjectAccessInput accessInput = newProjectAccessInput();
+      AccessSectionInfo accessSection = newAccessSectionInfo();
+      PermissionInfo read = newPermissionInfo();
+      PermissionRuleInfo pri = new PermissionRuleInfo(PermissionRuleInfo.Action.BLOCK, false);
+      read.rules.put(SystemGroupBackend.REGISTERED_USERS.get(), pri);
+      accessSection.permissions.put(Permission.READ, read);
+      accessInput.add.put(REFS_HEADS, accessSection);
+
+      ChangeInfo out = pApi().accessChange(accessInput);
+      assertThat(out.status).isEqualTo(ChangeStatus.NEW);
+    }
+  }
+
+  @Test
+  public void createAccessChange() throws Exception {
+    projectOperations
+        .project(newProjectName)
+        .forUpdate()
+        .add(allow(Permission.READ).ref(RefNames.REFS_CONFIG).group(REGISTERED_USERS))
+        .update();
+    // User can see the branch
+    requestScopeOperations.setApiUser(user.id());
+    pApi().branch("refs/heads/master").get();
+
+    ProjectAccessInput accessInput = newProjectAccessInput();
+
+    AccessSectionInfo accessSection = newAccessSectionInfo();
+
+    // Deny read to registered users.
+    PermissionInfo read = newPermissionInfo();
+    PermissionRuleInfo pri = new PermissionRuleInfo(PermissionRuleInfo.Action.DENY, false);
+    read.rules.put(SystemGroupBackend.REGISTERED_USERS.get(), pri);
+    read.exclusive = true;
+    accessSection.permissions.put(Permission.READ, read);
+    accessInput.add.put(REFS_HEADS, accessSection);
+
+    requestScopeOperations.setApiUser(user.id());
+    ChangeInfo out = pApi().accessChange(accessInput);
+
+    assertThat(out.project).isEqualTo(newProjectName.get());
+    assertThat(out.branch).isEqualTo(RefNames.REFS_CONFIG);
+    assertThat(out.status).isEqualTo(ChangeStatus.NEW);
+    assertThat(out.submitted).isNull();
+
+    requestScopeOperations.setApiUser(admin.id());
+
+    ChangeInfo c = gApi.changes().id(out._number).get(MESSAGES);
+    assertThat(c.messages.stream().map(m -> m.message)).containsExactly("Uploaded patch set 1");
+
+    ReviewInput reviewIn = new ReviewInput();
+    reviewIn.label("Code-Review", (short) 2);
+    gApi.changes().id(out._number).current().review(reviewIn);
+    gApi.changes().id(out._number).current().submit();
+
+    // check that the change took effect.
+    requestScopeOperations.setApiUser(user.id());
+    assertThrows(ResourceNotFoundException.class, () -> pApi().branch("refs/heads/master").get());
+
+    // Restore.
+    accessInput.add.clear();
+    accessInput.remove.put(REFS_HEADS, accessSection);
+    requestScopeOperations.setApiUser(user.id());
+
+    requestScopeOperations.setApiUser(admin.id());
+    out = pApi().accessChange(accessInput);
+
+    gApi.changes().id(out._number).current().review(reviewIn);
+    gApi.changes().id(out._number).current().submit();
+
+    // Now it works again.
+    requestScopeOperations.setApiUser(user.id());
+    pApi().branch("refs/heads/master").get();
+  }
+
+  @Test
+  public void removePermission() throws Exception {
+    // Add initial permission set
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    AccessSectionInfo accessSectionInfo = createDefaultAccessSectionInfo();
+
+    accessInput.add.put(REFS_HEADS, accessSectionInfo);
+    pApi().access(accessInput);
+
+    // Remove specific permission
+    AccessSectionInfo accessSectionToRemove = newAccessSectionInfo();
+    accessSectionToRemove.permissions.put(
+        Permission.LABEL + LabelId.CODE_REVIEW, newPermissionInfo());
+    ProjectAccessInput removal = newProjectAccessInput();
+    removal.remove.put(REFS_HEADS, accessSectionToRemove);
+    pApi().access(removal);
+
+    // Remove locally
+    accessInput.add.get(REFS_HEADS).permissions.remove(Permission.LABEL + LabelId.CODE_REVIEW);
+
+    // Check
+    assertThat(pApi().access().local).isEqualTo(accessInput.add);
+  }
+
+  @Test
+  public void removePermissionRule() throws Exception {
+    // Add initial permission set
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    AccessSectionInfo accessSectionInfo = createDefaultAccessSectionInfo();
+
+    accessInput.add.put(REFS_HEADS, accessSectionInfo);
+    pApi().access(accessInput);
+
+    // Remove specific permission rule
+    AccessSectionInfo accessSectionToRemove = newAccessSectionInfo();
+    PermissionInfo codeReview = newPermissionInfo();
+    codeReview.label = LabelId.CODE_REVIEW;
+    PermissionRuleInfo pri = new PermissionRuleInfo(PermissionRuleInfo.Action.DENY, false);
+    codeReview.rules.put(SystemGroupBackend.REGISTERED_USERS.get(), pri);
+    accessSectionToRemove.permissions.put(Permission.LABEL + LabelId.CODE_REVIEW, codeReview);
+    ProjectAccessInput removal = newProjectAccessInput();
+    removal.remove.put(REFS_HEADS, accessSectionToRemove);
+    pApi().access(removal);
+
+    // Remove locally
+    accessInput
+        .add
+        .get(REFS_HEADS)
+        .permissions
+        .get(Permission.LABEL + LabelId.CODE_REVIEW)
+        .rules
+        .remove(SystemGroupBackend.REGISTERED_USERS.get());
+
+    // Check
+    assertThat(pApi().access().local).isEqualTo(accessInput.add);
+  }
+
+  @Test
+  public void removePermissionRulesAndCleanupEmptyEntries() throws Exception {
+    // Add initial permission set
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    AccessSectionInfo accessSectionInfo = createDefaultAccessSectionInfo();
+
+    accessInput.add.put(REFS_HEADS, accessSectionInfo);
+    pApi().access(accessInput);
+
+    // Remove specific permission rules
+    AccessSectionInfo accessSectionToRemove = newAccessSectionInfo();
+    PermissionInfo codeReview = newPermissionInfo();
+    codeReview.label = LabelId.CODE_REVIEW;
+    PermissionRuleInfo pri = new PermissionRuleInfo(PermissionRuleInfo.Action.DENY, false);
+    codeReview.rules.put(SystemGroupBackend.REGISTERED_USERS.get(), pri);
+    pri = new PermissionRuleInfo(PermissionRuleInfo.Action.DENY, false);
+    codeReview.rules.put(SystemGroupBackend.PROJECT_OWNERS.get(), pri);
+    accessSectionToRemove.permissions.put(Permission.LABEL + LabelId.CODE_REVIEW, codeReview);
+    ProjectAccessInput removal = newProjectAccessInput();
+    removal.remove.put(REFS_HEADS, accessSectionToRemove);
+    pApi().access(removal);
+
+    // Remove locally
+    accessInput.add.get(REFS_HEADS).permissions.remove(Permission.LABEL + LabelId.CODE_REVIEW);
+
+    // Check
+    assertThat(pApi().access().local).isEqualTo(accessInput.add);
+  }
+
+  @Test
+  public void getPermissionsWithDisallowedUser() throws Exception {
+    // Add initial permission set
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    AccessSectionInfo accessSectionInfo = createAccessSectionInfoDenyAll();
+
+    // Disallow READ
+    accessInput.add.put(REFS_META_VERSION, accessSectionInfo);
+    accessInput.add.put(REFS_DRAFTS, accessSectionInfo);
+    accessInput.add.put(REFS_STARRED_CHANGES, accessSectionInfo);
+    accessInput.add.put(REFS_HEADS, accessSectionInfo);
+    pApi().access(accessInput);
+
+    requestScopeOperations.setApiUser(user.id());
+    assertThrows(ResourceNotFoundException.class, () -> pApi().access());
+  }
+
+  @Test
+  public void setPermissionsWithDisallowedUser() throws Exception {
+    // Add initial permission set
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    AccessSectionInfo accessSectionInfo = createAccessSectionInfoDenyAll();
+
+    // Disallow READ
+    accessInput.add.put(REFS_META_VERSION, accessSectionInfo);
+    accessInput.add.put(REFS_DRAFTS, accessSectionInfo);
+    accessInput.add.put(REFS_STARRED_CHANGES, accessSectionInfo);
+    accessInput.add.put(REFS_HEADS, accessSectionInfo);
+    pApi().access(accessInput);
+
+    // Create a change to apply
+    ProjectAccessInput accessInfoToApply = newProjectAccessInput();
+    AccessSectionInfo accessSectionInfoToApply = createDefaultAccessSectionInfo();
+    accessInfoToApply.add.put(REFS_HEADS, accessSectionInfoToApply);
+
+    requestScopeOperations.setApiUser(user.id());
+    assertThrows(ResourceNotFoundException.class, () -> pApi().access());
+  }
+
+  @Test
+  public void permissionsGroupMap() throws Exception {
+    // Add initial permission set
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    AccessSectionInfo accessSection = newAccessSectionInfo();
+
+    PermissionInfo push = newPermissionInfo();
+    PermissionRuleInfo pri = new PermissionRuleInfo(PermissionRuleInfo.Action.ALLOW, false);
+    push.rules.put(SystemGroupBackend.PROJECT_OWNERS.get(), pri);
+    accessSection.permissions.put(Permission.PUSH, push);
+
+    PermissionInfo read = newPermissionInfo();
+    pri = new PermissionRuleInfo(PermissionRuleInfo.Action.ALLOW, false);
+    read.rules.put(SystemGroupBackend.ANONYMOUS_USERS.get(), pri);
+    accessSection.permissions.put(Permission.READ, read);
+
+    accessInput.add.put(REFS_ALL, accessSection);
+    ProjectAccessInfo result = pApi().access(accessInput);
+    assertThatMap(result.groups)
+        .keys()
+        .containsExactly(
+            SystemGroupBackend.PROJECT_OWNERS.get(), SystemGroupBackend.ANONYMOUS_USERS.get());
+
+    // Check the name, which is what the UI cares about; exhaustive
+    // coverage of GroupInfo should be in groups REST API tests.
+    assertThat(result.groups.get(SystemGroupBackend.PROJECT_OWNERS.get()).name)
+        .isEqualTo("Project Owners");
+    // Strip the ID, since it is in the key.
+    assertThat(result.groups.get(SystemGroupBackend.PROJECT_OWNERS.get()).id).isNull();
+
+    // Get call returns groups too.
+    ProjectAccessInfo loggedInResult = pApi().access();
+    assertThatMap(loggedInResult.groups)
+        .keys()
+        .containsExactly(
+            SystemGroupBackend.PROJECT_OWNERS.get(), SystemGroupBackend.ANONYMOUS_USERS.get());
+
+    GroupInfo owners = loggedInResult.groups.get(SystemGroupBackend.PROJECT_OWNERS.get());
+    assertThat(owners.name).isEqualTo("Project Owners");
+    assertThat(owners.id).isNull();
+    assertThat(owners.members).isNull();
+    assertThat(owners.includes).isNull();
+
+    // PROJECT_OWNERS is invisible to anonymous user, but GetAccess disregards visibility.
+    requestScopeOperations.setApiUserAnonymous();
+    ProjectAccessInfo anonResult = pApi().access();
+    assertThatMap(anonResult.groups)
+        .keys()
+        .containsExactly(
+            SystemGroupBackend.PROJECT_OWNERS.get(), SystemGroupBackend.ANONYMOUS_USERS.get());
+  }
+
+  @Test
+  public void updateParentAsUser() throws Exception {
+    // Create child
+    String newParentProjectName = projectOperations.newProject().create().get();
+
+    // Set new parent
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    accessInput.parent = newParentProjectName;
+
+    requestScopeOperations.setApiUser(user.id());
+    AuthException thrown = assertThrows(AuthException.class, () -> pApi().access(accessInput));
+    assertThat(thrown).hasMessageThat().contains("administrate server not permitted");
+  }
+
+  @Test
+  public void updateParentAsAdministrator() throws Exception {
+    // Create parent
+    String newParentProjectName = projectOperations.newProject().create().get();
+
+    // Set new parent
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    accessInput.parent = newParentProjectName;
+
+    pApi().access(accessInput);
+
+    assertThat(pApi().access().inheritsFrom.name).isEqualTo(newParentProjectName);
+  }
+
+  @Test
+  public void addGlobalCapabilityAsUser() throws Exception {
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    AccessSectionInfo accessSectionInfo = createDefaultGlobalCapabilitiesAccessSectionInfo();
+
+    accessInput.add.put(AccessSection.GLOBAL_CAPABILITIES, accessSectionInfo);
+
+    requestScopeOperations.setApiUser(user.id());
+    assertThrows(
+        AuthException.class, () -> gApi.projects().name(allProjects.get()).access(accessInput));
+  }
+
+  @Test
+  public void addGlobalCapabilityAsAdmin() throws Exception {
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    AccessSectionInfo accessSectionInfo = createDefaultGlobalCapabilitiesAccessSectionInfo();
+
+    accessInput.add.put(AccessSection.GLOBAL_CAPABILITIES, accessSectionInfo);
+
+    ProjectAccessInfo updatedAccessSectionInfo =
+        gApi.projects().name(allProjects.get()).access(accessInput);
+    assertThatMap(updatedAccessSectionInfo.local.get(AccessSection.GLOBAL_CAPABILITIES).permissions)
+        .keys()
+        .containsAtLeastElementsIn(accessSectionInfo.permissions.keySet());
+  }
+
+  @Test
+  public void addPluginGlobalCapability() throws Exception {
+    try (Registration registration =
+        extensionRegistry
+            .newRegistration()
+            .add(
+                new CapabilityDefinition() {
+                  @Override
+                  public String getDescription() {
+                    return "A Plugin Global Capability";
+                  }
+                },
+                "fooCapability")) {
+      ProjectAccessInput accessInput = newProjectAccessInput();
+      AccessSectionInfo accessSectionInfo = newAccessSectionInfo();
+
+      PermissionInfo foo = newPermissionInfo();
+      PermissionRuleInfo pri = new PermissionRuleInfo(PermissionRuleInfo.Action.ALLOW, false);
+      foo.rules.put(SystemGroupBackend.REGISTERED_USERS.get(), pri);
+      accessSectionInfo.permissions.put(ExtensionRegistry.PLUGIN_NAME + "-fooCapability", foo);
+
+      accessInput.add.put(AccessSection.GLOBAL_CAPABILITIES, accessSectionInfo);
+
+      ProjectAccessInfo updatedAccessSectionInfo =
+          gApi.projects().name(allProjects.get()).access(accessInput);
+      assertThatMap(
+              updatedAccessSectionInfo.local.get(AccessSection.GLOBAL_CAPABILITIES).permissions)
+          .keys()
+          .containsAtLeastElementsIn(accessSectionInfo.permissions.keySet());
+    }
+  }
+
+  @Test
+  public void addPermissionAsGlobalCapability() throws Exception {
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    AccessSectionInfo accessSectionInfo = newAccessSectionInfo();
+
+    PermissionInfo push = newPermissionInfo();
+    PermissionRuleInfo pri = new PermissionRuleInfo(PermissionRuleInfo.Action.ALLOW, false);
+    push.rules.put(SystemGroupBackend.REGISTERED_USERS.get(), pri);
+    accessSectionInfo.permissions.put(Permission.PUSH, push);
+
+    accessInput.add.put(AccessSection.GLOBAL_CAPABILITIES, accessSectionInfo);
+    BadRequestException ex =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.projects().name(allProjects.get()).access(accessInput));
+    assertThat(ex).hasMessageThat().isEqualTo("Unknown global capability: " + Permission.PUSH);
+  }
+
+  @Test
+  public void addInvalidGlobalCapability() throws Exception {
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    AccessSectionInfo accessSectionInfo = createDefaultGlobalCapabilitiesAccessSectionInfo();
+
+    PermissionInfo permissionInfo = newPermissionInfo();
+    PermissionRuleInfo pri = new PermissionRuleInfo(PermissionRuleInfo.Action.ALLOW, false);
+    permissionInfo.rules.put(SystemGroupBackend.REGISTERED_USERS.get(), pri);
+    accessSectionInfo.permissions.put("Invalid Global Capability", permissionInfo);
+
+    accessInput.add.put(AccessSection.GLOBAL_CAPABILITIES, accessSectionInfo);
+    BadRequestException ex =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.projects().name(allProjects.get()).access(accessInput));
+    assertThat(ex)
+        .hasMessageThat()
+        .isEqualTo("Unknown global capability: Invalid Global Capability");
+  }
+
+  @Test
+  public void addGlobalCapabilityForNonRootProject() throws Exception {
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    AccessSectionInfo accessSectionInfo = createDefaultGlobalCapabilitiesAccessSectionInfo();
+
+    accessInput.add.put(AccessSection.GLOBAL_CAPABILITIES, accessSectionInfo);
+
+    assertThrows(BadRequestException.class, () -> pApi().access(accessInput));
+  }
+
+  @Test
+  public void addNonGlobalCapabilityToGlobalCapabilities() throws Exception {
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    AccessSectionInfo accessSectionInfo = newAccessSectionInfo();
+
+    PermissionInfo permissionInfo = newPermissionInfo();
+    permissionInfo.rules.put(adminGroupUuid().get(), null);
+    accessSectionInfo.permissions.put(Permission.PUSH, permissionInfo);
+
+    accessInput.add.put(AccessSection.GLOBAL_CAPABILITIES, accessSectionInfo);
+    assertThrows(
+        BadRequestException.class,
+        () -> gApi.projects().name(allProjects.get()).access(accessInput));
+  }
+
+  @Test
+  public void removeGlobalCapabilityAsUser() throws Exception {
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    AccessSectionInfo accessSectionInfo = createDefaultGlobalCapabilitiesAccessSectionInfo();
+
+    accessInput.remove.put(AccessSection.GLOBAL_CAPABILITIES, accessSectionInfo);
+
+    requestScopeOperations.setApiUser(user.id());
+    assertThrows(
+        AuthException.class, () -> gApi.projects().name(allProjects.get()).access(accessInput));
+  }
+
+  @Test
+  public void removeGlobalCapabilityAsAdmin() throws Exception {
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    AccessSectionInfo accessSectionInfo = newAccessSectionInfo();
+
+    PermissionInfo permissionInfo = newPermissionInfo();
+    permissionInfo.rules.put(adminGroupUuid().get(), null);
+    accessSectionInfo.permissions.put(GlobalCapability.ACCESS_DATABASE, permissionInfo);
+
+    // Add and validate first as removing existing privileges such as
+    // administrateServer would break upcoming tests
+    accessInput.add.put(AccessSection.GLOBAL_CAPABILITIES, accessSectionInfo);
+
+    ProjectAccessInfo updatedProjectAccessInfo =
+        gApi.projects().name(allProjects.get()).access(accessInput);
+    assertThatMap(updatedProjectAccessInfo.local.get(AccessSection.GLOBAL_CAPABILITIES).permissions)
+        .keys()
+        .containsAtLeastElementsIn(accessSectionInfo.permissions.keySet());
+
+    // Remove
+    accessInput.add.clear();
+    accessInput.remove.put(AccessSection.GLOBAL_CAPABILITIES, accessSectionInfo);
+
+    updatedProjectAccessInfo = gApi.projects().name(allProjects.get()).access(accessInput);
+    assertThatMap(updatedProjectAccessInfo.local.get(AccessSection.GLOBAL_CAPABILITIES).permissions)
+        .keys()
+        .containsNoneIn(accessSectionInfo.permissions.keySet());
+  }
+
+  @Test
+  public void unknownPermissionRemainsUnchanged() throws Exception {
+    String access = "access";
+    String unknownPermission = "unknownPermission";
+    String registeredUsers = "group Registered Users";
+    String refsFor = "refs/for/*";
+    // Clone repository to forcefully add permission
+    TestRepository<InMemoryRepository> allProjectsRepo = cloneProject(allProjects, admin);
+
+    // Fetch permission ref
+    GitUtil.fetch(allProjectsRepo, "refs/meta/config:cfg");
+    allProjectsRepo.reset("cfg");
+
+    // Load current permissions
+    String config =
+        gApi.projects()
+            .name(allProjects.get())
+            .branch(RefNames.REFS_CONFIG)
+            .file(ProjectConfig.PROJECT_CONFIG)
+            .asString();
+
+    // Append and push unknown permission
+    Config cfg = new Config();
+    cfg.fromText(config);
+    cfg.setString(access, refsFor, unknownPermission, registeredUsers);
+    config = cfg.toText();
+    PushOneCommit push =
+        pushFactory.create(
+            admin.newIdent(), allProjectsRepo, "Subject", ProjectConfig.PROJECT_CONFIG, config);
+    push.to(RefNames.REFS_CONFIG).assertOkStatus();
+
+    // Verify that unknownPermission is present
+    config =
+        gApi.projects()
+            .name(allProjects.get())
+            .branch(RefNames.REFS_CONFIG)
+            .file(ProjectConfig.PROJECT_CONFIG)
+            .asString();
+    cfg.fromText(config);
+    assertThat(cfg).stringValue(access, refsFor, unknownPermission).isEqualTo(registeredUsers);
+
+    // Make permission change through API
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    AccessSectionInfo accessSectionInfo = createDefaultAccessSectionInfo();
+    accessInput.add.put(refsFor, accessSectionInfo);
+    gApi.projects().name(allProjects.get()).access(accessInput);
+    accessInput.add.clear();
+    accessInput.remove.put(refsFor, accessSectionInfo);
+    gApi.projects().name(allProjects.get()).access(accessInput);
+
+    // Verify that unknownPermission is still present
+    config =
+        gApi.projects()
+            .name(allProjects.get())
+            .branch(RefNames.REFS_CONFIG)
+            .file(ProjectConfig.PROJECT_CONFIG)
+            .asString();
+    cfg.fromText(config);
+    assertThat(cfg).stringValue(access, refsFor, unknownPermission).isEqualTo(registeredUsers);
+  }
+
+  @Test
+  public void allUsersCanOnlyInheritFromAllProjects() throws Exception {
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    accessInput.parent = project.get();
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.projects().name(allUsers.get()).access(accessInput));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains(allUsers.get() + " must inherit from " + allProjects.get());
+  }
+
+  @Test
+  public void syncCreateGroupPermission_addAndRemoveCreateGroupCapability() throws Exception {
+    // Grant CREATE_GROUP to Registered Users
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    AccessSectionInfo accessSection = newAccessSectionInfo();
+    PermissionInfo createGroup = newPermissionInfo();
+    PermissionRuleInfo pri = new PermissionRuleInfo(PermissionRuleInfo.Action.ALLOW, false);
+    createGroup.rules.put(SystemGroupBackend.REGISTERED_USERS.get(), pri);
+    accessSection.permissions.put(GlobalCapability.CREATE_GROUP, createGroup);
+    accessInput.add.put(AccessSection.GLOBAL_CAPABILITIES, accessSection);
+    gApi.projects().name(allProjects.get()).access(accessInput);
+
+    // Assert that the permission was synced from All-Projects (global) to All-Users (ref)
+    Map<String, AccessSectionInfo> local = gApi.projects().name("All-Users").access().local;
+    assertThatMap(local).keys().contains(RefNames.REFS_GROUPS + "*");
+    Map<String, PermissionInfo> permissions = local.get(RefNames.REFS_GROUPS + "*").permissions;
+    // READ is the default permission and should be preserved by the syncer
+    assertThatMap(permissions).keys().containsExactly(Permission.READ, Permission.CREATE);
+    Map<String, PermissionRuleInfo> rules = permissions.get(Permission.CREATE).rules;
+    assertThatMap(rules).values().containsExactly(pri);
+
+    // Revoke the permission
+    accessInput.add.clear();
+    accessInput.remove.put(AccessSection.GLOBAL_CAPABILITIES, accessSection);
+    gApi.projects().name(allProjects.get()).access(accessInput);
+
+    // Assert that the permission was synced from All-Projects (global) to All-Users (ref)
+    Map<String, AccessSectionInfo> local2 = gApi.projects().name("All-Users").access().local;
+    assertThatMap(local2).keys().contains(RefNames.REFS_GROUPS + "*");
+    Map<String, PermissionInfo> permissions2 = local2.get(RefNames.REFS_GROUPS + "*").permissions;
+    // READ is the default permission and should be preserved by the syncer
+    assertThatMap(permissions2).keys().containsExactly(Permission.READ);
+  }
+
+  @Test
+  public void syncCreateGroupPermission_addCreateGroupCapabilityToMultipleGroups()
+      throws Exception {
+    PermissionRuleInfo pri = new PermissionRuleInfo(PermissionRuleInfo.Action.ALLOW, false);
+
+    // Grant CREATE_GROUP to Registered Users
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    AccessSectionInfo accessSection = newAccessSectionInfo();
+    PermissionInfo createGroup = newPermissionInfo();
+    createGroup.rules.put(SystemGroupBackend.REGISTERED_USERS.get(), pri);
+    accessSection.permissions.put(GlobalCapability.CREATE_GROUP, createGroup);
+    accessInput.add.put(AccessSection.GLOBAL_CAPABILITIES, accessSection);
+    gApi.projects().name(allProjects.get()).access(accessInput);
+
+    // Grant CREATE_GROUP to Administrators
+    accessInput = newProjectAccessInput();
+    accessSection = newAccessSectionInfo();
+    createGroup = newPermissionInfo();
+    createGroup.rules.put(adminGroupUuid().get(), pri);
+    accessSection.permissions.put(GlobalCapability.CREATE_GROUP, createGroup);
+    accessInput.add.put(AccessSection.GLOBAL_CAPABILITIES, accessSection);
+    gApi.projects().name(allProjects.get()).access(accessInput);
+
+    // Assert that the permissions were synced from All-Projects (global) to All-Users (ref)
+    Map<String, AccessSectionInfo> local = gApi.projects().name("All-Users").access().local;
+    assertThatMap(local).keys().contains(RefNames.REFS_GROUPS + "*");
+    Map<String, PermissionInfo> permissions = local.get(RefNames.REFS_GROUPS + "*").permissions;
+    // READ is the default permission and should be preserved by the syncer
+    assertThatMap(permissions).keys().containsExactly(Permission.READ, Permission.CREATE);
+    Map<String, PermissionRuleInfo> rules = permissions.get(Permission.CREATE).rules;
+    assertThatMap(rules)
+        .keys()
+        .containsExactly(SystemGroupBackend.REGISTERED_USERS.get(), adminGroupUuid().get());
+    assertThat(rules.get(SystemGroupBackend.REGISTERED_USERS.get())).isEqualTo(pri);
+    assertThat(rules.get(adminGroupUuid().get())).isEqualTo(pri);
+  }
+
+  @Test
+  public void addAccessSectionForInvalidRef() throws Exception {
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    AccessSectionInfo accessSectionInfo = createDefaultAccessSectionInfo();
+
+    // 'refs/heads/stable_*' is invalid, correct would be '^refs/heads/stable_.*'
+    String invalidRef = Constants.R_HEADS + "stable_*";
+    accessInput.add.put(invalidRef, accessSectionInfo);
+
+    BadRequestException thrown =
+        assertThrows(BadRequestException.class, () -> pApi().access(accessInput));
+    assertThat(thrown).hasMessageThat().contains("Invalid Name: " + invalidRef);
+  }
+
+  @Test
+  public void createAccessChangeWithAccessSectionForInvalidRef() throws Exception {
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    AccessSectionInfo accessSectionInfo = createDefaultAccessSectionInfo();
+
+    // 'refs/heads/stable_*' is invalid, correct would be '^refs/heads/stable_.*'
+    String invalidRef = Constants.R_HEADS + "stable_*";
+    accessInput.add.put(invalidRef, accessSectionInfo);
+
+    BadRequestException thrown =
+        assertThrows(BadRequestException.class, () -> pApi().accessChange(accessInput));
+    assertThat(thrown).hasMessageThat().contains("Invalid Name: " + invalidRef);
+  }
+
+  private ProjectApi pApi() throws Exception {
+    return gApi.projects().name(newProjectName.get());
+  }
+
+  private ProjectAccessInput newProjectAccessInput() {
+    ProjectAccessInput p = new ProjectAccessInput();
+    p.add = new HashMap<>();
+    p.remove = new HashMap<>();
+    return p;
+  }
+
+  private PermissionInfo newPermissionInfo() {
+    PermissionInfo p = new PermissionInfo(null, null);
+    p.rules = new HashMap<>();
+    return p;
+  }
+
+  private AccessSectionInfo newAccessSectionInfo() {
+    AccessSectionInfo a = new AccessSectionInfo();
+    a.permissions = new HashMap<>();
+    return a;
+  }
+
+  private AccessSectionInfo createDefaultAccessSectionInfo() {
+    AccessSectionInfo accessSection = newAccessSectionInfo();
+
+    PermissionInfo push = newPermissionInfo();
+    PermissionRuleInfo pri = new PermissionRuleInfo(PermissionRuleInfo.Action.ALLOW, false);
+    push.rules.put(SystemGroupBackend.REGISTERED_USERS.get(), pri);
+    accessSection.permissions.put(Permission.PUSH, push);
+
+    PermissionInfo codeReview = newPermissionInfo();
+    codeReview.label = LabelId.CODE_REVIEW;
+    pri = new PermissionRuleInfo(PermissionRuleInfo.Action.DENY, false);
+    codeReview.rules.put(SystemGroupBackend.REGISTERED_USERS.get(), pri);
+
+    pri = new PermissionRuleInfo(PermissionRuleInfo.Action.ALLOW, false);
+    pri.max = 1;
+    pri.min = -1;
+    codeReview.rules.put(SystemGroupBackend.PROJECT_OWNERS.get(), pri);
+    accessSection.permissions.put(Permission.LABEL + LabelId.CODE_REVIEW, codeReview);
+
+    return accessSection;
+  }
+
+  private AccessSectionInfo createDefaultGlobalCapabilitiesAccessSectionInfo() {
+    AccessSectionInfo accessSection = newAccessSectionInfo();
+
+    PermissionInfo email = newPermissionInfo();
+    PermissionRuleInfo pri = new PermissionRuleInfo(PermissionRuleInfo.Action.ALLOW, false);
+    email.rules.put(SystemGroupBackend.REGISTERED_USERS.get(), pri);
+    accessSection.permissions.put(GlobalCapability.EMAIL_REVIEWERS, email);
+
+    return accessSection;
+  }
+
+  private AccessSectionInfo createAccessSectionInfoDenyAll() {
+    AccessSectionInfo accessSection = newAccessSectionInfo();
+
+    PermissionInfo read = newPermissionInfo();
+    PermissionRuleInfo pri = new PermissionRuleInfo(PermissionRuleInfo.Action.DENY, false);
+    read.rules.put(SystemGroupBackend.ANONYMOUS_USERS.get(), pri);
+    accessSection.permissions.put(Permission.READ, read);
+
+    return accessSection;
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/project/CheckAccessIT.java b/javatests/com/google/gerrit/acceptance/api/project/CheckAccessIT.java
index 45a895a..b0de1c1 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/CheckAccessIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/CheckAccessIT.java
@@ -236,10 +236,10 @@
                 Permission.VIEW_PRIVATE_CHANGES,
                 403,
                 ImmutableList.of(
-                    "'user' can perform 'read' with force=false on project '"
+                    "'user1' can perform 'read' with force=false on project '"
                         + normalProject.get()
                         + "' for ref 'refs/heads/*'",
-                    "'user' cannot perform 'viewPrivateChanges' with force=false on project '"
+                    "'user1' cannot perform 'viewPrivateChanges' with force=false on project '"
                         + normalProject.get()
                         + "' for ref 'refs/heads/master'")),
             // Test 2
@@ -248,7 +248,7 @@
                 normalProject.get(),
                 200,
                 ImmutableList.of(
-                    "'user' can perform 'read' with force=false on project '"
+                    "'user1' can perform 'read' with force=false on project '"
                         + normalProject.get()
                         + "' for ref 'refs/heads/*'")),
             // Test 3
@@ -257,10 +257,10 @@
                 secretProject.get(),
                 403,
                 ImmutableList.of(
-                    "'user' cannot perform 'read' with force=false on project '"
+                    "'user1' cannot perform 'read' with force=false on project '"
                         + secretProject.get()
                         + "' for ref 'refs/heads/*' because this permission is blocked",
-                    "'user' cannot perform 'read' with force=false on project '"
+                    "'user1' cannot perform 'read' with force=false on project '"
                         + secretProject.get()
                         + "' for ref 'refs/meta/version' because this permission is blocked")),
             // Test 4
@@ -270,10 +270,10 @@
                 "refs/heads/secret/master",
                 403,
                 ImmutableList.of(
-                    "'user' can perform 'read' with force=false on project '"
+                    "'user1' can perform 'read' with force=false on project '"
                         + secretRefProject.get()
                         + "' for ref 'refs/heads/*'",
-                    "'user' cannot perform 'read' with force=false on project '"
+                    "'user1' cannot perform 'read' with force=false on project '"
                         + secretRefProject.get()
                         + "' for ref 'refs/heads/secret/master' because this permission is blocked")),
             // Test 5
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java b/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java
index 0b18503..1a55b82 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;
@@ -25,23 +26,34 @@
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.util.stream.Collectors.joining;
 import static java.util.stream.Collectors.toMap;
+import static org.eclipse.jgit.lib.Constants.EMPTY_TREE_ID;
 
 import com.google.common.base.Joiner;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.ExtensionRegistry;
+import com.google.gerrit.acceptance.ExtensionRegistry.Registration;
 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;
 import com.google.gerrit.extensions.common.ChangeType;
 import com.google.gerrit.extensions.common.DiffInfo;
 import com.google.gerrit.extensions.common.FileInfo;
+import com.google.gerrit.extensions.common.WebLinkInfo;
 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;
 import java.io.IOException;
@@ -52,6 +64,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;
@@ -75,10 +88,15 @@
           .collect(joining());
   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;
@@ -97,6 +115,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));
 
@@ -117,10 +137,33 @@
     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(), null);
+
+    assertThat(modifiedFiles.keySet()).containsExactly("/COMMIT_MSG", "f.txt");
+    assertThat(
+            modifiedFiles.values().stream()
+                .map(FileDiffOutput::oldCommitId)
+                .collect(Collectors.toSet()))
+        .containsExactly(EMPTY_TREE_ID);
+    assertThat(modifiedFiles.get("/COMMIT_MSG").changeType()).isEqualTo(Patch.ChangeType.ADDED);
+    assertThat(modifiedFiles.get("f.txt").changeType()).isEqualTo(Patch.ChangeType.ADDED);
   }
 
   @Test
@@ -142,6 +185,23 @@
   }
 
   @Test
+  public void editWebLinkIncludedInDiff() throws Exception {
+    try (Registration registration = newEditWebLink()) {
+      String fileName = "a_new_file.txt";
+      String fileContent = "First line\nSecond line\n";
+      PushOneCommit.Result result = createChange("Add a file", fileName, fileContent);
+      DiffInfo info =
+          gApi.changes()
+              .id(result.getChangeId())
+              .revision(result.getCommit().name())
+              .file(fileName)
+              .diff();
+      assertThat(info.editWebLinks).hasSize(1);
+      assertThat(info.editWebLinks.get(0).url).isEqualTo("http://edit/" + project + "/" + fileName);
+    }
+  }
+
+  @Test
   public void deletedFileIsIncludedInDiff() throws Exception {
     gApi.changes().id(changeId).edit().deleteFile(FILE_NAME);
     gApi.changes().id(changeId).edit().publish();
@@ -2875,6 +2935,18 @@
     assertThat(e).hasMessageThat().isEqualTo("edit not allowed as base");
   }
 
+  private Registration newEditWebLink() {
+    EditWebLink webLink =
+        new EditWebLink() {
+          @Override
+          public WebLinkInfo getEditWebLink(String projectName, String revision, String fileName) {
+            return new WebLinkInfo(
+                "name", "imageURL", "http://edit/" + projectName + "/" + fileName);
+          }
+        };
+    return extensionRegistry.newRegistration().add(webLink);
+  }
+
   private String updatedCommitMessage() {
     return "An unchanged patchset\n\nChange-Id: " + changeId;
   }
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java b/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
index abfd7896..9d0b1f4 100644
--- a/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
@@ -93,6 +93,8 @@
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.extensions.webui.PatchSetWebLink;
+import com.google.gerrit.extensions.webui.ResolveConflictsWebLink;
+import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.testing.FakeEmailSender;
 import com.google.inject.Inject;
@@ -152,7 +154,8 @@
     PushOneCommit.Result r = createChange();
     String changeId = project.get() + "~master~" + r.getChangeId();
     gApi.changes().id(changeId).current().review(ReviewInput.approve());
-    gApi.changes().id(changeId).current().submit();
+    ChangeInfo changeInfo = gApi.changes().id(changeId).current().submit();
+    assertThat(changeInfo.status).isEqualTo(ChangeStatus.MERGED);
     assertThat(gApi.changes().id(changeId).get().status).isEqualTo(ChangeStatus.MERGED);
   }
 
@@ -1196,6 +1199,23 @@
   }
 
   @Test
+  public void cherryPickSetsReadyChangeOnNewPatchset() throws Exception {
+    PushOneCommit.Result result = pushTo("refs/for/master");
+    CherryPickInput input = new CherryPickInput();
+    input.destination = "foo";
+    gApi.projects().name(project.get()).branch(input.destination).create(new BranchInput());
+    ChangeApi originalChange = gApi.changes().id(project.get() + "~master~" + result.getChangeId());
+
+    ChangeApi cherryPick = originalChange.revision(result.getCommit().name()).cherryPick(input);
+    cherryPick.setWorkInProgress();
+    cherryPick = originalChange.revision(result.getCommit().name()).cherryPick(input);
+
+    ChangeInfo secondCherryPickResult = cherryPick.get(ALL_REVISIONS);
+    assertThat(secondCherryPickResult.revisions).hasSize(2);
+    assertThat(secondCherryPickResult.workInProgress).isNull();
+  }
+
+  @Test
   public void canRebase() throws Exception {
     PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo);
     PushOneCommit.Result r1 = push.to("refs/for/master");
@@ -1614,16 +1634,26 @@
 
   @Test
   public void commit() throws Exception {
-    WebLinkInfo expectedWebLinkInfo = new WebLinkInfo("foo", "imageUrl", "url");
-    PatchSetWebLink link =
+    WebLinkInfo expectedPatchSetLinkInfo = new WebLinkInfo("foo", "imageUrl", "url");
+    PatchSetWebLink patchSetLink =
         new PatchSetWebLink() {
           @Override
           public WebLinkInfo getPatchSetWebLink(
               String projectName, String commit, String commitMessage, String branchName) {
-            return expectedWebLinkInfo;
+            return expectedPatchSetLinkInfo;
           }
         };
-    try (Registration registration = extensionRegistry.newRegistration().add(link)) {
+    WebLinkInfo expectedResolveConflictsLinkInfo = new WebLinkInfo("bar", "img", "resolve");
+    ResolveConflictsWebLink resolveConflictsLink =
+        new ResolveConflictsWebLink() {
+          @Override
+          public WebLinkInfo getResolveConflictsWebLink(
+              String projectName, String commit, String commitMessage, String branchName) {
+            return expectedResolveConflictsLinkInfo;
+          }
+        };
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(patchSetLink).add(resolveConflictsLink)) {
       PushOneCommit.Result r = createChange();
       RevCommit c = r.getCommit();
 
@@ -1640,11 +1670,20 @@
 
       commitInfo = gApi.changes().id(r.getChangeId()).current().commit(true);
       assertThat(commitInfo.webLinks).hasSize(1);
-      WebLinkInfo webLinkInfo = Iterables.getOnlyElement(commitInfo.webLinks);
-      assertThat(webLinkInfo.name).isEqualTo(expectedWebLinkInfo.name);
-      assertThat(webLinkInfo.imageUrl).isEqualTo(expectedWebLinkInfo.imageUrl);
-      assertThat(webLinkInfo.url).isEqualTo(expectedWebLinkInfo.url);
-      assertThat(webLinkInfo.target).isEqualTo(expectedWebLinkInfo.target);
+      WebLinkInfo patchSetLinkInfo = Iterables.getOnlyElement(commitInfo.webLinks);
+      assertThat(patchSetLinkInfo.name).isEqualTo(expectedPatchSetLinkInfo.name);
+      assertThat(patchSetLinkInfo.imageUrl).isEqualTo(expectedPatchSetLinkInfo.imageUrl);
+      assertThat(patchSetLinkInfo.url).isEqualTo(expectedPatchSetLinkInfo.url);
+      assertThat(patchSetLinkInfo.target).isEqualTo(expectedPatchSetLinkInfo.target);
+
+      assertThat(commitInfo.resolveConflictsWebLinks).hasSize(1);
+      WebLinkInfo resolveCommentsLinkInfo =
+          Iterables.getOnlyElement(commitInfo.resolveConflictsWebLinks);
+      assertThat(resolveCommentsLinkInfo.name).isEqualTo(expectedResolveConflictsLinkInfo.name);
+      assertThat(resolveCommentsLinkInfo.imageUrl)
+          .isEqualTo(expectedResolveConflictsLinkInfo.imageUrl);
+      assertThat(resolveCommentsLinkInfo.url).isEqualTo(expectedResolveConflictsLinkInfo.url);
+      assertThat(resolveCommentsLinkInfo.target).isEqualTo(expectedResolveConflictsLinkInfo.target);
     }
   }
 
@@ -1876,7 +1915,10 @@
     ChangeInfo c = gApi.changes().id(r.getChangeId()).get();
     ChangeMessageInfo message = Iterables.getLast(c.messages);
     assertThat(message.author._accountId).isEqualTo(admin.id().get());
-    assertThat(message.message).isEqualTo("Removed Code-Review+1 by User <user@example.com>\n");
+    assertThat(message.message)
+        .isEqualTo(
+            String.format(
+                "Removed Code-Review+1 by %s\n", ChangeMessagesUtil.getAccountTemplate(user.id())));
     assertThat(getReviewers(c.reviewers.get(ReviewerState.REVIEWER)))
         .containsExactlyElementsIn(ImmutableSet.of(admin.id(), user.id()));
   }
diff --git a/javatests/com/google/gerrit/acceptance/edit/ChangeEditIT.java b/javatests/com/google/gerrit/acceptance/edit/ChangeEditIT.java
index 8233f0c..a90cd56 100644
--- a/javatests/com/google/gerrit/acceptance/edit/ChangeEditIT.java
+++ b/javatests/com/google/gerrit/acceptance/edit/ChangeEditIT.java
@@ -46,11 +46,11 @@
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.Project;
-import com.google.gerrit.extensions.api.changes.AddReviewerInput;
 import com.google.gerrit.extensions.api.changes.FileContentInput;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.api.changes.PublishChangeEditInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.changes.ReviewerInput;
 import com.google.gerrit.extensions.client.ChangeEditDetailOption;
 import com.google.gerrit.extensions.client.ChangeStatus;
 import com.google.gerrit.extensions.client.ListChangesOption;
@@ -154,7 +154,7 @@
   public void publishEdit() throws Exception {
     createArbitraryEditFor(changeId);
 
-    AddReviewerInput in = new AddReviewerInput();
+    ReviewerInput in = new ReviewerInput();
     in.reviewer = user.email();
     gApi.changes().id(changeId).addReviewer(in);
 
@@ -209,7 +209,7 @@
 
   @Test
   public void publishEditNotifyRest() throws Exception {
-    AddReviewerInput in = new AddReviewerInput();
+    ReviewerInput in = new ReviewerInput();
     in.reviewer = user.email();
     gApi.changes().id(changeId).addReviewer(in);
 
@@ -224,7 +224,7 @@
 
   @Test
   public void publishEditWithDefaultNotify() throws Exception {
-    AddReviewerInput in = new AddReviewerInput();
+    ReviewerInput in = new ReviewerInput();
     in.reviewer = user.email();
     gApi.changes().id(changeId).addReviewer(in);
 
diff --git a/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java b/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
index eac0f1b..45d1b76 100644
--- a/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
+++ b/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
@@ -674,7 +674,7 @@
     GroupInput gin = new GroupInput();
     gin.name = group;
     gin.members = ImmutableList.of(user.username(), user2.username());
-    gin.visibleToAll = true; // TODO(dborowitz): Shouldn't be necessary; see ReviewerAdder.
+    gin.visibleToAll = true; // TODO(dborowitz): Shouldn't be necessary; see ReviewerModifier.
     gApi.groups().create(gin);
 
     PushOneCommit.Result r = pushTo("refs/for/master%cc=" + group);
@@ -752,7 +752,7 @@
     GroupInput gin = new GroupInput();
     gin.name = group;
     gin.members = ImmutableList.of(user.username(), user2.username());
-    gin.visibleToAll = true; // TODO(dborowitz): Shouldn't be necessary; see ReviewerAdder.
+    gin.visibleToAll = true; // TODO(dborowitz): Shouldn't be necessary; see ReviewerModifier.
     gApi.groups().create(gin);
 
     PushOneCommit.Result r = pushTo("refs/for/master%r=" + group);
@@ -2823,6 +2823,14 @@
     r.assertErrorStatus("\"--unknown\" is not a valid option");
   }
 
+  @Test
+  public void pushForMagicBranchWithSkipValidationOptionIsNotAllowed() throws Exception {
+    PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo);
+    push.setPushOptions(ImmutableList.of(PUSH_OPTION_SKIP_VALIDATION));
+    PushOneCommit.Result r = push.to("refs/for/master");
+    r.assertErrorStatus("\"--skip-validation\" option is only supported for direct push");
+  }
+
   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 23bcdec..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;
@@ -396,7 +396,7 @@
         .add(allow(Permission.SUBMIT).ref("refs/for/refs/heads/master").group(adminGroupUuid()))
         .update();
 
-    TestAccount user = accountCreator.user();
+    TestAccount user = accountCreator.user1();
     String pushSpec = "refs/for/master%reviewer=" + user.email();
     sender.clear();
 
@@ -433,7 +433,7 @@
         .add(allow(Permission.SUBMIT).ref("refs/for/refs/heads/master").group(adminGroupUuid()))
         .update();
 
-    TestAccount user = accountCreator.user();
+    TestAccount user = accountCreator.user1();
     String pushSpec = "refs/for/master%reviewer=" + user.email() + ",cc=" + user.email();
 
     TestAccount user2 = accountCreator.user2();
diff --git a/javatests/com/google/gerrit/acceptance/git/AutoMergeIT.java b/javatests/com/google/gerrit/acceptance/git/AutoMergeIT.java
index 46688dd..b16394d 100644
--- a/javatests/com/google/gerrit/acceptance/git/AutoMergeIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/AutoMergeIT.java
@@ -166,6 +166,25 @@
     assertNoAutoMergeCreated(result.getCommit());
   }
 
+  @Test
+  public void pushWorksIfAutoMergeExists() throws Exception {
+    PushOneCommit m =
+        pushFactory.create(
+            admin.newIdent(), testRepo, "merge", ImmutableMap.of("foo", "foo-1", "bar", "bar-2"));
+    m.setParents(ImmutableList.of(parent1, parent2));
+    PushOneCommit.Result result = m.to("refs/for/master");
+    result.assertOkStatus();
+    assertAutoMergeCreated(result.getCommit());
+
+    // Delete change and push commit again.
+    gApi.changes().id(result.getChangeId()).delete();
+
+    // Push again successfully and check that AutoMerge commit is still there
+    result = m.to("refs/for/master");
+    result.assertOkStatus();
+    assertAutoMergeCreated(result.getCommit());
+  }
+
   private void assertAutoMergeCreated(ObjectId mergeCommit) throws Exception {
     try (Repository repo = repoManager.openRepository(project)) {
       assertThat(repo.exactRef(RefNames.refsCacheAutomerge(mergeCommit.name()))).isNotNull();
diff --git a/javatests/com/google/gerrit/acceptance/git/PushPermissionsIT.java b/javatests/com/google/gerrit/acceptance/git/PushPermissionsIT.java
index 385780b..9d1bdaa 100644
--- a/javatests/com/google/gerrit/acceptance/git/PushPermissionsIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/PushPermissionsIT.java
@@ -103,7 +103,7 @@
     assertThat(r)
         .hasMessages(
             "error: branch refs/heads/master:",
-            "To push into this reference you need 'Push' rights.",
+            "Push to refs/for/master to create a review, or get 'Push' rights to update the branch.",
             "User: admin",
             "Contact an administrator to fix the permissions");
     assertThat(r).hasProcessed(ImmutableMap.of("refs", 1));
@@ -183,7 +183,7 @@
             "You need 'Delete Reference' rights or 'Push' rights with the ",
             "'Force Push' flag set to delete references.",
             "error: branch refs/heads/master:",
-            "To push into this reference you need 'Push' rights.",
+            "Push to refs/for/master to create a review, or get 'Push' rights to update the branch.",
             "User: admin",
             "Contact an administrator to fix the permissions");
   }
diff --git a/javatests/com/google/gerrit/acceptance/rest/account/CapabilityInfo.java b/javatests/com/google/gerrit/acceptance/rest/account/CapabilityInfo.java
index 1ca019e..7598062 100644
--- a/javatests/com/google/gerrit/acceptance/rest/account/CapabilityInfo.java
+++ b/javatests/com/google/gerrit/acceptance/rest/account/CapabilityInfo.java
@@ -32,12 +32,12 @@
   public boolean runAs;
   public boolean runGC;
   public boolean streamEvents;
+  public boolean viewAccess;
   public boolean viewAllAccounts;
   public boolean viewCaches;
   public boolean viewConnections;
   public boolean viewPlugins;
   public boolean viewQueue;
-  public boolean viewAccess;
 
   static class QueryLimit {
     short min;
diff --git a/javatests/com/google/gerrit/acceptance/rest/account/ExternalIdIT.java b/javatests/com/google/gerrit/acceptance/rest/account/ExternalIdIT.java
index ac82a78..b6033d4 100644
--- a/javatests/com/google/gerrit/acceptance/rest/account/ExternalIdIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/account/ExternalIdIT.java
@@ -127,7 +127,7 @@
   }
 
   @Test
-  public void getExternalIdsOfOtherUserNotAllowed() throws Exception {
+  public void getExternalIdsOfOtherUserNotAllowed() {
     requestScopeOperations.setApiUser(user.id());
     AuthException thrown =
         assertThrows(
@@ -510,7 +510,7 @@
   }
 
   @Test
-  public void checkConsistencyNotAllowed() throws Exception {
+  public void checkConsistencyNotAllowed() {
     AuthException thrown =
         assertThrows(
             AuthException.class,
@@ -882,7 +882,8 @@
       ExternalIdNotes extIdNotes = externalIdNotesFactory.load(repo);
       extIdNotes.insert(extId);
       extIdNotes.commit(update);
-      extIdNotes.updateCaches();
+      externalIdNotesFactory.updateExternalIdCacheAndMaybeReindexAccounts(
+          extIdNotes, ImmutableList.of());
     }
   }
 
@@ -909,7 +910,8 @@
       metaDataUpdate.getCommitBuilder().setAuthor(admin.newIdent());
       metaDataUpdate.getCommitBuilder().setCommitter(admin.newIdent());
       extIdNotes.commit(metaDataUpdate);
-      extIdNotes.updateCaches();
+      externalIdNotesFactory.updateExternalIdCacheAndMaybeReindexAccounts(
+          extIdNotes, ImmutableList.of());
     }
   }
 
diff --git a/javatests/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java b/javatests/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java
index bf8de93..650334f 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;
diff --git a/javatests/com/google/gerrit/acceptance/rest/binding/ChangesRestApiBindingsIT.java b/javatests/com/google/gerrit/acceptance/rest/binding/ChangesRestApiBindingsIT.java
index 2e702c10..68b24ce 100644
--- a/javatests/com/google/gerrit/acceptance/rest/binding/ChangesRestApiBindingsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/binding/ChangesRestApiBindingsIT.java
@@ -25,11 +25,11 @@
 import com.google.gerrit.acceptance.rest.util.RestApiCallHelper;
 import com.google.gerrit.acceptance.rest.util.RestCall;
 import com.google.gerrit.entities.Patch;
-import com.google.gerrit.extensions.api.changes.AddReviewerInput;
 import com.google.gerrit.extensions.api.changes.DraftInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput.DraftHandling;
 import com.google.gerrit.extensions.api.changes.ReviewInput.RobotCommentInput;
+import com.google.gerrit.extensions.api.changes.ReviewerInput;
 import com.google.gerrit.extensions.client.Comment;
 import com.google.gerrit.extensions.client.Side;
 import com.google.gerrit.extensions.common.CommentInfo;
@@ -289,15 +289,15 @@
   public void reviewerEndpoints() throws Exception {
     String changeId = createChange().getChangeId();
 
-    AddReviewerInput addReviewerInput = new AddReviewerInput();
-    addReviewerInput.reviewer = user.email();
+    ReviewerInput reviewerInput = new ReviewerInput();
+    reviewerInput.reviewer = user.email();
 
     RestApiCallHelper.execute(
         adminRestSession,
         REVIEWER_ENDPOINTS,
-        () -> gApi.changes().id(changeId).addReviewer(addReviewerInput),
+        () -> gApi.changes().id(changeId).addReviewer(reviewerInput),
         changeId,
-        addReviewerInput.reviewer);
+        reviewerInput.reviewer);
   }
 
   @Test
@@ -323,16 +323,16 @@
   public void revisionReviewerEndpoints() throws Exception {
     String changeId = createChange().getChangeId();
 
-    AddReviewerInput addReviewerInput = new AddReviewerInput();
-    addReviewerInput.reviewer = user.email();
+    ReviewerInput reviewerInput = new ReviewerInput();
+    reviewerInput.reviewer = user.email();
 
     RestApiCallHelper.execute(
         adminRestSession,
         REVISION_REVIEWER_ENDPOINTS,
-        () -> gApi.changes().id(changeId).addReviewer(addReviewerInput),
+        () -> gApi.changes().id(changeId).addReviewer(reviewerInput),
         changeId,
         "current",
-        addReviewerInput.reviewer);
+        reviewerInput.reviewer);
   }
 
   @Test
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/AssigneeIT.java b/javatests/com/google/gerrit/acceptance/rest/change/AssigneeIT.java
index 36cd3cb..be94cdf 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/AssigneeIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/AssigneeIT.java
@@ -147,7 +147,7 @@
                 + "' only matches inactive accounts. To use an inactive account, retry with one"
                 + " of the following exact account IDs:\n"
                 + user.id()
-                + ": User <user@example.com>");
+                + ": User1 <user1@example.com>");
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java b/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java
index d2a48be1..d480eb1 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java
@@ -28,6 +28,7 @@
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.acceptance.UseClockStep;
+import com.google.gerrit.acceptance.VerifyNoPiiInChangeNotes;
 import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
 import com.google.gerrit.acceptance.testsuite.change.ChangeOperations;
@@ -37,12 +38,13 @@
 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.extensions.api.changes.AddReviewerInput;
 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.client.GeneralPreferencesInfo;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo.EmailStrategy;
 import com.google.gerrit.extensions.client.ReviewerState;
@@ -70,6 +72,7 @@
 
 @NoHttpd
 @UseClockStep(clockStepUnit = TimeUnit.MINUTES)
+@VerifyNoPiiInChangeNotes(true)
 public class AttentionSetIT extends AbstractDaemonTest {
 
   @Inject private ChangeOperations changeOperations;
@@ -351,7 +354,7 @@
     PushOneCommit.Result r = createChange();
 
     // Add cc
-    AddReviewerInput input = new AddReviewerInput();
+    ReviewerInput input = new ReviewerInput();
     input.reviewer = user.email();
     input.state = ReviewerState.CC;
     change(r).addReviewer(input);
@@ -403,10 +406,10 @@
   public void addingReviewerWhileMarkingWorkInProgressDoesntAddToAttentionSet() throws Exception {
     PushOneCommit.Result r = createChange();
     ReviewInput reviewInput = ReviewInput.create().setWorkInProgress(true);
-    AddReviewerInput addReviewerInput = new AddReviewerInput();
-    addReviewerInput.state = ReviewerState.REVIEWER;
-    addReviewerInput.reviewer = user.email();
-    reviewInput.reviewers = ImmutableList.of(addReviewerInput);
+    ReviewerInput reviewerInput = new ReviewerInput();
+    reviewerInput.state = ReviewerState.REVIEWER;
+    reviewerInput.reviewer = user.email();
+    reviewInput.reviewers = ImmutableList.of(reviewerInput);
 
     change(r).current().review(reviewInput);
     assertThat(getAttentionSetUpdatesForUser(r, user)).isEmpty();
@@ -434,11 +437,11 @@
   @Test
   public void ccsAreIgnored() throws Exception {
     PushOneCommit.Result r = createChange();
-    AddReviewerInput addReviewerInput = new AddReviewerInput();
-    addReviewerInput.state = ReviewerState.CC;
-    addReviewerInput.reviewer = user.email();
+    ReviewerInput reviewerInput = new ReviewerInput();
+    reviewerInput.state = ReviewerState.CC;
+    reviewerInput.reviewer = user.email();
 
-    change(r).addReviewer(addReviewerInput);
+    change(r).addReviewer(reviewerInput);
 
     assertThat(r.getChange().attentionSet()).isEmpty();
   }
@@ -448,10 +451,10 @@
     PushOneCommit.Result r = createChange();
     change(r).addReviewer(user.email());
 
-    AddReviewerInput addReviewerInput = new AddReviewerInput();
-    addReviewerInput.state = ReviewerState.CC;
-    addReviewerInput.reviewer = user.email();
-    change(r).addReviewer(addReviewerInput);
+    ReviewerInput reviewerInput = new ReviewerInput();
+    reviewerInput.state = ReviewerState.CC;
+    reviewerInput.reviewer = user.email();
+    change(r).addReviewer(reviewerInput);
 
     AttentionSetUpdate attentionSet = Iterables.getOnlyElement(r.getChange().attentionSet());
     assertThat(attentionSet).hasAccountIdThat().isEqualTo(user.id());
@@ -557,10 +560,10 @@
     change(r).addReviewer(user.email());
 
     ReviewInput reviewInput = ReviewInput.create().setReady(true);
-    AddReviewerInput addReviewerInput = new AddReviewerInput();
-    addReviewerInput.state = ReviewerState.CC;
-    addReviewerInput.reviewer = user.email();
-    reviewInput.reviewers = ImmutableList.of(addReviewerInput);
+    ReviewerInput reviewerInput = new ReviewerInput();
+    reviewerInput.state = ReviewerState.CC;
+    reviewerInput.reviewer = user.email();
+    reviewInput.reviewers = ImmutableList.of(reviewerInput);
     change(r).addToAttentionSet(new AttentionSetInput(user.email(), "reason"));
     change(r).current().review(reviewInput);
 
@@ -702,7 +705,7 @@
 
     assertThat(exception.getMessage())
         .isEqualTo(
-            "user can not be added/removed twice, and can not be added and removed at the same"
+            "user1 can not be added/removed twice, and can not be added and removed at the same"
                 + " time");
   }
 
@@ -719,7 +722,7 @@
 
     assertThat(exception.getMessage())
         .isEqualTo(
-            "user can not be added/removed twice, and can not be added and removed at the same"
+            "user1 can not be added/removed twice, and can not be added and removed at the same"
                 + " time");
   }
 
@@ -756,7 +759,7 @@
 
     assertThat(exception.getMessage())
         .isEqualTo(
-            "user can not be added/removed twice, and can not be added and removed at the same"
+            "user1 can not be added/removed twice, and can not be added and removed at the same"
                 + " time");
   }
 
@@ -1356,6 +1359,21 @@
   }
 
   @Test
+  public void robotReviewWithNegativeLabelDoesNotAddOwnerOnWorkInProgressChanges()
+      throws Exception {
+    TestAccount robot =
+        accountCreator.create(
+            "robot2", "robot2@example.com", "Ro Bot", "Ro", ServiceUserClassifier.SERVICE_USERS);
+    PushOneCommit.Result r = createChange();
+    gApi.changes().id(r.getChangeId()).setWorkInProgress();
+
+    requestScopeOperations.setApiUser(robot.id());
+    change(r).current().review(ReviewInput.dislike());
+
+    assertThat(r.getChange().attentionSet()).isEmpty();
+  }
+
+  @Test
   public void robotReviewWithNegativeLabelAddsOwner() throws Exception {
     TestAccount robot =
         accountCreator.create(
@@ -1758,6 +1776,40 @@
         .isEqualTo(Operation.REMOVE);
   }
 
+  @Test
+  public void deleteSelfVotesDoesNotAddToAttentionSet() throws Exception {
+    PushOneCommit.Result r = createChange();
+    approve(r.getChangeId());
+    gApi.changes()
+        .id(r.getChangeId())
+        .current()
+        .reviewer(admin.id().toString())
+        .deleteVote(LabelId.CODE_REVIEW);
+
+    assertThat(getAttentionSetUpdates(r.getChange().getId())).isEmpty();
+  }
+
+  @Test
+  public void deleteVotesOfOthersAddThemToAttentionSet() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    requestScopeOperations.setApiUser(user.id());
+    recommend(r.getChangeId());
+
+    requestScopeOperations.setApiUser(admin.id());
+    gApi.changes()
+        .id(r.getChangeId())
+        .current()
+        .reviewer(user.id().toString())
+        .deleteVote(LabelId.CODE_REVIEW);
+
+    AttentionSetUpdate attentionSet =
+        Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r, user));
+    assertThat(attentionSet).hasAccountIdThat().isEqualTo(user.id());
+    assertThat(attentionSet).hasOperationThat().isEqualTo(AttentionSetUpdate.Operation.ADD);
+    assertThat(attentionSet).hasReasonThat().isEqualTo("Their vote was deleted");
+  }
+
   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 ad3a3c1..d93d3f7 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/ChangeMessagesIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/ChangeMessagesIT.java
@@ -11,6 +11,7 @@
 // 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.change;
 
 import static com.google.common.truth.Truth.assertThat;
@@ -21,12 +22,13 @@
 import static com.google.gerrit.extensions.client.ListChangesOption.MESSAGES;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.parseCommitMessageRange;
-import static com.google.gerrit.server.restapi.change.DeleteChangeMessage.createNewChangeMessage;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.util.stream.Collectors.toSet;
 import static org.eclipse.jgit.util.RawParseUtils.decode;
 
+import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Iterables;
 import com.google.common.collect.Lists;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.PushOneCommit;
@@ -45,10 +47,12 @@
 import com.google.gerrit.extensions.common.CommentInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.notedb.ChangeNoteUtil;
 import com.google.inject.Inject;
 import java.nio.charset.Charset;
 import java.util.ArrayList;
+import java.util.Collection;
 import java.util.Iterator;
 import java.util.List;
 import java.util.Optional;
@@ -145,6 +149,29 @@
   }
 
   @Test
+  public void getChangeMessagesWithTemplate() throws Exception {
+    String changeId = createChange().getChangeId();
+    String messageTemplate = "Review by " + ChangeMessagesUtil.getAccountTemplate(admin.id());
+    postMessage(changeId, messageTemplate);
+    assertMessage(
+        messageTemplate,
+        Iterables.getLast(gApi.changes().id(changeId).get(MESSAGES).messages).message);
+
+    Collection<ChangeMessageInfo> listMessages = gApi.changes().id(changeId).messages();
+    assertThat(listMessages).hasSize(2);
+    ChangeMessageInfo changeMessageApi = Iterables.getLast(gApi.changes().id(changeId).messages());
+    assertMessage("Review by " + admin.getNameEmail(), changeMessageApi.message);
+    assertMessage(
+        "Review by " + admin.getNameEmail(),
+        gApi.changes().id(changeId).message(changeMessageApi.id).get().message);
+    DeleteChangeMessageInput input = new DeleteChangeMessageInput("message deleted");
+    assertThat(gApi.changes().id(changeId).message(changeMessageApi.id).delete(input).message)
+        .isEqualTo(
+            String.format(
+                "Change message removed by: %s\nReason: message deleted", admin.getNameEmail()));
+  }
+
+  @Test
   public void deleteCannotBeAppliedWithoutAdministrateServerCapability() throws Exception {
     int changeNum = createOneChangeWithMultipleChangeMessagesInHistory();
     requestScopeOperations.setApiUser(user.id());
@@ -278,10 +305,18 @@
     ChangeMessageInfo info = gApi.changes().id(changeNum).message(id).delete(input);
 
     // Verify the return change message info is as expect.
-    assertThat(info.message).isEqualTo(createNewChangeMessage(deletedBy.fullName(), reason));
+    String expectedMessage = "Change message removed by: " + deletedBy.getNameEmail();
+    if (!Strings.isNullOrEmpty(reason)) {
+      expectedMessage = expectedMessage + "\nReason: " + reason;
+    }
+    assertThat(info.message).isEqualTo(expectedMessage);
     List<ChangeMessageInfo> messagesAfterDeletion = gApi.changes().id(changeNum).messages();
     assertMessagesAfterDeletion(
-        messagesBeforeDeletion, messagesAfterDeletion, deletedMessageIndex, deletedBy, reason);
+        messagesBeforeDeletion,
+        messagesAfterDeletion,
+        deletedMessageIndex,
+        deletedBy,
+        expectedMessage);
     assertCommentsAfterDeletion(changeNum, commentsBefore);
 
     // Verify change index is updated after deletion.
@@ -297,7 +332,7 @@
       List<ChangeMessageInfo> messagesAfterDeletion,
       int deletedMessageIndex,
       TestAccount deletedBy,
-      String deleteReason) {
+      String expectedDeleteMessage) {
     assertWithMessage("after: %s; before: %s", messagesAfterDeletion, messagesBeforeDeletion)
         .that(messagesAfterDeletion)
         .hasSize(messagesBeforeDeletion.size());
@@ -317,8 +352,7 @@
       assertThat(after._revisionNumber).isEqualTo(before._revisionNumber);
 
       if (i == deletedMessageIndex) {
-        assertThat(after.message)
-            .isEqualTo(createNewChangeMessage(deletedBy.fullName(), deleteReason));
+        assertThat(after.message).isEqualTo(expectedDeleteMessage);
       } else {
         assertThat(after.message).isEqualTo(before.message);
       }
@@ -381,7 +415,12 @@
                 rawAfter,
                 rangeAfter.get().changeMessageStart(),
                 rangeAfter.get().changeMessageEnd() + 1);
-        assertThat(message).isEqualTo(createNewChangeMessage(deletedBy.fullName(), deleteReason));
+        String expectedMessageTemplate =
+            "Change message removed by: " + ChangeMessagesUtil.getAccountTemplate(deletedBy.id());
+        if (!Strings.isNullOrEmpty(deleteReason)) {
+          expectedMessageTemplate = expectedMessageTemplate + "\nReason: " + deleteReason;
+        }
+        assertThat(message).isEqualTo(expectedMessageTemplate);
       } else {
         assertThat(commitAfter.getFullMessage()).isEqualTo(commitBefore.getFullMessage());
       }
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/ChangeOwnerIT.java b/javatests/com/google/gerrit/acceptance/rest/change/ChangeOwnerIT.java
index 6bffdf7..6a748a5 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/ChangeOwnerIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/ChangeOwnerIT.java
@@ -51,20 +51,20 @@
   }
 
   @Test
-  @TestProjectInput(cloneAs = "user")
+  @TestProjectInput(cloneAs = "user1")
   public void testChangeOwner_OwnerACLNotGranted() throws Exception {
     assertApproveFails(user, createMyChange(testRepo));
   }
 
   @Test
-  @TestProjectInput(cloneAs = "user")
+  @TestProjectInput(cloneAs = "user1")
   public void testChangeOwner_OwnerACLGranted() throws Exception {
     grantApproveToChangeOwner(project);
     approve(user, createMyChange(testRepo));
   }
 
   @Test
-  @TestProjectInput(cloneAs = "user")
+  @TestProjectInput(cloneAs = "user1")
   public void testChangeOwner_NotOwnerACLGranted() throws Exception {
     grantApproveToChangeOwner(project);
     assertApproveFails(user2, createMyChange(testRepo));
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersByEmailIT.java b/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersByEmailIT.java
index 012e98d..88e5f10 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersByEmailIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersByEmailIT.java
@@ -25,10 +25,10 @@
 import com.google.gerrit.acceptance.RestResponse;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.entities.Address;
-import com.google.gerrit.extensions.api.changes.AddReviewerInput;
-import com.google.gerrit.extensions.api.changes.AddReviewerResult;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.changes.ReviewerInfo;
+import com.google.gerrit.extensions.api.changes.ReviewerInput;
+import com.google.gerrit.extensions.api.changes.ReviewerResult;
 import com.google.gerrit.extensions.api.projects.ConfigInput;
 import com.google.gerrit.extensions.client.InheritableBoolean;
 import com.google.gerrit.extensions.client.ReviewerState;
@@ -59,7 +59,7 @@
     for (ReviewerState state : ImmutableList.of(ReviewerState.CC, ReviewerState.REVIEWER)) {
       PushOneCommit.Result r = createChange();
 
-      AddReviewerInput input = new AddReviewerInput();
+      ReviewerInput input = new ReviewerInput();
       input.reviewer = toRfcAddressString(acc);
       input.state = state;
       gApi.changes().id(r.getChangeId()).addReviewer(input);
@@ -79,12 +79,12 @@
     for (ReviewerState state : ImmutableList.of(ReviewerState.CC, ReviewerState.REVIEWER)) {
       PushOneCommit.Result r = createChange();
 
-      AddReviewerInput inputByEmail = new AddReviewerInput();
+      ReviewerInput inputByEmail = new ReviewerInput();
       inputByEmail.reviewer = toRfcAddressString(byEmail);
       inputByEmail.state = state;
       gApi.changes().id(r.getChangeId()).addReviewer(inputByEmail);
 
-      AddReviewerInput inputById = new AddReviewerInput();
+      ReviewerInput inputById = new ReviewerInput();
       inputById.reviewer = user.email();
       inputById.state = state;
       gApi.changes().id(r.getChangeId()).addReviewer(inputById);
@@ -103,7 +103,7 @@
     for (ReviewerState state : ImmutableList.of(ReviewerState.CC, ReviewerState.REVIEWER)) {
       PushOneCommit.Result r = createChange();
 
-      AddReviewerInput input = new AddReviewerInput();
+      ReviewerInput input = new ReviewerInput();
       input.reviewer = toRfcAddressString(acc);
       input.state = state;
       gApi.changes().id(r.getChangeId()).addReviewer(input);
@@ -130,7 +130,7 @@
     for (ReviewerState state : ImmutableList.of(ReviewerState.CC, ReviewerState.REVIEWER)) {
       PushOneCommit.Result r = createChange();
 
-      AddReviewerInput addInput = new AddReviewerInput();
+      ReviewerInput addInput = new ReviewerInput();
       addInput.reviewer = toRfcAddressString(acc);
       addInput.state = state;
       gApi.changes().id(r.getChangeId()).addReviewer(addInput);
@@ -148,12 +148,12 @@
 
     PushOneCommit.Result r = createChange();
 
-    AddReviewerInput addInput = new AddReviewerInput();
+    ReviewerInput addInput = new ReviewerInput();
     addInput.reviewer = toRfcAddressString(acc);
     addInput.state = ReviewerState.CC;
     gApi.changes().id(r.getChangeId()).addReviewer(addInput);
 
-    AddReviewerInput modifyInput = new AddReviewerInput();
+    ReviewerInput modifyInput = new ReviewerInput();
     modifyInput.reviewer = addInput.reviewer;
     modifyInput.state = ReviewerState.REVIEWER;
     gApi.changes().id(r.getChangeId()).addReviewer(modifyInput);
@@ -170,7 +170,7 @@
     for (ReviewerState state : ImmutableList.of(ReviewerState.CC, ReviewerState.REVIEWER)) {
       PushOneCommit.Result r = createChange();
 
-      AddReviewerInput input = new AddReviewerInput();
+      ReviewerInput input = new ReviewerInput();
       input.reviewer = toRfcAddressString(acc);
       input.state = state;
       gApi.changes().id(r.getChangeId()).addReviewer(input);
@@ -189,7 +189,7 @@
     for (ReviewerState state : ImmutableList.of(ReviewerState.CC, ReviewerState.REVIEWER)) {
       PushOneCommit.Result r = createChange();
 
-      AddReviewerInput addInput = new AddReviewerInput();
+      ReviewerInput addInput = new ReviewerInput();
       addInput.reviewer = toRfcAddressString(acc);
       addInput.state = state;
       gApi.changes().id(r.getChangeId()).addReviewer(addInput);
@@ -221,7 +221,7 @@
     for (ReviewerState state : ImmutableList.of(ReviewerState.CC, ReviewerState.REVIEWER)) {
       PushOneCommit.Result r = createChange();
 
-      AddReviewerInput input = new AddReviewerInput();
+      ReviewerInput input = new ReviewerInput();
       input.reviewer = toRfcAddressString(acc);
       input.state = state;
       gApi.changes().id(r.getChangeId()).addReviewer(input);
@@ -241,7 +241,7 @@
     PushOneCommit.Result r = createChange();
     for (ReviewerState state : ImmutableList.of(ReviewerState.CC, ReviewerState.REVIEWER)) {
       for (int i = 0; i < 10; i++) {
-        AddReviewerInput input = new AddReviewerInput();
+        ReviewerInput input = new ReviewerInput();
         input.reviewer = String.format("%s-%s@gerritcodereview.com", state, i);
         input.state = state;
         gApi.changes().id(r.getChangeId()).addReviewer(input);
@@ -249,7 +249,7 @@
     }
 
     // Also add user as a regular reviewer
-    AddReviewerInput input = new AddReviewerInput();
+    ReviewerInput input = new ReviewerInput();
     input.reviewer = user.email();
     input.state = ReviewerState.REVIEWER;
     gApi.changes().id(r.getChangeId()).addReviewer(input);
@@ -280,7 +280,7 @@
   public void rejectMissingEmail() throws Exception {
     PushOneCommit.Result r = createChange();
 
-    AddReviewerResult result = gApi.changes().id(r.getChangeId()).addReviewer("");
+    ReviewerResult result = gApi.changes().id(r.getChangeId()).addReviewer("");
     assertThat(result.error).isEqualTo(" is not a valid user identifier");
     assertThat(result.reviewers).isNull();
   }
@@ -289,7 +289,7 @@
   public void rejectMalformedEmail() throws Exception {
     PushOneCommit.Result r = createChange();
 
-    AddReviewerResult result = gApi.changes().id(r.getChangeId()).addReviewer("Foo Bar <foo.bar@");
+    ReviewerResult result = gApi.changes().id(r.getChangeId()).addReviewer("Foo Bar <foo.bar@");
     assertThat(result.error).isEqualTo("Foo Bar <foo.bar@ is not a valid user identifier");
     assertThat(result.reviewers).isNull();
   }
@@ -302,7 +302,7 @@
 
     PushOneCommit.Result r = createChange();
 
-    AddReviewerResult result =
+    ReviewerResult result =
         gApi.changes().id(r.getChangeId()).addReviewer("Foo Bar <foo.bar@gerritcodereview.com>");
     assertThat(result.error)
         .isEqualTo(
@@ -319,7 +319,7 @@
     for (ReviewerState state : ImmutableList.of(ReviewerState.CC, ReviewerState.REVIEWER)) {
       PushOneCommit.Result r = createChange();
 
-      AddReviewerInput input = new AddReviewerInput();
+      ReviewerInput input = new ReviewerInput();
       input.reviewer = toRfcAddressString(acc);
       input.state = state;
       gApi.changes().id(r.getChangeId()).addReviewer(input);
@@ -337,11 +337,11 @@
   public void addExistingReviewerByEmailShortCircuits() throws Exception {
     PushOneCommit.Result r = createChange();
 
-    AddReviewerInput input = new AddReviewerInput();
+    ReviewerInput input = new ReviewerInput();
     input.reviewer = "nonexisting@example.com";
     input.state = ReviewerState.REVIEWER;
 
-    AddReviewerResult result = gApi.changes().id(r.getChangeId()).addReviewer(input);
+    ReviewerResult result = gApi.changes().id(r.getChangeId()).addReviewer(input);
     assertThat(result.reviewers).hasSize(1);
     ReviewerInfo info = result.reviewers.get(0);
     assertThat(info._accountId).isNull();
@@ -354,10 +354,10 @@
   public void addExistingCcByEmailShortCircuits() throws Exception {
     PushOneCommit.Result r = createChange();
 
-    AddReviewerInput input = new AddReviewerInput();
+    ReviewerInput input = new ReviewerInput();
     input.reviewer = "nonexisting@example.com";
     input.state = ReviewerState.CC;
-    AddReviewerResult result = gApi.changes().id(r.getChangeId()).addReviewer(input);
+    ReviewerResult result = gApi.changes().id(r.getChangeId()).addReviewer(input);
 
     assertThat(result.ccs).hasSize(1);
     AccountInfo info = result.ccs.get(0);
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersIT.java b/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersIT.java
index f9493c2..647e26b 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersIT.java
@@ -41,14 +41,14 @@
 import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.RefNames;
-import com.google.gerrit.extensions.api.changes.AddReviewerInput;
-import com.google.gerrit.extensions.api.changes.AddReviewerResult;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.api.changes.NotifyInfo;
 import com.google.gerrit.extensions.api.changes.RecipientType;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.changes.ReviewResult;
 import com.google.gerrit.extensions.api.changes.ReviewerInfo;
+import com.google.gerrit.extensions.api.changes.ReviewerInput;
+import com.google.gerrit.extensions.api.changes.ReviewerResult;
 import com.google.gerrit.extensions.client.ReviewerState;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.ApprovalInfo;
@@ -56,7 +56,7 @@
 import com.google.gerrit.extensions.common.LabelInfo;
 import com.google.gerrit.extensions.common.ReviewerUpdateInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.server.change.ReviewerAdder;
+import com.google.gerrit.server.change.ReviewerModifier;
 import com.google.gerrit.testing.FakeEmailSender.Message;
 import com.google.gson.stream.JsonReader;
 import com.google.inject.Inject;
@@ -82,8 +82,8 @@
     String largeGroup = groupOperations.newGroup().name("largeGroup").create().get();
     String mediumGroup = groupOperations.newGroup().name("mediumGroup").create().get();
 
-    int largeGroupSize = ReviewerAdder.DEFAULT_MAX_REVIEWERS + 1;
-    int mediumGroupSize = ReviewerAdder.DEFAULT_MAX_REVIEWERS_WITHOUT_CHECK + 1;
+    int largeGroupSize = ReviewerModifier.DEFAULT_MAX_REVIEWERS + 1;
+    int mediumGroupSize = ReviewerModifier.DEFAULT_MAX_REVIEWERS_WITHOUT_CHECK + 1;
     List<TestAccount> users = createAccounts(largeGroupSize, "addGroupAsReviewer");
     List<String> largeGroupUsernames = new ArrayList<>(mediumGroupSize);
     for (TestAccount u : users) {
@@ -100,7 +100,7 @@
     // Attempt to add overly large group as reviewers.
     PushOneCommit.Result r = createChange();
     String changeId = r.getChangeId();
-    AddReviewerResult result = addReviewer(changeId, largeGroup);
+    ReviewerResult result = addReviewer(changeId, largeGroup);
     assertThat(result.input).isEqualTo(largeGroup);
     assertThat(result.confirm).isNull();
     assertThat(result.error).contains("has too many members to add them all as reviewers");
@@ -115,7 +115,7 @@
     assertThat(result.reviewers).isNull();
 
     // Add medium group with confirmation.
-    AddReviewerInput in = new AddReviewerInput();
+    ReviewerInput in = new ReviewerInput();
     in.reviewer = mediumGroup;
     in.confirmed = true;
     result = addReviewer(changeId, in);
@@ -133,10 +133,10 @@
   public void addCcAccount() throws Exception {
     PushOneCommit.Result r = createChange();
     String changeId = r.getChangeId();
-    AddReviewerInput in = new AddReviewerInput();
+    ReviewerInput in = new ReviewerInput();
     in.reviewer = user.email();
     in.state = CC;
-    AddReviewerResult result = addReviewer(changeId, in);
+    ReviewerResult result = addReviewer(changeId, in);
 
     assertThat(result.input).isEqualTo(user.email());
     assertThat(result.confirm).isNull();
@@ -169,13 +169,13 @@
 
     PushOneCommit.Result r = createChange();
     String changeId = r.getChangeId();
-    AddReviewerInput in = new AddReviewerInput();
+    ReviewerInput in = new ReviewerInput();
     in.reviewer = groupOperations.newGroup().name("cc1").create().get();
     in.state = CC;
     gApi.groups()
         .id(in.reviewer)
         .addMembers(firstUsernames.toArray(new String[firstUsernames.size()]));
-    AddReviewerResult result = addReviewer(changeId, in);
+    ReviewerResult result = addReviewer(changeId, in);
 
     assertThat(result.input).isEqualTo(in.reviewer);
     assertThat(result.confirm).isNull();
@@ -229,7 +229,7 @@
   public void transitionCcToReviewer() throws Exception {
     PushOneCommit.Result r = createChange();
     String changeId = r.getChangeId();
-    AddReviewerInput in = new AddReviewerInput();
+    ReviewerInput in = new ReviewerInput();
     in.reviewer = user.email();
     in.state = CC;
     addReviewer(changeId, in);
@@ -414,8 +414,8 @@
 
   @Test
   public void reviewAndAddGroupReviewers() throws Exception {
-    int largeGroupSize = ReviewerAdder.DEFAULT_MAX_REVIEWERS + 1;
-    int mediumGroupSize = ReviewerAdder.DEFAULT_MAX_REVIEWERS_WITHOUT_CHECK + 1;
+    int largeGroupSize = ReviewerModifier.DEFAULT_MAX_REVIEWERS + 1;
+    int mediumGroupSize = ReviewerModifier.DEFAULT_MAX_REVIEWERS_WITHOUT_CHECK + 1;
     List<TestAccount> users = createAccounts(largeGroupSize, "reviewAndAddGroupReviewers");
     List<String> usernames = new ArrayList<>(largeGroupSize);
     for (TestAccount u : users) {
@@ -442,7 +442,8 @@
     assertThat(result.labels).isNull();
     assertThat(result.reviewers).isNotNull();
     assertThat(result.reviewers).hasSize(3);
-    AddReviewerResult reviewerResult = result.reviewers.get(largeGroup);
+
+    ReviewerResult reviewerResult = result.reviewers.get(largeGroup);
     assertThat(reviewerResult).isNotNull();
     assertThat(reviewerResult.confirm).isNull();
     assertThat(reviewerResult.error).isNotNull();
@@ -495,7 +496,7 @@
   public void addReviewerToReviewerChangeInfo() throws Exception {
     PushOneCommit.Result r = createChange();
     String changeId = r.getChangeId();
-    AddReviewerInput in = new AddReviewerInput();
+    ReviewerInput in = new ReviewerInput();
     in.reviewer = user.email();
     in.state = CC;
     addReviewer(changeId, in);
@@ -539,7 +540,8 @@
     ReviewResult result = review(r.getChangeId(), r.getCommit().name(), input);
     assertThat(result.reviewers).isNotNull();
     assertThat(result.reviewers).hasSize(1);
-    AddReviewerResult reviewerResult = result.reviewers.get(user.email());
+
+    ReviewerResult reviewerResult = result.reviewers.get(user.email());
     assertThat(reviewerResult).isNotNull();
     assertThat(reviewerResult.confirm).isNull();
     assertThat(reviewerResult.error).isNull();
@@ -564,7 +566,8 @@
     ReviewResult result = review(r.getChangeId(), r.getCommit().name(), input);
     assertThat(result.reviewers).isNotNull();
     assertThat(result.reviewers).hasSize(2);
-    AddReviewerResult reviewerResult = result.reviewers.get(group1);
+
+    ReviewerResult reviewerResult = result.reviewers.get(group1);
     assertThat(reviewerResult.error).isNull();
     assertThat(reviewerResult.reviewers).hasSize(2);
     reviewerResult = result.reviewers.get(group2);
@@ -646,7 +649,7 @@
     PushOneCommit.Result r = createChange();
     TestAccount userToNotify = createAccounts(1, "notify-details-post-reviewers").get(0);
 
-    AddReviewerInput addReviewer = new AddReviewerInput();
+    ReviewerInput addReviewer = new ReviewerInput();
     addReviewer.reviewer = user.email();
     addReviewer.notify = NotifyHandling.NONE;
     addReviewer.notifyDetails =
@@ -859,7 +862,7 @@
     PushOneCommit.Result r = createChange();
     TestAccount newUser = createAccounts(1, name("foo")).get(0);
 
-    AddReviewerInput input = new AddReviewerInput();
+    ReviewerInput input = new ReviewerInput();
     input.reviewer = user.email();
     input.state = ReviewerState.CC;
     gApi.changes().id(r.getChangeId()).addReviewer(input);
@@ -877,11 +880,11 @@
   public void addExistingReviewerShortCircuits() throws Exception {
     PushOneCommit.Result r = createChange();
 
-    AddReviewerInput input = new AddReviewerInput();
+    ReviewerInput input = new ReviewerInput();
     input.reviewer = user.email();
     input.state = ReviewerState.REVIEWER;
 
-    AddReviewerResult result = gApi.changes().id(r.getChangeId()).addReviewer(input);
+    ReviewerResult result = gApi.changes().id(r.getChangeId()).addReviewer(input);
     assertThat(result.reviewers).hasSize(1);
     ReviewerInfo info = result.reviewers.get(0);
     assertThat(info._accountId).isEqualTo(user.id().get());
@@ -893,11 +896,11 @@
   public void addExistingCcShortCircuits() throws Exception {
     PushOneCommit.Result r = createChange();
 
-    AddReviewerInput input = new AddReviewerInput();
+    ReviewerInput input = new ReviewerInput();
     input.reviewer = user.email();
     input.state = ReviewerState.CC;
 
-    AddReviewerResult result = gApi.changes().id(r.getChangeId()).addReviewer(input);
+    ReviewerResult result = gApi.changes().id(r.getChangeId()).addReviewer(input);
     assertThat(result.ccs).hasSize(1);
     AccountInfo info = result.ccs.get(0);
     assertThat(info._accountId).isEqualTo(user.id().get());
@@ -909,7 +912,7 @@
   public void moveCcToReviewer() throws Exception {
     // Create a change and add 'user' as CC.
     String changeId = createChange().getChangeId();
-    AddReviewerInput reviewerInput = new AddReviewerInput();
+    ReviewerInput reviewerInput = new ReviewerInput();
     reviewerInput.reviewer = user.email();
     reviewerInput.state = ReviewerState.CC;
     gApi.changes().id(changeId).addReviewer(reviewerInput);
@@ -967,7 +970,7 @@
 
     // Move 'user' from reviewer to CC.
     requestScopeOperations.setApiUser(admin.id());
-    AddReviewerInput reviewerInput = new AddReviewerInput();
+    ReviewerInput reviewerInput = new ReviewerInput();
     reviewerInput.reviewer = user.id().toString();
     reviewerInput.state = CC;
     gApi.changes().id(changeId).addReviewer(reviewerInput);
@@ -984,6 +987,19 @@
     assertThat(c.labels.get(LabelId.CODE_REVIEW).approved._accountId).isEqualTo(user.id().get());
   }
 
+  @Test
+  public void wipChangeDoesNotExposeReviewersInChangeSearch() throws Exception {
+    requestScopeOperations.setApiUser(admin.id());
+    String changeId = createChange().getChangeId();
+    gApi.changes().id(changeId).setWorkInProgress();
+    gApi.changes().id(changeId).addReviewer(user.email());
+    assertThat(
+            gApi.changes()
+                .query("project:" + project.get() + " AND reviewer:" + user.email() + "")
+                .get())
+        .isEmpty();
+  }
+
   private void assertThatUserIsOnlyReviewer(String changeId) throws Exception {
     AccountInfo userInfo = new AccountInfo(user.fullName(), user.getNameEmail().email());
     userInfo._accountId = user.id().get();
@@ -992,25 +1008,25 @@
         .containsExactly(ReviewerState.REVIEWER, ImmutableList.of(userInfo));
   }
 
-  private AddReviewerResult addReviewer(String changeId, String reviewer) throws Exception {
+  private ReviewerResult addReviewer(String changeId, String reviewer) throws Exception {
     return addReviewer(changeId, reviewer, SC_OK);
   }
 
-  private AddReviewerResult addReviewer(String changeId, String reviewer, int expectedStatus)
+  private ReviewerResult addReviewer(String changeId, String reviewer, int expectedStatus)
       throws Exception {
-    AddReviewerInput in = new AddReviewerInput();
+    ReviewerInput in = new ReviewerInput();
     in.reviewer = reviewer;
     return addReviewer(changeId, in, expectedStatus);
   }
 
-  private AddReviewerResult addReviewer(String changeId, AddReviewerInput in) throws Exception {
+  private ReviewerResult addReviewer(String changeId, ReviewerInput in) throws Exception {
     return addReviewer(changeId, in, SC_OK);
   }
 
-  private AddReviewerResult addReviewer(String changeId, AddReviewerInput in, int expectedStatus)
+  private ReviewerResult addReviewer(String changeId, ReviewerInput in, int expectedStatus)
       throws Exception {
     RestResponse resp = adminRestSession.post("/changes/" + changeId + "/reviewers", in);
-    return readContentFromJson(resp, expectedStatus, AddReviewerResult.class);
+    return readContentFromJson(resp, expectedStatus, ReviewerResult.class);
   }
 
   private RestResponse deleteReviewer(String changeId, TestAccount account) throws Exception {
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/ConfigChangeIT.java b/javatests/com/google/gerrit/acceptance/rest/change/ConfigChangeIT.java
index ed21050..695bb90 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/ConfigChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/ConfigChangeIT.java
@@ -60,14 +60,14 @@
   }
 
   @Test
-  @TestProjectInput(cloneAs = "user")
+  @TestProjectInput(cloneAs = "user1")
   public void updateProjectConfig() throws Exception {
     String id = testUpdateProjectConfig();
     assertThat(gApi.changes().id(id).get().revisions).hasSize(1);
   }
 
   @Test
-  @TestProjectInput(cloneAs = "user", submitType = SubmitType.CHERRY_PICK)
+  @TestProjectInput(cloneAs = "user1", submitType = SubmitType.CHERRY_PICK)
   public void updateProjectConfigWithCherryPick() throws Exception {
     String id = testUpdateProjectConfig();
     assertThat(gApi.changes().id(id).get().revisions).hasSize(2);
@@ -102,7 +102,7 @@
   }
 
   @Test
-  @TestProjectInput(cloneAs = "user")
+  @TestProjectInput(cloneAs = "user1")
   public void onlyAdminMayUpdateProjectParent() throws Exception {
     requestScopeOperations.setApiUser(admin.id());
     ProjectInput parent = new ProjectInput();
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/DeleteVoteIT.java b/javatests/com/google/gerrit/acceptance/rest/change/DeleteVoteIT.java
index 0c2c3a1..b4eb692 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/DeleteVoteIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/DeleteVoteIT.java
@@ -29,6 +29,7 @@
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.ChangeMessageInfo;
+import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.testing.FakeEmailSender;
 import com.google.gson.reflect.TypeToken;
 import com.google.inject.Inject;
@@ -99,7 +100,10 @@
 
     ChangeMessageInfo message = Iterables.getLast(c.messages);
     assertThat(message.author._accountId).isEqualTo(admin.id().get());
-    assertThat(message.message).isEqualTo("Removed Code-Review+1 by User <user@example.com>\n");
+    assertThat(message.message)
+        .isEqualTo(
+            String.format(
+                "Removed Code-Review+1 by %s\n", ChangeMessagesUtil.getAccountTemplate(user.id())));
     assertThat(getReviewers(c.reviewers.get(REVIEWER)))
         .containsExactlyElementsIn(ImmutableSet.of(admin.id(), user.id()));
   }
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/IndexChangeIT.java b/javatests/com/google/gerrit/acceptance/rest/change/IndexChangeIT.java
index def4ed8..99aa273 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/IndexChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/IndexChangeIT.java
@@ -67,7 +67,7 @@
     TestAccount user2 = accountCreator.user2();
     AccountGroup.UUID groupId = groupOperations.newGroup().name("test").create();
     String group = groupOperations.group(groupId).get().name();
-    gApi.groups().id(group).addMembers("admin", "user", user2.username());
+    gApi.groups().id(group).addMembers("admin", user.username(), user2.username());
 
     // Create a project and restrict its visibility to the group
     Project.NameKey p = projectOperations.newProject().create();
@@ -103,7 +103,7 @@
 
     // Remove the user from the group so they can no longer see the project
     requestScopeOperations.setApiUser(admin.id());
-    gApi.groups().id(group).removeMembers("user");
+    gApi.groups().id(group).removeMembers(user.username());
 
     // User can no longer see the change
     requestScopeOperations.setApiUser(user.id());
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/MoveChangeIT.java b/javatests/com/google/gerrit/acceptance/rest/change/MoveChangeIT.java
index e94b660..6ed0bf8 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/MoveChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/MoveChangeIT.java
@@ -24,6 +24,7 @@
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Iterables;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.GitUtil;
 import com.google.gerrit.acceptance.NoHttpd;
@@ -91,7 +92,8 @@
     expectedMessage.append("Change destination moved from master to moveTest");
     expectedMessage.append("\n\n");
     expectedMessage.append(moveMessage);
-    assertThat(r.getChange().messages().get(1).getMessage()).isEqualTo(expectedMessage.toString());
+    assertThat(Iterables.getLast(gApi.changes().id(r.getChangeId()).get().messages).message)
+        .isEqualTo(expectedMessage.toString());
   }
 
   @Test
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 ffde622..ed6254a 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/SuggestReviewersIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/SuggestReviewersIT.java
@@ -38,7 +38,7 @@
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.accounts.EmailInput;
-import com.google.gerrit.extensions.api.changes.AddReviewerInput;
+import com.google.gerrit.extensions.api.changes.ReviewerInput;
 import com.google.gerrit.extensions.client.ReviewerState;
 import com.google.gerrit.extensions.common.ChangeInput;
 import com.google.gerrit.extensions.common.SuggestedReviewerInfo;
@@ -502,7 +502,7 @@
     assertReviewers(
         suggestReviewers(changeId, name), ImmutableList.of(foo1, foo2), ImmutableList.of());
 
-    AddReviewerInput reviewerInput = new AddReviewerInput();
+    ReviewerInput reviewerInput = new ReviewerInput();
     reviewerInput.reviewer = foo2.id().toString();
     reviewerInput.state = ReviewerState.CC;
     gApi.changes().id(changeId).addReviewer(reviewerInput);
@@ -525,7 +525,7 @@
 
     assertReviewers(suggestCcs(changeId, name), ImmutableList.of(foo1, foo2), ImmutableList.of());
 
-    AddReviewerInput reviewerInput = new AddReviewerInput();
+    ReviewerInput reviewerInput = new ReviewerInput();
     reviewerInput.reviewer = foo2.id().toString();
     reviewerInput.state = ReviewerState.REVIEWER;
     gApi.changes().id(changeId).addReviewer(reviewerInput);
diff --git a/javatests/com/google/gerrit/acceptance/rest/config/ServerInfoIT.java b/javatests/com/google/gerrit/acceptance/rest/config/ServerInfoIT.java
index c893214..4fe86b3 100644
--- a/javatests/com/google/gerrit/acceptance/rest/config/ServerInfoIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/config/ServerInfoIT.java
@@ -213,4 +213,12 @@
     ServerInfo i = gApi.config().server().getInfo();
     assertThat(i.change.mergeabilityComputationBehavior).isEqualTo("NEVER");
   }
+
+  @Test
+  @GerritConfig(name = "download.scheme", value = "fooBar")
+  @GerritConfig(name = "download.command", value = "fooBar")
+  public void misconfiguredDownloadCommands() throws Exception {
+    ServerInfo i = gApi.config().server().getInfo();
+    assertThat(i.download.schemes).isEmpty();
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/AccessIT.java b/javatests/com/google/gerrit/acceptance/rest/project/AccessIT.java
index ff4f203..b94ea37 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/AccessIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/AccessIT.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2016 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.
@@ -11,1004 +11,102 @@
 // 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.project;
 
 import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.Truth8.assertThat;
-import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
-import static com.google.gerrit.extensions.client.ListChangesOption.MESSAGES;
-import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
-import static com.google.gerrit.server.schema.AclUtil.grant;
-import static com.google.gerrit.testing.GerritJUnit.assertThrows;
-import static com.google.gerrit.truth.ConfigSubject.assertThat;
-import static com.google.gerrit.truth.MapSubject.assertThatMap;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.ExtensionRegistry;
-import com.google.gerrit.acceptance.ExtensionRegistry.Registration;
-import com.google.gerrit.acceptance.GitUtil;
-import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.RestResponse;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
-import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
-import com.google.gerrit.common.data.GlobalCapability;
-import com.google.gerrit.entities.AccessSection;
-import com.google.gerrit.entities.GroupReference;
-import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.entities.Permission;
-import com.google.gerrit.entities.Project;
-import com.google.gerrit.entities.RefNames;
-import com.google.gerrit.extensions.api.access.AccessSectionInfo;
-import com.google.gerrit.extensions.api.access.PermissionInfo;
-import com.google.gerrit.extensions.api.access.PermissionRuleInfo;
 import com.google.gerrit.extensions.api.access.ProjectAccessInfo;
-import com.google.gerrit.extensions.api.access.ProjectAccessInput;
-import com.google.gerrit.extensions.api.changes.ReviewInput;
-import com.google.gerrit.extensions.api.projects.ProjectApi;
-import com.google.gerrit.extensions.client.ChangeStatus;
-import com.google.gerrit.extensions.common.ChangeInfo;
-import com.google.gerrit.extensions.common.GroupInfo;
-import com.google.gerrit.extensions.common.WebLinkInfo;
-import com.google.gerrit.extensions.config.CapabilityDefinition;
-import com.google.gerrit.extensions.config.PluginProjectPermissionDefinition;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.BadRequestException;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.extensions.webui.FileHistoryWebLink;
-import com.google.gerrit.server.config.AllProjectsNameProvider;
-import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
-import com.google.gerrit.server.git.meta.MetaDataUpdate;
-import com.google.gerrit.server.group.SystemGroupBackend;
-import com.google.gerrit.server.project.ProjectConfig;
-import com.google.gerrit.server.schema.GrantRevertPermission;
+import com.google.gerrit.extensions.api.projects.ProjectInput;
+import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gson.reflect.TypeToken;
 import com.google.inject.Inject;
-import java.util.HashMap;
 import java.util.Map;
-import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
-import org.eclipse.jgit.junit.TestRepository;
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.Constants;
-import org.eclipse.jgit.lib.RefUpdate;
-import org.eclipse.jgit.lib.RefUpdate.Result;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.junit.Before;
 import org.junit.Test;
 
 public class AccessIT extends AbstractDaemonTest {
-
-  private static final String REFS_ALL = Constants.R_REFS + "*";
-  private static final String REFS_HEADS = Constants.R_HEADS + "*";
-  private static final String REFS_META_VERSION = "refs/meta/version";
-  private static final String REFS_DRAFTS = "refs/draft-comments/*";
-  private static final String REFS_STARRED_CHANGES = "refs/starred-changes/*";
-
   @Inject private ProjectOperations projectOperations;
-  @Inject private RequestScopeOperations requestScopeOperations;
-  @Inject private ExtensionRegistry extensionRegistry;
-  @Inject private GrantRevertPermission grantRevertPermission;
 
-  private Project.NameKey newProjectName;
-
-  @Before
-  public void setUp() throws Exception {
-    newProjectName = projectOperations.newProject().create();
+  @Test
+  public void listAccessWithoutSpecifyingProject() throws Exception {
+    RestResponse r = adminRestSession.get("/access/");
+    r.assertOK();
+    Map<String, ProjectAccessInfo> infoByProject =
+        newGson()
+            .fromJson(r.getReader(), new TypeToken<Map<String, ProjectAccessInfo>>() {}.getType());
+    assertThat(infoByProject).isEmpty();
   }
 
   @Test
-  public void grantRevertPermission() throws Exception {
-    String ref = "refs/*";
-    String groupId = "global:Registered-Users";
-
-    grantRevertPermission.execute(newProjectName);
-
-    ProjectAccessInfo info = pApi().access();
-    assertThat(info.local.containsKey(ref)).isTrue();
-    AccessSectionInfo accessSectionInfo = info.local.get(ref);
-    assertThat(accessSectionInfo.permissions.containsKey(Permission.REVERT)).isTrue();
-    PermissionInfo permissionInfo = accessSectionInfo.permissions.get(Permission.REVERT);
-    assertThat(permissionInfo.rules.containsKey(groupId)).isTrue();
-    PermissionRuleInfo permissionRuleInfo = permissionInfo.rules.get(groupId);
-    assertThat(permissionRuleInfo.action).isEqualTo(PermissionRuleInfo.Action.ALLOW);
+  public void listAccessWithoutSpecifyingAnEmptyProjectName() throws Exception {
+    RestResponse r = adminRestSession.get("/access/?p=");
+    r.assertOK();
+    Map<String, ProjectAccessInfo> infoByProject =
+        newGson()
+            .fromJson(r.getReader(), new TypeToken<Map<String, ProjectAccessInfo>>() {}.getType());
+    assertThat(infoByProject).isEmpty();
   }
 
   @Test
-  public void grantRevertPermissionByOnNewRefAndDeletingOnOldRef() throws Exception {
-    String refsHeads = "refs/heads/*";
-    String refsStar = "refs/*";
-    String groupId = "global:Registered-Users";
-    GroupReference registeredUsers = systemGroupBackend.getGroup(REGISTERED_USERS);
-
-    try (Repository repo = repoManager.openRepository(newProjectName)) {
-      MetaDataUpdate md = new MetaDataUpdate(GitReferenceUpdated.DISABLED, newProjectName, repo);
-      ProjectConfig projectConfig = projectConfigFactory.read(md);
-      projectConfig.upsertAccessSection(
-          AccessSection.HEADS,
-          heads -> {
-            grant(projectConfig, heads, Permission.REVERT, registeredUsers);
-          });
-      md.getCommitBuilder().setAuthor(admin.newIdent());
-      md.getCommitBuilder().setCommitter(admin.newIdent());
-      md.setMessage("Add revert permission for all registered users\n");
-
-      projectConfig.commit(md);
-    }
-    grantRevertPermission.execute(newProjectName);
-
-    ProjectAccessInfo info = pApi().access();
-
-    // Revert permission is removed on refs/heads/*.
-    assertThat(info.local.containsKey(refsHeads)).isTrue();
-    AccessSectionInfo accessSectionInfo = info.local.get(refsHeads);
-    assertThat(accessSectionInfo.permissions.containsKey(Permission.REVERT)).isFalse();
-
-    // new permission is added on refs/* with Registered-Users.
-    assertThat(info.local.containsKey(refsStar)).isTrue();
-    accessSectionInfo = info.local.get(refsStar);
-    assertThat(accessSectionInfo.permissions.containsKey(Permission.REVERT)).isTrue();
-    PermissionInfo permissionInfo = accessSectionInfo.permissions.get(Permission.REVERT);
-    assertThat(permissionInfo.rules.containsKey(groupId)).isTrue();
-    PermissionRuleInfo permissionRuleInfo = permissionInfo.rules.get(groupId);
-    assertThat(permissionRuleInfo.action).isEqualTo(PermissionRuleInfo.Action.ALLOW);
+  public void listAccessForNonExistingProject() throws Exception {
+    RestResponse r = adminRestSession.get("/access/?project=non-existing");
+    r.assertNotFound();
+    assertThat(r.getEntityContent()).isEqualTo("non-existing");
   }
 
   @Test
-  public void grantRevertPermissionDoesntDeleteAdminsPreferences() throws Exception {
-    GroupReference registeredUsers = systemGroupBackend.getGroup(REGISTERED_USERS);
-    GroupReference otherGroup = systemGroupBackend.getGroup(ANONYMOUS_USERS);
-
-    try (Repository repo = repoManager.openRepository(newProjectName)) {
-      MetaDataUpdate md = new MetaDataUpdate(GitReferenceUpdated.DISABLED, newProjectName, repo);
-      ProjectConfig projectConfig = projectConfigFactory.read(md);
-      projectConfig.upsertAccessSection(
-          AccessSection.HEADS,
-          heads -> {
-            grant(projectConfig, heads, Permission.REVERT, registeredUsers);
-            grant(projectConfig, heads, Permission.REVERT, otherGroup);
-          });
-      md.getCommitBuilder().setAuthor(admin.newIdent());
-      md.getCommitBuilder().setCommitter(admin.newIdent());
-      md.setMessage("Add revert permission for all registered users\n");
-
-      projectConfig.commit(md);
-    }
-    projectCache.evict(newProjectName);
-    ProjectAccessInfo expected = pApi().access();
-
-    grantRevertPermission.execute(newProjectName);
-    projectCache.evict(newProjectName);
-    ProjectAccessInfo actual = pApi().access();
-    // Permissions don't change
-    assertThat(expected.local).isEqualTo(actual.local);
-  }
-
-  @Test
-  public void grantRevertPermissionOnlyWorksOnce() throws Exception {
-    grantRevertPermission.execute(newProjectName);
-    grantRevertPermission.execute(newProjectName);
-
-    try (Repository repo = repoManager.openRepository(newProjectName)) {
-      MetaDataUpdate md = new MetaDataUpdate(GitReferenceUpdated.DISABLED, newProjectName, repo);
-      ProjectConfig projectConfig = projectConfigFactory.read(md);
-      AccessSection all = projectConfig.getAccessSection(AccessSection.ALL);
-
-      Permission permission = all.getPermission(Permission.REVERT);
-      assertThat(permission.getRules()).hasSize(1);
-    }
-  }
-
-  @Test
-  public void getDefaultInheritance() throws Exception {
-    String inheritedName = pApi().access().inheritsFrom.name;
-    assertThat(inheritedName).isEqualTo(AllProjectsNameProvider.DEFAULT);
-  }
-
-  private Registration newFileHistoryWebLink() {
-    FileHistoryWebLink weblink =
-        new FileHistoryWebLink() {
-          @Override
-          public WebLinkInfo getFileHistoryWebLink(
-              String projectName, String revision, String fileName) {
-            return new WebLinkInfo(
-                "name", "imageURL", "http://view/" + projectName + "/" + fileName);
-          }
-        };
-    return extensionRegistry.newRegistration().add(weblink);
-  }
-
-  @Test
-  public void webLink() throws Exception {
-    try (Registration registration = newFileHistoryWebLink()) {
-      ProjectAccessInfo info = pApi().access();
-      assertThat(info.configWebLinks).hasSize(1);
-      assertThat(info.configWebLinks.get(0).url)
-          .isEqualTo("http://view/" + newProjectName + "/project.config");
-    }
-  }
-
-  @Test
-  public void webLinkNoRefsMetaConfig() throws Exception {
-    try (Repository repo = repoManager.openRepository(newProjectName);
-        Registration registration = newFileHistoryWebLink()) {
-      RefUpdate u = repo.updateRef(RefNames.REFS_CONFIG);
-      u.setForceUpdate(true);
-      assertThat(u.delete()).isEqualTo(Result.FORCED);
-
-      // This should not crash.
-      pApi().access();
-    }
-  }
-
-  @Test
-  public void addAccessSection() throws Exception {
-    RevCommit initialHead = projectOperations.project(newProjectName).getHead(RefNames.REFS_CONFIG);
-
-    ProjectAccessInput accessInput = newProjectAccessInput();
-    AccessSectionInfo accessSectionInfo = createDefaultAccessSectionInfo();
-
-    accessInput.add.put(REFS_HEADS, accessSectionInfo);
-    pApi().access(accessInput);
-
-    assertThat(pApi().access().local).isEqualTo(accessInput.add);
-
-    RevCommit updatedHead = projectOperations.project(newProjectName).getHead(RefNames.REFS_CONFIG);
-    eventRecorder.assertRefUpdatedEvents(
-        newProjectName.get(), RefNames.REFS_CONFIG, null, initialHead, initialHead, updatedHead);
-  }
-
-  @Test
-  public void addAccessSectionForPluginPermission() throws Exception {
-    try (Registration registration =
-        extensionRegistry
-            .newRegistration()
-            .add(
-                new PluginProjectPermissionDefinition() {
-                  @Override
-                  public String getDescription() {
-                    return "A Plugin Project Permission";
-                  }
-                },
-                "fooPermission")) {
-      ProjectAccessInput accessInput = newProjectAccessInput();
-      AccessSectionInfo accessSectionInfo = newAccessSectionInfo();
-
-      PermissionInfo foo = newPermissionInfo();
-      PermissionRuleInfo pri = new PermissionRuleInfo(PermissionRuleInfo.Action.ALLOW, false);
-      foo.rules.put(SystemGroupBackend.REGISTERED_USERS.get(), pri);
-      accessSectionInfo.permissions.put(
-          "plugin-" + ExtensionRegistry.PLUGIN_NAME + "-fooPermission", foo);
-
-      accessInput.add.put(REFS_HEADS, accessSectionInfo);
-      ProjectAccessInfo updatedAccessSectionInfo = pApi().access(accessInput);
-      assertThat(updatedAccessSectionInfo.local).isEqualTo(accessInput.add);
-
-      assertThat(pApi().access().local).isEqualTo(accessInput.add);
-    }
-  }
-
-  @Test
-  public void addAccessSectionWithInvalidPermission() throws Exception {
-    ProjectAccessInput accessInput = newProjectAccessInput();
-    AccessSectionInfo accessSectionInfo = createDefaultAccessSectionInfo();
-
-    PermissionInfo push = newPermissionInfo();
-    PermissionRuleInfo pri = new PermissionRuleInfo(PermissionRuleInfo.Action.ALLOW, false);
-    push.rules.put(SystemGroupBackend.REGISTERED_USERS.get(), pri);
-    accessSectionInfo.permissions.put("Invalid Permission", push);
-
-    accessInput.add.put(REFS_HEADS, accessSectionInfo);
-    BadRequestException ex =
-        assertThrows(BadRequestException.class, () -> pApi().access(accessInput));
-    assertThat(ex).hasMessageThat().isEqualTo("Unknown permission: Invalid Permission");
-  }
-
-  @Test
-  public void addAccessSectionWithInvalidLabelPermission() throws Exception {
-    ProjectAccessInput accessInput = newProjectAccessInput();
-    AccessSectionInfo accessSectionInfo = createDefaultAccessSectionInfo();
-
-    PermissionInfo push = newPermissionInfo();
-    PermissionRuleInfo pri = new PermissionRuleInfo(PermissionRuleInfo.Action.ALLOW, false);
-    push.rules.put(SystemGroupBackend.REGISTERED_USERS.get(), pri);
-    accessSectionInfo.permissions.put("label-Invalid Permission", push);
-
-    accessInput.add.put(REFS_HEADS, accessSectionInfo);
-    BadRequestException ex =
-        assertThrows(BadRequestException.class, () -> pApi().access(accessInput));
-    assertThat(ex).hasMessageThat().isEqualTo("Unknown permission: label-Invalid Permission");
-  }
-
-  @Test
-  public void createAccessChangeNop() throws Exception {
-    ProjectAccessInput accessInput = newProjectAccessInput();
-    assertThrows(BadRequestException.class, () -> pApi().accessChange(accessInput));
-  }
-
-  @Test
-  public void createAccessChangeEmptyConfig() throws Exception {
-    try (Repository repo = repoManager.openRepository(newProjectName)) {
-      RefUpdate ru = repo.updateRef(RefNames.REFS_CONFIG);
-      ru.setForceUpdate(true);
-      assertThat(ru.delete()).isEqualTo(Result.FORCED);
-
-      ProjectAccessInput accessInput = newProjectAccessInput();
-      AccessSectionInfo accessSection = newAccessSectionInfo();
-      PermissionInfo read = newPermissionInfo();
-      PermissionRuleInfo pri = new PermissionRuleInfo(PermissionRuleInfo.Action.BLOCK, false);
-      read.rules.put(SystemGroupBackend.REGISTERED_USERS.get(), pri);
-      accessSection.permissions.put(Permission.READ, read);
-      accessInput.add.put(REFS_HEADS, accessSection);
-
-      ChangeInfo out = pApi().accessChange(accessInput);
-      assertThat(out.status).isEqualTo(ChangeStatus.NEW);
-    }
-  }
-
-  @Test
-  public void createAccessChange() throws Exception {
+  public void listAccessForNonVisibleProject() throws Exception {
     projectOperations
-        .project(newProjectName)
+        .project(project)
         .forUpdate()
-        .add(allow(Permission.READ).ref(RefNames.REFS_CONFIG).group(REGISTERED_USERS))
+        .add(block(Permission.READ).ref("refs/*").group(REGISTERED_USERS))
         .update();
-    // User can see the branch
-    requestScopeOperations.setApiUser(user.id());
-    pApi().branch("refs/heads/master").get();
 
-    ProjectAccessInput accessInput = newProjectAccessInput();
-
-    AccessSectionInfo accessSection = newAccessSectionInfo();
-
-    // Deny read to registered users.
-    PermissionInfo read = newPermissionInfo();
-    PermissionRuleInfo pri = new PermissionRuleInfo(PermissionRuleInfo.Action.DENY, false);
-    read.rules.put(SystemGroupBackend.REGISTERED_USERS.get(), pri);
-    read.exclusive = true;
-    accessSection.permissions.put(Permission.READ, read);
-    accessInput.add.put(REFS_HEADS, accessSection);
-
-    requestScopeOperations.setApiUser(user.id());
-    ChangeInfo out = pApi().accessChange(accessInput);
-
-    assertThat(out.project).isEqualTo(newProjectName.get());
-    assertThat(out.branch).isEqualTo(RefNames.REFS_CONFIG);
-    assertThat(out.status).isEqualTo(ChangeStatus.NEW);
-    assertThat(out.submitted).isNull();
-
-    requestScopeOperations.setApiUser(admin.id());
-
-    ChangeInfo c = gApi.changes().id(out._number).get(MESSAGES);
-    assertThat(c.messages.stream().map(m -> m.message)).containsExactly("Uploaded patch set 1");
-
-    ReviewInput reviewIn = new ReviewInput();
-    reviewIn.label("Code-Review", (short) 2);
-    gApi.changes().id(out._number).current().review(reviewIn);
-    gApi.changes().id(out._number).current().submit();
-
-    // check that the change took effect.
-    requestScopeOperations.setApiUser(user.id());
-    assertThrows(ResourceNotFoundException.class, () -> pApi().branch("refs/heads/master").get());
-
-    // Restore.
-    accessInput.add.clear();
-    accessInput.remove.put(REFS_HEADS, accessSection);
-    requestScopeOperations.setApiUser(user.id());
-
-    requestScopeOperations.setApiUser(admin.id());
-    out = pApi().accessChange(accessInput);
-
-    gApi.changes().id(out._number).current().review(reviewIn);
-    gApi.changes().id(out._number).current().submit();
-
-    // Now it works again.
-    requestScopeOperations.setApiUser(user.id());
-    pApi().branch("refs/heads/master").get();
+    RestResponse r = userRestSession.get("/access/?project=" + project.get());
+    r.assertNotFound();
+    assertThat(r.getEntityContent()).isEqualTo(project.get());
   }
 
   @Test
-  public void removePermission() throws Exception {
-    // Add initial permission set
-    ProjectAccessInput accessInput = newProjectAccessInput();
-    AccessSectionInfo accessSectionInfo = createDefaultAccessSectionInfo();
-
-    accessInput.add.put(REFS_HEADS, accessSectionInfo);
-    pApi().access(accessInput);
-
-    // Remove specific permission
-    AccessSectionInfo accessSectionToRemove = newAccessSectionInfo();
-    accessSectionToRemove.permissions.put(
-        Permission.LABEL + LabelId.CODE_REVIEW, newPermissionInfo());
-    ProjectAccessInput removal = newProjectAccessInput();
-    removal.remove.put(REFS_HEADS, accessSectionToRemove);
-    pApi().access(removal);
-
-    // Remove locally
-    accessInput.add.get(REFS_HEADS).permissions.remove(Permission.LABEL + LabelId.CODE_REVIEW);
-
-    // Check
-    assertThat(pApi().access().local).isEqualTo(accessInput.add);
+  public void listAccess() throws Exception {
+    RestResponse r = adminRestSession.get("/access/?project=" + project.get());
+    r.assertOK();
+    Map<String, ProjectAccessInfo> infoByProject =
+        newGson()
+            .fromJson(r.getReader(), new TypeToken<Map<String, ProjectAccessInfo>>() {}.getType());
+    assertThat(infoByProject.keySet()).containsExactly(project.get());
   }
 
   @Test
-  public void removePermissionRule() throws Exception {
-    // Add initial permission set
-    ProjectAccessInput accessInput = newProjectAccessInput();
-    AccessSectionInfo accessSectionInfo = createDefaultAccessSectionInfo();
+  public void listAccess_withUrlEncodedProjectName() throws Exception {
+    String fooBarBazProjectName = name("foo/bar/baz");
+    ProjectInput in = new ProjectInput();
+    in.name = fooBarBazProjectName;
+    gApi.projects().create(in);
 
-    accessInput.add.put(REFS_HEADS, accessSectionInfo);
-    pApi().access(accessInput);
-
-    // Remove specific permission rule
-    AccessSectionInfo accessSectionToRemove = newAccessSectionInfo();
-    PermissionInfo codeReview = newPermissionInfo();
-    codeReview.label = LabelId.CODE_REVIEW;
-    PermissionRuleInfo pri = new PermissionRuleInfo(PermissionRuleInfo.Action.DENY, false);
-    codeReview.rules.put(SystemGroupBackend.REGISTERED_USERS.get(), pri);
-    accessSectionToRemove.permissions.put(Permission.LABEL + LabelId.CODE_REVIEW, codeReview);
-    ProjectAccessInput removal = newProjectAccessInput();
-    removal.remove.put(REFS_HEADS, accessSectionToRemove);
-    pApi().access(removal);
-
-    // Remove locally
-    accessInput
-        .add
-        .get(REFS_HEADS)
-        .permissions
-        .get(Permission.LABEL + LabelId.CODE_REVIEW)
-        .rules
-        .remove(SystemGroupBackend.REGISTERED_USERS.get());
-
-    // Check
-    assertThat(pApi().access().local).isEqualTo(accessInput.add);
+    RestResponse r =
+        adminRestSession.get("/access/?project=" + IdString.fromDecoded(fooBarBazProjectName));
+    r.assertOK();
+    Map<String, ProjectAccessInfo> infoByProject =
+        newGson()
+            .fromJson(r.getReader(), new TypeToken<Map<String, ProjectAccessInfo>>() {}.getType());
+    assertThat(infoByProject.keySet()).containsExactly(fooBarBazProjectName);
   }
 
   @Test
-  public void removePermissionRulesAndCleanupEmptyEntries() throws Exception {
-    // Add initial permission set
-    ProjectAccessInput accessInput = newProjectAccessInput();
-    AccessSectionInfo accessSectionInfo = createDefaultAccessSectionInfo();
-
-    accessInput.add.put(REFS_HEADS, accessSectionInfo);
-    pApi().access(accessInput);
-
-    // Remove specific permission rules
-    AccessSectionInfo accessSectionToRemove = newAccessSectionInfo();
-    PermissionInfo codeReview = newPermissionInfo();
-    codeReview.label = LabelId.CODE_REVIEW;
-    PermissionRuleInfo pri = new PermissionRuleInfo(PermissionRuleInfo.Action.DENY, false);
-    codeReview.rules.put(SystemGroupBackend.REGISTERED_USERS.get(), pri);
-    pri = new PermissionRuleInfo(PermissionRuleInfo.Action.DENY, false);
-    codeReview.rules.put(SystemGroupBackend.PROJECT_OWNERS.get(), pri);
-    accessSectionToRemove.permissions.put(Permission.LABEL + LabelId.CODE_REVIEW, codeReview);
-    ProjectAccessInput removal = newProjectAccessInput();
-    removal.remove.put(REFS_HEADS, accessSectionToRemove);
-    pApi().access(removal);
-
-    // Remove locally
-    accessInput.add.get(REFS_HEADS).permissions.remove(Permission.LABEL + LabelId.CODE_REVIEW);
-
-    // Check
-    assertThat(pApi().access().local).isEqualTo(accessInput.add);
-  }
-
-  @Test
-  public void getPermissionsWithDisallowedUser() throws Exception {
-    // Add initial permission set
-    ProjectAccessInput accessInput = newProjectAccessInput();
-    AccessSectionInfo accessSectionInfo = createAccessSectionInfoDenyAll();
-
-    // Disallow READ
-    accessInput.add.put(REFS_META_VERSION, accessSectionInfo);
-    accessInput.add.put(REFS_DRAFTS, accessSectionInfo);
-    accessInput.add.put(REFS_STARRED_CHANGES, accessSectionInfo);
-    accessInput.add.put(REFS_HEADS, accessSectionInfo);
-    pApi().access(accessInput);
-
-    requestScopeOperations.setApiUser(user.id());
-    assertThrows(ResourceNotFoundException.class, () -> pApi().access());
-  }
-
-  @Test
-  public void setPermissionsWithDisallowedUser() throws Exception {
-    // Add initial permission set
-    ProjectAccessInput accessInput = newProjectAccessInput();
-    AccessSectionInfo accessSectionInfo = createAccessSectionInfoDenyAll();
-
-    // Disallow READ
-    accessInput.add.put(REFS_META_VERSION, accessSectionInfo);
-    accessInput.add.put(REFS_DRAFTS, accessSectionInfo);
-    accessInput.add.put(REFS_STARRED_CHANGES, accessSectionInfo);
-    accessInput.add.put(REFS_HEADS, accessSectionInfo);
-    pApi().access(accessInput);
-
-    // Create a change to apply
-    ProjectAccessInput accessInfoToApply = newProjectAccessInput();
-    AccessSectionInfo accessSectionInfoToApply = createDefaultAccessSectionInfo();
-    accessInfoToApply.add.put(REFS_HEADS, accessSectionInfoToApply);
-
-    requestScopeOperations.setApiUser(user.id());
-    assertThrows(ResourceNotFoundException.class, () -> pApi().access());
-  }
-
-  @Test
-  public void permissionsGroupMap() throws Exception {
-    // Add initial permission set
-    ProjectAccessInput accessInput = newProjectAccessInput();
-    AccessSectionInfo accessSection = newAccessSectionInfo();
-
-    PermissionInfo push = newPermissionInfo();
-    PermissionRuleInfo pri = new PermissionRuleInfo(PermissionRuleInfo.Action.ALLOW, false);
-    push.rules.put(SystemGroupBackend.PROJECT_OWNERS.get(), pri);
-    accessSection.permissions.put(Permission.PUSH, push);
-
-    PermissionInfo read = newPermissionInfo();
-    pri = new PermissionRuleInfo(PermissionRuleInfo.Action.ALLOW, false);
-    read.rules.put(SystemGroupBackend.ANONYMOUS_USERS.get(), pri);
-    accessSection.permissions.put(Permission.READ, read);
-
-    accessInput.add.put(REFS_ALL, accessSection);
-    ProjectAccessInfo result = pApi().access(accessInput);
-    assertThatMap(result.groups)
-        .keys()
-        .containsExactly(
-            SystemGroupBackend.PROJECT_OWNERS.get(), SystemGroupBackend.ANONYMOUS_USERS.get());
-
-    // Check the name, which is what the UI cares about; exhaustive
-    // coverage of GroupInfo should be in groups REST API tests.
-    assertThat(result.groups.get(SystemGroupBackend.PROJECT_OWNERS.get()).name)
-        .isEqualTo("Project Owners");
-    // Strip the ID, since it is in the key.
-    assertThat(result.groups.get(SystemGroupBackend.PROJECT_OWNERS.get()).id).isNull();
-
-    // Get call returns groups too.
-    ProjectAccessInfo loggedInResult = pApi().access();
-    assertThatMap(loggedInResult.groups)
-        .keys()
-        .containsExactly(
-            SystemGroupBackend.PROJECT_OWNERS.get(), SystemGroupBackend.ANONYMOUS_USERS.get());
-
-    GroupInfo owners = loggedInResult.groups.get(SystemGroupBackend.PROJECT_OWNERS.get());
-    assertThat(owners.name).isEqualTo("Project Owners");
-    assertThat(owners.id).isNull();
-    assertThat(owners.members).isNull();
-    assertThat(owners.includes).isNull();
-
-    // PROJECT_OWNERS is invisible to anonymous user, but GetAccess disregards visibility.
-    requestScopeOperations.setApiUserAnonymous();
-    ProjectAccessInfo anonResult = pApi().access();
-    assertThatMap(anonResult.groups)
-        .keys()
-        .containsExactly(
-            SystemGroupBackend.PROJECT_OWNERS.get(), SystemGroupBackend.ANONYMOUS_USERS.get());
-  }
-
-  @Test
-  public void updateParentAsUser() throws Exception {
-    // Create child
-    String newParentProjectName = projectOperations.newProject().create().get();
-
-    // Set new parent
-    ProjectAccessInput accessInput = newProjectAccessInput();
-    accessInput.parent = newParentProjectName;
-
-    requestScopeOperations.setApiUser(user.id());
-    AuthException thrown = assertThrows(AuthException.class, () -> pApi().access(accessInput));
-    assertThat(thrown).hasMessageThat().contains("administrate server not permitted");
-  }
-
-  @Test
-  public void updateParentAsAdministrator() throws Exception {
-    // Create parent
-    String newParentProjectName = projectOperations.newProject().create().get();
-
-    // Set new parent
-    ProjectAccessInput accessInput = newProjectAccessInput();
-    accessInput.parent = newParentProjectName;
-
-    pApi().access(accessInput);
-
-    assertThat(pApi().access().inheritsFrom.name).isEqualTo(newParentProjectName);
-  }
-
-  @Test
-  public void addGlobalCapabilityAsUser() throws Exception {
-    ProjectAccessInput accessInput = newProjectAccessInput();
-    AccessSectionInfo accessSectionInfo = createDefaultGlobalCapabilitiesAccessSectionInfo();
-
-    accessInput.add.put(AccessSection.GLOBAL_CAPABILITIES, accessSectionInfo);
-
-    requestScopeOperations.setApiUser(user.id());
-    assertThrows(
-        AuthException.class, () -> gApi.projects().name(allProjects.get()).access(accessInput));
-  }
-
-  @Test
-  public void addGlobalCapabilityAsAdmin() throws Exception {
-    ProjectAccessInput accessInput = newProjectAccessInput();
-    AccessSectionInfo accessSectionInfo = createDefaultGlobalCapabilitiesAccessSectionInfo();
-
-    accessInput.add.put(AccessSection.GLOBAL_CAPABILITIES, accessSectionInfo);
-
-    ProjectAccessInfo updatedAccessSectionInfo =
-        gApi.projects().name(allProjects.get()).access(accessInput);
-    assertThatMap(updatedAccessSectionInfo.local.get(AccessSection.GLOBAL_CAPABILITIES).permissions)
-        .keys()
-        .containsAtLeastElementsIn(accessSectionInfo.permissions.keySet());
-  }
-
-  @Test
-  public void addPluginGlobalCapability() throws Exception {
-    try (Registration registration =
-        extensionRegistry
-            .newRegistration()
-            .add(
-                new CapabilityDefinition() {
-                  @Override
-                  public String getDescription() {
-                    return "A Plugin Global Capability";
-                  }
-                },
-                "fooCapability")) {
-      ProjectAccessInput accessInput = newProjectAccessInput();
-      AccessSectionInfo accessSectionInfo = newAccessSectionInfo();
-
-      PermissionInfo foo = newPermissionInfo();
-      PermissionRuleInfo pri = new PermissionRuleInfo(PermissionRuleInfo.Action.ALLOW, false);
-      foo.rules.put(SystemGroupBackend.REGISTERED_USERS.get(), pri);
-      accessSectionInfo.permissions.put(ExtensionRegistry.PLUGIN_NAME + "-fooCapability", foo);
-
-      accessInput.add.put(AccessSection.GLOBAL_CAPABILITIES, accessSectionInfo);
-
-      ProjectAccessInfo updatedAccessSectionInfo =
-          gApi.projects().name(allProjects.get()).access(accessInput);
-      assertThatMap(
-              updatedAccessSectionInfo.local.get(AccessSection.GLOBAL_CAPABILITIES).permissions)
-          .keys()
-          .containsAtLeastElementsIn(accessSectionInfo.permissions.keySet());
-    }
-  }
-
-  @Test
-  public void addPermissionAsGlobalCapability() throws Exception {
-    ProjectAccessInput accessInput = newProjectAccessInput();
-    AccessSectionInfo accessSectionInfo = newAccessSectionInfo();
-
-    PermissionInfo push = newPermissionInfo();
-    PermissionRuleInfo pri = new PermissionRuleInfo(PermissionRuleInfo.Action.ALLOW, false);
-    push.rules.put(SystemGroupBackend.REGISTERED_USERS.get(), pri);
-    accessSectionInfo.permissions.put(Permission.PUSH, push);
-
-    accessInput.add.put(AccessSection.GLOBAL_CAPABILITIES, accessSectionInfo);
-    BadRequestException ex =
-        assertThrows(
-            BadRequestException.class,
-            () -> gApi.projects().name(allProjects.get()).access(accessInput));
-    assertThat(ex).hasMessageThat().isEqualTo("Unknown global capability: " + Permission.PUSH);
-  }
-
-  @Test
-  public void addInvalidGlobalCapability() throws Exception {
-    ProjectAccessInput accessInput = newProjectAccessInput();
-    AccessSectionInfo accessSectionInfo = createDefaultGlobalCapabilitiesAccessSectionInfo();
-
-    PermissionInfo permissionInfo = newPermissionInfo();
-    PermissionRuleInfo pri = new PermissionRuleInfo(PermissionRuleInfo.Action.ALLOW, false);
-    permissionInfo.rules.put(SystemGroupBackend.REGISTERED_USERS.get(), pri);
-    accessSectionInfo.permissions.put("Invalid Global Capability", permissionInfo);
-
-    accessInput.add.put(AccessSection.GLOBAL_CAPABILITIES, accessSectionInfo);
-    BadRequestException ex =
-        assertThrows(
-            BadRequestException.class,
-            () -> gApi.projects().name(allProjects.get()).access(accessInput));
-    assertThat(ex)
-        .hasMessageThat()
-        .isEqualTo("Unknown global capability: Invalid Global Capability");
-  }
-
-  @Test
-  public void addGlobalCapabilityForNonRootProject() throws Exception {
-    ProjectAccessInput accessInput = newProjectAccessInput();
-    AccessSectionInfo accessSectionInfo = createDefaultGlobalCapabilitiesAccessSectionInfo();
-
-    accessInput.add.put(AccessSection.GLOBAL_CAPABILITIES, accessSectionInfo);
-
-    assertThrows(BadRequestException.class, () -> pApi().access(accessInput));
-  }
-
-  @Test
-  public void addNonGlobalCapabilityToGlobalCapabilities() throws Exception {
-    ProjectAccessInput accessInput = newProjectAccessInput();
-    AccessSectionInfo accessSectionInfo = newAccessSectionInfo();
-
-    PermissionInfo permissionInfo = newPermissionInfo();
-    permissionInfo.rules.put(adminGroupUuid().get(), null);
-    accessSectionInfo.permissions.put(Permission.PUSH, permissionInfo);
-
-    accessInput.add.put(AccessSection.GLOBAL_CAPABILITIES, accessSectionInfo);
-    assertThrows(
-        BadRequestException.class,
-        () -> gApi.projects().name(allProjects.get()).access(accessInput));
-  }
-
-  @Test
-  public void removeGlobalCapabilityAsUser() throws Exception {
-    ProjectAccessInput accessInput = newProjectAccessInput();
-    AccessSectionInfo accessSectionInfo = createDefaultGlobalCapabilitiesAccessSectionInfo();
-
-    accessInput.remove.put(AccessSection.GLOBAL_CAPABILITIES, accessSectionInfo);
-
-    requestScopeOperations.setApiUser(user.id());
-    assertThrows(
-        AuthException.class, () -> gApi.projects().name(allProjects.get()).access(accessInput));
-  }
-
-  @Test
-  public void removeGlobalCapabilityAsAdmin() throws Exception {
-    ProjectAccessInput accessInput = newProjectAccessInput();
-    AccessSectionInfo accessSectionInfo = newAccessSectionInfo();
-
-    PermissionInfo permissionInfo = newPermissionInfo();
-    permissionInfo.rules.put(adminGroupUuid().get(), null);
-    accessSectionInfo.permissions.put(GlobalCapability.ACCESS_DATABASE, permissionInfo);
-
-    // Add and validate first as removing existing privileges such as
-    // administrateServer would break upcoming tests
-    accessInput.add.put(AccessSection.GLOBAL_CAPABILITIES, accessSectionInfo);
-
-    ProjectAccessInfo updatedProjectAccessInfo =
-        gApi.projects().name(allProjects.get()).access(accessInput);
-    assertThatMap(updatedProjectAccessInfo.local.get(AccessSection.GLOBAL_CAPABILITIES).permissions)
-        .keys()
-        .containsAtLeastElementsIn(accessSectionInfo.permissions.keySet());
-
-    // Remove
-    accessInput.add.clear();
-    accessInput.remove.put(AccessSection.GLOBAL_CAPABILITIES, accessSectionInfo);
-
-    updatedProjectAccessInfo = gApi.projects().name(allProjects.get()).access(accessInput);
-    assertThatMap(updatedProjectAccessInfo.local.get(AccessSection.GLOBAL_CAPABILITIES).permissions)
-        .keys()
-        .containsNoneIn(accessSectionInfo.permissions.keySet());
-  }
-
-  @Test
-  public void unknownPermissionRemainsUnchanged() throws Exception {
-    String access = "access";
-    String unknownPermission = "unknownPermission";
-    String registeredUsers = "group Registered Users";
-    String refsFor = "refs/for/*";
-    // Clone repository to forcefully add permission
-    TestRepository<InMemoryRepository> allProjectsRepo = cloneProject(allProjects, admin);
-
-    // Fetch permission ref
-    GitUtil.fetch(allProjectsRepo, "refs/meta/config:cfg");
-    allProjectsRepo.reset("cfg");
-
-    // Load current permissions
-    String config =
-        gApi.projects()
-            .name(allProjects.get())
-            .branch(RefNames.REFS_CONFIG)
-            .file(ProjectConfig.PROJECT_CONFIG)
-            .asString();
-
-    // Append and push unknown permission
-    Config cfg = new Config();
-    cfg.fromText(config);
-    cfg.setString(access, refsFor, unknownPermission, registeredUsers);
-    config = cfg.toText();
-    PushOneCommit push =
-        pushFactory.create(
-            admin.newIdent(), allProjectsRepo, "Subject", ProjectConfig.PROJECT_CONFIG, config);
-    push.to(RefNames.REFS_CONFIG).assertOkStatus();
-
-    // Verify that unknownPermission is present
-    config =
-        gApi.projects()
-            .name(allProjects.get())
-            .branch(RefNames.REFS_CONFIG)
-            .file(ProjectConfig.PROJECT_CONFIG)
-            .asString();
-    cfg.fromText(config);
-    assertThat(cfg).stringValue(access, refsFor, unknownPermission).isEqualTo(registeredUsers);
-
-    // Make permission change through API
-    ProjectAccessInput accessInput = newProjectAccessInput();
-    AccessSectionInfo accessSectionInfo = createDefaultAccessSectionInfo();
-    accessInput.add.put(refsFor, accessSectionInfo);
-    gApi.projects().name(allProjects.get()).access(accessInput);
-    accessInput.add.clear();
-    accessInput.remove.put(refsFor, accessSectionInfo);
-    gApi.projects().name(allProjects.get()).access(accessInput);
-
-    // Verify that unknownPermission is still present
-    config =
-        gApi.projects()
-            .name(allProjects.get())
-            .branch(RefNames.REFS_CONFIG)
-            .file(ProjectConfig.PROJECT_CONFIG)
-            .asString();
-    cfg.fromText(config);
-    assertThat(cfg).stringValue(access, refsFor, unknownPermission).isEqualTo(registeredUsers);
-  }
-
-  @Test
-  public void allUsersCanOnlyInheritFromAllProjects() throws Exception {
-    ProjectAccessInput accessInput = newProjectAccessInput();
-    accessInput.parent = project.get();
-    BadRequestException thrown =
-        assertThrows(
-            BadRequestException.class,
-            () -> gApi.projects().name(allUsers.get()).access(accessInput));
-    assertThat(thrown)
-        .hasMessageThat()
-        .contains(allUsers.get() + " must inherit from " + allProjects.get());
-  }
-
-  @Test
-  public void syncCreateGroupPermission_addAndRemoveCreateGroupCapability() throws Exception {
-    // Grant CREATE_GROUP to Registered Users
-    ProjectAccessInput accessInput = newProjectAccessInput();
-    AccessSectionInfo accessSection = newAccessSectionInfo();
-    PermissionInfo createGroup = newPermissionInfo();
-    PermissionRuleInfo pri = new PermissionRuleInfo(PermissionRuleInfo.Action.ALLOW, false);
-    createGroup.rules.put(SystemGroupBackend.REGISTERED_USERS.get(), pri);
-    accessSection.permissions.put(GlobalCapability.CREATE_GROUP, createGroup);
-    accessInput.add.put(AccessSection.GLOBAL_CAPABILITIES, accessSection);
-    gApi.projects().name(allProjects.get()).access(accessInput);
-
-    // Assert that the permission was synced from All-Projects (global) to All-Users (ref)
-    Map<String, AccessSectionInfo> local = gApi.projects().name("All-Users").access().local;
-    assertThatMap(local).keys().contains(RefNames.REFS_GROUPS + "*");
-    Map<String, PermissionInfo> permissions = local.get(RefNames.REFS_GROUPS + "*").permissions;
-    // READ is the default permission and should be preserved by the syncer
-    assertThatMap(permissions).keys().containsExactly(Permission.READ, Permission.CREATE);
-    Map<String, PermissionRuleInfo> rules = permissions.get(Permission.CREATE).rules;
-    assertThatMap(rules).values().containsExactly(pri);
-
-    // Revoke the permission
-    accessInput.add.clear();
-    accessInput.remove.put(AccessSection.GLOBAL_CAPABILITIES, accessSection);
-    gApi.projects().name(allProjects.get()).access(accessInput);
-
-    // Assert that the permission was synced from All-Projects (global) to All-Users (ref)
-    Map<String, AccessSectionInfo> local2 = gApi.projects().name("All-Users").access().local;
-    assertThatMap(local2).keys().contains(RefNames.REFS_GROUPS + "*");
-    Map<String, PermissionInfo> permissions2 = local2.get(RefNames.REFS_GROUPS + "*").permissions;
-    // READ is the default permission and should be preserved by the syncer
-    assertThatMap(permissions2).keys().containsExactly(Permission.READ);
-  }
-
-  @Test
-  public void syncCreateGroupPermission_addCreateGroupCapabilityToMultipleGroups()
-      throws Exception {
-    PermissionRuleInfo pri = new PermissionRuleInfo(PermissionRuleInfo.Action.ALLOW, false);
-
-    // Grant CREATE_GROUP to Registered Users
-    ProjectAccessInput accessInput = newProjectAccessInput();
-    AccessSectionInfo accessSection = newAccessSectionInfo();
-    PermissionInfo createGroup = newPermissionInfo();
-    createGroup.rules.put(SystemGroupBackend.REGISTERED_USERS.get(), pri);
-    accessSection.permissions.put(GlobalCapability.CREATE_GROUP, createGroup);
-    accessInput.add.put(AccessSection.GLOBAL_CAPABILITIES, accessSection);
-    gApi.projects().name(allProjects.get()).access(accessInput);
-
-    // Grant CREATE_GROUP to Administrators
-    accessInput = newProjectAccessInput();
-    accessSection = newAccessSectionInfo();
-    createGroup = newPermissionInfo();
-    createGroup.rules.put(adminGroupUuid().get(), pri);
-    accessSection.permissions.put(GlobalCapability.CREATE_GROUP, createGroup);
-    accessInput.add.put(AccessSection.GLOBAL_CAPABILITIES, accessSection);
-    gApi.projects().name(allProjects.get()).access(accessInput);
-
-    // Assert that the permissions were synced from All-Projects (global) to All-Users (ref)
-    Map<String, AccessSectionInfo> local = gApi.projects().name("All-Users").access().local;
-    assertThatMap(local).keys().contains(RefNames.REFS_GROUPS + "*");
-    Map<String, PermissionInfo> permissions = local.get(RefNames.REFS_GROUPS + "*").permissions;
-    // READ is the default permission and should be preserved by the syncer
-    assertThatMap(permissions).keys().containsExactly(Permission.READ, Permission.CREATE);
-    Map<String, PermissionRuleInfo> rules = permissions.get(Permission.CREATE).rules;
-    assertThatMap(rules)
-        .keys()
-        .containsExactly(SystemGroupBackend.REGISTERED_USERS.get(), adminGroupUuid().get());
-    assertThat(rules.get(SystemGroupBackend.REGISTERED_USERS.get())).isEqualTo(pri);
-    assertThat(rules.get(adminGroupUuid().get())).isEqualTo(pri);
-  }
-
-  @Test
-  public void addAccessSectionForInvalidRef() throws Exception {
-    ProjectAccessInput accessInput = newProjectAccessInput();
-    AccessSectionInfo accessSectionInfo = createDefaultAccessSectionInfo();
-
-    // 'refs/heads/stable_*' is invalid, correct would be '^refs/heads/stable_.*'
-    String invalidRef = Constants.R_HEADS + "stable_*";
-    accessInput.add.put(invalidRef, accessSectionInfo);
-
-    BadRequestException thrown =
-        assertThrows(BadRequestException.class, () -> pApi().access(accessInput));
-    assertThat(thrown).hasMessageThat().contains("Invalid Name: " + invalidRef);
-  }
-
-  @Test
-  public void createAccessChangeWithAccessSectionForInvalidRef() throws Exception {
-    ProjectAccessInput accessInput = newProjectAccessInput();
-    AccessSectionInfo accessSectionInfo = createDefaultAccessSectionInfo();
-
-    // 'refs/heads/stable_*' is invalid, correct would be '^refs/heads/stable_.*'
-    String invalidRef = Constants.R_HEADS + "stable_*";
-    accessInput.add.put(invalidRef, accessSectionInfo);
-
-    BadRequestException thrown =
-        assertThrows(BadRequestException.class, () -> pApi().accessChange(accessInput));
-    assertThat(thrown).hasMessageThat().contains("Invalid Name: " + invalidRef);
-  }
-
-  private ProjectApi pApi() throws Exception {
-    return gApi.projects().name(newProjectName.get());
-  }
-
-  private ProjectAccessInput newProjectAccessInput() {
-    ProjectAccessInput p = new ProjectAccessInput();
-    p.add = new HashMap<>();
-    p.remove = new HashMap<>();
-    return p;
-  }
-
-  private PermissionInfo newPermissionInfo() {
-    PermissionInfo p = new PermissionInfo(null, null);
-    p.rules = new HashMap<>();
-    return p;
-  }
-
-  private AccessSectionInfo newAccessSectionInfo() {
-    AccessSectionInfo a = new AccessSectionInfo();
-    a.permissions = new HashMap<>();
-    return a;
-  }
-
-  private AccessSectionInfo createDefaultAccessSectionInfo() {
-    AccessSectionInfo accessSection = newAccessSectionInfo();
-
-    PermissionInfo push = newPermissionInfo();
-    PermissionRuleInfo pri = new PermissionRuleInfo(PermissionRuleInfo.Action.ALLOW, false);
-    push.rules.put(SystemGroupBackend.REGISTERED_USERS.get(), pri);
-    accessSection.permissions.put(Permission.PUSH, push);
-
-    PermissionInfo codeReview = newPermissionInfo();
-    codeReview.label = LabelId.CODE_REVIEW;
-    pri = new PermissionRuleInfo(PermissionRuleInfo.Action.DENY, false);
-    codeReview.rules.put(SystemGroupBackend.REGISTERED_USERS.get(), pri);
-
-    pri = new PermissionRuleInfo(PermissionRuleInfo.Action.ALLOW, false);
-    pri.max = 1;
-    pri.min = -1;
-    codeReview.rules.put(SystemGroupBackend.PROJECT_OWNERS.get(), pri);
-    accessSection.permissions.put(Permission.LABEL + LabelId.CODE_REVIEW, codeReview);
-
-    return accessSection;
-  }
-
-  private AccessSectionInfo createDefaultGlobalCapabilitiesAccessSectionInfo() {
-    AccessSectionInfo accessSection = newAccessSectionInfo();
-
-    PermissionInfo email = newPermissionInfo();
-    PermissionRuleInfo pri = new PermissionRuleInfo(PermissionRuleInfo.Action.ALLOW, false);
-    email.rules.put(SystemGroupBackend.REGISTERED_USERS.get(), pri);
-    accessSection.permissions.put(GlobalCapability.EMAIL_REVIEWERS, email);
-
-    return accessSection;
-  }
-
-  private AccessSectionInfo createAccessSectionInfoDenyAll() {
-    AccessSectionInfo accessSection = newAccessSectionInfo();
-
-    PermissionInfo read = newPermissionInfo();
-    PermissionRuleInfo pri = new PermissionRuleInfo(PermissionRuleInfo.Action.DENY, false);
-    read.rules.put(SystemGroupBackend.ANONYMOUS_USERS.get(), pri);
-    accessSection.permissions.put(Permission.READ, read);
-
-    return accessSection;
+  public void listAccess_projectNameAreTrimmed() throws Exception {
+    RestResponse r =
+        adminRestSession.get("/access/?project=" + IdString.fromDecoded(" " + project.get() + " "));
+    r.assertOK();
+    Map<String, ProjectAccessInfo> infoByProject =
+        newGson()
+            .fromJson(r.getReader(), new TypeToken<Map<String, ProjectAccessInfo>>() {}.getType());
+    assertThat(infoByProject.keySet()).containsExactly(project.get());
   }
 }
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/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/CommentsIT.java b/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
index b29c031..cbf8438 100644
--- a/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
@@ -1270,6 +1270,224 @@
   }
 
   @Test
+  public void publishPartialDraftsAllRevisions() throws Exception {
+    pushFactory
+        .create(admin.newIdent(), testRepo, SUBJECT, FILE_NAME, "initial content\n")
+        .to("refs/heads/master");
+
+    PushOneCommit.Result r1 =
+        pushFactory
+            .create(admin.newIdent(), testRepo, SUBJECT, FILE_NAME, "File content in PS1\n")
+            .to("refs/for/master");
+
+    PushOneCommit.Result r2 =
+        pushFactory
+            .create(
+                admin.newIdent(),
+                testRepo,
+                SUBJECT,
+                FILE_NAME,
+                "File content in PS2\n",
+                r1.getChangeId())
+            .to("refs/for/master");
+
+    CommentInfo draftOnePs1 =
+        addDraft(
+            r1.getChangeId(),
+            r1.getCommit().getName(),
+            CommentsUtil.newDraft(FILE_NAME, Side.REVISION, createLineRange(4, 10), "comment 1"));
+
+    CommentInfo draftTwoPs1 =
+        addDraft(
+            r1.getChangeId(),
+            r1.getCommit().getName(),
+            CommentsUtil.newDraft(FILE_NAME, Side.REVISION, createLineRange(4, 15), "comment 2"));
+
+    CommentInfo draftThreePs2 =
+        addDraft(
+            r1.getChangeId(),
+            r2.getCommit().getName(),
+            CommentsUtil.newDraft(FILE_NAME, Side.REVISION, createLineRange(3, 12), "comment 3"));
+
+    ReviewInput reviewInput =
+        createReviewInput(
+            DraftHandling.PUBLISH_ALL_REVISIONS,
+            "review message",
+            /* draftIdsToPublish= */ ImmutableList.of(draftOnePs1.id, draftThreePs2.id));
+    gApi.changes().id(r1.getChangeId()).current().review(reviewInput);
+
+    assertThat(
+            gApi.changes().id(r1.getChangeId()).commentsRequest().getAsList().stream()
+                .map(c -> c.id))
+        .containsExactly(draftOnePs1.id, draftThreePs2.id);
+
+    assertThat(
+            gApi.changes().id(r1.getChangeId()).draftsRequest().getAsList().stream().map(c -> c.id))
+        .containsExactly(draftTwoPs1.id);
+  }
+
+  @Test
+  public void publishPartialDrafts_whenDraftHandlingIsKeep_doesNotPublishDrafts() throws Exception {
+    pushFactory
+        .create(admin.newIdent(), testRepo, SUBJECT, FILE_NAME, "initial content\n")
+        .to("refs/heads/master");
+
+    PushOneCommit.Result r1 =
+        pushFactory
+            .create(admin.newIdent(), testRepo, SUBJECT, FILE_NAME, "File content in PS1\n")
+            .to("refs/for/master");
+
+    CommentInfo draftPs1 =
+        addDraft(
+            r1.getChangeId(),
+            r1.getCommit().getName(),
+            CommentsUtil.newDraft(FILE_NAME, Side.REVISION, createLineRange(4, 10), "comment 1"));
+
+    ReviewInput reviewInput =
+        createReviewInput(
+            DraftHandling.KEEP,
+            "review message",
+            /* draftIdsToPublish= */ ImmutableList.of(draftPs1.id));
+
+    gApi.changes().id(r1.getChangeId()).current().review(reviewInput);
+
+    assertThat(gApi.changes().id(r1.getChangeId()).commentsRequest().getAsList()).isEmpty();
+    assertThat(
+            gApi.changes().id(r1.getChangeId()).draftsRequest().getAsList().stream().map(c -> c.id))
+        .containsExactly(draftPs1.id);
+  }
+
+  @Test
+  public void publishPartialDrafts_whenDraftHandlingIsPublish_isNotAllowedForOtherRevisions()
+      throws Exception {
+    pushFactory
+        .create(admin.newIdent(), testRepo, SUBJECT, FILE_NAME, "initial content\n")
+        .to("refs/heads/master");
+
+    PushOneCommit.Result r1 =
+        pushFactory
+            .create(admin.newIdent(), testRepo, SUBJECT, FILE_NAME, "File content in PS1\n")
+            .to("refs/for/master");
+
+    PushOneCommit.Result r2 =
+        pushFactory
+            .create(
+                admin.newIdent(),
+                testRepo,
+                SUBJECT,
+                FILE_NAME,
+                "File content in PS2\n",
+                r1.getChangeId())
+            .to("refs/for/master");
+
+    CommentInfo draftPs1 =
+        addDraft(
+            r1.getChangeId(),
+            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"));
+
+    ReviewInput reviewInput =
+        createReviewInput(
+            DraftHandling.PUBLISH,
+            "review message",
+            /* draftIdsToPublish= */ ImmutableList.of(draftPs1.id));
+
+    // Request to publish draft of PS1, while sending review for PS2 with DraftHandling=PUBLISH
+    Exception error =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.changes().id(r1.getChangeId()).current().review(reviewInput));
+    assertThat(error)
+        .hasMessageThat()
+        .isEqualTo(
+            String.format(
+                "Draft comments for other revisions cannot be published when DraftHandling = PUBLISH."
+                    + " (draft IDs: [%s])",
+                draftPs1.id));
+  }
+
+  @Test
+  public void publishPartialDraftsWithInvalidDraftIdsIsNotAllowed() throws Exception {
+    pushFactory
+        .create(admin.newIdent(), testRepo, SUBJECT, FILE_NAME, "initial content\n")
+        .to("refs/heads/master");
+
+    PushOneCommit.Result r1 =
+        pushFactory
+            .create(admin.newIdent(), testRepo, SUBJECT, FILE_NAME, "File content in PS1\n")
+            .to("refs/for/master");
+
+    addDraft(
+        r1.getChangeId(),
+        r1.getCommit().getName(),
+        CommentsUtil.newDraft(FILE_NAME, Side.REVISION, createLineRange(4, 10), "comment 1"));
+
+    ReviewInput reviewInput =
+        createReviewInput(
+            DraftHandling.PUBLISH_ALL_REVISIONS,
+            "review message",
+            /* draftIdsToPublish= */ ImmutableList.of("1234"));
+
+    Exception error =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.changes().id(r1.getChangeId()).current().review(reviewInput));
+    assertThat(error).hasMessageThat().isEqualTo("Non-existing draft IDs: [1234]");
+  }
+
+  @Test
+  public void publishPartialDraftsForAnotherUserIsNotAllowed() throws Exception {
+    pushFactory
+        .create(admin.newIdent(), testRepo, SUBJECT, FILE_NAME, "initial content\n")
+        .to("refs/heads/master");
+
+    PushOneCommit.Result r1 =
+        pushFactory
+            .create(admin.newIdent(), testRepo, SUBJECT, FILE_NAME, "File content in PS1\n")
+            .to("refs/for/master");
+
+    // Add drafts with user scope
+    requestScopeOperations.setApiUser(accountCreator.user1().id());
+    CommentInfo draft =
+        addDraft(
+            r1.getChangeId(),
+            r1.getCommit().getName(),
+            CommentsUtil.newDraft(FILE_NAME, Side.REVISION, createLineRange(4, 10), "comment 1"));
+
+    ReviewInput reviewInput =
+        createReviewInput(
+            DraftHandling.PUBLISH_ALL_REVISIONS,
+            "review message",
+            /* draftIdsToPublish= */ ImmutableList.of(draft.id));
+
+    // Try to publish the drafts using user2 scope
+    requestScopeOperations.setApiUser(accountCreator.user2().id());
+    Exception error =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.changes().id(r1.getChangeId()).current().review(reviewInput));
+    assertThat(error)
+        .hasMessageThat()
+        .isEqualTo(String.format("Non-existing draft IDs: [%s]", draft.id));
+
+    // Request will succeed if done by user
+    requestScopeOperations.setApiUser(accountCreator.user1().id());
+    gApi.changes().id(r1.getChangeId()).current().review(reviewInput);
+    assertThat(
+            gApi.changes().id(r1.getChangeId()).commentsRequest().getAsList().stream()
+                .map(c -> c.id))
+        .containsExactly(draft.id);
+
+    assertThat(gApi.changes().id(r1.getChangeId()).draftsRequest().getAsList()).isEmpty();
+  }
+
+  @Test
   public void commentTags() throws Exception {
     PushOneCommit.Result r = createChange();
 
@@ -1789,4 +2007,13 @@
     to.range = from.range;
     to.inReplyTo = from.inReplyTo;
   }
+
+  private ReviewInput createReviewInput(
+      DraftHandling handling, String message, List<String> draftIdsToPublish) {
+    ReviewInput reviewInput = new ReviewInput();
+    reviewInput.drafts = handling;
+    reviewInput.message = message;
+    reviewInput.draftIdsToPublish = draftIdsToPublish;
+    return reviewInput;
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java b/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java
index f267513..4a8c376 100644
--- a/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java
@@ -41,12 +41,12 @@
 import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.changes.AbandonInput;
-import com.google.gerrit.extensions.api.changes.AddReviewerInput;
 import com.google.gerrit.extensions.api.changes.AssigneeInput;
 import com.google.gerrit.extensions.api.changes.DeleteReviewerInput;
 import com.google.gerrit.extensions.api.changes.DeleteVoteInput;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.changes.ReviewerInput;
 import com.google.gerrit.extensions.api.changes.SubmitInput;
 import com.google.gerrit.extensions.api.projects.ConfigInput;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
@@ -273,7 +273,7 @@
   }
 
   /*
-   * AddReviewerSender tests.
+   * ModifyReviewerSender tests (only for additions).
    */
 
   private void addReviewerToReviewableChange(Adder adder) throws Exception {
@@ -421,7 +421,7 @@
     TestAccount reviewer = accountCreator.create("added", "added@example.com", "added", null);
     addReviewer(singly(), sc.changeId, sc.owner, reviewer.email());
     // TODO(dborowitz): In theory this should match the batch case, but we don't currently pass
-    // enough info into AddReviewersEmail#emailReviewers to distinguish the reviewStarted case.
+    // enough info into ModifyReviewersEmail#emailReviewers to distinguish the reviewStarted case.
     // Complicating the emailReviewers arguments is not the answer; this needs to be rewritten.
     // Tolerate the difference for now.
     assertThat(sender).didNotSend();
@@ -582,7 +582,7 @@
 
   private Adder singly(ReviewerState reviewerState) {
     return (String changeId, String reviewer, @Nullable NotifyHandling notify) -> {
-      AddReviewerInput in = new AddReviewerInput();
+      ReviewerInput in = new ReviewerInput();
       in.reviewer = reviewer;
       in.state = reviewerState;
       if (notify != null) {
diff --git a/javatests/com/google/gerrit/acceptance/server/mail/ListMailFilterIT.java b/javatests/com/google/gerrit/acceptance/server/mail/ListMailFilterIT.java
index 49b184b..df66e9f 100644
--- a/javatests/com/google/gerrit/acceptance/server/mail/ListMailFilterIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/mail/ListMailFilterIT.java
@@ -48,7 +48,7 @@
   @GerritConfig(name = "receiveemail.filter.mode", value = "ALLOW")
   @GerritConfig(
       name = "receiveemail.filter.patterns",
-      values = {".+ser@example\\.com", "a@b\\.com"})
+      values = {".+ser1@example\\.com", "a@b\\.com"})
   public void listFilterAllowDoesNotFilterListedUser() throws Exception {
     ChangeInfo changeInfo = createChangeAndReplyByEmail();
     // Check that the comments from the email have been persisted
diff --git a/javatests/com/google/gerrit/acceptance/server/mail/MailProcessorIT.java b/javatests/com/google/gerrit/acceptance/server/mail/MailProcessorIT.java
index 5679c41..85c0212 100644
--- a/javatests/com/google/gerrit/acceptance/server/mail/MailProcessorIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/mail/MailProcessorIT.java
@@ -16,6 +16,8 @@
 
 import static com.google.common.collect.ImmutableSet.toImmutableSet;
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.Mockito.clearInvocations;
 import static org.mockito.Mockito.mock;
@@ -28,7 +30,9 @@
 import com.google.common.collect.Streams;
 import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.entities.EmailHeader;
+import com.google.gerrit.entities.Permission;
 import com.google.gerrit.extensions.annotations.Exports;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput.CommentInput;
@@ -44,6 +48,7 @@
 import com.google.gerrit.mail.MailMessage;
 import com.google.gerrit.mail.MailProcessingUtil;
 import com.google.gerrit.server.mail.receive.MailProcessor;
+import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.gerrit.testing.FakeEmailSender.Message;
 import com.google.gerrit.testing.TestCommentHelper;
 import com.google.inject.Inject;
@@ -61,6 +66,7 @@
   @Inject private MailProcessor mailProcessor;
   @Inject private AccountOperations accountOperations;
   @Inject private TestCommentHelper testCommentHelper;
+  @Inject private ProjectOperations projectOperations;
 
   private static final CommentValidator mockCommentValidator = mock(CommentValidator.class);
 
@@ -276,6 +282,133 @@
   }
 
   @Test
+  public void sendNotificationOnProjectNotFound() throws Exception {
+    String ts =
+        MailProcessingUtil.rfcDateformatter.format(
+            ZonedDateTime.ofInstant(TimeUtil.now(), ZoneId.of("UTC")));
+
+    String changeUrl = canonicalWebUrl.get() + "c/non-existing-project/+/123";
+
+    // Build Message
+    String txt = newPlaintextBody(changeUrl + "/1", "Test Message", null, null);
+    MailMessage.Builder b =
+        messageBuilderWithDefaultFields()
+            .from(user.getNameEmail())
+            .textContent(txt + textFooterForChange(123, ts));
+
+    sender.clear();
+    mailProcessor.process(b.build());
+
+    assertNotifyTo(user);
+    Message message = sender.nextMessage();
+    assertThat(message.body())
+        .contains(
+            "Gerrit Code Review was unable to process your email because the change was not"
+                + " found.");
+    assertThat(message.headers()).containsKey("Subject");
+  }
+
+  @Test
+  public void sendNotificationOnProjectNotVisible() throws Exception {
+    String changeId = createChangeWithReview();
+    ChangeInfo changeInfo = gApi.changes().id(changeId).get();
+
+    String ts =
+        MailProcessingUtil.rfcDateformatter.format(
+            ZonedDateTime.ofInstant(
+                gApi.changes().id(changeId).get().updated.toInstant(), ZoneId.of("UTC")));
+
+    // Block read permissions on the project.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(Permission.READ).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+
+    // Build Message
+    String txt = newPlaintextBody(getChangeUrl(changeInfo) + "/1", "Test Message", null, null);
+    MailMessage.Builder b =
+        messageBuilderWithDefaultFields()
+            .from(user.getNameEmail())
+            .textContent(txt + textFooterForChange(changeInfo._number, ts));
+
+    sender.clear();
+    mailProcessor.process(b.build());
+
+    assertNotifyTo(user);
+    Message message = sender.nextMessage();
+    assertThat(message.body())
+        .contains(
+            "Gerrit Code Review was unable to process your email because the change was not"
+                + " found.");
+    assertThat(message.headers()).containsKey("Subject");
+  }
+
+  @Test
+  public void sendNotificationOnChangeNotFound() throws Exception {
+    String changeId = createChangeWithReview();
+    ChangeInfo changeInfo = gApi.changes().id(changeId).get();
+
+    String ts =
+        MailProcessingUtil.rfcDateformatter.format(
+            ZonedDateTime.ofInstant(
+                gApi.changes().id(changeId).get().updated.toInstant(), ZoneId.of("UTC")));
+
+    // Delete the change so that it's not found.
+    gApi.changes().id(changeId).delete();
+
+    // Build Message
+    String txt = newPlaintextBody(getChangeUrl(changeInfo) + "/1", "Test Message", null, null);
+    MailMessage.Builder b =
+        messageBuilderWithDefaultFields()
+            .from(user.getNameEmail())
+            .textContent(txt + textFooterForChange(changeInfo._number, ts));
+
+    sender.clear();
+    mailProcessor.process(b.build());
+
+    assertNotifyTo(user);
+    Message message = sender.nextMessage();
+    assertThat(message.body())
+        .contains(
+            "Gerrit Code Review was unable to process your email because the change was not"
+                + " found.");
+    assertThat(message.headers()).containsKey("Subject");
+  }
+
+  @Test
+  public void sendNotificationOnChangeNotVisible() throws Exception {
+    String changeId = createChangeWithReview();
+    ChangeInfo changeInfo = gApi.changes().id(changeId).get();
+
+    String ts =
+        MailProcessingUtil.rfcDateformatter.format(
+            ZonedDateTime.ofInstant(
+                gApi.changes().id(changeId).get().updated.toInstant(), ZoneId.of("UTC")));
+
+    // Make change private so that it's no visible to user.
+    gApi.changes().id(changeId).setPrivate(true);
+
+    // Build Message
+    String txt = newPlaintextBody(getChangeUrl(changeInfo) + "/1", "Test Message", null, null);
+    MailMessage.Builder b =
+        messageBuilderWithDefaultFields()
+            .from(user.getNameEmail())
+            .textContent(txt + textFooterForChange(changeInfo._number, ts));
+
+    sender.clear();
+    mailProcessor.process(b.build());
+
+    assertNotifyTo(user);
+    Message message = sender.nextMessage();
+    assertThat(message.body())
+        .contains(
+            "Gerrit Code Review was unable to process your email because the change was not"
+                + " found.");
+    assertThat(message.headers()).containsKey("Subject");
+  }
+
+  @Test
   public void validateChangeMessage_rejected() throws Exception {
     String changeId = createChangeWithReview();
     ChangeInfo changeInfo = gApi.changes().id(changeId).get();
diff --git a/javatests/com/google/gerrit/acceptance/server/project/CustomLabelIT.java b/javatests/com/google/gerrit/acceptance/server/project/CustomLabelIT.java
index 6aa5878..6b34cca 100644
--- a/javatests/com/google/gerrit/acceptance/server/project/CustomLabelIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/project/CustomLabelIT.java
@@ -43,8 +43,8 @@
 import com.google.gerrit.entities.CachedProjectConfig;
 import com.google.gerrit.entities.LabelFunction;
 import com.google.gerrit.entities.LabelType;
-import com.google.gerrit.extensions.api.changes.AddReviewerInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.changes.ReviewerInput;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.LabelInfo;
 import com.google.gerrit.extensions.events.CommentAddedListener;
@@ -169,7 +169,7 @@
     try (Registration registration = extensionRegistry.newRegistration().add(testListener)) {
       saveLabelConfig(P.toBuilder().setFunction(ANY_WITH_BLOCK));
       PushOneCommit.Result r = createChange();
-      AddReviewerInput in = new AddReviewerInput();
+      ReviewerInput in = new ReviewerInput();
       in.reviewer = user.email();
       gApi.changes().id(r.getChangeId()).addReviewer(in);
 
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..23047a4
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsEvaluatorIT.java
@@ -0,0 +1,265 @@
+// 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.PredicateResult;
+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.getPassingAtoms())
+        .containsExactly(
+            PredicateResult.builder()
+                .predicateString(String.format("project:%s", project.get()))
+                .status(true)
+                .build(),
+            PredicateResult.builder()
+                .predicateString("message:\"Fix a bug\"")
+                .status(true)
+                .build());
+
+    assertThat(result.getFailingAtoms())
+        .containsExactly(
+            PredicateResult.builder()
+                // TODO(ghareeb): querying "branch:" creates a RefPredicate. Fix names so that they
+                // match
+                .predicateString(String.format("ref:refs/heads/foo"))
+                .status(false)
+                .build());
+  }
+
+  @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/server/quota/RepositorySizeQuotaIT.java b/javatests/com/google/gerrit/acceptance/server/quota/RepositorySizeQuotaIT.java
index 2692584..c9c3bbb 100644
--- a/javatests/com/google/gerrit/acceptance/server/quota/RepositorySizeQuotaIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/quota/RepositorySizeQuotaIT.java
@@ -76,8 +76,13 @@
 
   @Test
   public void pushWithAvailableTokens() throws Exception {
+    // The push creates a pack that contains 325 bytes of uncompressed data.
+    // The data in the push contains sha and timestamps which are different on each test run.
+    // Due to it, the push's pack size varies after data compression and lead to a flaky tests
+    // if the amount of availableTokens doesn't cover all possible sizes. To avoid flakiness, we
+    // set availableTokens value large enough to cover all possible pack sizes.
     when(quotaBackendWithResource.availableTokens(REPOSITORY_SIZE_GROUP))
-        .thenReturn(singletonAggregation(ok(277L)));
+        .thenReturn(singletonAggregation(ok(512L)));
     when(quotaBackendWithResource.requestTokens(eq(REPOSITORY_SIZE_GROUP), anyLong()))
         .thenReturn(singletonAggregation(ok()));
     when(quotaBackendWithUser.project(project)).thenReturn(quotaBackendWithResource);
diff --git a/javatests/com/google/gerrit/acceptance/ssh/QueryIT.java b/javatests/com/google/gerrit/acceptance/ssh/QueryIT.java
index 78960bb..cf316c7 100644
--- a/javatests/com/google/gerrit/acceptance/ssh/QueryIT.java
+++ b/javatests/com/google/gerrit/acceptance/ssh/QueryIT.java
@@ -23,8 +23,8 @@
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.SshSession;
 import com.google.gerrit.acceptance.UseSsh;
-import com.google.gerrit.extensions.api.changes.AddReviewerInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.changes.ReviewerInput;
 import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.client.Side;
 import com.google.gerrit.server.data.ChangeAttribute;
@@ -84,7 +84,7 @@
   @Test
   public void allReviewersOptionJSON() throws Exception {
     String changeId = createChange().getChangeId();
-    AddReviewerInput in = new AddReviewerInput();
+    ReviewerInput in = new ReviewerInput();
     in.reviewer = user.email();
     gApi.changes().id(changeId).addReviewer(in);
 
diff --git a/javatests/com/google/gerrit/entities/PatchSetTest.java b/javatests/com/google/gerrit/entities/PatchSetTest.java
index 61e1b4c..7e04fe8 100644
--- a/javatests/com/google/gerrit/entities/PatchSetTest.java
+++ b/javatests/com/google/gerrit/entities/PatchSetTest.java
@@ -96,6 +96,10 @@
   public void parseId() {
     assertThat(PatchSet.Id.parse("1,2")).isEqualTo(PatchSet.id(Change.id(1), 2));
     assertThat(PatchSet.Id.parse("01,02")).isEqualTo(PatchSet.id(Change.id(1), 2));
+    Change.Id cId = Change.id(321);
+    assertThat(
+            PatchSet.Id.fromEditRef(RefNames.refsEdit(Account.id(123), cId, PatchSet.id(cId, 6))))
+        .isEqualTo(PatchSet.id(cId, 6));
     assertInvalidId(null);
     assertInvalidId("");
     assertInvalidId("1");
diff --git a/javatests/com/google/gerrit/entities/converter/BUILD b/javatests/com/google/gerrit/entities/converter/BUILD
index 6ca9871..6c4d1e4 100644
--- a/javatests/com/google/gerrit/entities/converter/BUILD
+++ b/javatests/com/google/gerrit/entities/converter/BUILD
@@ -6,6 +6,7 @@
     deps = [
         "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/proto/testing",
+        "//java/com/google/gerrit/server",
         "//lib:guava",
         "//lib:jgit",
         "//lib:protobuf",
diff --git a/javatests/com/google/gerrit/entities/converter/ChangeMessageProtoConverterTest.java b/javatests/com/google/gerrit/entities/converter/ChangeMessageProtoConverterTest.java
index b185558..f612d0f 100644
--- a/javatests/com/google/gerrit/entities/converter/ChangeMessageProtoConverterTest.java
+++ b/javatests/com/google/gerrit/entities/converter/ChangeMessageProtoConverterTest.java
@@ -19,12 +19,15 @@
 import static com.google.gerrit.proto.testing.SerializedClassSubject.assertThatSerializedClass;
 
 import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.proto.Entities;
 import com.google.gerrit.proto.testing.SerializedClassSubject;
+import com.google.gerrit.server.ChangeMessagesUtil;
+import com.google.inject.TypeLiteral;
 import java.lang.reflect.Type;
 import java.sql.Timestamp;
 import org.junit.Test;
@@ -36,14 +39,14 @@
   @Test
   public void allValuesConvertedToProto() {
     ChangeMessage changeMessage =
-        new ChangeMessage(
+        ChangeMessage.create(
             ChangeMessage.key(Change.id(543), "change-message-21"),
             Account.id(63),
             new Timestamp(9876543),
-            PatchSet.id(Change.id(34), 13));
-    changeMessage.setMessage("This is a change message.");
-    changeMessage.setTag("An arbitrary tag.");
-    changeMessage.setRealAuthor(Account.id(10003));
+            PatchSet.id(Change.id(34), 13),
+            "This is a change message.",
+            Account.id(10003),
+            "An arbitrary tag.");
 
     Entities.ChangeMessage proto = changeMessageProtoConverter.toProto(changeMessage);
 
@@ -69,7 +72,7 @@
   @Test
   public void mainValuesConvertedToProto() {
     ChangeMessage changeMessage =
-        new ChangeMessage(
+        ChangeMessage.create(
             ChangeMessage.key(Change.id(543), "change-message-21"),
             Account.id(63),
             new Timestamp(9876543),
@@ -97,7 +100,7 @@
   @Test
   public void realAuthorIsNotAutomaticallySetToAuthorWhenConvertedToProto() {
     ChangeMessage changeMessage =
-        new ChangeMessage(
+        ChangeMessage.create(
             ChangeMessage.key(Change.id(543), "change-message-21"), Account.id(63), null, null);
 
     Entities.ChangeMessage proto = changeMessageProtoConverter.toProto(changeMessage);
@@ -118,7 +121,8 @@
     // writtenOn may not be null according to the column definition but it's optional for the
     // protobuf definition. -> assume as optional and hence test null
     ChangeMessage changeMessage =
-        new ChangeMessage(ChangeMessage.key(Change.id(543), "change-message-21"), null, null, null);
+        ChangeMessage.create(
+            ChangeMessage.key(Change.id(543), "change-message-21"), null, null, null);
 
     Entities.ChangeMessage proto = changeMessageProtoConverter.toProto(changeMessage);
 
@@ -135,14 +139,14 @@
   @Test
   public void allValuesConvertedToProtoAndBackAgain() {
     ChangeMessage changeMessage =
-        new ChangeMessage(
+        ChangeMessage.create(
             ChangeMessage.key(Change.id(543), "change-message-21"),
             Account.id(63),
             new Timestamp(9876543),
-            PatchSet.id(Change.id(34), 13));
-    changeMessage.setMessage("This is a change message.");
-    changeMessage.setTag("An arbitrary tag.");
-    changeMessage.setRealAuthor(Account.id(10003));
+            PatchSet.id(Change.id(34), 13),
+            "This is a change message.",
+            Account.id(10003),
+            "An arbitrary tag.");
 
     ChangeMessage convertedChangeMessage =
         changeMessageProtoConverter.fromProto(changeMessageProtoConverter.toProto(changeMessage));
@@ -150,9 +154,31 @@
   }
 
   @Test
+  public void messageTemplateConvertedToProtoAndParsedBack() {
+    ChangeMessage changeMessage =
+        ChangeMessage.create(
+            ChangeMessage.key(Change.id(543), "change-message-21"),
+            Account.id(63),
+            new Timestamp(9876543),
+            PatchSet.id(Change.id(34), 13),
+            String.format(
+                "This is a change message by %s and includes %s ",
+                ChangeMessagesUtil.getAccountTemplate(Account.id(10001)),
+                ChangeMessagesUtil.getAccountTemplate(Account.id(10002))),
+            Account.id(10003),
+            "An arbitrary tag.");
+
+    ChangeMessage convertedChangeMessage =
+        changeMessageProtoConverter.fromProto(changeMessageProtoConverter.toProto(changeMessage));
+    assertThat(convertedChangeMessage.getAccountsInMessage())
+        .containsExactly(Account.id(10001), Account.id(10002));
+    assertThat(convertedChangeMessage).isEqualTo(changeMessage);
+  }
+
+  @Test
   public void mainValuesConvertedToProtoAndBackAgain() {
     ChangeMessage changeMessage =
-        new ChangeMessage(
+        ChangeMessage.create(
             ChangeMessage.key(Change.id(543), "change-message-21"),
             Account.id(63),
             new Timestamp(9876543),
@@ -166,7 +192,8 @@
   @Test
   public void mandatoryValuesConvertedToProtoAndBackAgain() {
     ChangeMessage changeMessage =
-        new ChangeMessage(ChangeMessage.key(Change.id(543), "change-message-21"), null, null, null);
+        ChangeMessage.create(
+            ChangeMessage.key(Change.id(543), "change-message-21"), null, null, null);
 
     ChangeMessage convertedChangeMessage =
         changeMessageProtoConverter.fromProto(changeMessageProtoConverter.toProto(changeMessage));
@@ -183,6 +210,8 @@
                 .put("author", Account.Id.class)
                 .put("writtenOn", Timestamp.class)
                 .put("message", String.class)
+                // accountsInMessage are parsed from message template and are not serialized.
+                .put("accountsInMessage", new TypeLiteral<ImmutableSet<Account.Id>>() {}.getType())
                 .put("patchset", PatchSet.Id.class)
                 .put("tag", String.class)
                 .put("realAuthor", Account.Id.class)
diff --git a/javatests/com/google/gerrit/extensions/client/ListOptionTest.java b/javatests/com/google/gerrit/extensions/client/ListOptionTest.java
index 5e8c7b6..543428a 100644
--- a/javatests/com/google/gerrit/extensions/client/ListOptionTest.java
+++ b/javatests/com/google/gerrit/extensions/client/ListOptionTest.java
@@ -19,8 +19,10 @@
 import static com.google.gerrit.extensions.client.ListOptionTest.MyOption.BAR;
 import static com.google.gerrit.extensions.client.ListOptionTest.MyOption.BAZ;
 import static com.google.gerrit.extensions.client.ListOptionTest.MyOption.FOO;
+import static org.junit.Assert.fail;
 
 import com.google.common.math.IntMath;
+import com.google.gerrit.extensions.restapi.BadRequestException;
 import java.util.EnumSet;
 import org.junit.Test;
 
@@ -43,6 +45,17 @@
   }
 
   @Test
+  public void fromHexString() {
+    try {
+      // TODO(hanwen): move GerritJUnit.assertThrows to a place that doesn't depend on everything.
+      ListOption.fromHexString(MyOption.class, "xyz");
+      fail("must throw");
+    } catch (BadRequestException e) {
+      assertThat(e.getMessage()).contains("32-bit integer");
+    }
+  }
+
+  @Test
   public void fromBits() {
     assertThat(IntMath.pow(2, BAZ.getValue())).isEqualTo(131072);
     assertThat(ListOption.fromBits(MyOption.class, 0)).isEmpty();
diff --git a/javatests/com/google/gerrit/integration/git/GitProtocolV2IT.java b/javatests/com/google/gerrit/integration/git/GitProtocolV2IT.java
index f9df375..b114acc 100644
--- a/javatests/com/google/gerrit/integration/git/GitProtocolV2IT.java
+++ b/javatests/com/google/gerrit/integration/git/GitProtocolV2IT.java
@@ -114,7 +114,7 @@
       setUpUserAuthentication(admin.username());
 
       // Create non-admin user
-      TestAccount user = accountCreator.user();
+      TestAccount user = accountCreator.user1();
       setUpUserAuthentication(user.username());
 
       // Prepare data for new change on master branch
diff --git a/javatests/com/google/gerrit/integration/git/NoAccessSameAsNotFoundIT.java b/javatests/com/google/gerrit/integration/git/NoAccessSameAsNotFoundIT.java
index c33ef8e..ad8a486 100644
--- a/javatests/com/google/gerrit/integration/git/NoAccessSameAsNotFoundIT.java
+++ b/javatests/com/google/gerrit/integration/git/NoAccessSameAsNotFoundIT.java
@@ -64,7 +64,7 @@
   private void setup(ServerContext ctx) throws Exception {
     ctx.getInjector().injectMembers(this);
 
-    TestAccount user = accountCreator.user();
+    TestAccount user = accountCreator.user1();
     gApi.accounts().id(user.username()).setHttpPassword(PASSWORD);
 
     String canonical = config.getString("gerrit", null, "canonicalweburl");
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
new file mode 100644
index 0000000..a1dee1a
--- /dev/null
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/SubmitRequirementSerializerTest.java
@@ -0,0 +1,41 @@
+// 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 static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.server.cache.serialize.entities.SubmitRequirementSerializer.deserialize;
+import static com.google.gerrit.server.cache.serialize.entities.SubmitRequirementSerializer.serialize;
+
+import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.entities.SubmitRequirementExpression;
+import java.util.Optional;
+import org.junit.Test;
+
+public class SubmitRequirementSerializerTest {
+  private static final SubmitRequirement submitReq =
+      SubmitRequirement.builder()
+          .setName("code-review")
+          .setDescription(Optional.of("require code review +2"))
+          .setApplicabilityExpression(SubmitRequirementExpression.of("branch(refs/heads/master)"))
+          .setSubmittabilityExpression(SubmitRequirementExpression.create("label(code-review, 2+)"))
+          .setOverrideExpression(Optional.empty())
+          .setAllowOverrideInChildProjects(true)
+          .build();
+
+  @Test
+  public void roundTrip() {
+    assertThat(deserialize(serialize(submitReq))).isEqualTo(submitReq);
+  }
+}
diff --git a/javatests/com/google/gerrit/server/group/db/AuditLogReaderTest.java b/javatests/com/google/gerrit/server/group/db/AuditLogReaderTest.java
index 7da4785..6ad899e 100644
--- a/javatests/com/google/gerrit/server/group/db/AuditLogReaderTest.java
+++ b/javatests/com/google/gerrit/server/group/db/AuditLogReaderTest.java
@@ -242,17 +242,17 @@
             .setNameKey(AccountGroup.nameKey(groupName))
             .setId(AccountGroup.id(next))
             .build();
-    InternalGroupUpdate groupUpdate =
+    GroupDelta groupDelta =
         authorIdent.equals(serverIdent)
-            ? InternalGroupUpdate.builder().setDescription("Groups").build()
-            : InternalGroupUpdate.builder()
+            ? GroupDelta.builder().setDescription("Groups").build()
+            : GroupDelta.builder()
                 .setDescription("Groups")
                 .setMemberModification(members -> ImmutableSet.of(authorId))
                 .build();
 
     GroupConfig groupConfig =
         GroupConfig.createForNewGroup(allUsersName, allUsersRepo, groupCreation);
-    groupConfig.setGroupUpdate(groupUpdate, getAuditLogFormatter());
+    groupConfig.setGroupDelta(groupDelta, getAuditLogFormatter());
 
     groupConfig.commit(createMetaDataUpdate(authorIdent));
     return groupConfig
@@ -260,45 +260,42 @@
         .orElseThrow(() -> new IllegalStateException("create group failed"));
   }
 
-  private void updateGroup(AccountGroup.UUID uuid, InternalGroupUpdate groupUpdate)
-      throws Exception {
+  private void updateGroup(AccountGroup.UUID uuid, GroupDelta groupDelta) throws Exception {
     GroupConfig groupConfig = GroupConfig.loadForGroup(allUsersName, allUsersRepo, uuid);
-    groupConfig.setGroupUpdate(groupUpdate, getAuditLogFormatter());
+    groupConfig.setGroupDelta(groupDelta, getAuditLogFormatter());
     groupConfig.commit(createMetaDataUpdate(userIdent));
   }
 
   private void addMembers(AccountGroup.UUID groupUuid, Set<Account.Id> ids) throws Exception {
-    InternalGroupUpdate update =
-        InternalGroupUpdate.builder()
-            .setMemberModification(memberIds -> Sets.union(memberIds, ids))
-            .build();
-    updateGroup(groupUuid, update);
+    GroupDelta groupDelta =
+        GroupDelta.builder().setMemberModification(memberIds -> Sets.union(memberIds, ids)).build();
+    updateGroup(groupUuid, groupDelta);
   }
 
   private void removeMembers(AccountGroup.UUID groupUuid, Set<Account.Id> ids) throws Exception {
-    InternalGroupUpdate update =
-        InternalGroupUpdate.builder()
+    GroupDelta groupDelta =
+        GroupDelta.builder()
             .setMemberModification(memberIds -> Sets.difference(memberIds, ids))
             .build();
-    updateGroup(groupUuid, update);
+    updateGroup(groupUuid, groupDelta);
   }
 
   private void addSubgroups(AccountGroup.UUID groupUuid, Set<AccountGroup.UUID> uuids)
       throws Exception {
-    InternalGroupUpdate update =
-        InternalGroupUpdate.builder()
+    GroupDelta groupDelta =
+        GroupDelta.builder()
             .setSubgroupModification(memberIds -> Sets.union(memberIds, uuids))
             .build();
-    updateGroup(groupUuid, update);
+    updateGroup(groupUuid, groupDelta);
   }
 
   private void removeSubgroups(AccountGroup.UUID groupUuid, Set<AccountGroup.UUID> uuids)
       throws Exception {
-    InternalGroupUpdate update =
-        InternalGroupUpdate.builder()
+    GroupDelta groupDelta =
+        GroupDelta.builder()
             .setSubgroupModification(memberIds -> Sets.difference(memberIds, uuids))
             .build();
-    updateGroup(groupUuid, update);
+    updateGroup(groupUuid, groupDelta);
   }
 
   private static AccountGroupMemberAudit createExpMemberAudit(
diff --git a/javatests/com/google/gerrit/server/group/db/GroupConfigTest.java b/javatests/com/google/gerrit/server/group/db/GroupConfigTest.java
index 1c41c4c..5d88a5f 100644
--- a/javatests/com/google/gerrit/server/group/db/GroupConfigTest.java
+++ b/javatests/com/google/gerrit/server/group/db/GroupConfigTest.java
@@ -100,8 +100,8 @@
 
     InternalGroupCreation groupCreation =
         getPrefilledGroupCreationBuilder().setNameKey(groupName).build();
-    InternalGroupUpdate groupUpdate = InternalGroupUpdate.builder().setName(anotherName).build();
-    createGroup(groupCreation, groupUpdate);
+    GroupDelta groupDelta = GroupDelta.builder().setName(anotherName).build();
+    createGroup(groupCreation, groupDelta);
 
     Optional<InternalGroup> group = loadGroup(groupCreation.getGroupUUID());
     assertThatGroup(group).value().nameKey().isEqualTo(anotherName);
@@ -161,9 +161,8 @@
     String description = "This is a test group.";
 
     InternalGroupCreation groupCreation = getPrefilledGroupCreationBuilder().build();
-    InternalGroupUpdate groupUpdate =
-        InternalGroupUpdate.builder().setDescription(description).build();
-    createGroup(groupCreation, groupUpdate);
+    GroupDelta groupDelta = GroupDelta.builder().setDescription(description).build();
+    createGroup(groupCreation, groupDelta);
 
     Optional<InternalGroup> group = loadGroup(groupCreation.getGroupUUID());
     assertThatGroup(group).value().description().isEqualTo(description);
@@ -172,8 +171,8 @@
   @Test
   public void emptyDescriptionForNewGroupIsIgnored() throws Exception {
     InternalGroupCreation groupCreation = getPrefilledGroupCreationBuilder().build();
-    InternalGroupUpdate groupUpdate = InternalGroupUpdate.builder().setDescription("").build();
-    createGroup(groupCreation, groupUpdate);
+    GroupDelta groupDelta = GroupDelta.builder().setDescription("").build();
+    createGroup(groupCreation, groupDelta);
 
     Optional<InternalGroup> group = loadGroup(groupCreation.getGroupUUID());
     assertThatGroup(group).value().description().isNull();
@@ -198,9 +197,8 @@
     AccountGroup.UUID ownerGroupUuid = AccountGroup.uuid("anotherOwnerUuid");
 
     InternalGroupCreation groupCreation = getPrefilledGroupCreationBuilder().build();
-    InternalGroupUpdate groupUpdate =
-        InternalGroupUpdate.builder().setOwnerGroupUUID(ownerGroupUuid).build();
-    createGroup(groupCreation, groupUpdate);
+    GroupDelta groupDelta = GroupDelta.builder().setOwnerGroupUUID(ownerGroupUuid).build();
+    createGroup(groupCreation, groupDelta);
 
     Optional<InternalGroup> group = loadGroup(groupCreation.getGroupUUID());
     assertThatGroup(group).value().ownerGroupUuid().isEqualTo(ownerGroupUuid);
@@ -209,10 +207,9 @@
   @Test
   public void ownerGroupUuidOfNewGroupMustNotBeEmpty() throws Exception {
     InternalGroupCreation groupCreation = getPrefilledGroupCreationBuilder().build();
-    InternalGroupUpdate groupUpdate =
-        InternalGroupUpdate.builder().setOwnerGroupUUID(AccountGroup.uuid("")).build();
+    GroupDelta groupDelta = GroupDelta.builder().setOwnerGroupUUID(AccountGroup.uuid("")).build();
     GroupConfig groupConfig = GroupConfig.createForNewGroup(projectName, repository, groupCreation);
-    groupConfig.setGroupUpdate(groupUpdate, auditLogFormatter);
+    groupConfig.setGroupDelta(groupDelta, auditLogFormatter);
 
     try (MetaDataUpdate metaDataUpdate = createMetaDataUpdate()) {
       Throwable thrown = assertThrows(Throwable.class, () -> groupConfig.commit(metaDataUpdate));
@@ -238,8 +235,8 @@
   @Test
   public void specifiedVisibleToAllIsRespectedForNewGroup() throws Exception {
     InternalGroupCreation groupCreation = getPrefilledGroupCreationBuilder().build();
-    InternalGroupUpdate groupUpdate = InternalGroupUpdate.builder().setVisibleToAll(true).build();
-    createGroup(groupCreation, groupUpdate);
+    GroupDelta groupDelta = GroupDelta.builder().setVisibleToAll(true).build();
+    createGroup(groupCreation, groupDelta);
 
     Optional<InternalGroup> group = loadGroup(groupCreation.getGroupUUID());
     assertThatGroup(group).value().visibleToAll().isTrue();
@@ -267,8 +264,8 @@
     Timestamp createdOn = toTimestamp(LocalDate.of(2017, Month.DECEMBER, 11).atTime(13, 44, 10));
 
     InternalGroupCreation groupCreation = getPrefilledGroupCreationBuilder().build();
-    InternalGroupUpdate groupUpdate = InternalGroupUpdate.builder().setUpdatedOn(createdOn).build();
-    createGroup(groupCreation, groupUpdate);
+    GroupDelta groupDelta = GroupDelta.builder().setUpdatedOn(createdOn).build();
+    createGroup(groupCreation, groupDelta);
 
     Optional<InternalGroup> group = loadGroup(groupCreation.getGroupUUID());
     assertThatGroup(group).value().createdOn().isEqualTo(createdOn);
@@ -280,11 +277,11 @@
     Account.Id member2 = Account.id(2);
 
     InternalGroupCreation groupCreation = getPrefilledGroupCreationBuilder().build();
-    InternalGroupUpdate groupUpdate =
-        InternalGroupUpdate.builder()
+    GroupDelta groupDelta =
+        GroupDelta.builder()
             .setMemberModification(members -> ImmutableSet.of(member1, member2))
             .build();
-    createGroup(groupCreation, groupUpdate);
+    createGroup(groupCreation, groupDelta);
 
     Optional<InternalGroup> group = loadGroup(groupCreation.getGroupUUID());
     assertThatGroup(group).value().members().containsExactly(member1, member2);
@@ -296,11 +293,11 @@
     AccountGroup.UUID subgroup2 = AccountGroup.uuid("subgroup2");
 
     InternalGroupCreation groupCreation = getPrefilledGroupCreationBuilder().build();
-    InternalGroupUpdate groupUpdate =
-        InternalGroupUpdate.builder()
+    GroupDelta groupDelta =
+        GroupDelta.builder()
             .setSubgroupModification(subgroups -> ImmutableSet.of(subgroup1, subgroup2))
             .build();
-    createGroup(groupCreation, groupUpdate);
+    createGroup(groupCreation, groupDelta);
 
     Optional<InternalGroup> group = loadGroup(groupCreation.getGroupUUID());
     assertThatGroup(group).value().subgroups().containsExactly(subgroup1, subgroup2);
@@ -507,8 +504,8 @@
     createArbitraryGroup(groupUuid);
     AccountGroup.NameKey newName = AccountGroup.nameKey("New name");
 
-    InternalGroupUpdate groupUpdate = InternalGroupUpdate.builder().setName(newName).build();
-    updateGroup(groupUuid, groupUpdate);
+    GroupDelta groupDelta = GroupDelta.builder().setName(newName).build();
+    updateGroup(groupUuid, groupDelta);
 
     Optional<InternalGroup> group = loadGroup(groupUuid);
     assertThatGroup(group).value().nameKey().isEqualTo(newName);
@@ -519,9 +516,8 @@
     createArbitraryGroup(groupUuid);
 
     GroupConfig groupConfig = GroupConfig.loadForGroup(projectName, repository, groupUuid);
-    InternalGroupUpdate groupUpdate =
-        InternalGroupUpdate.builder().setName(AccountGroup.nameKey("")).build();
-    groupConfig.setGroupUpdate(groupUpdate, auditLogFormatter);
+    GroupDelta groupDelta = GroupDelta.builder().setName(AccountGroup.nameKey("")).build();
+    groupConfig.setGroupDelta(groupDelta, auditLogFormatter);
 
     try (MetaDataUpdate metaDataUpdate = createMetaDataUpdate()) {
       Throwable thrown = assertThrows(Throwable.class, () -> groupConfig.commit(metaDataUpdate));
@@ -537,8 +533,8 @@
 
     GroupConfig groupConfig = GroupConfig.loadForGroup(projectName, repository, groupUuid);
     groupConfig.setAllowSaveEmptyName();
-    InternalGroupUpdate groupUpdate = InternalGroupUpdate.builder().setName(emptyName).build();
-    groupConfig.setGroupUpdate(groupUpdate, auditLogFormatter);
+    GroupDelta groupDelta = GroupDelta.builder().setName(emptyName).build();
+    groupConfig.setGroupDelta(groupDelta, auditLogFormatter);
     commit(groupConfig);
 
     Optional<InternalGroup> group = loadGroup(groupUuid);
@@ -550,9 +546,8 @@
     createArbitraryGroup(groupUuid);
     String newDescription = "New description";
 
-    InternalGroupUpdate groupUpdate =
-        InternalGroupUpdate.builder().setDescription(newDescription).build();
-    updateGroup(groupUuid, groupUpdate);
+    GroupDelta groupDelta = GroupDelta.builder().setDescription(newDescription).build();
+    updateGroup(groupUuid, groupDelta);
 
     Optional<InternalGroup> group = loadGroup(groupUuid);
     assertThatGroup(group).value().description().isEqualTo(newDescription);
@@ -562,8 +557,8 @@
   public void descriptionCanBeRemoved() throws Exception {
     createArbitraryGroup(groupUuid);
 
-    InternalGroupUpdate groupUpdate = InternalGroupUpdate.builder().setDescription("").build();
-    Optional<InternalGroup> group = updateGroup(groupUuid, groupUpdate);
+    GroupDelta groupDelta = GroupDelta.builder().setDescription("").build();
+    Optional<InternalGroup> group = updateGroup(groupUuid, groupDelta);
 
     assertThatGroup(group).value().description().isNull();
   }
@@ -573,9 +568,8 @@
     createArbitraryGroup(groupUuid);
     AccountGroup.UUID newOwnerGroupUuid = AccountGroup.uuid("New owner");
 
-    InternalGroupUpdate groupUpdate =
-        InternalGroupUpdate.builder().setOwnerGroupUUID(newOwnerGroupUuid).build();
-    updateGroup(groupUuid, groupUpdate);
+    GroupDelta groupDelta = GroupDelta.builder().setOwnerGroupUUID(newOwnerGroupUuid).build();
+    updateGroup(groupUuid, groupDelta);
 
     Optional<InternalGroup> group = loadGroup(groupUuid);
     assertThatGroup(group).value().ownerGroupUuid().isEqualTo(newOwnerGroupUuid);
@@ -586,9 +580,8 @@
     createArbitraryGroup(groupUuid);
 
     GroupConfig groupConfig = GroupConfig.loadForGroup(projectName, repository, groupUuid);
-    InternalGroupUpdate groupUpdate =
-        InternalGroupUpdate.builder().setOwnerGroupUUID(AccountGroup.uuid("")).build();
-    groupConfig.setGroupUpdate(groupUpdate, auditLogFormatter);
+    GroupDelta groupDelta = GroupDelta.builder().setOwnerGroupUUID(AccountGroup.uuid("")).build();
+    groupConfig.setGroupDelta(groupDelta, auditLogFormatter);
 
     try (MetaDataUpdate metaDataUpdate = createMetaDataUpdate()) {
       Throwable thrown = assertThrows(Throwable.class, () -> groupConfig.commit(metaDataUpdate));
@@ -602,9 +595,8 @@
     createArbitraryGroup(groupUuid);
     boolean oldVisibleAll = loadGroup(groupUuid).map(InternalGroup::isVisibleToAll).orElse(false);
 
-    InternalGroupUpdate groupUpdate =
-        InternalGroupUpdate.builder().setVisibleToAll(!oldVisibleAll).build();
-    updateGroup(groupUuid, groupUpdate);
+    GroupDelta groupDelta = GroupDelta.builder().setVisibleToAll(!oldVisibleAll).build();
+    updateGroup(groupUuid, groupDelta);
 
     Optional<InternalGroup> group = loadGroup(groupUuid);
     assertThatGroup(group).value().visibleToAll().isEqualTo(!oldVisibleAll);
@@ -616,16 +608,15 @@
     Timestamp updatedOn = toTimestamp(LocalDate.of(2017, Month.DECEMBER, 12).atTime(10, 21, 49));
 
     InternalGroupCreation groupCreation = getPrefilledGroupCreationBuilder().build();
-    InternalGroupUpdate initialGroupUpdate =
-        InternalGroupUpdate.builder().setUpdatedOn(createdOn).build();
-    createGroup(groupCreation, initialGroupUpdate);
+    GroupDelta initialGroupDelta = GroupDelta.builder().setUpdatedOn(createdOn).build();
+    createGroup(groupCreation, initialGroupDelta);
 
-    InternalGroupUpdate laterGroupUpdate =
-        InternalGroupUpdate.builder()
+    GroupDelta laterGroupDelta =
+        GroupDelta.builder()
             .setName(AccountGroup.nameKey("Another name"))
             .setUpdatedOn(updatedOn)
             .build();
-    Optional<InternalGroup> group = updateGroup(groupCreation.getGroupUUID(), laterGroupUpdate);
+    Optional<InternalGroup> group = updateGroup(groupCreation.getGroupUUID(), laterGroupDelta);
 
     assertThatGroup(group).value().createdOn().isEqualTo(createdOn);
     Optional<InternalGroup> reloadedGroup = loadGroup(groupUuid);
@@ -638,17 +629,15 @@
     Account.Id member1 = Account.id(1);
     Account.Id member2 = Account.id(2);
 
-    InternalGroupUpdate groupUpdate1 =
-        InternalGroupUpdate.builder()
-            .setMemberModification(members -> ImmutableSet.of(member1))
-            .build();
-    updateGroup(groupUuid, groupUpdate1);
+    GroupDelta groupDelta1 =
+        GroupDelta.builder().setMemberModification(members -> ImmutableSet.of(member1)).build();
+    updateGroup(groupUuid, groupDelta1);
 
-    InternalGroupUpdate groupUpdate2 =
-        InternalGroupUpdate.builder()
+    GroupDelta groupDelta2 =
+        GroupDelta.builder()
             .setMemberModification(members -> Sets.union(members, ImmutableSet.of(member2)))
             .build();
-    updateGroup(groupUuid, groupUpdate2);
+    updateGroup(groupUuid, groupDelta2);
 
     Optional<InternalGroup> group = loadGroup(groupUuid);
     assertThatGroup(group).value().members().containsExactly(member1, member2);
@@ -660,17 +649,17 @@
     Account.Id member1 = Account.id(1);
     Account.Id member2 = Account.id(2);
 
-    InternalGroupUpdate groupUpdate1 =
-        InternalGroupUpdate.builder()
+    GroupDelta groupDelta1 =
+        GroupDelta.builder()
             .setMemberModification(members -> ImmutableSet.of(member1, member2))
             .build();
-    updateGroup(groupUuid, groupUpdate1);
+    updateGroup(groupUuid, groupDelta1);
 
-    InternalGroupUpdate groupUpdate2 =
-        InternalGroupUpdate.builder()
+    GroupDelta groupDelta2 =
+        GroupDelta.builder()
             .setMemberModification(members -> Sets.difference(members, ImmutableSet.of(member1)))
             .build();
-    updateGroup(groupUuid, groupUpdate2);
+    updateGroup(groupUuid, groupDelta2);
 
     Optional<InternalGroup> group = loadGroup(groupUuid);
     assertThatGroup(group).value().members().containsExactly(member2);
@@ -682,17 +671,17 @@
     AccountGroup.UUID subgroup1 = AccountGroup.uuid("subgroups1");
     AccountGroup.UUID subgroup2 = AccountGroup.uuid("subgroups2");
 
-    InternalGroupUpdate groupUpdate1 =
-        InternalGroupUpdate.builder()
+    GroupDelta groupDelta1 =
+        GroupDelta.builder()
             .setSubgroupModification(subgroups -> ImmutableSet.of(subgroup1))
             .build();
-    updateGroup(groupUuid, groupUpdate1);
+    updateGroup(groupUuid, groupDelta1);
 
-    InternalGroupUpdate groupUpdate2 =
-        InternalGroupUpdate.builder()
+    GroupDelta groupDelta2 =
+        GroupDelta.builder()
             .setSubgroupModification(subgroups -> Sets.union(subgroups, ImmutableSet.of(subgroup2)))
             .build();
-    updateGroup(groupUuid, groupUpdate2);
+    updateGroup(groupUuid, groupDelta2);
 
     Optional<InternalGroup> group = loadGroup(groupUuid);
     assertThatGroup(group).value().subgroups().containsExactly(subgroup1, subgroup2);
@@ -704,18 +693,18 @@
     AccountGroup.UUID subgroup1 = AccountGroup.uuid("subgroups1");
     AccountGroup.UUID subgroup2 = AccountGroup.uuid("subgroups2");
 
-    InternalGroupUpdate groupUpdate1 =
-        InternalGroupUpdate.builder()
+    GroupDelta groupDelta1 =
+        GroupDelta.builder()
             .setSubgroupModification(members -> ImmutableSet.of(subgroup1, subgroup2))
             .build();
-    updateGroup(groupUuid, groupUpdate1);
+    updateGroup(groupUuid, groupDelta1);
 
-    InternalGroupUpdate groupUpdate2 =
-        InternalGroupUpdate.builder()
+    GroupDelta groupDelta2 =
+        GroupDelta.builder()
             .setSubgroupModification(
                 members -> Sets.difference(members, ImmutableSet.of(subgroup1)))
             .build();
-    updateGroup(groupUuid, groupUpdate2);
+    updateGroup(groupUuid, groupDelta2);
 
     Optional<InternalGroup> group = loadGroup(groupUuid);
     assertThatGroup(group).value().subgroups().containsExactly(subgroup2);
@@ -742,8 +731,8 @@
   @Test
   public void loadedNewGroupWithAllPropertiesDoesNotChangeOnReload() throws Exception {
     InternalGroupCreation groupCreation = getPrefilledGroupCreationBuilder().build();
-    InternalGroupUpdate groupUpdate =
-        InternalGroupUpdate.builder()
+    GroupDelta groupDelta =
+        GroupDelta.builder()
             .setDescription("A test group")
             .setOwnerGroupUUID(AccountGroup.uuid("another owner"))
             .setVisibleToAll(true)
@@ -753,7 +742,7 @@
             .setSubgroupModification(subgroups -> ImmutableSet.of(AccountGroup.uuid("subgroup")))
             .build();
 
-    Optional<InternalGroup> createdGroup = createGroup(groupCreation, groupUpdate);
+    Optional<InternalGroup> createdGroup = createGroup(groupCreation, groupDelta);
     Optional<InternalGroup> reloadedGroup = loadGroup(groupCreation.getGroupUUID());
 
     assertThat(createdGroup).isEqualTo(reloadedGroup);
@@ -763,8 +752,8 @@
   public void loadedGroupAfterUpdatesForAllPropertiesDoesNotChangeOnReload() throws Exception {
     createArbitraryGroup(groupUuid);
 
-    InternalGroupUpdate groupUpdate =
-        InternalGroupUpdate.builder()
+    GroupDelta groupDelta =
+        GroupDelta.builder()
             .setDescription("A test group")
             .setOwnerGroupUUID(AccountGroup.uuid("another owner"))
             .setVisibleToAll(true)
@@ -774,7 +763,7 @@
             .setSubgroupModification(subgroups -> ImmutableSet.of(AccountGroup.uuid("subgroup")))
             .build();
 
-    Optional<InternalGroup> updatedGroup = updateGroup(groupUuid, groupUpdate);
+    Optional<InternalGroup> updatedGroup = updateGroup(groupUuid, groupDelta);
     Optional<InternalGroup> reloadedGroup = loadGroup(groupUuid);
 
     assertThat(updatedGroup).isEqualTo(reloadedGroup);
@@ -785,8 +774,8 @@
       throws Exception {
     // Create a group with all properties set.
     InternalGroupCreation groupCreation = getPrefilledGroupCreationBuilder().build();
-    InternalGroupUpdate initialGroupUpdate =
-        InternalGroupUpdate.builder()
+    GroupDelta initialGroupDelta =
+        GroupDelta.builder()
             .setDescription("A test group")
             .setOwnerGroupUUID(AccountGroup.uuid("another owner"))
             .setVisibleToAll(true)
@@ -795,13 +784,13 @@
             .setMemberModification(members -> ImmutableSet.of(Account.id(1), Account.id(2)))
             .setSubgroupModification(subgroups -> ImmutableSet.of(AccountGroup.uuid("subgroup")))
             .build();
-    createGroup(groupCreation, initialGroupUpdate);
+    createGroup(groupCreation, initialGroupDelta);
 
     // Only update one of the properties.
-    InternalGroupUpdate groupUpdate =
-        InternalGroupUpdate.builder().setName(AccountGroup.nameKey("Another name")).build();
+    GroupDelta groupDelta =
+        GroupDelta.builder().setName(AccountGroup.nameKey("Another name")).build();
 
-    Optional<InternalGroup> updatedGroup = updateGroup(groupCreation.getGroupUUID(), groupUpdate);
+    Optional<InternalGroup> updatedGroup = updateGroup(groupCreation.getGroupUUID(), groupDelta);
     Optional<InternalGroup> reloadedGroup = loadGroup(groupCreation.getGroupUUID());
 
     assertThat(updatedGroup).isEqualTo(reloadedGroup);
@@ -815,14 +804,13 @@
     commit(groupConfig);
 
     AccountGroup.NameKey name = AccountGroup.nameKey("Robots");
-    InternalGroupUpdate groupUpdate1 = InternalGroupUpdate.builder().setName(name).build();
-    groupConfig.setGroupUpdate(groupUpdate1, auditLogFormatter);
+    GroupDelta groupDelta1 = GroupDelta.builder().setName(name).build();
+    groupConfig.setGroupDelta(groupDelta1, auditLogFormatter);
     commit(groupConfig);
 
     String description = "Test group for robots";
-    InternalGroupUpdate groupUpdate2 =
-        InternalGroupUpdate.builder().setDescription(description).build();
-    groupConfig.setGroupUpdate(groupUpdate2, auditLogFormatter);
+    GroupDelta groupDelta2 = GroupDelta.builder().setDescription(description).build();
+    groupConfig.setGroupDelta(groupDelta2, auditLogFormatter);
     commit(groupConfig);
 
     Optional<InternalGroup> group = loadGroup(groupUuid);
@@ -850,9 +838,9 @@
 
     RevCommit commitAfterCreation = getLatestCommitForGroup(groupUuid);
 
-    InternalGroupUpdate groupUpdate =
-        InternalGroupUpdate.builder().setName(AccountGroup.nameKey("Another name")).build();
-    updateGroup(groupUuid, groupUpdate);
+    GroupDelta groupDelta =
+        GroupDelta.builder().setName(AccountGroup.nameKey("Another name")).build();
+    updateGroup(groupUuid, groupDelta);
 
     RevCommit commitAfterUpdate = getLatestCommitForGroup(groupUuid);
     assertThat(commitAfterUpdate).isNotEqualTo(commitAfterCreation);
@@ -863,10 +851,10 @@
   public void newCommitIsNotCreatedForEmptyUpdate() throws Exception {
     createArbitraryGroup(groupUuid);
 
-    InternalGroupUpdate groupUpdate = InternalGroupUpdate.builder().build();
+    GroupDelta groupDelta = GroupDelta.builder().build();
 
     RevCommit commitBeforeUpdate = getLatestCommitForGroup(groupUuid);
-    updateGroup(groupUuid, groupUpdate);
+    updateGroup(groupUuid, groupDelta);
     RevCommit commitAfterUpdate = getLatestCommitForGroup(groupUuid);
 
     assertThat(commitAfterUpdate).isEqualTo(commitBeforeUpdate);
@@ -877,10 +865,10 @@
     createArbitraryGroup(groupUuid);
 
     Timestamp updatedOn = toTimestamp(LocalDate.of(3017, Month.DECEMBER, 12).atTime(10, 21, 49));
-    InternalGroupUpdate groupUpdate = InternalGroupUpdate.builder().setUpdatedOn(updatedOn).build();
+    GroupDelta groupDelta = GroupDelta.builder().setUpdatedOn(updatedOn).build();
 
     RevCommit commitBeforeUpdate = getLatestCommitForGroup(groupUuid);
-    updateGroup(groupUuid, groupUpdate);
+    updateGroup(groupUuid, groupDelta);
     RevCommit commitAfterUpdate = getLatestCommitForGroup(groupUuid);
 
     assertThat(commitAfterUpdate).isEqualTo(commitBeforeUpdate);
@@ -890,11 +878,11 @@
   public void newCommitIsNotCreatedForRedundantNameUpdate() throws Exception {
     createArbitraryGroup(groupUuid);
 
-    InternalGroupUpdate groupUpdate = InternalGroupUpdate.builder().setName(groupName).build();
-    updateGroup(groupUuid, groupUpdate);
+    GroupDelta groupDelta = GroupDelta.builder().setName(groupName).build();
+    updateGroup(groupUuid, groupDelta);
 
     RevCommit commitBeforeUpdate = getLatestCommitForGroup(groupUuid);
-    updateGroup(groupUuid, groupUpdate);
+    updateGroup(groupUuid, groupDelta);
     RevCommit commitAfterUpdate = getLatestCommitForGroup(groupUuid);
 
     assertThat(commitAfterUpdate).isEqualTo(commitBeforeUpdate);
@@ -904,12 +892,11 @@
   public void newCommitIsNotCreatedForRedundantDescriptionUpdate() throws Exception {
     createArbitraryGroup(groupUuid);
 
-    InternalGroupUpdate groupUpdate =
-        InternalGroupUpdate.builder().setDescription("A test group").build();
-    updateGroup(groupUuid, groupUpdate);
+    GroupDelta groupDelta = GroupDelta.builder().setDescription("A test group").build();
+    updateGroup(groupUuid, groupDelta);
 
     RevCommit commitBeforeUpdate = getLatestCommitForGroup(groupUuid);
-    updateGroup(groupUuid, groupUpdate);
+    updateGroup(groupUuid, groupDelta);
     RevCommit commitAfterUpdate = getLatestCommitForGroup(groupUuid);
 
     assertThat(commitAfterUpdate).isEqualTo(commitBeforeUpdate);
@@ -919,11 +906,11 @@
   public void newCommitIsNotCreatedForRedundantVisibleToAllUpdate() throws Exception {
     createArbitraryGroup(groupUuid);
 
-    InternalGroupUpdate groupUpdate = InternalGroupUpdate.builder().setVisibleToAll(true).build();
-    updateGroup(groupUuid, groupUpdate);
+    GroupDelta groupDelta = GroupDelta.builder().setVisibleToAll(true).build();
+    updateGroup(groupUuid, groupDelta);
 
     RevCommit commitBeforeUpdate = getLatestCommitForGroup(groupUuid);
-    updateGroup(groupUuid, groupUpdate);
+    updateGroup(groupUuid, groupDelta);
     RevCommit commitAfterUpdate = getLatestCommitForGroup(groupUuid);
 
     assertThat(commitAfterUpdate).isEqualTo(commitBeforeUpdate);
@@ -933,12 +920,12 @@
   public void newCommitIsNotCreatedForRedundantOwnerGroupUuidUpdate() throws Exception {
     createArbitraryGroup(groupUuid);
 
-    InternalGroupUpdate groupUpdate =
-        InternalGroupUpdate.builder().setOwnerGroupUUID(AccountGroup.uuid("Another owner")).build();
-    updateGroup(groupUuid, groupUpdate);
+    GroupDelta groupDelta =
+        GroupDelta.builder().setOwnerGroupUUID(AccountGroup.uuid("Another owner")).build();
+    updateGroup(groupUuid, groupDelta);
 
     RevCommit commitBeforeUpdate = getLatestCommitForGroup(groupUuid);
-    updateGroup(groupUuid, groupUpdate);
+    updateGroup(groupUuid, groupDelta);
     RevCommit commitAfterUpdate = getLatestCommitForGroup(groupUuid);
 
     assertThat(commitAfterUpdate).isEqualTo(commitBeforeUpdate);
@@ -948,14 +935,14 @@
   public void newCommitIsNotCreatedForRedundantMemberUpdate() throws Exception {
     createArbitraryGroup(groupUuid);
 
-    InternalGroupUpdate groupUpdate =
-        InternalGroupUpdate.builder()
+    GroupDelta groupDelta =
+        GroupDelta.builder()
             .setMemberModification(members -> Sets.union(members, ImmutableSet.of(Account.id(10))))
             .build();
-    updateGroup(groupUuid, groupUpdate);
+    updateGroup(groupUuid, groupDelta);
 
     RevCommit commitBeforeUpdate = getLatestCommitForGroup(groupUuid);
-    updateGroup(groupUuid, groupUpdate);
+    updateGroup(groupUuid, groupDelta);
     RevCommit commitAfterUpdate = getLatestCommitForGroup(groupUuid);
 
     assertThat(commitAfterUpdate).isEqualTo(commitBeforeUpdate);
@@ -965,15 +952,15 @@
   public void newCommitIsNotCreatedForRedundantSubgroupsUpdate() throws Exception {
     createArbitraryGroup(groupUuid);
 
-    InternalGroupUpdate groupUpdate =
-        InternalGroupUpdate.builder()
+    GroupDelta groupDelta =
+        GroupDelta.builder()
             .setSubgroupModification(
                 subgroups -> Sets.union(subgroups, ImmutableSet.of(AccountGroup.uuid("subgroup"))))
             .build();
-    updateGroup(groupUuid, groupUpdate);
+    updateGroup(groupUuid, groupDelta);
 
     RevCommit commitBeforeUpdate = getLatestCommitForGroup(groupUuid);
-    updateGroup(groupUuid, groupUpdate);
+    updateGroup(groupUuid, groupDelta);
     RevCommit commitAfterUpdate = getLatestCommitForGroup(groupUuid);
 
     assertThat(commitAfterUpdate).isEqualTo(commitBeforeUpdate);
@@ -984,11 +971,11 @@
     InternalGroupCreation groupCreation =
         getPrefilledGroupCreationBuilder().setGroupUUID(groupUuid).build();
 
-    InternalGroupUpdate groupUpdate =
-        InternalGroupUpdate.builder().setName(AccountGroup.nameKey("Another name")).build();
+    GroupDelta groupDelta =
+        GroupDelta.builder().setName(AccountGroup.nameKey("Another name")).build();
 
     GroupConfig groupConfig = GroupConfig.createForNewGroup(projectName, repository, groupCreation);
-    groupConfig.setGroupUpdate(groupUpdate, auditLogFormatter);
+    groupConfig.setGroupDelta(groupDelta, auditLogFormatter);
     commit(groupConfig);
 
     RevCommit commitBeforeSecondCommit = getLatestCommitForGroup(groupUuid);
@@ -1002,11 +989,10 @@
   public void newCommitIsNotCreatedWhenCommittingGroupUpdateTwice() throws Exception {
     createArbitraryGroup(groupUuid);
 
-    InternalGroupUpdate groupUpdate =
-        InternalGroupUpdate.builder().setDescription("A test group").build();
+    GroupDelta groupDelta = GroupDelta.builder().setDescription("A test group").build();
 
     GroupConfig groupConfig = GroupConfig.loadForGroup(projectName, repository, groupUuid);
-    groupConfig.setGroupUpdate(groupUpdate, auditLogFormatter);
+    groupConfig.setGroupDelta(groupDelta, auditLogFormatter);
     commit(groupConfig);
 
     RevCommit commitBeforeSecondCommit = getLatestCommitForGroup(groupUuid);
@@ -1044,11 +1030,11 @@
             .setNameKey(groupName)
             .setId(groupId)
             .build();
-    InternalGroupUpdate groupUpdate =
-        InternalGroupUpdate.builder()
+    GroupDelta groupDelta =
+        GroupDelta.builder()
             .setUpdatedOn(new Timestamp(createdOnAsSecondsSinceEpoch * 1000))
             .build();
-    createGroup(groupCreation, groupUpdate);
+    createGroup(groupCreation, groupDelta);
 
     RevCommit revCommit = getLatestCommitForGroup(groupUuid);
     assertThat(revCommit.getCommitTime()).isEqualTo(createdOnAsSecondsSinceEpoch);
@@ -1066,13 +1052,13 @@
             .setNameKey(groupName)
             .setId(groupId)
             .build();
-    InternalGroupUpdate groupUpdate =
-        InternalGroupUpdate.builder()
+    GroupDelta groupDelta =
+        GroupDelta.builder()
             .setName(AccountGroup.nameKey("Another name"))
             .setUpdatedOn(createdOn)
             .build();
     GroupConfig groupConfig = GroupConfig.createForNewGroup(projectName, repository, groupCreation);
-    groupConfig.setGroupUpdate(groupUpdate, auditLogFormatter);
+    groupConfig.setGroupDelta(groupDelta, auditLogFormatter);
 
     PersonIdent committerIdent =
         new PersonIdent("Jane", "Jane@gerritcodereview.com", committerTimestamp, timeZone);
@@ -1099,13 +1085,13 @@
             .setNameKey(groupName)
             .setId(groupId)
             .build();
-    InternalGroupUpdate groupUpdate =
-        InternalGroupUpdate.builder()
+    GroupDelta groupDelta =
+        GroupDelta.builder()
             .setName(AccountGroup.nameKey("Another name"))
             .setUpdatedOn(createdOn)
             .build();
     GroupConfig groupConfig = GroupConfig.createForNewGroup(projectName, repository, groupCreation);
-    groupConfig.setGroupUpdate(groupUpdate, auditLogFormatter);
+    groupConfig.setGroupDelta(groupDelta, auditLogFormatter);
 
     PersonIdent authorIdent =
         new PersonIdent("Jane", "Jane@gerritcodereview.com", authorTimestamp, timeZone);
@@ -1126,9 +1112,9 @@
     long testStartAsSecondsSinceEpoch = TimeUtil.nowTs().getTime() / 1000;
 
     createArbitraryGroup(groupUuid);
-    InternalGroupUpdate groupUpdate =
-        InternalGroupUpdate.builder().setName(AccountGroup.nameKey("Another name")).build();
-    updateGroup(groupUuid, groupUpdate);
+    GroupDelta groupDelta =
+        GroupDelta.builder().setName(AccountGroup.nameKey("Another name")).build();
+    updateGroup(groupUuid, groupDelta);
 
     RevCommit revCommit = getLatestCommitForGroup(groupUuid);
     assertThat(revCommit.getCommitTime()).isAtLeast((int) testStartAsSecondsSinceEpoch);
@@ -1140,12 +1126,12 @@
     long updatedOnAsSecondsSinceEpoch = 9082093;
 
     createArbitraryGroup(groupUuid);
-    InternalGroupUpdate groupUpdate =
-        InternalGroupUpdate.builder()
+    GroupDelta groupDelta =
+        GroupDelta.builder()
             .setName(AccountGroup.nameKey("Another name"))
             .setUpdatedOn(new Timestamp(updatedOnAsSecondsSinceEpoch * 1000))
             .build();
-    updateGroup(groupUuid, groupUpdate);
+    updateGroup(groupUuid, groupDelta);
 
     RevCommit revCommit = getLatestCommitForGroup(groupUuid);
     assertThat(revCommit.getCommitTime()).isEqualTo(updatedOnAsSecondsSinceEpoch);
@@ -1158,13 +1144,13 @@
     Timestamp updatedOn = toTimestamp(LocalDate.of(2016, Month.MARCH, 11).atTime(23, 49, 11));
 
     createArbitraryGroup(groupUuid);
-    InternalGroupUpdate groupUpdate =
-        InternalGroupUpdate.builder()
+    GroupDelta groupDelta =
+        GroupDelta.builder()
             .setName(AccountGroup.nameKey("Another name"))
             .setUpdatedOn(updatedOn)
             .build();
     GroupConfig groupConfig = GroupConfig.loadForGroup(projectName, repository, groupUuid);
-    groupConfig.setGroupUpdate(groupUpdate, auditLogFormatter);
+    groupConfig.setGroupDelta(groupDelta, auditLogFormatter);
 
     PersonIdent committerIdent =
         new PersonIdent("Jane", "Jane@gerritcodereview.com", committerTimestamp, timeZone);
@@ -1186,13 +1172,13 @@
     Timestamp updatedOn = toTimestamp(LocalDate.of(2016, Month.MARCH, 11).atTime(23, 49, 11));
 
     createArbitraryGroup(groupUuid);
-    InternalGroupUpdate groupUpdate =
-        InternalGroupUpdate.builder()
+    GroupDelta groupDelta =
+        GroupDelta.builder()
             .setName(AccountGroup.nameKey("Another name"))
             .setUpdatedOn(updatedOn)
             .build();
     GroupConfig groupConfig = GroupConfig.loadForGroup(projectName, repository, groupUuid);
-    groupConfig.setGroupUpdate(groupUpdate, auditLogFormatter);
+    groupConfig.setGroupDelta(groupDelta, auditLogFormatter);
 
     PersonIdent authorIdent =
         new PersonIdent("Jane", "Jane@gerritcodereview.com", authorTimestamp, timeZone);
@@ -1222,14 +1208,13 @@
     createArbitraryGroup(groupUuid);
 
     AccountGroup.NameKey firstName = AccountGroup.nameKey("Bots");
-    InternalGroupUpdate groupUpdate1 = InternalGroupUpdate.builder().setName(firstName).build();
-    updateGroup(groupUuid, groupUpdate1);
+    GroupDelta groupDelta1 = GroupDelta.builder().setName(firstName).build();
+    updateGroup(groupUuid, groupDelta1);
 
     RevCommit commitAfterUpdate1 = getLatestCommitForGroup(groupUuid);
 
-    InternalGroupUpdate groupUpdate2 =
-        InternalGroupUpdate.builder().setName(AccountGroup.nameKey("Robots")).build();
-    updateGroup(groupUuid, groupUpdate2);
+    GroupDelta groupDelta2 = GroupDelta.builder().setName(AccountGroup.nameKey("Robots")).build();
+    updateGroup(groupUuid, groupDelta2);
 
     GroupConfig groupConfig =
         GroupConfig.loadForGroupSnapshot(
@@ -1254,9 +1239,9 @@
       throws Exception {
     InternalGroupCreation groupCreation =
         getPrefilledGroupCreationBuilder().setGroupUUID(groupUuid).build();
-    InternalGroupUpdate groupUpdate =
-        InternalGroupUpdate.builder().setName(AccountGroup.nameKey("Another name")).build();
-    createGroup(groupCreation, groupUpdate);
+    GroupDelta groupDelta =
+        GroupDelta.builder().setName(AccountGroup.nameKey("Another name")).build();
+    createGroup(groupCreation, groupDelta);
 
     RevCommit revCommit = getLatestCommitForGroup(groupUuid);
     assertThat(revCommit.getFullMessage()).isEqualTo("Create group");
@@ -1273,13 +1258,13 @@
 
     InternalGroupCreation groupCreation =
         getPrefilledGroupCreationBuilder().setGroupUUID(groupUuid).build();
-    InternalGroupUpdate groupUpdate =
-        InternalGroupUpdate.builder()
+    GroupDelta groupDelta =
+        GroupDelta.builder()
             .setMemberModification(members -> ImmutableSet.of(account13.id(), account7.id()))
             .build();
 
     GroupConfig groupConfig = GroupConfig.createForNewGroup(projectName, repository, groupCreation);
-    groupConfig.setGroupUpdate(groupUpdate, auditLogFormatter);
+    groupConfig.setGroupDelta(groupDelta, auditLogFormatter);
     commit(groupConfig);
 
     RevCommit revCommit = getLatestCommitForGroup(groupUuid);
@@ -1298,13 +1283,13 @@
 
     InternalGroupCreation groupCreation =
         getPrefilledGroupCreationBuilder().setGroupUUID(groupUuid).build();
-    InternalGroupUpdate groupUpdate =
-        InternalGroupUpdate.builder()
+    GroupDelta groupDelta =
+        GroupDelta.builder()
             .setSubgroupModification(
                 subgroups -> ImmutableSet.of(group1.getGroupUUID(), group2.getGroupUUID()))
             .build();
     GroupConfig groupConfig = GroupConfig.createForNewGroup(projectName, repository, groupCreation);
-    groupConfig.setGroupUpdate(groupUpdate, auditLogFormatter);
+    groupConfig.setGroupDelta(groupDelta, auditLogFormatter);
     commit(groupConfig);
 
     RevCommit revCommit = getLatestCommitForGroup(groupUuid);
@@ -1323,11 +1308,11 @@
     AuditLogFormatter auditLogFormatter =
         AuditLogFormatter.createBackedBy(accounts, ImmutableSet.of(), "GerritServer1");
 
-    InternalGroupUpdate groupUpdate =
-        InternalGroupUpdate.builder()
+    GroupDelta groupDelta =
+        GroupDelta.builder()
             .setMemberModification(members -> ImmutableSet.of(account13.id(), account7.id()))
             .build();
-    updateGroup(groupUuid, groupUpdate, auditLogFormatter);
+    updateGroup(groupUuid, groupDelta, auditLogFormatter);
 
     RevCommit revCommit = getLatestCommitForGroup(groupUuid);
     assertThat(revCommit.getFullMessage())
@@ -1345,17 +1330,17 @@
     AuditLogFormatter auditLogFormatter =
         AuditLogFormatter.createBackedBy(accounts, ImmutableSet.of(), "server-id");
 
-    InternalGroupUpdate groupUpdate1 =
-        InternalGroupUpdate.builder()
+    GroupDelta groupDelta1 =
+        GroupDelta.builder()
             .setMemberModification(members -> ImmutableSet.of(account13.id(), account7.id()))
             .build();
-    updateGroup(groupUuid, groupUpdate1, auditLogFormatter);
+    updateGroup(groupUuid, groupDelta1, auditLogFormatter);
 
-    InternalGroupUpdate groupUpdate2 =
-        InternalGroupUpdate.builder()
+    GroupDelta groupDelta2 =
+        GroupDelta.builder()
             .setMemberModification(members -> ImmutableSet.of(account7.id()))
             .build();
-    updateGroup(groupUuid, groupUpdate2, auditLogFormatter);
+    updateGroup(groupUuid, groupDelta2, auditLogFormatter);
 
     RevCommit revCommit = getLatestCommitForGroup(groupUuid);
     assertThat(revCommit.getFullMessage()).isEqualTo("Update group\n\nRemove: John <13@server-id>");
@@ -1372,12 +1357,12 @@
     AuditLogFormatter auditLogFormatter =
         AuditLogFormatter.createBackedBy(ImmutableSet.of(), groups, "serverId");
 
-    InternalGroupUpdate groupUpdate =
-        InternalGroupUpdate.builder()
+    GroupDelta groupDelta =
+        GroupDelta.builder()
             .setSubgroupModification(
                 subgroups -> ImmutableSet.of(group1.getGroupUUID(), group2.getGroupUUID()))
             .build();
-    updateGroup(groupUuid, groupUpdate, auditLogFormatter);
+    updateGroup(groupUuid, groupDelta, auditLogFormatter);
 
     RevCommit revCommit = getLatestCommitForGroup(groupUuid);
     assertThat(revCommit.getFullMessage())
@@ -1395,18 +1380,18 @@
     AuditLogFormatter auditLogFormatter =
         AuditLogFormatter.createBackedBy(ImmutableSet.of(), groups, "serverId");
 
-    InternalGroupUpdate groupUpdate1 =
-        InternalGroupUpdate.builder()
+    GroupDelta groupDelta1 =
+        GroupDelta.builder()
             .setSubgroupModification(
                 subgroups -> ImmutableSet.of(group1.getGroupUUID(), group2.getGroupUUID()))
             .build();
-    updateGroup(groupUuid, groupUpdate1, auditLogFormatter);
+    updateGroup(groupUuid, groupDelta1, auditLogFormatter);
 
-    InternalGroupUpdate groupUpdate2 =
-        InternalGroupUpdate.builder()
+    GroupDelta groupDelta2 =
+        GroupDelta.builder()
             .setSubgroupModification(subgroups -> ImmutableSet.of(group1.getGroupUUID()))
             .build();
-    updateGroup(groupUuid, groupUpdate2, auditLogFormatter);
+    updateGroup(groupUuid, groupDelta2, auditLogFormatter);
 
     RevCommit revCommit = getLatestCommitForGroup(groupUuid);
     assertThat(revCommit.getFullMessage())
@@ -1417,13 +1402,11 @@
   public void commitMessageOfGroupRenameContainsFooters() throws Exception {
     createArbitraryGroup(groupUuid);
 
-    InternalGroupUpdate groupUpdate1 =
-        InternalGroupUpdate.builder().setName(AccountGroup.nameKey("Old name")).build();
-    updateGroup(groupUuid, groupUpdate1);
+    GroupDelta groupDelta1 = GroupDelta.builder().setName(AccountGroup.nameKey("Old name")).build();
+    updateGroup(groupUuid, groupDelta1);
 
-    InternalGroupUpdate groupUpdate2 =
-        InternalGroupUpdate.builder().setName(AccountGroup.nameKey("New name")).build();
-    updateGroup(groupUuid, groupUpdate2);
+    GroupDelta groupDelta2 = GroupDelta.builder().setName(AccountGroup.nameKey("New name")).build();
+    updateGroup(groupUuid, groupDelta2);
 
     RevCommit revCommit = getLatestCommitForGroup(groupUuid);
     assertThat(revCommit.getFullMessage())
@@ -1444,21 +1427,21 @@
     AuditLogFormatter auditLogFormatter =
         AuditLogFormatter.createBackedBy(accounts, groups, "serverId");
 
-    InternalGroupUpdate groupUpdate1 =
-        InternalGroupUpdate.builder()
+    GroupDelta groupDelta1 =
+        GroupDelta.builder()
             .setName(AccountGroup.nameKey("Old name"))
             .setMemberModification(members -> ImmutableSet.of(account7.id()))
             .setSubgroupModification(subgroups -> ImmutableSet.of(group2.getGroupUUID()))
             .build();
-    updateGroup(groupUuid, groupUpdate1, auditLogFormatter);
+    updateGroup(groupUuid, groupDelta1, auditLogFormatter);
 
-    InternalGroupUpdate groupUpdate2 =
-        InternalGroupUpdate.builder()
+    GroupDelta groupDelta2 =
+        GroupDelta.builder()
             .setName(AccountGroup.nameKey("New name"))
             .setMemberModification(members -> ImmutableSet.of(account13.id()))
             .setSubgroupModification(subgroups -> ImmutableSet.of(group1.getGroupUUID()))
             .build();
-    updateGroup(groupUuid, groupUpdate2, auditLogFormatter);
+    updateGroup(groupUuid, groupDelta2, auditLogFormatter);
 
     RevCommit revCommit = getLatestCommitForGroup(groupUuid);
     assertThat(revCommit.getFullMessage())
@@ -1524,23 +1507,23 @@
   }
 
   private Optional<InternalGroup> createGroup(
-      InternalGroupCreation groupCreation, InternalGroupUpdate groupUpdate) throws Exception {
+      InternalGroupCreation groupCreation, GroupDelta groupDelta) throws Exception {
     GroupConfig groupConfig = GroupConfig.createForNewGroup(projectName, repository, groupCreation);
-    groupConfig.setGroupUpdate(groupUpdate, auditLogFormatter);
+    groupConfig.setGroupDelta(groupDelta, auditLogFormatter);
     commit(groupConfig);
     return groupConfig.getLoadedGroup();
   }
 
-  private Optional<InternalGroup> updateGroup(
-      AccountGroup.UUID uuid, InternalGroupUpdate groupUpdate) throws Exception {
-    return updateGroup(uuid, groupUpdate, auditLogFormatter);
+  private Optional<InternalGroup> updateGroup(AccountGroup.UUID uuid, GroupDelta groupDelta)
+      throws Exception {
+    return updateGroup(uuid, groupDelta, auditLogFormatter);
   }
 
   private Optional<InternalGroup> updateGroup(
-      AccountGroup.UUID uuid, InternalGroupUpdate groupUpdate, AuditLogFormatter auditLogFormatter)
+      AccountGroup.UUID uuid, GroupDelta groupDelta, AuditLogFormatter auditLogFormatter)
       throws Exception {
     GroupConfig groupConfig = GroupConfig.loadForGroup(projectName, repository, uuid);
-    groupConfig.setGroupUpdate(groupUpdate, auditLogFormatter);
+    groupConfig.setGroupDelta(groupDelta, auditLogFormatter);
     commit(groupConfig);
     return groupConfig.getLoadedGroup();
   }
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java b/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java
index 5f375ad..feff89c 100644
--- a/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java
+++ b/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java
@@ -721,7 +721,7 @@
   @Test
   public void serializeChangeMessages() throws Exception {
     ChangeMessage m1 =
-        new ChangeMessage(
+        ChangeMessage.create(
             ChangeMessage.key(ID, "uuid1"),
             Account.id(1000),
             new Timestamp(1212L),
@@ -731,7 +731,7 @@
     assertThat(m1Bytes.size()).isEqualTo(35);
 
     ChangeMessage m2 =
-        new ChangeMessage(
+        ChangeMessage.create(
             ChangeMessage.key(ID, "uuid2"),
             Account.id(2000),
             new Timestamp(3434L),
@@ -1007,6 +1007,8 @@
                 .put("author", Account.Id.class)
                 .put("writtenOn", Timestamp.class)
                 .put("message", String.class)
+                // accountsInMessage are parsed from message template and are not serialized.
+                .put("accountsInMessage", new TypeLiteral<ImmutableSet<Account.Id>>() {}.getType())
                 .put("patchset", PatchSet.Id.class)
                 .put("tag", String.class)
                 .put("realAuthor", Account.Id.class)
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java b/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java
index de49cdf..fa37704 100644
--- a/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java
+++ b/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java
@@ -52,6 +52,7 @@
 import com.google.gerrit.entities.SubmitRecord;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.server.AssigneeStatusUpdate;
+import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.ReviewerSet;
@@ -1602,6 +1603,7 @@
     ChangeNotes notes = newNotes(c);
     ChangeMessage cm1 = Iterables.getOnlyElement(notes.getChangeMessages());
     assertThat(cm1.getMessage()).isEqualTo("Testing trailing double newline\n\n");
+
     assertThat(cm1.getAuthor()).isEqualTo(changeOwner.getAccount().id());
   }
 
@@ -1621,10 +1623,32 @@
                 + "Testing paragraph 2\n"
                 + "\n"
                 + "Testing paragraph 3");
+
     assertThat(cm1.getAuthor()).isEqualTo(changeOwner.getAccount().id());
   }
 
   @Test
+  public void changeMessageWithTemplate() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.putReviewer(changeOwner.getAccount().id(), REVIEWER);
+    String messageTemplate =
+        String.format(
+            "Change update by %s, also includes %s",
+            ChangeMessagesUtil.getAccountTemplate(changeOwner.getAccountId()),
+            ChangeMessagesUtil.getAccountTemplate(otherUser.getAccountId()));
+    update.setChangeMessage(messageTemplate);
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+    ChangeMessage cm = Iterables.getOnlyElement(notes.getChangeMessages());
+    assertThat(cm.getMessage()).isEqualTo(messageTemplate);
+
+    assertThat(cm.getAccountsInMessage())
+        .containsExactly(changeOwner.getAccountId(), otherUser.getAccountId());
+  }
+
+  @Test
   public void changeMessagesMultiplePatchSets() throws Exception {
     Change c = newChange();
     ChangeUpdate update = newUpdate(c, changeOwner);
@@ -1646,11 +1670,13 @@
     ChangeMessage cm1 = notes.getChangeMessages().get(0);
     assertThat(cm1.getPatchSetId()).isEqualTo(ps1);
     assertThat(cm1.getMessage()).isEqualTo("This is the change message for the first PS.");
+
     assertThat(cm1.getAuthor()).isEqualTo(changeOwner.getAccount().id());
 
     ChangeMessage cm2 = notes.getChangeMessages().get(1);
     assertThat(cm2.getPatchSetId()).isEqualTo(ps2);
     assertThat(cm2.getMessage()).isEqualTo("This is the change message for the second PS.");
+
     assertThat(cm2.getAuthor()).isEqualTo(changeOwner.getAccount().id());
     assertThat(cm2.getPatchSetId()).isEqualTo(ps2);
   }
diff --git a/javatests/com/google/gerrit/server/project/ProjectConfigTest.java b/javatests/com/google/gerrit/server/project/ProjectConfigTest.java
index aecb5d3..2e934e9 100644
--- a/javatests/com/google/gerrit/server/project/ProjectConfigTest.java
+++ b/javatests/com/google/gerrit/server/project/ProjectConfigTest.java
@@ -32,6 +32,8 @@
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.entities.StoredCommentLinkInfo;
+import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.entities.SubmitRequirementExpression;
 import com.google.gerrit.extensions.client.InheritableBoolean;
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.config.PluginConfig;
@@ -46,6 +48,7 @@
 import java.util.Arrays;
 import java.util.Collections;
 import java.util.Map;
+import java.util.Optional;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.errors.IncorrectObjectTypeException;
 import org.eclipse.jgit.errors.MissingObjectException;
@@ -200,6 +203,135 @@
   }
 
   @Test
+  public void readSubmitRequirements() throws Exception {
+    RevCommit rev =
+        tr.commit()
+            .add("groups", group(developers))
+            .add(
+                "project.config",
+                "[submitRequirement \"Code-review\"]\n"
+                    + "  description =  At least one Code Review +2\n"
+                    + "  applicabilityExpression = branch(refs/heads/master)\n"
+                    + "  submittabilityExpression = label(code-review, +2)\n"
+                    + "[submitRequirement \"api-review\"]\n"
+                    + "  description =  Additional review required for API modifications\n"
+                    + "  applicabilityExpression = commit_filepath_contains(\\\"/api/.*\\\")\n"
+                    + "  submittabilityExpression = label(api-review, +2)\n"
+                    + "  overrideExpression = label(build-cop-override, +1)\n"
+                    + "  canOverrideInChildProjects = true\n")
+            .create();
+
+    ProjectConfig cfg = read(rev);
+    Map<String, SubmitRequirement> submitRequirements = cfg.getSubmitRequirementSections();
+    assertThat(submitRequirements)
+        .containsExactly(
+            "Code-review", // Capitalization preserved
+            SubmitRequirement.builder()
+                .setName("Code-review") // Capitalization preserved
+                .setDescription(Optional.of("At least one Code Review +2"))
+                .setApplicabilityExpression(
+                    SubmitRequirementExpression.of("branch(refs/heads/master)"))
+                .setSubmittabilityExpression(
+                    SubmitRequirementExpression.create("label(code-review, +2)"))
+                .setOverrideExpression(Optional.empty())
+                .setAllowOverrideInChildProjects(false)
+                .build(),
+            "api-review",
+            SubmitRequirement.builder()
+                .setName("api-review")
+                .setDescription(Optional.of("Additional review required for API modifications"))
+                .setApplicabilityExpression(
+                    SubmitRequirementExpression.of("commit_filepath_contains(\"/api/.*\")"))
+                .setSubmittabilityExpression(
+                    SubmitRequirementExpression.create("label(api-review, +2)"))
+                .setOverrideExpression(
+                    SubmitRequirementExpression.of("label(build-cop-override, +1)"))
+                .setAllowOverrideInChildProjects(true)
+                .build());
+  }
+
+  @Test
+  public void readSubmitRequirementEmpty() throws Exception {
+    RevCommit rev =
+        tr.commit()
+            .add("groups", group(developers))
+            .add(
+                "project.config",
+                "[submitRequirement \"code-review\"]\n"
+                    + "  submittabilityExpression = label(code-review, +2)\n")
+            .create();
+
+    ProjectConfig cfg = read(rev);
+    Map<String, SubmitRequirement> submitRequirements = cfg.getSubmitRequirementSections();
+    assertThat(submitRequirements)
+        .containsExactly(
+            "code-review",
+            SubmitRequirement.builder()
+                .setName("code-review")
+                .setSubmittabilityExpression(
+                    SubmitRequirementExpression.create("label(code-review, +2)"))
+                .setAllowOverrideInChildProjects(false)
+                .build());
+  }
+
+  @Test
+  public void readSubmitRequirementsIdentical_WithCapitalizationDifference() throws Exception {
+    RevCommit rev =
+        tr.commit()
+            .add("groups", group(developers))
+            .add(
+                "project.config",
+                "[submitRequirement \"code-review\"]\n"
+                    + "  description = At least one Code Review +2\n"
+                    + "  submittabilityExpression = label(code-review, +2)\n"
+                    + "[submitRequirement \"Code-Review\"]\n"
+                    + "  description = Another code review label\n"
+                    + "  submittabilityExpression = label(code-review, +2)\n"
+                    + "  canOverrideInChildProjects = true\n")
+            .create();
+
+    ProjectConfig cfg = read(rev);
+    Map<String, SubmitRequirement> submitRequirements = cfg.getSubmitRequirementSections();
+    assertThat(submitRequirements)
+        .containsExactly(
+            "code-review",
+            SubmitRequirement.builder()
+                .setName("code-review")
+                .setDescription(Optional.of("At least one Code Review +2"))
+                .setSubmittabilityExpression(
+                    SubmitRequirementExpression.create("label(code-review, +2)"))
+                .setAllowOverrideInChildProjects(false)
+                .build());
+    assertThat(cfg.getValidationErrors()).hasSize(1);
+    assertThat(Iterables.getOnlyElement(cfg.getValidationErrors()).getMessage())
+        .isEqualTo(
+            "project.config: "
+                + "Submit requirement \"Code-Review\" conflicts with \"code-review\". "
+                + "Skipping the former.");
+  }
+
+  @Test
+  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")
+            .create();
+
+    ProjectConfig cfg = read(rev);
+    Map<String, SubmitRequirement> submitRequirements = cfg.getSubmitRequirementSections();
+    assertThat(submitRequirements).isEmpty();
+    assertThat(cfg.getValidationErrors()).hasSize(1);
+    assertThat(Iterables.getOnlyElement(cfg.getValidationErrors()).getMessage())
+        .isEqualTo(
+            "project.config: Submit requirement \"code-review\" does not define a submittability expression."
+                + " Skipping this requirement.");
+  }
+
+  @Test
   public void readConfigLabelOldStyleWithLeadingSpace() throws Exception {
     RevCommit rev =
         tr.commit()
@@ -806,6 +938,28 @@
   }
 
   @Test
+  public void submitRequirementSectionIsUnsetIfNoSubmitRequirementsAreSet() throws Exception {
+    RevCommit rev =
+        tr.commit()
+            .add(
+                "project.config",
+                "[submitRequirement \"code-review\"]\n"
+                    + "  description =  At least one Code Review +2\n"
+                    + "  applicabilityExpression = branch(refs/heads/master)\n"
+                    + "  submittabilityExpression = label(code-review, +2)\n"
+                    + "[notify \"name\"]\n"
+                    + "  email = example@example.com\n")
+            .create();
+    update(rev);
+
+    ProjectConfig cfg = read(rev);
+    cfg.getSubmitRequirementSections().clear();
+    rev = commit(cfg);
+    assertThat(text(rev, "project.config"))
+        .isEqualTo("[notify \"name\"]\n\temail = example@example.com\n");
+  }
+
+  @Test
   public void pluginSectionIsUnsetIfAllPluginConfigsAreEmpty() throws Exception {
     RevCommit rev =
         tr.commit()
diff --git a/javatests/com/google/gerrit/server/query/account/AbstractQueryAccountsTest.java b/javatests/com/google/gerrit/server/query/account/AbstractQueryAccountsTest.java
index f5c9628..b7be40b 100644
--- a/javatests/com/google/gerrit/server/query/account/AbstractQueryAccountsTest.java
+++ b/javatests/com/google/gerrit/server/query/account/AbstractQueryAccountsTest.java
@@ -58,12 +58,12 @@
 import com.google.gerrit.server.ServerInitiated;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AccountConfig;
+import com.google.gerrit.server.account.AccountDelta;
 import com.google.gerrit.server.account.AccountManager;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.account.Accounts;
 import com.google.gerrit.server.account.AccountsUpdate;
 import com.google.gerrit.server.account.AuthRequest;
-import com.google.gerrit.server.account.InternalAccountUpdate;
 import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.account.externalids.ExternalIds;
 import com.google.gerrit.server.config.AllProjectsName;
@@ -146,6 +146,8 @@
 
   protected abstract Injector createInjector();
 
+  protected void validateAssumptions() {}
+
   @Before
   public void setUpInjector() throws Exception {
     lifecycle = new LifecycleManager();
@@ -155,6 +157,7 @@
     lifecycle.start();
     initAfterLifecycleStart();
     setUpDatabase();
+    validateAssumptions();
   }
 
   @After
@@ -604,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";
@@ -615,13 +617,14 @@
       md.getCommitBuilder().setCommitter(ident);
       new AccountConfig(accountId, allUsers, repo)
           .load()
-          .setAccountUpdate(InternalAccountUpdate.builder().setFullName(newName).build())
+          .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/BUILD b/javatests/com/google/gerrit/server/query/account/BUILD
index 5c910a0..4ae2039 100644
--- a/javatests/com/google/gerrit/server/query/account/BUILD
+++ b/javatests/com/google/gerrit/server/query/account/BUILD
@@ -32,8 +32,7 @@
     name = "lucene_query_test",
     size = "large",
     srcs = glob(
-        ["*.java"],
-        exclude = ABSTRACT_QUERY_TEST,
+        ["LuceneQueryAccountsTest.java"],
     ),
     visibility = ["//visibility:public"],
     deps = [
@@ -44,3 +43,21 @@
         "//lib/guice",
     ],
 )
+
+junit_tests(
+    name = "fake_query_test",
+    size = "large",
+    srcs = glob(
+        ["FakeQueryAccountsTest.java"],
+    ),
+    visibility = ["//visibility:public"],
+    deps = [
+        ":abstract_query_tests",
+        "//java/com/google/gerrit/entities",
+        "//java/com/google/gerrit/server",
+        "//java/com/google/gerrit/testing:gerrit-test-util",
+        "//lib:jgit",
+        "//lib/guice",
+        "@truth//jar",
+    ],
+)
diff --git a/javatests/com/google/gerrit/server/query/account/FakeQueryAccountsTest.java b/javatests/com/google/gerrit/server/query/account/FakeQueryAccountsTest.java
new file mode 100644
index 0000000..31d256e
--- /dev/null
+++ b/javatests/com/google/gerrit/server/query/account/FakeQueryAccountsTest.java
@@ -0,0 +1,50 @@
+// 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.account;
+
+import com.google.gerrit.server.index.account.AccountSchemaDefinitions;
+import com.google.gerrit.testing.ConfigSuite;
+import com.google.gerrit.testing.InMemoryModule;
+import com.google.gerrit.testing.IndexConfig;
+import com.google.gerrit.testing.IndexVersions;
+import com.google.inject.Guice;
+import com.google.inject.Injector;
+import java.util.List;
+import java.util.Map;
+import org.eclipse.jgit.lib.Config;
+
+public class FakeQueryAccountsTest extends AbstractQueryAccountsTest {
+  @ConfigSuite.Default
+  public static Config defaultConfig() {
+    return IndexConfig.createForFake();
+  }
+
+  @ConfigSuite.Configs
+  public static Map<String, Config> againstPreviousIndexVersion() {
+    // the current schema version is already tested by the inherited default config suite
+    List<Integer> schemaVersions =
+        IndexVersions.getWithoutLatest(AccountSchemaDefinitions.INSTANCE);
+    return IndexVersions.asConfigMap(
+        AccountSchemaDefinitions.INSTANCE, schemaVersions, "againstIndexVersion", defaultConfig());
+  }
+
+  @Override
+  protected Injector createInjector() {
+    Config fakeConfig = new Config(config);
+    InMemoryModule.setDefaults(fakeConfig);
+    fakeConfig.setString("index", null, "type", "fake");
+    return Guice.createInjector(new InMemoryModule(fakeConfig));
+  }
+}
diff --git a/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
index b97d9f2..6c8026b 100644
--- a/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
+++ b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
@@ -19,6 +19,7 @@
 import static com.google.common.truth.Truth.assertWithMessage;
 import static com.google.common.truth.TruthJUnit.assume;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabel;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
 import static com.google.gerrit.extensions.client.ListChangesOption.DETAILED_LABELS;
 import static com.google.gerrit.extensions.client.ListChangesOption.REVIEWED;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
@@ -58,7 +59,6 @@
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.GerritApi;
-import com.google.gerrit.extensions.api.changes.AddReviewerInput;
 import com.google.gerrit.extensions.api.changes.AssigneeInput;
 import com.google.gerrit.extensions.api.changes.AttentionSetInput;
 import com.google.gerrit.extensions.api.changes.ChangeApi;
@@ -67,6 +67,7 @@
 import com.google.gerrit.extensions.api.changes.HashtagsInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput.DraftHandling;
+import com.google.gerrit.extensions.api.changes.ReviewerInput;
 import com.google.gerrit.extensions.api.changes.StarsInput;
 import com.google.gerrit.extensions.api.groups.GroupInput;
 import com.google.gerrit.extensions.api.projects.ConfigInput;
@@ -705,6 +706,23 @@
   }
 
   @Test
+  public void byProjectWithHidden() throws Exception {
+    TestRepository<Repo> hiddenProject = createProject("hiddenProject");
+    insert(hiddenProject, newChange(hiddenProject));
+    projectOperations
+        .project(Project.nameKey("hiddenProject"))
+        .forUpdate()
+        .add(block(Permission.READ).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+
+    TestRepository<Repo> visibleProject = createProject("visibleProject");
+    Change visibleChange = insert(visibleProject, newChange(visibleProject));
+    assertQuery("project:visibleProject", visibleChange);
+    assertQuery("project:hiddenProject");
+    assertQuery("project:visibleProject OR project:hiddenProject", visibleChange);
+  }
+
+  @Test
   public void byParentOf() throws Exception {
     TestRepository<Repo> repo1 = createProject("repo1");
     RevCommit commit1 = repo1.parseBody(repo1.commit().message("message").create());
@@ -1887,7 +1905,7 @@
     assertQuery("-added:<=4");
 
     assertQuery("added:3", change1);
-    assertQuery("-(added:<3 OR added>3)", change1);
+    assertQuery("-(added:<3 OR added:>3)", change1);
 
     assertQuery("added:>2", change1);
     assertQuery("-added:<=2", change1);
@@ -1905,7 +1923,7 @@
     assertQuery("-deleted:<=3");
 
     assertQuery("deleted:2", change2);
-    assertQuery("-(deleted:<2 OR deleted>2)", change2);
+    assertQuery("-(deleted:<2 OR deleted:>2)", change2);
 
     assertQuery("deleted:>1", change2);
     assertQuery("-deleted:<=1", change2);
@@ -1933,16 +1951,17 @@
     Change change1 = insert(repo, newChange(repo));
     Change change2 = insert(repo, newChange(repo));
 
-    HashtagsInput in = new HashtagsInput();
-    in.add = ImmutableSet.of("foo");
-    gApi.changes().id(change1.getId().get()).setHashtags(in);
-
-    in.add = ImmutableSet.of("foo", "bar", "a tag", "ACamelCaseTag");
-    gApi.changes().id(change2.getId().get()).setHashtags(in);
-
+    addHashtags(change1.getId(), "foo", "aaa-bbb-ccc");
+    addHashtags(change2.getId(), "foo", "bar", "a tag", "ACamelCaseTag");
     return ImmutableList.of(change1, change2);
   }
 
+  private void addHashtags(Change.Id changeId, String... hashtags) throws Exception {
+    HashtagsInput in = new HashtagsInput();
+    in.add = ImmutableSet.copyOf(hashtags);
+    gApi.changes().id(changeId.get()).setHashtags(in);
+  }
+
   @Test
   public void byHashtag() throws Exception {
     List<Change> changes = setUpHashtagChanges();
@@ -1958,6 +1977,31 @@
   }
 
   @Test
+  public void byHashtagFullText() throws Exception {
+    assume().that(getSchema().hasField(ChangeField.FUZZY_HASHTAG)).isTrue();
+    List<Change> changes = setUpHashtagChanges();
+    assertQuery("inhashtag:foo", changes.get(1), changes.get(0));
+    assertQuery("inhashtag:bbb", changes.get(0));
+    assertQuery("inhashtag:tag", changes.get(1));
+  }
+
+  @Test
+  public void byHashtagRegex() throws Exception {
+    TestRepository<Repo> repo = createProject("repo");
+    Change change1 = insert(repo, newChange(repo));
+    Change change2 = insert(repo, newChange(repo));
+    Change change3 = insert(repo, newChange(repo));
+    addHashtags(change1.getId(), "feature1");
+    addHashtags(change1.getId(), "trending");
+    addHashtags(change2.getId(), "Cherrypick-feature1");
+    addHashtags(change3.getId(), "feature1-fixup");
+
+    assertQuery("inhashtag:^feature1.*", change3, change1);
+    assertQuery("inhashtag:{^.*feature1$}", change2, change1);
+    assertQuery("inhashtag:^trending.*", change1);
+  }
+
+  @Test
   public void byDefault() throws Exception {
     TestRepository<Repo> repo = createProject("repo");
 
@@ -1995,7 +2039,7 @@
     assertQuery("user@example.com", expected);
     assertQuery("repo", expected);
 
-    assertQuery("Code-Review:+1", change4);
+    assertQuery("Code-Review=+1", change4);
   }
 
   @Test
@@ -2440,12 +2484,12 @@
     Change change3 = insert(repo, newChange(repo));
     insert(repo, newChange(repo));
 
-    AddReviewerInput rin = new AddReviewerInput();
+    ReviewerInput rin = new ReviewerInput();
     rin.reviewer = user1.toString();
     rin.state = ReviewerState.REVIEWER;
     gApi.changes().id(change1.getId().get()).addReviewer(rin);
 
-    rin = new AddReviewerInput();
+    rin = new ReviewerInput();
     rin.reviewer = user1.toString();
     rin.state = ReviewerState.CC;
     gApi.changes().id(change2.getId().get()).addReviewer(rin);
@@ -2496,17 +2540,17 @@
     Change change2 = insert(repo, newChange(repo));
     Change change3 = insert(repo, newChange(repo));
 
-    AddReviewerInput rin = new AddReviewerInput();
+    ReviewerInput rin = new ReviewerInput();
     rin.reviewer = user1.toString();
     rin.state = ReviewerState.REVIEWER;
     gApi.changes().id(change1.getId().get()).addReviewer(rin);
 
-    rin = new AddReviewerInput();
+    rin = new ReviewerInput();
     rin.reviewer = user2.toString();
     rin.state = ReviewerState.REVIEWER;
     gApi.changes().id(change2.getId().get()).addReviewer(rin);
 
-    rin = new AddReviewerInput();
+    rin = new ReviewerInput();
     rin.reviewer = user3.toString();
     rin.state = ReviewerState.CC;
     gApi.changes().id(change3.getId().get()).addReviewer(rin);
@@ -2546,12 +2590,12 @@
     Change change2 = insert(repo, newChange(repo));
     insert(repo, newChange(repo));
 
-    AddReviewerInput rin = new AddReviewerInput();
+    ReviewerInput rin = new ReviewerInput();
     rin.reviewer = userByEmailWithName;
     rin.state = ReviewerState.REVIEWER;
     gApi.changes().id(change1.getId().get()).addReviewer(rin);
 
-    rin = new AddReviewerInput();
+    rin = new ReviewerInput();
     rin.reviewer = userByEmailWithName;
     rin.state = ReviewerState.CC;
     gApi.changes().id(change2.getId().get()).addReviewer(rin);
@@ -2578,12 +2622,12 @@
     Change change2 = insert(repo, newChange(repo));
     insert(repo, newChange(repo));
 
-    AddReviewerInput rin = new AddReviewerInput();
+    ReviewerInput rin = new ReviewerInput();
     rin.reviewer = userByEmail;
     rin.state = ReviewerState.REVIEWER;
     gApi.changes().id(change1.getId().get()).addReviewer(rin);
 
-    rin = new AddReviewerInput();
+    rin = new ReviewerInput();
     rin.reviewer = userByEmail;
     rin.state = ReviewerState.CC;
     gApi.changes().id(change2.getId().get()).addReviewer(rin);
@@ -2945,7 +2989,7 @@
         cApi.addReviewer("" + reviewerId);
       }
       for (Account.Id reviewerId : cced) {
-        AddReviewerInput in = new AddReviewerInput();
+        ReviewerInput in = new ReviewerInput();
         in.reviewer = reviewerId.toString();
         in.state = ReviewerState.CC;
         cApi.addReviewer(in);
@@ -3307,10 +3351,10 @@
 
     // Add the second user as cc to ensure that user took part of the change and can be added to the
     // attention set.
-    AddReviewerInput addReviewerInput = new AddReviewerInput();
-    addReviewerInput.reviewer = user2Id.toString();
-    addReviewerInput.state = ReviewerState.CC;
-    gApi.changes().id(change.getChangeId()).addReviewer(addReviewerInput);
+    ReviewerInput reviewerInput = new ReviewerInput();
+    reviewerInput.reviewer = user2Id.toString();
+    reviewerInput.state = ReviewerState.CC;
+    gApi.changes().id(change.getChangeId()).addReviewer(reviewerInput);
 
     input = new AttentionSetInput(user2Id.toString(), "reason 2");
     gApi.changes().id(change.getChangeId()).addToAttentionSet(input);
diff --git a/javatests/com/google/gerrit/server/query/change/BUILD b/javatests/com/google/gerrit/server/query/change/BUILD
index 43b9690..0f102a8 100644
--- a/javatests/com/google/gerrit/server/query/change/BUILD
+++ b/javatests/com/google/gerrit/server/query/change/BUILD
@@ -4,6 +4,7 @@
 ABSTRACT_QUERY_TEST = [
     "AbstractQueryChangesTest.java",
     "LuceneQueryChangesTest.java",
+    "FakeQueryChangesTest.java",
 ]
 
 java_library(
@@ -24,6 +25,7 @@
         "//java/com/google/gerrit/httpd",
         "//java/com/google/gerrit/index",
         "//java/com/google/gerrit/index:query_exception",
+        "//java/com/google/gerrit/index/testing",
         "//java/com/google/gerrit/lifecycle",
         "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/server/project/testing:project-test-util",
@@ -38,9 +40,11 @@
     ],
 )
 
-LUCENE_QUERY_TEST = [
+QUERY_TEST = [
     "LuceneQueryChangesLatestIndexVersionTest.java",
     "LuceneQueryChangesPreviousIndexVersionTest.java",
+    "FakeQueryChangesLatestIndexVersionTest.java",
+    "FakeQueryChangesPreviousIndexVersionTest.java",
 ]
 
 [junit_tests(
@@ -60,14 +64,14 @@
         "//lib/guice",
         "//lib/truth",
     ],
-) for f in LUCENE_QUERY_TEST]
+) for f in QUERY_TEST]
 
 junit_tests(
     name = "small_tests",
     size = "small",
     srcs = glob(
         ["*.java"],
-        exclude = ABSTRACT_QUERY_TEST + LUCENE_QUERY_TEST,
+        exclude = ABSTRACT_QUERY_TEST + QUERY_TEST,
     ),
     visibility = ["//visibility:public"],
     deps = [
diff --git a/javatests/com/google/gerrit/server/query/change/FakeQueryChangesLatestIndexVersionTest.java b/javatests/com/google/gerrit/server/query/change/FakeQueryChangesLatestIndexVersionTest.java
new file mode 100644
index 0000000..5496f56
--- /dev/null
+++ b/javatests/com/google/gerrit/server/query/change/FakeQueryChangesLatestIndexVersionTest.java
@@ -0,0 +1,29 @@
+// 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.testing.ConfigSuite;
+import com.google.gerrit.testing.IndexConfig;
+import org.eclipse.jgit.lib.Config;
+
+/**
+ * Test against {@link com.google.gerrit.index.testing.AbstractFakeIndex} using the latest schema.
+ */
+public class FakeQueryChangesLatestIndexVersionTest extends FakeQueryChangesTest {
+  @ConfigSuite.Default
+  public static Config defaultConfig() {
+    return IndexConfig.createForFake();
+  }
+}
diff --git a/javatests/com/google/gerrit/server/query/change/FakeQueryChangesPreviousIndexVersionTest.java b/javatests/com/google/gerrit/server/query/change/FakeQueryChangesPreviousIndexVersionTest.java
new file mode 100644
index 0000000..1610eca
--- /dev/null
+++ b/javatests/com/google/gerrit/server/query/change/FakeQueryChangesPreviousIndexVersionTest.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.server.query.change;
+
+import com.google.common.collect.Iterables;
+import com.google.gerrit.server.index.change.ChangeSchemaDefinitions;
+import com.google.gerrit.testing.ConfigSuite;
+import com.google.gerrit.testing.IndexConfig;
+import com.google.gerrit.testing.IndexVersions;
+import org.eclipse.jgit.lib.Config;
+
+/**
+ * Test against {@link com.google.gerrit.index.testing.AbstractFakeIndex} using the current schema.
+ */
+public class FakeQueryChangesPreviousIndexVersionTest extends FakeQueryChangesTest {
+  @ConfigSuite.Default
+  public static Config againstPreviousIndexVersion() {
+    // the current schema version is already tested by the inherited default config suite
+    return Iterables.getOnlyElement(
+        IndexVersions.asConfigMap(
+                ChangeSchemaDefinitions.INSTANCE,
+                IndexVersions.getWithoutLatest(ChangeSchemaDefinitions.INSTANCE),
+                "againstIndexVersion",
+                IndexConfig.createForFake())
+            .values());
+  }
+}
diff --git a/javatests/com/google/gerrit/server/query/change/FakeQueryChangesTest.java b/javatests/com/google/gerrit/server/query/change/FakeQueryChangesTest.java
new file mode 100644
index 0000000..1e23420
--- /dev/null
+++ b/javatests/com/google/gerrit/server/query/change/FakeQueryChangesTest.java
@@ -0,0 +1,35 @@
+// 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.testing.InMemoryModule;
+import com.google.inject.Guice;
+import com.google.inject.Injector;
+import org.eclipse.jgit.lib.Config;
+
+/**
+ * Test against {@link com.google.gerrit.index.testing.AbstractFakeIndex}. This test might seem
+ * obsolete, but it makes sure that the fake index implementation used in tests gives the same
+ * results as production indices.
+ */
+public abstract class FakeQueryChangesTest extends AbstractQueryChangesTest {
+  @Override
+  protected Injector createInjector() {
+    Config fakeConfig = new Config(config);
+    InMemoryModule.setDefaults(fakeConfig);
+    fakeConfig.setString("index", null, "type", "fake");
+    return Guice.createInjector(new InMemoryModule(fakeConfig));
+  }
+}
diff --git a/javatests/com/google/gerrit/server/query/group/AbstractQueryGroupsTest.java b/javatests/com/google/gerrit/server/query/group/AbstractQueryGroupsTest.java
index d760003..f392747 100644
--- a/javatests/com/google/gerrit/server/query/group/AbstractQueryGroupsTest.java
+++ b/javatests/com/google/gerrit/server/query/group/AbstractQueryGroupsTest.java
@@ -48,8 +48,8 @@
 import com.google.gerrit.server.account.AuthRequest;
 import com.google.gerrit.server.account.GroupCache;
 import com.google.gerrit.server.config.AllProjectsName;
+import com.google.gerrit.server.group.db.GroupDelta;
 import com.google.gerrit.server.group.db.GroupsUpdate;
-import com.google.gerrit.server.group.db.InternalGroupUpdate;
 import com.google.gerrit.server.index.group.GroupField;
 import com.google.gerrit.server.index.group.GroupIndex;
 import com.google.gerrit.server.index.group.GroupIndexCollection;
@@ -115,6 +115,8 @@
 
   protected abstract Injector createInjector();
 
+  protected void validateAssumptions() {}
+
   @Before
   public void setUpInjector() throws Exception {
     lifecycle = new LifecycleManager();
@@ -124,6 +126,7 @@
     lifecycle.start();
     initAfterLifecycleStart();
     setUpDatabase();
+    validateAssumptions();
   }
 
   @After
@@ -347,9 +350,8 @@
     // update group in the database so that group index is stale
     String newDescription = "barY";
     AccountGroup.UUID groupUuid = AccountGroup.uuid(group1.id);
-    InternalGroupUpdate groupUpdate =
-        InternalGroupUpdate.builder().setDescription(newDescription).build();
-    groupsUpdateProvider.get().updateGroupInNoteDb(groupUuid, groupUpdate);
+    GroupDelta groupDelta = GroupDelta.builder().setDescription(newDescription).build();
+    groupsUpdateProvider.get().updateGroupInNoteDb(groupUuid, groupDelta);
 
     assertQuery("description:" + group1.description, group1);
     assertQuery("description:" + newDescription);
diff --git a/javatests/com/google/gerrit/server/query/group/BUILD b/javatests/com/google/gerrit/server/query/group/BUILD
index e14350f..4b74325 100644
--- a/javatests/com/google/gerrit/server/query/group/BUILD
+++ b/javatests/com/google/gerrit/server/query/group/BUILD
@@ -29,8 +29,7 @@
     name = "lucene_query_test",
     size = "large",
     srcs = glob(
-        ["*.java"],
-        exclude = ABSTRACT_QUERY_TEST,
+        ["LuceneQueryGroupsTest.java"],
     ),
     visibility = ["//visibility:public"],
     deps = [
@@ -41,3 +40,20 @@
         "//lib/guice",
     ],
 )
+
+junit_tests(
+    name = "fake_query_test",
+    size = "large",
+    srcs = glob(
+        ["FakeQueryGroupsTest.java"],
+    ),
+    visibility = ["//visibility:public"],
+    deps = [
+        ":abstract_query_tests",
+        "//java/com/google/gerrit/server",
+        "//java/com/google/gerrit/testing:gerrit-test-util",
+        "//lib:jgit",
+        "//lib/guice",
+        "@truth//jar",
+    ],
+)
diff --git a/javatests/com/google/gerrit/server/query/group/FakeQueryGroupsTest.java b/javatests/com/google/gerrit/server/query/group/FakeQueryGroupsTest.java
new file mode 100644
index 0000000..e4f228a
--- /dev/null
+++ b/javatests/com/google/gerrit/server/query/group/FakeQueryGroupsTest.java
@@ -0,0 +1,49 @@
+// 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.group;
+
+import com.google.gerrit.server.index.group.GroupSchemaDefinitions;
+import com.google.gerrit.testing.ConfigSuite;
+import com.google.gerrit.testing.InMemoryModule;
+import com.google.gerrit.testing.IndexConfig;
+import com.google.gerrit.testing.IndexVersions;
+import com.google.inject.Guice;
+import com.google.inject.Injector;
+import java.util.List;
+import java.util.Map;
+import org.eclipse.jgit.lib.Config;
+
+public class FakeQueryGroupsTest extends AbstractQueryGroupsTest {
+  @ConfigSuite.Default
+  public static Config defaultConfig() {
+    return IndexConfig.createForFake();
+  }
+
+  @ConfigSuite.Configs
+  public static Map<String, Config> againstPreviousIndexVersion() {
+    // the current schema version is already tested by the inherited default config suite
+    List<Integer> schemaVersions = IndexVersions.getWithoutLatest(GroupSchemaDefinitions.INSTANCE);
+    return IndexVersions.asConfigMap(
+        GroupSchemaDefinitions.INSTANCE, schemaVersions, "againstIndexVersion", defaultConfig());
+  }
+
+  @Override
+  protected Injector createInjector() {
+    Config fakeConfig = new Config(config);
+    InMemoryModule.setDefaults(fakeConfig);
+    fakeConfig.setString("index", null, "type", "fake");
+    return Guice.createInjector(new InMemoryModule(fakeConfig));
+  }
+}
diff --git a/javatests/com/google/gerrit/server/query/project/AbstractQueryProjectsTest.java b/javatests/com/google/gerrit/server/query/project/AbstractQueryProjectsTest.java
index dfd7928..2317c7e 100644
--- a/javatests/com/google/gerrit/server/query/project/AbstractQueryProjectsTest.java
+++ b/javatests/com/google/gerrit/server/query/project/AbstractQueryProjectsTest.java
@@ -111,6 +111,8 @@
 
   protected abstract Injector createInjector();
 
+  protected void validateAssumptions() {}
+
   @Before
   public void setUpInjector() throws Exception {
     lifecycle = new LifecycleManager();
@@ -120,6 +122,7 @@
     lifecycle.start();
     initAfterLifecycleStart();
     setUpDatabase();
+    validateAssumptions();
   }
 
   @After
diff --git a/javatests/com/google/gerrit/server/query/project/BUILD b/javatests/com/google/gerrit/server/query/project/BUILD
index 984d824..a65306c 100644
--- a/javatests/com/google/gerrit/server/query/project/BUILD
+++ b/javatests/com/google/gerrit/server/query/project/BUILD
@@ -29,8 +29,7 @@
     name = "lucene_query_test",
     size = "large",
     srcs = glob(
-        ["*.java"],
-        exclude = ABSTRACT_QUERY_TEST,
+        ["LuceneQueryProjectsTest.java"],
     ),
     visibility = ["//visibility:public"],
     deps = [
@@ -41,3 +40,21 @@
         "//lib/guice",
     ],
 )
+
+junit_tests(
+    name = "fake_query_test",
+    size = "large",
+    srcs = glob(
+        ["FakeQueryProjectsTest.java"],
+    ),
+    visibility = ["//visibility:public"],
+    deps = [
+        ":abstract_query_tests",
+        "//java/com/google/gerrit/index/project",
+        "//java/com/google/gerrit/server",
+        "//java/com/google/gerrit/testing:gerrit-test-util",
+        "//lib:jgit",
+        "//lib/guice",
+        "@truth//jar",
+    ],
+)
diff --git a/javatests/com/google/gerrit/server/query/project/FakeQueryProjectsTest.java b/javatests/com/google/gerrit/server/query/project/FakeQueryProjectsTest.java
new file mode 100644
index 0000000..6fc0568
--- /dev/null
+++ b/javatests/com/google/gerrit/server/query/project/FakeQueryProjectsTest.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.server.query.project;
+
+import com.google.gerrit.index.project.ProjectSchemaDefinitions;
+import com.google.gerrit.testing.ConfigSuite;
+import com.google.gerrit.testing.InMemoryModule;
+import com.google.gerrit.testing.IndexConfig;
+import com.google.gerrit.testing.IndexVersions;
+import com.google.inject.Guice;
+import com.google.inject.Injector;
+import java.util.List;
+import java.util.Map;
+import org.eclipse.jgit.lib.Config;
+
+public class FakeQueryProjectsTest extends AbstractQueryProjectsTest {
+  @ConfigSuite.Default
+  public static Config defaultConfig() {
+    return IndexConfig.createForFake();
+  }
+
+  @ConfigSuite.Configs
+  public static Map<String, Config> againstPreviousIndexVersion() {
+    // the current schema version is already tested by the inherited default config suite
+    List<Integer> schemaVersions =
+        IndexVersions.getWithoutLatest(
+            com.google.gerrit.index.project.ProjectSchemaDefinitions.INSTANCE);
+    return IndexVersions.asConfigMap(
+        ProjectSchemaDefinitions.INSTANCE, schemaVersions, "againstIndexVersion", defaultConfig());
+  }
+
+  @Override
+  protected Injector createInjector() {
+    Config fakeConfig = new Config(config);
+    InMemoryModule.setDefaults(fakeConfig);
+    fakeConfig.setString("index", null, "type", "fake");
+    return Guice.createInjector(new InMemoryModule(fakeConfig));
+  }
+}
diff --git a/javatests/com/google/gerrit/server/restapi/change/ListChangeCommentsTest.java b/javatests/com/google/gerrit/server/restapi/change/ListChangeCommentsTest.java
index b3e0c56..44c3cef 100644
--- a/javatests/com/google/gerrit/server/restapi/change/ListChangeCommentsTest.java
+++ b/javatests/com/google/gerrit/server/restapi/change/ListChangeCommentsTest.java
@@ -125,9 +125,8 @@
   private static ChangeMessage newChangeMessage(String id, String message, String ts, String tag) {
     ChangeMessage.Key key = ChangeMessage.key(Change.id(1), id);
     ChangeMessage cm =
-        new ChangeMessage(key, null, Timestamp.valueOf("2000-01-01 00:00:" + ts), null);
-    cm.setMessage(message);
-    cm.setTag(tag);
+        ChangeMessage.create(
+            key, null, Timestamp.valueOf("2000-01-01 00:00:" + ts), null, message, null, tag);
     return cm;
   }
 
diff --git a/lib/LICENSE-CC-BY3.0-unported b/lib/LICENSE-CC-BY3.0-unported
deleted file mode 100644
index d2f2550..0000000
--- a/lib/LICENSE-CC-BY3.0-unported
+++ /dev/null
@@ -1,333 +0,0 @@
-link:http://creativecommons.org/licenses/by/3.0/[CC-BY 3.0]
-
-THE WORK (AS DEFINED BELOW) IS PROVIDED UNDER THE TERMS OF THIS
-CREATIVE COMMONS PUBLIC LICENSE ("CCPL" OR "LICENSE").  THE WORK IS
-PROTECTED BY COPYRIGHT AND/OR OTHER APPLICABLE LAW.  ANY USE OF THE
-WORK OTHER THAN AS AUTHORIZED UNDER THIS LICENSE OR COPYRIGHT LAW IS
-PROHIBITED.
-
-BY EXERCISING ANY RIGHTS TO THE WORK PROVIDED HERE, YOU ACCEPT AND
-AGREE TO BE BOUND BY THE TERMS OF THIS LICENSE.  TO THE EXTENT THIS
-LICENSE MAY BE CONSIDERED TO BE A CONTRACT, THE LICENSOR GRANTS YOU
-THE RIGHTS CONTAINED HERE IN CONSIDERATION OF YOUR ACCEPTANCE OF SUCH
-TERMS AND CONDITIONS.
-
-1.  Definitions
-
-  a.  "Adaptation" means a work based upon the Work, or upon the Work
-      and other pre-existing works, such as a translation, adaptation,
-      derivative work, arrangement of music or other alterations of a
-      literary or artistic work, or phonogram or performance and
-      includes cinematographic adaptations or any other form in which
-      the Work may be recast, transformed, or adapted including in any
-      form recognizably derived from the original, except that a work
-      that constitutes a Collection will not be considered an
-      Adaptation for the purpose of this License.  For the avoidance
-      of doubt, where the Work is a musical work, performance or
-      phonogram, the synchronization of the Work in timed-relation
-      with a moving image ("synching") will be considered an
-      Adaptation for the purpose of this License.
-
-  b.  "Collection" means a collection of literary or artistic works,
-      such as encyclopedias and anthologies, or performances,
-      phonograms or broadcasts, or other works or subject matter other
-      than works listed in Section 1(f) below, which, by reason of the
-      selection and arrangement of their contents, constitute
-      intellectual creations, in which the Work is included in its
-      entirety in unmodified form along with one or more other
-      contributions, each constituting separate and independent works
-      in themselves, which together are assembled into a collective
-      whole.  A work that constitutes a Collection will not be
-      considered an Adaptation (as defined above) for the purposes of
-      this License.
-
-  c.  "Distribute" means to make available to the public the original
-      and copies of the Work or Adaptation, as appropriate, through
-      sale or other transfer of ownership.
-
-  d.  "Licensor" means the individual, individuals, entity or entities
-      that offer(s) the Work under the terms of this License.
-
-  e.  "Original Author" means, in the case of a literary or artistic
-      work, the individual, individuals, entity or entities who
-      created the Work or if no individual or entity can be
-      identified, the publisher; and in addition (i) in the case of a
-      performance the actors, singers, musicians, dancers, and other
-      persons who act, sing, deliver, declaim, play in, interpret or
-      otherwise perform literary or artistic works or expressions of
-      folklore; (ii) in the case of a phonogram the producer being the
-      person or legal entity who first fixes the sounds of a
-      performance or other sounds; and, (iii) in the case of
-      broadcasts, the organization that transmits the broadcast.
-
-  f.  "Work" means the literary and/or artistic work offered under the
-      terms of this License including without limitation any
-      production in the literary, scientific and artistic domain,
-      whatever may be the mode or form of its expression including
-      digital form, such as a book, pamphlet and other writing; a
-      lecture, address, sermon or other work of the same nature; a
-      dramatic or dramatico-musical work; a choreographic work or
-      entertainment in dumb show; a musical composition with or
-      without words; a cinematographic work to which are assimilated
-      works expressed by a process analogous to cinematography; a work
-      of drawing, painting, architecture, sculpture, engraving or
-      lithography; a photographic work to which are assimilated works
-      expressed by a process analogous to photography; a work of
-      applied art; an illustration, map, plan, sketch or
-      three-dimensional work relative to geography, topography,
-      architecture or science; a performance; a broadcast; a
-      phonogram; a compilation of data to the extent it is protected
-      as a copyrightable work; or a work performed by a variety or
-      circus performer to the extent it is not otherwise considered a
-      literary or artistic work.
-
-  g.  "You" means an individual or entity exercising rights under this
-      License who has not previously violated the terms of this
-      License with respect to the Work, or who has received express
-      permission from the Licensor to exercise rights under this
-      License despite a previous violation.
-
-  h.  "Publicly Perform" means to perform public recitations of the
-      Work and to communicate to the public those public recitations,
-      by any means or process, including by wire or wireless means or
-      public digital performances; to make available to the public
-      Works in such a way that members of the public may access these
-      Works from a place and at a place individually chosen by them;
-      to perform the Work to the public by any means or process and
-      the communication to the public of the performances of the Work,
-      including by public digital performance; to broadcast and
-      rebroadcast the Work by any means including signs, sounds or
-      images.
-
-  i.  "Reproduce" means to make copies of the Work by any means
-      including without limitation by sound or visual recordings and
-      the right of fixation and reproducing fixations of the Work,
-      including storage of a protected performance or phonogram in
-      digital form or other electronic medium.
-
-2.  Fair Dealing Rights.  Nothing in this License is intended to
-    reduce, limit, or restrict any uses free from copyright or rights
-    arising from limitations or exceptions that are provided for in
-    connection with the copyright protection under copyright law or
-    other applicable laws.
-
-3.  License Grant.  Subject to the terms and conditions of this
-    License, Licensor hereby grants You a worldwide, royalty-free,
-    non-exclusive, perpetual (for the duration of the applicable
-    copyright) license to exercise the rights in the Work as stated
-    below:
-
-  a.  to Reproduce the Work, to incorporate the Work into one or more
-      Collections, and to Reproduce the Work as incorporated in the
-      Collections;
-
-  b.  to create and Reproduce Adaptations provided that any such
-      Adaptation, including any translation in any medium, takes
-      reasonable steps to clearly label, demarcate or otherwise
-      identify that changes were made to the original Work.  For
-      example, a translation could be marked "The original work was
-      translated from English to Spanish," or a modification could
-      indicate "The original work has been modified.";
-
-  c.  to Distribute and Publicly Perform the Work including as
-      incorporated in Collections; and,
-
-  d.  to Distribute and Publicly Perform Adaptations.
-
-  e.  For the avoidance of doubt:
-
-    i.   Non-waivable Compulsory License Schemes.  In those
-	     jurisdictions in which the right to collect royalties
-	     through any statutory or compulsory licensing scheme
-	     cannot be waived, the Licensor reserves the exclusive
-	     right to collect such royalties for any exercise by You
-	     of the rights granted under this License;
-
-    ii.  Waivable Compulsory License Schemes.  In those jurisdictions
-	     in which the right to collect royalties through any
-	     statutory or compulsory licensing scheme can be waived,
-	     the Licensor waives the exclusive right to collect such
-	     royalties for any exercise by You of the rights granted
-	     under this License; and,
-
-    iii. Voluntary License Schemes.  The Licensor waives the right to
-	     collect royalties, whether individually or, in the event
-	     that the Licensor is a member of a collecting society
-	     that administers voluntary licensing schemes, via that
-	     society, from any exercise by You of the rights granted
-	     under this License.
-
-The above rights may be exercised in all media and formats whether now
-known or hereafter devised.  The above rights include the right to
-make such modifications as are technically necessary to exercise the
-rights in other media and formats.  Subject to Section 8(f), all
-rights not expressly granted by Licensor are hereby reserved.
-
-4.  Restrictions.  The license granted in Section 3 above is expressly
-    made subject to and limited by the following restrictions:
-
-  a.  You may Distribute or Publicly Perform the Work only under the
-      terms of this License.  You must include a copy of, or the
-      Uniform Resource Identifier (URI) for, this License with every
-      copy of the Work You Distribute or Publicly Perform.  You may
-      not offer or impose any terms on the Work that restrict the
-      terms of this License or the ability of the recipient of the
-      Work to exercise the rights granted to that recipient under the
-      terms of the License.  You may not sublicense the Work.  You
-      must keep intact all notices that refer to this License and to
-      the disclaimer of warranties with every copy of the Work You
-      Distribute or Publicly Perform.  When You Distribute or Publicly
-      Perform the Work, You may not impose any effective technological
-      measures on the Work that restrict the ability of a recipient of
-      the Work from You to exercise the rights granted to that
-      recipient under the terms of the License.  This Section 4(a)
-      applies to the Work as incorporated in a Collection, but this
-      does not require the Collection apart from the Work itself to be
-      made subject to the terms of this License.  If You create a
-      Collection, upon notice from any Licensor You must, to the
-      extent practicable, remove from the Collection any credit as
-      required by Section 4(b), as requested.  If You create an
-      Adaptation, upon notice from any Licensor You must, to the
-      extent practicable, remove from the Adaptation any credit as
-      required by Section 4(b), as requested.
-
-  b.  If You Distribute, or Publicly Perform the Work or any
-      Adaptations or Collections, You must, unless a request has been
-      made pursuant to Section 4(a), keep intact all copyright notices
-      for the Work and provide, reasonable to the medium or means You
-      are utilizing: (i) the name of the Original Author (or
-      pseudonym, if applicable) if supplied, and/or if the Original
-      Author and/or Licensor designate another party or parties (e.g.,
-      a sponsor institute, publishing entity, journal) for attribution
-      ("Attribution Parties") in Licensor's copyright notice, terms of
-      service or by other reasonable means, the name of such party or
-      parties; (ii) the title of the Work if supplied; (iii) to the
-      extent reasonably practicable, the URI, if any, that Licensor
-      specifies to be associated with the Work, unless such URI does
-      not refer to the copyright notice or licensing information for
-      the Work; and (iv) , consistent with Section 3(b), in the case
-      of an Adaptation, a credit identifying the use of the Work in
-      the Adaptation (e.g., "French translation of the Work by
-      Original Author," or "Screenplay based on original Work by
-      Original Author").  The credit required by this Section 4 (b)
-      may be implemented in any reasonable manner; provided, however,
-      that in the case of a Adaptation or Collection, at a minimum
-      such credit will appear, if a credit for all contributing
-      authors of the Adaptation or Collection appears, then as part of
-      these credits and in a manner at least as prominent as the
-      credits for the other contributing authors.  For the avoidance
-      of doubt, You may only use the credit required by this Section
-      for the purpose of attribution in the manner set out above and,
-      by exercising Your rights under this License, You may not
-      implicitly or explicitly assert or imply any connection with,
-      sponsorship or endorsement by the Original Author, Licensor
-      and/or Attribution Parties, as appropriate, of You or Your use
-      of the Work, without the separate, express prior written
-      permission of the Original Author, Licensor and/or Attribution
-      Parties.
-
-  c.  Except as otherwise agreed in writing by the Licensor or as may
-      be otherwise permitted by applicable law, if You Reproduce,
-      Distribute or Publicly Perform the Work either by itself or as
-      part of any Adaptations or Collections, You must not distort,
-      mutilate, modify or take other derogatory action in relation to
-      the Work which would be prejudicial to the Original Author's
-      honor or reputation.  Licensor agrees that in those
-      jurisdictions (e.g.  Japan), in which any exercise of the right
-      granted in Section 3(b) of this License (the right to make
-      Adaptations) would be deemed to be a distortion, mutilation,
-      modification or other derogatory action prejudicial to the
-      Original Author's honor and reputation, the Licensor will waive
-      or not assert, as appropriate, this Section, to the fullest
-      extent permitted by the applicable national law, to enable You
-      to reasonably exercise Your right under Section 3(b) of this
-      License (right to make Adaptations) but not otherwise.
-
-5.  Representations, Warranties and Disclaimer
-
-UNLESS OTHERWISE MUTUALLY AGREED TO BY THE PARTIES IN WRITING,
-LICENSOR OFFERS THE WORK AS-IS AND MAKES NO REPRESENTATIONS OR
-WARRANTIES OF ANY KIND CONCERNING THE WORK, EXPRESS, IMPLIED,
-STATUTORY OR OTHERWISE, INCLUDING, WITHOUT LIMITATION, WARRANTIES OF
-TITLE, MERCHANTIBILITY, FITNESS FOR A PARTICULAR PURPOSE,
-NONINFRINGEMENT, OR THE ABSENCE OF LATENT OR OTHER DEFECTS, ACCURACY,
-OR THE PRESENCE OF ABSENCE OF ERRORS, WHETHER OR NOT DISCOVERABLE.
-SOME JURISDICTIONS DO NOT ALLOW THE EXCLUSION OF IMPLIED WARRANTIES,
-SO SUCH EXCLUSION MAY NOT APPLY TO YOU.
-
-6.  Limitation on Liability.  EXCEPT TO THE EXTENT REQUIRED BY
-    APPLICABLE LAW, IN NO EVENT WILL LICENSOR BE LIABLE TO YOU ON ANY
-    LEGAL THEORY FOR ANY SPECIAL, INCIDENTAL, CONSEQUENTIAL, PUNITIVE
-    OR EXEMPLARY DAMAGES ARISING OUT OF THIS LICENSE OR THE USE OF THE
-    WORK, EVEN IF LICENSOR HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH
-    DAMAGES.
-
-7.  Termination
-
-  a.  This License and the rights granted hereunder will terminate
-      automatically upon any breach by You of the terms of this
-      License.  Individuals or entities who have received Adaptations
-      or Collections from You under this License, however, will not
-      have their licenses terminated provided such individuals or
-      entities remain in full compliance with those licenses.
-      Sections 1, 2, 5, 6, 7, and 8 will survive any termination of
-      this License.
-
-  b.  Subject to the above terms and conditions, the license granted
-      here is perpetual (for the duration of the applicable copyright
-      in the Work).  Notwithstanding the above, Licensor reserves the
-      right to release the Work under different license terms or to
-      stop distributing the Work at any time; provided, however that
-      any such election will not serve to withdraw this License (or
-      any other license that has been, or is required to be, granted
-      under the terms of this License), and this License will continue
-      in full force and effect unless terminated as stated above.
-
-8. Miscellaneous
-
-  a.  Each time You Distribute or Publicly Perform the Work or a
-      Collection, the Licensor offers to the recipient a license to
-      the Work on the same terms and conditions as the license granted
-      to You under this License.
-
-  b.  Each time You Distribute or Publicly Perform an Adaptation,
-      Licensor offers to the recipient a license to the original Work
-      on the same terms and conditions as the license granted to You
-      under this License.
-
-  c.  If any provision of this License is invalid or unenforceable
-      under applicable law, it shall not affect the validity or
-      enforceability of the remainder of the terms of this License,
-      and without further action by the parties to this agreement,
-      such provision shall be reformed to the minimum extent necessary
-      to make such provision valid and enforceable.
-
-  d.  No term or provision of this License shall be deemed waived and
-      no breach consented to unless such waiver or consent shall be in
-      writing and signed by the party to be charged with such waiver
-      or consent.
-
-  e.  This License constitutes the entire agreement between the
-      parties with respect to the Work licensed here.  There are no
-      understandings, agreements or representations with respect to
-      the Work not specified here.  Licensor shall not be bound by any
-      additional provisions that may appear in any communication from
-      You.  This License may not be modified without the mutual
-      written agreement of the Licensor and You.
-
-  f.  The rights granted under, and the subject matter referenced, in
-      this License were drafted utilizing the terminology of the Berne
-      Convention for the Protection of Literary and Artistic Works (as
-      amended on September 28, 1979), the Rome Convention of 1961, the
-      WIPO Copyright Treaty of 1996, the WIPO Performances and
-      Phonograms Treaty of 1996 and the Universal Copyright Convention
-      (as revised on July 24, 1971).  These rights and subject matter
-      take effect in the relevant jurisdiction in which the License
-      terms are sought to be enforced according to the corresponding
-      provisions of the implementation of those treaty provisions in
-      the applicable national law.  If the standard suite of rights
-      granted under applicable copyright law includes additional
-      rights not granted under this License, such additional rights
-      are deemed to be included in the License; this License is not
-      intended to restrict the license of any rights under applicable
-      law.
diff --git a/lib/LICENSE-OFL1.1 b/lib/LICENSE-OFL1.1
deleted file mode 100644
index 0754257..0000000
--- a/lib/LICENSE-OFL1.1
+++ /dev/null
@@ -1,93 +0,0 @@
-Copyright 2010, 2012 Adobe Systems Incorporated (http://www.adobe.com/), with Reserved Font Name 'Source'. All Rights Reserved. Source is a trademark of Adobe Systems Incorporated in the United States and/or other countries.
-
-This Font Software is licensed under the SIL Open Font License, Version 1.1.
-
-This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL
-
-
------------------------------------------------------------
-SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
------------------------------------------------------------
-
-PREAMBLE
-The goals of the Open Font License (OFL) are to stimulate worldwide
-development of collaborative font projects, to support the font creation
-efforts of academic and linguistic communities, and to provide a free and
-open framework in which fonts may be shared and improved in partnership
-with others.
-
-The OFL allows the licensed fonts to be used, studied, modified and
-redistributed freely as long as they are not sold by themselves. The
-fonts, including any derivative works, can be bundled, embedded,
-redistributed and/or sold with any software provided that any reserved
-names are not used by derivative works. The fonts and derivatives,
-however, cannot be released under any other type of license. The
-requirement for fonts to remain under this license does not apply
-to any document created using the fonts or their derivatives.
-
-DEFINITIONS
-"Font Software" refers to the set of files released by the Copyright
-Holder(s) under this license and clearly marked as such. This may
-include source files, build scripts and documentation.
-
-"Reserved Font Name" refers to any names specified as such after the
-copyright statement(s).
-
-"Original Version" refers to the collection of Font Software components as
-distributed by the Copyright Holder(s).
-
-"Modified Version" refers to any derivative made by adding to, deleting,
-or substituting -- in part or in whole -- any of the components of the
-Original Version, by changing formats or by porting the Font Software to a
-new environment.
-
-"Author" refers to any designer, engineer, programmer, technical
-writer or other person who contributed to the Font Software.
-
-PERMISSION & CONDITIONS
-Permission is hereby granted, free of charge, to any person obtaining
-a copy of the Font Software, to use, study, copy, merge, embed, modify,
-redistribute, and sell modified and unmodified copies of the Font
-Software, subject to the following conditions:
-
-1) Neither the Font Software nor any of its individual components,
-in Original or Modified Versions, may be sold by itself.
-
-2) Original or Modified Versions of the Font Software may be bundled,
-redistributed and/or sold with any software, provided that each copy
-contains the above copyright notice and this license. These can be
-included either as stand-alone text files, human-readable headers or
-in the appropriate machine-readable metadata fields within text or
-binary files as long as those fields can be easily viewed by the user.
-
-3) No Modified Version of the Font Software may use the Reserved Font
-Name(s) unless explicit written permission is granted by the corresponding
-Copyright Holder. This restriction only applies to the primary font name as
-presented to the users.
-
-4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
-Software shall not be used to promote, endorse or advertise any
-Modified Version, except to acknowledge the contribution(s) of the
-Copyright Holder(s) and the Author(s) or with their explicit written
-permission.
-
-5) The Font Software, modified or unmodified, in part or in whole,
-must be distributed entirely under this license, and must not be
-distributed under any other license. The requirement for fonts to
-remain under this license does not apply to any document created
-using the Font Software.
-
-TERMINATION
-This license becomes null and void if any of the above conditions are
-not met.
-
-DISCLAIMER
-THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
-EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
-MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
-OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
-COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
-INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
-DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
-FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
-OTHER DEALINGS IN THE FONT SOFTWARE.
diff --git a/lib/LICENSE-ba-linkify b/lib/LICENSE-ba-linkify
deleted file mode 100644
index 93672f9..0000000
--- a/lib/LICENSE-ba-linkify
+++ /dev/null
@@ -1,22 +0,0 @@
-Copyright (c) 2009 "Cowboy" Ben Alman
-
-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.
diff --git a/lib/LICENSE-codemirror-minified b/lib/LICENSE-codemirror-minified
deleted file mode 100644
index 89f23625..0000000
--- a/lib/LICENSE-codemirror-minified
+++ /dev/null
@@ -1,22 +0,0 @@
-The MIT License (MIT)
-
-Copyright (c) 2016 Marijn Haverbeke <marijnh@gmail.com> and others
-Copyright (c) 2016 Michael Zhou <zhoumotongxue008@gmail.com>
-
-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.
diff --git a/lib/LICENSE-es6-promise b/lib/LICENSE-es6-promise
deleted file mode 100644
index 954ec59..0000000
--- a/lib/LICENSE-es6-promise
+++ /dev/null
@@ -1,19 +0,0 @@
-Copyright (c) 2014 Yehuda Katz, Tom Dale, Stefan Penner and contributors
-
-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.
diff --git a/lib/LICENSE-fetch b/lib/LICENSE-fetch
deleted file mode 100644
index 0e319d5..0000000
--- a/lib/LICENSE-fetch
+++ /dev/null
@@ -1,20 +0,0 @@
-Copyright (c) 2014-2016 GitHub, Inc.
-
-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.
diff --git a/lib/LICENSE-moment b/lib/LICENSE-moment
deleted file mode 100644
index 9ee5374..0000000
--- a/lib/LICENSE-moment
+++ /dev/null
@@ -1,22 +0,0 @@
-Copyright (c) 2011-2016 Tim Wood, Iskren Chernev, Moment.js contributors
-
-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.
diff --git a/lib/LICENSE-page.js b/lib/LICENSE-page.js
deleted file mode 100644
index 78152a9..0000000
--- a/lib/LICENSE-page.js
+++ /dev/null
@@ -1,20 +0,0 @@
-(The MIT License)
-
-Copyright (c) 2012 TJ Holowaychuk <tj@vision-media.ca>
-
-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.
diff --git a/lib/LICENSE-polymer b/lib/LICENSE-polymer
deleted file mode 100644
index 322c5a8..0000000
--- a/lib/LICENSE-polymer
+++ /dev/null
@@ -1,27 +0,0 @@
-Copyright (c) 2014 The Polymer Authors. All rights reserved.
-
-Redistribution and use in source and binary forms, with or without
-modification, are permitted provided that the following conditions are
-met:
-
-   * Redistributions of source code must retain the above copyright
-notice, this list of conditions and the following disclaimer.
-   * Redistributions in binary form must reproduce the above
-copyright notice, this list of conditions and the following disclaimer
-in the documentation and/or other materials provided with the
-distribution.
-   * Neither the name of Google Inc. nor the names of its
-contributors may be used to endorse or promote products derived from
-this software without specific prior written permission.
-
-THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
-"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
-LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
-A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
-OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
-SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
-LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
-DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
-THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
-(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
-OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/lib/LICENSE-promise-polyfill b/lib/LICENSE-promise-polyfill
deleted file mode 100644
index 6f7c0123..0000000
--- a/lib/LICENSE-promise-polyfill
+++ /dev/null
@@ -1,20 +0,0 @@
-Copyright (c) 2014 Taylor Hakes
-Copyright (c) 2014 Forbes Lindesay
-
-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.
diff --git a/lib/LICENSE-resemblejs b/lib/LICENSE-resemblejs
deleted file mode 100644
index b265c8a..0000000
--- a/lib/LICENSE-resemblejs
+++ /dev/null
@@ -1,18 +0,0 @@
-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.
diff --git a/lib/LICENSE-shadycss b/lib/LICENSE-shadycss
deleted file mode 100644
index 0fe5c52..0000000
--- a/lib/LICENSE-shadycss
+++ /dev/null
@@ -1,20 +0,0 @@
-# License
-
-Everything in this repo is BSD style license unless otherwise specified.
-
-Copyright (c) 2015 The Polymer Authors. All rights reserved.
-
-Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
-
-* Redistributions of source code must retain the above copyright
-notice, this list of conditions and the following disclaimer.
-* Redistributions in binary form must reproduce the above
-copyright notice, this list of conditions and the following disclaimer
-in the documentation and/or other materials provided with the
-distribution.
-* Neither the name of Google Inc. nor the names of its
-contributors may be used to endorse or promote products derived from
-this software without specific prior written permission.
-
-THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
-
diff --git a/lib/js/BUILD b/lib/js/BUILD
index 106aabb..82089bd 100644
--- a/lib/js/BUILD
+++ b/lib/js/BUILD
@@ -1,4 +1,4 @@
-load("//tools/bzl:js.bzl", "bower_component", "js_component")
+load("//tools/bzl:js.bzl", "js_component")
 
 package(default_visibility = ["//visibility:public"])
 
@@ -22,15 +22,3 @@
     srcs = ["//lib/ba-linkify:ba-linkify.js"],
     license = "//lib:LICENSE-ba-linkify",
 )
-
-##TODO: remove after plugins migration to npm
-bower_component(
-    name = "codemirror-minified",
-    license = "//lib:LICENSE-codemirror-minified",
-)
-
-bower_component(
-    name = "resemblejs",
-    license = "//lib:LICENSE-resemblejs",
-)
-#End of removal
diff --git a/lib/js/npm.bzl b/lib/js/npm.bzl
deleted file mode 100644
index 5a6a8c0..0000000
--- a/lib/js/npm.bzl
+++ /dev/null
@@ -1,11 +0,0 @@
-NPM_VERSIONS = {
-    "bower": "1.8.8",
-    "crisper": "2.0.2",
-    "polymer-bundler": "4.0.9",
-}
-
-NPM_SHA1S = {
-    "bower": "82544be34a33aeae7efb8bdf9905247b2cffa985",
-    "crisper": "7183c58cea33632fb036c91cefd1b43e390d22a2",
-    "polymer-bundler": "c80c9815690d76656d1fa6a231481850b4fa3874",
-}
diff --git a/package.json b/package.json
index 320b196..a3329a1 100644
--- a/package.json
+++ b/package.json
@@ -3,9 +3,9 @@
   "version": "3.1.0-SNAPSHOT",
   "description": "Gerrit Code Review",
   "dependencies": {
-    "@bazel/rollup": "^3.2.3",
-    "@bazel/terser": "^3.2.3",
-    "@bazel/typescript": "^3.2.3"
+    "@bazel/rollup": "^3.5.0",
+    "@bazel/terser": "^3.5.0",
+    "@bazel/typescript": "^3.5.0"
   },
   "devDependencies": {
     "@typescript-eslint/eslint-plugin": "^4.22.0",
diff --git a/plugins/codemirror-editor b/plugins/codemirror-editor
index 7c94eb2..42d5fe0 160000
--- a/plugins/codemirror-editor
+++ b/plugins/codemirror-editor
@@ -1 +1 @@
-Subproject commit 7c94eb2fd3cdea33a200ae7c73c19777ca865a41
+Subproject commit 42d5fe041ee2ef6be579c0085396fa5e60889222
diff --git a/plugins/delete-project b/plugins/delete-project
index 549de03..7f2f1c5 160000
--- a/plugins/delete-project
+++ b/plugins/delete-project
@@ -1 +1 @@
-Subproject commit 549de033d60b13aaeef45ce5c4bf42be39506268
+Subproject commit 7f2f1c5961f89c7f44ac4a26bf8e035db5e70e0c
diff --git a/plugins/download-commands b/plugins/download-commands
index 5bd359c..774e915 160000
--- a/plugins/download-commands
+++ b/plugins/download-commands
@@ -1 +1 @@
-Subproject commit 5bd359c08e10b93d2c08762f75cde01a14e45fc6
+Subproject commit 774e9159128a72a76a0b226033b038c8f24fd88b
diff --git a/plugins/replication b/plugins/replication
index 7d9ba0d..dc9bb2e 160000
--- a/plugins/replication
+++ b/plugins/replication
@@ -1 +1 @@
-Subproject commit 7d9ba0d543e8212b73d8cf8e2e2d5764b63b7678
+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/Polymer2.md b/polygerrit-ui/Polymer2.md
deleted file mode 100644
index e2a9124..0000000
--- a/polygerrit-ui/Polymer2.md
+++ /dev/null
@@ -1,19 +0,0 @@
-Note: Gerrit has moved to polymer 3 as of submitted of https://gerrit-review.googlesource.com/q/topic:%22bower+to+npm+packages+switch%22+(status:open%20OR%20status:merged).
-
-The change is backward compatible, so no code change needed to support all plugins, but we would highly recommend to start moving to latest polymer 3 for all plugins, check out [Polymer3.md](./Polymer3.md) for more insights.
-
-## Polymer 2 upgrade
-
-Gerrit is updating to use polymer 2 from polymer 1 by following the [Polymer 2.0 upgrade guide](https://polymer-library.polymer-project.org/2.0/docs/upgrade).
-
-Polymer 2 contains several breaking changes that may affect some of the UI features and plugins. One of the biggest change is to have the shadow DOM enabled. This will affect how you query elements inside of your component, how css style works within and across components, and several other usages.
-
-If you are owner of any plugins, please start following the [Polymer 2.0 upgrade guide](https://polymer-library.polymer-project.org/2.0/docs/upgrade) to migrate your plugins to be polymer 2 ready.
-
-If you notice any issues or need help with anything, don't hesitate to report to us [here](https://bugs.chromium.org/p/gerrit/issues/list).
-
-
-### Related resources
-
-- [Polymer 2.0 upgrade guide](https://polymer-library.polymer-project.org/2.0/docs/upgrade)
-- [Polymer Shadow DOM](https://polymer-library.polymer-project.org/2.0/docs/devguide/shadow-dom)
diff --git a/polygerrit-ui/Polymer3.md b/polygerrit-ui/Polymer3.md
deleted file mode 100644
index 186f0f4..0000000
--- a/polygerrit-ui/Polymer3.md
+++ /dev/null
@@ -1,25 +0,0 @@
-## Gerrit in Polymer 3
-
-Gerrit has migrated to polymer 3 as of submitted of submitted of https://gerrit-review.googlesource.com/q/topic:%22bower+to+npm+packages+switch%22+(status:open%20OR%20status:merged).
-
-## Polymer 3 vs Polymer 2
-
-The biggest difference between 2 and 3 is the changing of package management from bower to npm and also replaced the html imports with es6 imports so we no longer need templates in separate `html` files for polymer components.
-
-### How that impact plugins
-
-As of now, we still support all syntax in Polymer 2 and most from Polymer 1 with the [legacy layer](https://polymer-library.polymer-project.org/3.0/docs/devguide/legacy-elements). But we do plan to remove those in the future.
-
-So we recommend all plugin owners to start migrating to Polymer 3 for your plugins. You can refer more about polymer 3 from the related resources section.
-
-To get inspirations, check out our [samples here](https://gerrit.googlesource.com/gerrit/+/master/polygerrit-ui/app/samples).
-
-### Plugin dependencies
-
-Since most of Gerrit plugins are treated as sub modules and part of the Gerrit workspace when develop, dependencies of plugins are also defined and installed from Gerrit WORKSPACE, currently most of them are `bower_archives`. When moving to npm, if your plugin requires dependencies, you can have them added to your plugin's `package.json` and then link that file to `plugins/package.json` in gerrit.
-Then use `@plugins_npm//:node_modules` to make sure `rollup_bundle` knows the right place to look for. More examples from `image-diff` plugin, [change 271672](https://gerrit-review.googlesource.com/c/plugins/image-diff/+/271672).
-
-### Related resources
-
-- [Polymer 3.0 upgrade guide](https://polymer-library.polymer-project.org/3.0/docs/upgrade)
--[What's new in Polymer 3.0](https://polymer-library.polymer-project.org/3.0/docs/about_30)
\ No newline at end of file
diff --git a/polygerrit-ui/app/api/admin.ts b/polygerrit-ui/app/api/admin.ts
index a7b549d..0606153 100644
--- a/polygerrit-ui/app/api/admin.ts
+++ b/polygerrit-ui/app/api/admin.ts
@@ -16,13 +16,13 @@
  */
 
 /** Interface for menu link */
-export interface MenuLink {
+export declare interface MenuLink {
   text: string;
   url: string;
   capability: string | null;
 }
 
-export interface AdminPluginApi {
+export declare interface AdminPluginApi {
   addMenuLink(text: string, url: string, capability?: string): void;
 
   getMenuLinks(): MenuLink[];
diff --git a/polygerrit-ui/app/api/annotation.ts b/polygerrit-ui/app/api/annotation.ts
index bd4f399..ad0846d 100644
--- a/polygerrit-ui/app/api/annotation.ts
+++ b/polygerrit-ui/app/api/annotation.ts
@@ -36,7 +36,7 @@
   change?: unknown
 ) => Promise<Array<CoverageRange>>;
 
-export interface AnnotationPluginApi {
+export declare interface AnnotationPluginApi {
   /**
    * The specified function will be called when a gr-diff component is built,
    * and feeds the returned coverage data into the diff. Optional.
diff --git a/polygerrit-ui/app/api/attribute-helper.ts b/polygerrit-ui/app/api/attribute-helper.ts
index cd52259..d813beb 100644
--- a/polygerrit-ui/app/api/attribute-helper.ts
+++ b/polygerrit-ui/app/api/attribute-helper.ts
@@ -15,7 +15,7 @@
  * limitations under the License.
  */
 
-export interface AttributeHelperPluginApi {
+export declare interface AttributeHelperPluginApi {
   /**
    * Binds callback to property updates.
    *
diff --git a/polygerrit-ui/app/api/change-actions.ts b/polygerrit-ui/app/api/change-actions.ts
index 792f31e..2ce697a 100644
--- a/polygerrit-ui/app/api/change-actions.ts
+++ b/polygerrit-ui/app/api/change-actions.ts
@@ -16,7 +16,7 @@
  */
 import {HttpMethod} from './rest';
 
-export interface ActionInfo {
+export declare interface ActionInfo {
   method?: HttpMethod;
   label?: string;
   title?: string;
@@ -59,6 +59,7 @@
   UNIGNORE = 'unignore',
   UNREVIEWED = 'unreviewed',
   WIP = 'wip',
+  INCLUDED_IN = 'includedIn',
 }
 
 export enum RevisionActions {
@@ -70,7 +71,7 @@
 
 export type PrimaryActionKey = ChangeActions | RevisionActions;
 
-export interface ChangeActionsPluginApi {
+export declare interface ChangeActionsPluginApi {
   addPrimaryActionKey(key: PrimaryActionKey): void;
 
   removePrimaryActionKey(key: string): void;
diff --git a/polygerrit-ui/app/api/change-reply.ts b/polygerrit-ui/app/api/change-reply.ts
index 6016004..bcbdc61 100644
--- a/polygerrit-ui/app/api/change-reply.ts
+++ b/polygerrit-ui/app/api/change-reply.ts
@@ -14,17 +14,17 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-export interface LabelsChangedDetail {
+export declare interface LabelsChangedDetail {
   name: string;
   value: string;
 }
-export interface ValueChangedDetail {
+export declare interface ValueChangedDetail {
   value: string;
 }
 export type ReplyChangedCallback = (text: string) => void;
 export type LabelsChangedCallback = (detail: LabelsChangedDetail) => void;
 
-export interface ChangeReplyPluginApi {
+export declare interface ChangeReplyPluginApi {
   getLabelValue(label: string): string;
 
   setLabelValue(label: string, value: string): void;
diff --git a/polygerrit-ui/app/api/checks.ts b/polygerrit-ui/app/api/checks.ts
index ee4ea9e..2682158 100644
--- a/polygerrit-ui/app/api/checks.ts
+++ b/polygerrit-ui/app/api/checks.ts
@@ -14,8 +14,9 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
+import {CommentRange} from './core';
 
-export interface ChecksPluginApi {
+export declare interface ChecksPluginApi {
   /**
    * Must only be called once. You cannot register twice. You cannot unregister.
    */
@@ -29,7 +30,7 @@
   announceUpdate(): void;
 }
 
-export interface ChecksApiConfig {
+export declare interface ChecksApiConfig {
   /**
    * How often should the provider be called for new CheckData while the user
    * navigates change related pages and the browser tab remains visible?
@@ -38,7 +39,7 @@
   fetchPollingIntervalSeconds: number;
 }
 
-export interface ChangeData {
+export declare interface ChangeData {
   changeNumber: number;
   patchsetNumber: number;
   patchsetSha: string;
@@ -48,7 +49,7 @@
   changeInfo: unknown;
 }
 
-export interface ChecksProvider {
+export declare interface ChecksProvider {
   /**
    * Gerrit calls this method when ...
    * - ... the change or diff page is loaded.
@@ -59,7 +60,7 @@
   fetch(change: ChangeData): Promise<FetchResponse>;
 }
 
-export interface FetchResponse {
+export declare interface FetchResponse {
   responseCode: ResponseCode;
 
   /** Only relevant when the responseCode is ERROR. */
@@ -102,7 +103,7 @@
  * runs are completed the users' interest shifts to results: What do I have to
  * fix? The only actions that can be associated with runs are RUN and CANCEL.
  */
-export interface CheckRun {
+export declare interface CheckRun {
   /**
    * Gerrit requests check runs and results from the plugin by change number and
    * patchset number. So these two properties can as well be left empty when
@@ -221,17 +222,24 @@
   results?: CheckResult[];
 }
 
-export interface Action {
+export declare interface Action {
   name: string;
   tooltip?: string;
   /**
-   * TODO: Maybe drop this property? Do we really need it?
-   *
    * Primary actions will get a more prominent treatment in the UI. For example
    * primary actions might be rendered as buttons versus just menu entries in
    * an overflow menu.
    */
-  primary: boolean;
+  primary?: boolean;
+  /**
+   * Renders the action button in a disabled state. That can be useful for
+   * actions that are present most of the time, but sometimes don't apply. Then
+   * a grayed out button with a tooltip makes it easier for the user to
+   * understand why an expected action is not available. The tooltip should then
+   * contain a message about why the disabled state was set, not just about what
+   * the action would normally do.
+   */
+  disabled?: boolean;
   callback: ActionCallback;
 }
 
@@ -272,7 +280,7 @@
  * If `message` is empty or undefined, then the `Triggering ...` toast will just
  * be hidden and no further toast will be shown.
  */
-export interface ActionResult {
+export declare interface ActionResult {
   /** An empty errorMessage means success. */
   message?: string;
   /**
@@ -290,7 +298,7 @@
   COMPLETED = 'COMPLETED',
 }
 
-export interface CheckResult {
+export declare interface CheckResult {
   /**
    * An optional opaque identifier not used by Gerrit directly, but might be
    * used by plugin extensions and callbacks.
@@ -298,6 +306,11 @@
   externalId?: string;
 
   /**
+   * SUCCESS: Indicates that some build, test or check is passing. A COMPLETED
+   *          run without results will also be treated as "passing" and will get
+   *          an artificial SUCCESS result. But you can also make this explicit,
+   *          which also allows one run to have multiple "passing" results,
+   *          maybe along with results of other categories.
    * INFO:    The user will typically not bother to look into this category,
    *          only for looking up something that they are searching for. Can be
    *          used for reporting secondary metrics and analysis, or a wider
@@ -370,6 +383,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
@@ -383,12 +401,13 @@
 }
 
 export enum Category {
+  SUCCESS = 'SUCCESS',
   INFO = 'INFO',
   WARNING = 'WARNING',
   ERROR = 'ERROR',
 }
 
-export interface Tag {
+export declare interface Tag {
   name: string;
   tooltip?: string;
   color?: TagColor;
@@ -404,13 +423,11 @@
   BROWN = 'brown',
 }
 
-export interface Link {
+export declare interface Link {
   /** Must begin with 'http'. */
   url: string;
   tooltip?: string;
   /**
-   * TODO: Maybe drop this property? Do we really need it?
-   *
    * Primary links will get a more prominent treatment in the UI, e.g. being
    * always visible in the results table or also showing up in the change page
    * summary of checks.
@@ -419,6 +436,11 @@
   icon: LinkIcon;
 }
 
+export declare interface CodePointer {
+  path: string;
+  range: CommentRange;
+}
+
 export enum LinkIcon {
   EXTERNAL = 'external',
   IMAGE = 'image',
@@ -427,4 +449,5 @@
   DOWNLOAD_MOBILE = 'download_mobile',
   HELP_PAGE = 'help_page',
   REPORT_BUG = 'report_bug',
+  CODE = 'code',
 }
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 9cd8cb3..ad83fb8 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
@@ -53,6 +53,35 @@
 }
 
 /**
+ * Represents a syntax block in a code (e.g. method, function, class, if-else).
+ */
+export declare interface SyntaxBlock {
+  /** Name of the block (e.g. name of the method/class)*/
+  name: string;
+  /** Where does this block syntatically starts and ends (line number and column).*/
+  range: {
+    /** first line of the block (1-based inclusive). */
+    start_line: number;
+    /**
+     * column of the range start inside the first line (e.g. "{" character ending a function/method)
+     * (1-based inclusive).
+     */
+    start_column: number;
+    /**
+     * last line of the block (1-based inclusive).
+     */
+    end_line: number;
+    /**
+     * column of the block end inside the end line (e.g. "}" character ending a function/method)
+     * (1-based inclusive).
+     */
+    end_column: number;
+  };
+  /** Sub-blocks of the current syntax block (e.g. methods of a class) */
+  children: SyntaxBlock[];
+}
+
+/**
  * The DiffFileMetaInfo entity contains meta information about a file diff.
  * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#diff-file-meta-info
  */
@@ -65,6 +94,12 @@
   lines: number;
   // TODO: Not documented.
   language?: string;
+  /**
+   * The first level of syntax blocks tree (outline) within the current file.
+   * It contains an hierarchical structure where each block contains its
+   * sub-blocks (children).
+   */
+  syntax_tree?: SyntaxBlock[];
 }
 
 export declare type ChangeType =
@@ -178,6 +213,7 @@
   disable_context_control_buttons?: boolean;
   show_file_comment_button?: boolean;
   hide_line_length_indicator?: boolean;
+  use_block_expansion?: boolean;
 }
 
 /**
@@ -251,6 +287,46 @@
   lineNum: LineNumber;
 }
 
+export declare interface LineNumberEventDetail {
+  side: Side;
+  lineNum: LineNumber;
+}
+
+/** All types of button for expanding diff sections */
+export enum ContextButtonType {
+  ABOVE = 'above',
+  BELOW = 'below',
+  BLOCK_ABOVE = 'block-above',
+  BLOCK_BELOW = 'block-below',
+  ALL = 'all',
+}
+
+/** Details to be externally accessed when expanding diffs */
+export declare interface DiffContextExpandedExternalDetail {
+  expandedLines: number;
+  buttonType: ContextButtonType;
+}
+
+/**
+ * Details to be externally accessed when hovering context
+ * expansion buttons
+ */
+export declare interface DiffContextButtonHoveredDetail {
+  linesToExpand: number;
+  buttonType: ContextButtonType;
+}
+
+export declare type ImageDiffAction =
+  | {type: 'overview-image-clicked'}
+  | {type: 'overview-frame-dragged'}
+  | {type: 'magnifier-clicked'}
+  | {type: 'magnifier-dragged'}
+  | {type: 'version-switcher-clicked'; button: 'base' | 'revision' | 'switch'}
+  | {type: 'zoom-level-changed'; scale: number | 'fit'}
+  | {type: 'follow-mouse-changed'; value: boolean}
+  | {type: 'background-color-changed'; value: string}
+  | {type: 'automatic-blink-changed'; value: boolean};
+
 export enum GrDiffLineType {
   ADD = 'add',
   BOTH = 'both',
@@ -285,6 +361,82 @@
   annotate(
     textElement: HTMLElement,
     lineNumberElement: HTMLElement,
-    line: GrDiffLine
+    line: GrDiffLine,
+    side: Side
   ): void;
 }
+
+/** Data used by GrAnnotation to generate elements. */
+export declare interface ElementSpec {
+  tagName: string;
+  attributes?: {[key: string]: unknown};
+}
+
+/** Used to annotate segments of an HTMLElement with a class string. */
+export declare interface GrAnnotation {
+  /**
+   * Annotates the [offset, offset+length) text segment in the parent with the
+   * element definition provided as arguments.
+   *
+   * @param parent the node whose contents will be annotated.
+   * If parent is Text then parent.parentNode must not be null
+   * @param offset the 0-based offset from which the annotation will
+   * start.
+   * @param length of the annotated text.
+   * @param elementSpec the spec to create the
+   * annotating element.
+   */
+  annotateWithElement(
+    el: HTMLElement,
+    start: number,
+    length: number,
+    elementSpec: ElementSpec
+  ): void;
+
+  /**
+   * Surrounds the element's text at specified range in an ANNOTATION_TAG
+   * element. If the element has child elements, the range is split and
+   * applied as deeply as possible.
+   */
+  annotateElement(
+    el: HTMLElement,
+    start: number,
+    length: number,
+    className: string
+  ): void;
+}
+
+// The current setup requires API users to register GrDiff instances with the
+// cursor, but we do not at this point want to expose the API that GrDiffCursor
+// uses to the public as it is likely to change. So for now, we allow any type
+// and cast. This works fine so long as API users do provide whatever the
+// gr-diff tag creates.
+export type GrDiff = unknown;
+
+/** A service to interact with the line cursor in gr-diff instances. */
+export declare interface GrDiffCursor {
+  replaceDiffs(diffs: GrDiff[]): void;
+  unregisterDiff(diff: GrDiff): 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
new file mode 100644
index 0000000..b1b7f34
--- /dev/null
+++ b/polygerrit-ui/app/api/embed.ts
@@ -0,0 +1,33 @@
+/**
+ * @fileoverview The API of class exported globally in embed/gr-diff.ts
+ *
+ * This is a mechanism to make classes accessible to separately compiled
+ * bundles, which cannot directly import the classes from their modules.
+ *
+ * @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 {DiffLayer, GrAnnotation, GrDiffCursor} from './diff';
+
+declare global {
+  interface Window {
+    grdiff: {
+      GrAnnotation: GrAnnotation;
+      GrDiffCursor: {new (): GrDiffCursor};
+      TokenHighlightLayer: {new (): DiffLayer};
+    };
+  }
+}
diff --git a/polygerrit-ui/app/api/event-helper.ts b/polygerrit-ui/app/api/event-helper.ts
index 16c327d..5dc15dc 100644
--- a/polygerrit-ui/app/api/event-helper.ts
+++ b/polygerrit-ui/app/api/event-helper.ts
@@ -16,7 +16,7 @@
  */
 export type UnsubscribeCallback = () => void;
 
-export interface EventHelperPluginApi {
+export declare interface EventHelperPluginApi {
   /**
    * Alias for @see onClick
    */
diff --git a/polygerrit-ui/app/api/hook.ts b/polygerrit-ui/app/api/hook.ts
index 179b967..0ac6468 100644
--- a/polygerrit-ui/app/api/hook.ts
+++ b/polygerrit-ui/app/api/hook.ts
@@ -30,12 +30,12 @@
 
 export type HookCallback = (el: HTMLElement & GerritElementExtensions) => void;
 
-export interface RegisterOptions {
+export declare interface RegisterOptions {
   slot?: string;
   replace: unknown;
 }
 
-export interface HookApi {
+export declare interface HookApi {
   onAttached(callback: HookCallback): HookApi;
 
   onDetached(callback: HookCallback): HookApi;
diff --git a/polygerrit-ui/app/api/plugin.ts b/polygerrit-ui/app/api/plugin.ts
index 0aadb38..0c91546 100644
--- a/polygerrit-ui/app/api/plugin.ts
+++ b/polygerrit-ui/app/api/plugin.ts
@@ -48,7 +48,7 @@
   HIGHLIGHTJS_LOADED = 'highlightjs-loaded',
 }
 
-export interface PluginApi {
+export declare interface PluginApi {
   _url?: URL;
   admin(): AdminPluginApi;
   annotationApi(): AnnotationPluginApi;
diff --git a/polygerrit-ui/app/api/popup.ts b/polygerrit-ui/app/api/popup.ts
index 60772cc..8d81831 100644
--- a/polygerrit-ui/app/api/popup.ts
+++ b/polygerrit-ui/app/api/popup.ts
@@ -15,7 +15,7 @@
  * limitations under the License.
  */
 
-export interface PopupPluginApi {
+export declare interface PopupPluginApi {
   /**
    * Opens the popup, inserts it into DOM over current UI.
    * Creates the popup if not previously created. Creates popup content element,
diff --git a/polygerrit-ui/app/api/reporting.ts b/polygerrit-ui/app/api/reporting.ts
index 65bdc3f..c3655bb 100644
--- a/polygerrit-ui/app/api/reporting.ts
+++ b/polygerrit-ui/app/api/reporting.ts
@@ -18,7 +18,7 @@
 // eslint-disable-next-line @typescript-eslint/no-explicit-any
 export type EventDetails = any;
 
-export interface ReportingPluginApi {
+export declare interface ReportingPluginApi {
   reportInteraction(eventName: string, details?: EventDetails): void;
 
   reportLifeCycle(eventName: string, details?: EventDetails): void;
diff --git a/polygerrit-ui/app/api/rest.ts b/polygerrit-ui/app/api/rest.ts
index fd9cada..2b91bf6 100644
--- a/polygerrit-ui/app/api/rest.ts
+++ b/polygerrit-ui/app/api/rest.ts
@@ -26,7 +26,7 @@
 
 export type ErrorCallback = (response?: Response | null, err?: Error) => void;
 
-export interface RestPluginApi {
+export declare interface RestPluginApi {
   getLoggedIn(): Promise<boolean>;
 
   getVersion(): Promise<string | undefined>;
diff --git a/polygerrit-ui/app/constants/constants.ts b/polygerrit-ui/app/constants/constants.ts
index be502f7..689347a 100644
--- a/polygerrit-ui/app/constants/constants.ts
+++ b/polygerrit-ui/app/constants/constants.ts
@@ -54,6 +54,14 @@
   TAG_SET_ASSIGNEE = 'autogenerated:gerrit:setAssignee',
   TAG_UNSET_ASSIGNEE = 'autogenerated:gerrit:deleteAssignee',
   TAG_MERGED = 'autogenerated:gerrit:merged',
+  TAG_REVERT = 'autogenerated:gerrit:revert',
+}
+
+/**
+ * @desc Templates that can be used in change log messages.
+ */
+export enum ChangeMessageTemplate {
+  ACCOUNT_TEMPLATE = '<GERRIT_ACCOUNT_(\\d+)>',
 }
 
 /**
diff --git a/polygerrit-ui/app/constants/reporting.ts b/polygerrit-ui/app/constants/reporting.ts
index 0738825..d22026a 100644
--- a/polygerrit-ui/app/constants/reporting.ts
+++ b/polygerrit-ui/app/constants/reporting.ts
@@ -90,3 +90,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.ts b/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section.ts
index aa28f04..c41fe57 100644
--- a/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section.ts
+++ b/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section.ts
@@ -189,10 +189,6 @@
     );
   }
 
-  _computeHideEditClass(section: PermissionAccessSection) {
-    return section.id === 'GLOBAL_CAPABILITIES' ? 'hide' : '';
-  }
-
   _handleAddedPermissionRemoved(e: PolymerDomRepeatEvent) {
     if (!this._permissions) {
       return;
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-group/gr-group_html.ts b/polygerrit-ui/app/elements/admin/gr-group/gr-group_html.ts
index ba089f6..98d21f9 100644
--- a/polygerrit-ui/app/elements/admin/gr-group/gr-group_html.ts
+++ b/polygerrit-ui/app/elements/admin/gr-group/gr-group_html.ts
@@ -25,9 +25,6 @@
       color: var(--deemphasized-text-color);
       content: ' *';
     }
-    .inputUpdateBtn {
-      margin-top: var(--spacing-s);
-    }
   </style>
   <style include="gr-form-styles">
     /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
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 2f07268..06087ed 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
@@ -28,10 +28,6 @@
     :host(:hover) {
       background-color: var(--hover-background-color);
     }
-    :host([needs-review]) {
-      font-weight: var(--font-weight-bold);
-      color: var(--primary-text-color);
-    }
     .container {
       position: relative;
     }
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.ts
index 8ce00f2..9f7848c 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.ts
@@ -33,7 +33,6 @@
   EmailAddress,
   PreferencesInput,
 } from '../../../types/common';
-import {ChangeListToggleReviewedDetail} from '../gr-change-list-item/gr-change-list-item';
 import {ChangeStarToggleStarDetail} from '../../shared/gr-change-star/gr-change-star';
 import {ChangeListViewState} from '../../../types/types';
 import {fireTitleChange} from '../../../utils/event-util';
@@ -109,6 +108,8 @@
 
   private readonly restApiService = appContext.restApiService;
 
+  private reporting = appContext.reportingService;
+
   constructor() {
     super();
     this.addEventListener('next-page', () => this._handleNextPage());
@@ -273,17 +274,21 @@
   }
 
   _handleToggleStar(e: CustomEvent<ChangeStarToggleStarDetail>) {
+    if (e.detail.starred) {
+      this.reporting.reportInteraction('change-starred-from-change-list');
+    }
     this.restApiService.saveChangeStarred(
       e.detail.change._number,
       e.detail.starred
     );
   }
 
-  _handleToggleReviewed(e: CustomEvent<ChangeListToggleReviewedDetail>) {
-    this.restApiService.saveChangeReviewed(
-      e.detail.change._number,
-      e.detail.reviewed
-    );
+  /**
+   * Returns `this` as the visibility observer target for the keyboard shortcut
+   * mixin to decide whether shortcuts should be enabled or not.
+   */
+  _computeObserverTarget() {
+    return this;
   }
 }
 
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_html.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_html.ts
index 9914e70..54d3911 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_html.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_html.ts
@@ -77,7 +77,7 @@
       selected-index="{{viewState.selectedChangeIndex}}"
       show-star="[[_loggedIn]]"
       on-toggle-star="_handleToggleStar"
-      on-toggle-reviewed="_handleToggleReviewed"
+      observer-target="[[_computeObserverTarget()]]"
     ></gr-change-list>
     <nav class$="[[_computeNavClass(_loading)]]">
       Page [[_computePage(_offset, _changesPerPage)]]
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 62cfcb4..8c33f52 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
@@ -28,7 +28,6 @@
 import {
   KeyboardShortcutMixin,
   Shortcut,
-  Modifier,
 } from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
 import {
   GerritNav,
@@ -38,7 +37,7 @@
 } from '../../core/gr-navigation/gr-navigation';
 import {getPluginEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints';
 import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
-import {changeIsOpen, isOwner} from '../../../utils/change-util';
+import {isOwner} from '../../../utils/change-util';
 import {customElement, property, observe} from '@polymer/decorators';
 import {GrCursorManager} from '../../shared/gr-cursor-manager/gr-cursor-manager';
 import {
@@ -47,13 +46,10 @@
   ServerInfo,
   PreferencesInput,
 } from '../../../types/common';
-import {
-  hasAttention,
-  isAttentionSetEnabled,
-} from '../../../utils/attention-set-util';
+import {hasAttention} from '../../../utils/attention-set-util';
 import {CustomKeyboardEvent} from '../../../types/events';
-import {fireEvent} from '../../../utils/event-util';
-import {windowLocationReload} from '../../../utils/dom-util';
+import {fireEvent, fireReload} from '../../../utils/event-util';
+import {isShiftPressed} from '../../../utils/dom-util';
 import {ScrollMode} from '../../../constants/constants';
 
 const NUMBER_FIXED_COLUMNS = 3;
@@ -371,35 +367,18 @@
       : undefined;
   }
 
-  _computeItemNeedsReview(
-    account: AccountInfo | undefined,
-    change: ChangeInfo,
-    showReviewedState: boolean,
-    config?: ServerInfo
-  ) {
-    return (
-      !isAttentionSetEnabled(config) &&
-      showReviewedState &&
-      !change.reviewed &&
-      !change.work_in_progress &&
-      changeIsOpen(change) &&
-      (!account || account._account_id !== change.owner._account_id)
-    );
-  }
-
   _computeItemHighlight(
     account?: AccountInfo,
     change?: ChangeInfo,
-    config?: ServerInfo,
     sectionName?: string
   ) {
     if (!change || !account) return false;
     if (CLOSED_STATUS.indexOf(change.status) !== -1) return false;
-    return isAttentionSetEnabled(config)
-      ? hasAttention(config, account, change) &&
-          !isOwner(change, account) &&
-          sectionName === YOUR_TURN.name
-      : account._account_id === change.assignee?._account_id;
+    return (
+      hasAttention(account, change) &&
+      !isOwner(change, account) &&
+      sectionName === YOUR_TURN.name
+    );
   }
 
   _nextChange(e: CustomKeyboardEvent) {
@@ -439,8 +418,7 @@
   _nextPage(e: CustomKeyboardEvent) {
     if (
       this.shouldSuppressKeyboardShortcut(e) ||
-      (this.modifierPressed(e) &&
-        !this.isModifierPressed(e, Modifier.SHIFT_KEY))
+      (this.modifierPressed(e) && !isShiftPressed(e))
     ) {
       return;
     }
@@ -452,8 +430,7 @@
   _prevPage(e: CustomKeyboardEvent) {
     if (
       this.shouldSuppressKeyboardShortcut(e) ||
-      (this.modifierPressed(e) &&
-        !this.isModifierPressed(e, Modifier.SHIFT_KEY))
+      (this.modifierPressed(e) && !isShiftPressed(e))
     ) {
       return;
     }
@@ -492,11 +469,7 @@
     }
 
     e.preventDefault();
-    this._reloadWindow();
-  }
-
-  _reloadWindow() {
-    windowLocationReload();
+    fireReload(this);
   }
 
   _toggleChangeStar(e: CustomKeyboardEvent) {
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_html.ts b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_html.ts
index 317d27f..37d969e 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_html.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_html.ts
@@ -144,8 +144,7 @@
           <gr-change-list-item
             account="[[account]]"
             selected$="[[_computeItemSelected(sectionIndex, index, selectedIndex)]]"
-            highlight$="[[_computeItemHighlight(account, change, _config, changeSection.name)]]"
-            needs-review$="[[_computeItemNeedsReview(account, change, showReviewedState, _config)]]"
+            highlight$="[[_computeItemHighlight(account, change, changeSection.name)]]"
             change="[[change]]"
             config="[[_config]]"
             section-name="[[changeSection.name]]"
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.js b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.js
index 494d05a..13490d7 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.js
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.js
@@ -192,82 +192,10 @@
       MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
       assert.equal(element.selectedIndex, 0);
 
-      const reloadStub = sinon.stub(element, '_reloadWindow');
-      MockInteractions.pressAndReleaseKeyOn(element, 82, 'shift', 'r');
-      assert.isTrue(reloadStub.called);
-
       done();
     });
   });
 
-  test('changes needing review', () => {
-    element.changes = [
-      {
-        _number: 0,
-        status: 'NEW',
-        reviewed: true,
-        owner: {_account_id: 0},
-      },
-      {
-        _number: 1,
-        status: 'NEW',
-        owner: {_account_id: 0},
-      },
-      {
-        _number: 2,
-        status: 'MERGED',
-        owner: {_account_id: 0},
-      },
-      {
-        _number: 3,
-        status: 'ABANDONED',
-        owner: {_account_id: 0},
-      },
-      {
-        _number: 4,
-        status: 'NEW',
-        work_in_progress: true,
-        owner: {_account_id: 0},
-      },
-    ];
-    flush();
-    let elementItems = element.root.querySelectorAll(
-        'gr-change-list-item');
-    assert.equal(elementItems.length, 5);
-    for (let i = 0; i < elementItems.length; i++) {
-      assert.isFalse(elementItems[i].hasAttribute('needs-review'));
-    }
-
-    element.showReviewedState = true;
-    elementItems = element.root.querySelectorAll(
-        'gr-change-list-item');
-    assert.equal(elementItems.length, 5);
-    assert.isFalse(elementItems[0].hasAttribute('needs-review'));
-    assert.isTrue(elementItems[1].hasAttribute('needs-review'));
-    assert.isFalse(elementItems[2].hasAttribute('needs-review'));
-    assert.isFalse(elementItems[3].hasAttribute('needs-review'));
-    assert.isFalse(elementItems[4].hasAttribute('needs-review'));
-
-    element.account = {_account_id: 42};
-    elementItems = element.root.querySelectorAll(
-        'gr-change-list-item');
-    assert.equal(elementItems.length, 5);
-    assert.isFalse(elementItems[0].hasAttribute('needs-review'));
-    assert.isTrue(elementItems[1].hasAttribute('needs-review'));
-    assert.isFalse(elementItems[2].hasAttribute('needs-review'));
-    assert.isFalse(elementItems[3].hasAttribute('needs-review'));
-    assert.isFalse(elementItems[4].hasAttribute('needs-review'));
-
-    element._config = {
-      change: {enable_attention_set: true},
-    };
-    elementItems = element.root.querySelectorAll(
-        'gr-change-list-item');
-    for (let i = 0; i < elementItems.length; i++) {
-      assert.isFalse(elementItems[i].hasAttribute('needs-review'));
-    }
-  });
-
   test('no changes', () => {
     element.changes = [];
     flush();
@@ -575,37 +503,6 @@
       });
     });
 
-    test('highlight attribute is updated correctly', () => {
-      element.changes = [
-        {
-          _number: 0,
-          status: 'NEW',
-          owner: {_account_id: 0},
-        },
-        {
-          _number: 1,
-          status: 'ABANDONED',
-          owner: {_account_id: 0},
-        },
-      ];
-      element.account = {_account_id: 42};
-      flush();
-      let items = element._getListItems();
-      assert.equal(items.length, 2);
-      assert.isFalse(items[0].hasAttribute('highlight'));
-      assert.isFalse(items[1].hasAttribute('highlight'));
-
-      // Assign all issues to the user, but only the first one is highlighted
-      // because the second one is abandoned.
-      element.set(['changes', 0, 'assignee'], {_account_id: 12});
-      element.set(['changes', 1, 'assignee'], {_account_id: 12});
-      element.account = {_account_id: 12};
-      flush();
-      items = element._getListItems();
-      assert.isTrue(items[0].hasAttribute('highlight'));
-      assert.isFalse(items[1].hasAttribute('highlight'));
-    });
-
     test('_computeItemHighlight gives false for null account', () => {
       assert.isFalse(
           element._computeItemHighlight(null, {assignee: {_account_id: 42}}));
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 f9dee5f..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
@@ -50,7 +50,6 @@
   GrCreateDestinationDialog,
 } from '../gr-create-destination-dialog/gr-create-destination-dialog';
 import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
-import {ChangeListToggleReviewedDetail} from '../gr-change-list-item/gr-change-list-item';
 import {ChangeStarToggleStarDetail} from '../../shared/gr-change-star/gr-change-star';
 import {DashboardViewState} from '../../../types/types';
 import {firePageError, fireTitleChange} from '../../../utils/event-util';
@@ -129,10 +128,7 @@
   connectedCallback() {
     super.connectedCallback();
     this._loadPreferences();
-    this.addEventListener('reload', e => {
-      e.stopPropagation();
-      this._reload(this.params);
-    });
+    this.addEventListener('reload', () => this._reload(this.params));
     document.addEventListener('visibilitychange', () => {
       if (document.visibilityState === 'visible') {
         if (
@@ -256,7 +252,7 @@
       })
       .catch(err => {
         fireTitleChange(this, title || this._computeTitle(user));
-        console.warn(err);
+        this.reporting.error(err);
       })
       .then(() => {
         this._loading = false;
@@ -376,6 +372,9 @@
       e.detail.change._number,
       e.detail.starred
     );
+    if (e.detail.starred) {
+      this.reporting.reportInteraction('change-starred-from-dashboard');
+    }
     // When a change is updated the same change may appear elsewhere in the
     // dashboard (but is not the same object), so we must update other
     // occurrences of the same change.
@@ -391,26 +390,6 @@
     );
   }
 
-  _handleToggleReviewed(e: CustomEvent<ChangeListToggleReviewedDetail>) {
-    this.restApiService.saveChangeReviewed(
-      e.detail.change._number,
-      e.detail.reviewed
-    );
-    // When a change is updated the same change may appear elsewhere in the
-    // dashboard (but is not the same object), so we must update other
-    // occurrences of the same change.
-    this._results?.forEach((dashboardChange, dashboardIndex) =>
-      dashboardChange.results.forEach((change, changeIndex) => {
-        if (change.id === e.detail.change.id) {
-          this.set(
-            `_results.${dashboardIndex}.results.${changeIndex}.reviewed`,
-            e.detail.reviewed
-          );
-        }
-      })
-    );
-  }
-
   /**
    * Banner is shown if a user is on their own dashboard and they have draft
    * comments on closed changes.
@@ -474,6 +453,14 @@
     this.$.commandsDialog.branch = e.detail.branch;
     this.$.commandsDialog.open();
   }
+
+  /**
+   * Returns `this` as the visibility observer target for the keyboard shortcut
+   * mixin to decide whether shortcuts should be enabled or not.
+   */
+  _computeObserverTarget() {
+    return this;
+  }
 }
 
 declare global {
diff --git a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_html.ts b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_html.ts
index fd23970..484a952 100644
--- a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_html.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_html.ts
@@ -78,13 +78,12 @@
     <h1 class="assistive-tech-only">Dashboard</h1>
     <gr-change-list
       show-star=""
-      show-reviewed-state=""
       account="[[account]]"
       preferences="[[preferences]]"
       selected-index="{{_selectedChangeIndex}}"
       sections="[[_results]]"
       on-toggle-star="_handleToggleStar"
-      on-toggle-reviewed="_handleToggleReviewed"
+      observer-target="[[_computeObserverTarget()]]"
     >
       <div id="emptyOutgoing" slot="empty-outgoing">
         <template is="dom-if" if="[[_showNewUserHelp]]">
diff --git a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.js b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.js
index 165306e..473d2b9 100644
--- a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.js
+++ b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.js
@@ -356,31 +356,6 @@
     assert.isFalse(differentChange.starred);
   });
 
-  test('toggling reviewed will update change everywhere', () => {
-    // It is important that the same change is represented by multiple objects
-    // and all are updated.
-    const change = {id: '5', reviewed: false};
-    const sameChange = {id: '5', reviewed: false};
-    const differentChange = {id: '4', reviewed: false};
-    element._results = [
-      {query: 'has:draft', results: [change]},
-      {query: 'is:open', results: [sameChange, differentChange]},
-    ];
-
-    element._handleToggleReviewed(
-        new CustomEvent('toggle-reviewed', {
-          detail: {
-            change,
-            reviewed: true,
-          },
-        })
-    );
-
-    assert.isTrue(change.reviewed);
-    assert.isTrue(sameChange.reviewed);
-    assert.isFalse(differentChange.reviewed);
-  });
-
   test('_showNewUserHelp', () => {
     element._loading = false;
     element._showNewUserHelp = false;
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 e9a5a46..eb979a1 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
@@ -26,7 +26,6 @@
 import '../gr-confirm-move-dialog/gr-confirm-move-dialog';
 import '../gr-confirm-rebase-dialog/gr-confirm-rebase-dialog';
 import '../gr-confirm-revert-dialog/gr-confirm-revert-dialog';
-import '../gr-confirm-revert-submission-dialog/gr-confirm-revert-submission-dialog';
 import '../gr-confirm-submit-dialog/gr-confirm-submit-dialog';
 import '../../../styles/shared-styles';
 import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
@@ -35,7 +34,7 @@
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
 import {appContext} from '../../../services/app-context';
-import {fetchChangeUpdates, CURRENT} from '../../../utils/patch-set-util';
+import {CURRENT} from '../../../utils/patch-set-util';
 import {
   changeIsOpen,
   isOwner,
@@ -76,7 +75,6 @@
 import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
 import {GrCreateChangeDialog} from '../../admin/gr-create-change-dialog/gr-create-change-dialog';
 import {GrConfirmSubmitDialog} from '../gr-confirm-submit-dialog/gr-confirm-submit-dialog';
-import {GrConfirmRevertSubmissionDialog} from '../gr-confirm-revert-submission-dialog/gr-confirm-revert-submission-dialog';
 import {
   ConfirmRevertEventDetail,
   GrConfirmRevertDialog,
@@ -95,7 +93,7 @@
   GrChangeActionsElement,
   UIActionInfo,
 } from '../../shared/gr-js-api-interface/gr-change-actions-js-api';
-import {fireAlert} from '../../../utils/event-util';
+import {fireAlert, fireEvent, fireReload} from '../../../utils/event-util';
 import {
   CODE_REVIEW,
   getApprovalInfo,
@@ -111,6 +109,7 @@
   RevisionActions,
 } from '../../../api/change-actions';
 import {ErrorCallback} from '../../../api/rest';
+import {GrDropdown} from '../../shared/gr-dropdown/gr-dropdown';
 
 const ERR_BRANCH_EMPTY = 'The destination branch can’t be empty.';
 const ERR_COMMIT_EMPTY = 'The commit message can’t be empty.';
@@ -151,7 +150,6 @@
   rebase: 'Rebasing...',
   restore: 'Restoring...',
   revert: 'Reverting...',
-  revert_submission: 'Reverting Submission...',
   submit: 'Submitting...',
 };
 
@@ -186,6 +184,15 @@
   __type: ActionType.REVISION,
 };
 
+const INCLUDED_IN_ACTION: UIActionInfo = {
+  enabled: true,
+  label: 'Included In',
+  title: 'Open Included In dialog',
+  __key: 'includedIn',
+  __primary: false,
+  __type: ActionType.CHANGE,
+};
+
 const REBASE_EDIT: UIActionInfo = {
   enabled: true,
   label: 'Rebase edit',
@@ -245,7 +252,6 @@
   ChangeActions.REBASE_EDIT,
   ChangeActions.RESTORE,
   ChangeActions.REVERT,
-  ChangeActions.REVERT_SUBMISSION,
   ChangeActions.STOP_EDIT,
   QUICK_APPROVE_ACTION.key,
   RevisionActions.REBASE,
@@ -263,18 +269,15 @@
 const AWAIT_CHANGE_ATTEMPTS = 5;
 const AWAIT_CHANGE_TIMEOUT_MS = 1000;
 
-/* Revert submission is skipped as the normal revert dialog will now show
-the user a choice between reverting single change or an entire submission.
-Hence, a second button is not needed.
-*/
-const SKIP_ACTION_KEYS = [ChangeActions.REVERT_SUBMISSION];
-
-const SKIP_ACTION_KEYS_ATTENTION_SET = [
+// TODO: Remove these once we are sure that the backend does not support/send
+// them anymore.
+const SKIP_ACTION_KEYS: string[] = [
   ChangeActions.REVIEWED,
   ChangeActions.UNREVIEWED,
+  ChangeActions.REVERT_SUBMISSION,
 ];
 
-function assertUIActionInfo(action?: ActionInfo): UIActionInfo {
+export function assertUIActionInfo(action?: ActionInfo): UIActionInfo {
   // TODO(TS): Remove this function. The gr-change-actions adds properties
   // to existing ActionInfo objects instead of creating a new objects. This
   // function checks, that 'action' has all property required by UIActionInfo.
@@ -325,13 +328,14 @@
     confirmCherrypickConflict: GrConfirmCherrypickConflictDialog;
     confirmMove: GrConfirmMoveDialog;
     confirmRevertDialog: GrConfirmRevertDialog;
-    confirmRevertSubmissionDialog: GrConfirmRevertSubmissionDialog;
     confirmAbandonDialog: GrConfirmAbandonDialog;
     confirmSubmitDialog: GrConfirmSubmitDialog;
     createFollowUpDialog: GrDialog;
     createFollowUpChange: GrCreateChangeDialog;
     confirmDeleteDialog: GrDialog;
     confirmDeleteEditDialog: GrDialog;
+    moreActions: GrDropdown;
+    secondaryActions: HTMLElement;
   };
 }
 
@@ -380,6 +384,8 @@
 
   private readonly jsAPI = appContext.jsApiService;
 
+  private readonly changeService = appContext.changeService;
+
   @property({type: Object})
   change?: ChangeViewChangeInfo;
 
@@ -448,7 +454,7 @@
     computed:
       '_computeAllActions(actions.*, revisionActions.*,' +
       'primaryActionKeys.*, _additionalActions.*, change, ' +
-      '_config, _actionPriorityOverrides.*)',
+      '_actionPriorityOverrides.*)',
   })
   _allActionValues: UIActionInfo[] = []; // _computeAllActions always returns an array
 
@@ -525,6 +531,10 @@
       type: ActionType.CHANGE,
       key: ChangeActions.FOLLOW_UP,
     },
+    {
+      type: ActionType.CHANGE,
+      key: ChangeActions.INCLUDED_IN,
+    },
   ];
 
   @property({type: Array})
@@ -809,6 +819,10 @@
         this.set('revisionActions.download', DOWNLOAD_ACTION);
       }
     }
+    const actions = actionsChangeRecord.base || {};
+    if (!actions.includedIn && this.change?.status === ChangeStatus.MERGED) {
+      this.set('actions.includedIn', INCLUDED_IN_ACTION);
+    }
   }
 
   _deleteAndNotify(actionName: string) {
@@ -1175,23 +1189,6 @@
     });
   }
 
-  showRevertSubmissionDialog() {
-    const change = this.change;
-    if (!change) return;
-    const query = `submissionid:${change.submission_id}`;
-    this.restApiService.getChanges(0, query).then(changes => {
-      if (!changes) {
-        this.reporting.error(new Error('changes is undefined'));
-        return;
-      }
-      this.$.confirmRevertSubmissionDialog._populateRevertSubmissionMessage(
-        change,
-        changes
-      );
-      this._showActionDialog(this.$.confirmRevertSubmissionDialog);
-    });
-  }
-
   _handleActionTap(e: MouseEvent) {
     e.preventDefault();
     let el = (dom(e) as EventApi).localTarget as Element;
@@ -1266,9 +1263,6 @@
       case ChangeActions.REVERT:
         this.showRevertDialog();
         break;
-      case ChangeActions.REVERT_SUBMISSION:
-        this.showRevertSubmissionDialog();
-        break;
       case ChangeActions.ABANDON:
         this._showActionDialog(this.$.confirmAbandonDialog);
         break;
@@ -1307,6 +1301,9 @@
       case ChangeActions.REBASE_EDIT:
         this._handleRebaseEditTap();
         break;
+      case ChangeActions.INCLUDED_IN:
+        this._handleIncludedInTap();
+        break;
       default:
         this._fireAction(
           this._prependSlash(key),
@@ -1451,9 +1448,11 @@
         );
         break;
       case RevertType.REVERT_SUBMISSION:
+        // TODO(dhruvsri): replace with this.actions.revert_submission once
+        // BE starts sending it again
         this._fireAction(
           '/revert_submission',
-          assertUIActionInfo(this.actions.revert_submission),
+          {__key: 'revert_submission', method: HttpMethod.POST} as UIActionInfo,
           false,
           {message}
         );
@@ -1463,18 +1462,6 @@
     }
   }
 
-  _handleRevertSubmissionDialogConfirm() {
-    const el = this.$.confirmRevertSubmissionDialog;
-    this.$.overlay.close();
-    el.hidden = true;
-    this._fireAction(
-      '/revert_submission',
-      assertUIActionInfo(this.actions.revert_submission),
-      false,
-      {message: el.message}
-    );
-  }
-
   _handleAbandonDialogConfirm() {
     const el = this.$.confirmAbandonDialog;
     this.$.overlay.close();
@@ -1649,13 +1636,7 @@
         case ChangeActions.REBASE_EDIT:
         case ChangeActions.REBASE:
         case ChangeActions.SUBMIT:
-          this.dispatchEvent(
-            new CustomEvent('reload', {
-              detail: {clearPatchset: true},
-              bubbles: false,
-              composed: true,
-            })
-          );
+          fireReload(this, true);
           break;
         case ChangeActions.REVERT_SUBMISSION: {
           const revertSubmistionInfo = (obj as unknown) as RevertSubmissionInfo;
@@ -1672,22 +1653,12 @@
           break;
         }
         default:
-          this.dispatchEvent(
-            new CustomEvent('reload', {
-              detail: {action: action.__key, clearPatchset: true},
-              bubbles: false,
-              composed: true,
-            })
-          );
+          fireReload(this, true);
           break;
       }
     });
   }
 
-  _handleShowRevertSubmissionChangesConfirm() {
-    this._hideAllDialogs();
-  }
-
   _handleResponseError(
     action: UIActionInfo,
     response: Response | undefined | null,
@@ -1746,7 +1717,7 @@
         new Error('Properties change and changeNum must be set.')
       );
     }
-    return fetchChangeUpdates(change, this.restApiService).then(result => {
+    return this.changeService.fetchChangeUpdates(change).then(result => {
       if (!result.isLatest) {
         this.dispatchEvent(
           new CustomEvent<ShowAlertEventDetail>('show-alert', {
@@ -1755,15 +1726,7 @@
                 'Cannot set label: a newer patch has been ' +
                 'uploaded to this change.',
               action: 'Reload',
-              callback: () => {
-                this.dispatchEvent(
-                  new CustomEvent('reload', {
-                    detail: {clearPatchset: true},
-                    bubbles: false,
-                    composed: true,
-                  })
-                );
-              },
+              callback: () => fireReload(this, true),
             },
             composed: true,
             bubbles: true,
@@ -1793,10 +1756,6 @@
     });
   }
 
-  _handleAbandonTap() {
-    this._showActionDialog(this.$.confirmAbandonDialog);
-  }
-
   _handleCherrypickTap() {
     if (!this.change) {
       throw new Error('The change property must be set');
@@ -1826,12 +1785,11 @@
   }
 
   _handleDownloadTap() {
-    this.dispatchEvent(
-      new CustomEvent('download-tap', {
-        composed: true,
-        bubbles: false,
-      })
-    );
+    fireEvent(this, 'download-tap');
+  }
+
+  _handleIncludedInTap() {
+    fireEvent(this, 'included-tap');
   }
 
   _handleDeleteTap() {
@@ -1905,8 +1863,7 @@
       UIActionInfo[],
       UIActionInfo[]
     >,
-    change?: ChangeInfo,
-    config?: ServerInfo
+    change?: ChangeInfo
   ): UIActionInfo[] {
     // Polymer 2: check for undefined
     if (
@@ -1953,7 +1910,7 @@
         // End of hack
         return action;
       })
-      .filter(action => !this._shouldSkipAction(action, config));
+      .filter(action => !this._shouldSkipAction(action));
   }
 
   _getActionPriority(action: UIActionInfo) {
@@ -1992,14 +1949,8 @@
     }
   }
 
-  _shouldSkipAction(action: UIActionInfo, config?: ServerInfo) {
-    const skipActionKeys: string[] = [...SKIP_ACTION_KEYS];
-    const isAttentionSetEnabled =
-      !!config && !!config.change && config.change.enable_attention_set;
-    if (isAttentionSetEnabled) {
-      skipActionKeys.push(...SKIP_ACTION_KEYS_ATTENTION_SET);
-    }
-    return skipActionKeys.includes(action.__key);
+  _shouldSkipAction(action: UIActionInfo) {
+    return SKIP_ACTION_KEYS.includes(action.__key);
   }
 
   _computeTopLevelActions(
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_html.ts b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_html.ts
index 2d4ed32..614339a 100644
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_html.ts
@@ -90,6 +90,7 @@
           has-tooltip="[[_computeHasTooltip(action.title)]]"
           position-below="true"
           data-action-key$="[[action.__key]]"
+          class$="[[action.__key]]"
           data-action-type$="[[action.__type]]"
           data-label$="[[action.label]]"
           disabled$="[[_calculateDisabled(action, _hasKnownChainState)]]"
@@ -118,6 +119,7 @@
           has-tooltip="[[_computeHasTooltip(action.title)]]"
           position-below="true"
           data-action-key$="[[action.__key]]"
+          class$="[[action.__key]]"
           data-action-type$="[[action.__type]]"
           data-label$="[[action.label]]"
           disabled$="[[_calculateDisabled(action, _hasKnownChainState)]]"
@@ -194,14 +196,6 @@
       on-cancel="_handleConfirmDialogCancel"
       hidden=""
     ></gr-confirm-revert-dialog>
-    <gr-confirm-revert-submission-dialog
-      id="confirmRevertSubmissionDialog"
-      class="confirmDialog"
-      commit-message="[[commitMessage]]"
-      on-confirm="_handleRevertSubmissionDialogConfirm"
-      on-cancel="_handleConfirmDialogCancel"
-      hidden=""
-    ></gr-confirm-revert-submission-dialog>
     <gr-confirm-abandon-dialog
       id="confirmAbandonDialog"
       class="confirmDialog"
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.js b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.js
deleted file mode 100644
index c193e60..0000000
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.js
+++ /dev/null
@@ -1,2186 +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-change-actions.js';
-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 {
-  createAccountWithId,
-  createApproval,
-  createChange,
-  createChangeMessages,
-  createRevisions,
-} from '../../../test/test-data-generators.js';
-import {ChangeStatus} from '../../../constants/constants.js';
-import {stubRestApi} from '../../../test/test-utils.js';
-
-const basicFixture = fixtureFromElement('gr-change-actions');
-
-const CHERRY_PICK_TYPES = {
-  SINGLE_CHANGE: 1,
-  TOPIC: 2,
-};
-// TODO(dhruvsri): remove use of _populateRevertMessage as it's private
-suite('gr-change-actions tests', () => {
-  let element;
-
-  suite('basic tests', () => {
-    setup(() => {
-      stubRestApi('getChangeRevisionActions').returns(Promise.resolve({
-        cherrypick: {
-          method: 'POST',
-          label: 'Cherry Pick',
-          title: 'Cherry pick change to a different branch',
-          enabled: true,
-        },
-        rebase: {
-          method: 'POST',
-          label: 'Rebase',
-          title: 'Rebase onto tip of branch or parent change',
-          enabled: true,
-        },
-        submit: {
-          method: 'POST',
-          label: 'Submit',
-          title: 'Submit patch set 2 into master',
-          enabled: true,
-        },
-        revert_submission: {
-          method: 'POST',
-          label: 'Revert submission',
-          title: 'Revert this submission',
-          enabled: true,
-        },
-      }));
-      stubRestApi('send').callsFake((method, url, payload) => {
-        if (method !== 'POST') {
-          return Promise.reject(new Error('bad method'));
-        }
-        if (url === '/changes/test~42/revisions/2/submit') {
-          return Promise.resolve({
-            ok: true,
-            text() { return Promise.resolve(')]}\'\n{}'); },
-          });
-        } else if (url === '/changes/test~42/revisions/2/rebase') {
-          return Promise.resolve({
-            ok: true,
-            text() { return Promise.resolve(')]}\'\n{}'); },
-          });
-        }
-        return Promise.reject(new Error('bad url'));
-      });
-      stubRestApi('getProjectConfig').returns(Promise.resolve({}));
-
-      sinon.stub(getPluginLoader(), 'awaitPluginsLoaded')
-          .returns(Promise.resolve());
-
-      element = basicFixture.instantiate();
-      element.change = {};
-      element.changeNum = '42';
-      element.latestPatchNum = '2';
-      element.actions = {
-        '/': {
-          method: 'DELETE',
-          label: 'Delete Change',
-          title: 'Delete change X_X',
-          enabled: true,
-        },
-      };
-      element.account = {
-        _account_id: 123,
-      };
-      stubRestApi('getRepoBranches').returns(Promise.resolve([]));
-
-      return element.reload();
-    });
-
-    test('show-revision-actions event should fire', done => {
-      const spy = sinon.spy(element, '_sendShowRevisionActions');
-      element.reload();
-      flush(() => {
-        assert.isTrue(spy.called);
-        done();
-      });
-    });
-
-    test('primary and secondary actions split properly', () => {
-      // Submit should be the only primary action.
-      assert.equal(element._topLevelPrimaryActions.length, 1);
-      assert.equal(element._topLevelPrimaryActions[0].label, 'Submit');
-      assert.equal(element._topLevelSecondaryActions.length,
-          element._topLevelActions.length - 1);
-    });
-
-    test('revert submission action is skipped', () => {
-      assert.equal(element._allActionValues.filter(action =>
-        action.__key === 'submit').length, 1);
-      assert.equal(element._allActionValues.filter(action =>
-        action.__key === 'revert_submission').length, 0);
-    });
-
-    test('_shouldHideActions', () => {
-      assert.isTrue(element._shouldHideActions(undefined, true));
-      assert.isTrue(element._shouldHideActions({base: {}}, false));
-      assert.isFalse(element._shouldHideActions({base: ['test']}, false));
-    });
-
-    test('plugin revision actions', done => {
-      const stub = stubRestApi('getChangeActionURL').returns(
-          Promise.resolve('the-url'));
-      element.revisionActions = {
-        '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'].__url, 'the-url');
-        done();
-      });
-    });
-
-    test('plugin change actions', async () => {
-      const stub = stubRestApi('getChangeActionURL').returns(
-          Promise.resolve('the-url'));
-      element.actions = {
-        'plugin~action': {},
-      };
-      assert.isOk(element.actions['plugin~action']);
-      await flush();
-      assert.isTrue(stub.calledWith(
-          element.changeNum, undefined, '/plugin~action'));
-      assert.equal(element.actions['plugin~action'].__url, 'the-url');
-    });
-
-    test('not supported actions are filtered out', () => {
-      element.revisionActions = {followup: {}};
-      assert.equal(element.querySelectorAll(
-          'section gr-button[data-action-type="revision"]').length, 0);
-    });
-
-    test('getActionDetails', () => {
-      element.revisionActions = {
-        'plugin~action': {},
-        ...element.revisionActions,
-      };
-      assert.isUndefined(element.getActionDetails('rubbish'));
-      assert.strictEqual(element.revisionActions['plugin~action'],
-          element.getActionDetails('plugin~action'));
-      assert.strictEqual(element.revisionActions['rebase'],
-          element.getActionDetails('rebase'));
-    });
-
-    test('hide revision action', done => {
-      flush(() => {
-        const buttonEl = element.shadowRoot
-            .querySelector('[data-action-key="submit"]');
-        assert.isOk(buttonEl);
-        assert.throws(element.setActionHidden.bind(element, 'invalid type'));
-        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);
-
-          element.setActionHidden(element.ActionType.REVISION,
-              element.RevisionActions.SUBMIT, false);
-          flush(() => {
-            const buttonEl = element.shadowRoot
-                .querySelector('[data-action-key="submit"]');
-            assert.isOk(buttonEl);
-            assert.isFalse(buttonEl.hasAttribute('hidden'));
-            done();
-          });
-        });
-      });
-    });
-
-    test('buttons exist', done => {
-      element._loading = false;
-      flush(() => {
-        const buttonEls = dom(element.root)
-            .querySelectorAll('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();
-      });
-    });
-
-    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.notEqual(deleteItems[0].name);
-        assert.equal(deleteItems[0].name, 'Delete change');
-        done();
-      });
-    });
-
-    test('get revision object from change', () => {
-      const revObj = {_number: 2, foo: 'bar'};
-      const change = {
-        revisions: {
-          rev1: {_number: 1},
-          rev2: revObj,
-        },
-      };
-      assert.deepEqual(element._getRevision(change, 2), revObj);
-    });
-
-    test('_actionComparator sort order', () => {
-      const actions = [
-        {label: '123', __type: 'change', __key: 'review'},
-        {label: 'abc-ro', __type: 'revision'},
-        {label: 'abc', __type: 'change'},
-        {label: 'def', __type: 'change'},
-        {label: 'def-p', __type: 'change', __primary: true},
-      ];
-
-      const result = actions.slice();
-      result.reverse();
-      result.sort(element._actionComparator.bind(element));
-      assert.deepEqual(result, actions);
-    });
-
-    test('submit change', () => {
-      const showSpy = sinon.spy(element, '_showActionDialog');
-      stubRestApi('getFromProjectLookup')
-          .returns(Promise.resolve('test'));
-      sinon.stub(element.$.overlay, 'open').returns(Promise.resolve());
-      element.change = {
-        revisions: {
-          rev1: {_number: 1},
-          rev2: {_number: 2},
-        },
-      };
-      element.latestPatchNum = '2';
-
-      const submitButton = element.shadowRoot
-          .querySelector('gr-button[data-action-key="submit"]');
-      assert.ok(submitButton);
-      MockInteractions.tap(submitButton);
-
-      flush();
-      assert.isTrue(showSpy.calledWith(element.$.confirmSubmitDialog));
-    });
-
-    test('submit change, tap on icon', done => {
-      sinon.stub(element.$.confirmSubmitDialog, 'resetFocus').callsFake( done);
-      stubRestApi('getFromProjectLookup')
-          .returns(Promise.resolve('test'));
-      sinon.stub(element.$.overlay, 'open').returns(Promise.resolve());
-      element.change = {
-        revisions: {
-          rev1: {_number: 1},
-          rev2: {_number: 2},
-        },
-      };
-      element.latestPatchNum = '2';
-
-      const submitIcon =
-          element.shadowRoot
-              .querySelector('gr-button[data-action-key="submit"] iron-icon');
-      assert.ok(submitIcon);
-      MockInteractions.tap(submitIcon);
-    });
-
-    test('_handleSubmitConfirm', () => {
-      const fireStub = sinon.stub(element, '_fireAction');
-      sinon.stub(element, '_canSubmitChange').returns(true);
-      element._handleSubmitConfirm();
-      assert.isTrue(fireStub.calledOnce);
-      assert.deepEqual(fireStub.lastCall.args,
-          ['/submit', element.revisionActions.submit, true]);
-    });
-
-    test('_handleSubmitConfirm when not able to submit', () => {
-      const fireStub = sinon.stub(element, '_fireAction');
-      sinon.stub(element, '_canSubmitChange').returns(false);
-      element._handleSubmitConfirm();
-      assert.isFalse(fireStub.called);
-    });
-
-    test('submit change with plugin hook', done => {
-      sinon.stub(element, '_canSubmitChange').callsFake(
-          () => false);
-      const fireActionStub = sinon.stub(element, '_fireAction');
-      flush(() => {
-        const submitButton = element.shadowRoot
-            .querySelector('gr-button[data-action-key="submit"]');
-        assert.ok(submitButton);
-        MockInteractions.tap(submitButton);
-        assert.equal(fireActionStub.callCount, 0);
-
-        done();
-      });
-    });
-
-    test('chain state', () => {
-      assert.equal(element._hasKnownChainState, false);
-      element.hasParent = true;
-      assert.equal(element._hasKnownChainState, true);
-      element.hasParent = false;
-    });
-
-    test('_calculateDisabled', () => {
-      let hasKnownChainState = false;
-      const action = {__key: 'rebase', enabled: true};
-      assert.equal(
-          element._calculateDisabled(action, hasKnownChainState), true);
-
-      action.__key = 'delete';
-      assert.equal(
-          element._calculateDisabled(action, hasKnownChainState), false);
-
-      action.__key = 'rebase';
-      hasKnownChainState = true;
-      assert.equal(
-          element._calculateDisabled(action, hasKnownChainState), false);
-
-      action.enabled = false;
-      assert.equal(
-          element._calculateDisabled(action, hasKnownChainState), false);
-    });
-
-    test('rebase change', done => {
-      const fireActionStub = sinon.stub(element, '_fireAction');
-      const fetchChangesStub = sinon.stub(element.$.confirmRebase,
-          'fetchRecentChanges').returns(Promise.resolve([]));
-      element._hasKnownChainState = true;
-      flush(() => {
-        const rebaseButton = element.shadowRoot
-            .querySelector('gr-button[data-action-key="rebase"]');
-        MockInteractions.tap(rebaseButton);
-        const rebaseAction = {
-          __key: 'rebase',
-          __type: 'revision',
-          __primary: false,
-          enabled: true,
-          label: 'Rebase',
-          method: 'POST',
-          title: 'Rebase onto tip of branch or parent change',
-        };
-        assert.isTrue(fetchChangesStub.called);
-        element._handleRebaseConfirm({detail: {base: '1234'}});
-        assert.deepEqual(fireActionStub.lastCall.args,
-            ['/rebase', rebaseAction, true, {base: '1234'}]);
-        done();
-      });
-    });
-
-    test('rebase change fires reload event', done => {
-      const eventStub = sinon.stub(element, 'dispatchEvent');
-      stubRestApi('getResponseObject').returns(
-          Promise.resolve({}));
-      element._handleResponse({__key: 'rebase'}, {});
-      flush(() => {
-        assert.isTrue(eventStub.called);
-        assert.equal(eventStub.lastCall.args[0].type, 'reload');
-        done();
-      });
-    });
-
-    test(`rebase dialog gets recent changes each time it's opened`, done => {
-      const fetchChangesStub = sinon.stub(element.$.confirmRebase,
-          'fetchRecentChanges').returns(Promise.resolve([]));
-      element._hasKnownChainState = true;
-      const rebaseButton = element.shadowRoot
-          .querySelector('gr-button[data-action-key="rebase"]');
-      MockInteractions.tap(rebaseButton);
-      assert.isTrue(fetchChangesStub.calledOnce);
-
-      flush(() => {
-        element.$.confirmRebase.dispatchEvent(
-            new CustomEvent('cancel', {
-              composed: true, bubbles: true,
-            }));
-        MockInteractions.tap(rebaseButton);
-        assert.isTrue(fetchChangesStub.calledTwice);
-        done();
-      });
-    });
-
-    test('two dialogs are not shown at the same time', async () => {
-      element._hasKnownChainState = true;
-      await flush();
-      const rebaseButton = element.shadowRoot
-          .querySelector('gr-button[data-action-key="rebase"]');
-      assert.ok(rebaseButton);
-      MockInteractions.tap(rebaseButton);
-      await flush();
-      assert.isFalse(element.$.confirmRebase.hidden);
-      stubRestApi('getChanges')
-          .returns(Promise.resolve([]));
-      element._handleCherrypickTap();
-      await flush();
-      assert.isTrue(element.$.confirmRebase.hidden);
-      assert.isFalse(element.$.confirmCherrypick.hidden);
-    });
-
-    test('fullscreen-overlay-opened hides content', () => {
-      sinon.spy(element, '_handleHideBackgroundContent');
-      element.$.overlay.dispatchEvent(
-          new CustomEvent('fullscreen-overlay-opened', {
-            composed: true, bubbles: true,
-          }));
-      assert.isTrue(element._handleHideBackgroundContent.called);
-      assert.isTrue(element.$.mainContent.classList.contains('overlayOpen'));
-    });
-
-    test('fullscreen-overlay-closed shows content', () => {
-      sinon.spy(element, '_handleShowBackgroundContent');
-      element.$.overlay.dispatchEvent(
-          new CustomEvent('fullscreen-overlay-closed', {
-            composed: true, bubbles: true,
-          }));
-      assert.isTrue(element._handleShowBackgroundContent.called);
-      assert.isFalse(element.$.mainContent.classList.contains('overlayOpen'));
-    });
-
-    test('_setReviewOnRevert', () => {
-      const review = {labels: {'Foo': 1, 'Bar-Baz': -2}};
-      const changeId = 1234;
-      sinon.stub(element.jsAPI, 'getReviewPostRevert').returns(review);
-      const saveStub = stubRestApi('saveChangeReview')
-          .returns(Promise.resolve());
-      return element._setReviewOnRevert(changeId).then(() => {
-        assert.isTrue(saveStub.calledOnce);
-        assert.equal(saveStub.lastCall.args[0], changeId);
-        assert.deepEqual(saveStub.lastCall.args[2], review);
-      });
-    });
-
-    suite('change edits', () => {
-      test('disableEdit', () => {
-        element.set('editMode', false);
-        element.set('editPatchsetLoaded', false);
-        element.change = {status: 'NEW'};
-        element.set('disableEdit', true);
-        flush();
-
-        assert.isNotOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="publishEdit"]'));
-        assert.isNotOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="rebaseEdit"]'));
-        assert.isNotOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="deleteEdit"]'));
-        assert.isNotOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="edit"]'));
-        assert.isNotOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="stopEdit"]'));
-      });
-
-      test('shows confirm dialog for delete edit', () => {
-        element.set('editMode', true);
-        element.set('editPatchsetLoaded', true);
-
-        const fireActionStub = sinon.stub(element, '_fireAction');
-        element._handleDeleteEditTap();
-        assert.isFalse(element.$.confirmDeleteEditDialog.hidden);
-        MockInteractions.tap(
-            element.shadowRoot
-                .querySelector('#confirmDeleteEditDialog')
-                .shadowRoot
-                .querySelector('gr-button[primary]'));
-        flush();
-
-        assert.equal(fireActionStub.lastCall.args[0], '/edit');
-      });
-
-      test('hide publishEdit and rebaseEdit if change is not open', () => {
-        element.set('editMode', true);
-        element.set('editPatchsetLoaded', true);
-        element.change = {status: 'MERGED'};
-        flush();
-
-        assert.isNotOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="publishEdit"]'));
-        assert.isNotOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="rebaseEdit"]'));
-        assert.isOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="deleteEdit"]'));
-        assert.isNotOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="edit"]'));
-      });
-
-      test('edit patchset is loaded, needs rebase', () => {
-        element.set('editMode', true);
-        element.set('editPatchsetLoaded', true);
-        element.change = {status: 'NEW'};
-        element.editBasedOnCurrentPatchSet = false;
-        flush();
-
-        assert.isNotOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="publishEdit"]'));
-        assert.isOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="rebaseEdit"]'));
-        assert.isOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="deleteEdit"]'));
-        assert.isNotOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="edit"]'));
-        assert.isNotOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="stopEdit"]'));
-      });
-
-      test('edit patchset is loaded, does not need rebase', () => {
-        element.set('editMode', true);
-        element.set('editPatchsetLoaded', true);
-        element.change = {status: 'NEW'};
-        element.editBasedOnCurrentPatchSet = true;
-        flush();
-
-        assert.isOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="publishEdit"]'));
-        assert.isNotOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="rebaseEdit"]'));
-        assert.isOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="deleteEdit"]'));
-        assert.isNotOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="edit"]'));
-        assert.isNotOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="stopEdit"]'));
-      });
-
-      test('edit mode is loaded, no edit patchset', () => {
-        element.set('editMode', true);
-        element.set('editPatchsetLoaded', false);
-        element.change = {status: 'NEW'};
-        flush();
-
-        assert.isNotOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="publishEdit"]'));
-        assert.isNotOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="rebaseEdit"]'));
-        assert.isNotOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="deleteEdit"]'));
-        assert.isNotOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="edit"]'));
-        assert.isOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="stopEdit"]'));
-      });
-
-      test('normal patch set', () => {
-        element.set('editMode', false);
-        element.set('editPatchsetLoaded', false);
-        element.change = {status: 'NEW'};
-        flush();
-
-        assert.isNotOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="publishEdit"]'));
-        assert.isNotOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="rebaseEdit"]'));
-        assert.isNotOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="deleteEdit"]'));
-        assert.isOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="edit"]'));
-        assert.isNotOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="stopEdit"]'));
-      });
-
-      test('edit action', done => {
-        element.addEventListener('edit-tap', () => { done(); });
-        element.set('editMode', true);
-        element.change = {status: 'NEW'};
-        flush();
-
-        assert.isNotOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="edit"]'));
-        assert.isOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="stopEdit"]'));
-        element.change = {status: 'MERGED'};
-        flush();
-
-        assert.isNotOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="edit"]'));
-        element.change = {status: 'NEW'};
-        element.set('editMode', false);
-        flush();
-
-        const editButton = element.shadowRoot
-            .querySelector('gr-button[data-action-key="edit"]');
-        assert.isOk(editButton);
-        MockInteractions.tap(editButton);
-      });
-    });
-
-    suite('cherry-pick', () => {
-      let fireActionStub;
-
-      setup(() => {
-        fireActionStub = sinon.stub(element, '_fireAction');
-        sinon.stub(window, 'alert');
-      });
-
-      test('works', () => {
-        element._handleCherrypickTap();
-        const action = {
-          __key: 'cherrypick',
-          __type: 'revision',
-          __primary: false,
-          enabled: true,
-          label: 'Cherry pick',
-          method: 'POST',
-          title: 'Cherry pick change to a different branch',
-        };
-
-        element._handleCherrypickConfirm({
-          detail: {
-            branch: '',
-            type: CHERRY_PICK_TYPES.SINGLE_CHANGE,
-          },
-        });
-        assert.equal(fireActionStub.callCount, 0);
-
-        element.$.confirmCherrypick.branch = 'master';
-        element._handleCherrypickConfirm({
-          detail: {
-            branch: 'master',
-            type: CHERRY_PICK_TYPES.SINGLE_CHANGE,
-          },
-        });
-        assert.equal(fireActionStub.callCount, 0); // Still needs a message.
-
-        // Add attributes that are used to determine the message.
-        element.$.confirmCherrypick.commitMessage = 'foo message';
-        element.$.confirmCherrypick.changeStatus = 'OPEN';
-        element.$.confirmCherrypick.commitNum = '123';
-
-        element._handleCherrypickConfirm({
-          detail: {
-            branch: 'master',
-            type: CHERRY_PICK_TYPES.SINGLE_CHANGE,
-          },
-        });
-
-        assert.equal(element.$.confirmCherrypick.shadowRoot.
-            querySelector('#messageInput').value, 'foo message');
-
-        assert.deepEqual(fireActionStub.lastCall.args, [
-          '/cherrypick', action, true, {
-            destination: 'master',
-            base: null,
-            message: 'foo message',
-            allow_conflicts: false,
-          },
-        ]);
-      });
-
-      test('cherry pick even with conflicts', () => {
-        element._handleCherrypickTap();
-        const action = {
-          __key: 'cherrypick',
-          __type: 'revision',
-          __primary: false,
-          enabled: true,
-          label: 'Cherry pick',
-          method: 'POST',
-          title: 'Cherry pick change to a different branch',
-        };
-
-        element.$.confirmCherrypick.branch = 'master';
-
-        // Add attributes that are used to determine the message.
-        element.$.confirmCherrypick.commitMessage = 'foo message';
-        element.$.confirmCherrypick.changeStatus = 'OPEN';
-        element.$.confirmCherrypick.commitNum = '123';
-
-        element._handleCherrypickConflictConfirm();
-
-        assert.deepEqual(fireActionStub.lastCall.args, [
-          '/cherrypick', action, true, {
-            destination: 'master',
-            base: null,
-            message: 'foo message',
-            allow_conflicts: true,
-          },
-        ]);
-      });
-
-      test('branch name cleared when re-open cherrypick', () => {
-        const emptyBranchName = '';
-        element.$.confirmCherrypick.branch = 'master';
-
-        element._handleCherrypickTap();
-        assert.equal(element.$.confirmCherrypick.branch, emptyBranchName);
-      });
-
-      suite('cherry pick topics', () => {
-        const changes = [
-          {
-            change_id: '12345678901234', topic: 'T', subject: 'random',
-            project: 'A', status: 'MERGED',
-          },
-          {
-            change_id: '23456', topic: 'T', subject: 'a'.repeat(100),
-            project: 'B', status: 'NEW',
-          },
-        ];
-        setup(done => {
-          stubRestApi('getChanges')
-              .returns(Promise.resolve(changes));
-          element._handleCherrypickTap();
-          flush(() => {
-            const radioButtons = element.$.confirmCherrypick.shadowRoot.
-                querySelectorAll(`input[name='cherryPickOptions']`);
-            assert.equal(radioButtons.length, 2);
-            MockInteractions.tap(radioButtons[1]);
-            flush(() => {
-              done();
-            });
-          });
-        });
-
-        test('cherry pick topic dialog is rendered', done => {
-          const dialog = element.$.confirmCherrypick;
-          flush(() => {
-            const changesTable = dialog.shadowRoot.querySelector('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 = changesTable.querySelectorAll('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();
-          });
-        });
-
-        test('changes with duplicate project show an error', done => {
-          const dialog = element.$.confirmCherrypick;
-          const error = dialog.shadowRoot.querySelector('.error-message');
-          assert.equal(error.innerText, '');
-          dialog.updateChanges([
-            {
-              change_id: '12345678901234', topic: 'T', subject: 'random',
-              project: 'A',
-            },
-            {
-              change_id: '23456', topic: 'T', subject: 'a'.repeat(100),
-              project: 'A',
-            },
-          ]);
-          flush(() => {
-            assert.equal(error.innerText, 'Two changes cannot be of the same'
-             + ' project');
-            done();
-          });
-        });
-      });
-    });
-
-    suite('move change', () => {
-      let fireActionStub;
-
-      setup(() => {
-        fireActionStub = sinon.stub(element, '_fireAction');
-        sinon.stub(window, 'alert');
-        element.actions = {
-          move: {
-            method: 'POST',
-            label: 'Move',
-            title: 'Move the change',
-            enabled: true,
-          },
-        };
-      });
-
-      test('works', () => {
-        element._handleMoveTap();
-
-        element._handleMoveConfirm();
-        assert.equal(fireActionStub.callCount, 0);
-
-        element.$.confirmMove.branch = 'master';
-        element._handleMoveConfirm();
-        assert.equal(fireActionStub.callCount, 1);
-      });
-
-      test('branch name cleared when re-open move', () => {
-        const emptyBranchName = '';
-        element.$.confirmMove.branch = 'master';
-
-        element._handleMoveTap();
-        assert.equal(element.$.confirmMove.branch, emptyBranchName);
-      });
-    });
-
-    test('custom actions', done => {
-      // 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 => {
-        assert.equal(e.detail.node.getAttribute('data-action-key'), key);
-        element.removeActionButton(key);
-        flush(() => {
-          assert.notOk(element.shadowRoot
-              .querySelector('[data-action-key="' + key + '"]'));
-          done();
-        });
-      });
-      flush(() => {
-        MockInteractions.tap(element.shadowRoot
-            .querySelector('[data-action-key="' + key + '"]'));
-      });
-    });
-
-    test('_setLoadingOnButtonWithKey top-level', () => {
-      const key = 'rebase';
-      const type = 'revision';
-      const cleanup = element._setLoadingOnButtonWithKey(type, key);
-      assert.equal(element._actionLoadingMessage, 'Rebasing...');
-
-      const button = element.shadowRoot
-          .querySelector('[data-action-key="' + key + '"]');
-      assert.isTrue(button.hasAttribute('loading'));
-      assert.isTrue(button.disabled);
-
-      assert.isOk(cleanup);
-      assert.isFunction(cleanup);
-      cleanup();
-
-      assert.isFalse(button.hasAttribute('loading'));
-      assert.isFalse(button.disabled);
-      assert.isNotOk(element._actionLoadingMessage);
-    });
-
-    test('_setLoadingOnButtonWithKey overflow menu', () => {
-      const key = 'cherrypick';
-      const type = 'revision';
-      const cleanup = element._setLoadingOnButtonWithKey(type, key);
-      assert.equal(element._actionLoadingMessage, 'Cherry-picking...');
-      assert.include(element._disabledMenuActions, 'cherrypick');
-      assert.isFunction(cleanup);
-
-      cleanup();
-
-      assert.notOk(element._actionLoadingMessage);
-      assert.notInclude(element._disabledMenuActions, 'cherrypick');
-    });
-
-    suite('abandon change', () => {
-      let alertStub;
-      let fireActionStub;
-
-      setup(() => {
-        fireActionStub = sinon.stub(element, '_fireAction');
-        alertStub = sinon.stub(window, 'alert');
-        element.actions = {
-          abandon: {
-            method: 'POST',
-            label: 'Abandon',
-            title: 'Abandon the change',
-            enabled: true,
-          },
-        };
-        return element.reload();
-      });
-
-      test('abandon change with message', done => {
-        const newAbandonMsg = 'Test Abandon Message';
-        element.$.confirmAbandonDialog.message = newAbandonMsg;
-        flush(() => {
-          const abandonButton =
-              element.shadowRoot
-                  .querySelector('gr-button[data-action-key="abandon"]');
-          MockInteractions.tap(abandonButton);
-
-          assert.equal(element.$.confirmAbandonDialog.message, newAbandonMsg);
-          done();
-        });
-      });
-
-      test('abandon change with no message', done => {
-        flush(() => {
-          const abandonButton =
-              element.shadowRoot
-                  .querySelector('gr-button[data-action-key="abandon"]');
-          MockInteractions.tap(abandonButton);
-
-          assert.isUndefined(element.$.confirmAbandonDialog.message);
-          done();
-        });
-      });
-
-      test('works', () => {
-        element.$.confirmAbandonDialog.message = 'original message';
-        const restoreButton =
-            element.shadowRoot
-                .querySelector('gr-button[data-action-key="abandon"]');
-        MockInteractions.tap(restoreButton);
-
-        element.$.confirmAbandonDialog.message = 'foo message';
-        element._handleAbandonDialogConfirm();
-        assert.notOk(alertStub.called);
-
-        const action = {
-          __key: 'abandon',
-          __type: 'change',
-          __primary: false,
-          enabled: true,
-          label: 'Abandon',
-          method: 'POST',
-          title: 'Abandon the change',
-        };
-        assert.deepEqual(fireActionStub.lastCall.args, [
-          '/abandon', action, false, {
-            message: 'foo message',
-          }]);
-      });
-    });
-
-    suite('revert change', () => {
-      let fireActionStub;
-
-      setup(() => {
-        fireActionStub = sinon.stub(element, '_fireAction');
-        element.commitMessage = 'random commit message';
-        element.change.current_revision = 'abcdef';
-        element.actions = {
-          revert: {
-            method: 'POST',
-            label: 'Revert',
-            title: 'Revert the change',
-            enabled: true,
-          },
-        };
-        return element.reload();
-      });
-
-      test('revert change with plugin hook', done => {
-        const newRevertMsg = 'Modified revert msg';
-        sinon.stub(element.$.confirmRevertDialog, '_modifyRevertMsg').callsFake(
-            () => newRevertMsg);
-        element.change = {
-          current_revision: 'abc1234',
-        };
-        stubRestApi('getChanges')
-            .returns(Promise.resolve([
-              {change_id: '12345678901234', topic: 'T', subject: 'random'},
-              {change_id: '23456', topic: 'T', subject: 'a'.repeat(100)},
-            ]));
-        sinon.stub(element.$.confirmRevertDialog,
-            '_populateRevertSubmissionMessage').callsFake(() => 'original msg');
-        flush(() => {
-          const revertButton = element.shadowRoot
-              .querySelector('gr-button[data-action-key="revert"]');
-          MockInteractions.tap(revertButton);
-          flush(() => {
-            assert.equal(element.$.confirmRevertDialog._message, newRevertMsg);
-            done();
-          });
-        });
-      });
-
-      suite('revert change submitted together', () => {
-        let getChangesStub;
-        setup(() => {
-          element.change = {
-            submission_id: '199 0',
-            current_revision: '2000',
-          };
-          getChangesStub = stubRestApi('getChanges')
-              .returns(Promise.resolve([
-                {change_id: '12345678901234', topic: 'T', subject: 'random'},
-                {change_id: '23456', topic: 'T', subject: 'a'.repeat(100)},
-              ]));
-        });
-
-        test('confirm revert dialog shows both options', done => {
-          const revertButton = element.shadowRoot
-              .querySelector('gr-button[data-action-key="revert"]');
-          MockInteractions.tap(revertButton);
-          flush(() => {
-            assert.equal(getChangesStub.args[0][1], 'submissionid: "199 0"');
-            const confirmRevertDialog = element.$.confirmRevertDialog;
-            const revertSingleChangeLabel = confirmRevertDialog
-                .shadowRoot.querySelector('.revertSingleChange');
-            const revertSubmissionLabel = confirmRevertDialog.
-                shadowRoot.querySelector('.revertSubmission');
-            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 = confirmRevertDialog.shadowRoot
-                .querySelectorAll('input[name="revertOptions"]');
-            MockInteractions.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();
-            });
-          });
-        });
-
-        test('submit fails if message is not edited', done => {
-          const revertButton = element.shadowRoot
-              .querySelector('gr-button[data-action-key="revert"]');
-          const confirmRevertDialog = element.$.confirmRevertDialog;
-          MockInteractions.tap(revertButton);
-          const fireStub = sinon.stub(confirmRevertDialog, 'dispatchEvent');
-          flush(() => {
-            const confirmButton = element.$.confirmRevertDialog.shadowRoot
-                .querySelector('gr-dialog')
-                .shadowRoot.querySelector('#confirm');
-            MockInteractions.tap(confirmButton);
-            flush(() => {
-              assert.isTrue(confirmRevertDialog._showErrorMessage);
-              assert.isFalse(fireStub.called);
-              done();
-            });
-          });
-        });
-
-        test('message modification is retained on switching', done => {
-          const revertButton = element.shadowRoot
-              .querySelector('gr-button[data-action-key="revert"]');
-          const confirmRevertDialog = element.$.confirmRevertDialog;
-          MockInteractions.tap(revertButton);
-          flush(() => {
-            const radioInputs = confirmRevertDialog.shadowRoot
-                .querySelectorAll('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;
-            MockInteractions.tap(radioInputs[0]);
-            flush(() => {
-              assert.equal(confirmRevertDialog._message, singleChangeMsg);
-              confirmRevertDialog._message = newSingleChangeMsg;
-              MockInteractions.tap(radioInputs[1]);
-              flush(() => {
-                assert.equal(confirmRevertDialog._message, newRevertMsg);
-                MockInteractions.tap(radioInputs[0]);
-                flush(() => {
-                  assert.equal(
-                      confirmRevertDialog._message,
-                      newSingleChangeMsg
-                  );
-                  done();
-                });
-              });
-            });
-          });
-        });
-      });
-
-      suite('revert single change', () => {
-        setup(() => {
-          element.change = {
-            submission_id: '199',
-            current_revision: '2000',
-          };
-          stubRestApi('getChanges')
-              .returns(Promise.resolve([
-                {change_id: '12345678901234', topic: 'T', subject: 'random'},
-              ]));
-        });
-
-        test('submit fails if message is not edited', done => {
-          const revertButton = element.shadowRoot
-              .querySelector('gr-button[data-action-key="revert"]');
-          const confirmRevertDialog = element.$.confirmRevertDialog;
-          MockInteractions.tap(revertButton);
-          const fireStub = sinon.stub(confirmRevertDialog, 'dispatchEvent');
-          flush(() => {
-            const confirmButton = element.$.confirmRevertDialog.shadowRoot
-                .querySelector('gr-dialog')
-                .shadowRoot.querySelector('#confirm');
-            MockInteractions.tap(confirmButton);
-            flush(() => {
-              assert.isTrue(confirmRevertDialog._showErrorMessage);
-              assert.isFalse(fireStub.called);
-              done();
-            });
-          });
-        });
-
-        test('confirm revert dialog shows no radio button', done => {
-          const revertButton = element.shadowRoot
-              .querySelector('gr-button[data-action-key="revert"]');
-          MockInteractions.tap(revertButton);
-          flush(() => {
-            const confirmRevertDialog = element.$.confirmRevertDialog;
-            const radioInputs = confirmRevertDialog.shadowRoot
-                .querySelectorAll('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 = element.$.confirmRevertDialog.shadowRoot
-                .querySelector('gr-dialog')
-                .shadowRoot.querySelector('#confirm');
-            MockInteractions.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();
-            });
-          });
-        });
-      });
-    });
-
-    suite('mark change private', () => {
-      setup(() => {
-        const privateAction = {
-          __key: 'private',
-          __type: 'change',
-          __primary: false,
-          method: 'POST',
-          label: 'Mark private',
-          title: 'Working...',
-          enabled: true,
-        };
-
-        element.actions = {
-          private: privateAction,
-        };
-
-        element.change.is_private = false;
-
-        element.changeNum = '2';
-        element.latestPatchNum = '2';
-
-        return element.reload();
-      });
-
-      test('make sure the mark private change button is not outside of the ' +
-           'overflow menu', done => {
-        flush(() => {
-          assert.isNotOk(element.shadowRoot
-              .querySelector('[data-action-key="private"]'));
-          done();
-        });
-      });
-
-      test('private change', done => {
-        flush(() => {
-          assert.isOk(
-              element.$.moreActions.shadowRoot
-                  .querySelector('span[data-id="private-change"]'));
-          element.setActionOverflow('change', 'private', false);
-          flush();
-          assert.isOk(element.shadowRoot
-              .querySelector('[data-action-key="private"]'));
-          assert.isNotOk(
-              element.$.moreActions.shadowRoot
-                  .querySelector('span[data-id="private-change"]'));
-          done();
-        });
-      });
-    });
-
-    suite('unmark private change', () => {
-      setup(() => {
-        const unmarkPrivateAction = {
-          __key: 'private.delete',
-          __type: 'change',
-          __primary: false,
-          method: 'POST',
-          label: 'Unmark private',
-          title: 'Working...',
-          enabled: true,
-        };
-
-        element.actions = {
-          'private.delete': unmarkPrivateAction,
-        };
-
-        element.change.is_private = true;
-
-        element.changeNum = '2';
-        element.latestPatchNum = '2';
-
-        return element.reload();
-      });
-
-      test('make sure the unmark private change button is not outside of the ' +
-           'overflow menu', done => {
-        flush(() => {
-          assert.isNotOk(element.shadowRoot
-              .querySelector('[data-action-key="private.delete"]'));
-          done();
-        });
-      });
-
-      test('unmark the private change', done => {
-        flush(() => {
-          assert.isOk(
-              element.$.moreActions.shadowRoot
-                  .querySelector('span[data-id="private.delete-change"]')
-          );
-          element.setActionOverflow('change', 'private.delete', false);
-          flush();
-          assert.isOk(element.shadowRoot
-              .querySelector('[data-action-key="private.delete"]'));
-          assert.isNotOk(
-              element.$.moreActions.shadowRoot
-                  .querySelector('span[data-id="private.delete-change"]')
-          );
-          done();
-        });
-      });
-    });
-
-    suite('delete change', () => {
-      let fireActionStub;
-      let deleteAction;
-
-      setup(() => {
-        fireActionStub = sinon.stub(element, '_fireAction');
-        element.change = {
-          current_revision: 'abc1234',
-        };
-        deleteAction = {
-          method: 'DELETE',
-          label: 'Delete Change',
-          title: 'Delete change X_X',
-          enabled: true,
-        };
-        element.actions = {
-          '/': deleteAction,
-        };
-      });
-
-      test('does not delete on action', () => {
-        element._handleDeleteTap();
-        assert.isFalse(fireActionStub.called);
-      });
-
-      test('shows confirm dialog', () => {
-        element._handleDeleteTap();
-        assert.isFalse(element.shadowRoot
-            .querySelector('#confirmDeleteDialog').hidden);
-        MockInteractions.tap(
-            element.shadowRoot
-                .querySelector('#confirmDeleteDialog')
-                .shadowRoot
-                .querySelector('gr-button[primary]'));
-        flush();
-        assert.isTrue(fireActionStub.calledWith('/', deleteAction, false));
-      });
-
-      test('hides delete confirm on cancel', () => {
-        element._handleDeleteTap();
-        MockInteractions.tap(
-            element.shadowRoot
-                .querySelector('#confirmDeleteDialog')
-                .shadowRoot
-                .querySelector('gr-button:not([primary])'));
-        flush();
-        assert.isTrue(element.shadowRoot
-            .querySelector('#confirmDeleteDialog').hidden);
-        assert.isFalse(fireActionStub.called);
-      });
-    });
-
-    suite('ignore change', () => {
-      setup(done => {
-        sinon.stub(element, '_fireAction');
-
-        const IgnoreAction = {
-          __key: 'ignore',
-          __type: 'change',
-          __primary: false,
-          method: 'PUT',
-          label: 'Ignore',
-          title: 'Working...',
-          enabled: true,
-        };
-
-        element.actions = {
-          ignore: IgnoreAction,
-        };
-
-        element.changeNum = '2';
-        element.latestPatchNum = '2';
-
-        element.reload().then(() => { flush(done); });
-      });
-
-      test('make sure the ignore button is not outside of the overflow menu',
-          () => {
-            assert.isNotOk(element.shadowRoot
-                .querySelector('[data-action-key="ignore"]'));
-          });
-
-      test('ignoring change', () => {
-        assert.isOk(element.$.moreActions.shadowRoot
-            .querySelector('span[data-id="ignore-change"]'));
-        element.setActionOverflow('change', 'ignore', false);
-        flush();
-        assert.isOk(element.shadowRoot
-            .querySelector('[data-action-key="ignore"]'));
-        assert.isNotOk(
-            element.$.moreActions.shadowRoot
-                .querySelector('span[data-id="ignore-change"]'));
-      });
-    });
-
-    suite('unignore change', () => {
-      setup(done => {
-        sinon.stub(element, '_fireAction');
-
-        const UnignoreAction = {
-          __key: 'unignore',
-          __type: 'change',
-          __primary: false,
-          method: 'PUT',
-          label: 'Unignore',
-          title: 'Working...',
-          enabled: true,
-        };
-
-        element.actions = {
-          unignore: UnignoreAction,
-        };
-
-        element.changeNum = '2';
-        element.latestPatchNum = '2';
-
-        element.reload().then(() => { flush(done); });
-      });
-
-      test('unignore button is not outside of the overflow menu', () => {
-        assert.isNotOk(element.shadowRoot
-            .querySelector('[data-action-key="unignore"]'));
-      });
-
-      test('unignoring change', () => {
-        assert.isOk(
-            element.$.moreActions.shadowRoot
-                .querySelector('span[data-id="unignore-change"]'));
-        element.setActionOverflow('change', 'unignore', false);
-        flush();
-        assert.isOk(element.shadowRoot
-            .querySelector('[data-action-key="unignore"]'));
-        assert.isNotOk(
-            element.$.moreActions.shadowRoot
-                .querySelector('span[data-id="unignore-change"]'));
-      });
-    });
-
-    suite('reviewed change', () => {
-      setup(done => {
-        sinon.stub(element, '_fireAction');
-
-        const ReviewedAction = {
-          __key: 'reviewed',
-          __type: 'change',
-          __primary: false,
-          method: 'PUT',
-          label: 'Mark reviewed',
-          title: 'Working...',
-          enabled: true,
-        };
-
-        element.actions = {
-          reviewed: ReviewedAction,
-        };
-
-        element.changeNum = '2';
-        element.latestPatchNum = '2';
-
-        element.reload().then(() => { flush(done); });
-      });
-
-      test('action is enabled', () => {
-        assert.equal(element._allActionValues.filter(action =>
-          action.__key === 'reviewed').length, 1);
-      });
-
-      test('action is skipped when attention set is enabled', () => {
-        element._config = {
-          change: {enable_attention_set: true},
-        };
-        assert.equal(element._allActionValues.filter(action =>
-          action.__key === 'reviewed').length, 0);
-      });
-
-      test('make sure the reviewed button is not outside of the overflow menu',
-          () => {
-            assert.isNotOk(element.shadowRoot
-                .querySelector('[data-action-key="reviewed"]'));
-          });
-
-      test('reviewing change', () => {
-        assert.isOk(
-            element.$.moreActions.shadowRoot
-                .querySelector('span[data-id="reviewed-change"]'));
-        element.setActionOverflow('change', 'reviewed', false);
-        flush();
-        assert.isOk(element.shadowRoot
-            .querySelector('[data-action-key="reviewed"]'));
-        assert.isNotOk(
-            element.$.moreActions.shadowRoot
-                .querySelector('span[data-id="reviewed-change"]'));
-      });
-    });
-
-    suite('unreviewed change', () => {
-      setup(done => {
-        sinon.stub(element, '_fireAction');
-
-        const UnreviewedAction = {
-          __key: 'unreviewed',
-          __type: 'change',
-          __primary: false,
-          method: 'PUT',
-          label: 'Mark unreviewed',
-          title: 'Working...',
-          enabled: true,
-        };
-
-        element.actions = {
-          unreviewed: UnreviewedAction,
-        };
-
-        element.changeNum = '2';
-        element.latestPatchNum = '2';
-
-        element.reload().then(() => { flush(done); });
-      });
-
-      test('unreviewed button not outside of the overflow menu', () => {
-        assert.isNotOk(element.shadowRoot
-            .querySelector('[data-action-key="unreviewed"]'));
-      });
-
-      test('unreviewed change', () => {
-        assert.isOk(
-            element.$.moreActions.shadowRoot
-                .querySelector('span[data-id="unreviewed-change"]'));
-        element.setActionOverflow('change', 'unreviewed', false);
-        flush();
-        assert.isOk(element.shadowRoot
-            .querySelector('[data-action-key="unreviewed"]'));
-        assert.isNotOk(
-            element.$.moreActions.shadowRoot
-                .querySelector('span[data-id="unreviewed-change"]'));
-      });
-    });
-
-    suite('quick approve', () => {
-      setup(() => {
-        element.change = {
-          current_revision: 'abc1234',
-          labels: {
-            foo: {
-              values: {
-                '-1': '',
-                ' 0': '',
-                '+1': '',
-              },
-            },
-          },
-          permitted_labels: {
-            foo: ['-1', ' 0', '+1'],
-          },
-        };
-        flush();
-      });
-
-      test('added when can approve', () => {
-        const approveButton =
-            element.shadowRoot
-                .querySelector('gr-button[data-action-key=\'review\']');
-        assert.isNotNull(approveButton);
-      });
-
-      test('hide quick approve', () => {
-        const approveButton =
-            element.shadowRoot
-                .querySelector('gr-button[data-action-key=\'review\']');
-        assert.isNotNull(approveButton);
-        assert.isFalse(element._hideQuickApproveAction);
-
-        // Assert approve button gets removed from list of buttons.
-        element.hideQuickApproveAction();
-        flush();
-        const approveButtonUpdated =
-            element.shadowRoot
-                .querySelector('gr-button[data-action-key=\'review\']');
-        assert.isNull(approveButtonUpdated);
-        assert.isTrue(element._hideQuickApproveAction);
-      });
-
-      test('is first in list of secondary actions', () => {
-        const approveButton = element.$.secondaryActions
-            .querySelector('gr-button');
-        assert.equal(approveButton.getAttribute('data-label'), 'foo+1');
-      });
-
-      test('not added when change is merged', () => {
-        element.change.status = ChangeStatus.MERGED;
-        flush(() => {
-          const approveButton =
-          element.shadowRoot
-              .querySelector('gr-button[data-action-key=\'review\']');
-          assert.isNull(approveButton);
-        });
-      });
-
-      test('not added when already approved', () => {
-        element.change = {
-          current_revision: 'abc1234',
-          labels: {
-            foo: {
-              approved: {},
-              values: {},
-            },
-          },
-          permitted_labels: {
-            foo: [' 0', '+1'],
-          },
-        };
-        flush();
-        const approveButton =
-            element.shadowRoot
-                .querySelector('gr-button[data-action-key=\'review\']');
-        assert.isNull(approveButton);
-      });
-
-      test('not added when label not permitted', () => {
-        element.change = {
-          current_revision: 'abc1234',
-          labels: {
-            foo: {values: {}},
-          },
-          permitted_labels: {
-            bar: [],
-          },
-        };
-        flush();
-        const approveButton =
-            element.shadowRoot
-                .querySelector('gr-button[data-action-key=\'review\']');
-        assert.isNull(approveButton);
-      });
-
-      test('approves when tapped', () => {
-        const fireActionStub = sinon.stub(element, '_fireAction');
-        MockInteractions.tap(
-            element.shadowRoot
-                .querySelector('gr-button[data-action-key=\'review\']'));
-        flush();
-        assert.isTrue(fireActionStub.called);
-        assert.isTrue(fireActionStub.calledWith('/review'));
-        const payload = fireActionStub.lastCall.args[3];
-        assert.deepEqual(payload.labels, {foo: 1});
-      });
-
-      test('not added when multiple labels are required without code review',
-          () => {
-            element.change = {
-              current_revision: 'abc1234',
-              labels: {
-                foo: {values: {}},
-                bar: {values: {}},
-              },
-              permitted_labels: {
-                foo: [' 0', '+1'],
-                bar: [' 0', '+1', '+2'],
-              },
-            };
-            flush();
-            const approveButton =
-            element.shadowRoot
-                .querySelector('gr-button[data-action-key=\'review\']');
-            assert.isNull(approveButton);
-          });
-
-      test('code review shown with multiple missing approval', () => {
-        element.change = {
-          current_revision: 'abc1234',
-          labels: {
-            'foo': {values: {}},
-            'bar': {values: {}},
-            'Code-Review': {
-              approved: {},
-              values: {
-                ' 0': '',
-                '+1': '',
-                '+2': '',
-              },
-            },
-          },
-          permitted_labels: {
-            'foo': [' 0', '+1'],
-            'bar': [' 0', '+1', '+2'],
-            'Code-Review': [' 0', '+1', '+2'],
-          },
-        };
-        flush();
-        const approveButton =
-            element.shadowRoot
-                .querySelector('gr-button[data-action-key=\'review\']');
-        assert.isOk(approveButton);
-      });
-
-      test('button label for missing approval', () => {
-        element.change = {
-          current_revision: 'abc1234',
-          labels: {
-            foo: {
-              values: {
-                ' 0': '',
-                '+1': '',
-              },
-            },
-            bar: {approved: {}, values: {}},
-          },
-          permitted_labels: {
-            foo: [' 0', '+1'],
-            bar: [' 0', '+1', '+2'],
-          },
-        };
-        flush();
-        const approveButton =
-            element.shadowRoot
-                .querySelector('gr-button[data-action-key=\'review\']');
-        assert.equal(approveButton.getAttribute('data-label'), 'foo+1');
-      });
-
-      test('no quick approve if score is not maximal for a label', () => {
-        element.change = {
-          current_revision: 'abc1234',
-          labels: {
-            bar: {
-              value: 1,
-              values: {
-                ' 0': '',
-                '+1': '',
-                '+2': '',
-              },
-            },
-          },
-          permitted_labels: {
-            bar: [' 0', '+1'],
-          },
-        };
-        flush();
-        const approveButton =
-            element.shadowRoot
-                .querySelector('gr-button[data-action-key=\'review\']');
-        assert.isNull(approveButton);
-      });
-
-      test('approving label with a non-max score', () => {
-        element.change = {
-          current_revision: 'abc1234',
-          labels: {
-            bar: {
-              value: 1,
-              values: {
-                ' 0': '',
-                '+1': '',
-                '+2': '',
-              },
-            },
-          },
-          permitted_labels: {
-            bar: [' 0', '+1', '+2'],
-          },
-        };
-        flush();
-        const approveButton =
-            element.shadowRoot
-                .querySelector('gr-button[data-action-key=\'review\']');
-        assert.equal(approveButton.getAttribute('data-label'), 'bar+2');
-      });
-
-      test('added when can approve an already-approved code review label',
-          () => {
-            element.change = {
-              current_revision: 'abc1234',
-              labels: {
-                'Code-Review': {
-                  approved: {},
-                  values: {
-                    ' 0': '',
-                    '+1': '',
-                    '+2': '',
-                  },
-                },
-              },
-              permitted_labels: {
-                'Code-Review': [' 0', '+1', '+2'],
-              },
-            };
-            flush();
-            const approveButton =
-                element.shadowRoot
-                    .querySelector('gr-button[data-action-key=\'review\']');
-            assert.isNotNull(approveButton);
-          });
-
-      test('not added when the user has already approved', () => {
-        const vote = {
-          ...createApproval(),
-          _account_id: 123,
-          name: 'name',
-          value: 2,
-        };
-        element.change = {
-          current_revision: 'abc1234',
-          labels: {
-            'Code-Review': {
-              approved: {},
-              values: {
-                ' 0': '',
-                '+1': '',
-                '+2': '',
-              },
-              all: [vote],
-            },
-          },
-          permitted_labels: {
-            'Code-Review': [' 0', '+1', '+2'],
-          },
-        };
-        flush();
-        const approveButton =
-            element.shadowRoot
-                .querySelector('gr-button[data-action-key=\'review\']');
-        assert.isNull(approveButton);
-      });
-
-      test('not added when user owns the change', () => {
-        element.change = {
-          current_revision: 'abc1234',
-          owner: createAccountWithId(123),
-          labels: {
-            'Code-Review': {
-              approved: {},
-              values: {
-                ' 0': '',
-                '+1': '',
-                '+2': '',
-              },
-            },
-          },
-          permitted_labels: {
-            'Code-Review': [' 0', '+1', '+2'],
-          },
-        };
-        flush();
-        const approveButton =
-            element.shadowRoot
-                .querySelector('gr-button[data-action-key=\'review\']');
-        assert.isNull(approveButton);
-      });
-    });
-
-    test('adds download revision action', () => {
-      const handler = sinon.stub();
-      element.addEventListener('download-tap', handler);
-      assert.ok(element.revisionActions.download);
-      element._handleDownloadTap();
-      flush();
-
-      assert.isTrue(handler.called);
-    });
-
-    test('changing changeNum or patchNum does not reload', () => {
-      const reloadStub = sinon.stub(element, 'reload');
-      element.changeNum = 123;
-      assert.isFalse(reloadStub.called);
-      element.latestPatchNum = 456;
-      assert.isFalse(reloadStub.called);
-    });
-
-    test('_toSentenceCase', () => {
-      assert.equal(element._toSentenceCase('blah blah'), 'Blah blah');
-      assert.equal(element._toSentenceCase('BLAH BLAH'), 'Blah blah');
-      assert.equal(element._toSentenceCase('b'), 'B');
-      assert.equal(element._toSentenceCase(''), '');
-      assert.equal(element._toSentenceCase('!@#$%^&*()'), '!@#$%^&*()');
-    });
-
-    suite('setActionOverflow', () => {
-      test('move action from overflow', () => {
-        assert.isNotOk(element.shadowRoot
-            .querySelector('[data-action-key="cherrypick"]'));
-        assert.strictEqual(
-            element.$.moreActions.items[0].id, 'cherrypick-revision');
-        element.setActionOverflow('revision', 'cherrypick', false);
-        flush();
-        assert.isOk(element.shadowRoot
-            .querySelector('[data-action-key="cherrypick"]'));
-        assert.notEqual(
-            element.$.moreActions.items[0].id, 'cherrypick-revision');
-      });
-
-      test('move action to overflow', () => {
-        assert.isOk(element.shadowRoot
-            .querySelector('[data-action-key="submit"]'));
-        element.setActionOverflow('revision', 'submit', true);
-        flush();
-        assert.isNotOk(element.shadowRoot
-            .querySelector('[data-action-key="submit"]'));
-        assert.strictEqual(
-            element.$.moreActions.items[3].id, 'submit-revision');
-      });
-
-      suite('_waitForChangeReachable', () => {
-        let clock;
-        setup(() => {
-          clock = sinon.useFakeTimers();
-        });
-
-        const makeGetChange = numTries => () => {
-          if (numTries === 1) {
-            return Promise.resolve({_number: 123});
-          } else {
-            numTries--;
-            return Promise.resolve(undefined);
-          }
-        };
-
-        const tickAndFlush = async repetitions => {
-          for (let i = 1; i <= repetitions; i++) {
-            clock.tick(1000);
-            await flush();
-          }
-        };
-
-        test('succeed', async () => {
-          stubRestApi('getChange').callsFake(makeGetChange(5));
-          const promise = element._waitForChangeReachable(123);
-          tickAndFlush(5);
-          const success = await promise;
-          assert.isTrue(success);
-        });
-
-        test('fail', async () => {
-          stubRestApi('getChange').callsFake(makeGetChange(6));
-          const promise = element._waitForChangeReachable(123);
-          tickAndFlush(6);
-          const success = await promise;
-          assert.isFalse(success);
-        });
-      });
-    });
-
-    suite('_send', () => {
-      let cleanup;
-      let payload;
-      let onShowError;
-      let onShowAlert;
-      let getResponseObjectStub;
-
-      setup(() => {
-        cleanup = sinon.stub();
-        element.changeNum = 42;
-        element.change._number = 42;
-        element.latestPatchNum = 12;
-        element.change = {
-          ...createChange(),
-          revisions: createRevisions(element.latestPatchNum),
-          messages: createChangeMessages(1),
-        };
-        payload = {foo: 'bar'};
-
-        onShowError = sinon.stub();
-        element.addEventListener('show-error', onShowError);
-        onShowAlert = sinon.stub();
-        element.addEventListener('show-alert', onShowAlert);
-      });
-
-      suite('happy path', () => {
-        let sendStub;
-        setup(() => {
-          stubRestApi('getChangeDetail')
-              .returns(Promise.resolve({
-                ...createChange(),
-                // element has latest info
-                revisions: createRevisions(element.latestPatchNum),
-                messages: createChangeMessages(1),
-              }));
-          sendStub = stubRestApi('executeChangeAction')
-              .returns(Promise.resolve({}));
-          getResponseObjectStub = stubRestApi(
-              'getResponseObject');
-          sinon.stub(GerritNav,
-              'navigateToChange').returns(Promise.resolve(true));
-        });
-
-        test('change action', async () => {
-          await element._send('DELETE', payload, '/endpoint', false, cleanup);
-          assert.isFalse(onShowError.called);
-          assert.isTrue(cleanup.calledOnce);
-          assert.isTrue(sendStub.calledWith(42, 'DELETE', '/endpoint',
-              undefined, payload));
-        });
-
-        suite('show revert submission dialog', () => {
-          setup(() => {
-            element.change.submission_id = '199';
-            element.change.current_revision = '2000';
-            stubRestApi('getChanges')
-                .returns(Promise.resolve([
-                  {change_id: '12345678901234', topic: 'T', subject: 'random'},
-                  {change_id: '23456', topic: 'T', subject: 'a'.repeat(100)},
-                ]));
-          });
-
-          test('revert submission shows submissionId', done => {
-            const expectedMsg = 'Revert submission 199' + '\n\n' +
-              'Reason for revert: <INSERT REASONING HERE>' + '\n' +
-              'Reverted Changes:' + '\n' +
-              '1234567890: random' + '\n' +
-              '23456: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa...' +
-              '\n';
-            const modifiedMsg = expectedMsg + 'abcd';
-            sinon.stub(element.$.confirmRevertSubmissionDialog,
-                '_modifyRevertSubmissionMsg').returns(modifiedMsg);
-            element.showRevertSubmissionDialog();
-            flush(() => {
-              const msg = element.$.confirmRevertSubmissionDialog.message;
-              assert.equal(msg, modifiedMsg);
-              done();
-            });
-          });
-        });
-
-        suite('single changes revert', () => {
-          let navigateToSearchQueryStub;
-          setup(() => {
-            getResponseObjectStub
-                .returns(Promise.resolve({revert_changes: [
-                  {change_id: 12345},
-                ]}));
-            navigateToSearchQueryStub = sinon.stub(GerritNav,
-                'navigateToSearchQuery');
-          });
-
-          test('revert submission single change', done => {
-            element._send('POST', {message: 'Revert submission'},
-                '/revert_submission', false, cleanup).then(res => {
-              element._handleResponse({__key: 'revert_submission'}, {}).
-                  then(() => {
-                    assert.isTrue(navigateToSearchQueryStub.called);
-                    done();
-                  });
-            });
-          });
-        });
-
-        suite('multiple changes revert', () => {
-          let showActionDialogStub;
-          let navigateToSearchQueryStub;
-          setup(() => {
-            getResponseObjectStub
-                .returns(Promise.resolve({revert_changes: [
-                  {change_id: 12345, topic: 'T'},
-                  {change_id: 23456, topic: 'T'},
-                ]}));
-            showActionDialogStub = sinon.stub(element, '_showActionDialog');
-            navigateToSearchQueryStub = sinon.stub(GerritNav,
-                'navigateToSearchQuery');
-          });
-
-          test('revert submission multiple change', done => {
-            element._send('POST', {message: 'Revert submission'},
-                '/revert_submission', false, cleanup).then(res => {
-              element._handleResponse({__key: 'revert_submission'}, {}).then(
-                  () => {
-                    assert.isFalse(showActionDialogStub.called);
-                    assert.isTrue(navigateToSearchQueryStub.calledWith(
-                        'topic: T'));
-                    done();
-                  });
-            });
-          });
-        });
-
-        test('revision action', done => {
-          element
-              ._send('DELETE', payload, '/endpoint', true, cleanup)
-              .then(() => {
-                assert.isFalse(onShowError.called);
-                assert.isTrue(cleanup.calledOnce);
-                assert.isTrue(sendStub.calledWith(42, 'DELETE', '/endpoint',
-                    12, payload));
-                done();
-              });
-        });
-      });
-
-      suite('failure modes', () => {
-        test('non-latest', () => {
-          stubRestApi('getChangeDetail')
-              .returns(Promise.resolve({
-                ...createChange(),
-                // new patchset was uploaded
-                revisions: createRevisions(element.latestPatchNum + 1),
-                messages: createChangeMessages(1),
-              }));
-          const sendStub = stubRestApi(
-              'executeChangeAction');
-
-          return element._send('DELETE', payload, '/endpoint', true, cleanup)
-              .then(() => {
-                assert.isTrue(onShowAlert.calledOnce);
-                assert.isFalse(onShowError.called);
-                assert.isTrue(cleanup.calledOnce);
-                assert.isFalse(sendStub.called);
-              });
-        });
-
-        test('send fails', () => {
-          stubRestApi('getChangeDetail')
-              .returns(Promise.resolve({
-                ...createChange(),
-                // element has latest info
-                revisions: createRevisions(element.latestPatchNum),
-                messages: createChangeMessages(1),
-              }));
-          const sendStub = stubRestApi(
-              'executeChangeAction').callsFake(
-              (num, method, patchNum, endpoint, payload, onErr) => {
-                onErr();
-                return Promise.resolve(null);
-              });
-          const handleErrorStub = sinon.stub(element, '_handleResponseError');
-
-          return element._send('DELETE', payload, '/endpoint', true, cleanup)
-              .then(() => {
-                assert.isFalse(onShowError.called);
-                assert.isTrue(cleanup.called);
-                assert.isTrue(sendStub.calledOnce);
-                assert.isTrue(handleErrorStub.called);
-              });
-        });
-      });
-    });
-
-    test('_handleAction reports', () => {
-      sinon.stub(element, '_fireAction');
-      element.actions = {
-        key: {
-          __key: 'key',
-          __type: 'type',
-        },
-      };
-
-      const reportStub = sinon.stub(element.reporting, 'reportInteraction');
-      element._handleAction('type', 'key');
-      assert.isTrue(reportStub.called);
-      assert.equal(reportStub.lastCall.args[0], 'type-key');
-    });
-  });
-
-  suite('getChangeRevisionActions returns only some actions', () => {
-    let element;
-
-    let changeRevisionActions;
-
-    setup(() => {
-      stubRestApi('getChangeRevisionActions').returns(
-          Promise.resolve(changeRevisionActions));
-      stubRestApi('send').returns(Promise.reject(new Error('error')));
-      stubRestApi('getProjectConfig').returns(Promise.resolve({}));
-
-      sinon.stub(getPluginLoader(), 'awaitPluginsLoaded')
-          .returns(Promise.resolve());
-
-      element = basicFixture.instantiate();
-      // getChangeRevisionActions is not called without
-      // set the following properties
-      element.change = {};
-      element.changeNum = '42';
-      element.latestPatchNum = '2';
-
-      stubRestApi('getRepoBranches').returns(Promise.resolve([]));
-      return element.reload();
-    });
-
-    test('confirmSubmitDialog and confirmRebase properties are changed', () => {
-      changeRevisionActions = {};
-      element.reload();
-      assert.strictEqual(element.$.confirmSubmitDialog.action, null);
-      assert.strictEqual(element.$.confirmRebase.rebaseOnCurrent, null);
-    });
-
-    test('_computeRebaseOnCurrent', () => {
-      const rebaseAction = {
-        enabled: true,
-        label: 'Rebase',
-        method: 'POST',
-        title: 'Rebase onto tip of branch or parent change',
-      };
-
-      // When rebase is enabled initially, rebaseOnCurrent should be set to
-      // true.
-      assert.isTrue(element._computeRebaseOnCurrent(rebaseAction));
-
-      delete rebaseAction.enabled;
-
-      // When rebase is not enabled initially, rebaseOnCurrent should be set to
-      // false.
-      assert.isFalse(element._computeRebaseOnCurrent(rebaseAction));
-    });
-  });
-});
-
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
new file mode 100644
index 0000000..02e4161
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.ts
@@ -0,0 +1,2376 @@
+/**
+ * @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-change-actions';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
+import {
+  createAccountWithId,
+  createApproval,
+  createChange,
+  createChangeMessages,
+  createChangeViewChange,
+  createRevision,
+  createRevisions,
+} from '../../../test/test-data-generators';
+import {ChangeStatus, HttpMethod} from '../../../constants/constants';
+import {
+  mockPromise,
+  query,
+  queryAll,
+  queryAndAssert,
+  stubRestApi,
+} from '../../../test/test-utils';
+import {assertUIActionInfo, GrChangeActions} from './gr-change-actions';
+import {
+  AccountId,
+  ActionInfo,
+  ActionNameToActionInfoMap,
+  BranchName,
+  ChangeId,
+  ChangeSubmissionId,
+  CommitId,
+  NumericChangeId,
+  PatchSetNum,
+  RepoName,
+  ReviewInput,
+  TopicName,
+} from '../../../types/common';
+import {ActionType} from '../../../api/change-actions';
+import {tap} from '@polymer/iron-test-helpers/mock-interactions';
+import {SinonFakeTimers} from 'sinon/pkg/sinon-esm';
+import {IronAutogrowTextareaElement} from '@polymer/iron-autogrow-textarea';
+import {GrButton} from '../../shared/gr-button/gr-button';
+import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
+import {UIActionInfo} from '../../shared/gr-js-api-interface/gr-change-actions-js-api';
+import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
+import {appContext} from '../../../services/app-context';
+
+const basicFixture = fixtureFromElement('gr-change-actions');
+
+// TODO(dhruvsri): remove use of _populateRevertMessage as it's private
+suite('gr-change-actions tests', () => {
+  let element: GrChangeActions;
+
+  suite('basic tests', () => {
+    setup(() => {
+      stubRestApi('getChangeRevisionActions').returns(
+        Promise.resolve({
+          cherrypick: {
+            method: HttpMethod.POST,
+            label: 'Cherry Pick',
+            title: 'Cherry pick change to a different branch',
+            enabled: true,
+          },
+          rebase: {
+            method: HttpMethod.POST,
+            label: 'Rebase',
+            title: 'Rebase onto tip of branch or parent change',
+            enabled: true,
+          },
+          submit: {
+            method: HttpMethod.POST,
+            label: 'Submit',
+            title: 'Submit patch set 2 into master',
+            enabled: true,
+          },
+          revert_submission: {
+            method: HttpMethod.POST,
+            label: 'Revert submission',
+            title: 'Revert this submission',
+            enabled: true,
+          },
+        })
+      );
+      stubRestApi('send').callsFake((method, url) => {
+        if (method !== 'POST') {
+          return Promise.reject(new Error('bad method'));
+        }
+        if (url === '/changes/test~42/revisions/2/submit') {
+          return Promise.resolve({
+            ...new Response(),
+            ok: true,
+            text() {
+              return Promise.resolve(")]}'\n{}");
+            },
+          });
+        } else if (url === '/changes/test~42/revisions/2/rebase') {
+          return Promise.resolve({
+            ...new Response(),
+            ok: true,
+            text() {
+              return Promise.resolve(")]}'\n{}");
+            },
+          });
+        }
+        return Promise.reject(new Error('bad url'));
+      });
+
+      sinon
+        .stub(getPluginLoader(), 'awaitPluginsLoaded')
+        .returns(Promise.resolve());
+
+      element = basicFixture.instantiate();
+      element.change = createChangeViewChange();
+      element.changeNum = 42 as NumericChangeId;
+      element.latestPatchNum = 2 as PatchSetNum;
+      element.actions = {
+        '/': {
+          method: HttpMethod.DELETE,
+          label: 'Delete Change',
+          title: 'Delete change X_X',
+          enabled: true,
+        },
+      };
+      element.account = {
+        _account_id: 123 as AccountId,
+      };
+      stubRestApi('getRepoBranches').returns(Promise.resolve([]));
+
+      return element.reload();
+    });
+
+    test('show-revision-actions event should fire', async () => {
+      const spy = sinon.spy(element, '_sendShowRevisionActions');
+      element.reload();
+      await flush();
+      assert.isTrue(spy.called);
+    });
+
+    test('primary and secondary actions split properly', () => {
+      // Submit should be the only primary action.
+      assert.equal(element._topLevelPrimaryActions!.length, 1);
+      assert.equal(element._topLevelPrimaryActions![0].label, 'Submit');
+      assert.equal(
+        element._topLevelSecondaryActions!.length,
+        element._topLevelActions!.length - 1
+      );
+    });
+
+    test('revert submission action is skipped', () => {
+      assert.equal(
+        element._allActionValues.filter(action => action.__key === 'submit')
+          .length,
+        1
+      );
+      assert.equal(
+        element._allActionValues.filter(
+          action => action.__key === 'revert_submission'
+        ).length,
+        0
+      );
+    });
+
+    test('_shouldHideActions', () => {
+      assert.isTrue(element._shouldHideActions(undefined, true));
+      assert.isTrue(
+        element._shouldHideActions(
+          {base: [] as UIActionInfo[]} as PolymerDeepPropertyChange<
+            UIActionInfo[],
+            UIActionInfo[]
+          >,
+          false
+        )
+      );
+      assert.isFalse(
+        element._shouldHideActions(
+          {
+            base: [{__key: 'test'}] as UIActionInfo[],
+          } as PolymerDeepPropertyChange<UIActionInfo[], UIActionInfo[]>,
+          false
+        )
+      );
+    });
+
+    test('plugin revision actions', async () => {
+      const stub = stubRestApi('getChangeActionURL').returns(
+        Promise.resolve('the-url')
+      );
+      element.revisionActions = {
+        'plugin~action': {},
+      };
+      assert.isOk(element.revisionActions['plugin~action']);
+      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 () => {
+      const stub = stubRestApi('getChangeActionURL').returns(
+        Promise.resolve('the-url')
+      );
+      element.actions = {
+        'plugin~action': {},
+      };
+      assert.isOk(element.actions['plugin~action']);
+      await flush();
+      assert.isTrue(
+        stub.calledWith(element.changeNum, undefined, '/plugin~action')
+      );
+      assert.equal(
+        (element.actions['plugin~action'] as UIActionInfo)!.__url,
+        'the-url'
+      );
+    });
+
+    test('not supported actions are filtered out', () => {
+      element.revisionActions = {followup: {}};
+      assert.equal(
+        element.querySelectorAll(
+          'section gr-button[data-action-type="revision"]'
+        ).length,
+        0
+      );
+    });
+
+    test('getActionDetails', () => {
+      element.revisionActions = {
+        'plugin~action': {},
+        ...element.revisionActions,
+      };
+      assert.isUndefined(element.getActionDetails('rubbish'));
+      assert.strictEqual(
+        element.revisionActions['plugin~action'],
+        element.getActionDetails('plugin~action')
+      );
+      assert.strictEqual(
+        element.revisionActions['rebase'],
+        element.getActionDetails('rebase')
+      );
+    });
+
+    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
+      );
+      await flush();
+      buttonEl = queryAndAssert(element, '[data-action-key="submit"]');
+      assert.isFalse(buttonEl.hasAttribute('hidden'));
+    });
+
+    test('buttons exist', async () => {
+      element._loading = false;
+      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);
+    });
+
+    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', () => {
+      const revObj = {
+        ...createRevision(),
+        _number: 2 as PatchSetNum,
+        foo: 'bar',
+      };
+      const change = {
+        ...createChangeViewChange(),
+        revisions: {
+          rev1: {...createRevision(), _number: 1 as PatchSetNum},
+          rev2: revObj,
+        },
+      };
+      assert.deepEqual(element._getRevision(change, 2 as PatchSetNum), revObj);
+    });
+
+    test('_actionComparator sort order', () => {
+      const actions = [
+        {label: '123', __type: ActionType.CHANGE, __key: 'review'},
+        {label: 'abc-ro', __type: ActionType.REVISION, __key: 'random'},
+        {label: 'abc', __type: ActionType.CHANGE, __key: 'random'},
+        {label: 'def', __type: ActionType.CHANGE, __key: 'random'},
+        {
+          label: 'def-p',
+          __type: ActionType.CHANGE,
+          __primary: true,
+          __key: 'random',
+        },
+      ];
+
+      const result = actions.slice();
+      result.reverse();
+      result.sort(element._actionComparator.bind(element));
+      assert.deepEqual(result, actions);
+    });
+
+    test('submit change', async () => {
+      const showSpy = sinon.spy(element, '_showActionDialog');
+      stubRestApi('getFromProjectLookup').returns(
+        Promise.resolve('test' as RepoName)
+      );
+      sinon.stub(element.$.overlay, 'open').returns(Promise.resolve());
+      element.change = {
+        ...createChangeViewChange(),
+        revisions: {
+          rev1: {...createRevision(), _number: 1 as PatchSetNum},
+          rev2: {...createRevision(), _number: 2 as PatchSetNum},
+        },
+      };
+      element.latestPatchNum = 2 as PatchSetNum;
+
+      const submitButton = queryAndAssert(
+        element,
+        'gr-button[data-action-key="submit"]'
+      );
+      tap(submitButton);
+
+      await flush();
+      assert.isTrue(showSpy.calledWith(element.$.confirmSubmitDialog));
+    });
+
+    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)
+      );
+      sinon.stub(element.$.overlay, 'open').returns(Promise.resolve());
+      element.change = {
+        ...createChangeViewChange(),
+        revisions: {
+          rev1: {...createRevision(), _number: 1 as PatchSetNum},
+          rev2: {...createRevision(), _number: 2 as PatchSetNum},
+        },
+      };
+      element.latestPatchNum = 2 as PatchSetNum;
+
+      const submitIcon = queryAndAssert(
+        element,
+        'gr-button[data-action-key="submit"] iron-icon'
+      );
+      tap(submitIcon);
+      await submitted;
+    });
+
+    test('_handleSubmitConfirm', () => {
+      const fireStub = sinon.stub(element, '_fireAction');
+      sinon.stub(element, '_canSubmitChange').returns(true);
+      element._handleSubmitConfirm();
+      assert.isTrue(fireStub.calledOnce);
+      assert.deepEqual(fireStub.lastCall.args, [
+        '/submit',
+        assertUIActionInfo(element.revisionActions.submit),
+        true,
+      ]);
+    });
+
+    test('_handleSubmitConfirm when not able to submit', () => {
+      const fireStub = sinon.stub(element, '_fireAction');
+      sinon.stub(element, '_canSubmitChange').returns(false);
+      element._handleSubmitConfirm();
+      assert.isFalse(fireStub.called);
+    });
+
+    test('submit change with plugin hook', async () => {
+      sinon.stub(element, '_canSubmitChange').callsFake(() => false);
+      const fireActionStub = sinon.stub(element, '_fireAction');
+      await flush();
+      const submitButton = queryAndAssert(
+        element,
+        'gr-button[data-action-key="submit"]'
+      );
+      tap(submitButton);
+      assert.equal(fireActionStub.callCount, 0);
+    });
+
+    test('chain state', () => {
+      assert.equal(element._hasKnownChainState, false);
+      element.hasParent = true;
+      assert.equal(element._hasKnownChainState, true);
+      element.hasParent = false;
+    });
+
+    test('_calculateDisabled', () => {
+      let hasKnownChainState = false;
+      const action = {
+        __key: 'rebase',
+        enabled: true,
+        __type: ActionType.CHANGE,
+        label: 'l',
+      };
+      assert.equal(
+        element._calculateDisabled(action, hasKnownChainState),
+        true
+      );
+
+      action.__key = 'delete';
+      assert.equal(
+        element._calculateDisabled(action, hasKnownChainState),
+        false
+      );
+
+      action.__key = 'rebase';
+      hasKnownChainState = true;
+      assert.equal(
+        element._calculateDisabled(action, hasKnownChainState),
+        false
+      );
+
+      action.enabled = false;
+      assert.equal(
+        element._calculateDisabled(action, hasKnownChainState),
+        false
+      );
+    });
+
+    test('rebase change', async () => {
+      const fireActionStub = sinon.stub(element, '_fireAction');
+      const fetchChangesStub = sinon
+        .stub(element.$.confirmRebase, 'fetchRecentChanges')
+        .returns(Promise.resolve([]));
+      element._hasKnownChainState = true;
+      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', async () => {
+      const eventStub = sinon.stub(element, 'dispatchEvent');
+      element._handleResponse(
+        {__key: 'rebase', __type: ActionType.CHANGE, label: 'l'},
+        new Response()
+      );
+      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", async () => {
+      const fetchChangesStub = sinon
+        .stub(element.$.confirmRebase, 'fetchRecentChanges')
+        .returns(Promise.resolve([]));
+      element._hasKnownChainState = true;
+      const rebaseButton = queryAndAssert(
+        element,
+        'gr-button[data-action-key="rebase"]'
+      );
+      tap(rebaseButton);
+      assert.isTrue(fetchChangesStub.calledOnce);
+
+      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 () => {
+      element._hasKnownChainState = true;
+      await flush();
+      const rebaseButton = queryAndAssert(
+        element,
+        'gr-button[data-action-key="rebase"]'
+      );
+      tap(rebaseButton);
+      await flush();
+      assert.isFalse(element.$.confirmRebase.hidden);
+      stubRestApi('getChanges').returns(Promise.resolve([]));
+      element._handleCherrypickTap();
+      await flush();
+      assert.isTrue(element.$.confirmRebase.hidden);
+      assert.isFalse(element.$.confirmCherrypick.hidden);
+    });
+
+    test('fullscreen-overlay-opened hides content', () => {
+      const spy = sinon.spy(element, '_handleHideBackgroundContent');
+      element.$.overlay.dispatchEvent(
+        new CustomEvent('fullscreen-overlay-opened', {
+          composed: true,
+          bubbles: true,
+        })
+      );
+      assert.isTrue(spy.called);
+      assert.isTrue(element.$.mainContent.classList.contains('overlayOpen'));
+    });
+
+    test('fullscreen-overlay-closed shows content', () => {
+      const spy = sinon.spy(element, '_handleShowBackgroundContent');
+      element.$.overlay.dispatchEvent(
+        new CustomEvent('fullscreen-overlay-closed', {
+          composed: true,
+          bubbles: true,
+        })
+      );
+      assert.isTrue(spy.called);
+      assert.isFalse(element.$.mainContent.classList.contains('overlayOpen'));
+    });
+
+    test('_setReviewOnRevert', () => {
+      const review = {labels: {Foo: 1, 'Bar-Baz': -2}};
+      const changeId = 1234 as NumericChangeId;
+      sinon
+        .stub(appContext.jsApiService, 'getReviewPostRevert')
+        .returns(review);
+      const saveStub = stubRestApi('saveChangeReview').returns(
+        Promise.resolve(new Response())
+      );
+      const setReviewOnRevert = element._setReviewOnRevert(changeId) as Promise<
+        undefined | Response
+      >;
+      return setReviewOnRevert.then((_res: Response | undefined) => {
+        assert.isTrue(saveStub.calledOnce);
+        assert.equal(saveStub.lastCall.args[0], changeId);
+        assert.deepEqual(saveStub.lastCall.args[2], review);
+      });
+    });
+
+    suite('change edits', () => {
+      test('disableEdit', async () => {
+        element.set('editMode', false);
+        element.set('editPatchsetLoaded', false);
+        element.change = {
+          ...createChangeViewChange(),
+          status: ChangeStatus.NEW,
+        };
+        element.set('disableEdit', true);
+        await flush();
+
+        assert.isNotOk(
+          query(element, 'gr-button[data-action-key="publishEdit"]')
+        );
+        assert.isNotOk(
+          query(element, 'gr-button[data-action-key="rebaseEdit"]')
+        );
+        assert.isNotOk(
+          query(element, 'gr-button[data-action-key="deleteEdit"]')
+        );
+        assert.isNotOk(query(element, 'gr-button[data-action-key="edit"]'));
+        assert.isNotOk(query(element, 'gr-button[data-action-key="stopEdit"]'));
+      });
+
+      test('shows confirm dialog for delete edit', async () => {
+        element.set('editMode', true);
+        element.set('editPatchsetLoaded', true);
+
+        const fireActionStub = sinon.stub(element, '_fireAction');
+        element._handleDeleteEditTap();
+        assert.isFalse(element.$.confirmDeleteEditDialog.hidden);
+        tap(
+          queryAndAssert(
+            queryAndAssert(element, '#confirmDeleteEditDialog'),
+            'gr-button[primary]'
+          )
+        );
+        await flush();
+
+        assert.equal(fireActionStub.lastCall.args[0], '/edit');
+      });
+
+      test('edit patchset is loaded, needs rebase', async () => {
+        element.set('editMode', true);
+        element.set('editPatchsetLoaded', true);
+        element.change = {
+          ...createChangeViewChange(),
+          status: ChangeStatus.NEW,
+        };
+        element.editBasedOnCurrentPatchSet = false;
+        await flush();
+
+        assert.isNotOk(
+          query(element, 'gr-button[data-action-key="publishEdit"]')
+        );
+        assert.isOk(query(element, 'gr-button[data-action-key="rebaseEdit"]'));
+        assert.isOk(query(element, 'gr-button[data-action-key="deleteEdit"]'));
+        assert.isNotOk(query(element, 'gr-button[data-action-key="edit"]'));
+        assert.isNotOk(query(element, 'gr-button[data-action-key="stopEdit"]'));
+      });
+
+      test('edit patchset is loaded, does not need rebase', async () => {
+        element.set('editMode', true);
+        element.set('editPatchsetLoaded', true);
+        element.change = {
+          ...createChangeViewChange(),
+          status: ChangeStatus.NEW,
+        };
+        element.editBasedOnCurrentPatchSet = true;
+        await flush();
+
+        assert.isOk(query(element, 'gr-button[data-action-key="publishEdit"]'));
+        assert.isNotOk(
+          query(element, 'gr-button[data-action-key="rebaseEdit"]')
+        );
+        assert.isOk(query(element, 'gr-button[data-action-key="deleteEdit"]'));
+        assert.isNotOk(query(element, 'gr-button[data-action-key="edit"]'));
+        assert.isNotOk(query(element, 'gr-button[data-action-key="stopEdit"]'));
+      });
+
+      test('edit mode is loaded, no edit patchset', async () => {
+        element.set('editMode', true);
+        element.set('editPatchsetLoaded', false);
+        element.change = {
+          ...createChangeViewChange(),
+          status: ChangeStatus.NEW,
+        };
+        await flush();
+
+        assert.isNotOk(
+          query(element, 'gr-button[data-action-key="publishEdit"]')
+        );
+        assert.isNotOk(
+          query(element, 'gr-button[data-action-key="rebaseEdit"]')
+        );
+        assert.isNotOk(
+          query(element, 'gr-button[data-action-key="deleteEdit"]')
+        );
+        assert.isNotOk(query(element, 'gr-button[data-action-key="edit"]'));
+        assert.isOk(query(element, 'gr-button[data-action-key="stopEdit"]'));
+      });
+
+      test('normal patch set', async () => {
+        element.set('editMode', false);
+        element.set('editPatchsetLoaded', false);
+        element.change = {
+          ...createChangeViewChange(),
+          status: ChangeStatus.NEW,
+        };
+        await flush();
+
+        assert.isNotOk(
+          query(element, 'gr-button[data-action-key="publishEdit"]')
+        );
+        assert.isNotOk(
+          query(element, 'gr-button[data-action-key="rebaseEdit"]')
+        );
+        assert.isNotOk(
+          query(element, 'gr-button[data-action-key="deleteEdit"]')
+        );
+        assert.isOk(query(element, 'gr-button[data-action-key="edit"]'));
+        assert.isNotOk(query(element, 'gr-button[data-action-key="stopEdit"]'));
+      });
+
+      test('edit action', async () => {
+        const editTapped = mockPromise();
+        element.addEventListener('edit-tap', () => {
+          editTapped.resolve();
+        });
+        element.set('editMode', true);
+        element.change = {
+          ...createChangeViewChange(),
+          status: ChangeStatus.NEW,
+        };
+        await flush();
+
+        assert.isNotOk(query(element, 'gr-button[data-action-key="edit"]'));
+        assert.isOk(query(element, 'gr-button[data-action-key="stopEdit"]'));
+        element.change = {
+          ...createChangeViewChange(),
+          status: ChangeStatus.MERGED,
+        };
+        await flush();
+
+        assert.isNotOk(query(element, 'gr-button[data-action-key="edit"]'));
+        element.change = {
+          ...createChangeViewChange(),
+          status: ChangeStatus.NEW,
+        };
+        element.set('editMode', false);
+        await flush();
+
+        const editButton = queryAndAssert(
+          element,
+          'gr-button[data-action-key="edit"]'
+        );
+        tap(editButton);
+        await editTapped;
+      });
+    });
+
+    suite('cherry-pick', () => {
+      let fireActionStub: sinon.SinonStub;
+
+      setup(() => {
+        fireActionStub = sinon.stub(element, '_fireAction');
+        sinon.stub(window, 'alert');
+      });
+
+      test('works', () => {
+        element._handleCherrypickTap();
+        const action = {
+          __key: 'cherrypick',
+          __type: 'revision',
+          __primary: false,
+          enabled: true,
+          label: 'Cherry pick',
+          method: HttpMethod.POST,
+          title: 'Cherry pick change to a different branch',
+        };
+
+        element._handleCherrypickConfirm();
+        assert.equal(fireActionStub.callCount, 0);
+
+        element.$.confirmCherrypick.branch = 'master' as BranchName;
+        element._handleCherrypickConfirm();
+        assert.equal(fireActionStub.callCount, 0); // Still needs a message.
+
+        // Add attributes that are used to determine the message.
+        element.$.confirmCherrypick.commitMessage = 'foo message';
+        element.$.confirmCherrypick.changeStatus = ChangeStatus.NEW;
+        element.$.confirmCherrypick.commitNum = '123' as CommitId;
+
+        element._handleCherrypickConfirm();
+
+        const autogrowEl = queryAndAssert(
+          element.$.confirmCherrypick,
+          '#messageInput'
+        ) as IronAutogrowTextareaElement;
+        assert.equal(autogrowEl.value, 'foo message');
+
+        assert.deepEqual(fireActionStub.lastCall.args, [
+          '/cherrypick',
+          action,
+          true,
+          {
+            destination: 'master',
+            base: null,
+            message: 'foo message',
+            allow_conflicts: false,
+          },
+        ]);
+      });
+
+      test('cherry pick even with conflicts', () => {
+        element._handleCherrypickTap();
+        const action = {
+          __key: 'cherrypick',
+          __type: 'revision',
+          __primary: false,
+          enabled: true,
+          label: 'Cherry pick',
+          method: HttpMethod.POST,
+          title: 'Cherry pick change to a different branch',
+        };
+
+        element.$.confirmCherrypick.branch = 'master' as BranchName;
+
+        // Add attributes that are used to determine the message.
+        element.$.confirmCherrypick.commitMessage = 'foo message';
+        element.$.confirmCherrypick.changeStatus = ChangeStatus.NEW;
+        element.$.confirmCherrypick.commitNum = '123' as CommitId;
+
+        element._handleCherrypickConflictConfirm();
+
+        assert.deepEqual(fireActionStub.lastCall.args, [
+          '/cherrypick',
+          action,
+          true,
+          {
+            destination: 'master',
+            base: null,
+            message: 'foo message',
+            allow_conflicts: true,
+          },
+        ]);
+      });
+
+      test('branch name cleared when re-open cherrypick', () => {
+        const emptyBranchName = '';
+        element.$.confirmCherrypick.branch = 'master' as BranchName;
+
+        element._handleCherrypickTap();
+        assert.equal(element.$.confirmCherrypick.branch, emptyBranchName);
+      });
+
+      suite('cherry pick topics', () => {
+        const changes = [
+          {
+            ...createChangeViewChange(),
+            change_id: '12345678901234' as ChangeId,
+            topic: 'T' as TopicName,
+            subject: 'random',
+            project: 'A' as RepoName,
+            status: ChangeStatus.MERGED,
+          },
+          {
+            ...createChangeViewChange(),
+            change_id: '23456' as ChangeId,
+            topic: 'T' as TopicName,
+            subject: 'a'.repeat(100),
+            project: 'B' as RepoName,
+            status: ChangeStatus.NEW,
+          },
+        ];
+        setup(async () => {
+          stubRestApi('getChanges').returns(Promise.resolve(changes));
+          element._handleCherrypickTap();
+          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', async () => {
+          const dialog = element.$.confirmCherrypick;
+          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', async () => {
+          const dialog = element.$.confirmCherrypick;
+          const error = queryAndAssert(
+            dialog,
+            '.error-message'
+          ) as HTMLSpanElement;
+          assert.equal(error.innerText, '');
+          dialog.updateChanges([
+            {
+              ...createChangeViewChange(),
+              change_id: '12345678901234' as ChangeId,
+              topic: 'T' as TopicName,
+              subject: 'random',
+              project: 'A' as RepoName,
+            },
+            {
+              ...createChangeViewChange(),
+              change_id: '23456' as ChangeId,
+              topic: 'T' as TopicName,
+              subject: 'a'.repeat(100),
+              project: 'A' as RepoName,
+            },
+          ]);
+          await flush();
+          assert.equal(
+            error.innerText,
+            'Two changes cannot be of the same' + ' project'
+          );
+        });
+      });
+    });
+
+    suite('move change', () => {
+      let fireActionStub: sinon.SinonStub;
+
+      setup(() => {
+        fireActionStub = sinon.stub(element, '_fireAction');
+        sinon.stub(window, 'alert');
+        element.actions = {
+          move: {
+            method: HttpMethod.POST,
+            label: 'Move',
+            title: 'Move the change',
+            enabled: true,
+          },
+        };
+      });
+
+      test('works', () => {
+        element._handleMoveTap();
+
+        element._handleMoveConfirm();
+        assert.equal(fireActionStub.callCount, 0);
+
+        element.$.confirmMove.branch = 'master' as BranchName;
+        element._handleMoveConfirm();
+        assert.equal(fireActionStub.callCount, 1);
+      });
+
+      test('branch name cleared when re-open move', () => {
+        const emptyBranchName = '';
+        element.$.confirmMove.branch = 'master' as BranchName;
+
+        element._handleMoveTap();
+        assert.equal(element.$.confirmMove.branch, emptyBranchName);
+      });
+    });
+
+    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!');
+      const keyTapped = mockPromise();
+      element.addEventListener(key + '-tap', async e => {
+        assert.equal(
+          (e as CustomEvent).detail.node.getAttribute('data-action-key'),
+          key
+        );
+        element.removeActionButton(key);
+        await flush();
+        assert.notOk(query(element, '[data-action-key="' + key + '"]'));
+        keyTapped.resolve();
+      });
+      await flush();
+      tap(queryAndAssert(element, '[data-action-key="' + key + '"]'));
+      await keyTapped;
+    });
+
+    test('_setLoadingOnButtonWithKey top-level', () => {
+      const key = 'rebase';
+      const type = 'revision';
+      const cleanup = element._setLoadingOnButtonWithKey(type, key);
+      assert.equal(element._actionLoadingMessage, 'Rebasing...');
+
+      const button = queryAndAssert(
+        element,
+        '[data-action-key="' + key + '"]'
+      ) as GrButton;
+      assert.isTrue(button.hasAttribute('loading'));
+      assert.isTrue(button.disabled);
+
+      assert.isOk(cleanup);
+      assert.isFunction(cleanup);
+      cleanup();
+
+      assert.isFalse(button.hasAttribute('loading'));
+      assert.isFalse(button.disabled);
+      assert.isNotOk(element._actionLoadingMessage);
+    });
+
+    test('_setLoadingOnButtonWithKey overflow menu', () => {
+      const key = 'cherrypick';
+      const type = 'revision';
+      const cleanup = element._setLoadingOnButtonWithKey(type, key);
+      assert.equal(element._actionLoadingMessage, 'Cherry-picking...');
+      assert.include(element._disabledMenuActions, 'cherrypick');
+      assert.isFunction(cleanup);
+
+      cleanup();
+
+      assert.notOk(element._actionLoadingMessage);
+      assert.notInclude(element._disabledMenuActions, 'cherrypick');
+    });
+
+    suite('abandon change', () => {
+      let alertStub: sinon.SinonStub;
+      let fireActionStub: sinon.SinonStub;
+
+      setup(() => {
+        fireActionStub = sinon.stub(element, '_fireAction');
+        alertStub = sinon.stub(window, 'alert');
+        element.actions = {
+          abandon: {
+            method: HttpMethod.POST,
+            label: 'Abandon',
+            title: 'Abandon the change',
+            enabled: true,
+          },
+        };
+        return element.reload();
+      });
+
+      test('abandon change with message', async () => {
+        const newAbandonMsg = 'Test Abandon Message';
+        element.$.confirmAbandonDialog.message = newAbandonMsg;
+        await flush();
+        const abandonButton = queryAndAssert(
+          element,
+          'gr-button[data-action-key="abandon"]'
+        );
+        tap(abandonButton);
+
+        assert.equal(element.$.confirmAbandonDialog.message, newAbandonMsg);
+      });
+
+      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);
+      });
+
+      test('works', () => {
+        element.$.confirmAbandonDialog.message = 'original message';
+        const restoreButton = queryAndAssert(
+          element,
+          'gr-button[data-action-key="abandon"]'
+        );
+        tap(restoreButton);
+
+        element.$.confirmAbandonDialog.message = 'foo message';
+        element._handleAbandonDialogConfirm();
+        assert.notOk(alertStub.called);
+
+        const action = {
+          __key: 'abandon',
+          __type: 'change',
+          __primary: false,
+          enabled: true,
+          label: 'Abandon',
+          method: HttpMethod.POST,
+          title: 'Abandon the change',
+        };
+        assert.deepEqual(fireActionStub.lastCall.args, [
+          '/abandon',
+          action,
+          false,
+          {
+            message: 'foo message',
+          },
+        ]);
+      });
+    });
+
+    suite('revert change', () => {
+      let fireActionStub: sinon.SinonStub;
+
+      setup(() => {
+        fireActionStub = sinon.stub(element, '_fireAction');
+        element.commitMessage = 'random commit message';
+        element.change!.current_revision = 'abcdef' as CommitId;
+        element.actions = {
+          revert: {
+            method: HttpMethod.POST,
+            label: 'Revert',
+            title: 'Revert the change',
+            enabled: true,
+          },
+        };
+        return element.reload();
+      });
+
+      test('revert change with plugin hook', async () => {
+        const newRevertMsg = 'Modified revert msg';
+        sinon
+          .stub(element.$.confirmRevertDialog, '_modifyRevertMsg')
+          .callsFake(() => newRevertMsg);
+        element.change = {
+          ...createChangeViewChange(),
+          current_revision: 'abc1234' as CommitId,
+        };
+        stubRestApi('getChanges').returns(
+          Promise.resolve([
+            {
+              ...createChange(),
+              change_id: '12345678901234' as ChangeId,
+              topic: 'T' as TopicName,
+              subject: 'random',
+            },
+            {
+              ...createChange(),
+              change_id: '23456' as ChangeId,
+              topic: 'T' as TopicName,
+              subject: 'a'.repeat(100),
+            },
+          ])
+        );
+        sinon
+          .stub(
+            element.$.confirmRevertDialog,
+            '_populateRevertSubmissionMessage'
+          )
+          .callsFake(() => 'original msg');
+        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', () => {
+        let getChangesStub: sinon.SinonStub;
+        setup(() => {
+          element.change = {
+            ...createChangeViewChange(),
+            submission_id: '199 0' as ChangeSubmissionId,
+            current_revision: '2000' as CommitId,
+          };
+          getChangesStub = stubRestApi('getChanges').returns(
+            Promise.resolve([
+              {
+                ...createChange(),
+                change_id: '12345678901234' as ChangeId,
+                topic: 'T' as TopicName,
+                subject: 'random',
+              },
+              {
+                ...createChange(),
+                change_id: '23456' as ChangeId,
+                topic: 'T' as TopicName,
+                subject: 'a'.repeat(100),
+              },
+            ])
+          );
+        });
+
+        test('confirm revert dialog shows both options', async () => {
+          const revertButton = queryAndAssert(
+            element,
+            'gr-button[data-action-key="revert"]'
+          );
+          tap(revertButton);
+          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', async () => {
+          const revertButton = queryAndAssert(
+            element,
+            'gr-button[data-action-key="revert"]'
+          );
+          const confirmRevertDialog = element.$.confirmRevertDialog;
+          tap(revertButton);
+          const fireStub = sinon.stub(confirmRevertDialog, 'dispatchEvent');
+          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', async () => {
+          const revertButton = queryAndAssert(
+            element,
+            'gr-button[data-action-key="revert"]'
+          );
+          const confirmRevertDialog = element.$.confirmRevertDialog;
+          tap(revertButton);
+          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);
+        });
+      });
+
+      suite('revert single change', () => {
+        setup(() => {
+          element.change = {
+            ...createChangeViewChange(),
+            submission_id: '199' as ChangeSubmissionId,
+            current_revision: '2000' as CommitId,
+          };
+          stubRestApi('getChanges').returns(
+            Promise.resolve([
+              {
+                ...createChange(),
+                change_id: '12345678901234' as ChangeId,
+                topic: 'T' as TopicName,
+                subject: 'random',
+              },
+            ])
+          );
+        });
+
+        test('submit fails if message is not edited', async () => {
+          const revertButton = queryAndAssert(
+            element,
+            'gr-button[data-action-key="revert"]'
+          );
+          const confirmRevertDialog = element.$.confirmRevertDialog;
+          tap(revertButton);
+          const fireStub = sinon.stub(confirmRevertDialog, 'dispatchEvent');
+          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', async () => {
+          const revertButton = queryAndAssert(
+            element,
+            'gr-button[data-action-key="revert"]'
+          );
+          tap(revertButton);
+          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);
+        });
+      });
+    });
+
+    suite('mark change private', () => {
+      setup(() => {
+        const privateAction = {
+          __key: 'private',
+          __type: 'change',
+          __primary: false,
+          method: HttpMethod.POST,
+          label: 'Mark private',
+          title: 'Working...',
+          enabled: true,
+        };
+
+        element.actions = {
+          private: privateAction,
+        };
+
+        element.change!.is_private = false;
+
+        element.changeNum = 2 as NumericChangeId;
+        element.latestPatchNum = 2 as PatchSetNum;
+
+        return element.reload();
+      });
+
+      test(
+        'make sure the mark private change button is not outside of the ' +
+          'overflow menu',
+        async () => {
+          await flush();
+          assert.isNotOk(query(element, '[data-action-key="private"]'));
+        }
+      );
+
+      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"]')
+        );
+      });
+    });
+
+    suite('unmark private change', () => {
+      setup(() => {
+        const unmarkPrivateAction = {
+          __key: 'private.delete',
+          __type: 'change',
+          __primary: false,
+          method: HttpMethod.POST,
+          label: 'Unmark private',
+          title: 'Working...',
+          enabled: true,
+        };
+
+        element.actions = {
+          'private.delete': unmarkPrivateAction,
+        };
+
+        element.change!.is_private = true;
+
+        element.changeNum = 2 as NumericChangeId;
+        element.latestPatchNum = 2 as PatchSetNum;
+
+        return element.reload();
+      });
+
+      test(
+        'make sure the unmark private change button is not outside of the ' +
+          'overflow menu',
+        async () => {
+          await flush();
+          assert.isNotOk(query(element, '[data-action-key="private.delete"]'));
+        }
+      );
+
+      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"]')
+        );
+      });
+    });
+
+    suite('delete change', () => {
+      let fireActionStub: sinon.SinonStub;
+      let deleteAction: ActionInfo;
+
+      setup(() => {
+        fireActionStub = sinon.stub(element, '_fireAction');
+        element.change = {
+          ...createChangeViewChange(),
+          current_revision: 'abc1234' as CommitId,
+        };
+        deleteAction = {
+          method: HttpMethod.DELETE,
+          label: 'Delete Change',
+          title: 'Delete change X_X',
+          enabled: true,
+        };
+        element.actions = {
+          '/': deleteAction,
+        };
+      });
+
+      test('does not delete on action', () => {
+        element._handleDeleteTap();
+        assert.isFalse(fireActionStub.called);
+      });
+
+      test('shows confirm dialog', async () => {
+        element._handleDeleteTap();
+        assert.isFalse(
+          (queryAndAssert(element, '#confirmDeleteDialog') as GrDialog).hidden
+        );
+        tap(
+          queryAndAssert(
+            queryAndAssert(element, '#confirmDeleteDialog'),
+            'gr-button[primary]'
+          )
+        );
+        await flush();
+        assert.isTrue(fireActionStub.calledWith('/', deleteAction, false));
+      });
+
+      test('hides delete confirm on cancel', async () => {
+        element._handleDeleteTap();
+        tap(
+          queryAndAssert(
+            queryAndAssert(element, '#confirmDeleteDialog'),
+            'gr-button:not([primary])'
+          )
+        );
+        await flush();
+        assert.isTrue(
+          (queryAndAssert(element, '#confirmDeleteDialog') as GrDialog).hidden
+        );
+        assert.isFalse(fireActionStub.called);
+      });
+    });
+
+    suite('ignore change', () => {
+      setup(async () => {
+        sinon.stub(element, '_fireAction');
+
+        const IgnoreAction = {
+          __key: 'ignore',
+          __type: 'change',
+          __primary: false,
+          method: HttpMethod.PUT,
+          label: 'Ignore',
+          title: 'Working...',
+          enabled: true,
+        };
+
+        element.actions = {
+          ignore: IgnoreAction,
+        };
+
+        element.changeNum = 2 as NumericChangeId;
+        element.latestPatchNum = 2 as PatchSetNum;
+
+        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', async () => {
+        queryAndAssert(element.$.moreActions, 'span[data-id="ignore-change"]');
+        element.setActionOverflow(ActionType.CHANGE, 'ignore', false);
+        await flush();
+        queryAndAssert(element, '[data-action-key="ignore"]');
+        assert.isNotOk(
+          query(element.$.moreActions, 'span[data-id="ignore-change"]')
+        );
+      });
+    });
+
+    suite('unignore change', () => {
+      setup(async () => {
+        sinon.stub(element, '_fireAction');
+
+        const UnignoreAction = {
+          __key: 'unignore',
+          __type: 'change',
+          __primary: false,
+          method: HttpMethod.PUT,
+          label: 'Unignore',
+          title: 'Working...',
+          enabled: true,
+        };
+
+        element.actions = {
+          unignore: UnignoreAction,
+        };
+
+        element.changeNum = 2 as NumericChangeId;
+        element.latestPatchNum = 2 as PatchSetNum;
+
+        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', async () => {
+        assert.isOk(
+          query(element.$.moreActions, 'span[data-id="unignore-change"]')
+        );
+        element.setActionOverflow(ActionType.CHANGE, 'unignore', false);
+        await flush();
+        assert.isOk(query(element, '[data-action-key="unignore"]'));
+        assert.isNotOk(
+          query(element.$.moreActions, 'span[data-id="unignore-change"]')
+        );
+      });
+    });
+
+    suite('quick approve', () => {
+      setup(async () => {
+        element.change = {
+          ...createChangeViewChange(),
+          current_revision: 'abc1234' as CommitId,
+          labels: {
+            foo: {
+              values: {
+                '-1': '',
+                ' 0': '',
+                '+1': '',
+              },
+            },
+          },
+          permitted_labels: {
+            foo: ['-1', ' 0', '+1'],
+          },
+        };
+        await flush();
+      });
+
+      test('added when can approve', () => {
+        const approveButton = queryAndAssert(
+          element,
+          "gr-button[data-action-key='review']"
+        );
+        assert.isNotNull(approveButton);
+      });
+
+      test('hide quick approve', async () => {
+        const approveButton = query(
+          element,
+          "gr-button[data-action-key='review']"
+        );
+        assert.isNotNull(approveButton);
+        assert.isFalse(element._hideQuickApproveAction);
+
+        // Assert approve button gets removed from list of buttons.
+        element.hideQuickApproveAction();
+        await flush();
+        const approveButtonUpdated = query(
+          element,
+          "gr-button[data-action-key='review']"
+        );
+        assert.isNotOk(approveButtonUpdated);
+        assert.isTrue(element._hideQuickApproveAction);
+      });
+
+      test('is first in list of secondary actions', () => {
+        const approveButton = element.$.secondaryActions.querySelector(
+          'gr-button'
+        );
+        assert.equal(approveButton!.getAttribute('data-label'), 'foo+1');
+      });
+
+      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', async () => {
+        element.change = {
+          ...createChangeViewChange(),
+          current_revision: 'abc1234' as CommitId,
+          labels: {
+            foo: {
+              approved: {},
+              values: {},
+            },
+          },
+          permitted_labels: {
+            foo: [' 0', '+1'],
+          },
+        };
+        await flush();
+        const approveButton = query(
+          element,
+          "gr-button[data-action-key='review']"
+        );
+        assert.isNotOk(approveButton);
+      });
+
+      test('not added when label not permitted', async () => {
+        element.change = {
+          ...createChangeViewChange(),
+          current_revision: 'abc1234' as CommitId,
+          labels: {
+            foo: {values: {}},
+          },
+          permitted_labels: {
+            bar: [],
+          },
+        };
+        await flush();
+        const approveButton = query(
+          element,
+          "gr-button[data-action-key='review']"
+        );
+        assert.isNotOk(approveButton);
+      });
+
+      test('approves when tapped', async () => {
+        const fireActionStub = sinon.stub(element, '_fireAction');
+        tap(queryAndAssert(element, "gr-button[data-action-key='review']"));
+        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', async () => {
+        element.change = {
+          ...createChangeViewChange(),
+          current_revision: 'abc1234' as CommitId,
+          labels: {
+            foo: {values: {}},
+            bar: {values: {}},
+          },
+          permitted_labels: {
+            foo: [' 0', '+1'],
+            bar: [' 0', '+1', '+2'],
+          },
+        };
+        await flush();
+        const approveButton = query(
+          element,
+          "gr-button[data-action-key='review']"
+        );
+        assert.isNotOk(approveButton);
+      });
+
+      test('code review shown with multiple missing approval', async () => {
+        element.change = {
+          ...createChangeViewChange(),
+          current_revision: 'abc1234' as CommitId,
+          labels: {
+            foo: {values: {}},
+            bar: {values: {}},
+            'Code-Review': {
+              approved: {},
+              values: {
+                ' 0': '',
+                '+1': '',
+                '+2': '',
+              },
+            },
+          },
+          permitted_labels: {
+            foo: [' 0', '+1'],
+            bar: [' 0', '+1', '+2'],
+            'Code-Review': [' 0', '+1', '+2'],
+          },
+        };
+        await flush();
+        const approveButton = queryAndAssert(
+          element,
+          "gr-button[data-action-key='review']"
+        );
+        assert.isOk(approveButton);
+      });
+
+      test('button label for missing approval', async () => {
+        element.change = {
+          ...createChangeViewChange(),
+          current_revision: 'abc1234' as CommitId,
+          labels: {
+            foo: {
+              values: {
+                ' 0': '',
+                '+1': '',
+              },
+            },
+            bar: {approved: {}, values: {}},
+          },
+          permitted_labels: {
+            foo: [' 0', '+1'],
+            bar: [' 0', '+1', '+2'],
+          },
+        };
+        await flush();
+        const approveButton = queryAndAssert(
+          element,
+          "gr-button[data-action-key='review']"
+        );
+        assert.equal(approveButton.getAttribute('data-label'), 'foo+1');
+      });
+
+      test('no quick approve if score is not maximal for a label', async () => {
+        element.change = {
+          ...createChangeViewChange(),
+          current_revision: 'abc1234' as CommitId,
+          labels: {
+            bar: {
+              value: 1,
+              values: {
+                ' 0': '',
+                '+1': '',
+                '+2': '',
+              },
+            },
+          },
+          permitted_labels: {
+            bar: [' 0', '+1'],
+          },
+        };
+        await flush();
+        const approveButton = query(
+          element,
+          "gr-button[data-action-key='review']"
+        );
+        assert.isNotOk(approveButton);
+      });
+
+      test('approving label with a non-max score', async () => {
+        element.change = {
+          ...createChangeViewChange(),
+          current_revision: 'abc1234' as CommitId,
+          labels: {
+            bar: {
+              value: 1,
+              values: {
+                ' 0': '',
+                '+1': '',
+                '+2': '',
+              },
+            },
+          },
+          permitted_labels: {
+            bar: [' 0', '+1', '+2'],
+          },
+        };
+        await flush();
+        const approveButton = queryAndAssert(
+          element,
+          "gr-button[data-action-key='review']"
+        );
+        assert.equal(approveButton.getAttribute('data-label'), 'bar+2');
+      });
+
+      test('added when can approve an already-approved code review label', async () => {
+        element.change = {
+          ...createChangeViewChange(),
+          current_revision: 'abc1234' as CommitId,
+          labels: {
+            'Code-Review': {
+              approved: {},
+              values: {
+                ' 0': '',
+                '+1': '',
+                '+2': '',
+              },
+            },
+          },
+          permitted_labels: {
+            'Code-Review': [' 0', '+1', '+2'],
+          },
+        };
+        await flush();
+        const approveButton = queryAndAssert(
+          element,
+          "gr-button[data-action-key='review']"
+        );
+        assert.isNotNull(approveButton);
+      });
+
+      test('not added when the user has already approved', async () => {
+        const vote = {
+          ...createApproval(),
+          _account_id: 123 as AccountId,
+          name: 'name',
+          value: 2,
+        };
+        element.change = {
+          ...createChangeViewChange(),
+          current_revision: 'abc1234' as CommitId,
+          labels: {
+            'Code-Review': {
+              approved: {},
+              values: {
+                ' 0': '',
+                '+1': '',
+                '+2': '',
+              },
+              all: [vote],
+            },
+          },
+          permitted_labels: {
+            'Code-Review': [' 0', '+1', '+2'],
+          },
+        };
+        await flush();
+        const approveButton = query(
+          element,
+          "gr-button[data-action-key='review']"
+        );
+        assert.isNotOk(approveButton);
+      });
+
+      test('not added when user owns the change', async () => {
+        element.change = {
+          ...createChangeViewChange(),
+          current_revision: 'abc1234' as CommitId,
+          owner: createAccountWithId(123),
+          labels: {
+            'Code-Review': {
+              approved: {},
+              values: {
+                ' 0': '',
+                '+1': '',
+                '+2': '',
+              },
+            },
+          },
+          permitted_labels: {
+            'Code-Review': [' 0', '+1', '+2'],
+          },
+        };
+        await flush();
+        const approveButton = query(
+          element,
+          "gr-button[data-action-key='review']"
+        );
+        assert.isNotOk(approveButton);
+      });
+    });
+
+    test('adds download revision action', async () => {
+      const handler = sinon.stub();
+      element.addEventListener('download-tap', handler);
+      assert.ok(element.revisionActions.download);
+      element._handleDownloadTap();
+      await flush();
+
+      assert.isTrue(handler.called);
+    });
+
+    test('changing changeNum or patchNum does not reload', () => {
+      const reloadStub = sinon.stub(element, 'reload');
+      element.changeNum = 123 as NumericChangeId;
+      assert.isFalse(reloadStub.called);
+      element.latestPatchNum = 456 as PatchSetNum;
+      assert.isFalse(reloadStub.called);
+    });
+
+    test('_toSentenceCase', () => {
+      assert.equal(element._toSentenceCase('blah blah'), 'Blah blah');
+      assert.equal(element._toSentenceCase('BLAH BLAH'), 'Blah blah');
+      assert.equal(element._toSentenceCase('b'), 'B');
+      assert.equal(element._toSentenceCase(''), '');
+      assert.equal(element._toSentenceCase('!@#$%^&*()'), '!@#$%^&*()');
+    });
+
+    suite('setActionOverflow', () => {
+      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);
+        await flush();
+        assert.isOk(query(element, '[data-action-key="cherrypick"]'));
+        assert.notEqual(
+          element.$.moreActions!.items![0].id,
+          'cherrypick-revision'
+        );
+      });
+
+      test('move action to overflow', async () => {
+        assert.isOk(query(element, '[data-action-key="submit"]'));
+        element.setActionOverflow(ActionType.REVISION, 'submit', true);
+        await flush();
+        assert.isNotOk(query(element, '[data-action-key="submit"]'));
+        assert.strictEqual(
+          element.$.moreActions.items![3].id,
+          'submit-revision'
+        );
+      });
+
+      suite('_waitForChangeReachable', () => {
+        let clock: SinonFakeTimers;
+        setup(() => {
+          clock = sinon.useFakeTimers();
+        });
+
+        const makeGetChange = (numTries: number) => () => {
+          if (numTries === 1) {
+            return Promise.resolve({
+              ...createChangeViewChange(),
+              _number: 123 as NumericChangeId,
+            });
+          } else {
+            numTries--;
+            return Promise.resolve(null);
+          }
+        };
+
+        const tickAndFlush = async (repetitions: number) => {
+          for (let i = 1; i <= repetitions; i++) {
+            clock.tick(1000);
+            await flush();
+          }
+        };
+
+        test('succeed', async () => {
+          stubRestApi('getChange').callsFake(makeGetChange(5));
+          const promise = element._waitForChangeReachable(
+            123 as NumericChangeId
+          );
+          tickAndFlush(5);
+          const success = await promise;
+          assert.isTrue(success);
+        });
+
+        test('fail', async () => {
+          stubRestApi('getChange').callsFake(makeGetChange(6));
+          const promise = element._waitForChangeReachable(
+            123 as NumericChangeId
+          );
+          tickAndFlush(6);
+          const success = await promise;
+          assert.isFalse(success);
+        });
+      });
+    });
+
+    suite('_send', () => {
+      let cleanup: sinon.SinonStub;
+      const payload = {foo: 'bar'};
+      let onShowError: sinon.SinonStub;
+      let onShowAlert: sinon.SinonStub;
+      let getResponseObjectStub: sinon.SinonStub;
+
+      setup(() => {
+        cleanup = sinon.stub();
+        element.changeNum = 42 as NumericChangeId;
+        element.latestPatchNum = 12 as PatchSetNum;
+        element.change = {
+          ...createChangeViewChange(),
+          revisions: createRevisions(element.latestPatchNum as number),
+          messages: createChangeMessages(1),
+        };
+        element.change!._number = 42 as NumericChangeId;
+
+        onShowError = sinon.stub();
+        element.addEventListener('show-error', onShowError);
+        onShowAlert = sinon.stub();
+        element.addEventListener('show-alert', onShowAlert);
+      });
+
+      suite('happy path', () => {
+        let sendStub: sinon.SinonStub;
+        setup(() => {
+          stubRestApi('getChangeDetail').returns(
+            Promise.resolve({
+              ...createChangeViewChange(),
+              // element has latest info
+              revisions: createRevisions(element.latestPatchNum as number),
+              messages: createChangeMessages(1),
+            })
+          );
+          getResponseObjectStub = stubRestApi('getResponseObject');
+          sendStub = stubRestApi('executeChangeAction').returns(
+            Promise.resolve(new Response())
+          );
+          sinon.stub(GerritNav, 'navigateToChange');
+        });
+
+        test('change action', async () => {
+          await element._send(
+            HttpMethod.DELETE,
+            payload,
+            '/endpoint',
+            false,
+            cleanup,
+            {} as UIActionInfo
+          );
+          assert.isFalse(onShowError.called);
+          assert.isTrue(cleanup.calledOnce);
+          assert.isTrue(
+            sendStub.calledWith(
+              42,
+              HttpMethod.DELETE,
+              '/endpoint',
+              undefined,
+              payload
+            )
+          );
+        });
+
+        suite('show revert submission dialog', () => {
+          setup(() => {
+            element.change!.submission_id = '199' as ChangeSubmissionId;
+            element.change!.current_revision = '2000' as CommitId;
+            stubRestApi('getChanges').returns(
+              Promise.resolve([
+                {
+                  ...createChangeViewChange(),
+                  change_id: '12345678901234' as ChangeId,
+                  topic: 'T' as TopicName,
+                  subject: 'random',
+                },
+                {
+                  ...createChangeViewChange(),
+                  change_id: '23456' as ChangeId,
+                  topic: 'T' as TopicName,
+                  subject: 'a'.repeat(100),
+                },
+              ])
+            );
+          });
+        });
+
+        suite('single changes revert', () => {
+          let navigateToSearchQueryStub: sinon.SinonStub;
+          setup(() => {
+            getResponseObjectStub.returns(
+              Promise.resolve({revert_changes: [{change_id: 12345}]})
+            );
+            navigateToSearchQueryStub = sinon.stub(
+              GerritNav,
+              'navigateToSearchQuery'
+            );
+          });
+
+          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);
+          });
+        });
+
+        suite('multiple changes revert', () => {
+          let showActionDialogStub: sinon.SinonStub;
+          let navigateToSearchQueryStub: sinon.SinonStub;
+          setup(() => {
+            getResponseObjectStub.returns(
+              Promise.resolve({
+                revert_changes: [
+                  {change_id: 12345, topic: 'T'},
+                  {change_id: 23456, topic: 'T'},
+                ],
+              })
+            );
+            showActionDialogStub = sinon.stub(element, '_showActionDialog');
+            navigateToSearchQueryStub = sinon.stub(
+              GerritNav,
+              'navigateToSearchQuery'
+            );
+          });
+
+          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', 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)
+          );
+        });
+      });
+
+      suite('failure modes', () => {
+        test('non-latest', () => {
+          stubRestApi('getChangeDetail').returns(
+            Promise.resolve({
+              ...createChangeViewChange(),
+              // new patchset was uploaded
+              revisions: createRevisions(
+                (element.latestPatchNum as number) + 1
+              ),
+              messages: createChangeMessages(1),
+            })
+          );
+          const sendStub = stubRestApi('executeChangeAction');
+
+          return element
+            ._send(
+              HttpMethod.DELETE,
+              payload,
+              '/endpoint',
+              true,
+              cleanup,
+              {} as UIActionInfo
+            )
+            .then(() => {
+              assert.isTrue(onShowAlert.calledOnce);
+              assert.isFalse(onShowError.called);
+              assert.isTrue(cleanup.calledOnce);
+              assert.isFalse(sendStub.called);
+            });
+        });
+
+        test('send fails', () => {
+          stubRestApi('getChangeDetail').returns(
+            Promise.resolve({
+              ...createChangeViewChange(),
+              // element has latest info
+              revisions: createRevisions(element.latestPatchNum as number),
+              messages: createChangeMessages(1),
+            })
+          );
+          const sendStub = stubRestApi('executeChangeAction').callsFake(
+            (_num, _method, _patchNum, _endpoint, _payload, onErr) => {
+              onErr!();
+              return Promise.resolve(undefined);
+            }
+          );
+          const handleErrorStub = sinon.stub(element, '_handleResponseError');
+
+          return element
+            ._send(
+              HttpMethod.DELETE,
+              payload,
+              '/endpoint',
+              true,
+              cleanup,
+              {} as UIActionInfo
+            )
+            .then(() => {
+              assert.isFalse(onShowError.called);
+              assert.isTrue(cleanup.called);
+              assert.isTrue(sendStub.calledOnce);
+              assert.isTrue(handleErrorStub.called);
+            });
+        });
+      });
+    });
+
+    test('_handleAction reports', () => {
+      sinon.stub(element, '_fireAction');
+      sinon.stub(element, '_handleChangeAction');
+
+      const reportStub = sinon.stub(element.reporting, 'reportInteraction');
+      element._handleAction(ActionType.CHANGE, 'key');
+      assert.isTrue(reportStub.called);
+      assert.equal(reportStub.lastCall.args[0], 'change-key');
+    });
+  });
+
+  suite('getChangeRevisionActions returns only some actions', () => {
+    let element: GrChangeActions;
+
+    let changeRevisionActions: ActionNameToActionInfoMap = {};
+
+    setup(() => {
+      stubRestApi('getChangeRevisionActions').returns(
+        Promise.resolve(changeRevisionActions)
+      );
+      stubRestApi('send').returns(Promise.reject(new Error('error')));
+
+      sinon
+        .stub(getPluginLoader(), 'awaitPluginsLoaded')
+        .returns(Promise.resolve());
+
+      element = basicFixture.instantiate();
+      // getChangeRevisionActions is not called without
+      // set the following properties
+      element.change = createChangeViewChange();
+      element.changeNum = 42 as NumericChangeId;
+      element.latestPatchNum = 2 as PatchSetNum;
+
+      stubRestApi('getRepoBranches').returns(Promise.resolve([]));
+      return element.reload();
+    });
+
+    test('confirmSubmitDialog and confirmRebase properties are changed', () => {
+      changeRevisionActions = {};
+      element.reload();
+      assert.strictEqual(element.$.confirmSubmitDialog.action, null);
+      assert.strictEqual(element.$.confirmRebase.rebaseOnCurrent, null);
+    });
+
+    test('_computeRebaseOnCurrent', () => {
+      const rebaseAction = {
+        enabled: true,
+        label: 'Rebase',
+        method: HttpMethod.POST,
+        title: 'Rebase onto tip of branch or parent change',
+      };
+
+      // When rebase is enabled initially, rebaseOnCurrent should be set to
+      // true.
+      assert.isTrue(element._computeRebaseOnCurrent(rebaseAction));
+
+      rebaseAction.enabled = false;
+
+      // When rebase is not enabled initially, rebaseOnCurrent should be set to
+      // false.
+      assert.isFalse(element._computeRebaseOnCurrent(rebaseAction));
+    });
+  });
+});
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 6749ed1..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
@@ -51,6 +51,7 @@
   AccountDetailInfo,
   AccountInfo,
   BranchName,
+  ChangeInfo,
   CommitId,
   CommitInfo,
   ElementPropertyDeepChange,
@@ -84,6 +85,8 @@
   AutocompleteQuery,
   AutocompleteSuggestion,
 } from '../../shared/gr-autocomplete/gr-autocomplete';
+import {getRevertCreatedChangeIds} from '../../../utils/message-util';
+import {Interaction} from '../../../constants/reporting';
 
 const HASHTAG_ADD_MESSAGE = 'Add Hashtag';
 
@@ -137,6 +140,9 @@
   @property({type: Object})
   change?: ParsedChangeInfo;
 
+  @property({type: Object})
+  revertedChange?: ChangeInfo;
+
   @property({type: Object, notify: true})
   labels?: LabelNameToInfoMap;
 
@@ -577,6 +583,33 @@
     return rev.commit;
   }
 
+  _getRevertSectionTitle(
+    _change?: ParsedChangeInfo,
+    revertedChange?: ChangeInfo
+  ) {
+    return revertedChange?.status === ChangeStatus.MERGED
+      ? 'Revert Submitted As'
+      : 'Revert Created As';
+  }
+
+  _showRevertCreatedAs(change?: ParsedChangeInfo) {
+    if (!change?.messages) return false;
+    return getRevertCreatedChangeIds(change.messages).length > 0;
+  }
+
+  _computeRevertCommit(change?: ParsedChangeInfo, revertedChange?: ChangeInfo) {
+    if (revertedChange?.current_revision && revertedChange?.revisions) {
+      return {
+        commit: this._computeMergedCommitInfo(
+          revertedChange.current_revision,
+          revertedChange.revisions
+        ),
+      };
+    }
+    if (!change?.messages) return undefined;
+    return {commit: getRevertCreatedChangeIds(change.messages)?.[0]};
+  }
+
   _computeShowAllLabelText(showAllSections: boolean) {
     if (showAllSections) {
       return 'Show less';
@@ -587,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 ed38d6f..ca68926 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
@@ -172,7 +172,14 @@
     <section
       class$="[[_computeDisplayState(_showAllSections, change, _SECTION.OWNER)]]"
     >
-      <span class="title">Owner</span>
+      <span class="title">
+        <gr-tooltip-content
+          has-tooltip=""
+          title="This user created or uploaded the first patchset of this change."
+        >
+          Owner
+        </gr-tooltip-content>
+      </span>
       <span class="value">
         <gr-account-chip
           account="[[change.owner]]"
@@ -194,7 +201,14 @@
       </span>
     </section>
     <section class$="[[_computeShowRoleClass(change, _CHANGE_ROLE.UPLOADER)]]">
-      <span class="title">Uploader</span>
+      <span class="title">
+        <gr-tooltip-content
+          has-tooltip=""
+          title="This user uploaded the patchset to Gerrit (typically by running the 'git push' command)."
+        >
+          Uploader
+        </gr-tooltip-content>
+      </span>
       <span class="value">
         <gr-account-chip
           account="[[_getNonOwnerRole(change, _CHANGE_ROLE.UPLOADER)]]"
@@ -204,7 +218,14 @@
       </span>
     </section>
     <section class$="[[_computeShowRoleClass(change, _CHANGE_ROLE.AUTHOR)]]">
-      <span class="title">Author</span>
+      <span class="title">
+        <gr-tooltip-content
+          has-tooltip=""
+          title="This user wrote the code change."
+        >
+          Author
+        </gr-tooltip-content>
+      </span>
       <span class="value">
         <gr-account-chip
           account="[[_getNonOwnerRole(change, _CHANGE_ROLE.AUTHOR)]]"
@@ -213,7 +234,14 @@
       </span>
     </section>
     <section class$="[[_computeShowRoleClass(change, _CHANGE_ROLE.COMMITTER)]]">
-      <span class="title">Committer</span>
+      <span class="title">
+        <gr-tooltip-content
+          has-tooltip=""
+          title="This user committed the code change to the Git repository (typically to the local Git repo before uploading)."
+        >
+          Committer
+        </gr-tooltip-content>
+      </span>
       <span class="value">
         <gr-account-chip
           account="[[_getNonOwnerRole(change, _CHANGE_ROLE.COMMITTER)]]"
@@ -358,6 +386,22 @@
         </span>
       </section>
     </template>
+    <template is="dom-if" if="[[_showRevertCreatedAs(change)]]">
+      <section
+        class$="[[_computeDisplayState(_showAllSections, change, _SECTION.REVERT_CREATED_AS)]]"
+      >
+        <span class="title"
+          >[[_getRevertSectionTitle(change, revertedChange)]]</span
+        >
+        <span class="value">
+          <gr-commit-info
+            change="[[change]]"
+            commit-info="[[_computeRevertCommit(change, revertedChange)]]"
+            server-config="[[serverConfig]]"
+          ></gr-commit-info>
+        </span>
+      </section>
+    </template>
     <section
       class$="topic [[_computeDisplayState(_showAllSections, change, _SECTION.TOPIC)]]"
     >
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..7fc3ddf 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;
@@ -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 89f7b89..c34a6ae 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,18 +20,19 @@
 import {sharedStyles} from '../../../styles/shared-styles';
 import {appContext} from '../../../services/app-context';
 import {
+  allRunsLatestPatchsetLatestAttempt$,
+  aPluginHasRegistered$,
   CheckResult,
   CheckRun,
-  aPluginHasRegistered$,
-  someProvidersAreLoading$,
-  allRunsLatest$,
-  errorMessage$,
-  loginCallback$,
+  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,
@@ -43,11 +44,11 @@
 import {ChangeComments} from '../../diff/gr-comment-api/gr-comment-api';
 import {
   CommentThread,
-  isResolved,
-  isUnresolved,
   getFirstComment,
-  isRobotThread,
   hasHumanReply,
+  isResolved,
+  isRobotThread,
+  isUnresolved,
 } from '../../../utils/comment-util';
 import {pluralize} from '../../../utils/string-util';
 import {AccountInfo} from '../../../types/common';
@@ -55,6 +56,7 @@
 import {uniqueDefinedAvatar} from '../../../utils/account-util';
 import {PrimaryTab} from '../../../constants/constants';
 import {ChecksTabState, CommentTabState} from '../../../types/events';
+import {spinnerStyles} from '../../../styles/gr-spinner-styles';
 
 export enum SummaryChipStyles {
   INFO = 'info',
@@ -259,7 +261,7 @@
 }
 
 /** What is the maximum number of expanded checks chips? */
-const DETAILS_QUOTA = 3;
+const DETAILS_QUOTA = 2;
 
 @customElement('gr-change-summary')
 export class GrChangeSummary extends GrLitElement {
@@ -287,21 +289,36 @@
   @property()
   loginCallback?: () => void;
 
-  /** Is reset when rendering beings and decreases while chips are rendered. */
+  /**
+   * How many check chips may still be rendered as a detailed chip. Is reset
+   * when rendering begins and decreases while chips are rendered. So when
+   * there are two ERRORs, then those would consume 2 from this quota and then
+   * there would only be DETAILS_QUOTA - 2 left for the other summary chips.
+   * Once there are more results than quota left we will stop rendering
+   * detailed chips and fall back to just icon+number rendering.
+   */
   private detailsQuota = DETAILS_QUOTA;
 
+  /**
+   * Is reset when rendering begins and contains the check names of runs that
+   * have a detailed chip. We keep track of this such that we can ensure to not
+   * show two detailed chips with the same name.
+   */
+  private detailsCheckNames: string[] = [];
+
   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() {
     return [
       sharedStyles,
+      spinnerStyles,
       css`
         :host {
           display: block;
@@ -310,7 +327,10 @@
           margin-bottom: var(--spacing-m);
         }
         .zeroState {
-          color: var(--primary-text-color);
+          color: var(--deemphasized-text-color);
+        }
+        .loading.zeroState {
+          margin-right: var(--spacing-m);
         }
         div.error {
           background-color: var(--error-background);
@@ -348,6 +368,16 @@
           vertical-align: top;
           margin-right: var(--spacing-xs);
         }
+        /* The basics of .loadingSpin are defined in shared styles. */
+        .loadingSpin {
+          width: calc(var(--line-height-normal) - 2px);
+          height: calc(var(--line-height-normal) - 2px);
+          display: inline-block;
+          vertical-align: top;
+          position: relative;
+          /* Making up for the 2px reduced height above. */
+          top: 1px;
+        }
       `,
     ];
   }
@@ -380,14 +410,17 @@
   renderChecksZeroState() {
     if (this.errorMessage || this.loginCallback) return;
     if (this.runs.some(isRunningOrHasCompleted)) return;
-    const msg = this.someProvidersAreLoading ? 'Loading...' : 'No results';
-    return html`<div class="loading zeroState">${msg}</div>`;
+    const msg = this.someProvidersAreLoading ? 'Loading results' : 'No results';
+    return html`<span class="loading zeroState">${msg}</span>`;
   }
 
   renderChecksChipForCategory(category: Category) {
     if (this.errorMessage || this.loginCallback) return;
     const icon = iconForCategory(category);
-    const runs = this.runs.filter(run => hasResultsOf(run, 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);
   }
@@ -411,16 +444,21 @@
     if (runs.length === 0) {
       return html``;
     }
-    if (runs.length <= this.detailsQuota) {
+    // If a run has both an error and a warning result, then we only want to
+    // show a detailed chip with the expanded checkName once. For simplicity
+    // just stop rendering detailed chips completely as soon as we run into
+    // this by setting detailsQuota to 0 (after the if-block).
+    const hasDetailChipAlready = runs.some(run =>
+      this.detailsCheckNames.includes(run.checkName)
+    );
+    if (!hasDetailChipAlready && runs.length <= this.detailsQuota) {
       this.detailsQuota -= runs.length;
       return runs.map(run => {
-        const allLinks = resultFilter(run)
-          .reduce(
-            (links, result) => links.concat(result.links ?? []),
-            [] as Link[]
-          )
-          .filter(link => link.primary);
-        const links = allLinks.length === 1 ? allLinks : [];
+        this.detailsCheckNames.push(run.checkName);
+        const allPrimaryLinks = resultFilter(run)
+          .map(firstPrimaryLink)
+          .filter(notUndefined);
+        const links = allPrimaryLinks.length === 1 ? allPrimaryLinks : [];
         const text = `${run.checkName}`;
         return html`<gr-checks-chip
           class="${icon}"
@@ -437,8 +475,8 @@
         </gr-checks-chip>`;
       });
     }
-    // runs.length > this.detailsQuota
     this.detailsQuota = 0;
+    this.detailsCheckNames = [];
     const sum = runs.reduce(
       (sum, run) => sum + (resultFilter(run).length || 1),
       0
@@ -465,6 +503,7 @@
 
   render() {
     this.detailsQuota = DETAILS_QUOTA;
+    this.detailsCheckNames = [];
     const commentThreads =
       this.commentThreads?.filter(t => !isRobotThread(t) || hasHumanReply(t)) ??
       [];
@@ -486,10 +525,13 @@
                 Category.WARNING
               )}${this.renderChecksChipForCategory(
                 Category.INFO
-              )}${this.renderChecksChipForStatus(
-                RunStatus.COMPLETED,
-                hasCompletedWithoutResults
+              )}${this.renderChecksChipForCategory(
+                Category.SUCCESS
               )}${this.renderChecksChipForStatus(RunStatus.RUNNING, isRunning)}
+              <span
+                class="loadingSpin"
+                ?hidden="${!this.someProvidersAreLoading}"
+              ></span>
             </td>
           </tr>
           <tr>
@@ -518,7 +560,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 d5dfa25..58cf489 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
@@ -52,7 +52,10 @@
 import {GrEditConstants} from '../../edit/gr-edit-constants';
 import {pluralize} from '../../../utils/string-util';
 import {windowLocationReload} from '../../../utils/dom-util';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {
+  GeneratedWebLink,
+  GerritNav,
+} from '../../core/gr-navigation/gr-navigation';
 import {getPluginEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints';
 import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
 import {RevisionInfo as RevisionInfoClass} from '../../shared/revision-info/revision-info';
@@ -65,12 +68,11 @@
 import {
   computeAllPatchSets,
   computeLatestPatchNum,
-  fetchChangeUpdates,
   hasEditBasedOnCurrentPatchSet,
   hasEditPatchsetLoaded,
   PatchSet,
 } from '../../../utils/patch-set-util';
-import {changeStatuses} from '../../../utils/change-util';
+import {changeStatuses, isOwner, isReviewer} from '../../../utils/change-util';
 import {EventType as PluginEventType} from '../../../api/plugin';
 import {customElement, property, observe} from '@polymer/decorators';
 import {GrApplyFixDialog} from '../../diff/gr-apply-fix-dialog/gr-apply-fix-dialog';
@@ -123,6 +125,7 @@
   DraftInfo,
   isDraftThread,
   isRobot,
+  isUnresolved,
 } from '../../../utils/comment-util';
 import {
   PolymerDeepPropertyChange,
@@ -162,13 +165,16 @@
   firePageError,
   fireDialogChange,
   fireTitleChange,
+  fireReload,
 } from '../../../utils/event-util';
 import {GerritView} 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 {Interaction, Timing} from '../../../constants/reporting';
+import {ChangeStates} from '../../shared/gr-change-status/gr-change-status';
+import {getRevertCreatedChangeIds} from '../../../utils/message-util';
 
 const MIN_LINES_FOR_COMMIT_COLLAPSE = 17;
 
@@ -177,6 +183,8 @@
 
 const REPLY_REFIT_DEBOUNCE_INTERVAL_MS = 500;
 
+const ACCIDENTAL_STARRING_LIMIT_MS = 10 * 1000;
+
 const TRAILING_WHITESPACE_REGEX = /[ \t]+$/gm;
 
 const MSG_PREFIX = '#message-';
@@ -421,7 +429,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})
@@ -498,6 +506,9 @@
   _activeTabs: string[] = [PrimaryTab.FILES, SecondaryTab.CHANGE_LOG];
 
   @property({type: Boolean})
+  unresolvedOnly = false;
+
+  @property({type: Boolean})
   _showAllRobotComments = false;
 
   @property({type: Boolean})
@@ -511,9 +522,16 @@
   @property({type: String})
   _tabState?: TabState;
 
-  restApiService = appContext.restApiService;
+  @property({type: Object})
+  revertedChange?: ChangeInfo;
 
-  checksService = appContext.checksService;
+  @property({
+    type: Array,
+    computed: '_computeResolveWeblinks(_change, _commitInfo, _serverConfig)',
+  })
+  resolveWeblinks?: GeneratedWebLink[];
+
+  restApiService = appContext.restApiService;
 
   keyboardShortcuts() {
     return {
@@ -543,6 +561,8 @@
 
   private scrollTask?: DelayedTask;
 
+  private lastStarredTimestamp?: number;
+
   /** @override */
   ready() {
     super.ready();
@@ -614,7 +634,7 @@
           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));
@@ -624,7 +644,7 @@
     this.addEventListener('comment-discard', e =>
       this._handleCommentDiscard(e)
     );
-    this.addEventListener('change-message-deleted', () => this._reload());
+    this.addEventListener('change-message-deleted', () => fireReload(this));
     this.addEventListener('editable-content-save', e =>
       this._handleCommitMessageSave(e)
     );
@@ -639,12 +659,8 @@
     this.addEventListener(EventType.SHOW_PRIMARY_TAB, e =>
       this._setActivePrimaryTab(e)
     );
-    this.addEventListener('show-secondary-tab', e =>
-      this._setActiveSecondaryTab(e)
-    );
     this.addEventListener('reload', e => {
-      e.stopPropagation();
-      this._reload(
+      this.loadData(
         /* isLocationChange= */ false,
         /* clearPatchset= */ e.detail && e.detail.clearPatchset
       );
@@ -699,7 +715,7 @@
   }
 
   _onCloseFixPreview(e: CloseFixPreviewEvent) {
-    if (e.detail.fixApplied) this._reload();
+    if (e.detail.fixApplied) fireReload(this);
   }
 
   _handleToggleDiffMode(e: CustomKeyboardEvent) {
@@ -730,7 +746,8 @@
       activeTabName?: string;
       activeTabIndex?: number;
       scrollIntoView?: boolean;
-    }
+    },
+    src?: string
   ) {
     if (!paperTabs) return;
     const {activeTabName, activeTabIndex, scrollIntoView} = activeDetails;
@@ -750,7 +767,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'];
@@ -760,7 +777,7 @@
     if (paperTabs.selected !== activeIndex) {
       // paperTabs.selected is undefined during rendering
       if (paperTabs.selected !== undefined) {
-        this.reporting.reportInteraction('show-tab', {tabName});
+        this.reporting.reportInteraction(Interaction.SHOW_TAB, {tabName, src});
       }
       paperTabs.selected = activeIndex;
     }
@@ -774,11 +791,15 @@
     const primaryTabs = this.shadowRoot!.querySelector<PaperTabsElement>(
       '#primaryTabs'
     );
-    const activeTabName = this._setActiveTab(primaryTabs, {
-      activeTabName: e.detail.tab,
-      activeTabIndex: e.detail.value,
-      scrollIntoView: e.detail.scrollIntoView,
-    });
+    const activeTabName = this._setActiveTab(
+      primaryTabs,
+      {
+        activeTabName: e.detail.tab,
+        activeTabIndex: e.detail.value,
+        scrollIntoView: e.detail.scrollIntoView,
+      },
+      (e.composedPath()?.[0] as Element | undefined)?.tagName
+    );
     if (activeTabName) {
       this._activeTabs = [activeTabName, this._activeTabs[1]];
 
@@ -801,21 +822,28 @@
     this._tabState = e.detail.tabState;
   }
 
-  /**
-   * Changes active secondary tab.
-   */
-  _setActiveSecondaryTab(e: SwitchTabEvent) {
-    const secondaryTabs = this.shadowRoot!.querySelector<PaperTabsElement>(
-      '#secondaryTabs'
-    );
-    const activeTabName = this._setActiveTab(secondaryTabs, {
-      activeTabName: e.detail.tab,
-      activeTabIndex: e.detail.value,
-      scrollIntoView: e.detail.scrollIntoView,
-    });
-    if (activeTabName) {
-      this._activeTabs = [this._activeTabs[0], activeTabName];
+  _onPaperTabClick(e: MouseEvent) {
+    let target = e.target as HTMLElement | null;
+    let tabName: string | undefined;
+    // target can be slot child of papertab, so we search for tabName in parents
+    do {
+      tabName = target?.dataset?.['name'];
+      if (tabName) break;
+      target = target?.parentElement as HTMLElement | null;
+    } while (target);
+
+    if (tabName === PrimaryTab.COMMENT_THREADS) {
+      // Show unresolved threads by default only if they are present
+      const hasUnresolvedThreads =
+        (this._commentThreads ?? []).filter(thread => isUnresolved(thread))
+          .length > 0;
+      if (hasUnresolvedThreads) this.unresolvedOnly = true;
     }
+
+    this.reporting.reportInteraction(Interaction.SHOW_TAB, {
+      tabName,
+      src: 'paper-tab-click',
+    });
   }
 
   _handleCommitMessageSave(e: EditableContentSaveEvent) {
@@ -908,6 +936,14 @@
     }, {} as {[patchset: string]: number});
   }
 
+  /**
+   * Returns `this` as the visibility observer target for the keyboard shortcut
+   * mixin to decide whether shortcuts should be enabled or not.
+   */
+  _computeObserverTarget() {
+    return this;
+  }
+
   _computeText(patch: RevisionInfo, commentThreads: CommentThread[]) {
     const commentCount = this._robotCommentCountPerPatchSet(commentThreads);
     const commentCnt = commentCount[patch._number] || 0;
@@ -1153,7 +1189,7 @@
       {once: true}
     );
     this.$.replyOverlay.cancel();
-    this._reload();
+    fireReload(this);
   }
 
   _handleReplyCancel() {
@@ -1189,10 +1225,7 @@
     this._shownFileCount = e.detail.length;
   }
 
-  _expandAllDiffs(e: CustomKeyboardEvent) {
-    if (this.shouldSuppressKeyboardShortcut(e)) {
-      return;
-    }
+  _expandAllDiffs() {
     this.$.fileList.expandAllDiffs();
   }
 
@@ -1206,6 +1239,14 @@
       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) {
+      fireEvent(this, EventType.RECREATE_CHANGE_VIEW);
+      return;
+    }
+
     if (value.changeNum && value.project) {
       this.restApiService.setInProjectLookup(value.changeNum, value.project);
     }
@@ -1216,7 +1257,6 @@
       value.basePatchNum !== undefined &&
       (this._patchRange.patchNum !== value.patchNum ||
         this._patchRange.basePatchNum !== value.basePatchNum);
-    const changeChanged = this._changeNum !== value.changeNum;
 
     let rightPatchNumChanged =
       this._patchRange &&
@@ -1231,9 +1271,13 @@
     this.$.fileList.collapseAllDiffs();
     this._patchRange = patchRange;
 
+    const patchKnown =
+      !patchRange.patchNum ||
+      (this._allPatchSets ?? []).some(ps => ps.num === patchRange.patchNum);
+
     // 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) {
+    if (!changeChanged && patchChanged && patchKnown) {
       if (!patchRange.patchNum) {
         patchRange.patchNum = computeLatestPatchNum(this._allPatchSets);
         rightPatchNumChanged = true;
@@ -1246,7 +1290,7 @@
 
     this._initialLoadComplete = false;
     this._changeNum = value.changeNum;
-    this._reload(true).then(() => {
+    this.loadData(true).then(() => {
       this._performPostLoadTasks();
     });
 
@@ -1269,13 +1313,6 @@
         },
       })
     );
-    this._setActiveSecondaryTab(
-      new CustomEvent('initActiveTab', {
-        detail: {
-          tab: SecondaryTab.CHANGE_LOG,
-        },
-      })
-    );
   }
 
   _sendShowChangeEvent() {
@@ -1495,17 +1532,6 @@
     return GerritNav.getUrlForChange(change);
   }
 
-  _computeShowCommitInfo(
-    changeStatuses: string[],
-    current_revision: RevisionInfo
-  ) {
-    return (
-      changeStatuses.length === 1 &&
-      changeStatuses[0] === 'Merged' &&
-      current_revision
-    );
-  }
-
   _computeReplyButtonLabel(
     changeRecord?: ElementPropertyDeepChange<
       GrChangeView,
@@ -1652,7 +1678,7 @@
       return;
     }
     e.preventDefault();
-    this._reload(/* isLocationChange= */ false, /* clearPatchset= */ true);
+    fireReload(this, true);
   }
 
   _handleToggleChangeStar(e: CustomKeyboardEvent) {
@@ -1726,7 +1752,7 @@
           labelDict.approved &&
           labelDict.approved._account_id === removed._account_id
         ) {
-          this._reload();
+          fireReload(this);
           return;
         }
       }
@@ -1836,6 +1862,31 @@
     }
   }
 
+  computeRevertSubmitted(change?: ChangeInfo | ParsedChangeInfo) {
+    if (!change?.messages) return;
+    Promise.all(
+      getRevertCreatedChangeIds(change.messages).map(changeId =>
+        this.restApiService.getChange(changeId)
+      )
+    ).then(changes => {
+      changes = changes.filter(
+        change => change?.status !== ChangeStatus.ABANDONED
+      );
+      if (!changes.length) return;
+      const submittedRevert = changes.find(
+        change => change?.status === ChangeStatus.MERGED
+      );
+      if (!this._changeStatuses) return;
+      if (submittedRevert) {
+        this.revertedChange = submittedRevert;
+        this.push('_changeStatuses', ChangeStates.REVERT_SUBMITTED);
+      } else {
+        if (changes[0]) this.revertedChange = changes[0];
+        this.push('_changeStatuses', ChangeStates.REVERT_CREATED);
+      }
+    });
+  }
+
   _getChangeDetail() {
     if (!this._changeNum)
       throw new Error('missing required changeNum property');
@@ -1881,6 +1932,7 @@
         this._lineHeight = Number(lineHeight.slice(0, lineHeight.length - 2));
 
         this._change = change;
+        this.computeRevertSubmitted(change);
         this.changeService.updateChange(change);
         if (
           !this._patchRange ||
@@ -2051,7 +2103,7 @@
    * Some non-core data loading may still be in-flight when the core data
    * promise resolves.
    */
-  _reload(isLocationChange?: boolean, clearPatchset?: boolean) {
+  loadData(isLocationChange?: boolean, clearPatchset?: boolean) {
     if (clearPatchset && this._change) {
       GerritNav.navigateToChange(this._change);
       return Promise.resolve([]);
@@ -2067,7 +2119,6 @@
     // are loaded.
     const detailCompletes = this._getChangeDetail();
     allDataPromises.push(detailCompletes);
-    this.checksService.reloadAll();
 
     // Resolves when the loading flag is set to false, meaning that some
     // change content may start appearing.
@@ -2079,7 +2130,10 @@
       .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),
+          });
         }
       });
 
@@ -2256,6 +2310,24 @@
       });
   }
 
+  _computeResolveWeblinks(
+    change?: ChangeInfo,
+    commitInfo?: CommitInfo,
+    config?: ServerInfo
+  ) {
+    if (!change || !commitInfo || !config) {
+      return [];
+    }
+    return GerritNav.getResolveConflictsWeblinks(
+      change.project,
+      commitInfo.commit,
+      {
+        weblinks: commitInfo.resolve_conflicts_web_links,
+        config,
+      }
+    );
+  }
+
   _computeCanStartReview(change: ChangeInfo) {
     return !!(
       change.actions &&
@@ -2264,10 +2336,6 @@
     );
   }
 
-  _computeReplyDisabled() {
-    return false;
-  }
-
   _computeChangePermalinkAriaLabel(changeNum: NumericChangeId) {
     return `Change ${changeNum}`;
   }
@@ -2304,7 +2372,7 @@
     this._updateCheckTimerHandle = window.setTimeout(() => {
       assertIsDefined(this._change, '_change');
       const change = this._change;
-      fetchChangeUpdates(change, this.restApiService).then(result => {
+      this.changeService.fetchChangeUpdates(change).then(result => {
         let toastMessage = null;
         if (!result.isLatest) {
           toastMessage = ReloadToastMessage.NEWER_REVISION;
@@ -2340,12 +2408,7 @@
               dismissOnNavigation: true,
               showDismiss: true,
               action: 'Reload',
-              callback: () => {
-                this._reload(
-                  /* isLocationChange= */ false,
-                  /* clearPatchset= */ true
-                );
-              },
+              callback: () => fireReload(this, true),
             },
             composed: true,
             bubbles: true,
@@ -2504,6 +2567,17 @@
   }
 
   _handleToggleStar(e: CustomEvent<{change: ChangeInfo; starred: boolean}>) {
+    if (e.detail.starred) {
+      this.reporting.reportInteraction('change-starred-from-change-view');
+      this.lastStarredTimestamp = Date.now();
+    } else {
+      if (
+        this.lastStarredTimestamp &&
+        Date.now() - this.lastStarredTimestamp < ACCIDENTAL_STARRING_LIMIT_MS
+      ) {
+        this.reporting.reportInteraction('change-accidentally-starred');
+      }
+    }
     this.restApiService.saveChangeStarred(
       e.detail.change._number,
       e.detail.starred
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 6d8c1ec..693406c 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
@@ -40,7 +40,6 @@
       margin-right: var(--spacing-l);
     }
     gr-change-status {
-      display: initial;
       margin-left: var(--spacing-s);
     }
     gr-change-status:first-child {
@@ -100,7 +99,7 @@
       font-size: var(--font-size-mono);
       line-height: var(--line-height-mono);
       margin-right: var(--spacing-l);
-      margin-bottom: var(--spacing-m);
+      margin-bottom: var(--spacing-l);
       /* Account for border and padding and rounding errors. */
       max-width: calc(72ch + 2px + 2 * var(--spacing-m) + 0.4px);
     }
@@ -113,8 +112,7 @@
       --collapsed-max-height: 300px;
     }
     .changeStatuses,
-    .commitActions,
-    .statusText {
+    .commitActions {
       align-items: center;
       display: flex;
     }
@@ -199,8 +197,7 @@
       width: 100%;
     }
     gr-change-summary {
-      /* temporary for old checks status */
-      margin-bottom: var(--spacing-m);
+      margin-left: var(--spacing-m);
     }
     @media screen and (max-width: 75em) {
       .relatedChanges {
@@ -323,8 +320,10 @@
           <div class="changeStatuses">
             <template is="dom-repeat" items="[[_changeStatuses]]" as="status">
               <gr-change-status
-                max-width="100"
+                change="[[_change]]"
+                reverted-change="[[revertedChange]]"
                 status="[[status]]"
+                resolve-weblinks="[[resolveWeblinks]]"
               ></gr-change-status>
             </template>
           </div>
@@ -372,6 +371,7 @@
             on-edit-tap="_handleEditTap"
             on-stop-edit-tap="_handleStopEditTap"
             on-download-tap="_handleOpenDownloadDialog"
+            on-included-tap="_handleOpenIncludedInDialog"
             comment-threads="[[_commentThreads]]"
           ></gr-change-actions>
         </div>
@@ -384,6 +384,7 @@
           <gr-change-metadata
             id="metadata"
             change="{{_change}}"
+            reverted-change="[[revertedChange]]"
             account="[[_account]]"
             revision="[[_selectedRevision]]"
             commit-info="[[_commitInfo]]"
@@ -428,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]]"
@@ -460,8 +462,13 @@
 
     <h2 class="assistive-tech-only">Files and Comments tabs</h2>
     <paper-tabs id="primaryTabs" on-selected-changed="_setActivePrimaryTab">
-      <paper-tab data-name$="[[_constants.PrimaryTab.FILES]]">Files</paper-tab>
       <paper-tab
+        on-click="_onPaperTabClick"
+        data-name$="[[_constants.PrimaryTab.FILES]]"
+        ><span>Files</span></paper-tab
+      >
+      <paper-tab
+        on-click="_onPaperTabClick"
         data-name$="[[_constants.PrimaryTab.COMMENT_THREADS]]"
         class="commentThreads"
       >
@@ -473,8 +480,10 @@
         >
       </paper-tab>
       <template is="dom-if" if="[[_showChecksTab]]">
-        <paper-tab data-name$="[[_constants.PrimaryTab.CHECKS]]"
-          >Checks</paper-tab
+        <paper-tab
+          data-name$="[[_constants.PrimaryTab.CHECKS]]"
+          on-click="_onPaperTabClick"
+          ><span>Checks</span></paper-tab
         >
       </template>
       <template
@@ -491,8 +500,11 @@
           </gr-endpoint-decorator>
         </paper-tab>
       </template>
-      <paper-tab data-name$="[[_constants.PrimaryTab.FINDINGS]]">
-        Findings
+      <paper-tab
+        data-name$="[[_constants.PrimaryTab.FINDINGS]]"
+        on-click="_onPaperTabClick"
+      >
+        <span>Findings</span>
       </paper-tab>
     </paper-tabs>
 
@@ -522,7 +534,6 @@
           diff-prefs-disabled="[[_diffPrefsDisabled]]"
           on-open-diff-prefs="_handleOpenDiffPrefs"
           on-open-download-dialog="_handleOpenDownloadDialog"
-          on-open-included-in-dialog="_handleOpenIncludedInDialog"
           on-expand-diffs="_expandAllDiffs"
           on-collapse-diffs="_collapseAllDiffs"
         >
@@ -545,6 +556,7 @@
           on-files-shown-changed="_setShownFiles"
           on-file-action-tap="_handleFileActionTap"
           on-reload-drafts="_reloadDraftsWithCallback"
+          observer-target="[[_computeObserverTarget()]]"
         >
         </gr-file-list>
       </div>
@@ -552,6 +564,7 @@
         is="dom-if"
         if="[[_isTabActive(_constants.PrimaryTab.COMMENT_THREADS, _activeTabs)]]"
       >
+        <h3 class="assistive-tech-only">Comments</h3>
         <gr-thread-list
           threads="[[_commentThreads]]"
           change="[[_change]]"
@@ -559,7 +572,7 @@
           logged-in="[[_loggedIn]]"
           comment-tab-state="[[_tabState.commentTab]]"
           only-show-robot-comments-with-human-reply=""
-          unresolved-only
+          unresolved-only="[[unresolvedOnly]]"
           show-comment-context
         ></gr-thread-list>
       </template>
@@ -567,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]]"
@@ -621,7 +635,7 @@
       </gr-endpoint-param>
     </gr-endpoint-decorator>
 
-    <paper-tabs id="secondaryTabs" on-selected-changed="_setActiveSecondaryTab">
+    <paper-tabs id="secondaryTabs">
       <paper-tab
         data-name$="[[_constants.SecondaryTab.CHANGE_LOG]]"
         class="changeLog"
@@ -631,24 +645,19 @@
     </paper-tabs>
     <section class="changeLog">
       <h2 class="assistive-tech-only">Change Log</h2>
-      <template
-        is="dom-if"
-        if="[[_isTabActive(_constants.SecondaryTab.CHANGE_LOG, _activeTabs)]]"
-      >
-        <gr-messages-list
-          class="hideOnMobileOverlay"
-          change="[[_change]]"
-          change-num="[[_changeNum]]"
-          labels="[[_change.labels]]"
-          messages="[[_change.messages]]"
-          reviewer-updates="[[_change.reviewer_updates]]"
-          change-comments="[[_changeComments]]"
-          project-name="[[_change.project]]"
-          show-reply-buttons="[[_loggedIn]]"
-          on-message-anchor-tap="_handleMessageAnchorTap"
-          on-reply="_handleMessageReply"
-        ></gr-messages-list>
-      </template>
+      <gr-messages-list
+        class="hideOnMobileOverlay"
+        change="[[_change]]"
+        change-num="[[_changeNum]]"
+        labels="[[_change.labels]]"
+        messages="[[_change.messages]]"
+        reviewer-updates="[[_change.reviewer_updates]]"
+        change-comments="[[_changeComments]]"
+        project-name="[[_change.project]]"
+        show-reply-buttons="[[_loggedIn]]"
+        on-message-anchor-tap="_handleMessageAnchorTap"
+        on-reply="_handleMessageReply"
+      ></gr-messages-list>
     </section>
   </div>
 
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 d8aebcb..5128948 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
@@ -24,6 +24,7 @@
   DefaultBase,
   DiffViewMode,
   HttpMethod,
+  MessageTag,
   PrimaryTab,
   SecondaryTab,
 } from '../../../constants/constants';
@@ -77,6 +78,7 @@
   PatchRange,
   PatchSetNum,
   RelatedChangeAndCommitInfo,
+  ReviewInputTag,
   RevisionInfo,
   RevisionPatchSetNum,
   RobotId,
@@ -103,6 +105,7 @@
 import {ParsedChangeInfo} from '../../../types/types';
 import {GrRelatedChangesList} from '../gr-related-changes-list/gr-related-changes-list';
 import {appContext} from '../../../services/app-context';
+import {ChangeStates} from '../../shared/gr-change-status/gr-change-status';
 
 const pluginApi = _testOnly_initGerritPluginApi();
 const fixture = fixtureFromElement('gr-change-view');
@@ -377,7 +380,7 @@
     stubRestApi('getDiffRobotComments').returns(Promise.resolve({}));
     stubRestApi('getDiffDrafts').returns(Promise.resolve({}));
     element = fixture.instantiate();
-    element._changeNum = 1 as NumericChangeId;
+    element._changeNum = TEST_NUMERIC_CHANGE_ID;
     sinon.stub(element.$.actions, 'reload').returns(Promise.resolve());
     getPluginLoader().loadPlugins([]);
     pluginApi.install(
@@ -515,7 +518,7 @@
 
   suite('plugins adding to file tab', () => {
     setup(done => {
-      element._changeNum = 1 as NumericChangeId;
+      element._changeNum = TEST_NUMERIC_CHANGE_ID;
       // Resolving it here instead of during setup() as other tests depend
       // on flush() not being called during setup.
       flush(() => done());
@@ -816,43 +819,6 @@
       });
     });
 
-    test('reload event from reply dialog is processed', () => {
-      const handleReloadStub = sinon.stub(element, '_reload');
-      element.$.replyDialog.dispatchEvent(
-        new CustomEvent('reload', {
-          detail: {clearPatchset: true},
-          bubbles: true,
-          composed: true,
-        })
-      );
-      assert.isTrue(handleReloadStub.called);
-    });
-
-    test('shift + R should fetch and navigate to the latest patch set', done => {
-      element._changeNum = TEST_NUMERIC_CHANGE_ID;
-      element._patchRange = {
-        basePatchNum: ParentPatchSetNum,
-        patchNum: 1 as RevisionPatchSetNum,
-      };
-      element._change = {
-        ...createChangeViewChange(),
-        revisions: {
-          rev1: createRevision(),
-        },
-        current_revision: 'rev1' as CommitId,
-        status: ChangeStatus.NEW,
-        labels: {},
-        actions: {},
-      };
-
-      const reloadChangeStub = sinon.stub(element, '_reload');
-      pressAndReleaseKeyOn(element, 82, 'shift', 'r');
-      flush(() => {
-        assert.isTrue(reloadChangeStub.called);
-        done();
-      });
-    });
-
     test('d should open download overlay', () => {
       const stub = sinon
         .stub(element.$.downloadOverlay, 'open')
@@ -1102,7 +1068,7 @@
         '#relatedChanges'
       ) as GrRelatedChangesList;
       sinon.stub(relatedChanges, 'reload');
-      sinon.stub(element, '_reload').returns(Promise.resolve([]));
+      sinon.stub(element, 'loadData').returns(Promise.resolve([]));
       sinon.spy(element, '_paramsChanged');
       element.params = createAppElementChangeViewParams();
     });
@@ -1256,7 +1222,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(
@@ -1265,6 +1231,153 @@
     assert.equal(statusChips.length, 2);
   });
 
+  suite('ChangeStatus revert', () => {
+    test('do not show any chip if no revert created', done => {
+      const change = {
+        ...createChange(),
+        messages: createChangeMessages(2),
+      };
+      const getChangeStub = stubRestApi('getChange');
+      getChangeStub.onFirstCall().returns(
+        Promise.resolve({
+          ...createChange(),
+        })
+      );
+      getChangeStub.onSecondCall().returns(
+        Promise.resolve({
+          ...createChange(),
+        })
+      );
+      element._change = change;
+      element._mergeable = true;
+      element._submitEnabled = true;
+      flush();
+      element.computeRevertSubmitted(element._change);
+      flush(() => {
+        assert.isFalse(
+          element._changeStatuses?.includes(ChangeStates.REVERT_SUBMITTED)
+        );
+        assert.isFalse(
+          element._changeStatuses?.includes(ChangeStates.REVERT_CREATED)
+        );
+        done();
+      });
+    });
+
+    test('do not show any chip if all reverts are abandoned', done => {
+      const change = {
+        ...createChange(),
+        messages: createChangeMessages(2),
+      };
+      change.messages[0].message = 'Created a revert of this change as 12345';
+      change.messages[0].tag = MessageTag.TAG_REVERT as ReviewInputTag;
+
+      change.messages[1].message = 'Created a revert of this change as 23456';
+      change.messages[1].tag = MessageTag.TAG_REVERT as ReviewInputTag;
+
+      const getChangeStub = stubRestApi('getChange');
+      getChangeStub.onFirstCall().returns(
+        Promise.resolve({
+          ...createChange(),
+          status: ChangeStatus.ABANDONED,
+        })
+      );
+      getChangeStub.onSecondCall().returns(
+        Promise.resolve({
+          ...createChange(),
+          status: ChangeStatus.ABANDONED,
+        })
+      );
+      element._change = change;
+      element._mergeable = true;
+      element._submitEnabled = true;
+      flush();
+      element.computeRevertSubmitted(element._change);
+      flush(() => {
+        assert.isFalse(
+          element._changeStatuses?.includes(ChangeStates.REVERT_SUBMITTED)
+        );
+        assert.isFalse(
+          element._changeStatuses?.includes(ChangeStates.REVERT_CREATED)
+        );
+        done();
+      });
+    });
+
+    test('show revert created if no revert is merged', done => {
+      const change = {
+        ...createChange(),
+        messages: createChangeMessages(2),
+      };
+      change.messages[0].message = 'Created a revert of this change as 12345';
+      change.messages[0].tag = MessageTag.TAG_REVERT as ReviewInputTag;
+
+      change.messages[1].message = 'Created a revert of this change as 23456';
+      change.messages[1].tag = MessageTag.TAG_REVERT as ReviewInputTag;
+
+      const getChangeStub = stubRestApi('getChange');
+      getChangeStub.onFirstCall().returns(
+        Promise.resolve({
+          ...createChange(),
+        })
+      );
+      getChangeStub.onSecondCall().returns(
+        Promise.resolve({
+          ...createChange(),
+        })
+      );
+      element._change = change;
+      element._mergeable = true;
+      element._submitEnabled = true;
+      flush();
+      element.computeRevertSubmitted(element._change);
+      flush(() => {
+        assert.isFalse(
+          element._changeStatuses?.includes(ChangeStates.REVERT_SUBMITTED)
+        );
+        assert.isTrue(
+          element._changeStatuses?.includes(ChangeStates.REVERT_CREATED)
+        );
+        done();
+      });
+    });
+
+    test('show revert submitted if revert is merged', done => {
+      const change = {
+        ...createChange(),
+        messages: createChangeMessages(2),
+      };
+      change.messages[0].message = 'Created a revert of this change as 12345';
+      change.messages[0].tag = MessageTag.TAG_REVERT as ReviewInputTag;
+      const getChangeStub = stubRestApi('getChange');
+      getChangeStub.onFirstCall().returns(
+        Promise.resolve({
+          ...createChange(),
+          status: ChangeStatus.MERGED,
+        })
+      );
+      getChangeStub.onSecondCall().returns(
+        Promise.resolve({
+          ...createChange(),
+        })
+      );
+      element._change = change;
+      element._mergeable = true;
+      element._submitEnabled = true;
+      flush();
+      element.computeRevertSubmitted(element._change);
+      flush(() => {
+        assert.isFalse(
+          element._changeStatuses?.includes(ChangeStates.REVERT_CREATED)
+        );
+        assert.isTrue(
+          element._changeStatuses?.includes(ChangeStates.REVERT_SUBMITTED)
+        );
+        done();
+      });
+    });
+  });
+
   test('diff preferences open when open-diff-prefs is fired', () => {
     const overlayOpenStub = sinon.stub(element.$.fileList, 'openDiffPrefs');
     element.$.fileListHeader.dispatchEvent(
@@ -1330,7 +1443,7 @@
     };
     element._change = change;
     flush();
-    const reloadStub = sinon.stub(element, '_reload');
+    const reloadStub = sinon.stub(element, 'loadData');
     element.splice('_change.labels.test.all', 0, 1);
     assert.isFalse(reloadStub.called);
     change.labels.test.all.push(vote);
@@ -1503,7 +1616,7 @@
 
   test('don’t reload entire page when patchRange changes', () => {
     const reloadStub = sinon
-      .stub(element, '_reload')
+      .stub(element, 'loadData')
       .callsFake(() => Promise.resolve([]));
     const reloadPatchDependentStub = sinon
       .stub(element, '_reloadPatchNumDependentResources')
@@ -1520,6 +1633,13 @@
     assert.isTrue(reloadStub.calledOnce);
 
     element._initialLoadComplete = true;
+    element._change = {
+      ...createChangeViewChange(),
+      revisions: {
+        rev1: createRevision(1),
+        rev2: createRevision(2),
+      },
+    };
 
     value.basePatchNum = 1 as BasePatchSetNum;
     value.patchNum = 2 as RevisionPatchSetNum;
@@ -1530,7 +1650,7 @@
   });
 
   test('reload ported comments when patchNum changes', () => {
-    sinon.stub(element, '_reload').callsFake(() => Promise.resolve([]));
+    sinon.stub(element, 'loadData').callsFake(() => Promise.resolve([]));
     sinon.stub(element, '_getCommitInfo');
     sinon.stub(element.$.fileList, 'reload');
     flush();
@@ -1548,6 +1668,13 @@
     element._paramsChanged(value);
 
     element._initialLoadComplete = true;
+    element._change = {
+      ...createChangeViewChange(),
+      revisions: {
+        rev1: createRevision(1),
+        rev2: createRevision(2),
+      },
+    };
 
     value.basePatchNum = 1 as BasePatchSetNum;
     value.patchNum = 2 as RevisionPatchSetNum;
@@ -1557,7 +1684,7 @@
 
   test('reload entire page when patchRange doesnt change', () => {
     const reloadStub = sinon
-      .stub(element, '_reload')
+      .stub(element, 'loadData')
       .callsFake(() => Promise.resolve([]));
     const collapseStub = sinon.stub(element.$.fileList, 'collapseAllDiffs');
     const value: AppElementChangeViewParams = createAppElementChangeViewParams();
@@ -1570,13 +1697,13 @@
   });
 
   test('related changes are not updated after other action', done => {
-    sinon.stub(element, '_reload').callsFake(() => Promise.resolve([]));
+    sinon.stub(element, 'loadData').callsFake(() => Promise.resolve([]));
     flush();
     const relatedChanges = element.shadowRoot!.querySelector(
       '#relatedChanges'
     ) as GrRelatedChangesList;
     sinon.stub(relatedChanges, 'reload');
-    element._reload(true).then(() => {
+    element.loadData(true).then(() => {
       assert.isFalse(navigateToChangeStub.called);
       done();
     });
@@ -1902,7 +2029,7 @@
     test('scrollTop is set correctly', () => {
       element.viewState = {scrollTop: TEST_SCROLL_TOP_PX};
 
-      sinon.stub(element, '_reload').callsFake(() => {
+      sinon.stub(element, 'loadData').callsFake(() => {
         // When element is reloaded, ensure that the history
         // state has the scrollTop set earlier. This will then
         // be reset.
@@ -2417,23 +2544,6 @@
     });
   });
 
-  test('_paramsChanged sets in projectLookup', () => {
-    flush();
-    const relatedChanges = element.shadowRoot!.querySelector(
-      '#relatedChanges'
-    ) as GrRelatedChangesList;
-    sinon.stub(relatedChanges, 'reload');
-    sinon.stub(element, '_reload').returns(Promise.resolve([]));
-    const setStub = stubRestApi('setInProjectLookup');
-    element._paramsChanged({
-      view: GerritNav.View.CHANGE,
-      changeNum: 101 as NumericChangeId,
-      project: TEST_PROJECT_NAME,
-    });
-    assert.isTrue(setStub.calledOnce);
-    assert.isTrue(setStub.calledWith(101 as never, TEST_PROJECT_NAME as never));
-  });
-
   test('_handleToggleStar called when star is tapped', () => {
     element._change = {
       ...createChangeViewChange(),
@@ -2492,7 +2602,7 @@
       );
       element._paramsChanged({
         ...createAppElementChangeViewParams(),
-        changeNum: 101 as NumericChangeId,
+        changeNum: TEST_NUMERIC_CHANGE_ID,
         project: TEST_PROJECT_NAME,
       });
       flush(() => {
diff --git a/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info.ts b/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info.ts
index 3ba12b43..cc2f650 100644
--- a/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info.ts
+++ b/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info.ts
@@ -81,6 +81,18 @@
     });
   }
 
+  computeCommitLink(
+    webLink?: string,
+    change?: ChangeInfo,
+    commitInfo?: CommitInfo,
+    serverConfig?: ServerInfo
+  ) {
+    if (webLink) return webLink;
+    const hash = this._computeShortHash(change, commitInfo, serverConfig);
+    if (hash === undefined) return '';
+    return GerritNav.getUrlForSearchQuery(hash);
+  }
+
   _computeShortHash(
     change?: ChangeInfo,
     commitInfo?: CommitInfo,
@@ -90,7 +102,7 @@
       return '';
     }
 
-    const {name} = this._getWeblink(change, commitInfo, serverConfig) || {};
-    return name;
+    const weblink = this._getWeblink(change, commitInfo, serverConfig);
+    return weblink?.name ?? '';
   }
 }
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 df0bb4a..65ca8b5 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
@@ -24,14 +24,12 @@
     }
   </style>
   <div class="container">
-    <template is="dom-if" if="[[_showWebLink]]">
-      <a target="_blank" rel="noopener" href$="[[_webLink]]"
-        >[[_computeShortHash(change, commitInfo, serverConfig)]]</a
-      >
-    </template>
-    <template is="dom-if" if="[[!_showWebLink]]">
-      [[_computeShortHash(change, commitInfo, serverConfig)]]
-    </template>
+    <a
+      target="_blank"
+      rel="noopener"
+      href$="[[computeCommitLink(_webLink, change, commitInfo, serverConfig)]]"
+      >[[_computeShortHash(change, commitInfo, serverConfig)]]</a
+    >
     <gr-copy-clipboard
       has-tooltip=""
       button-title="Copy full SHA to clipboard"
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_html.ts b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_html.ts
index 17647ad..3ec4f2c 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_html.ts
@@ -32,10 +32,10 @@
     }
     .revertSubmissionLayout {
       display: flex;
+      align-items: center;
     }
     .label {
       margin-left: var(--spacing-m);
-      margin-bottom: var(--spacing-m);
     }
     iron-autogrow-textarea {
       font-family: var(--monospace-font-family);
@@ -47,6 +47,9 @@
       color: var(--error-text-color);
       margin-bottom: var(--spacing-m);
     }
+    label[for='messageInput'] {
+      margin-top: var(--spacing-m);
+    }
   </style>
   <gr-dialog
     confirm-label="Revert"
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-revert-submission-dialog/gr-confirm-revert-submission-dialog.ts b/polygerrit-ui/app/elements/change/gr-confirm-revert-submission-dialog/gr-confirm-revert-submission-dialog.ts
deleted file mode 100644
index bd268fd..0000000
--- a/polygerrit-ui/app/elements/change/gr-confirm-revert-submission-dialog/gr-confirm-revert-submission-dialog.ts
+++ /dev/null
@@ -1,127 +0,0 @@
-/**
- * @license
- * Copyright (C) 2019 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import '../../shared/gr-dialog/gr-dialog';
-import '../../../styles/shared-styles';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-confirm-revert-submission-dialog_html';
-import {customElement, property} from '@polymer/decorators';
-import {ChangeInfo} from '../../../types/common';
-import {fireAlert} from '../../../utils/event-util';
-import {appContext} from '../../../services/app-context';
-
-const ERR_COMMIT_NOT_FOUND = 'Unable to find the commit hash of this change.';
-const CHANGE_SUBJECT_LIMIT = 50;
-
-@customElement('gr-confirm-revert-submission-dialog')
-export class GrConfirmRevertSubmissionDialog extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
-  /**
-   * Fired when the confirm button is pressed.
-   *
-   * @event confirm
-   */
-
-  /**
-   * Fired when the cancel button is pressed.
-   *
-   * @event cancel
-   */
-
-  @property({type: String})
-  message?: string;
-
-  @property({type: String})
-  commitMessage?: string;
-
-  private readonly jsAPI = appContext.jsApiService;
-
-  _getTrimmedChangeSubject(subject: string) {
-    if (!subject) return '';
-    if (subject.length < CHANGE_SUBJECT_LIMIT) return subject;
-    return subject.substring(0, CHANGE_SUBJECT_LIMIT) + '...';
-  }
-
-  _modifyRevertSubmissionMsg(change?: ChangeInfo) {
-    if (!change || !this.message || !this.commitMessage) {
-      return this.message;
-    }
-    return this.jsAPI.modifyRevertSubmissionMsg(
-      change,
-      this.message,
-      this.commitMessage
-    );
-  }
-
-  _populateRevertSubmissionMessage(
-    change?: ChangeInfo,
-    changes?: ChangeInfo[]
-  ) {
-    if (change === undefined) {
-      return;
-    }
-    // Follow the same convention of the revert
-    const commitHash = change.current_revision;
-    if (!commitHash) {
-      fireAlert(this, ERR_COMMIT_NOT_FOUND);
-      return;
-    }
-    const revertTitle = `Revert submission ${change.submission_id}`;
-    this.message =
-      revertTitle + '\n\n' + 'Reason for revert: <INSERT REASONING HERE>\n';
-    changes = changes || [];
-    if (changes.length) {
-      this.message += 'Reverted Changes:\n';
-      changes.forEach(change => {
-        this.message +=
-          `${change.change_id.substring(0, 10)}: ` +
-          `${this._getTrimmedChangeSubject(change.subject)}\n`;
-      });
-    }
-    this.message = this._modifyRevertSubmissionMsg(change);
-  }
-
-  _handleConfirmTap(e: Event) {
-    e.preventDefault();
-    e.stopPropagation();
-    this.dispatchEvent(
-      new CustomEvent('confirm', {
-        composed: true,
-        bubbles: false,
-      })
-    );
-  }
-
-  _handleCancelTap(e: Event) {
-    e.preventDefault();
-    e.stopPropagation();
-    this.dispatchEvent(
-      new CustomEvent('cancel', {
-        composed: true,
-        bubbles: false,
-      })
-    );
-  }
-}
-
-declare global {
-  interface HTMLElementTagNameMap {
-    'gr-confirm-revert-submission-dialog': GrConfirmRevertSubmissionDialog;
-  }
-}
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-revert-submission-dialog/gr-confirm-revert-submission-dialog_html.ts b/polygerrit-ui/app/elements/change/gr-confirm-revert-submission-dialog/gr-confirm-revert-submission-dialog_html.ts
deleted file mode 100644
index 48bd796..0000000
--- a/polygerrit-ui/app/elements/change/gr-confirm-revert-submission-dialog/gr-confirm-revert-submission-dialog_html.ts
+++ /dev/null
@@ -1,58 +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`
-  <!-- TODO(taoalpha): move all shared styles to a style module. -->
-  <style include="shared-styles">
-    :host {
-      display: block;
-    }
-    :host([disabled]) {
-      opacity: 0.5;
-      pointer-events: none;
-    }
-    label {
-      cursor: pointer;
-      display: block;
-      width: 100%;
-    }
-    iron-autogrow-textarea {
-      font-family: var(--monospace-font-family);
-      font-size: var(--font-size-mono);
-      line-height: var(--line-height-mono);
-      width: 73ch; /* Add a char to account for the border. */
-    }
-  </style>
-  <gr-dialog
-    confirm-label="Revert Submission"
-    on-confirm="_handleConfirmTap"
-    on-cancel="_handleCancelTap"
-  >
-    <div class="header" slot="header">Revert Submission</div>
-    <div class="main" slot="main">
-      <label for="messageInput"> Revert Commit Message </label>
-      <iron-autogrow-textarea
-        id="messageInput"
-        class="message"
-        autocomplete="on"
-        max-rows="15"
-        bind-value="{{message}}"
-      ></iron-autogrow-textarea>
-    </div>
-  </gr-dialog>
-`;
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-revert-submission-dialog/gr-confirm-revert-submission-dialog_test.js b/polygerrit-ui/app/elements/change/gr-confirm-revert-submission-dialog/gr-confirm-revert-submission-dialog_test.js
deleted file mode 100644
index 1ed799f..0000000
--- a/polygerrit-ui/app/elements/change/gr-confirm-revert-submission-dialog/gr-confirm-revert-submission-dialog_test.js
+++ /dev/null
@@ -1,76 +0,0 @@
-/**
- * @license
- * Copyright (C) 2019 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../../../test/common-test-setup-karma.js';
-import './gr-confirm-revert-submission-dialog.js';
-
-const basicFixture = fixtureFromElement('gr-confirm-revert-submission-dialog');
-
-suite('gr-confirm-revert-submission-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._populateRevertSubmissionMessage(
-        'not a commitHash in sight', {}
-    );
-    assert.isTrue(alertStub.calledOnce);
-  });
-
-  test('single line', () => {
-    assert.isNotOk(element.message);
-    element._populateRevertSubmissionMessage(
-        {current_revision: 'abcd123', submission_id: '111'});
-    const expected = 'Revert submission 111\n\n' +
-      'Reason for revert: <INSERT REASONING HERE>\n';
-    assert.equal(element.message, expected);
-  });
-
-  test('multi line', () => {
-    assert.isNotOk(element.message);
-    element._populateRevertSubmissionMessage(
-        {current_revision: 'abcd123', submission_id: '111'});
-    const expected = 'Revert submission 111\n\n' +
-      'Reason for revert: <INSERT REASONING HERE>\n';
-    assert.equal(element.message, expected);
-  });
-
-  test('issue above change id', () => {
-    assert.isNotOk(element.message);
-    element._populateRevertSubmissionMessage(
-        {current_revision: 'abcd123', submission_id: '111'});
-    const expected = 'Revert submission 111\n\n' +
-        'Reason for revert: <INSERT REASONING HERE>\n';
-    assert.equal(element.message, expected);
-  });
-
-  test('revert a revert', () => {
-    assert.isNotOk(element.message);
-    element._populateRevertSubmissionMessage(
-        {current_revision: 'abcd123', submission_id: '111'});
-    const expected = 'Revert submission 111\n\n' +
-      'Reason for revert: <INSERT REASONING HERE>\n';
-    assert.equal(element.message, expected);
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.ts b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.ts
index fe1b618..0d34967 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
@@ -26,6 +26,8 @@
 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';
 
 export interface GrDownloadDialog {
   $: {
@@ -36,7 +38,7 @@
 }
 
 @customElement('gr-download-dialog')
-export class GrDownloadDialog extends PolymerElement {
+export class GrDownloadDialog extends KeyboardShortcutMixin(PolymerElement) {
   static get template() {
     return htmlTemplate;
   }
@@ -59,6 +61,16 @@
   @property({type: String})
   _selectedScheme?: string;
 
+  get keyBindings() {
+    return {
+      1: '_handleNumberKey',
+      2: '_handleNumberKey',
+      3: '_handleNumberKey',
+      4: '_handleNumberKey',
+      5: '_handleNumberKey',
+    };
+  }
+
   @computed('change', 'patchNum')
   get _schemes() {
     // Polymer 2: check for undefined
@@ -78,6 +90,19 @@
     return [];
   }
 
+  _handleNumberKey(e: CustomEvent) {
+    const index = Number(e.detail.key) - 1;
+    const commands = this._computeDownloadCommands(
+      this.change,
+      this.patchNum,
+      this._selectedScheme
+    );
+    if (index > commands.length) return;
+    navigator.clipboard.writeText(commands[index].command).then(() => {
+      fireAlert(this, `${commands[index].title} command copied to clipboard`);
+    });
+  }
+
   /** @override */
   ready() {
     super.ready();
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 90185a6..cbea584 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
@@ -71,6 +71,7 @@
       commands="[[_computeDownloadCommands(change, patchNum, _selectedScheme)]]"
       schemes="[[_schemes]]"
       selected-scheme="{{_selectedScheme}}"
+      show-keyboard-shortcut-tooltips
     ></gr-download-commands>
   </section>
   <section class="flexContainer">
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 e94f67e..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
@@ -24,7 +24,6 @@
 import '../gr-commit-info/gr-commit-info';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-file-list-header_html';
-import {KeyboardShortcutMixin} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
 import {FilesExpandedState} from '../gr-file-list-constants';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 import {computeLatestPatchNum, PatchSet} from '../../../utils/patch-set-util';
@@ -42,11 +41,10 @@
 import {DiffPreferencesInfo} from '../../../types/diff';
 import {ChangeComments} from '../../diff/gr-comment-api/gr-comment-api';
 import {GrDiffModeSelector} from '../../diff/gr-diff-mode-selector/gr-diff-mode-selector';
-import {ChangeStatus, DiffViewMode} from '../../../constants/constants';
+import {DiffViewMode} from '../../../constants/constants';
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {fireEvent} from '../../../utils/event-util';
-
-const MERGED_STATUS = 'MERGED';
+import {KeyboardShortcutMixin} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -81,10 +79,6 @@
    */
 
   /**
-   * @event open-included-in-dialog
-   */
-
-  /**
    * @event open-download-dialog
    */
 
@@ -185,13 +179,6 @@
     return shownFileCount <= maxFilesForBulkActions;
   }
 
-  _showAddPatchsetDescription(
-    patchsetDescription: string,
-    change?: ChangeInfo
-  ) {
-    return !patchsetDescription && change?.status === ChangeStatus.NEW;
-  }
-
   _handlePatchChange(e: CustomEvent) {
     const {basePatchNum, patchNum} = e.detail;
     if (
@@ -208,11 +195,6 @@
     fireEvent(this, 'open-diff-prefs');
   }
 
-  _handleIncludedInTap(e: Event) {
-    e.preventDefault();
-    fireEvent(this, 'open-included-in-dialog');
-  }
-
   _handleDownloadTap(e: Event) {
     e.preventDefault();
     e.stopPropagation();
@@ -232,8 +214,4 @@
     }
     return 'patchInfoOldPatchSet';
   }
-
-  _hideIncludedIn(change?: ChangeInfo) {
-    return change?.status === MERGED_STATUS ? '' : 'hide';
-  }
 }
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 8ebb029..8b9b80c 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
@@ -21,9 +21,6 @@
     .prefsButton {
       float: right;
     }
-    .collapseToggleButton {
-      text-decoration: none;
-    }
     .patchInfoOldPatchSet.patchInfo-header {
       background-color: var(--emphasis-color);
     }
@@ -61,11 +58,9 @@
       display: flex;
     }
     .downloadContainer,
-    .uploadContainer,
-    .includedInContainer {
+    .uploadContainer {
       margin-right: 16px;
     }
-    .includedInContainer.hide,
     .uploadContainer.hide {
       display: none;
     }
@@ -178,15 +173,11 @@
           class="download"
           title="[[createTitle(Shortcut.OPEN_DOWNLOAD_DIALOG,
                 ShortcutSection.ACTIONS)]]"
+          has-tooltip=""
           on-click="_handleDownloadTap"
           >Download</gr-button
         >
       </span>
-      <span class$="includedInContainer [[_hideIncludedIn(change)]] desktop">
-        <gr-button link="" class="includedIn" on-click="_handleIncludedInTap"
-          >Included In</gr-button
-        >
-      </span>
       <template
         is="dom-if"
         if="[[_fileListActionsVisible(shownFileCount, _maxFilesForBulkActions)]]"
@@ -196,6 +187,7 @@
           link=""
           title="[[createTitle(Shortcut.TOGGLE_ALL_INLINE_DIFFS,
                 ShortcutSection.FILE_LIST)]]"
+          has-tooltip=""
           on-click="_expandAllDiffs"
           >Expand All</gr-button
         >
@@ -205,6 +197,7 @@
           on-click="_collapseAllDiffs"
           title="[[createTitle(Shortcut.TOGGLE_ALL_INLINE_DIFFS,
           ShortcutSection.FILE_LIST)]]"
+          has-tooltip=""
           >Collapse All</gr-button
         >
       </template>
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 4b5525a..5ce6e3b 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
@@ -33,7 +33,6 @@
 import {asyncForeach, debounce, DelayedTask} from '../../../utils/async-util';
 import {
   KeyboardShortcutMixin,
-  Modifier,
   Shortcut,
 } from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
 import {FilesExpandedState} from '../gr-file-list-constants';
@@ -47,7 +46,12 @@
   ScrollMode,
   SpecialFilePath,
 } from '../../../constants/constants';
-import {descendedFromClass, toggleClass} from '../../../utils/dom-util';
+import {
+  descendedFromClass,
+  getKeyboardEvent,
+  isShiftPressed,
+  toggleClass,
+} from '../../../utils/dom-util';
 import {
   addUnmodifiedFiles,
   computeDisplayPath,
@@ -91,7 +95,6 @@
 export interface GrFileList {
   $: {
     diffPreferencesDialog: GrDiffPreferencesDialog;
-    diffCursor: GrDiffCursor;
   };
 }
 
@@ -346,6 +349,8 @@
 
   private fileCursor = new GrCursorManager();
 
+  private diffCursor = new GrDiffCursor();
+
   constructor() {
     super();
     this.fileCursor.scrollMode = ScrollMode.KEEP_VISIBLE;
@@ -380,31 +385,26 @@
           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.diffCursor.dispose();
     this.fileCursor.unsetCursor();
     this._cancelDiffs();
     this.loadingTask?.cancel();
@@ -593,7 +593,7 @@
       this._expandedFiles.length,
       this._files.length
     );
-    this.$.diffCursor.handleDiffUpdate();
+    this.diffCursor.handleDiffUpdate();
   }
 
   /**
@@ -614,52 +614,20 @@
     return changeComments.computeCommentsString(patchRange, file.__path, file);
   }
 
-  _computeDraftCount(
-    changeComments?: ChangeComments,
-    patchRange?: PatchRange,
-    path?: string
-  ) {
-    if (
-      changeComments === undefined ||
-      patchRange === undefined ||
-      path === undefined
-    ) {
-      return '';
-    }
-    return (
-      changeComments.computeDraftCount({
-        patchNum: patchRange.basePatchNum,
-        path,
-      }) +
-      changeComments.computeDraftCount({
-        patchNum: patchRange.patchNum,
-        path,
-      }) +
-      changeComments.computePortedDraftCount(
-        {
-          patchNum: patchRange.patchNum,
-          basePatchNum: patchRange.basePatchNum,
-        },
-        path
-      )
-    );
-  }
-
   /**
    * Computes a string with the number of drafts.
    */
   _computeDraftsString(
     changeComments?: ChangeComments,
     patchRange?: PatchRange,
-    path?: string
+    file?: NormalizedFileInfo
   ) {
-    const draftCount = this._computeDraftCount(
-      changeComments,
+    const draftCount = changeComments?.computeDraftCountForFile(
       patchRange,
-      path
+      file
     );
-    if (draftCount === '') return draftCount;
-    return pluralize(draftCount, 'draft');
+    if (draftCount === 0) return '';
+    return pluralize(Number(draftCount), 'draft');
   }
 
   /**
@@ -668,12 +636,11 @@
   _computeDraftsStringMobile(
     changeComments?: ChangeComments,
     patchRange?: PatchRange,
-    path?: string
+    file?: NormalizedFileInfo
   ) {
-    const draftCount = this._computeDraftCount(
-      changeComments,
+    const draftCount = changeComments?.computeDraftCountForFile(
       patchRange,
-      path
+      file
     );
     return draftCount === 0 ? '' : `${draftCount}d`;
   }
@@ -684,23 +651,23 @@
   _computeCommentsStringMobile(
     changeComments?: ChangeComments,
     patchRange?: PatchRange,
-    path?: string
+    file?: NormalizedFileInfo
   ) {
     if (
       changeComments === undefined ||
       patchRange === undefined ||
-      path === undefined
+      file === undefined
     ) {
       return '';
     }
     const commentThreadCount =
       changeComments.computeCommentThreadCount({
         patchNum: patchRange.basePatchNum,
-        path,
+        path: file.__path,
       }) +
       changeComments.computeCommentThreadCount({
         patchNum: patchRange.patchNum,
-        path,
+        path: file.__path,
       });
     return commentThreadCount === 0 ? '' : `${commentThreadCount}c`;
   }
@@ -870,7 +837,7 @@
     }
 
     e.preventDefault();
-    this.$.diffCursor.moveLeft();
+    this.diffCursor.moveLeft();
   }
 
   _handleRightPane(e: CustomKeyboardEvent) {
@@ -879,7 +846,7 @@
     }
 
     e.preventDefault();
-    this.$.diffCursor.moveRight();
+    this.diffCursor.moveRight();
   }
 
   _handleToggleInlineDiff(e: CustomKeyboardEvent) {
@@ -920,11 +887,11 @@
 
     if (this._showInlineDiffs) {
       e.preventDefault();
-      this.$.diffCursor.moveDown();
+      this.diffCursor.moveDown();
       this._displayLine = true;
     } else {
       // Down key
-      if (this.getKeyboardEvent(e).keyCode === 40) {
+      if (getKeyboardEvent(e).keyCode === 40) {
         return;
       }
       e.preventDefault();
@@ -940,11 +907,11 @@
 
     if (this._showInlineDiffs) {
       e.preventDefault();
-      this.$.diffCursor.moveUp();
+      this.diffCursor.moveUp();
       this._displayLine = true;
     } else {
       // Up key
-      if (this.getKeyboardEvent(e).keyCode === 38) {
+      if (getKeyboardEvent(e).keyCode === 38) {
         return;
       }
       e.preventDefault();
@@ -959,15 +926,12 @@
     }
     e.preventDefault();
     this.classList.remove('hideComments');
-    this.$.diffCursor.createCommentInPlace();
+    this.diffCursor.createCommentInPlace();
   }
 
   _handleOpenLastFile(e: CustomKeyboardEvent) {
     // Check for meta key to avoid overriding native chrome shortcut.
-    if (
-      this.shouldSuppressKeyboardShortcut(e) ||
-      this.getKeyboardEvent(e).metaKey
-    ) {
+    if (this.shouldSuppressKeyboardShortcut(e) || getKeyboardEvent(e).metaKey) {
       return;
     }
 
@@ -977,10 +941,7 @@
 
   _handleOpenFirstFile(e: CustomKeyboardEvent) {
     // Check for meta key to avoid overriding native chrome shortcut.
-    if (
-      this.shouldSuppressKeyboardShortcut(e) ||
-      this.getKeyboardEvent(e).metaKey
-    ) {
+    if (this.shouldSuppressKeyboardShortcut(e) || getKeyboardEvent(e).metaKey) {
       return;
     }
 
@@ -1005,36 +966,34 @@
   _handleNextChunk(e: CustomKeyboardEvent) {
     if (
       this.shouldSuppressKeyboardShortcut(e) ||
-      (this.modifierPressed(e) &&
-        !this.isModifierPressed(e, Modifier.SHIFT_KEY)) ||
+      (this.modifierPressed(e) && !isShiftPressed(e)) ||
       this._noDiffsExpanded()
     ) {
       return;
     }
 
     e.preventDefault();
-    if (this.isModifierPressed(e, Modifier.SHIFT_KEY)) {
-      this.$.diffCursor.moveToNextCommentThread();
+    if (isShiftPressed(e)) {
+      this.diffCursor.moveToNextCommentThread();
     } else {
-      this.$.diffCursor.moveToNextChunk();
+      this.diffCursor.moveToNextChunk();
     }
   }
 
   _handlePrevChunk(e: CustomKeyboardEvent) {
     if (
       this.shouldSuppressKeyboardShortcut(e) ||
-      (this.modifierPressed(e) &&
-        !this.isModifierPressed(e, Modifier.SHIFT_KEY)) ||
+      (this.modifierPressed(e) && !isShiftPressed(e)) ||
       this._noDiffsExpanded()
     ) {
       return;
     }
 
     e.preventDefault();
-    if (this.isModifierPressed(e, Modifier.SHIFT_KEY)) {
-      this.$.diffCursor.moveToPreviousCommentThread();
+    if (isShiftPressed(e)) {
+      this.diffCursor.moveToPreviousCommentThread();
     } else {
-      this.$.diffCursor.moveToPreviousChunk();
+      this.diffCursor.moveToPreviousChunk();
     }
   }
 
@@ -1070,7 +1029,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');
     }
@@ -1100,14 +1059,6 @@
     );
   }
 
-  _addDraftAtTarget() {
-    const diff = this.$.diffCursor.getTargetDiffElement();
-    const target = this.$.diffCursor.getTargetLineElement();
-    if (diff && target) {
-      diff.addDraftAtLine(target);
-    }
-  }
-
   _shouldHideChangeTotals(_patchChange: PatchChange): boolean {
     return _patchChange.inserted === 0 && _patchChange.deleted === 0;
   }
@@ -1285,12 +1236,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() {
@@ -1427,7 +1373,7 @@
     }
 
     this._updateDiffCursor();
-    this.$.diffCursor.reInitAndUpdateStops();
+    this.diffCursor.reInitAndUpdateStops();
   }
 
   private _clearCollapsedDiffs(collapsedDiffs: GrDiffHost[]) {
@@ -1477,7 +1423,9 @@
         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}`);
+          this.reporting.error(
+            new Error(`Did not find <gr-diff-host> element for ${path}`)
+          );
           return Promise.resolve();
         }
         if (!this.changeComments || !this.patchRange || !this.diffPrefs) {
@@ -1515,7 +1463,7 @@
        * prevented the issue of scrolling to top when we expand the second
        * file individually.
        */
-        this.$.diffCursor.reInitAndUpdateStops();
+        this.diffCursor.reInitAndUpdateStops();
       })
     );
   }
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 89983ad..8cd35a5 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
@@ -309,12 +309,12 @@
           as="headerEndpoint"
         >
           <gr-endpoint-decorator name$="[[headerEndpoint]]" role="columnheader">
-            <gr-endpoint-param
-              name="change"
-              value="[[change]]"
-            ></gr-endpoint-param>
+            <gr-endpoint-param name="change" value="[[change]]">
+            </gr-endpoint-param>
             <gr-endpoint-param name="patchRange" value="[[patchRange]]">
             </gr-endpoint-param>
+            <gr-endpoint-param name="files" value="[[_files]]">
+            </gr-endpoint-param>
           </gr-endpoint-decorator>
         </template>
       </template>
@@ -423,8 +423,7 @@
               <span class="drafts"
                 ><!-- This comments ensure that span is empty when the function
                 returns empty string.
-              -->[[_computeDraftsString(changeComments, patchRange,
-                file.__path)]]<!-- This comments ensure that span is empty when
+              -->[[_computeDraftsString(changeComments, patchRange, file)]]<!-- This comments ensure that span is empty when
                 the function returns empty string.
            --></span
               >
@@ -450,14 +449,14 @@
                 ><!-- This comments ensure that span is empty when the function
                 returns empty string.
               -->[[_computeDraftsStringMobile(changeComments, patchRange,
-                file.__path)]]<!-- This comments ensure that span is empty when
+                file)]]<!-- This comments ensure that span is empty when
                 the function returns empty string.
            --></span
               >
               <span
                 ><!--
              -->[[_computeCommentsStringMobile(changeComments, patchRange,
-                file.__path)]]<!--
+                file)]]<!--
            --></span
               >
               <span class="noCommentsScreenReaderText">
@@ -736,5 +735,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 b8ba86c..4d793b9 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
@@ -360,103 +360,103 @@
 
       assert.equal(
           element._computeCommentsStringMobile(element.changeComments, parentTo1
-              , '/COMMIT_MSG'), '2c');
+              , {__path: '/COMMIT_MSG'}), '2c');
       assert.equal(
           element._computeCommentsStringMobile(element.changeComments, _1To2
-              , '/COMMIT_MSG'), '3c');
+              , {__path: '/COMMIT_MSG'}), '3c');
       assert.equal(
           element._computeDraftsString(element.changeComments, parentTo1,
-              'unresolved.file'), '1 draft');
+              {__path: 'unresolved.file'}), '1 draft');
       assert.equal(
           element._computeDraftsString(element.changeComments, _1To2,
-              'unresolved.file'), '1 draft');
+              {__path: 'unresolved.file'}), '1 draft');
       assert.equal(
           element._computeDraftsStringMobile(element.changeComments, parentTo1,
-              'unresolved.file'), '1d');
+              {__path: 'unresolved.file'}), '1d');
       assert.equal(
           element._computeDraftsStringMobile(element.changeComments, _1To2,
-              'unresolved.file'), '1d');
+              {__path: 'unresolved.file'}), '1d');
       assert.equal(
           element._computeCommentsStringMobile(
               element.changeComments,
               parentTo1,
-              'myfile.txt'
+              {__path: 'myfile.txt'}
           ), '1c');
       assert.equal(
           element._computeCommentsStringMobile(element.changeComments, _1To2,
-              'myfile.txt'), '3c');
+              {__path: 'myfile.txt'}), '3c');
       assert.equal(
           element._computeDraftsString(element.changeComments, parentTo1,
-              'myfile.txt'), '');
+              {__path: 'myfile.txt'}), '');
       assert.equal(
           element._computeDraftsString(element.changeComments, _1To2,
-              'myfile.txt'), '');
+              {__path: 'myfile.txt'}), '');
       assert.equal(
           element._computeDraftsStringMobile(element.changeComments, parentTo1,
-              'myfile.txt'), '');
+              {__path: 'myfile.txt'}), '');
       assert.equal(
           element._computeDraftsStringMobile(element.changeComments, _1To2,
-              'myfile.txt'), '');
+              {__path: 'myfile.txt'}), '');
       assert.equal(
           element._computeCommentsStringMobile(
               element.changeComments,
               parentTo1,
-              'file_added_in_rev2.txt'
+              {__path: 'file_added_in_rev2.txt'}
           ), '');
       assert.equal(
           element._computeCommentsStringMobile(element.changeComments, _1To2,
-              'file_added_in_rev2.txt'), '');
+              {__path: 'file_added_in_rev2.txt'}), '');
       assert.equal(
           element._computeDraftsString(element.changeComments, parentTo1,
-              'file_added_in_rev2.txt'), '');
+              {__path: 'file_added_in_rev2.txt'}), '');
       assert.equal(
           element._computeDraftsString(element.changeComments, _1To2,
-              'file_added_in_rev2.txt'), '');
+              {__path: 'file_added_in_rev2.txt'}), '');
       assert.equal(
           element._computeDraftsStringMobile(element.changeComments, parentTo1,
-              'file_added_in_rev2.txt'), '');
+              {__path: 'file_added_in_rev2.txt'}), '');
       assert.equal(
           element._computeDraftsStringMobile(element.changeComments, _1To2,
-              'file_added_in_rev2.txt'), '');
+              {__path: 'file_added_in_rev2.txt'}), '');
       assert.equal(
           element._computeCommentsStringMobile(
               element.changeComments,
               parentTo2,
-              '/COMMIT_MSG'
+              {__path: '/COMMIT_MSG'}
           ), '1c');
       assert.equal(
           element._computeCommentsStringMobile(element.changeComments, _1To2,
-              '/COMMIT_MSG'), '3c');
+              {__path: '/COMMIT_MSG'}), '3c');
       assert.equal(
           element._computeDraftsString(element.changeComments, parentTo1,
-              '/COMMIT_MSG'), '2 drafts');
+              {__path: '/COMMIT_MSG'}), '2 drafts');
       assert.equal(
           element._computeDraftsString(element.changeComments, _1To2,
-              '/COMMIT_MSG'), '2 drafts');
+              {__path: '/COMMIT_MSG'}), '2 drafts');
       assert.equal(
           element._computeDraftsStringMobile(
               element.changeComments,
               parentTo1,
-              '/COMMIT_MSG'
+              {__path: '/COMMIT_MSG'}
           ), '2d');
       assert.equal(
           element._computeDraftsStringMobile(element.changeComments, _1To2,
-              '/COMMIT_MSG'), '2d');
+              {__path: '/COMMIT_MSG'}), '2d');
       assert.equal(
           element._computeCommentsStringMobile(
               element.changeComments,
               parentTo2,
-              'myfile.txt'
+              {__path: 'myfile.txt'}
           ), '2c');
       assert.equal(
           element._computeCommentsStringMobile(element.changeComments, _1To2,
-              'myfile.txt'), '3c');
+              {__path: 'myfile.txt'}), '3c');
       assert.equal(
           element._computeDraftsStringMobile(element.changeComments, parentTo2,
-              'myfile.txt'), '');
+              {__path: 'myfile.txt'}), '');
       assert.equal(
           element._computeDraftsStringMobile(element.changeComments, _1To2,
-              'myfile.txt'), '');
+              {__path: 'myfile.txt'}), '');
     });
 
     test('_reviewedTitle', () => {
@@ -543,7 +543,7 @@
         assert.equal(element.fileCursor.index, 0);
         assert.equal(element.selectedIndex, 0);
 
-        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 +664,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 +913,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';
@@ -1398,7 +1398,7 @@
       }
 
       element._updateDiffCursor();
-      element.$.diffCursor.handleDiffUpdate();
+      element.diffCursor.handleDiffUpdate();
       return diffs;
     }
 
@@ -1484,10 +1484,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 +1538,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)');
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 3ea9f68..1711499 100644
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message.ts
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message.ts
@@ -25,7 +25,11 @@
 import '../../../styles/gr-voting-styles';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-message_html';
-import {MessageTag, SpecialFilePath} from '../../../constants/constants';
+import {
+  ChangeMessageTemplate,
+  MessageTag,
+  SpecialFilePath,
+} from '../../../constants/constants';
 import {customElement, property, computed, observe} from '@polymer/decorators';
 import {
   ChangeInfo,
@@ -40,6 +44,7 @@
   PatchSetNum,
   AccountInfo,
   BasePatchSetNum,
+  AccountId,
 } from '../../../types/common';
 import {CommentThread} from '../../../utils/comment-util';
 import {hasOwnProperty} from '../../../utils/common-util';
@@ -57,6 +62,7 @@
 const LABEL_TITLE_SCORE_PATTERN = /^(-?)([A-Za-z0-9-]+?)([+-]\d+)?[.]?$/;
 const UPLOADED_NEW_PATCHSET_PATTERN = /Uploaded patch set (\d+)./;
 const MERGED_PATCHSET_PATTERN = /(\d+) is the latest approved patch-set/;
+const VOTE_RESET_TEXT = '0 (vote reset)';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -176,14 +182,19 @@
 
   @property({
     type: String,
-    computed: '_computeMessageContentExpanded(message.message, message.tag)',
+    computed:
+      '_computeMessageContentExpanded(message.message,' +
+      ' message.accounts_in_message,' +
+      ' message.tag)',
   })
   _messageContentExpanded = '';
 
   @property({
     type: String,
     computed:
-      '_computeMessageContentCollapsed(message.message, message.tag,' +
+      '_computeMessageContentCollapsed(message.message,' +
+      ' message.accounts_in_message,' +
+      ' message.tag,' +
       ' message.commentThreads)',
   })
   _messageContentCollapsed = '';
@@ -231,8 +242,12 @@
     return pluralize(threadsLength, 'comment');
   }
 
-  _computeMessageContentExpanded(content?: string, tag?: ReviewInputTag) {
-    return this._computeMessageContent(true, content, tag);
+  _computeMessageContentExpanded(
+    content?: string,
+    accountsInMessage?: AccountInfo[],
+    tag?: ReviewInputTag
+  ) {
+    return this._computeMessageContent(true, content, accountsInMessage, tag);
   }
 
   _patchsetCommentSummary(commentThreads: CommentThread[] = []) {
@@ -261,10 +276,16 @@
 
   _computeMessageContentCollapsed(
     content?: string,
+    accountsInMessage?: AccountInfo[],
     tag?: ReviewInputTag,
     commentThreads?: CommentThread[]
   ) {
-    const summary = this._computeMessageContent(false, content, tag);
+    const summary = this._computeMessageContent(
+      false,
+      content,
+      accountsInMessage,
+      tag
+    );
     if (summary || !commentThreads) return summary;
     return this._patchsetCommentSummary(commentThreads);
   }
@@ -319,11 +340,22 @@
   _computeMessageContent(
     isExpanded: boolean,
     content?: string,
+    accountsInMessage?: AccountInfo[],
     tag?: ReviewInputTag
   ) {
     if (!content) return '';
     const isNewPatchSet = this._isNewPatchsetTag(tag);
 
+    if (accountsInMessage) {
+      content = content.replace(
+        new RegExp(ChangeMessageTemplate.ACCOUNT_TEMPLATE, 'g'),
+        (_accountIdTemplate, accountId) =>
+          accountsInMessage.find(
+            account => account._account_id === (Number(accountId) as AccountId)
+          )?.name || `Gerrit Account ${accountId}`
+      );
+    }
+
     const lines = content.split('\n');
     const filteredLines = lines.filter(line => {
       if (!isExpanded && line.startsWith('>')) {
@@ -435,7 +467,7 @@
       )
       .map(ms => {
         const label = ms?.[2];
-        const value = ms?.[1] === '-' ? 'removed' : ms?.[3];
+        const value = ms?.[1] === '-' ? VOTE_RESET_TEXT : ms?.[3];
         return {label, value};
       });
   }
@@ -448,7 +480,7 @@
     if (!score.value) {
       return '';
     }
-    if (score.value === 'removed') {
+    if (score.value.includes(VOTE_RESET_TEXT)) {
       return 'removed';
     }
     const classes = [];
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..57c1a30 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;
@@ -199,6 +200,10 @@
         font-weight: var(--font-weight-bold);
       }
     }
+    iron-icon {
+      --iron-icon-height: 20px;
+      --iron-icon-width: 20px;
+    }
     @media screen and (max-width: 50em) {
       .expanded .content {
         padding-left: 0;
diff --git a/polygerrit-ui/app/elements/change/gr-message/gr-message_test.ts b/polygerrit-ui/app/elements/change/gr-message/gr-message_test.ts
index b8f3c73..97568dc 100644
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message_test.ts
@@ -19,6 +19,7 @@
 import './gr-message';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 import {
+  createAccountWithIdNameAndEmail,
   createChange,
   createChangeMessage,
   createComment,
@@ -349,11 +350,21 @@
     suite('compute messages', () => {
       test('empty', () => {
         assert.equal(
-          element._computeMessageContent(true, '', '' as ReviewInputTag),
+          element._computeMessageContent(
+            true,
+            '',
+            undefined,
+            '' as ReviewInputTag
+          ),
           ''
         );
         assert.equal(
-          element._computeMessageContent(false, '', '' as ReviewInputTag),
+          element._computeMessageContent(
+            false,
+            '',
+            undefined,
+            '' as ReviewInputTag
+          ),
           ''
         );
       });
@@ -361,13 +372,13 @@
       test('new patchset', () => {
         const original = 'Uploaded patch set 1.';
         const tag = 'autogenerated:gerrit:newPatchSet' as ReviewInputTag;
-        let actual = element._computeMessageContent(true, original, tag);
+        let actual = element._computeMessageContent(true, original, [], tag);
         assert.equal(
           actual,
-          element._computeMessageContentCollapsed(original, tag, [])
+          element._computeMessageContentCollapsed(original, [], tag, [])
         );
         assert.equal(actual, original);
-        actual = element._computeMessageContent(false, original, tag);
+        actual = element._computeMessageContent(false, original, [], tag);
         assert.equal(actual, original);
       });
 
@@ -375,13 +386,13 @@
         const original = 'Patch Set 27: Patch Set 26 was rebased';
         const tag = 'autogenerated:gerrit:newPatchSet' as ReviewInputTag;
         const expected = 'Patch Set 26 was rebased';
-        let actual = element._computeMessageContent(true, original, tag);
+        let actual = element._computeMessageContent(true, original, [], tag);
         assert.equal(actual, expected);
         assert.equal(
           actual,
-          element._computeMessageContentCollapsed(original, tag, [])
+          element._computeMessageContentCollapsed(original, [], tag, [])
         );
-        actual = element._computeMessageContent(false, original, tag);
+        actual = element._computeMessageContent(false, original, [], tag);
         assert.equal(actual, expected);
       });
 
@@ -389,13 +400,13 @@
         const original = 'Patch Set 1:\n\nThis change is ready for review.';
         const tag = undefined;
         const expected = 'This change is ready for review.';
-        let actual = element._computeMessageContent(true, original, tag);
+        let actual = element._computeMessageContent(true, original, [], tag);
         assert.equal(actual, expected);
         assert.equal(
           actual,
-          element._computeMessageContentCollapsed(original, tag, [])
+          element._computeMessageContentCollapsed(original, [], tag, [])
         );
-        actual = element._computeMessageContent(false, original, tag);
+        actual = element._computeMessageContent(false, original, [], tag);
         assert.equal(actual, expected);
       });
 
@@ -403,9 +414,9 @@
         const original = 'Patch Set 1: Code-Style+1';
         const tag = undefined;
         const expected = '';
-        let actual = element._computeMessageContent(true, original, tag);
+        let actual = element._computeMessageContent(true, original, [], tag);
         assert.equal(actual, expected);
-        actual = element._computeMessageContent(false, original, tag);
+        actual = element._computeMessageContent(false, original, [], tag);
         assert.equal(actual, expected);
       });
 
@@ -413,9 +424,47 @@
         const original = 'Patch Set 1:\n\n(3 comments)';
         const tag = undefined;
         const expected = '';
-        let actual = element._computeMessageContent(true, original, tag);
+        let actual = element._computeMessageContent(true, original, [], tag);
         assert.equal(actual, expected);
-        actual = element._computeMessageContent(false, original, tag);
+        actual = element._computeMessageContent(false, original, [], tag);
+        assert.equal(actual, expected);
+      });
+
+      test('message template', () => {
+        const original =
+          'Removed vote: \n\n * Code-Style+1 by <GERRIT_ACCOUNT_0000001>\n * Code-Style-1 by <GERRIT_ACCOUNT_0000002>';
+        const tag = undefined;
+        const expected =
+          'Removed vote: \n\n * Code-Style+1 by User-1\n * Code-Style-1 by User-2';
+        const accountsInMessage = [
+          createAccountWithIdNameAndEmail(1),
+          createAccountWithIdNameAndEmail(2),
+        ];
+        let actual = element._computeMessageContent(
+          true,
+          original,
+          accountsInMessage,
+          tag
+        );
+        assert.equal(actual, expected);
+        actual = element._computeMessageContent(
+          false,
+          original,
+          accountsInMessage,
+          tag
+        );
+        assert.equal(actual, expected);
+      });
+
+      test('message template missing accounts', () => {
+        const original =
+          'Removed vote: \n\n * Code-Style+1 by <GERRIT_ACCOUNT_0000001>\n * Code-Style-1 by <GERRIT_ACCOUNT_0000002>';
+        const tag = undefined;
+        const expected =
+          'Removed vote: \n\n * Code-Style+1 by Gerrit Account 0000001\n * Code-Style-1 by Gerrit Account 0000002';
+        let actual = element._computeMessageContent(true, original, [], tag);
+        assert.equal(actual, expected);
+        actual = element._computeMessageContent(false, original, [], tag);
         assert.equal(actual, expected);
       });
     });
@@ -570,10 +619,18 @@
         },
       ];
       assert.equal(
-        element._computeMessageContentCollapsed('', undefined, threads),
+        element._computeMessageContentCollapsed(
+          '',
+          undefined,
+          undefined,
+          threads
+        ),
         'testing the load'
       );
-      assert.equal(element._computeMessageContent(false, '', undefined), '');
+      assert.equal(
+        element._computeMessageContent(false, '', undefined, undefined),
+        ''
+      );
     });
 
     test('single patchset comment with reply', () => {
@@ -610,10 +667,18 @@
         },
       ];
       assert.equal(
-        element._computeMessageContentCollapsed('', undefined, threads),
+        element._computeMessageContentCollapsed(
+          '',
+          undefined,
+          undefined,
+          threads
+        ),
         'n'
       );
-      assert.equal(element._computeMessageContent(false, '', undefined), '');
+      assert.equal(
+        element._computeMessageContent(false, '', undefined, undefined),
+        ''
+      );
     });
   });
 
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..4e2aaf1 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
@@ -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) {
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-change.ts b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-change.ts
index 49c2486..921c45c 100644
--- a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-change.ts
+++ b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-change.ts
@@ -54,6 +54,7 @@
         a {
           display: block;
         }
+        :host,
         .changeContainer,
         a {
           max-width: 100%;
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 8d5bc33..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
@@ -21,13 +21,7 @@
 import '../../plugins/gr-endpoint-slot/gr-endpoint-slot';
 import {classMap} from 'lit-html/directives/class-map';
 import {GrLitElement} from '../../lit/gr-lit-element';
-import {
-  customElement,
-  property,
-  css,
-  internalProperty,
-  TemplateResult,
-} from 'lit-element';
+import {customElement, property, css, state, TemplateResult} from 'lit-element';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {
   SubmittedTogetherInfo,
@@ -46,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;
@@ -76,22 +71,22 @@
   @property()
   mergeable?: boolean;
 
-  @internalProperty()
+  @state()
   submittedTogether?: SubmittedTogetherInfo = {
     changes: [],
     non_visible_changes: 0,
   };
 
-  @internalProperty()
+  @state()
   relatedChanges: RelatedChangeAndCommitInfo[] = [];
 
-  @internalProperty()
+  @state()
   conflictingChanges: ChangeInfo[] = [];
 
-  @internalProperty()
+  @state()
   cherryPickChanges: ChangeInfo[] = [];
 
-  @internalProperty()
+  @state()
   sameTopicChanges: ChangeInfo[] = [];
 
   private readonly restApiService = appContext.restApiService;
@@ -107,15 +102,37 @@
         section {
           margin-bottom: var(--spacing-l);
         }
-        gr-related-change {
+        .relatedChangeLine {
           display: flex;
+          visibility: visible;
+          height: auto;
         }
-        .marker {
-          position: absolute;
-          margin-left: calc(-1 * var(--spacing-s));
+        .marker.arrow {
+          visibility: hidden;
+          min-width: 20px;
         }
-        .arrowToCurrentChange {
-          position: absolute;
+        .marker.arrowToCurrentChange {
+          min-width: 20px;
+          text-align: center;
+        }
+        .marker.space {
+          height: 1px;
+          min-width: 20px;
+        }
+        gr-related-collapse[collapsed] .marker.arrow {
+          visibility: visible;
+          min-width: auto;
+        }
+        gr-related-collapse[collapsed] .relatedChangeLine.show-when-collapsed {
+          visibility: visible;
+          height: auto;
+        }
+        /* keep width, so width of section and position of show all button
+         * are set according to width of all (even hidden) elements
+         */
+        gr-related-collapse[collapsed] .relatedChangeLine {
+          visibility: hidden;
+          height: 0px;
         }
       `,
     ];
@@ -157,13 +174,16 @@
       >
         ${this.relatedChanges.map(
           (change, index) =>
-            html`${this.renderMarkers(
+            html`<div
+              class="${classMap({
+                ['relatedChangeLine']: true,
+                ['show-when-collapsed']: relatedChangesMarkersPredicate(index)
+                  .showWhenCollapsed,
+              })}"
+            >
+              ${this.renderMarkers(
                 relatedChangesMarkersPredicate(index)
               )}<gr-related-change
-                class="${classMap({
-                  ['show-when-collapsed']: relatedChangesMarkersPredicate(index)
-                    .showWhenCollapsed,
-                })}"
                 .change="${change}"
                 .connectedRevisions="${connectedRevisions}"
                 .href="${change?._change_number
@@ -175,7 +195,8 @@
                   : ''}"
                 .showChangeStatus=${true}
                 >${change.commit.subject}</gr-related-change
-              >`
+              >
+            </div>`
         )}
       </gr-related-collapse>
     </section>`;
@@ -208,14 +229,17 @@
       >
         ${submittedTogetherChanges.map(
           (change, index) =>
-            html`${this.renderMarkers(
+            html`<div
+              class="${classMap({
+                ['relatedChangeLine']: true,
+                ['show-when-collapsed']: submittedTogetherMarkersPredicate(
+                  index
+                ).showWhenCollapsed,
+              })}"
+            >
+              ${this.renderMarkers(
                 submittedTogetherMarkersPredicate(index)
               )}<gr-related-change
-                class="${classMap({
-                  ['show-when-collapsed']: submittedTogetherMarkersPredicate(
-                    index
-                  ).showWhenCollapsed,
-                })}"
                 .change="${change}"
                 .href="${GerritNav.getUrlForChangeById(
                   change._number,
@@ -224,7 +248,8 @@
                 .showSubmittableCheck=${true}
                 >${change.project}: ${change.branch}:
                 ${change.subject}</gr-related-change
-              >`
+              >
+            </div>`
         )}
       </gr-related-collapse>
       <div class="note" ?hidden=${!countNonVisibleChanges}>
@@ -252,13 +277,16 @@
       >
         ${this.sameTopicChanges.map(
           (change, index) =>
-            html`${this.renderMarkers(
+            html`<div
+              class="${classMap({
+                ['relatedChangeLine']: true,
+                ['show-when-collapsed']: sameTopicMarkersPredicate(index)
+                  .showWhenCollapsed,
+              })}"
+            >
+              ${this.renderMarkers(
                 sameTopicMarkersPredicate(index)
               )}<gr-related-change
-                class="${classMap({
-                  ['show-when-collapsed']: sameTopicMarkersPredicate(index)
-                    .showWhenCollapsed,
-                })}"
                 .change="${change}"
                 .href="${GerritNav.getUrlForChangeById(
                   change._number,
@@ -266,7 +294,8 @@
                 )}"
                 >${change.project}: ${change.branch}:
                 ${change.subject}</gr-related-change
-              >`
+              >
+            </div>`
         )}
       </gr-related-collapse>
     </section>`;
@@ -291,20 +320,24 @@
       >
         ${this.conflictingChanges.map(
           (change, index) =>
-            html`${this.renderMarkers(
+            html`<div
+              class="${classMap({
+                ['relatedChangeLine']: true,
+                ['show-when-collapsed']: mergeConflictsMarkersPredicate(index)
+                  .showWhenCollapsed,
+              })}"
+            >
+              ${this.renderMarkers(
                 mergeConflictsMarkersPredicate(index)
               )}<gr-related-change
-                class="${classMap({
-                  ['show-when-collapsed']: mergeConflictsMarkersPredicate(index)
-                    .showWhenCollapsed,
-                })}"
                 .change="${change}"
                 .href="${GerritNav.getUrlForChangeById(
                   change._number,
                   change.project
                 )}"
                 >${change.subject}</gr-related-change
-              >`
+              >
+            </div>`
         )}
       </gr-related-collapse>
     </section>`;
@@ -329,20 +362,24 @@
       >
         ${this.cherryPickChanges.map(
           (change, index) =>
-            html`${this.renderMarkers(
+            html`<div
+              class="${classMap({
+                ['relatedChangeLine']: true,
+                ['show-when-collapsed']: cherryPicksMarkersPredicate(index)
+                  .showWhenCollapsed,
+              })}"
+            >
+              ${this.renderMarkers(
                 cherryPicksMarkersPredicate(index)
               )}<gr-related-change
-                class="${classMap({
-                  ['show-when-collapsed']: cherryPicksMarkersPredicate(index)
-                    .showWhenCollapsed,
-                })}"
                 .change="${change}"
                 .href="${GerritNav.getUrlForChangeById(
                   change._number,
                   change.project
                 )}"
                 >${change.branch}: ${change.subject}</gr-related-change
-              >`
+              >
+            </div>`
         )}
       </gr-related-collapse>
     </section>`;
@@ -471,7 +508,7 @@
     if (changeMarkers.showCurrentChangeArrow) {
       return html`<span
         role="img"
-        class="arrowToCurrentChange"
+        class="marker arrowToCurrentChange"
         aria-label="Arrow marking current change"
         >âž”</span
       >`;
@@ -479,7 +516,7 @@
     if (changeMarkers.showTopArrow) {
       return html`<span
         role="img"
-        class="marker"
+        class="marker arrow"
         aria-label="Arrow marking change has collapsed ancestors"
         ><iron-icon icon="gr-icons:arrowDropUp"></iron-icon
       ></span> `;
@@ -487,12 +524,12 @@
     if (changeMarkers.showBottomArrow) {
       return html`<span
         role="img"
-        class="marker"
+        class="marker arrow"
         aria-label="Arrow marking change has collapsed descendants"
         ><iron-icon icon="gr-icons:arrowDropDown"></iron-icon
       ></span> `;
     }
-    return nothing;
+    return html`<span class="marker space"></span>`;
   }
 
   reload(getRelatedChanges?: Promise<RelatedChangesInfo | undefined>) {
@@ -634,9 +671,12 @@
   @property()
   title = '';
 
-  @property()
+  @property({type: Boolean})
   showAll = false;
 
+  @property({type: Boolean, reflect: true})
+  collapsed = true;
+
   @property()
   length = 0;
 
@@ -661,36 +701,8 @@
         gr-button {
           display: flex;
         }
-        /* This is a hacky solution from old gr-related-change-list
-         * TODO(milutin): find layout without needing it
-         */
-        h4:before,
-        gr-button:before,
-        ::slotted(gr-related-change):before {
-          content: ' ';
-          flex-shrink: 0;
-          width: 1.2em;
-        }
-        .collapsed ::slotted(gr-related-change.show-when-collapsed) {
-          visibility: visible;
-          height: auto;
-        }
-        .collapsed ::slotted(.marker) {
-          display: block;
-        }
-        .show-all ::slotted(.marker) {
-          display: none;
-        }
-        /* keep width, so width of section and position of show all button
-         * are set according to width of all (even hidden) elements
-         */
-        .collapsed ::slotted(gr-related-change) {
-          visibility: hidden;
-          height: 0px;
-        }
-        ::slotted(gr-related-change) {
-          visibility: visible;
-          height: auto;
+        h4 {
+          margin-left: 20px;
         }
         gr-button iron-icon {
           color: inherit;
@@ -713,11 +725,7 @@
     const title = html`<h4 class="title">${this.title}</h4>`;
 
     const collapsible = this.length > this.numChangesWhenCollapsed;
-    const items = html` <div
-      class="${!this.showAll && collapsible ? 'collapsed' : 'show-all'}"
-    >
-      <slot></slot>
-    </div>`;
+    this.collapsed = !this.showAll && collapsible;
 
     let button: TemplateResult | typeof nothing = nothing;
     if (collapsible) {
@@ -733,13 +741,13 @@
     }
 
     return html`<div class="container">${title}${button}</div>
-      ${items}`;
+      <div><slot></slot></div>`;
   }
 
   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-reply-dialog/gr-reply-dialog-it_test.js b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog-it_test.js
index 5f35fd3..2767940 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
@@ -87,9 +87,6 @@
 
   test('_submit blocked when invalid email is supplied to ccs', () => {
     const sendStub = sinon.stub(element, 'send').returns(Promise.resolve());
-    // Stub the below function to avoid side effects from the send promise
-    // resolving.
-    sinon.stub(element, '_purgeReviewersPendingRemove');
 
     element.$.ccs.$.entry.setText('test');
     MockInteractions.tap(element.shadowRoot
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 2cabf90..939b23c 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
@@ -38,9 +38,13 @@
   ReviewerState,
   SpecialFilePath,
 } from '../../../constants/constants';
-import {fetchChangeUpdates} from '../../../utils/patch-set-util';
 import {KeyboardShortcutMixin} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
-import {accountKey, removeServiceUsers} from '../../../utils/account-util';
+import {
+  accountOrGroupKey,
+  isReviewerOrCC,
+  mapReviewer,
+  removeServiceUsers,
+} from '../../../utils/account-util';
 import {getDisplayName} from '../../../utils/display-name-util';
 import {IronA11yAnnouncer} from '@polymer/iron-a11y-announcer/iron-a11y-announcer';
 import {TargetElement} from '../../../api/plugin';
@@ -65,14 +69,12 @@
   GroupInfo,
   isAccount,
   isDetailedLabelInfo,
-  isGroup,
   isReviewerAccountSuggestion,
   isReviewerGroupSuggestion,
   LabelNameToValueMap,
   ParsedJSON,
   PatchSetNum,
   ProjectInfo,
-  ReviewerInput,
   Reviewers,
   ReviewInput,
   ReviewResult,
@@ -84,20 +86,17 @@
 import {GrLabelScoreRow} from '../gr-label-score-row/gr-label-score-row';
 import {
   PolymerDeepPropertyChange,
-  PolymerSplice,
   PolymerSpliceChange,
 } from '@polymer/polymer/interfaces';
 import {
   areSetsEqual,
   assertIsDefined,
-  assertNever,
   containsAll,
 } from '../../../utils/common-util';
 import {CommentThread, isUnresolved} from '../../../utils/comment-util';
 import {GrTextarea} from '../../shared/gr-textarea/gr-textarea';
 import {GrAccountChip} from '../../shared/gr-account-chip/gr-account-chip';
 import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
-import {isAttentionSetEnabled} from '../../../utils/attention-set-util';
 import {
   CODE_REVIEW,
   getApprovalInfo,
@@ -108,12 +107,13 @@
   fireAlert,
   fireEvent,
   fireIronAnnounce,
+  fireReload,
   fireServerError,
 } from '../../../utils/event-util';
 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';
 
 const STORAGE_DEBOUNCE_INTERVAL_MS = 400;
 
@@ -149,15 +149,6 @@
 
 const EMPTY_REPLY_MESSAGE = 'Cannot send an empty reply.';
 
-interface PendingRemovals {
-  CC: (AccountInfoInput | GroupInfoInput)[];
-  REVIEWER: (AccountInfoInput | GroupInfoInput)[];
-}
-const PENDING_REMOVAL_KEYS: (keyof PendingRemovals)[] = [
-  ReviewerType.CC,
-  ReviewerType.REVIEWER,
-];
-
 export interface GrReplyDialog {
   $: {
     reviewers: GrAccountList;
@@ -226,6 +217,8 @@
 
   flagsService = appContext.flagsService;
 
+  changeService = appContext.changeService;
+
   @property({type: Object})
   change?: ChangeInfo;
 
@@ -310,12 +303,6 @@
   @property({type: Boolean, observer: '_handleHeightChanged'})
   _previewFormatting = false;
 
-  @property({type: Object})
-  _reviewersPendingRemove: PendingRemovals = {
-    CC: [],
-    REVIEWER: [],
-  };
-
   @property({type: String, computed: '_computeSendButtonLabel(canBeStarted)'})
   _sendButtonLabel?: string;
 
@@ -436,7 +423,7 @@
   open(focusTarget?: FocusTarget) {
     assertIsDefined(this.change, 'change');
     this.knownLatestState = LatestPatchState.CHECKING;
-    fetchChangeUpdates(this.change, this.restApiService).then(result => {
+    this.changeService.fetchChangeUpdates(this.change).then(result => {
       this.knownLatestState = result.isLatest
         ? LatestPatchState.LATEST
         : LatestPatchState.NOT_LATEST;
@@ -513,22 +500,21 @@
   }
 
   @observe('_ccs.splices')
-  _ccsChanged(splices: PolymerSpliceChange<AccountInfo[]>) {
+  _ccsChanged(splices: PolymerSpliceChange<AccountInfo[] | GroupInfo[]>) {
     this._reviewerTypeChanged(splices, ReviewerType.CC);
   }
 
   @observe('_reviewers.splices')
-  _reviewersChanged(splices: PolymerSpliceChange<AccountInfo[]>) {
+  _reviewersChanged(splices: PolymerSpliceChange<AccountInfo[] | GroupInfo[]>) {
     this._reviewerTypeChanged(splices, ReviewerType.REVIEWER);
   }
 
   _reviewerTypeChanged(
-    splices: PolymerSpliceChange<AccountInfo[]>,
+    splices: PolymerSpliceChange<AccountInfo[] | GroupInfo[]>,
     reviewerType: ReviewerType
   ) {
     if (splices && splices.indexSplices) {
       this._reviewersMutated = true;
-      this._processReviewerChange(splices.indexSplices, reviewerType);
       let key: AccountId | EmailAddress | GroupId | undefined;
       let index;
       let account;
@@ -538,16 +524,16 @@
       for (const splice of splices.indexSplices) {
         for (let i = 0; i < splice.addedCount; i++) {
           account = splice.object[splice.index + i];
-          key = this._accountOrGroupKey(account);
+          key = accountOrGroupKey(account);
           const array = isReviewer ? this._ccs : this._reviewers;
           index = array.findIndex(
-            account => this._accountOrGroupKey(account) === key
+            account => accountOrGroupKey(account) === key
           );
           if (index >= 0) {
             this.splice(isReviewer ? '_ccs' : '_reviewers', index, 1);
             const moveFrom = isReviewer ? 'CC' : 'reviewer';
             const moveTo = isReviewer ? 'reviewer' : 'CC';
-            const id = account.name || account.email || key;
+            const id = account.name || key;
             const message = `${id} moved from ${moveFrom} to ${moveTo}.`;
             fireAlert(this, message);
           }
@@ -556,92 +542,7 @@
     }
   }
 
-  _processReviewerChange(
-    indexSplices: Array<PolymerSplice<AccountInfo[]>>,
-    type: ReviewerType
-  ) {
-    for (const splice of indexSplices) {
-      for (const account of splice.removed) {
-        if (!this._reviewersPendingRemove[type]) {
-          this.reporting.error(new Error(`Invalid type ${type} for reviewer.`));
-          return;
-        }
-        this._reviewersPendingRemove[type].push(account);
-      }
-    }
-  }
-
-  /**
-   * Resets the state of the _reviewersPendingRemove object, and removes
-   * accounts if necessary.
-   *
-   * @param isCancel true if the action is a cancel.
-   * @param keep map of account IDs that must
-   * not be removed, because they have been readded in another state.
-   */
-  _purgeReviewersPendingRemove(
-    isCancel: boolean,
-    keep = new Map<AccountId | EmailAddress, boolean>()
-  ) {
-    let reviewerArr: (AccountInfoInput | GroupInfoInput)[];
-    for (const type of PENDING_REMOVAL_KEYS) {
-      if (!isCancel) {
-        reviewerArr = this._reviewersPendingRemove[type];
-        for (let i = 0; i < reviewerArr.length; i++) {
-          const reviewer = reviewerArr[i];
-          if (!isAccount(reviewer) || !keep.get(accountKey(reviewer))) {
-            this._removeAccount(reviewer, type as ReviewerType);
-          }
-        }
-      }
-      this._reviewersPendingRemove[type] = [];
-    }
-  }
-
-  /**
-   * Removes an account from the change, both on the backend and the client.
-   * Does nothing if the account is a pending addition.
-   */
-  _removeAccount(
-    account: AccountInfoInput | GroupInfoInput,
-    type: ReviewerType
-  ) {
-    assertIsDefined(this.change, 'change');
-    if (account._pendingAdd || !isAccount(account)) {
-      return;
-    }
-
-    return this.restApiService
-      .removeChangeReviewer(this.change._number, accountKey(account))
-      .then((response?: Response) => {
-        if (!response?.ok || !this.change) return;
-
-        const reviewers = this.change.reviewers[type] || [];
-        for (let i = 0; i < reviewers.length; i++) {
-          if (reviewers[i]._account_id === account._account_id) {
-            this.splice(`change.reviewers.${type}`, i, 1);
-            break;
-          }
-        }
-      });
-  }
-
-  _mapReviewer(addition: AccountAddition): ReviewerInput {
-    if (addition.account) {
-      return {reviewer: accountKey(addition.account)};
-    }
-    if (addition.group) {
-      const reviewer = decodeURIComponent(addition.group.id) as GroupId;
-      const confirmed = addition.group.confirmed;
-      return {reviewer, confirmed};
-    }
-    throw new Error('Reviewer must be either an account or a group.');
-  }
-
-  send(
-    includeComments: boolean,
-    startReview: boolean
-  ): Promise<Map<AccountId | EmailAddress, boolean>> {
+  send(includeComments: boolean, startReview: boolean) {
     this.reporting.time(Timing.SEND_REPLY);
     const labels = this.$.labelScores.getLabelValues();
 
@@ -656,29 +557,27 @@
       reviewInput.ready = true;
     }
 
-    if (isAttentionSetEnabled(this.serverConfig)) {
-      const selfName = getDisplayName(this.serverConfig, this._account);
-      const reason = `${selfName} replied on the change`;
+    const selfName = getDisplayName(this.serverConfig, this._account);
+    const reason = `${selfName} replied on the change`;
 
-      reviewInput.ignore_automatic_attention_set_rules = true;
-      reviewInput.add_to_attention_set = [];
-      for (const user of this._newAttentionSet) {
-        if (!this._currentAttentionSet.has(user)) {
-          reviewInput.add_to_attention_set.push({user, reason});
-        }
+    reviewInput.ignore_automatic_attention_set_rules = true;
+    reviewInput.add_to_attention_set = [];
+    for (const user of this._newAttentionSet) {
+      if (!this._currentAttentionSet.has(user)) {
+        reviewInput.add_to_attention_set.push({user, reason});
       }
-      reviewInput.remove_from_attention_set = [];
-      for (const user of this._currentAttentionSet) {
-        if (!this._newAttentionSet.has(user)) {
-          reviewInput.remove_from_attention_set.push({user, reason});
-        }
-      }
-      this.reportAttentionSetChanges(
-        this._attentionExpanded,
-        reviewInput.add_to_attention_set,
-        reviewInput.remove_from_attention_set
-      );
     }
+    reviewInput.remove_from_attention_set = [];
+    for (const user of this._currentAttentionSet) {
+      if (!this._newAttentionSet.has(user)) {
+        reviewInput.remove_from_attention_set.push({user, reason});
+      }
+    }
+    this.reportAttentionSetChanges(
+      this._attentionExpanded,
+      reviewInput.add_to_attention_set,
+      reviewInput.remove_from_attention_set
+    );
 
     if (this.draft) {
       const comment: CommentInput = {
@@ -690,24 +589,29 @@
       };
     }
 
-    const accountAdditions = new Map<AccountId | EmailAddress, boolean>();
-    reviewInput.reviewers = this.$.reviewers.additions().map(reviewer => {
-      if (reviewer.account) {
-        accountAdditions.set(accountKey(reviewer.account), true);
-      }
-      return this._mapReviewer(reviewer);
-    });
-    const ccsEl = this.$.ccs;
-    if (ccsEl) {
-      for (const addition of ccsEl.additions()) {
-        if (addition.account) {
-          accountAdditions.set(accountKey(addition.account), true);
-        }
-        const reviewer = this._mapReviewer(addition);
-        reviewer.state = ReviewerState.CC;
-        reviewInput.reviewers.push(reviewer);
-      }
-    }
+    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
+    );
 
     this.disabled = true;
 
@@ -717,11 +621,11 @@
         if (!response) {
           // Null or undefined response indicates that an error handler
           // took responsibility, so just return.
-          return new Map<AccountId | EmailAddress, boolean>();
+          return;
         }
         if (!response.ok) {
           fireServerError(response);
-          return new Map<AccountId | EmailAddress, boolean>();
+          return;
         }
 
         this.draft = '';
@@ -733,7 +637,7 @@
           })
         );
         fireIronAnnounce(this, 'Reply sent');
-        return accountAdditions;
+        return;
       })
       .then(result => {
         this.disabled = false;
@@ -854,7 +758,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;
@@ -889,12 +793,12 @@
     fireEvent(this, 'iron-resize');
   }
 
-  _showAttentionSummary(config?: ServerInfo, attentionExpanded?: boolean) {
-    return isAttentionSetEnabled(config) && !attentionExpanded;
+  _showAttentionSummary(attentionExpanded?: boolean) {
+    return !attentionExpanded;
   }
 
-  _showAttentionDetails(config?: ServerInfo, attentionExpanded?: boolean) {
-    return isAttentionSetEnabled(config) && attentionExpanded;
+  _showAttentionDetails(attentionExpanded?: boolean) {
+    return attentionExpanded;
   }
 
   _computeAttentionButtonTitle(sendDisabled?: boolean) {
@@ -916,12 +820,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}`,
       });
     }
@@ -1163,12 +1067,6 @@
     return rev.uploader;
   }
 
-  _accountOrGroupKey(entry: AccountInfo | GroupInfo) {
-    if (isAccount(entry)) return accountKey(entry);
-    if (isGroup(entry)) return entry.id;
-    assertNever(entry, 'entry must be account or group');
-  }
-
   /**
    * Generates a function to filter out reviewer/CC entries. When isCCs is
    * truthy, the function filters out entries that already exist in this._ccs.
@@ -1187,16 +1085,15 @@
       } 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;
       }
 
-      const key = this._accountOrGroupKey(entry);
+      const key = accountOrGroupKey(entry);
       const finder = (entry: AccountInfo | GroupInfo) =>
-        this._accountOrGroupKey(entry) === key;
+        accountOrGroupKey(entry) === key;
       if (isCCs) {
         return this._ccs.find(finder) === undefined;
       }
@@ -1223,7 +1120,7 @@
       })
     );
     this.$.textarea.closeDropdown();
-    this._purgeReviewersPendingRemove(true);
+    this.$.reviewers.clearPendingRemovals();
     this._rebuildReviewerArrays(this.change.reviewers, this._owner);
   }
 
@@ -1234,9 +1131,7 @@
       // the text field of the CC entry.
       return;
     }
-    this.send(this._includeComments, false).then(keepReviewers => {
-      this._purgeReviewersPendingRemove(false, keepReviewers);
-    });
+    this.send(this._includeComments, false);
   }
 
   _sendTapHandler(e: Event) {
@@ -1254,19 +1149,15 @@
       fireAlert(this, EMPTY_REPLY_MESSAGE);
       return;
     }
-    return this.send(this._includeComments, this.canBeStarted)
-      .then(keepReviewers => {
-        this._purgeReviewersPendingRemove(false, keepReviewers);
-      })
-      .catch(err => {
-        this.dispatchEvent(
-          new CustomEvent('show-error', {
-            bubbles: true,
-            composed: true,
-            detail: {message: `Error submitting review ${err}`},
-          })
-        );
-      });
+    return this.send(this._includeComments, this.canBeStarted).catch(err => {
+      this.dispatchEvent(
+        new CustomEvent('show-error', {
+          bubbles: true,
+          composed: true,
+          detail: {message: `Error submitting review ${err}`},
+        })
+      );
+    });
   }
 
   _saveReview(review: ReviewInput, errFn?: ErrorCallback) {
@@ -1368,13 +1259,7 @@
   }
 
   _reload() {
-    this.dispatchEvent(
-      new CustomEvent('reload', {
-        detail: {clearPatchset: true},
-        bubbles: false,
-        composed: true,
-      })
-    );
+    fireReload(this, true);
     this.cancel();
   }
 
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 f458994..c3c1d54 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
@@ -363,7 +363,7 @@
     </section>
     <div class="stickyBottom">
       <section
-        hidden$="[[!_showAttentionSummary(serverConfig, _attentionExpanded)]]"
+        hidden$="[[!_showAttentionSummary(_attentionExpanded)]]"
         class="attention"
       >
         <div class="attentionSummary">
@@ -391,8 +391,8 @@
                   account="[[account]]"
                   force-attention="[[_computeHasNewAttention(account, _newAttentionSet)]]"
                   selected="[[_computeHasNewAttention(account, _newAttentionSet)]]"
-                  deselected="[[!_computeHasNewAttention(account, _newAttentionSet)]]"
                   hide-hovercard=""
+                  selection-chip-style
                   on-click="_handleAttentionClick"
                 ></gr-account-label>
               </template>
@@ -429,7 +429,7 @@
         </div>
       </section>
       <section
-        hidden$="[[!_showAttentionDetails(serverConfig, _attentionExpanded)]]"
+        hidden$="[[!_showAttentionDetails(_attentionExpanded)]]"
         class="attention-detail"
       >
         <div class="attentionDetailsTitle">
@@ -462,8 +462,8 @@
               account="[[_owner]]"
               force-attention="[[_computeHasNewAttention(_owner, _newAttentionSet)]]"
               selected="[[_computeHasNewAttention(_owner, _newAttentionSet)]]"
-              deselected="[[!_computeHasNewAttention(_owner, _newAttentionSet)]]"
               hide-hovercard=""
+              selection-chip-style
               on-click="_handleAttentionClick"
             >
             </gr-account-label>
@@ -477,8 +477,8 @@
                 account="[[_uploader]]"
                 force-attention="[[_computeHasNewAttention(_uploader, _newAttentionSet)]]"
                 selected="[[_computeHasNewAttention(_uploader, _newAttentionSet)]]"
-                deselected="[[!_computeHasNewAttention(_uploader, _newAttentionSet)]]"
                 hide-hovercard=""
+                selection-chip-style
                 on-click="_handleAttentionClick"
               >
               </gr-account-label>
@@ -496,9 +496,9 @@
               <gr-account-label
                 account="[[account]]"
                 force-attention="[[_computeHasNewAttention(account, _newAttentionSet)]]"
-                selected="[[_computeHasNewAttention(account, _newAttentionSet)]]"
-                deselected="[[!_computeHasNewAttention(account, _newAttentionSet)]]"
+                selected="[[_computeHasNewAttention(account, _newAttentionSet)]]
                 hide-hovercard=""
+                selection-chip-style
                 on-click="_handleAttentionClick"
               >
               </gr-account-label>
@@ -518,8 +518,8 @@
                   account="[[account]]"
                   force-attention="[[_computeHasNewAttention(account, _newAttentionSet)]]"
                   selected="[[_computeHasNewAttention(account, _newAttentionSet)]]"
-                  deselected="[[!_computeHasNewAttention(account, _newAttentionSet)]]"
                   hide-hovercard=""
+                  selection-chip-style
                   on-click="_handleAttentionClick"
                 >
                 </gr-account-label>
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.js b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.js
deleted file mode 100644
index c1e2564..0000000
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.js
+++ /dev/null
@@ -1,1589 +0,0 @@
-/**
- * @license
- * Copyright (C) 2015 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../../../test/common-test-setup-karma.js';
-import {IronOverlayManager} from '@polymer/iron-overlay-behavior/iron-overlay-manager.js';
-import './gr-reply-dialog.js';
-import {mockPromise, stubStorage} from '../../../test/test-utils.js';
-import {SpecialFilePath} from '../../../constants/constants.js';
-import {appContext} from '../../../services/app-context.js';
-import {addListenerForTest} from '../../../test/test-utils.js';
-import {stubRestApi} from '../../../test/test-utils.js';
-import {JSON_PREFIX} from '../../shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper.js';
-import {CODE_REVIEW} from '../../../utils/label-util.js';
-import {createAccountWithId} from '../../../test/test-data-generators.js';
-
-const basicFixture = fixtureFromElement('gr-reply-dialog');
-
-function cloneableResponse(status, text) {
-  return {
-    ok: false,
-    status,
-    text() {
-      return Promise.resolve(text);
-    },
-    clone() {
-      return {
-        ok: false,
-        status,
-        text() {
-          return Promise.resolve(text);
-        },
-      };
-    },
-  };
-}
-
-suite('gr-reply-dialog tests', () => {
-  let element;
-  let changeNum;
-  let patchNum;
-
-  let getDraftCommentStub;
-  let setDraftCommentStub;
-  let eraseDraftCommentStub;
-
-  let lastId = 0;
-  const makeAccount = function() { return {_account_id: lastId++}; };
-  const makeGroup = function() { return {id: lastId++}; };
-
-  setup(() => {
-    changeNum = 42;
-    patchNum = 1;
-
-    stubRestApi('getAccount').returns(Promise.resolve({}));
-    stubRestApi('getChange').returns(Promise.resolve({}));
-    stubRestApi('getChangeSuggestedReviewers').returns(Promise.resolve([]));
-
-    sinon.stub(appContext.flagsService, 'isEnabled').returns(true);
-
-    element = basicFixture.instantiate();
-    element.change = {
-      _number: changeNum,
-      owner: {
-        _account_id: 999,
-        display_name: 'Kermit',
-      },
-      labels: {
-        'Verified': {
-          values: {
-            '-1': 'Fails',
-            ' 0': 'No score',
-            '+1': 'Verified',
-          },
-          default_value: 0,
-        },
-        'Code-Review': {
-          values: {
-            '-2': 'Do not submit',
-            '-1': 'I would prefer that you didn\'t submit this',
-            ' 0': 'No score',
-            '+1': 'Looks good to me, but someone else must approve',
-            '+2': 'Looks good to me, approved',
-          },
-          default_value: 0,
-        },
-      },
-    };
-    element.patchNum = patchNum;
-    element.permittedLabels = {
-      'Code-Review': [
-        '-1',
-        ' 0',
-        '+1',
-      ],
-      'Verified': [
-        '-1',
-        ' 0',
-        '+1',
-      ],
-    };
-
-    getDraftCommentStub = stubStorage('getDraftComment');
-    setDraftCommentStub = stubStorage('setDraftComment');
-    eraseDraftCommentStub = stubStorage('eraseDraftComment');
-
-    // sinon.stub(patchSetUtilMockProxy, 'fetchChangeUpdates')
-    //     .returns(Promise.resolve({isLatest: true}));
-
-    // Allow the elements created by dom-repeat to be stamped.
-    flush();
-  });
-
-  function stubSaveReview(jsonResponseProducer) {
-    return sinon.stub(
-        element,
-        '_saveReview')
-        .callsFake(review => new Promise((resolve, reject) => {
-          try {
-            const result = jsonResponseProducer(review) || {};
-            const resultStr = JSON_PREFIX + JSON.stringify(result);
-            resolve({
-              ok: true,
-              text() {
-                return Promise.resolve(resultStr);
-              },
-            });
-          } catch (err) {
-            reject(err);
-          }
-        }));
-  }
-
-  test('default to publishing draft comments with reply', done => {
-    // Async tick is needed because iron-selector content is distributed and
-    // distributed content requires an observer to be set up.
-    // Note: Double flush seems to be needed in Safari. {@see Issue 4963}.
-    flush(() => {
-      flush(() => {
-        element.draft = 'I wholeheartedly disapprove';
-
-        stubSaveReview(review => {
-          assert.deepEqual(review, {
-            drafts: 'PUBLISH_ALL_REVISIONS',
-            labels: {
-              'Code-Review': 0,
-              'Verified': 0,
-            },
-            comments: {
-              [SpecialFilePath.PATCHSET_LEVEL_COMMENTS]: [{
-                message: 'I wholeheartedly disapprove',
-                unresolved: false,
-              }],
-            },
-            reviewers: [],
-          });
-          assert.isFalse(element.$.commentList.hidden);
-          done();
-        });
-
-        // This is needed on non-Blink engines most likely due to the ways in
-        // which the dom-repeat elements are stamped.
-        flush(() => {
-          MockInteractions.tap(element.shadowRoot
-              .querySelector('.send'));
-        });
-      });
-    });
-  });
-
-  test('modified attention set', done => {
-    element.serverConfig = {
-      change: {enable_attention_set: true},
-    };
-    element._newAttentionSet = new Set([314]);
-    const buttonEl = element.shadowRoot.querySelector('.edit-attention-button');
-    MockInteractions.tap(buttonEl);
-    flush();
-
-    stubSaveReview(review => {
-      assert.isTrue(review.ignore_automatic_attention_set_rules);
-      assert.deepEqual(review.add_to_attention_set, [{
-        user: 314,
-        reason: 'Anonymous replied on the change',
-      }]);
-      assert.deepEqual(review.remove_from_attention_set, []);
-      done();
-    });
-    MockInteractions.tap(element.shadowRoot.querySelector('.send'));
-  });
-
-  function checkComputeAttention(status, userId, reviewerIds, ownerId,
-      attSetIds, replyToIds, expectedIds, uploaderId, hasDraft,
-      includeComments = true) {
-    const user = {_account_id: userId};
-    const reviewers = {base: reviewerIds.map(id => {
-      return {_account_id: id};
-    })};
-    const draftThreads = [
-      {comments: []},
-    ];
-    if (hasDraft) {
-      draftThreads[0].comments.push({__draft: true, unresolved: true});
-    }
-    replyToIds.forEach(id => draftThreads[0].comments.push({
-      author: {_account_id: id},
-    }));
-    const change = {
-      owner: {_account_id: ownerId},
-      status,
-      attention_set: {},
-    };
-    attSetIds.forEach(id => change.attention_set[id] = {});
-    if (uploaderId) {
-      change.current_revision = 1;
-      change.revisions = [{}, {uploader: {_account_id: uploaderId}}];
-    }
-    element.change = change;
-    element._reviewers = reviewers.base;
-
-    flush();
-    const hasDrafts = draftThreads.length > 0;
-    element._computeNewAttention(
-        user, reviewers, [], change, draftThreads, includeComments, undefined,
-        hasDrafts);
-    assert.sameMembers([...element._newAttentionSet], expectedIds);
-  }
-
-  test('computeNewAttention NEW', () => {
-    checkComputeAttention('NEW', null, [], 999, [], [], [999]);
-    checkComputeAttention('NEW', 1, [], 999, [], [], [999]);
-    checkComputeAttention('NEW', 1, [], 999, [1], [], [999]);
-    checkComputeAttention('NEW', 1, [22], 999, [], [], [999]);
-    checkComputeAttention('NEW', 1, [22], 999, [22], [], [22, 999]);
-    checkComputeAttention('NEW', 1, [22], 999, [], [22], [22, 999]);
-    checkComputeAttention('NEW', 1, [22, 33], 999, [33], [22], [22, 33, 999]);
-    // If the owner replies, then do not add them.
-    checkComputeAttention('NEW', 1, [], 1, [], [], []);
-    checkComputeAttention('NEW', 1, [], 1, [1], [], []);
-    checkComputeAttention('NEW', 1, [22], 1, [], [], []);
-
-    checkComputeAttention('NEW', 1, [22], 1, [], [22], [22]);
-    checkComputeAttention('NEW', 1, [22, 33], 1, [33], [22], [22, 33]);
-    checkComputeAttention('NEW', 1, [22, 33], 1, [], [22], [22]);
-    checkComputeAttention('NEW', 1, [22, 33], 1, [], [22, 33], [22, 33]);
-    checkComputeAttention('NEW', 1, [22, 33], 1, [22, 33], [], [22, 33]);
-    // with uploader
-    checkComputeAttention('NEW', 1, [], 1, [], [2], [2], 2);
-    checkComputeAttention('NEW', 1, [], 1, [2], [], [2], 2);
-    checkComputeAttention('NEW', 1, [], 3, [], [], [2, 3], 2);
-  });
-
-  test('computeNewAttention MERGED', () => {
-    checkComputeAttention('MERGED', null, [], 999, [], [], []);
-    checkComputeAttention('MERGED', 1, [], 999, [], [], []);
-    checkComputeAttention('MERGED', 1, [], 999, [], [], [999], undefined, true);
-    checkComputeAttention(
-        'MERGED', 1, [], 999, [], [], [], undefined, true, false);
-    checkComputeAttention('MERGED', 1, [], 999, [1], [], []);
-    checkComputeAttention('MERGED', 1, [22], 999, [], [], []);
-    checkComputeAttention('MERGED', 1, [22], 999, [22], [], [22]);
-    checkComputeAttention('MERGED', 1, [22], 999, [], [22], []);
-    checkComputeAttention('MERGED', 1, [22, 33], 999, [33], [22], [33]);
-    checkComputeAttention('MERGED', 1, [], 1, [], [], []);
-    checkComputeAttention('MERGED', 1, [], 1, [], [], [], undefined, true);
-    checkComputeAttention('MERGED', 1, [], 1, [1], [], []);
-    checkComputeAttention('MERGED', 1, [], 1, [1], [], [], undefined, true);
-    checkComputeAttention('MERGED', 1, [22], 1, [], [], []);
-    checkComputeAttention('MERGED', 1, [22], 1, [], [22], []);
-    checkComputeAttention('MERGED', 1, [22, 33], 1, [33], [22], [33]);
-    checkComputeAttention('MERGED', 1, [22, 33], 1, [], [22], []);
-    checkComputeAttention('MERGED', 1, [22, 33], 1, [], [22, 33], []);
-    checkComputeAttention('MERGED', 1, [22, 33], 1, [22, 33], [], [22, 33]);
-  });
-
-  test('computeNewAttention when adding reviewers', () => {
-    const user = {_account_id: 1};
-    const reviewers = {base: [
-      {_account_id: 1, _pendingAdd: true},
-      {_account_id: 2, _pendingAdd: true},
-    ]};
-    const change = {
-      owner: {_account_id: 5},
-      status: 'NEW',
-      attention_set: {},
-    };
-    element.change = change;
-    element._reviewers = reviewers.base;
-    flush();
-
-    element._computeNewAttention(user, reviewers, [], change, [], true);
-    assert.sameMembers([...element._newAttentionSet], [1, 2]);
-
-    // If the user votes on the change, then they should not be added to the
-    // attention set, even if they have just added themselves as reviewer.
-    // But voting should also add the owner (5).
-    const labelsChanged = true;
-    element._computeNewAttention(
-        user, reviewers, [], change, [], true, labelsChanged);
-    assert.sameMembers([...element._newAttentionSet], [2, 5]);
-  });
-
-  test('computeNewAttention when sending wip change for review', () => {
-    const reviewers = {base: [
-      {_account_id: 2},
-      {_account_id: 3},
-    ]};
-    const change = {
-      owner: {_account_id: 1},
-      status: 'NEW',
-      attention_set: {},
-    };
-    element.change = change;
-    element._reviewers = reviewers.base;
-    flush();
-
-    // For an active change there is no reason to add anyone to the set.
-    let user = {_account_id: 1};
-    element._computeNewAttention(user, reviewers, [], change, [], false);
-    assert.sameMembers([...element._newAttentionSet], []);
-
-    // If the change is "work in progress" and the owner sends a reply, then
-    // add all reviewers.
-    element.canBeStarted = true;
-    flush();
-    user = {_account_id: 1};
-    element._computeNewAttention(user, reviewers, [], change, [], false);
-    assert.sameMembers([...element._newAttentionSet], [2, 3]);
-
-    // ... but not when someone else replies.
-    user = {_account_id: 4};
-    element._computeNewAttention(user, reviewers, [], change, [], false);
-    assert.sameMembers([...element._newAttentionSet], []);
-  });
-
-  test('computeNewAttentionAccounts', () => {
-    element._reviewers = [
-      {_account_id: 123, display_name: 'Ernie'},
-      {_account_id: 321, display_name: 'Bert'},
-    ];
-    element._ccs = [
-      {_account_id: 7, display_name: 'Elmo'},
-    ];
-    const compute = (currentAtt, newAtt) =>
-      element._computeNewAttentionAccounts(
-          undefined, new Set(currentAtt), new Set(newAtt))
-          .map(a => a._account_id);
-
-    assert.sameMembers(compute([], []), []);
-    assert.sameMembers(compute([], [999]), [999]);
-    assert.sameMembers(compute([999], []), []);
-    assert.sameMembers(compute([999], [999]), []);
-    assert.sameMembers(compute([123, 321], [999]), [999]);
-    assert.sameMembers(compute([999], [7, 123, 999]), [7, 123]);
-  });
-
-  test('_computeCommentAccounts', () => {
-    element.change = {
-      labels: {
-        'Code-Review': {
-          all: [
-            {_account_id: 1, value: 0},
-            {_account_id: 2, value: 1},
-            {_account_id: 3, value: 2},
-          ],
-          values: {
-            '-2': 'Do not submit',
-            '-1': 'I would prefer that you didnt submit this',
-            ' 0': 'No score',
-            '+1': 'Looks good to me, but someone else must approve',
-            '+2': 'Looks good to me, approved',
-          },
-        },
-      },
-    };
-    const threads = [
-      {
-        comments: [
-          {author: {_account_id: 1}, unresolved: false},
-          {author: {_account_id: 2}, unresolved: true},
-        ],
-      },
-      {
-        comments: [
-          {author: {_account_id: 3}, unresolved: false},
-          {author: {_account_id: 4}, unresolved: false},
-        ],
-      },
-    ];
-    const actualAccounts = [...element._computeCommentAccounts(threads)];
-    // Account 3 is not included, because the comment is resolved *and* they
-    // have given the highest possible vote on the Code-Review label.
-    assert.sameMembers(actualAccounts, [1, 2, 4]);
-  });
-
-  test('toggle resolved checkbox', done => {
-    // Async tick is needed because iron-selector content is distributed and
-    // distributed content requires an observer to be set up.
-    // Note: Double flush seems to be needed in Safari. {@see Issue 4963}.
-    const checkboxEl = element.shadowRoot.querySelector(
-        '#resolvedPatchsetLevelCommentCheckbox');
-    MockInteractions.tap(checkboxEl);
-    flush(() => {
-      flush(() => {
-        element.draft = 'I wholeheartedly disapprove';
-
-        stubSaveReview(review => {
-          assert.deepEqual(review, {
-            drafts: 'PUBLISH_ALL_REVISIONS',
-            labels: {
-              'Code-Review': 0,
-              'Verified': 0,
-            },
-            comments: {
-              [SpecialFilePath.PATCHSET_LEVEL_COMMENTS]: [{
-                message: 'I wholeheartedly disapprove',
-                unresolved: true,
-              }],
-            },
-            reviewers: [],
-          });
-          done();
-        });
-
-        // This is needed on non-Blink engines most likely due to the ways in
-        // which the dom-repeat elements are stamped.
-        flush(() => {
-          MockInteractions.tap(element.shadowRoot
-              .querySelector('.send'));
-        });
-      });
-    });
-  });
-
-  test('keep draft comments with reply', done => {
-    MockInteractions.tap(element.shadowRoot.querySelector('#includeComments'));
-    assert.equal(element._includeComments, false);
-
-    // Async tick is needed because iron-selector content is distributed and
-    // distributed content requires an observer to be set up.
-    // Note: Double flush seems to be needed in Safari. {@see Issue 4963}.
-    flush(() => {
-      flush(() => {
-        element.draft = 'I wholeheartedly disapprove';
-
-        stubSaveReview(review => {
-          assert.deepEqual(review, {
-            drafts: 'KEEP',
-            labels: {
-              'Code-Review': 0,
-              'Verified': 0,
-            },
-            comments: {
-              [SpecialFilePath.PATCHSET_LEVEL_COMMENTS]: [{
-                message: 'I wholeheartedly disapprove',
-                unresolved: false,
-              }],
-            },
-            reviewers: [],
-          });
-          assert.isTrue(element.$.commentList.hidden);
-          done();
-        });
-
-        // This is needed on non-Blink engines most likely due to the ways in
-        // which the dom-repeat elements are stamped.
-        flush(() => {
-          MockInteractions.tap(element.shadowRoot
-              .querySelector('.send'));
-        });
-      });
-    });
-  });
-
-  test('label picker', done => {
-    element.draft = 'I wholeheartedly disapprove';
-    stubSaveReview(review => {
-      assert.deepEqual(review, {
-        drafts: 'PUBLISH_ALL_REVISIONS',
-        labels: {
-          'Code-Review': -1,
-          'Verified': -1,
-        },
-        comments: {
-          [SpecialFilePath.PATCHSET_LEVEL_COMMENTS]: [{
-            message: 'I wholeheartedly disapprove',
-            unresolved: false,
-          }],
-        },
-        reviewers: [],
-      });
-    });
-
-    sinon.stub(element.$.labelScores, 'getLabelValues').callsFake( () => {
-      return {
-        'Code-Review': -1,
-        'Verified': -1,
-      };
-    });
-
-    element.addEventListener('send', () => {
-      // Flush to ensure properties are updated.
-      flush(() => {
-        assert.isFalse(element.disabled,
-            'Element should be enabled when done sending reply.');
-        assert.equal(element.draft.length, 0);
-        done();
-      });
-    });
-
-    // This is needed on non-Blink engines most likely due to the ways in
-    // which the dom-repeat elements are stamped.
-    flush(() => {
-      MockInteractions.tap(element.shadowRoot
-          .querySelector('.send'));
-      assert.isTrue(element.disabled);
-    });
-  });
-
-  test('getlabelValue returns value', done => {
-    flush(() => {
-      element.shadowRoot
-          .querySelector('gr-label-scores')
-          .shadowRoot
-          .querySelector(`gr-label-score-row[name="Verified"]`)
-          .setSelectedValue(-1);
-      assert.equal('-1', element.getLabelValue('Verified'));
-      done();
-    });
-  });
-
-  test('getlabelValue when no score is selected', done => {
-    flush(() => {
-      element.shadowRoot
-          .querySelector('gr-label-scores')
-          .shadowRoot
-          .querySelector(`gr-label-score-row[name="Code-Review"]`)
-          .setSelectedValue(-1);
-      assert.strictEqual(element.getLabelValue('Verified'), ' 0');
-      done();
-    });
-  });
-
-  test('setlabelValue', done => {
-    element._account = {_account_id: 1};
-    flush(() => {
-      const label = 'Verified';
-      const value = '+1';
-      element.setLabelValue(label, value);
-
-      const labels = element.$.labelScores.getLabelValues();
-      assert.deepEqual(labels, {
-        'Code-Review': 0,
-        'Verified': 1,
-      });
-      done();
-    });
-  });
-
-  function getActiveElement() {
-    return IronOverlayManager.deepActiveElement;
-  }
-
-  function isVisible(el) {
-    assert.ok(el);
-    return getComputedStyle(el).getPropertyValue('display') != 'none';
-  }
-
-  function overlayObserver(mode) {
-    return new Promise(resolve => {
-      function listener() {
-        element.removeEventListener('iron-overlay-' + mode, listener);
-        resolve();
-      }
-      element.addEventListener('iron-overlay-' + mode, listener);
-    });
-  }
-
-  function isFocusInsideElement(element) {
-    // In Polymer 2 focused element either <paper-input> or nested
-    // native input <input> element depending on the current focus
-    // in browser window.
-    // For example, the focus is changed if the developer console
-    // get a focus.
-    let activeElement = getActiveElement();
-    while (activeElement) {
-      if (activeElement === element) {
-        return true;
-      }
-      if (activeElement.parentElement) {
-        activeElement = activeElement.parentElement;
-      } else {
-        activeElement = activeElement.getRootNode().host;
-      }
-    }
-    return false;
-  }
-
-  async function testConfirmationDialog(cc) {
-    const yesButton = element
-        .shadowRoot
-        .querySelector('.reviewerConfirmationButtons gr-button:first-child');
-    const noButton = element
-        .shadowRoot
-        .querySelector('.reviewerConfirmationButtons gr-button:last-child');
-
-    element._ccPendingConfirmation = null;
-    element._reviewerPendingConfirmation = null;
-    flush();
-    assert.isFalse(isVisible(element.$.reviewerConfirmationOverlay));
-
-    // Cause the confirmation dialog to display.
-    let observer = overlayObserver('opened');
-    const group = {
-      id: 'id',
-      name: 'name',
-    };
-    if (cc) {
-      element._ccPendingConfirmation = {
-        group,
-        count: 10,
-      };
-    } else {
-      element._reviewerPendingConfirmation = {
-        group,
-        count: 10,
-      };
-    }
-    flush();
-
-    if (cc) {
-      assert.deepEqual(
-          element._ccPendingConfirmation,
-          element._pendingConfirmationDetails);
-    } else {
-      assert.deepEqual(
-          element._reviewerPendingConfirmation,
-          element._pendingConfirmationDetails);
-    }
-
-    await observer;
-    assert.isTrue(isVisible(element.$.reviewerConfirmationOverlay));
-    observer = overlayObserver('closed');
-    const expected = 'Group name has 10 members';
-    assert.notEqual(
-        element.$.reviewerConfirmationOverlay.innerText
-            .indexOf(expected),
-        -1);
-    MockInteractions.tap(noButton); // close the overlay
-
-    await observer;
-    assert.isFalse(isVisible(element.$.reviewerConfirmationOverlay));
-
-    // We should be focused on account entry input.
-    assert.isTrue(
-        isFocusInsideElement(
-            element.$.reviewers.$.entry.$.input.$.input
-        )
-    );
-
-    // No reviewer/CC should have been added.
-    assert.equal(element.$.ccs.additions().length, 0);
-    assert.equal(element.$.reviewers.additions().length, 0);
-
-    // Reopen confirmation dialog.
-    observer = overlayObserver('opened');
-    if (cc) {
-      element._ccPendingConfirmation = {
-        group,
-        count: 10,
-      };
-    } else {
-      element._reviewerPendingConfirmation = {
-        group,
-        count: 10,
-      };
-    }
-
-    await observer;
-    assert.isTrue(isVisible(element.$.reviewerConfirmationOverlay));
-    observer = overlayObserver('closed');
-    MockInteractions.tap(yesButton); // Confirm the group.
-
-    await observer;
-    assert.isFalse(isVisible(element.$.reviewerConfirmationOverlay));
-    const additions = cc ?
-      element.$.ccs.additions() :
-      element.$.reviewers.additions();
-    assert.deepEqual(
-        additions,
-        [
-          {
-            group: {
-              id: 'id',
-              name: 'name',
-              confirmed: true,
-              _group: true,
-              _pendingAdd: true,
-            },
-          },
-        ]);
-
-    // We should be focused on account entry input.
-    if (cc) {
-      assert.isTrue(
-          isFocusInsideElement(
-              element.$.ccs.$.entry.$.input.$.input
-          )
-      );
-    } else {
-      assert.isTrue(
-          isFocusInsideElement(
-              element.$.reviewers.$.entry.$.input.$.input
-          )
-      );
-    }
-  }
-
-  test('cc confirmation', async () => {
-    testConfirmationDialog(true);
-  });
-
-  test('reviewer confirmation', async () => {
-    testConfirmationDialog(false);
-  });
-
-  test('_getStorageLocation', () => {
-    const actual = element._getStorageLocation();
-    assert.equal(actual.changeNum, changeNum);
-    assert.equal(actual.patchNum, '@change');
-    assert.equal(actual.path, '@change');
-  });
-
-  test('_reviewersMutated when account-text-change is fired from ccs', () => {
-    flush();
-    assert.isFalse(element._reviewersMutated);
-    assert.isTrue(element.$.ccs.allowAnyInput);
-    assert.isFalse(element.shadowRoot
-        .querySelector('#reviewers').allowAnyInput);
-    element.$.ccs.dispatchEvent(new CustomEvent('account-text-changed',
-        {bubbles: true, composed: true}));
-    assert.isTrue(element._reviewersMutated);
-  });
-
-  test('gets draft from storage on open', () => {
-    const storedDraft = 'hello world';
-    getDraftCommentStub.returns({message: storedDraft});
-    element.open();
-    assert.isTrue(getDraftCommentStub.called);
-    assert.equal(element.draft, storedDraft);
-  });
-
-  test('gets draft from storage even when text is already present', () => {
-    const storedDraft = 'hello world';
-    getDraftCommentStub.returns({message: storedDraft});
-    element.draft = 'foo bar';
-    element.open();
-    assert.isTrue(getDraftCommentStub.called);
-    assert.equal(element.draft, storedDraft);
-  });
-
-  test('blank if no stored draft', () => {
-    getDraftCommentStub.returns(null);
-    element.draft = 'foo bar';
-    element.open();
-    assert.isTrue(getDraftCommentStub.called);
-    assert.equal(element.draft, '');
-  });
-
-  test('does not check stored draft when quote is present', () => {
-    const storedDraft = 'hello world';
-    const quote = '> foo bar';
-    getDraftCommentStub.returns({message: storedDraft});
-    element.quote = quote;
-    element.open();
-    assert.isFalse(getDraftCommentStub.called);
-    assert.equal(element.draft, quote);
-    assert.isNotOk(element.quote);
-  });
-
-  test('updates stored draft on edits', () => {
-    const firstEdit = 'hello';
-    const location = element._getStorageLocation();
-
-    element.draft = firstEdit;
-    element.storeTask.flush();
-
-    assert.isTrue(setDraftCommentStub.calledWith(location, firstEdit));
-
-    element.draft = '';
-    element.storeTask.flush();
-
-    assert.isTrue(eraseDraftCommentStub.calledWith(location));
-  });
-
-  test('400 converts to human-readable server-error', done => {
-    stubRestApi('saveChangeReview').callsFake(
-        (changeNum, patchNum, review, errFn) => {
-          errFn(cloneableResponse(
-              400,
-              '....{"reviewers":{"id1":{"error":"human readable"}}}'
-          ));
-          return Promise.resolve(undefined);
-        }
-    );
-
-    const listener = event => {
-      if (event.target !== document) return;
-      event.detail.response.text().then(body => {
-        if (body === 'human readable') {
-          done();
-        }
-      });
-    };
-    addListenerForTest(document, 'server-error', listener);
-
-    flush(() => { element.send(); });
-  });
-
-  test('non-json 400 is treated as a normal server-error', done => {
-    stubRestApi('saveChangeReview').callsFake(
-        (changeNum, patchNum, review, errFn) => {
-          errFn(cloneableResponse(400, 'Comment validation error!'));
-          return Promise.resolve(undefined);
-        }
-    );
-
-    const listener = event => {
-      if (event.target !== document) return;
-      event.detail.response.text().then(body => {
-        if (body === 'Comment validation error!') {
-          done();
-        }
-      });
-    };
-    addListenerForTest(document, 'server-error', listener);
-
-    // Async tick is needed because iron-selector content is distributed and
-    // distributed content requires an observer to be set up.
-    flush(() => { element.send(); });
-  });
-
-  test('filterReviewerSuggestion', () => {
-    const owner = makeAccount();
-    const reviewer1 = makeAccount();
-    const reviewer2 = makeGroup();
-    const cc1 = makeAccount();
-    const cc2 = makeGroup();
-    let filter = element._filterReviewerSuggestionGenerator(false);
-
-    element._owner = owner;
-    element._reviewers = [reviewer1, reviewer2];
-    element._ccs = [cc1, cc2];
-
-    assert.isTrue(filter({account: makeAccount()}));
-    assert.isTrue(filter({group: makeGroup()}));
-
-    // Owner should be excluded.
-    assert.isFalse(filter({account: owner}));
-
-    // Existing and pending reviewers should be excluded when isCC = false.
-    assert.isFalse(filter({account: reviewer1}));
-    assert.isFalse(filter({group: reviewer2}));
-
-    filter = element._filterReviewerSuggestionGenerator(true);
-
-    // Existing and pending CCs should be excluded when isCC = true;.
-    assert.isFalse(filter({account: cc1}));
-    assert.isFalse(filter({group: cc2}));
-  });
-
-  test('_focusOn', async () => {
-    sinon.spy(element, '_chooseFocusTarget');
-    element._focusOn();
-    await flush();
-    assert.equal(element._chooseFocusTarget.callCount, 1);
-    assert.equal(element.shadowRoot.activeElement.tagName, 'GR-TEXTAREA');
-    assert.equal(element.shadowRoot.activeElement.id, 'textarea');
-
-    element._focusOn(element.FocusTarget.ANY);
-    await flush();
-    assert.equal(element._chooseFocusTarget.callCount, 2);
-    assert.equal(element.shadowRoot.activeElement.tagName, 'GR-TEXTAREA');
-    assert.equal(element.shadowRoot.activeElement.id, 'textarea');
-
-    element._focusOn(element.FocusTarget.BODY);
-    await flush();
-    assert.equal(element._chooseFocusTarget.callCount, 2);
-    assert.equal(element.shadowRoot.activeElement.tagName, 'GR-TEXTAREA');
-    assert.equal(element.shadowRoot.activeElement.id, 'textarea');
-
-    element._focusOn(element.FocusTarget.REVIEWERS);
-    await flush();
-    assert.equal(element._chooseFocusTarget.callCount, 2);
-    assert.equal(element.shadowRoot.activeElement.tagName, 'GR-ACCOUNT-LIST');
-    assert.equal(element.shadowRoot.activeElement.id, 'reviewers');
-
-    element._focusOn(element.FocusTarget.CCS);
-    await flush();
-    assert.equal(element._chooseFocusTarget.callCount, 2);
-    assert.equal(element.shadowRoot.activeElement.tagName, 'GR-ACCOUNT-LIST');
-    assert.equal(element.shadowRoot.activeElement.id, 'ccs');
-  });
-
-  test('_chooseFocusTarget', () => {
-    element._account = undefined;
-    assert.strictEqual(
-        element._chooseFocusTarget(), element.FocusTarget.BODY);
-
-    element._account = {_account_id: 1};
-    assert.strictEqual(
-        element._chooseFocusTarget(), element.FocusTarget.BODY);
-
-    element.change.owner = {_account_id: 2};
-    assert.strictEqual(
-        element._chooseFocusTarget(), element.FocusTarget.BODY);
-
-    element.change.owner._account_id = 1;
-    element.change._reviewers = null;
-    assert.strictEqual(
-        element._chooseFocusTarget(), element.FocusTarget.REVIEWERS);
-
-    element._reviewers = [];
-    assert.strictEqual(
-        element._chooseFocusTarget(), element.FocusTarget.REVIEWERS);
-
-    element._reviewers.push({});
-    assert.strictEqual(
-        element._chooseFocusTarget(), element.FocusTarget.BODY);
-  });
-
-  test('only send labels that have changed', done => {
-    flush(() => {
-      stubSaveReview(review => {
-        assert.deepEqual(review.labels, {
-          'Code-Review': 0,
-          'Verified': -1,
-        });
-      });
-
-      element.addEventListener('send', () => {
-        done();
-      });
-      // Without wrapping this test in flush(), the below two calls to
-      // MockInteractions.tap() cause a race in some situations in shadow DOM.
-      // The send button can be tapped before the others, causing the test to
-      // fail.
-
-      element.shadowRoot
-          .querySelector('gr-label-scores').shadowRoot
-          .querySelector(
-              'gr-label-score-row[name="Verified"]')
-          .setSelectedValue(-1);
-      MockInteractions.tap(element.shadowRoot
-          .querySelector('.send'));
-    });
-  });
-
-  test('_processReviewerChange', () => {
-    const mockIndexSplices = function(toRemove) {
-      return [{
-        removed: [toRemove],
-      }];
-    };
-
-    element._processReviewerChange(
-        mockIndexSplices(makeAccount()), 'REVIEWER');
-    assert.equal(element._reviewersPendingRemove.REVIEWER.length, 1);
-  });
-
-  test('_purgeReviewersPendingRemove', () => {
-    const removeStub = sinon.stub(element, '_removeAccount');
-    const mock = function() {
-      element._reviewersPendingRemove = {
-        CC: [makeAccount()],
-        REVIEWER: [makeAccount(), makeAccount()],
-      };
-    };
-    const checkObjEmpty = function(obj) {
-      for (const prop of Object.keys(obj)) {
-        if (obj[prop].length) { return false; }
-      }
-      return true;
-    };
-    mock();
-    element._purgeReviewersPendingRemove(true); // Cancel
-    assert.isFalse(removeStub.called);
-    assert.isTrue(checkObjEmpty(element._reviewersPendingRemove));
-
-    mock();
-    element._purgeReviewersPendingRemove(false); // Submit
-    assert.isTrue(removeStub.called);
-    assert.isTrue(checkObjEmpty(element._reviewersPendingRemove));
-  });
-
-  test('_removeAccount', done => {
-    stubRestApi('removeChangeReviewer')
-        .returns(Promise.resolve({ok: true}));
-    const arr = [makeAccount(), makeAccount()];
-    element.change.reviewers = {
-      REVIEWER: arr.slice(),
-    };
-
-    element._removeAccount(arr[1], 'REVIEWER').then(() => {
-      assert.equal(element.change.reviewers.REVIEWER.length, 1);
-      assert.deepEqual(element.change.reviewers.REVIEWER, arr.slice(0, 1));
-      done();
-    });
-  });
-
-  test('moving from cc to reviewer', () => {
-    element._reviewersPendingRemove = {
-      CC: [],
-      REVIEWER: [],
-    };
-    flush();
-
-    const reviewer1 = makeAccount();
-    const reviewer2 = makeAccount();
-    const reviewer3 = makeAccount();
-    const cc1 = makeAccount();
-    const cc2 = makeAccount();
-    const cc3 = makeAccount();
-    const cc4 = makeAccount();
-    element._reviewers = [reviewer1, reviewer2, reviewer3];
-    element._ccs = [cc1, cc2, cc3, cc4];
-    element.push('_reviewers', cc1);
-    flush();
-
-    assert.deepEqual(element._reviewers,
-        [reviewer1, reviewer2, reviewer3, cc1]);
-    assert.deepEqual(element._ccs, [cc2, cc3, cc4]);
-    assert.deepEqual(element._reviewersPendingRemove.CC, [cc1]);
-
-    element.push('_reviewers', cc4, cc3);
-    flush();
-
-    assert.deepEqual(element._reviewers,
-        [reviewer1, reviewer2, reviewer3, cc1, cc4, cc3]);
-    assert.deepEqual(element._ccs, [cc2]);
-    assert.deepEqual(element._reviewersPendingRemove.CC, [cc1, cc4, cc3]);
-  });
-
-  test('update attention section when reviewers and ccs change', () => {
-    element._account = makeAccount();
-    element._reviewers = [makeAccount(), makeAccount()];
-    element._ccs = [makeAccount(), makeAccount()];
-    element.draftCommentThreads = [];
-    const modifyButton =
-        element.shadowRoot.querySelector('.edit-attention-button');
-    MockInteractions.tap(modifyButton);
-    flush();
-
-    // "Modify" button disabled, because "Send" button is disabled.
-    assert.isFalse(element._attentionExpanded);
-    element.draft = 'a test comment';
-    MockInteractions.tap(modifyButton);
-    flush();
-    assert.isTrue(element._attentionExpanded);
-
-    let accountLabels = Array.from(element.shadowRoot.querySelectorAll(
-        '.attention-detail gr-account-label'));
-    assert.equal(accountLabels.length, 5);
-
-    element.push('_reviewers', makeAccount());
-    element.push('_ccs', makeAccount());
-    flush();
-
-    // The 'attention modified' section collapses and resets when reviewers or
-    // ccs change.
-    assert.isFalse(element._attentionExpanded);
-
-    MockInteractions.tap(
-        element.shadowRoot.querySelector('.edit-attention-button'));
-    flush();
-
-    assert.isTrue(element._attentionExpanded);
-    accountLabels = Array.from(element.shadowRoot.querySelectorAll(
-        '.attention-detail gr-account-label'));
-    assert.equal(accountLabels.length, 7);
-
-    element.pop('_reviewers', makeAccount());
-    element.pop('_reviewers', makeAccount());
-    element.pop('_ccs', makeAccount());
-    element.pop('_ccs', makeAccount());
-
-    MockInteractions.tap(
-        element.shadowRoot.querySelector('.edit-attention-button'));
-    flush();
-
-    accountLabels = Array.from(element.shadowRoot.querySelectorAll(
-        '.attention-detail gr-account-label'));
-    assert.equal(accountLabels.length, 3);
-  });
-
-  test('moving from reviewer to cc', () => {
-    element._reviewersPendingRemove = {
-      CC: [],
-      REVIEWER: [],
-    };
-    flush();
-
-    const reviewer1 = makeAccount();
-    const reviewer2 = makeAccount();
-    const reviewer3 = makeAccount();
-    const cc1 = makeAccount();
-    const cc2 = makeAccount();
-    const cc3 = makeAccount();
-    const cc4 = makeAccount();
-    element._reviewers = [reviewer1, reviewer2, reviewer3];
-    element._ccs = [cc1, cc2, cc3, cc4];
-    element.push('_ccs', reviewer1);
-    flush();
-
-    assert.deepEqual(element._reviewers,
-        [reviewer2, reviewer3]);
-    assert.deepEqual(element._ccs, [cc1, cc2, cc3, cc4, reviewer1]);
-    assert.deepEqual(element._reviewersPendingRemove.REVIEWER, [reviewer1]);
-
-    element.push('_ccs', reviewer3, reviewer2);
-    flush();
-
-    assert.deepEqual(element._reviewers, []);
-    assert.deepEqual(element._ccs,
-        [cc1, cc2, cc3, cc4, reviewer1, reviewer3, reviewer2]);
-    assert.deepEqual(element._reviewersPendingRemove.REVIEWER,
-        [reviewer1, reviewer3, reviewer2]);
-  });
-
-  test('migrate reviewers between states', async () => {
-    element._reviewersPendingRemove = {
-      CC: [],
-      REVIEWER: [],
-    };
-    flush();
-    const reviewers = element.$.reviewers;
-    const ccs = element.$.ccs;
-    const reviewer1 = makeAccount();
-    const reviewer2 = makeAccount();
-    const cc1 = makeAccount();
-    const cc2 = makeAccount();
-    const cc3 = makeAccount();
-    element._reviewers = [reviewer1, reviewer2];
-    element._ccs = [cc1, cc2, cc3];
-
-    const mutations = [];
-
-    stubSaveReview(review => mutations.push(...review.reviewers));
-
-    sinon.stub(element, '_removeAccount').callsFake((account, type) => {
-      mutations.push({state: 'REMOVED', account});
-      return Promise.resolve();
-    });
-
-    // 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,
-        }));
-    ccs.dispatchEvent(
-        new CustomEvent('remove', {
-          detail: {account: cc1},
-          composed: true, bubbles: true,
-        }));
-    ccs.dispatchEvent(
-        new CustomEvent('remove', {
-          detail: {account: cc3},
-          composed: true, bubbles: true,
-        }));
-    reviewers.$.entry.dispatchEvent(
-        new CustomEvent('add', {
-          detail: {value: {account: cc1}},
-          composed: true, bubbles: true,
-        }));
-
-    // Add to other field without removing from former field.
-    // (Currently not possible in UI, but this is a good consistency check).
-    reviewers.$.entry.dispatchEvent(
-        new CustomEvent('add', {
-          detail: {value: {account: cc2}},
-          composed: true, bubbles: true,
-        }));
-    ccs.$.entry.dispatchEvent(
-        new CustomEvent('add', {
-          detail: {value: {account: reviewer2}},
-          composed: true, bubbles: true,
-        }));
-    const mapReviewer = function(reviewer, opt_state) {
-      const result = {reviewer: reviewer._account_id};
-      if (opt_state) {
-        result.state = opt_state;
-      }
-      return result;
-    };
-
-    // Send and purge and verify moves, delete cc3.
-    await element.send()
-        .then(keepReviewers =>
-          element._purgeReviewersPendingRemove(false, keepReviewers));
-    expect(mutations).to.have.lengthOf(5);
-    expect(mutations[0]).to.deep.equal(mapReviewer(cc1));
-    expect(mutations[1]).to.deep.equal(mapReviewer(cc2));
-    expect(mutations[2]).to.deep.equal(mapReviewer(reviewer1, 'CC'));
-    expect(mutations[3]).to.deep.equal(mapReviewer(reviewer2, 'CC'));
-    expect(mutations[4]).to.deep.equal({account: cc3, state: 'REMOVED'});
-  });
-
-  test('emits cancel on esc key', () => {
-    const cancelHandler = sinon.spy();
-    element.addEventListener('cancel', cancelHandler);
-    MockInteractions.pressAndReleaseKeyOn(element, 27, null, 'esc');
-    flush();
-
-    assert.isTrue(cancelHandler.called);
-  });
-
-  test('should not send on enter key', () => {
-    stubSaveReview(() => undefined);
-    element.addEventListener('send', () => assert.fail('wrongly called'));
-    MockInteractions.pressAndReleaseKeyOn(element, 13, null, 'enter');
-    flush();
-  });
-
-  test('emit send on ctrl+enter key', done => {
-    stubSaveReview(() => undefined);
-    element.addEventListener('send', () => done());
-    MockInteractions.pressAndReleaseKeyOn(element, 13, 'ctrl', 'enter');
-    flush();
-  });
-
-  test('_computeMessagePlaceholder', () => {
-    assert.equal(
-        element._computeMessagePlaceholder(false),
-        'Say something nice...');
-    assert.equal(
-        element._computeMessagePlaceholder(true),
-        'Add a note for your reviewers...');
-  });
-
-  test('_computeSendButtonLabel', () => {
-    assert.equal(
-        element._computeSendButtonLabel(false),
-        'Send');
-    assert.equal(
-        element._computeSendButtonLabel(true),
-        'Send and Start review');
-  });
-
-  test('_handle400Error reviewers and CCs', done => {
-    const error1 = 'error 1';
-    const error2 = 'error 2';
-    const error3 = 'error 3';
-    const text = ')]}\'' + JSON.stringify({
-      reviewers: {
-        username1: {
-          input: 'username1',
-          error: error1,
-        },
-        username2: {
-          input: 'username2',
-          error: error2,
-        },
-        username3: {
-          input: 'username3',
-          error: error3,
-        },
-      },
-    });
-    const listener = e => {
-      e.detail.response.text().then(text => {
-        assert.equal(text, [error1, error2, error3].join(', '));
-        done();
-      });
-    };
-    addListenerForTest(document, 'server-error', listener);
-    element._handle400Error(cloneableResponse(400, text));
-  });
-
-  test('fires height change when the drafts comments load', done => {
-    // Flush DOM operations before binding to the autogrow event so we don't
-    // catch the events fired from the initial layout.
-    flush(() => {
-      const autoGrowHandler = sinon.stub();
-      element.addEventListener('autogrow', autoGrowHandler);
-      element.draftCommentThreads = [];
-      flush(() => {
-        assert.isTrue(autoGrowHandler.called);
-        done();
-      });
-    });
-  });
-
-  suite('start review and save buttons', () => {
-    let sendStub;
-
-    setup(() => {
-      sendStub = sinon.stub(element, 'send').callsFake(() => Promise.resolve());
-      element.canBeStarted = true;
-      // Flush to make both Start/Save buttons appear in DOM.
-      flush();
-    });
-
-    test('start review sets ready', () => {
-      MockInteractions.tap(element.shadowRoot
-          .querySelector('.send'));
-      flush();
-      assert.isTrue(sendStub.calledWith(true, true));
-    });
-
-    test('save review doesn\'t set ready', () => {
-      MockInteractions.tap(element.shadowRoot
-          .querySelector('.save'));
-      flush();
-      assert.isTrue(sendStub.calledWith(true, false));
-    });
-  });
-
-  test('buttons disabled until all API calls are resolved', () => {
-    stubSaveReview(review => {
-      return {ready: true};
-    });
-    return element.send(true, true).then(() => {
-      assert.isFalse(element.disabled);
-    });
-  });
-
-  suite('error handling', () => {
-    const expectedDraft = 'draft';
-    const expectedError = new Error('test');
-
-    setup(() => {
-      element.draft = expectedDraft;
-    });
-
-    function assertDialogOpenAndEnabled() {
-      assert.strictEqual(expectedDraft, element.draft);
-      assert.isFalse(element.disabled);
-    }
-
-    test('error occurs in _saveReview', () => {
-      stubSaveReview(review => {
-        throw expectedError;
-      });
-      return element.send(true, true).catch(err => {
-        assert.strictEqual(expectedError, err);
-        assertDialogOpenAndEnabled();
-      });
-    });
-
-    suite('pending diff drafts?', () => {
-      test('yes', async () => {
-        const promise = mockPromise();
-        const refreshSpy = sinon.spy();
-        element.addEventListener('comment-refresh', refreshSpy);
-        stubRestApi('hasPendingDiffDrafts').returns(true);
-        stubRestApi('awaitPendingDiffDrafts').returns(promise);
-
-        element.open();
-
-        assert.isFalse(refreshSpy.called);
-        assert.isTrue(element._savingComments);
-
-        promise.resolve();
-        await flush();
-
-        assert.isTrue(refreshSpy.called);
-        assert.isFalse(element._savingComments);
-      });
-
-      test('no', () => {
-        stubRestApi('hasPendingDiffDrafts').returns(false);
-        element.open();
-        assert.isFalse(element._savingComments);
-      });
-    });
-  });
-
-  test('_computeSendButtonDisabled_canBeStarted', () => {
-    // Mock canBeStarted
-    assert.isFalse(element._computeSendButtonDisabled(
-        /* canBeStarted= */ true,
-        /* draftCommentThreads= */ [],
-        /* text= */ '',
-        /* reviewersMutated= */ false,
-        /* labelsChanged= */ false,
-        /* includeComments= */ false,
-        /* disabled= */ false,
-        /* commentEditing= */ false,
-        /* change= */ element.change,
-        /* account= */ makeAccount()
-    ));
-  });
-
-  test('_computeSendButtonDisabled_allFalse', () => {
-    // Mock everything false
-    assert.isTrue(element._computeSendButtonDisabled(
-        /* canBeStarted= */ false,
-        /* draftCommentThreads= */ [],
-        /* text= */ '',
-        /* reviewersMutated= */ false,
-        /* labelsChanged= */ false,
-        /* includeComments= */ false,
-        /* disabled= */ false,
-        /* commentEditing= */ false,
-        /* change= */ element.change,
-        /* account= */ makeAccount()
-    ));
-  });
-
-  test('_computeSendButtonDisabled_draftCommentsSend', () => {
-    // Mock nonempty comment draft array, with sending comments.
-    assert.isFalse(element._computeSendButtonDisabled(
-        /* canBeStarted= */ false,
-        /* draftCommentThreads= */ [{comments: [{__draft: true}]}],
-        /* text= */ '',
-        /* reviewersMutated= */ false,
-        /* labelsChanged= */ false,
-        /* includeComments= */ true,
-        /* disabled= */ false,
-        /* commentEditing= */ false,
-        /* change= */ element.change,
-        /* account= */ makeAccount()
-    ));
-  });
-
-  test('_computeSendButtonDisabled_draftCommentsDoNotSend', () => {
-    // Mock nonempty comment draft array, without sending comments.
-    assert.isTrue(element._computeSendButtonDisabled(
-        /* canBeStarted= */ false,
-        /* draftCommentThreads= */ [{comments: [{__draft: true}]}],
-        /* text= */ '',
-        /* reviewersMutated= */ false,
-        /* labelsChanged= */ false,
-        /* includeComments= */ false,
-        /* disabled= */ false,
-        /* commentEditing= */ false,
-        /* change= */ element.change,
-        /* account= */ makeAccount()
-    ));
-  });
-
-  test('_computeSendButtonDisabled_changeMessage', () => {
-    // Mock nonempty change message.
-    assert.isFalse(element._computeSendButtonDisabled(
-        /* canBeStarted= */ false,
-        /* draftCommentThreads= */ {},
-        /* text= */ 'test',
-        /* reviewersMutated= */ false,
-        /* labelsChanged= */ false,
-        /* includeComments= */ false,
-        /* disabled= */ false,
-        /* commentEditing= */ false,
-        /* change= */ element.change,
-        /* account= */ makeAccount()
-    ));
-  });
-
-  test('_computeSendButtonDisabled_reviewersChanged', () => {
-    // Mock reviewers mutated.
-    assert.isFalse(element._computeSendButtonDisabled(
-        /* canBeStarted= */ false,
-        /* draftCommentThreads= */ {},
-        /* text= */ '',
-        /* reviewersMutated= */ true,
-        /* labelsChanged= */ false,
-        /* includeComments= */ false,
-        /* disabled= */ false,
-        /* commentEditing= */ false,
-        /* change= */ element.change,
-        /* account= */ makeAccount()
-    ));
-  });
-
-  test('_computeSendButtonDisabled_labelsChanged', () => {
-    // Mock labels changed.
-    assert.isFalse(element._computeSendButtonDisabled(
-        /* canBeStarted= */ false,
-        /* draftCommentThreads= */ {},
-        /* text= */ '',
-        /* reviewersMutated= */ false,
-        /* labelsChanged= */ true,
-        /* includeComments= */ false,
-        /* disabled= */ false,
-        /* commentEditing= */ false,
-        /* change= */ element.change,
-        /* account= */ makeAccount()
-    ));
-  });
-
-  test('_computeSendButtonDisabled_dialogDisabled', () => {
-    // Whole dialog is disabled.
-    assert.isTrue(element._computeSendButtonDisabled(
-        /* canBeStarted= */ false,
-        /* draftCommentThreads= */ {},
-        /* text= */ '',
-        /* reviewersMutated= */ false,
-        /* labelsChanged= */ true,
-        /* includeComments= */ false,
-        /* disabled= */ true,
-        /* commentEditing= */ false,
-        /* change= */ element.change,
-        /* account= */ makeAccount()
-    ));
-  });
-
-  test('_computeSendButtonDisabled_existingVote', async () => {
-    const account = createAccountWithId();
-    element.change.labels[CODE_REVIEW].all = [account];
-    await flush();
-
-    // User has already voted.
-    assert.isFalse(element._computeSendButtonDisabled(
-        /* canBeStarted= */ false,
-        /* draftCommentThreads= */ {},
-        /* text= */ '',
-        /* reviewersMutated= */ false,
-        /* labelsChanged= */ false,
-        /* includeComments= */ false,
-        /* disabled= */ false,
-        /* commentEditing= */ false,
-        /* change= */ element.change,
-        /* account= */ account
-    ));
-  });
-
-  test('_submit blocked when no mutations exist', async () => {
-    const sendStub = sinon.stub(element, 'send').returns(Promise.resolve());
-    // Stub the below function to avoid side effects from the send promise
-    // resolving.
-    sinon.stub(element, '_purgeReviewersPendingRemove');
-    element.account = makeAccount();
-    element.draftCommentThreads = [];
-    await flush();
-
-    MockInteractions.tap(element.shadowRoot
-        .querySelector('gr-button.send'));
-    assert.isFalse(sendStub.called);
-
-    element.draftCommentThreads = [{comments: [
-      {__draft: true, path: 'test', line: 1, patch_set: 1},
-    ]}];
-    await flush();
-
-    MockInteractions.tap(element.shadowRoot
-        .querySelector('gr-button.send'));
-    assert.isTrue(sendStub.called);
-  });
-
-  test('getFocusStops', async () => {
-    // Setting draftCommentThreads to an empty object causes _sendDisabled to be
-    // computed to false.
-    element.draftCommentThreads = [];
-    element.account = makeAccount();
-    await flush();
-
-    assert.equal(element.getFocusStops().end, element.$.cancelButton);
-    element.draftCommentThreads = [
-      {comments: [{__draft: true, path: 'test', line: 1, patch_set: 1}]},
-    ];
-    await flush();
-
-    assert.equal(element.getFocusStops().end, element.$.sendButton);
-  });
-
-  test('setPluginMessage', () => {
-    element.setPluginMessage('foo');
-    assert.equal(element.$.pluginMessage.textContent, 'foo');
-  });
-});
-
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
new file mode 100644
index 0000000..2554b73
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.ts
@@ -0,0 +1,2115 @@
+/**
+ * @license
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma';
+import './gr-reply-dialog';
+import {
+  mockPromise,
+  queryAll,
+  queryAndAssert,
+  stubStorage,
+} from '../../../test/test-utils';
+import {
+  ChangeStatus,
+  ReviewerState,
+  SpecialFilePath,
+} from '../../../constants/constants';
+import {appContext} from '../../../services/app-context';
+import {addListenerForTest} from '../../../test/test-utils';
+import {stubRestApi} from '../../../test/test-utils';
+import {JSON_PREFIX} from '../../shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
+import {CODE_REVIEW} from '../../../utils/label-util';
+import {
+  createAccountWithId,
+  createChange,
+  createCommentThread,
+  createDraft,
+  createRevision,
+} from '../../../test/test-data-generators';
+import {
+  pressAndReleaseKeyOn,
+  tap,
+} from '@polymer/iron-test-helpers/mock-interactions';
+import {GrReplyDialog} from './gr-reply-dialog';
+import {
+  AccountId,
+  AccountInfo,
+  CommitId,
+  DetailedLabelInfo,
+  GroupId,
+  GroupName,
+  NumericChangeId,
+  PatchSetNum,
+  ReviewerInput,
+  ReviewInput,
+  ReviewResult,
+  Suggestion,
+  UrlEncodedCommentId,
+} from '../../../types/common';
+import {CommentThread} from '../../../utils/comment-util';
+import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
+import {
+  AccountInfoInput,
+  GrAccountList,
+} from '../../shared/gr-account-list/gr-account-list';
+import {GrLabelScoreRow} from '../gr-label-score-row/gr-label-score-row';
+import {GrLabelScores} from '../gr-label-scores/gr-label-scores';
+import {GrThreadList} from '../gr-thread-list/gr-thread-list';
+import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
+
+const basicFixture = fixtureFromElement('gr-reply-dialog');
+
+function cloneableResponse(status: number, text: string) {
+  return {
+    ...new Response(),
+    ok: false,
+    status,
+    text() {
+      return Promise.resolve(text);
+    },
+    clone() {
+      return {
+        ok: false,
+        status,
+        text() {
+          return Promise.resolve(text);
+        },
+      };
+    },
+  };
+}
+
+suite('gr-reply-dialog tests', () => {
+  let element: GrReplyDialog;
+  let changeNum: NumericChangeId;
+  let patchNum: PatchSetNum;
+
+  let getDraftCommentStub: sinon.SinonStub;
+  let setDraftCommentStub: sinon.SinonStub;
+  let eraseDraftCommentStub: sinon.SinonStub;
+
+  const emptyAccountInfoInputChanges = ([] as unknown) as PolymerDeepPropertyChange<
+    AccountInfoInput[],
+    AccountInfoInput[]
+  >;
+
+  let lastId = 1;
+  const makeAccount = function () {
+    return {_account_id: lastId++ as AccountId};
+  };
+  const makeGroup = function () {
+    return {id: `${lastId++}` as GroupId};
+  };
+
+  setup(() => {
+    changeNum = 42 as NumericChangeId;
+    patchNum = 1 as PatchSetNum;
+
+    stubRestApi('getChange').returns(Promise.resolve({...createChange()}));
+    stubRestApi('getChangeSuggestedReviewers').returns(Promise.resolve([]));
+
+    sinon.stub(appContext.flagsService, 'isEnabled').returns(true);
+
+    element = basicFixture.instantiate();
+    element.change = {
+      ...createChange(),
+      _number: changeNum,
+      owner: {
+        _account_id: (999 as AccountId) as AccountId,
+        display_name: 'Kermit',
+      },
+      labels: {
+        Verified: {
+          values: {
+            '-1': 'Fails',
+            ' 0': 'No score',
+            '+1': 'Verified',
+          },
+          default_value: 0,
+        },
+        'Code-Review': {
+          values: {
+            '-2': 'Do not submit',
+            '-1': "I would prefer that you didn't submit this",
+            ' 0': 'No score',
+            '+1': 'Looks good to me, but someone else must approve',
+            '+2': 'Looks good to me, approved',
+          },
+          default_value: 0,
+        },
+      },
+    };
+    element.patchNum = patchNum;
+    element.permittedLabels = {
+      'Code-Review': ['-1', ' 0', '+1'],
+      Verified: ['-1', ' 0', '+1'],
+    };
+
+    getDraftCommentStub = stubStorage('getDraftComment');
+    setDraftCommentStub = stubStorage('setDraftComment');
+    eraseDraftCommentStub = stubStorage('eraseDraftComment');
+
+    // sinon.stub(patchSetUtilMockProxy, 'fetchChangeUpdates')
+    //     .returns(Promise.resolve({isLatest: true}));
+
+    // Allow the elements created by dom-repeat to be stamped.
+    flush();
+  });
+
+  function stubSaveReview(
+    jsonResponseProducer: (input: ReviewInput) => ReviewResult | void
+  ) {
+    return sinon.stub(element, '_saveReview').callsFake(
+      review =>
+        new Promise((resolve, reject) => {
+          try {
+            const result = jsonResponseProducer(review) || {};
+            const resultStr = JSON_PREFIX + JSON.stringify(result);
+            resolve({
+              ...new Response(),
+              ok: true,
+              text() {
+                return Promise.resolve(resultStr);
+              },
+            });
+          } catch (err) {
+            reject(err);
+          }
+        })
+    );
+  }
+
+  function interceptSaveReview() {
+    let resolver: (review: ReviewInput) => void;
+    const promise = new Promise(resolve => {
+      resolver = resolve;
+    });
+    stubSaveReview((review: ReviewInput) => {
+      resolver(review);
+    });
+    return promise;
+  }
+
+  test('default to publishing draft comments with reply', async () => {
+    // Async tick is needed because iron-selector content is distributed and
+    // distributed content requires an observer to be set up.
+    await flush();
+    element.draft = 'I wholeheartedly disapprove';
+    const saveReviewPromise = interceptSaveReview();
+
+    // This is needed on non-Blink engines most likely due to the ways in
+    // which the dom-repeat elements are stamped.
+    await flush();
+    tap(queryAndAssert(element, '.send'));
+
+    const review = await saveReviewPromise;
+    assert.deepEqual(review, {
+      drafts: 'PUBLISH_ALL_REVISIONS',
+      labels: {
+        'Code-Review': 0,
+        Verified: 0,
+      },
+      comments: {
+        [SpecialFilePath.PATCHSET_LEVEL_COMMENTS]: [
+          {
+            message: 'I wholeheartedly disapprove',
+            unresolved: false,
+          },
+        ],
+      },
+      reviewers: [],
+      add_to_attention_set: [],
+      remove_from_attention_set: [],
+      ignore_automatic_attention_set_rules: true,
+    });
+    assert.isFalse(
+      (queryAndAssert(element, '#commentList') as GrThreadList).hidden
+    );
+  });
+
+  test('modified attention set', done => {
+    element._newAttentionSet = new Set([314 as AccountId]);
+    const buttonEl = queryAndAssert(element, '.edit-attention-button');
+    tap(buttonEl);
+    flush();
+
+    stubSaveReview((review: ReviewInput) => {
+      assert.isTrue(review?.ignore_automatic_attention_set_rules);
+      assert.deepEqual(review?.add_to_attention_set, [
+        {
+          user: 314 as AccountId,
+          reason: 'Anonymous replied on the change',
+        },
+      ]);
+      assert.deepEqual(review?.remove_from_attention_set, []);
+      done();
+    });
+    tap(queryAndAssert(element, '.send'));
+  });
+
+  function checkComputeAttention(
+    status: ChangeStatus,
+    userId?: AccountId,
+    reviewerIds?: AccountId[],
+    ownerId?: AccountId,
+    attSetIds?: AccountId[],
+    replyToIds?: AccountId[],
+    expectedIds?: AccountId[],
+    uploaderId?: AccountId,
+    hasDraft = true,
+    includeComments = true
+  ) {
+    const user = {_account_id: userId};
+    const reviewers = {
+      base: reviewerIds?.map(id => {
+        return {_account_id: id};
+      }),
+    } as PolymerDeepPropertyChange<AccountInfoInput[], AccountInfoInput[]>;
+    let draftThreads: CommentThread[] = [];
+    if (hasDraft) {
+      draftThreads = [
+        {
+          ...createCommentThread([
+            {
+              ...createDraft(),
+              __draft: true,
+              unresolved: true,
+            },
+          ]),
+        },
+      ];
+    }
+    replyToIds?.forEach(id =>
+      draftThreads[0].comments.push({
+        author: {_account_id: id},
+      })
+    );
+    const change = {
+      ...createChange(),
+      owner: {_account_id: ownerId},
+      status,
+    };
+    attSetIds?.forEach(id => {
+      if (!change.attention_set) change.attention_set = {};
+      change.attention_set[id.toString()] = {
+        account: createAccountWithId(id),
+      };
+    });
+    if (uploaderId) {
+      change.current_revision = 'b' as CommitId;
+      change.revisions = {
+        a: createRevision(1),
+        b: {...createRevision(2), uploader: {_account_id: uploaderId}},
+      };
+    }
+    element.change = change;
+    element._reviewers = reviewers.base!;
+
+    flush();
+    const hasDrafts = draftThreads.length > 0;
+    element._computeNewAttention(
+      user,
+      reviewers!,
+      emptyAccountInfoInputChanges,
+      change,
+      draftThreads,
+      includeComments,
+      undefined,
+      hasDrafts
+    );
+    assert.sameMembers([...element._newAttentionSet], expectedIds!);
+  }
+
+  test('computeNewAttention NEW', () => {
+    checkComputeAttention(
+      ChangeStatus.NEW,
+      1 as AccountId,
+      [],
+      999 as AccountId,
+      [],
+      [],
+      [999 as AccountId]
+    );
+    checkComputeAttention(
+      ChangeStatus.NEW,
+      1 as AccountId,
+      [],
+      999 as AccountId,
+      [1 as AccountId],
+      [],
+      [999 as AccountId]
+    );
+    checkComputeAttention(
+      ChangeStatus.NEW,
+      1 as AccountId,
+      [22 as AccountId],
+      999 as AccountId,
+      [],
+      [],
+      [999 as AccountId]
+    );
+    checkComputeAttention(
+      ChangeStatus.NEW,
+      1 as AccountId,
+      [22 as AccountId],
+      999 as AccountId,
+      [22 as AccountId],
+      [],
+      [22 as AccountId, 999 as AccountId]
+    );
+    checkComputeAttention(
+      ChangeStatus.NEW,
+      1 as AccountId,
+      [22 as AccountId],
+      999 as AccountId,
+      [],
+      [22 as AccountId],
+      [22 as AccountId, 999 as AccountId]
+    );
+    checkComputeAttention(
+      ChangeStatus.NEW,
+      1 as AccountId,
+      [22 as AccountId, 33 as AccountId],
+      999 as AccountId,
+      [33 as AccountId],
+      [22 as AccountId],
+      [22 as AccountId, 33 as AccountId, 999 as AccountId]
+    );
+    // If the owner replies, then do not add them.
+    checkComputeAttention(
+      ChangeStatus.NEW,
+      1 as AccountId,
+      [],
+      1 as AccountId,
+      [],
+      [],
+      []
+    );
+    checkComputeAttention(
+      ChangeStatus.NEW,
+      1 as AccountId,
+      [],
+      1 as AccountId,
+      [1 as AccountId],
+      [],
+      []
+    );
+    checkComputeAttention(
+      ChangeStatus.NEW,
+      1 as AccountId,
+      [22 as AccountId],
+      1 as AccountId,
+      [],
+      [],
+      []
+    );
+
+    checkComputeAttention(
+      ChangeStatus.NEW,
+      1 as AccountId,
+      [22 as AccountId],
+      1 as AccountId,
+      [],
+      [22 as AccountId],
+      [22 as AccountId]
+    );
+    checkComputeAttention(
+      ChangeStatus.NEW,
+      1 as AccountId,
+      [22 as AccountId, 33 as AccountId],
+      1 as AccountId,
+      [33 as AccountId],
+      [22 as AccountId],
+      [22 as AccountId, 33 as AccountId]
+    );
+    checkComputeAttention(
+      ChangeStatus.NEW,
+      1 as AccountId,
+      [22 as AccountId, 33 as AccountId],
+      1 as AccountId,
+      [],
+      [22 as AccountId],
+      [22 as AccountId]
+    );
+    checkComputeAttention(
+      ChangeStatus.NEW,
+      1 as AccountId,
+      [22 as AccountId, 33 as AccountId],
+      1 as AccountId,
+      [],
+      [22 as AccountId, 33 as AccountId],
+      [22 as AccountId, 33 as AccountId]
+    );
+    checkComputeAttention(
+      ChangeStatus.NEW,
+      1 as AccountId,
+      [22 as AccountId, 33 as AccountId],
+      1 as AccountId,
+      [22 as AccountId, 33 as AccountId],
+      [],
+      [22 as AccountId, 33 as AccountId]
+    );
+    // with uploader
+    checkComputeAttention(
+      ChangeStatus.NEW,
+      1 as AccountId,
+      [],
+      1 as AccountId,
+      [],
+      [2 as AccountId],
+      [2 as AccountId],
+      2 as AccountId
+    );
+    checkComputeAttention(
+      ChangeStatus.NEW,
+      1 as AccountId,
+      [],
+      1 as AccountId,
+      [2 as AccountId],
+      [],
+      [2 as AccountId],
+      2 as AccountId
+    );
+    checkComputeAttention(
+      ChangeStatus.NEW,
+      1 as AccountId,
+      [],
+      3 as AccountId,
+      [],
+      [],
+      [2 as AccountId, 3 as AccountId],
+      2 as AccountId
+    );
+  });
+
+  test('computeNewAttention MERGED', () => {
+    checkComputeAttention(
+      ChangeStatus.MERGED,
+      undefined,
+      [],
+      999 as AccountId,
+      [],
+      [],
+      [],
+      undefined,
+      false
+    );
+    checkComputeAttention(
+      ChangeStatus.MERGED,
+      1 as AccountId,
+      [],
+      999 as AccountId,
+      [],
+      [],
+      [],
+      undefined,
+      false
+    );
+    checkComputeAttention(
+      ChangeStatus.MERGED,
+      1 as AccountId,
+      [],
+      999 as AccountId,
+      [],
+      [],
+      [999 as AccountId],
+      undefined,
+      true
+    );
+    checkComputeAttention(
+      ChangeStatus.MERGED,
+      1 as AccountId,
+      [],
+      999 as AccountId,
+      [],
+      [],
+      [],
+      undefined,
+      true,
+      false
+    );
+    checkComputeAttention(
+      ChangeStatus.MERGED,
+      1 as AccountId,
+      [],
+      999 as AccountId,
+      [1 as AccountId],
+      [],
+      [],
+      undefined,
+      false
+    );
+    checkComputeAttention(
+      ChangeStatus.MERGED,
+      1 as AccountId,
+      [22 as AccountId],
+      999 as AccountId,
+      [],
+      [],
+      [],
+      undefined,
+      false
+    );
+    checkComputeAttention(
+      ChangeStatus.MERGED,
+      1 as AccountId,
+      [22 as AccountId],
+      999 as AccountId,
+      [22 as AccountId],
+      [],
+      [22 as AccountId],
+      undefined,
+      false
+    );
+    checkComputeAttention(
+      ChangeStatus.MERGED,
+      1 as AccountId,
+      [22 as AccountId],
+      999 as AccountId,
+      [],
+      [22 as AccountId],
+      []
+    );
+    checkComputeAttention(
+      ChangeStatus.MERGED,
+      1 as AccountId,
+      [22 as AccountId, 33 as AccountId],
+      999 as AccountId,
+      [33 as AccountId],
+      [22 as AccountId],
+      [33 as AccountId]
+    );
+    checkComputeAttention(
+      ChangeStatus.MERGED,
+      1 as AccountId,
+      [],
+      1 as AccountId,
+      [],
+      [],
+      []
+    );
+    checkComputeAttention(
+      ChangeStatus.MERGED,
+      1 as AccountId,
+      [],
+      1 as AccountId,
+      [],
+      [],
+      [],
+      undefined,
+      true
+    );
+    checkComputeAttention(
+      ChangeStatus.MERGED,
+      1 as AccountId,
+      [],
+      1 as AccountId,
+      [1 as AccountId],
+      [],
+      []
+    );
+    checkComputeAttention(
+      ChangeStatus.MERGED,
+      1 as AccountId,
+      [],
+      1 as AccountId,
+      [1 as AccountId],
+      [],
+      [],
+      undefined,
+      true
+    );
+    checkComputeAttention(
+      ChangeStatus.MERGED,
+      1 as AccountId,
+      [22 as AccountId],
+      1 as AccountId,
+      [],
+      [],
+      []
+    );
+    checkComputeAttention(
+      ChangeStatus.MERGED,
+      1 as AccountId,
+      [22 as AccountId],
+      1 as AccountId,
+      [],
+      [22 as AccountId],
+      []
+    );
+    checkComputeAttention(
+      ChangeStatus.MERGED,
+      1 as AccountId,
+      [22 as AccountId, 33 as AccountId],
+      1 as AccountId,
+      [33 as AccountId],
+      [22 as AccountId],
+      [33 as AccountId]
+    );
+    checkComputeAttention(
+      ChangeStatus.MERGED,
+      1 as AccountId,
+      [22 as AccountId, 33 as AccountId],
+      1 as AccountId,
+      [],
+      [22 as AccountId],
+      []
+    );
+    checkComputeAttention(
+      ChangeStatus.MERGED,
+      1 as AccountId,
+      [22 as AccountId, 33 as AccountId],
+      1 as AccountId,
+      [],
+      [22 as AccountId, 33 as AccountId],
+      []
+    );
+    checkComputeAttention(
+      ChangeStatus.MERGED,
+      1 as AccountId,
+      [22 as AccountId, 33 as AccountId],
+      1 as AccountId,
+      [22 as AccountId, 33 as AccountId],
+      [],
+      [22 as AccountId, 33 as AccountId]
+    );
+  });
+
+  test('computeNewAttention when adding reviewers', () => {
+    const user = {_account_id: 1 as AccountId};
+    const reviewers = {
+      base: [
+        {_account_id: 1 as AccountId, _pendingAdd: true},
+        {_account_id: 2 as AccountId, _pendingAdd: true},
+      ],
+    } as PolymerDeepPropertyChange<AccountInfoInput[], AccountInfoInput[]>;
+    const change = {
+      ...createChange(),
+      owner: {_account_id: 5 as AccountId},
+      status: ChangeStatus.NEW,
+      attention_set: {},
+    };
+    element.change = change;
+    element._reviewers = reviewers.base;
+    flush();
+
+    element._computeNewAttention(
+      user,
+      reviewers,
+      emptyAccountInfoInputChanges,
+      change,
+      [],
+      true
+    );
+    assert.sameMembers([...element._newAttentionSet], [1, 2]);
+
+    // If the user votes on the change, then they should not be added to the
+    // attention set, even if they have just added themselves as reviewer.
+    // But voting should also add the owner (5).
+    const labelsChanged = true;
+    element._computeNewAttention(
+      user,
+      reviewers,
+      emptyAccountInfoInputChanges,
+      change,
+      [],
+      true,
+      labelsChanged
+    );
+    assert.sameMembers([...element._newAttentionSet], [2, 5]);
+  });
+
+  test('computeNewAttention when sending wip change for review', () => {
+    const reviewers = {
+      base: [{...createAccountWithId(2)}, {...createAccountWithId(3)}],
+    } as PolymerDeepPropertyChange<AccountInfoInput[], AccountInfoInput[]>;
+    const change = {
+      ...createChange(),
+      owner: {_account_id: 1 as AccountId},
+      status: ChangeStatus.NEW,
+      attention_set: {},
+    };
+    element.change = change;
+    element._reviewers = reviewers.base;
+    flush();
+
+    // For an active change there is no reason to add anyone to the set.
+    let user = {_account_id: 1 as AccountId};
+    element._computeNewAttention(
+      user,
+      reviewers,
+      emptyAccountInfoInputChanges,
+      change,
+      [],
+      false
+    );
+    assert.sameMembers([...element._newAttentionSet], []);
+
+    // If the change is "work in progress" and the owner sends a reply, then
+    // add all reviewers.
+    element.canBeStarted = true;
+    flush();
+    user = {_account_id: 1 as AccountId};
+    element._computeNewAttention(
+      user,
+      reviewers,
+      emptyAccountInfoInputChanges,
+      change,
+      [],
+      false
+    );
+    assert.sameMembers([...element._newAttentionSet], [2, 3]);
+
+    // ... but not when someone else replies.
+    user = {_account_id: 4 as AccountId};
+    element._computeNewAttention(
+      user,
+      reviewers,
+      emptyAccountInfoInputChanges,
+      change,
+      [],
+      false
+    );
+    assert.sameMembers([...element._newAttentionSet], []);
+  });
+
+  test('computeNewAttentionAccounts', () => {
+    element._reviewers = [
+      {_account_id: 123 as AccountId, display_name: 'Ernie'},
+      {_account_id: 321 as AccountId, display_name: 'Bert'},
+    ];
+    element._ccs = [{_account_id: 7 as AccountId, display_name: 'Elmo'}];
+    const compute = (currentAtt: AccountId[], newAtt: AccountId[]) =>
+      element
+        ._computeNewAttentionAccounts(
+          undefined,
+          new Set(currentAtt),
+          new Set(newAtt)
+        )
+        .map(a => a!._account_id);
+
+    assert.sameMembers(compute([], []), []);
+    assert.sameMembers(compute([], [999 as AccountId]), [999 as AccountId]);
+    assert.sameMembers(compute([999 as AccountId], []), []);
+    assert.sameMembers(compute([999 as AccountId], [999 as AccountId]), []);
+    assert.sameMembers(
+      compute([123 as AccountId, 321 as AccountId], [999 as AccountId]),
+      [999 as AccountId]
+    );
+    assert.sameMembers(
+      compute(
+        [999 as AccountId],
+        [7 as AccountId, 123 as AccountId, 999 as AccountId]
+      ),
+      [7 as AccountId, 123 as AccountId]
+    );
+  });
+
+  test('_computeCommentAccounts', () => {
+    element.change = {
+      ...createChange(),
+      labels: {
+        'Code-Review': {
+          all: [
+            {_account_id: 1 as AccountId, value: 0},
+            {_account_id: 2 as AccountId, value: 1},
+            {_account_id: 3 as AccountId, value: 2},
+          ],
+          values: {
+            '-2': 'Do not submit',
+            '-1': 'I would prefer that you didnt submit this',
+            ' 0': 'No score',
+            '+1': 'Looks good to me, but someone else must approve',
+            '+2': 'Looks good to me, approved',
+          },
+        },
+      },
+    };
+    const threads = [
+      {
+        ...createCommentThread([
+          {
+            id: '1' as UrlEncodedCommentId,
+            author: {_account_id: 1 as AccountId},
+            unresolved: false,
+          },
+          {
+            id: '2' as UrlEncodedCommentId,
+            in_reply_to: '1' as UrlEncodedCommentId,
+            author: {_account_id: 2 as AccountId},
+            unresolved: true,
+          },
+        ]),
+      },
+      {
+        ...createCommentThread([
+          {
+            id: '3' as UrlEncodedCommentId,
+            author: {_account_id: 3 as AccountId},
+            unresolved: false,
+          },
+          {
+            id: '4' as UrlEncodedCommentId,
+            in_reply_to: '3' as UrlEncodedCommentId,
+            author: {_account_id: 4 as AccountId},
+            unresolved: false,
+          },
+        ]),
+      },
+    ];
+    const actualAccounts = [...element._computeCommentAccounts(threads)];
+    // Account 3 is not included, because the comment is resolved *and* they
+    // have given the highest possible vote on the Code-Review label.
+    assert.sameMembers(actualAccounts, [1, 2, 4]);
+  });
+
+  test('toggle resolved checkbox', async () => {
+    const checkboxEl = queryAndAssert(
+      element,
+      '#resolvedPatchsetLevelCommentCheckbox'
+    );
+    tap(checkboxEl);
+
+    // Async tick is needed because iron-selector content is distributed and
+    // distributed content requires an observer to be set up.
+    await flush();
+    element.draft = 'I wholeheartedly disapprove';
+    const saveReviewPromise = interceptSaveReview();
+
+    // This is needed on non-Blink engines most likely due to the ways in
+    // which the dom-repeat elements are stamped.
+    await flush();
+    tap(queryAndAssert(element, '.send'));
+
+    const review = await saveReviewPromise;
+    assert.deepEqual(review, {
+      drafts: 'PUBLISH_ALL_REVISIONS',
+      labels: {
+        'Code-Review': 0,
+        Verified: 0,
+      },
+      comments: {
+        [SpecialFilePath.PATCHSET_LEVEL_COMMENTS]: [
+          {
+            message: 'I wholeheartedly disapprove',
+            unresolved: true,
+          },
+        ],
+      },
+      reviewers: [],
+      add_to_attention_set: [],
+      remove_from_attention_set: [],
+      ignore_automatic_attention_set_rules: true,
+    });
+  });
+
+  test('label picker', async () => {
+    element.draft = 'I wholeheartedly disapprove';
+    const saveReviewPromise = interceptSaveReview();
+
+    sinon.stub(element.$.labelScores, 'getLabelValues').callsFake(() => {
+      return {
+        'Code-Review': -1,
+        Verified: -1,
+      };
+    });
+
+    // This is needed on non-Blink engines most likely due to the ways in
+    // which the dom-repeat elements are stamped.
+    await flush();
+    tap(queryAndAssert(element, '.send'));
+    assert.isTrue(element.disabled);
+
+    const review = await saveReviewPromise;
+    await flush();
+    assert.isFalse(
+      element.disabled,
+      'Element should be enabled when done sending reply.'
+    );
+    assert.equal(element.draft.length, 0);
+    assert.deepEqual(review, {
+      drafts: 'PUBLISH_ALL_REVISIONS',
+      labels: {
+        'Code-Review': -1,
+        Verified: -1,
+      },
+      comments: {
+        [SpecialFilePath.PATCHSET_LEVEL_COMMENTS]: [
+          {
+            message: 'I wholeheartedly disapprove',
+            unresolved: false,
+          },
+        ],
+      },
+      reviewers: [],
+      add_to_attention_set: [],
+      remove_from_attention_set: [],
+      ignore_automatic_attention_set_rules: true,
+    });
+  });
+
+  test('keep draft comments with reply', async () => {
+    tap(queryAndAssert(element, '#includeComments'));
+    assert.equal(element._includeComments, false);
+
+    // Async tick is needed because iron-selector content is distributed and
+    // distributed content requires an observer to be set up.
+    await flush();
+    element.draft = 'I wholeheartedly disapprove';
+    const saveReviewPromise = interceptSaveReview();
+
+    // This is needed on non-Blink engines most likely due to the ways in
+    // which the dom-repeat elements are stamped.
+    await flush();
+    tap(queryAndAssert(element, '.send'));
+
+    const review = await saveReviewPromise;
+    await flush();
+    assert.deepEqual(review, {
+      drafts: 'KEEP',
+      labels: {
+        'Code-Review': 0,
+        Verified: 0,
+      },
+      comments: {
+        [SpecialFilePath.PATCHSET_LEVEL_COMMENTS]: [
+          {
+            message: 'I wholeheartedly disapprove',
+            unresolved: false,
+          },
+        ],
+      },
+      reviewers: [],
+      add_to_attention_set: [],
+      remove_from_attention_set: [],
+      ignore_automatic_attention_set_rules: true,
+    });
+  });
+
+  test('getlabelValue returns value', done => {
+    flush(() => {
+      const el = queryAndAssert(
+        queryAndAssert(element, 'gr-label-scores'),
+        'gr-label-score-row[name="Verified"]'
+      ) as GrLabelScoreRow;
+      el.setSelectedValue('-1');
+      assert.equal('-1', element.getLabelValue('Verified'));
+      done();
+    });
+  });
+
+  test('getlabelValue when no score is selected', done => {
+    flush(() => {
+      const el = queryAndAssert(
+        queryAndAssert(element, 'gr-label-scores'),
+        'gr-label-score-row[name="Code-Review"]'
+      ) as GrLabelScoreRow;
+      el.setSelectedValue('-1');
+      assert.strictEqual(element.getLabelValue('Verified'), ' 0');
+      done();
+    });
+  });
+
+  test('setlabelValue', done => {
+    element._account = {_account_id: 1 as AccountId};
+    flush(() => {
+      const label = 'Verified';
+      const value = '+1';
+      element.setLabelValue(label, value);
+
+      const labels = (queryAndAssert(
+        element,
+        '#labelScores'
+      ) as GrLabelScores).getLabelValues();
+      assert.deepEqual(labels, {
+        'Code-Review': 0,
+        Verified: 1,
+      });
+      done();
+    });
+  });
+
+  function getActiveElement() {
+    return document.activeElement;
+  }
+
+  function isVisible(el: Element) {
+    assert.ok(el);
+    return getComputedStyle(el).getPropertyValue('display') !== 'none';
+  }
+
+  function overlayObserver(mode: string) {
+    return new Promise(resolve => {
+      function listener() {
+        element.removeEventListener('iron-overlay-' + mode, listener);
+        resolve(mode);
+      }
+      element.addEventListener('iron-overlay-' + mode, listener);
+    });
+  }
+
+  function isFocusInsideElement(element: Element) {
+    // In Polymer 2 focused element either <paper-input> or nested
+    // native input <input> element depending on the current focus
+    // in browser window.
+    // For example, the focus is changed if the developer console
+    // get a focus.
+    let activeElement = getActiveElement();
+    while (activeElement) {
+      if (activeElement === element) {
+        return true;
+      }
+      if (activeElement.parentElement) {
+        activeElement = activeElement.parentElement;
+      } else {
+        activeElement = (activeElement.getRootNode() as ShadowRoot).host;
+      }
+    }
+    return false;
+  }
+
+  async function testConfirmationDialog(cc?: boolean) {
+    const yesButton = queryAndAssert(
+      element,
+      '.reviewerConfirmationButtons gr-button:first-child'
+    );
+    const noButton = queryAndAssert(
+      element,
+      '.reviewerConfirmationButtons gr-button:last-child'
+    );
+
+    element._ccPendingConfirmation = null;
+    element._reviewerPendingConfirmation = null;
+    flush();
+    assert.isFalse(
+      isVisible(queryAndAssert(element, 'reviewerConfirmationOverlay'))
+    );
+
+    // Cause the confirmation dialog to display.
+    let observer = overlayObserver('opened');
+    const group = {
+      id: 'id' as GroupId,
+      name: 'name' as GroupName,
+    };
+    if (cc) {
+      element._ccPendingConfirmation = {
+        group,
+        confirm: false,
+      };
+    } else {
+      element._reviewerPendingConfirmation = {
+        group,
+        confirm: false,
+      };
+    }
+    flush();
+
+    if (cc) {
+      assert.deepEqual(
+        element._ccPendingConfirmation,
+        element._pendingConfirmationDetails
+      );
+    } else {
+      assert.deepEqual(
+        element._reviewerPendingConfirmation,
+        element._pendingConfirmationDetails
+      );
+    }
+
+    await observer;
+    assert.isTrue(
+      isVisible(queryAndAssert(element, 'reviewerConfirmationOverlay'))
+    );
+    observer = overlayObserver('closed');
+    const expected = 'Group name has 10 members';
+    assert.notEqual(
+      (queryAndAssert(
+        element,
+        'reviewerConfirmationOverlay'
+      ) as GrOverlay).innerText.indexOf(expected),
+      -1
+    );
+    tap(noButton); // close the overlay
+
+    await observer;
+    assert.isFalse(
+      isVisible(queryAndAssert(element, 'reviewerConfirmationOverlay'))
+    );
+
+    // We should be focused on account entry input.
+    assert.isTrue(
+      isFocusInsideElement(
+        (queryAndAssert(element, '#reviewers') as GrAccountList).$.entry.$.input
+          .$.input
+      )
+    );
+
+    // No reviewer/CC should have been added.
+    assert.equal(
+      (queryAndAssert(element, '#ccs') as GrAccountList).additions().length,
+      0
+    );
+    assert.equal(
+      (queryAndAssert(element, '#reviewers') as GrAccountList).additions()
+        .length,
+      0
+    );
+
+    // Reopen confirmation dialog.
+    observer = overlayObserver('opened');
+    if (cc) {
+      element._ccPendingConfirmation = {
+        group,
+        confirm: false,
+      };
+    } else {
+      element._reviewerPendingConfirmation = {
+        group,
+        confirm: false,
+      };
+    }
+
+    await observer;
+    assert.isTrue(
+      isVisible(queryAndAssert(element, 'reviewerConfirmationOverlay'))
+    );
+    observer = overlayObserver('closed');
+    tap(yesButton); // Confirm the group.
+
+    await observer;
+    assert.isFalse(
+      isVisible(queryAndAssert(element, 'reviewerConfirmationOverlay'))
+    );
+    const additions = cc
+      ? (queryAndAssert(element, '#ccs') as GrAccountList).additions()
+      : (queryAndAssert(element, '#reviewers') as GrAccountList).additions();
+    assert.deepEqual(additions, [
+      {
+        group: {
+          id: 'id' as GroupId,
+          name: 'name' as GroupName,
+          confirmed: true,
+          _group: true,
+          _pendingAdd: true,
+        },
+      },
+    ]);
+
+    // We should be focused on account entry input.
+    if (cc) {
+      assert.isTrue(
+        isFocusInsideElement(
+          (queryAndAssert(element, '#ccs') as GrAccountList).$.entry.$.input.$
+            .input
+        )
+      );
+    } else {
+      assert.isTrue(
+        isFocusInsideElement(
+          (queryAndAssert(element, '#reviewers') as GrAccountList).$.entry.$
+            .input.$.input
+        )
+      );
+    }
+  }
+
+  test('cc confirmation', async () => {
+    testConfirmationDialog(true);
+  });
+
+  test('reviewer confirmation', async () => {
+    testConfirmationDialog(false);
+  });
+
+  test('_getStorageLocation', () => {
+    const actual = element._getStorageLocation();
+    assert.equal(actual.changeNum, changeNum);
+    assert.equal(actual.patchNum, '@change');
+    assert.equal(actual.path, '@change');
+  });
+
+  test('_reviewersMutated when account-text-change is fired from ccs', () => {
+    flush();
+    assert.isFalse(element._reviewersMutated);
+    assert.isTrue(
+      (queryAndAssert(element, '#ccs') as GrAccountList).allowAnyInput
+    );
+    assert.isFalse(
+      (queryAndAssert(element, '#reviewers') as GrAccountList).allowAnyInput
+    );
+    queryAndAssert(element, '#ccs').dispatchEvent(
+      new CustomEvent('account-text-changed', {bubbles: true, composed: true})
+    );
+    assert.isTrue(element._reviewersMutated);
+  });
+
+  test('gets draft from storage on open', () => {
+    const storedDraft = 'hello world';
+    getDraftCommentStub.returns({message: storedDraft});
+    element.open();
+    assert.isTrue(getDraftCommentStub.called);
+    assert.equal(element.draft, storedDraft);
+  });
+
+  test('gets draft from storage even when text is already present', () => {
+    const storedDraft = 'hello world';
+    getDraftCommentStub.returns({message: storedDraft});
+    element.draft = 'foo bar';
+    element.open();
+    assert.isTrue(getDraftCommentStub.called);
+    assert.equal(element.draft, storedDraft);
+  });
+
+  test('blank if no stored draft', () => {
+    getDraftCommentStub.returns(null);
+    element.draft = 'foo bar';
+    element.open();
+    assert.isTrue(getDraftCommentStub.called);
+    assert.equal(element.draft, '');
+  });
+
+  test('does not check stored draft when quote is present', () => {
+    const storedDraft = 'hello world';
+    const quote = '> foo bar';
+    getDraftCommentStub.returns({message: storedDraft});
+    element.quote = quote;
+    element.open();
+    assert.isFalse(getDraftCommentStub.called);
+    assert.equal(element.draft, quote);
+    assert.isNotOk(element.quote);
+  });
+
+  test('updates stored draft on edits', async () => {
+    const clock = sinon.useFakeTimers();
+
+    const firstEdit = 'hello';
+    const location = element._getStorageLocation();
+
+    element.draft = firstEdit;
+    clock.tick(1000);
+    await flush();
+
+    assert.isTrue(setDraftCommentStub.calledWith(location, firstEdit));
+
+    element.draft = '';
+    clock.tick(1000);
+    await flush();
+
+    assert.isTrue(eraseDraftCommentStub.calledWith(location));
+  });
+
+  test('400 converts to human-readable server-error', done => {
+    stubRestApi('saveChangeReview').callsFake(
+      (_changeNum, _patchNum, _review, errFn) => {
+        errFn!(
+          cloneableResponse(
+            400,
+            '....{"reviewers":{"id1":{"error":"human readable"}}}'
+          ) as Response
+        );
+        return Promise.resolve(new Response());
+      }
+    );
+
+    const listener = (event: Event) => {
+      if (event.target !== document) return;
+      (event as CustomEvent).detail.response.text().then((body: string) => {
+        if (body === 'human readable') {
+          done();
+        }
+      });
+    };
+    addListenerForTest(document, 'server-error', listener);
+
+    flush(() => {
+      element.send(false, false);
+    });
+  });
+
+  test('non-json 400 is treated as a normal server-error', done => {
+    stubRestApi('saveChangeReview').callsFake(
+      (_changeNum, _patchNum, _review, errFn) => {
+        errFn!(cloneableResponse(400, 'Comment validation error!') as Response);
+        return Promise.resolve(new Response());
+      }
+    );
+
+    const listener = (event: Event) => {
+      if (event.target !== document) return;
+      (event as CustomEvent).detail.response.text().then((body: string) => {
+        if (body === 'Comment validation error!') {
+          done();
+        }
+      });
+    };
+    addListenerForTest(document, 'server-error', listener);
+
+    // Async tick is needed because iron-selector content is distributed and
+    // distributed content requires an observer to be set up.
+    flush(() => {
+      element.send(false, false);
+    });
+  });
+
+  test('filterReviewerSuggestion', () => {
+    const owner = makeAccount();
+    const reviewer1 = makeAccount();
+    const reviewer2 = makeGroup();
+    const cc1 = makeAccount();
+    const cc2 = makeGroup();
+    let filter = element._filterReviewerSuggestionGenerator(false);
+
+    element._owner = owner;
+    element._reviewers = [reviewer1, reviewer2];
+    element._ccs = [cc1, cc2];
+
+    assert.isTrue(filter({account: makeAccount()} as Suggestion));
+    assert.isTrue(filter({group: makeGroup()} as Suggestion));
+
+    // Owner should be excluded.
+    assert.isFalse(filter({account: owner} as Suggestion));
+
+    // Existing and pending reviewers should be excluded when isCC = false.
+    assert.isFalse(filter({account: reviewer1} as Suggestion));
+    assert.isFalse(filter({group: reviewer2} as Suggestion));
+
+    filter = element._filterReviewerSuggestionGenerator(true);
+
+    // Existing and pending CCs should be excluded when isCC = true;.
+    assert.isFalse(filter({account: cc1} as Suggestion));
+    assert.isFalse(filter({group: cc2} as Suggestion));
+  });
+
+  test('_focusOn', async () => {
+    const chooseFocusTargetSpy = sinon.spy(element, '_chooseFocusTarget');
+    element._focusOn();
+    await flush();
+    assert.equal(chooseFocusTargetSpy.callCount, 1);
+    assert.equal(element?.shadowRoot?.activeElement?.tagName, 'GR-TEXTAREA');
+    assert.equal(element?.shadowRoot?.activeElement?.id, 'textarea');
+
+    element._focusOn(element.FocusTarget.ANY);
+    await flush();
+    assert.equal(chooseFocusTargetSpy.callCount, 2);
+    assert.equal(element?.shadowRoot?.activeElement?.tagName, 'GR-TEXTAREA');
+    assert.equal(element?.shadowRoot?.activeElement?.id, 'textarea');
+
+    element._focusOn(element.FocusTarget.BODY);
+    await flush();
+    assert.equal(chooseFocusTargetSpy.callCount, 2);
+    assert.equal(element?.shadowRoot?.activeElement?.tagName, 'GR-TEXTAREA');
+    assert.equal(element?.shadowRoot?.activeElement?.id, 'textarea');
+
+    element._focusOn(element.FocusTarget.REVIEWERS);
+    await flush();
+    assert.equal(chooseFocusTargetSpy.callCount, 2);
+    assert.equal(
+      element?.shadowRoot?.activeElement?.tagName,
+      'GR-ACCOUNT-LIST'
+    );
+    assert.equal(element?.shadowRoot?.activeElement?.id, 'reviewers');
+
+    element._focusOn(element.FocusTarget.CCS);
+    await flush();
+    assert.equal(chooseFocusTargetSpy.callCount, 2);
+    assert.equal(
+      element?.shadowRoot?.activeElement?.tagName,
+      'GR-ACCOUNT-LIST'
+    );
+    assert.equal(element?.shadowRoot?.activeElement?.id, 'ccs');
+  });
+
+  test('_chooseFocusTarget', () => {
+    element._account = undefined;
+    assert.strictEqual(element._chooseFocusTarget(), element.FocusTarget.BODY);
+
+    element._account = {_account_id: 1 as AccountId};
+    assert.strictEqual(element._chooseFocusTarget(), element.FocusTarget.BODY);
+
+    element.change!.owner = {_account_id: 2 as AccountId};
+    assert.strictEqual(element._chooseFocusTarget(), element.FocusTarget.BODY);
+
+    element.change!.owner._account_id = 1 as AccountId;
+    assert.strictEqual(
+      element._chooseFocusTarget(),
+      element.FocusTarget.REVIEWERS
+    );
+
+    element._reviewers = [];
+    assert.strictEqual(
+      element._chooseFocusTarget(),
+      element.FocusTarget.REVIEWERS
+    );
+
+    element._reviewers.push({});
+    assert.strictEqual(element._chooseFocusTarget(), element.FocusTarget.BODY);
+  });
+
+  test('only send labels that have changed', done => {
+    flush(() => {
+      stubSaveReview((review: ReviewInput) => {
+        assert.deepEqual(review?.labels, {
+          'Code-Review': 0,
+          Verified: -1,
+        });
+      });
+
+      element.addEventListener('send', () => {
+        done();
+      });
+      // Without wrapping this test in flush(), the below two calls to
+      // tap() cause a race in some situations in shadow DOM.
+      // The send button can be tapped before the others, causing the test to
+      // fail.
+      const el = queryAndAssert(
+        queryAndAssert(element, 'gr-label-scores'),
+        'gr-label-score-row[name="Verified"]'
+      ) as GrLabelScoreRow;
+      el.setSelectedValue('-1');
+      tap(queryAndAssert(element, '.send'));
+    });
+  });
+
+  test('moving from cc to reviewer', () => {
+    flush();
+
+    const reviewer1 = makeAccount();
+    const reviewer2 = makeAccount();
+    const reviewer3 = makeAccount();
+    const cc1 = makeAccount();
+    const cc2 = makeAccount();
+    const cc3 = makeAccount();
+    const cc4 = makeAccount();
+    element._reviewers = [reviewer1, reviewer2, reviewer3];
+    element._ccs = [cc1, cc2, cc3, cc4];
+    element.push('_reviewers', cc1);
+    flush();
+
+    assert.deepEqual(element._reviewers, [
+      reviewer1,
+      reviewer2,
+      reviewer3,
+      cc1,
+    ]);
+    assert.deepEqual(element._ccs, [cc2, cc3, cc4]);
+
+    element.push('_reviewers', cc4, cc3);
+    flush();
+
+    assert.deepEqual(element._reviewers, [
+      reviewer1,
+      reviewer2,
+      reviewer3,
+      cc1,
+      cc4,
+      cc3,
+    ]);
+    assert.deepEqual(element._ccs, [cc2]);
+  });
+
+  test('update attention section when reviewers and ccs change', () => {
+    element._account = makeAccount();
+    element._reviewers = [makeAccount(), makeAccount()];
+    element._ccs = [makeAccount(), makeAccount()];
+    element.draftCommentThreads = [];
+    const modifyButton = queryAndAssert(element, '.edit-attention-button');
+    tap(modifyButton);
+    flush();
+
+    // "Modify" button disabled, because "Send" button is disabled.
+    assert.isFalse(element._attentionExpanded);
+    element.draft = 'a test comment';
+    tap(modifyButton);
+    flush();
+    assert.isTrue(element._attentionExpanded);
+
+    let accountLabels = Array.from(
+      queryAll(element, '.attention-detail gr-account-label')
+    );
+    assert.equal(accountLabels.length, 5);
+
+    element.push('_reviewers', makeAccount());
+    element.push('_ccs', makeAccount());
+    flush();
+
+    // The 'attention modified' section collapses and resets when reviewers or
+    // ccs change.
+    assert.isFalse(element._attentionExpanded);
+
+    tap(queryAndAssert(element, '.edit-attention-button'));
+    flush();
+
+    assert.isTrue(element._attentionExpanded);
+    accountLabels = Array.from(
+      queryAll(element, '.attention-detail gr-account-label')
+    );
+    assert.equal(accountLabels.length, 7);
+
+    element.pop('_reviewers');
+    element.pop('_reviewers');
+    element.pop('_ccs');
+    element.pop('_ccs');
+
+    tap(queryAndAssert(element, '.edit-attention-button'));
+    flush();
+
+    accountLabels = Array.from(
+      queryAll(element, '.attention-detail gr-account-label')
+    );
+    assert.equal(accountLabels.length, 3);
+  });
+
+  test('moving from reviewer to cc', () => {
+    flush();
+
+    const reviewer1 = makeAccount();
+    const reviewer2 = makeAccount();
+    const reviewer3 = makeAccount();
+    const cc1 = makeAccount();
+    const cc2 = makeAccount();
+    const cc3 = makeAccount();
+    const cc4 = makeAccount();
+    element._reviewers = [reviewer1, reviewer2, reviewer3];
+    element._ccs = [cc1, cc2, cc3, cc4];
+    element.push('_ccs', reviewer1);
+    flush();
+
+    assert.deepEqual(element._reviewers, [reviewer2, reviewer3]);
+    assert.deepEqual(element._ccs, [cc1, cc2, cc3, cc4, reviewer1]);
+
+    element.push('_ccs', reviewer3, reviewer2);
+    flush();
+
+    assert.deepEqual(element._reviewers, []);
+    assert.deepEqual(element._ccs, [
+      cc1,
+      cc2,
+      cc3,
+      cc4,
+      reviewer1,
+      reviewer3,
+      reviewer2,
+    ]);
+  });
+
+  test('migrate reviewers between states', async () => {
+    flush();
+    const reviewers = queryAndAssert(element, '#reviewers') as GrAccountList;
+    const ccs = queryAndAssert(element, '#ccs') as GrAccountList;
+    const reviewer1 = makeAccount();
+    const reviewer2 = makeAccount();
+    const cc1 = makeAccount();
+    const cc2 = makeAccount();
+    const cc3 = makeAccount();
+    element._reviewers = [reviewer1, reviewer2];
+    element._ccs = [cc1, cc2, cc3];
+
+    element.change!.reviewers = {
+      [ReviewerState.CC]: [],
+      [ReviewerState.REVIEWER]: [{_account_id: 33 as AccountId}],
+    };
+
+    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,
+      })
+    );
+    ccs.dispatchEvent(
+      new CustomEvent('remove', {
+        detail: {account: cc1},
+        composed: true,
+        bubbles: true,
+      })
+    );
+    ccs.dispatchEvent(
+      new CustomEvent('remove', {
+        detail: {account: cc3},
+        composed: true,
+        bubbles: true,
+      })
+    );
+    reviewers.$.entry.dispatchEvent(
+      new CustomEvent('add', {
+        detail: {value: {account: cc1}},
+        composed: true,
+        bubbles: true,
+      })
+    );
+
+    // Add to other field without removing from former field.
+    // (Currently not possible in UI, but this is a good consistency check).
+    reviewers.$.entry.dispatchEvent(
+      new CustomEvent('add', {
+        detail: {value: {account: cc2}},
+        composed: true,
+        bubbles: true,
+      })
+    );
+    ccs.$.entry.dispatchEvent(
+      new CustomEvent('add', {
+        detail: {value: {account: reviewer2}},
+        composed: true,
+        bubbles: true,
+      })
+    );
+
+    const mapReviewer = function (
+      reviewer: AccountInfo,
+      opt_state?: ReviewerState
+    ) {
+      const result: ReviewerInput = {
+        reviewer: reviewer._account_id as AccountId,
+      };
+      if (opt_state) {
+        result.state = opt_state;
+      }
+      return result;
+    };
+
+    // Send and purge and verify moves, delete cc3.
+    await element.send(false, false);
+    expect(mutations).to.have.lengthOf(5);
+    expect(mutations[0]).to.deep.equal(
+      mapReviewer(cc1, ReviewerState.REVIEWER)
+    );
+    expect(mutations[1]).to.deep.equal(
+      mapReviewer(cc2, ReviewerState.REVIEWER)
+    );
+    expect(mutations[2]).to.deep.equal(
+      mapReviewer(reviewer1, ReviewerState.CC)
+    );
+    expect(mutations[3]).to.deep.equal(
+      mapReviewer(reviewer2, ReviewerState.CC)
+    );
+
+    // Only 1 account was initially part of the change
+    expect(mutations[4]).to.deep.equal({
+      reviewer: 33,
+      state: ReviewerState.REMOVED,
+    });
+  });
+
+  test('emits cancel on esc key', () => {
+    const cancelHandler = sinon.spy();
+    element.addEventListener('cancel', cancelHandler);
+    pressAndReleaseKeyOn(element, 27, null, 'esc');
+    flush();
+
+    assert.isTrue(cancelHandler.called);
+  });
+
+  test('should not send on enter key', () => {
+    stubSaveReview(() => undefined);
+    element.addEventListener('send', () => assert.fail('wrongly called'));
+    pressAndReleaseKeyOn(element, 13, null, 'enter');
+    flush();
+  });
+
+  test('emit send on ctrl+enter key', done => {
+    stubSaveReview(() => undefined);
+    element.addEventListener('send', () => done());
+    pressAndReleaseKeyOn(element, 13, 'ctrl', 'enter');
+    flush();
+  });
+
+  test('_computeMessagePlaceholder', () => {
+    assert.equal(
+      element._computeMessagePlaceholder(false),
+      'Say something nice...'
+    );
+    assert.equal(
+      element._computeMessagePlaceholder(true),
+      'Add a note for your reviewers...'
+    );
+  });
+
+  test('_computeSendButtonLabel', () => {
+    assert.equal(element._computeSendButtonLabel(false), 'Send');
+    assert.equal(
+      element._computeSendButtonLabel(true),
+      'Send and Start review'
+    );
+  });
+
+  test('_handle400Error reviewers and CCs', done => {
+    const error1 = 'error 1';
+    const error2 = 'error 2';
+    const error3 = 'error 3';
+    const text =
+      ")]}'" +
+      JSON.stringify({
+        reviewers: {
+          username1: {
+            input: 'username1',
+            error: error1,
+          },
+          username2: {
+            input: 'username2',
+            error: error2,
+          },
+          username3: {
+            input: 'username3',
+            error: error3,
+          },
+        },
+      });
+    const listener = (e: Event) => {
+      (e as CustomEvent).detail.response.text().then((text: string) => {
+        assert.equal(text, [error1, error2, error3].join(', '));
+        done();
+      });
+    };
+    addListenerForTest(document, 'server-error', listener);
+    element._handle400Error(cloneableResponse(400, text) as Response);
+  });
+
+  test('fires height change when the drafts comments load', done => {
+    // Flush DOM operations before binding to the autogrow event so we don't
+    // catch the events fired from the initial layout.
+    flush(() => {
+      const autoGrowHandler = sinon.stub();
+      element.addEventListener('autogrow', autoGrowHandler);
+      element.draftCommentThreads = [];
+      flush(() => {
+        assert.isTrue(autoGrowHandler.called);
+        done();
+      });
+    });
+  });
+
+  suite('start review and save buttons', () => {
+    let sendStub: sinon.SinonStub;
+
+    setup(() => {
+      sendStub = sinon.stub(element, 'send').callsFake(() => Promise.resolve());
+      element.canBeStarted = true;
+      // Flush to make both Start/Save buttons appear in DOM.
+      flush();
+    });
+
+    test('start review sets ready', () => {
+      tap(queryAndAssert(element, '.send'));
+      flush();
+      assert.isTrue(sendStub.calledWith(true, true));
+    });
+
+    test("save review doesn't set ready", () => {
+      tap(queryAndAssert(element, '.save'));
+      flush();
+      assert.isTrue(sendStub.calledWith(true, false));
+    });
+  });
+
+  test('buttons disabled until all API calls are resolved', () => {
+    stubSaveReview(() => {
+      return {ready: true};
+    });
+    return element.send(true, true).then(() => {
+      assert.isFalse(element.disabled);
+    });
+  });
+
+  suite('error handling', () => {
+    const expectedDraft = 'draft';
+    const expectedError = new Error('test');
+
+    setup(() => {
+      element.draft = expectedDraft;
+    });
+
+    function assertDialogOpenAndEnabled() {
+      assert.strictEqual(expectedDraft, element.draft);
+      assert.isFalse(element.disabled);
+    }
+
+    test('error occurs in _saveReview', () => {
+      stubSaveReview(() => {
+        throw expectedError;
+      });
+      return element.send(true, true).catch(err => {
+        assert.strictEqual(expectedError, err);
+        assertDialogOpenAndEnabled();
+      });
+    });
+
+    suite('pending diff drafts?', () => {
+      test('yes', async () => {
+        const promise = mockPromise();
+        const refreshSpy = sinon.spy();
+        element.addEventListener('comment-refresh', refreshSpy);
+        stubRestApi('hasPendingDiffDrafts').returns(1);
+        stubRestApi('awaitPendingDiffDrafts').returns(promise as Promise<void>);
+
+        element.open();
+
+        assert.isFalse(refreshSpy.called);
+        assert.isTrue(element._savingComments);
+
+        promise.resolve();
+        await flush();
+
+        assert.isTrue(refreshSpy.called);
+        assert.isFalse(element._savingComments);
+      });
+
+      test('no', () => {
+        stubRestApi('hasPendingDiffDrafts').returns(0);
+        element.open();
+        assert.isFalse(element._savingComments);
+      });
+    });
+  });
+
+  test('_computeSendButtonDisabled_canBeStarted', () => {
+    // Mock canBeStarted
+    assert.isFalse(
+      element._computeSendButtonDisabled(
+        /* canBeStarted= */ true,
+        /* draftCommentThreads= */ [],
+        /* text= */ '',
+        /* reviewersMutated= */ false,
+        /* labelsChanged= */ false,
+        /* includeComments= */ false,
+        /* disabled= */ false,
+        /* commentEditing= */ false,
+        /* change= */ element.change,
+        /* account= */ makeAccount()
+      )
+    );
+  });
+
+  test('_computeSendButtonDisabled_allFalse', () => {
+    // Mock everything false
+    assert.isTrue(
+      element._computeSendButtonDisabled(
+        /* canBeStarted= */ false,
+        /* draftCommentThreads= */ [],
+        /* text= */ '',
+        /* reviewersMutated= */ false,
+        /* labelsChanged= */ false,
+        /* includeComments= */ false,
+        /* disabled= */ false,
+        /* commentEditing= */ false,
+        /* change= */ element.change,
+        /* account= */ makeAccount()
+      )
+    );
+  });
+
+  test('_computeSendButtonDisabled_draftCommentsSend', () => {
+    // Mock nonempty comment draft array, with sending comments.
+    assert.isFalse(
+      element._computeSendButtonDisabled(
+        /* canBeStarted= */ false,
+        /* draftCommentThreads= */ [
+          {...createCommentThread([{__draft: true}])},
+        ],
+        /* text= */ '',
+        /* reviewersMutated= */ false,
+        /* labelsChanged= */ false,
+        /* includeComments= */ true,
+        /* disabled= */ false,
+        /* commentEditing= */ false,
+        /* change= */ element.change,
+        /* account= */ makeAccount()
+      )
+    );
+  });
+
+  test('_computeSendButtonDisabled_draftCommentsDoNotSend', () => {
+    // Mock nonempty comment draft array, without sending comments.
+    assert.isTrue(
+      element._computeSendButtonDisabled(
+        /* canBeStarted= */ false,
+        /* draftCommentThreads= */ [
+          {...createCommentThread([{__draft: true}])},
+        ],
+        /* text= */ '',
+        /* reviewersMutated= */ false,
+        /* labelsChanged= */ false,
+        /* includeComments= */ false,
+        /* disabled= */ false,
+        /* commentEditing= */ false,
+        /* change= */ element.change,
+        /* account= */ makeAccount()
+      )
+    );
+  });
+
+  test('_computeSendButtonDisabled_changeMessage', () => {
+    // Mock nonempty change message.
+    assert.isFalse(
+      element._computeSendButtonDisabled(
+        /* canBeStarted= */ false,
+        /* draftCommentThreads= */ [{...createCommentThread([{}])}],
+        /* text= */ 'test',
+        /* reviewersMutated= */ false,
+        /* labelsChanged= */ false,
+        /* includeComments= */ false,
+        /* disabled= */ false,
+        /* commentEditing= */ false,
+        /* change= */ element.change,
+        /* account= */ makeAccount()
+      )
+    );
+  });
+
+  test('_computeSendButtonDisabled_reviewersChanged', () => {
+    // Mock reviewers mutated.
+    assert.isFalse(
+      element._computeSendButtonDisabled(
+        /* canBeStarted= */ false,
+        /* draftCommentThreads= */ [{...createCommentThread([{}])}],
+        /* text= */ '',
+        /* reviewersMutated= */ true,
+        /* labelsChanged= */ false,
+        /* includeComments= */ false,
+        /* disabled= */ false,
+        /* commentEditing= */ false,
+        /* change= */ element.change,
+        /* account= */ makeAccount()
+      )
+    );
+  });
+
+  test('_computeSendButtonDisabled_labelsChanged', () => {
+    // Mock labels changed.
+    assert.isFalse(
+      element._computeSendButtonDisabled(
+        /* canBeStarted= */ false,
+        /* draftCommentThreads= */ [{...createCommentThread([{}])}],
+        /* text= */ '',
+        /* reviewersMutated= */ false,
+        /* labelsChanged= */ true,
+        /* includeComments= */ false,
+        /* disabled= */ false,
+        /* commentEditing= */ false,
+        /* change= */ element.change,
+        /* account= */ makeAccount()
+      )
+    );
+  });
+
+  test('_computeSendButtonDisabled_dialogDisabled', () => {
+    // Whole dialog is disabled.
+    assert.isTrue(
+      element._computeSendButtonDisabled(
+        /* canBeStarted= */ false,
+        /* draftCommentThreads= */ [{...createCommentThread([{}])}],
+        /* text= */ '',
+        /* reviewersMutated= */ false,
+        /* labelsChanged= */ true,
+        /* includeComments= */ false,
+        /* disabled= */ true,
+        /* commentEditing= */ false,
+        /* change= */ element.change,
+        /* account= */ makeAccount()
+      )
+    );
+  });
+
+  test('_computeSendButtonDisabled_existingVote', async () => {
+    const account = createAccountWithId();
+    (element.change!.labels![CODE_REVIEW]! as DetailedLabelInfo).all = [
+      account,
+    ];
+    await flush();
+
+    // User has already voted.
+    assert.isFalse(
+      element._computeSendButtonDisabled(
+        /* canBeStarted= */ false,
+        /* draftCommentThreads= */ [{...createCommentThread([{}])}],
+        /* text= */ '',
+        /* reviewersMutated= */ false,
+        /* labelsChanged= */ false,
+        /* includeComments= */ false,
+        /* disabled= */ false,
+        /* commentEditing= */ false,
+        /* change= */ element.change,
+        /* account= */ account
+      )
+    );
+  });
+
+  test('_submit blocked when no mutations exist', async () => {
+    const sendStub = sinon.stub(element, 'send').returns(Promise.resolve());
+    element.draftCommentThreads = [];
+    await flush();
+
+    tap(queryAndAssert(element, 'gr-button.send'));
+    assert.isFalse(sendStub.called);
+
+    element.draftCommentThreads = [
+      {
+        ...createCommentThread([
+          {__draft: true, path: 'test', line: 1, patch_set: 1 as PatchSetNum},
+        ]),
+      },
+    ];
+    await flush();
+
+    tap(queryAndAssert(element, 'gr-button.send'));
+    assert.isTrue(sendStub.called);
+  });
+
+  test('getFocusStops', async () => {
+    // Setting draftCommentThreads to an empty object causes _sendDisabled to be
+    // computed to false.
+    element.draftCommentThreads = [];
+    await flush();
+
+    assert.equal(
+      element.getFocusStops().end,
+      queryAndAssert(element, '#cancelButton')
+    );
+    element.draftCommentThreads = [
+      {
+        ...createCommentThread([
+          {__draft: true, path: 'test', line: 1, patch_set: 1 as PatchSetNum},
+        ]),
+      },
+    ];
+    await flush();
+
+    assert.equal(
+      element.getFocusStops().end,
+      queryAndAssert(element, '#sendButton')
+    );
+  });
+
+  test('setPluginMessage', () => {
+    element.setPluginMessage('foo');
+    assert.equal(queryAndAssert(element, '#pluginMessage').textContent, 'foo');
+  });
+});
diff --git a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.ts b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.ts
index 1c70350..ffcafdd 100644
--- a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.ts
@@ -25,7 +25,6 @@
 import {customElement, property, computed, observe} from '@polymer/decorators';
 import {
   ChangeInfo,
-  ServerInfo,
   LabelNameToValueMap,
   AccountInfo,
   ApprovalInfo,
@@ -41,6 +40,7 @@
 import {isRemovableReviewer} from '../../../utils/change-util';
 import {ReviewerState} from '../../../constants/constants';
 import {appContext} from '../../../services/app-context';
+import {fireAlert} from '../../../utils/event-util';
 
 @customElement('gr-reviewer-list')
 export class GrReviewerList extends PolymerElement {
@@ -60,9 +60,6 @@
   @property({type: Object})
   account?: AccountDetailInfo;
 
-  @property({type: Object})
-  serverConfig?: ServerInfo;
-
   @property({type: Boolean, reflectToAttribute: true})
   disabled = false;
 
@@ -200,17 +197,15 @@
     return maxScores.join(', ');
   }
 
-  @observe('change.reviewers.*', 'change.owner', 'serverConfig')
+  @observe('change.reviewers.*', 'change.owner')
   _reviewersChanged(
     changeRecord: PolymerDeepPropertyChange<Reviewers, Reviewers>,
-    owner: AccountInfo,
-    serverConfig: ServerInfo
+    owner: AccountInfo
   ) {
     // Polymer 2: check for undefined
     if (
       changeRecord === undefined ||
       owner === undefined ||
-      serverConfig === undefined ||
       this.change === undefined
     ) {
       return;
@@ -240,8 +235,8 @@
           if (isSelf(r1, this.account)) return -1;
           if (isSelf(r2, this.account)) return 1;
         }
-        const a1 = hasAttention(serverConfig, r1, this.change!) ? 1 : 0;
-        const a2 = hasAttention(serverConfig, r2, this.change!) ? 1 : 0;
+        const a1 = hasAttention(r1, this.change!) ? 1 : 0;
+        const a2 = hasAttention(r2, this.change!) ? 1 : 0;
         const s1 = isServiceUser(r1) ? -2 : 0;
         const s2 = isServiceUser(r2) ? -2 : 0;
         return a2 - a1 + s2 - s1;
@@ -261,32 +256,37 @@
   _handleRemove(e: Event) {
     e.preventDefault();
     const target = (dom(e) as EventApi).rootTarget as GrAccountChip;
-    if (!target.account || !this.change) {
-      return;
-    }
+    if (!target.account || !this.change?.reviewers) return;
     const accountID = target.account._account_id || target.account.email;
-    this.disabled = true;
     if (!accountID) return;
-    this._xhrPromise = this._removeReviewer(accountID)
-      .then((response: Response | undefined) => {
-        this.disabled = false;
-        if (!response || !response.ok) {
-          return response;
+    const reviewers = this.change.reviewers;
+    let removedAccount: AccountInfo | undefined;
+    let removedType: ReviewerState | undefined;
+    for (const type of [ReviewerState.REVIEWER, ReviewerState.CC]) {
+      const reviewerStateByType = reviewers[type] || [];
+      reviewers[type] = reviewerStateByType;
+      for (let i = 0; i < reviewerStateByType.length; i++) {
+        if (
+          reviewerStateByType[i]._account_id === accountID ||
+          reviewerStateByType[i].email === accountID
+        ) {
+          removedAccount = reviewerStateByType[i];
+          removedType = type;
+          this.splice(`change.reviewers.${type}`, i, 1);
+          break;
         }
-        if (!this.change || !this.change.reviewers) return;
-        const reviewers = this.change.reviewers;
-        for (const type of [ReviewerState.REVIEWER, ReviewerState.CC]) {
-          const reviewerStateByType = reviewers[type] || [];
-          reviewers[type] = reviewerStateByType;
-          for (let i = 0; i < reviewerStateByType.length; i++) {
-            if (
-              reviewerStateByType[i]._account_id === accountID ||
-              reviewerStateByType[i].email === accountID
-            ) {
-              this.splice('change.reviewers.' + type, i, 1);
-              break;
-            }
-          }
+      }
+    }
+    const curChange = this.change;
+    this.disabled = true;
+    this._xhrPromise = this._removeReviewer(accountID)
+      .then(response => {
+        this.disabled = false;
+        if (!this.change?.reviewers || this.change !== curChange) return;
+        if (!response?.ok) {
+          this.push(`change.reviewers.${removedType}`, removedAccount);
+          fireAlert(this, `Cannot remove a ${removedType}`);
+          return response;
         }
         return;
       })
@@ -326,3 +326,9 @@
     return this.restApiService.removeChangeReviewer(this.change._number, id);
   }
 }
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-reviewer-list': GrReviewerList;
+  }
+}
diff --git a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.js b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.js
deleted file mode 100644
index ddfe537..0000000
--- a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.js
+++ /dev/null
@@ -1,423 +0,0 @@
-/**
- * @license
- * Copyright (C) 2015 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../../../test/common-test-setup-karma.js';
-import './gr-reviewer-list.js';
-import {stubRestApi} from '../../../test/test-utils.js';
-
-const basicFixture = fixtureFromElement('gr-reviewer-list');
-
-suite('gr-reviewer-list tests', () => {
-  let element;
-
-  setup(() => {
-    element = basicFixture.instantiate();
-    element.serverConfig = {};
-
-    stubRestApi('removeChangeReviewer').returns(Promise.resolve({ok: true}));
-  });
-
-  test('controls hidden on immutable element', () => {
-    flush();
-    element.mutable = false;
-    assert.isTrue(element.shadowRoot
-        .querySelector('.controlsContainer').hasAttribute('hidden'));
-    element.mutable = true;
-    assert.isFalse(element.shadowRoot
-        .querySelector('.controlsContainer').hasAttribute('hidden'));
-  });
-
-  test('add reviewer button opens reply dialog', done => {
-    element.addEventListener('show-reply-dialog', () => {
-      done();
-    });
-    flush();
-    MockInteractions.tap(element.shadowRoot
-        .querySelector('.addReviewer'));
-  });
-
-  test('only show remove for removable reviewers', () => {
-    element.mutable = true;
-    element.change = {
-      owner: {
-        _account_id: 1,
-      },
-      reviewers: {
-        REVIEWER: [
-          {
-            _account_id: 2,
-            name: 'Bojack Horseman',
-            email: 'SecretariatRulez96@hotmail.com',
-          },
-          {
-            _account_id: 3,
-            name: 'Pinky Penguin',
-          },
-        ],
-        CC: [
-          {
-            _account_id: 4,
-            name: 'Diane Nguyen',
-            email: 'macarthurfellow2B@juno.com',
-          },
-          {
-            email: 'test@e.mail',
-          },
-        ],
-      },
-      removable_reviewers: [
-        {
-          _account_id: 3,
-          name: 'Pinky Penguin',
-        },
-        {
-          _account_id: 4,
-          name: 'Diane Nguyen',
-          email: 'macarthurfellow2B@juno.com',
-        },
-        {
-          email: 'test@e.mail',
-        },
-      ],
-    };
-    flush();
-    const chips =
-        element.root.querySelectorAll('gr-account-chip');
-    assert.equal(chips.length, 4);
-
-    for (const el of Array.from(chips)) {
-      const accountID = el.account._account_id || el.account.email;
-      assert.ok(accountID);
-
-      const buttonEl = el.shadowRoot
-          .querySelector('gr-button');
-      assert.isNotNull(buttonEl);
-      if (accountID == 2) {
-        assert.isTrue(buttonEl.hasAttribute('hidden'));
-      } else {
-        assert.isFalse(buttonEl.hasAttribute('hidden'));
-      }
-    }
-  });
-
-  suite('_handleRemove', () => {
-    let removeReviewerStub;
-    let reviewersChangedSpy;
-
-    const reviewerWithId = {
-      _account_id: 2,
-      name: 'Some name',
-    };
-
-    const reviewerWithIdAndEmail = {
-      _account_id: 4,
-      name: 'Some other name',
-      email: 'example@',
-    };
-
-    const reviewerWithEmailOnly = {
-      email: 'example2@example',
-    };
-
-    let chips;
-
-    setup(() => {
-      removeReviewerStub = sinon
-          .stub(element, '_removeReviewer')
-          .returns(Promise.resolve(new Response({status: 200})));
-      element.mutable = true;
-
-      const allReviewers = [
-        reviewerWithId,
-        reviewerWithIdAndEmail,
-        reviewerWithEmailOnly,
-      ];
-
-      element.change = {
-        owner: {
-          _account_id: 1,
-        },
-        reviewers: {
-          REVIEWER: allReviewers,
-        },
-        removable_reviewers: allReviewers,
-      };
-      flush();
-      chips = Array.from(element.root.querySelectorAll('gr-account-chip'));
-      assert.equal(chips.length, allReviewers.length);
-      reviewersChangedSpy = sinon.spy(element, '_reviewersChanged');
-    });
-
-    test('_handleRemove for account with accountId only', async () => {
-      const accountChip = chips.find(chip =>
-        chip.account._account_id === reviewerWithId._account_id
-      );
-      accountChip._handleRemoveTap(new MouseEvent('click'));
-      await flush();
-      assert.isTrue(removeReviewerStub.calledOnce);
-      assert.isTrue(removeReviewerStub.calledWith(reviewerWithId._account_id));
-      assert.isTrue(reviewersChangedSpy.called);
-      expect(element.change.reviewers.REVIEWER).to.have.deep.members([
-        reviewerWithIdAndEmail,
-        reviewerWithEmailOnly,
-      ]);
-    });
-
-    test('_handleRemove for account with accountId and email', async () => {
-      const accountChip = chips.find(chip =>
-        chip.account._account_id === reviewerWithIdAndEmail._account_id
-      );
-      accountChip._handleRemoveTap(new MouseEvent('click'));
-      await flush();
-      assert.isTrue(removeReviewerStub.calledOnce);
-      assert.isTrue(
-          removeReviewerStub.calledWith(reviewerWithIdAndEmail._account_id));
-      assert.isTrue(reviewersChangedSpy.called);
-      expect(element.change.reviewers.REVIEWER).to.have.deep.members([
-        reviewerWithId,
-        reviewerWithEmailOnly,
-      ]);
-    });
-
-    test('_handleRemove for account with email only', async () => {
-      const accountChip = chips.find(
-          chip => chip.account.email === reviewerWithEmailOnly.email
-      );
-      accountChip._handleRemoveTap(new MouseEvent('click'));
-      await flush();
-      assert.isTrue(removeReviewerStub.calledOnce);
-      assert.isTrue(removeReviewerStub.calledWith(reviewerWithEmailOnly.email));
-      assert.isTrue(reviewersChangedSpy.called);
-      expect(element.change.reviewers.REVIEWER).to.have.deep.members([
-        reviewerWithId,
-        reviewerWithIdAndEmail,
-      ]);
-    });
-  });
-
-  test('tracking reviewers and ccs', () => {
-    let counter = 0;
-    function makeAccount() {
-      return {_account_id: counter++};
-    }
-
-    const owner = makeAccount();
-    const reviewer = makeAccount();
-    const cc = makeAccount();
-    const reviewers = {
-      REMOVED: [makeAccount()],
-      REVIEWER: [owner, reviewer],
-      CC: [owner, cc],
-    };
-
-    element.ccsOnly = false;
-    element.reviewersOnly = false;
-    element.change = {
-      owner,
-      reviewers,
-    };
-    assert.deepEqual(element._reviewers, [reviewer, cc]);
-
-    element.reviewersOnly = true;
-    element.change = {
-      owner,
-      reviewers,
-    };
-    assert.deepEqual(element._reviewers, [reviewer]);
-
-    element.ccsOnly = true;
-    element.reviewersOnly = false;
-    element.change = {
-      owner,
-      reviewers,
-    };
-    assert.deepEqual(element._reviewers, [cc]);
-  });
-
-  test('_handleAddTap passes mode with event', () => {
-    const fireStub = sinon.stub(element, 'dispatchEvent');
-    const e = {preventDefault() {}};
-
-    element.ccsOnly = false;
-    element.reviewersOnly = false;
-    element._handleAddTap(e);
-    assert.equal(fireStub.lastCall.args[0].type, 'show-reply-dialog');
-    assert.deepEqual(fireStub.lastCall.args[0].detail, {value: {
-      reviewersOnly: false,
-      ccsOnly: false,
-    }});
-
-    element.reviewersOnly = true;
-    element._handleAddTap(e);
-    assert.equal(fireStub.lastCall.args[0].type, 'show-reply-dialog');
-    assert.deepEqual(
-        fireStub.lastCall.args[0].detail,
-        {value: {reviewersOnly: true, ccsOnly: false}});
-
-    element.ccsOnly = true;
-    element.reviewersOnly = false;
-    element._handleAddTap(e);
-    assert.equal(fireStub.lastCall.args[0].type, 'show-reply-dialog');
-    assert.deepEqual(fireStub.lastCall.args[0].detail,
-        {value: {ccsOnly: true, reviewersOnly: false}});
-  });
-
-  test('dont show all reviewers button with 4 reviewers', () => {
-    const reviewers = [];
-    element.maxReviewersDisplayed = 3;
-    for (let i = 0; i < 4; i++) {
-      reviewers.push(
-          {email: i+'reviewer@google.com', name: 'reviewer-' + i});
-    }
-    element.ccsOnly = true;
-
-    element.change = {
-      owner: {
-        _account_id: 1,
-      },
-      reviewers: {
-        CC: reviewers,
-      },
-    };
-    assert.equal(element._hiddenReviewerCount, 0);
-    assert.equal(element._displayedReviewers.length, 4);
-    assert.equal(element._reviewers.length, 4);
-    assert.isTrue(element.shadowRoot
-        .querySelector('.hiddenReviewers').hidden);
-  });
-
-  test('account owner comes first in list of reviewers', () => {
-    const reviewers = [];
-    element.maxReviewersDisplayed = 3;
-    for (let i = 0; i < 4; i++) {
-      reviewers.push(
-          {email: i+'reviewer@google.com', name: 'reviewer-' + i,
-            _account_id: i});
-    }
-    element.reviewersOnly = true;
-    element.account = {
-      _account_id: 1,
-    };
-    element.change = {
-      owner: {
-        _account_id: 111,
-      },
-      reviewers: {
-        REVIEWER: reviewers,
-      },
-    };
-    flush();
-    assert.equal(element._displayedReviewers[0]._account_id, 1);
-  });
-
-  test('show all reviewers button with 9 reviewers', () => {
-    const reviewers = [];
-    for (let i = 0; i < 9; i++) {
-      reviewers.push(
-          {email: i+'reviewer@google.com', name: 'reviewer-' + i});
-    }
-    element.ccsOnly = true;
-
-    element.change = {
-      owner: {
-        _account_id: 1,
-      },
-      reviewers: {
-        CC: reviewers,
-      },
-    };
-    assert.equal(element._hiddenReviewerCount, 3);
-    assert.equal(element._displayedReviewers.length, 6);
-    assert.equal(element._reviewers.length, 9);
-    assert.isFalse(element.shadowRoot
-        .querySelector('.hiddenReviewers').hidden);
-  });
-
-  test('show all reviewers button', () => {
-    const reviewers = [];
-    for (let i = 0; i < 100; i++) {
-      reviewers.push(
-          {email: i+'reviewer@google.com', name: 'reviewer-' + i});
-    }
-    element.ccsOnly = true;
-
-    element.change = {
-      owner: {
-        _account_id: 1,
-      },
-      reviewers: {
-        CC: reviewers,
-      },
-    };
-    assert.equal(element._hiddenReviewerCount, 94);
-    assert.equal(element._displayedReviewers.length, 6);
-    assert.equal(element._reviewers.length, 100);
-    assert.isFalse(element.shadowRoot
-        .querySelector('.hiddenReviewers').hidden);
-
-    MockInteractions.tap(element.shadowRoot
-        .querySelector('.hiddenReviewers'));
-
-    assert.equal(element._hiddenReviewerCount, 0);
-    assert.equal(element._displayedReviewers.length, 100);
-    assert.equal(element._reviewers.length, 100);
-    assert.isTrue(element.shadowRoot
-        .querySelector('.hiddenReviewers').hidden);
-  });
-
-  test('votable labels', () => {
-    const change = {
-      labels: {
-        Foo: {
-          all: [{_account_id: 7, permitted_voting_range: {max: 2}}],
-        },
-        Bar: {
-          all: [{_account_id: 1, permitted_voting_range: {max: 1}},
-            {_account_id: 7, permitted_voting_range: {max: 1}}],
-        },
-        FooBar: {
-          all: [{_account_id: 7, value: 0}],
-        },
-      },
-      permitted_labels: {
-        Foo: ['-1', ' 0', '+1', '+2'],
-        FooBar: ['-1', ' 0'],
-      },
-    };
-    assert.strictEqual(
-        element._computeVoteableText({_account_id: 1}, change),
-        'Bar');
-    assert.strictEqual(
-        element._computeVoteableText({_account_id: 7}, change),
-        'Foo: +2, Bar, FooBar');
-    assert.strictEqual(
-        element._computeVoteableText({_account_id: 2}, change),
-        '');
-  });
-
-  test('fails gracefully when all is not included', () => {
-    const change = {
-      labels: {Foo: {}},
-      permitted_labels: {
-        Foo: ['-1', ' 0', '+1', '+2'],
-      },
-    };
-    assert.strictEqual(
-        element._computeVoteableText({_account_id: 1}, change), '');
-  });
-});
-
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
new file mode 100644
index 0000000..4b57651
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.ts
@@ -0,0 +1,475 @@
+/**
+ * @license
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma';
+import './gr-reviewer-list';
+import {queryAndAssert, stubRestApi} from '../../../test/test-utils';
+import {GrReviewerList} from './gr-reviewer-list';
+import {
+  createAccountDetailWithId,
+  createChange,
+} from '../../../test/test-data-generators';
+import {tap} from '@polymer/iron-test-helpers/mock-interactions';
+import {GrButton} from '../../shared/gr-button/gr-button';
+import {AccountId, EmailAddress} from '../../../types/common';
+import {GrAccountChip} from '../../shared/gr-account-chip/gr-account-chip';
+
+const basicFixture = fixtureFromElement('gr-reviewer-list');
+
+suite('gr-reviewer-list tests', () => {
+  let element: GrReviewerList;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+
+    stubRestApi('removeChangeReviewer').returns(
+      Promise.resolve({...new Response(), ok: true})
+    );
+  });
+
+  test('controls hidden on immutable element', () => {
+    flush();
+    element.mutable = false;
+    assert.isTrue(
+      queryAndAssert(element, '.controlsContainer').hasAttribute('hidden')
+    );
+    element.mutable = true;
+    assert.isFalse(
+      queryAndAssert(element, '.controlsContainer').hasAttribute('hidden')
+    );
+  });
+
+  test('add reviewer button opens reply dialog', done => {
+    element.addEventListener('show-reply-dialog', () => {
+      done();
+    });
+    flush();
+    tap(queryAndAssert(element, '.addReviewer'));
+  });
+
+  test('only show remove for removable reviewers', () => {
+    element.mutable = true;
+    element.change = {
+      ...createChange(),
+      owner: {
+        ...createAccountDetailWithId(1),
+      },
+      reviewers: {
+        REVIEWER: [
+          {
+            ...createAccountDetailWithId(2),
+            name: 'Bojack Horseman',
+            email: 'SecretariatRulez96@hotmail.com' as EmailAddress,
+          },
+          {
+            _account_id: 3 as AccountId,
+            name: 'Pinky Penguin',
+          },
+        ],
+        CC: [
+          {
+            ...createAccountDetailWithId(4),
+            name: 'Diane Nguyen',
+            email: 'macarthurfellow2B@juno.com' as EmailAddress,
+          },
+          {
+            email: 'test@e.mail' as EmailAddress,
+          },
+        ],
+      },
+      removable_reviewers: [
+        {
+          _account_id: 3 as AccountId,
+          name: 'Pinky Penguin',
+        },
+        {
+          ...createAccountDetailWithId(4),
+          name: 'Diane Nguyen',
+          email: 'macarthurfellow2B@juno.com' as EmailAddress,
+        },
+        {
+          email: 'test@e.mail' as EmailAddress,
+        },
+      ],
+    };
+    flush();
+    const chips = element.root!.querySelectorAll('gr-account-chip');
+    assert.equal(chips.length, 4);
+
+    for (const el of Array.from(chips)) {
+      const accountID = el.account!._account_id || el.account!.email;
+      assert.ok(accountID);
+
+      const buttonEl = queryAndAssert(el, 'gr-button');
+      if (accountID === 2) {
+        assert.isTrue(buttonEl.hasAttribute('hidden'));
+      } else {
+        assert.isFalse(buttonEl.hasAttribute('hidden'));
+      }
+    }
+  });
+
+  suite('_handleRemove', () => {
+    let removeReviewerStub: sinon.SinonStub;
+    let reviewersChangedSpy: sinon.SinonSpy;
+
+    const reviewerWithId = {
+      ...createAccountDetailWithId(2),
+      name: 'Some name',
+    };
+
+    const reviewerWithIdAndEmail = {
+      ...createAccountDetailWithId(4),
+      name: 'Some other name',
+      email: 'example@' as EmailAddress,
+    };
+
+    const reviewerWithEmailOnly = {
+      email: 'example2@example' as EmailAddress,
+    };
+
+    let chips: GrAccountChip[];
+
+    setup(() => {
+      removeReviewerStub = sinon
+        .stub(element, '_removeReviewer')
+        .returns(Promise.resolve(new Response()));
+      element.mutable = true;
+
+      const allReviewers = [
+        reviewerWithId,
+        reviewerWithIdAndEmail,
+        reviewerWithEmailOnly,
+      ];
+
+      element.change = {
+        ...createChange(),
+        owner: {
+          ...createAccountDetailWithId(1),
+        },
+        reviewers: {
+          REVIEWER: allReviewers,
+        },
+        removable_reviewers: allReviewers,
+      };
+      flush();
+      chips = Array.from(element.root!.querySelectorAll('gr-account-chip'));
+      assert.equal(chips.length, allReviewers.length);
+      reviewersChangedSpy = sinon.spy(element, '_reviewersChanged');
+    });
+
+    test('_handleRemove for account with accountId only', async () => {
+      const accountChip = chips.find(
+        chip => chip.account!._account_id === reviewerWithId._account_id
+      );
+      accountChip!._handleRemoveTap(new MouseEvent('click'));
+      await flush();
+      assert.isTrue(removeReviewerStub.calledOnce);
+      assert.isTrue(removeReviewerStub.calledWith(reviewerWithId._account_id));
+      assert.isTrue(reviewersChangedSpy.called);
+      expect(element.change!.reviewers.REVIEWER).to.have.deep.members([
+        reviewerWithIdAndEmail,
+        reviewerWithEmailOnly,
+      ]);
+    });
+
+    test('_handleRemove for account with accountId and email', async () => {
+      const accountChip = chips.find(
+        chip => chip.account!._account_id === reviewerWithIdAndEmail._account_id
+      );
+      accountChip!._handleRemoveTap(new MouseEvent('click'));
+      await flush();
+      assert.isTrue(removeReviewerStub.calledOnce);
+      assert.isTrue(
+        removeReviewerStub.calledWith(reviewerWithIdAndEmail._account_id)
+      );
+      assert.isTrue(reviewersChangedSpy.called);
+      expect(element.change!.reviewers.REVIEWER).to.have.deep.members([
+        reviewerWithId,
+        reviewerWithEmailOnly,
+      ]);
+    });
+
+    test('_handleRemove for account with email only', async () => {
+      const accountChip = chips.find(
+        chip => chip.account!.email === reviewerWithEmailOnly.email
+      );
+      accountChip!._handleRemoveTap(new MouseEvent('click'));
+      await flush();
+      assert.isTrue(removeReviewerStub.calledOnce);
+      assert.isTrue(removeReviewerStub.calledWith(reviewerWithEmailOnly.email));
+      assert.isTrue(reviewersChangedSpy.called);
+      expect(element.change!.reviewers.REVIEWER).to.have.deep.members([
+        reviewerWithId,
+        reviewerWithIdAndEmail,
+      ]);
+    });
+  });
+
+  test('tracking reviewers and ccs', () => {
+    let counter = 0;
+    function makeAccount() {
+      return {_account_id: counter++ as AccountId};
+    }
+
+    const owner = makeAccount();
+    const reviewer = makeAccount();
+    const cc = makeAccount();
+    const reviewers = {
+      REMOVED: [makeAccount()],
+      REVIEWER: [owner, reviewer],
+      CC: [owner, cc],
+    };
+
+    element.ccsOnly = false;
+    element.reviewersOnly = false;
+    element.change = {
+      ...createChange(),
+      owner,
+      reviewers,
+    };
+    assert.deepEqual(element._reviewers, [reviewer, cc]);
+
+    element.reviewersOnly = true;
+    element.change = {
+      ...createChange(),
+      owner,
+      reviewers,
+    };
+    assert.deepEqual(element._reviewers, [reviewer]);
+
+    element.ccsOnly = true;
+    element.reviewersOnly = false;
+    element.change = {
+      ...createChange(),
+      owner,
+      reviewers,
+    };
+    assert.deepEqual(element._reviewers, [cc]);
+  });
+
+  test('_handleAddTap passes mode with event', () => {
+    const fireStub = sinon.stub(element, 'dispatchEvent');
+    const e = {...new Event(''), preventDefault() {}};
+
+    element.ccsOnly = false;
+    element.reviewersOnly = false;
+    element._handleAddTap(e);
+    assert.equal(fireStub.lastCall.args[0].type, 'show-reply-dialog');
+    assert.deepEqual((fireStub.lastCall.args[0] as CustomEvent).detail, {
+      value: {
+        reviewersOnly: false,
+        ccsOnly: false,
+      },
+    });
+
+    element.reviewersOnly = true;
+    element._handleAddTap(e);
+    assert.equal(fireStub.lastCall.args[0].type, 'show-reply-dialog');
+    assert.deepEqual((fireStub.lastCall.args[0] as CustomEvent).detail, {
+      value: {reviewersOnly: true, ccsOnly: false},
+    });
+
+    element.ccsOnly = true;
+    element.reviewersOnly = false;
+    element._handleAddTap(e);
+    assert.equal(fireStub.lastCall.args[0].type, 'show-reply-dialog');
+    assert.deepEqual((fireStub.lastCall.args[0] as CustomEvent).detail, {
+      value: {ccsOnly: true, reviewersOnly: false},
+    });
+  });
+
+  test('dont show all reviewers button with 4 reviewers', () => {
+    const reviewers = [];
+    for (let i = 0; i < 4; i++) {
+      reviewers.push({
+        ...createAccountDetailWithId(i),
+        email: `${i}reviewer@google.com` as EmailAddress,
+        name: `reviewer${i}`,
+      });
+    }
+    element.ccsOnly = true;
+
+    element.change = {
+      ...createChange(),
+      owner: {
+        ...createAccountDetailWithId(111),
+      },
+      reviewers: {
+        CC: reviewers,
+      },
+    };
+    assert.equal(element._hiddenReviewerCount, 0);
+    assert.equal(element._displayedReviewers.length, 4);
+    assert.equal(element._reviewers.length, 4);
+    assert.isTrue(
+      (queryAndAssert(element, '.hiddenReviewers') as GrButton).hidden
+    );
+  });
+
+  test('account owner comes first in list of reviewers', () => {
+    const reviewers = [];
+    for (let i = 0; i < 4; i++) {
+      reviewers.push({
+        ...createAccountDetailWithId(i),
+        email: `${i}reviewer@google.com` as EmailAddress,
+        name: `reviewer${i}`,
+      });
+    }
+    element.reviewersOnly = true;
+    element.account = {
+      ...createAccountDetailWithId(1),
+    };
+    element.change = {
+      ...createChange(),
+      owner: {
+        ...createAccountDetailWithId(11),
+      },
+      reviewers: {
+        REVIEWER: reviewers,
+      },
+    };
+    flush();
+    assert.equal(element._displayedReviewers[0]._account_id, 1 as AccountId);
+  });
+
+  test('show all reviewers button with 9 reviewers', () => {
+    const reviewers = [];
+    for (let i = 0; i < 9; i++) {
+      reviewers.push({
+        ...createAccountDetailWithId(i),
+        email: `${i}reviewer@google.com` as EmailAddress,
+        name: `reviewer${i}`,
+      });
+    }
+    element.ccsOnly = true;
+
+    element.change = {
+      ...createChange(),
+      owner: {
+        ...createAccountDetailWithId(111),
+      },
+      reviewers: {
+        CC: reviewers,
+      },
+    };
+    assert.equal(element._hiddenReviewerCount, 3);
+    assert.equal(element._displayedReviewers.length, 6);
+    assert.equal(element._reviewers.length, 9);
+    assert.isFalse(
+      (queryAndAssert(element, '.hiddenReviewers') as GrButton).hidden
+    );
+  });
+
+  test('show all reviewers button', () => {
+    const reviewers = [];
+    for (let i = 0; i < 100; i++) {
+      reviewers.push({
+        ...createAccountDetailWithId(i),
+        email: `${i}reviewer@google.com` as EmailAddress,
+        name: `reviewer${i}`,
+      });
+    }
+    element.ccsOnly = true;
+
+    element.change = {
+      ...createChange(),
+      owner: {
+        ...createAccountDetailWithId(111),
+      },
+      reviewers: {
+        CC: reviewers,
+      },
+    };
+    assert.equal(element._hiddenReviewerCount, 94);
+    assert.equal(element._displayedReviewers.length, 6);
+    assert.equal(element._reviewers.length, 100);
+    assert.isFalse(
+      (queryAndAssert(element, '.hiddenReviewers') as GrButton).hidden
+    );
+
+    tap(queryAndAssert(element, '.hiddenReviewers'));
+
+    assert.equal(element._hiddenReviewerCount, 0);
+    assert.equal(element._displayedReviewers.length, 100);
+    assert.equal(element._reviewers.length, 100);
+    assert.isTrue(
+      (queryAndAssert(element, '.hiddenReviewers') as GrButton).hidden
+    );
+  });
+
+  test('votable labels', () => {
+    const change = {
+      ...createChange(),
+      labels: {
+        Foo: {
+          all: [
+            {
+              _account_id: 7 as AccountId,
+              permitted_voting_range: {max: 2, min: 0},
+            },
+          ],
+        },
+        Bar: {
+          all: [
+            {
+              ...createAccountDetailWithId(1),
+              permitted_voting_range: {max: 1, min: 0},
+            },
+            {
+              _account_id: 7 as AccountId,
+              permitted_voting_range: {max: 1, min: 0},
+            },
+          ],
+        },
+        FooBar: {
+          all: [{_account_id: 7 as AccountId, value: 0}],
+        },
+      },
+      permitted_labels: {
+        Foo: ['-1', ' 0', '+1', '+2'],
+        FooBar: ['-1', ' 0'],
+      },
+    };
+    assert.strictEqual(
+      element._computeVoteableText({...createAccountDetailWithId(1)}, change),
+      'Bar'
+    );
+    assert.strictEqual(
+      element._computeVoteableText({...createAccountDetailWithId(7)}, change),
+      'Foo: +2, Bar, FooBar'
+    );
+    assert.strictEqual(
+      element._computeVoteableText({...createAccountDetailWithId(2)}, change),
+      ''
+    );
+  });
+
+  test('fails gracefully when all is not included', () => {
+    const change = {
+      ...createChange(),
+      labels: {Foo: {}},
+      permitted_labels: {
+        Foo: ['-1', ' 0', '+1', '+2'],
+      },
+    };
+    assert.strictEqual(
+      element._computeVoteableText({...createAccountDetailWithId(1)}, change),
+      ''
+    );
+  });
+});
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..8283be79 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
@@ -112,7 +112,7 @@
   }
 
   _computeEmptyThreadsMessage(threads: CommentThread[]) {
-    return !threads.length ? 'No comments.' : 'No unresolved comments';
+    return !threads.length ? 'No comments' : 'No unresolved comments';
   }
 
   _showPartyPopper(threads: CommentThread[]) {
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 794e9c9..da91095 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
@@ -44,10 +44,6 @@
       border-top: 1px solid var(--border-color);
       margin-top: var(--spacing-xl);
     }
-    .resolved-comments-message {
-      color: var(--link-color);
-      cursor: pointer;
-    }
     .show-resolved-comments {
       box-shadow: none;
       padding-left: var(--spacing-m);
@@ -84,8 +80,9 @@
           type="radio"
           on-click="_handleOnlyDrafts"
           checked="[[_draftsOnly]]"
+          hidden$="[[!loggedIn]]"
         />
-        <label for="draftsRadio">
+        <label for="draftsRadio" hidden$="[[!loggedIn]]">
           Drafts ([[_countDrafts(threads)]])
         </label>
         <input
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 7b47bd7..3d6cfd4 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
@@ -270,6 +270,17 @@
     });
   });
 
+  test('draft toggle only appears when logged in', () => {
+    element.loggedIn = false;
+    assert.equal(getComputedStyle(element.shadowRoot
+        .querySelector('#draftsRadio')).display,
+    'none');
+    element.loggedIn = true;
+    assert.notEqual(getComputedStyle(element.shadowRoot
+        .querySelector('#draftsRadio')).display,
+    'none');
+  });
+
   test('show all threads by default', () => {
     assert.equal(dom(element.root)
         .querySelectorAll('gr-comment-thread').length, element.threads.length);
@@ -600,7 +611,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
new file mode 100644
index 0000000..82680aa
--- /dev/null
+++ b/polygerrit-ui/app/elements/checks/gr-checks-action.ts
@@ -0,0 +1,82 @@
+/**
+ * @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 {html} from 'lit-html';
+import {css, customElement, property} from 'lit-element';
+import {GrLitElement} from '../lit/gr-lit-element';
+import {Action} from '../../api/checks';
+import {checkRequiredProperty} from '../../utils/common-util';
+import {fireActionTriggered} from '../../services/checks/checks-util';
+
+@customElement('gr-checks-action')
+export class GrChecksAction extends GrLitElement {
+  @property()
+  action!: Action;
+
+  @property()
+  eventTarget?: EventTarget;
+
+  connectedCallback() {
+    super.connectedCallback();
+    checkRequiredProperty(this.action, 'action');
+  }
+
+  static get styles() {
+    return [
+      css`
+        :host {
+          display: inline-block;
+        }
+        gr-button {
+          --padding: var(--spacing-s) var(--spacing-m);
+        }
+        gr-button paper-tooltip {
+          text-transform: none;
+        }
+      `,
+    ];
+  }
+
+  render() {
+    return html`
+      <gr-button
+        link
+        ?disabled="${this.action.disabled}"
+        class="action"
+        @click="${(e: Event) => this.handleClick(e)}"
+      >
+        ${this.action.name}
+        <paper-tooltip
+          ?hidden="${!this.action.tooltip}"
+          offset="5"
+          fit-to-visible-bounds="true"
+          >${this.action.tooltip}</paper-tooltip
+        >
+      </gr-button>
+    `;
+  }
+
+  handleClick(e: Event) {
+    e.stopPropagation();
+    fireActionTriggered(this.eventTarget ?? this, this.action);
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-checks-action': GrChecksAction;
+  }
+}
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-attempt.ts b/polygerrit-ui/app/elements/checks/gr-checks-attempt.ts
new file mode 100644
index 0000000..6dce9bc
--- /dev/null
+++ b/polygerrit-ui/app/elements/checks/gr-checks-attempt.ts
@@ -0,0 +1,83 @@
+/**
+ * @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 {html} from 'lit-html';
+import {css, customElement, property} from 'lit-element';
+import {GrLitElement} from '../lit/gr-lit-element';
+import {CheckRun} from '../../services/checks/checks-model';
+
+@customElement('gr-checks-attempt')
+class GrChecksAttempt extends GrLitElement {
+  @property()
+  run?: CheckRun;
+
+  static get styles() {
+    return [
+      css`
+        .attempt {
+          display: inline-block;
+          height: var(--line-height-normal);
+          vertical-align: top;
+          font-size: var(--font-size-small);
+          position: relative;
+        }
+        .attempt .box,
+        .attempt .angle {
+          box-sizing: border-box;
+          height: calc(var(--line-height-normal) - 2px);
+          line-height: calc(var(--line-height-normal) - 2px);
+          border-radius: 2px;
+        }
+        .attempt .box {
+          margin-left: 2px;
+          margin-bottom: 2px;
+          border: 1px solid var(--deemphasized-text-color);
+          padding: 0 var(--spacing-s);
+        }
+        .attempt .angle {
+          position: absolute;
+          top: 2px;
+          /* The text in the .angle div just ensures the correct width. */
+          color: transparent;
+          border-left: 1px solid var(--deemphasized-text-color);
+          border-bottom: 1px solid var(--deemphasized-text-color);
+          /* 1px for the border of the .box div. */
+          padding: 0 calc(var(--spacing-s) + 1px);
+        }
+      `,
+    ];
+  }
+
+  render() {
+    if (!this.run) return undefined;
+    if (this.run.isSingleAttempt) return undefined;
+    if (!this.run.attempt) return undefined;
+    const attempt = this.run.attempt;
+
+    return html`
+      <span class="attempt">
+        <div class="box">${attempt}</div>
+        <div class="angle">${attempt}</div>
+      </span>
+    `;
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-checks-attempt': GrChecksAttempt;
+  }
+}
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-results.ts b/polygerrit-ui/app/elements/checks/gr-checks-results.ts
index 57bed1c..7796a6e 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-results.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-results.ts
@@ -17,16 +17,18 @@
 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,
-  internalProperty,
   property,
   PropertyValues,
   query,
+  state,
   TemplateResult,
 } from 'lit-element';
 import {GrLitElement} from '../lit/gr-lit-element';
+import './gr-checks-action';
 import '@polymer/paper-tooltip/paper-tooltip';
 import {
   Action,
@@ -38,36 +40,46 @@
 } from '../../api/checks';
 import {sharedStyles} from '../../styles/shared-styles';
 import {
-  allActions$,
-  checksPatchsetNumber$,
-  someProvidersAreLoading$,
-  RunResult,
   CheckRun,
-  allLinks$,
+  checksSelectedPatchsetNumber$,
+  RunResult,
+  someProvidersAreLoadingSelected$,
+  topLevelActionsSelected$,
+  topLevelLinksSelected$,
 } from '../../services/checks/checks-model';
 import {
   allResults,
   fireActionTriggered,
+  firstPrimaryLink,
   hasCompletedWithoutResults,
-  hasResultsOf,
   iconForCategory,
   iconForLink,
+  otherPrimaryLinks,
+  primaryRunAction,
+  secondaryLinks,
   tooltipForLink,
 } from '../../services/checks/checks-util';
-import {
-  assertIsDefined,
-  check,
-  checkRequiredProperty,
-} from '../../utils/common-util';
+import {assertIsDefined, check} from '../../utils/common-util';
 import {toggleClass, whenVisible} from '../../utils/dom-util';
 import {durationString} from '../../utils/date-util';
-import {charsOnly, pluralize} from '../../utils/string-util';
-import {fireRunSelectionReset, isAttemptSelected} from './gr-checks-util';
+import {charsOnly} from '../../utils/string-util';
+import {isAttemptSelected} from './gr-checks-util';
 import {ChecksTabState} from '../../types/events';
-import {ConfigInfo, PatchSetNumber} from '../../types/common';
-import {latestPatchNum$} from '../../services/change/change-model';
+import {
+  ConfigInfo,
+  LabelNameToInfoMap,
+  PatchSetNumber,
+} from '../../types/common';
+import {labels$, latestPatchNum$} from '../../services/change/change-model';
 import {appContext} from '../../services/app-context';
 import {repoConfig$} from '../../services/config/config-model';
+import {spinnerStyles} from '../../styles/gr-spinner-styles';
+import {
+  getLabelStatus,
+  getRepresentativeValue,
+  valueString,
+} from '../../utils/label-util';
+import {GerritNav} from '../core/gr-navigation/gr-navigation';
 
 @customElement('gr-result-row')
 class GrResultRow extends GrLitElement {
@@ -83,6 +95,14 @@
   @property()
   shouldRender = false;
 
+  @property()
+  labels?: LabelNameToInfoMap;
+
+  constructor() {
+    super();
+    this.subscribe('labels', labels$);
+  }
+
   static get styles() {
     return [
       sharedStyles,
@@ -96,7 +116,7 @@
         gr-result-expanded {
           cursor: default;
         }
-        tr {
+        tr.container {
           border-top: 1px solid var(--border-color);
         }
         iron-icon.link {
@@ -105,33 +125,29 @@
         }
         td.iconCol {
           padding-left: var(--spacing-l);
-          padding-right: var(--spacing-m);
         }
-        .iconCol div {
-          width: 20px;
+        td.nameCol div.flex {
+          display: flex;
         }
-        .nameCol div {
-          width: 165px;
+        td.nameCol .name {
           overflow: hidden;
           text-overflow: ellipsis;
+          margin-right: var(--spacing-s);
         }
-        .nameCol .attempt {
+        td.nameCol .space {
+          flex-grow: 1;
+        }
+        td.nameCol gr-checks-action {
+          display: none;
+        }
+        tr:hover td.nameCol gr-checks-action {
           display: inline-block;
-          background-color: var(--tag-gray);
-          border-radius: var(--line-height-normal);
-          height: var(--line-height-normal);
-          width: var(--line-height-normal);
-          text-align: center;
-          vertical-align: top;
-          font-size: var(--font-size-small);
-        }
-        .summaryCol {
-          /* Forces this column to get the remaining space that is left over by
-             the other columns. */
-          width: 99%;
-        }
-        .expanderCol div {
-          width: 20px;
+          /* The button should fit into the 20px line-height. The negative
+             margin provides the extra space needed for the vertical padding.
+             Alternatively we could have set the vertical padding to 0, but
+             that would not have been a nice click target. */
+          margin: calc(0px - var(--spacing-s)) 0px;
+          margin-left: var(--spacing-s);
         }
         td {
           white-space: nowrap;
@@ -139,7 +155,6 @@
         }
         td .summary-cell {
           display: flex;
-          max-width: calc(100vw - 630px);
         }
         td .summary-cell .summary {
           font-weight: var(--font-weight-bold);
@@ -157,24 +172,30 @@
           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 td .summary-cell .links,
+        tr.container 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 .links,
-        tr.collapsed td .summary-cell .actions {
+        tr.container.collapsed td .summary-cell .message {
+          color: var(--deemphasized-text-color);
+        }
+        tr.container.collapsed td .summary-cell .links,
+        tr.container.collapsed td .summary-cell .actions {
           display: none;
         }
-        tr.collapsed:hover .summary-cell .tags,
-        tr.collapsed:hover .summary-cell .label {
+        tr.container.collapsed:hover .summary-cell .hoverHide.tags,
+        tr.container.collapsed:hover .summary-cell .hoverHide.label {
+          display: none;
+        }
+        tr.detailsRow.collapsed {
           display: none;
         }
         td .summary-cell .tags .tag {
@@ -185,30 +206,22 @@
           padding: 0 var(--spacing-m);
           margin-left: var(--spacing-s);
         }
-        td .summary-cell .label {
-          color: var(--primary-text-color);
-          display: inline-block;
-          border-radius: 20px;
-          background-color: var(--label-background);
-          padding: 0 var(--spacing-m);
-          margin-left: var(--spacing-s);
-        }
-        .tag.gray {
+        td .summary-cell .tag.gray {
           background-color: var(--tag-gray);
         }
-        .tag.yellow {
+        td .summary-cell .tag.yellow {
           background-color: var(--tag-yellow);
         }
-        .tag.pink {
+        td .summary-cell .tag.pink {
           background-color: var(--tag-pink);
         }
-        .tag.purple {
+        td .summary-cell .tag.purple {
           background-color: var(--tag-purple);
         }
-        .tag.cyan {
+        td .summary-cell .tag.cyan {
           background-color: var(--tag-cyan);
         }
-        .tag.brown {
+        td .summary-cell .tag.brown {
           background-color: var(--tag-brown);
         }
         .actions gr-checks-action,
@@ -223,6 +236,36 @@
         #moreMessage {
           display: none;
         }
+        td .summary-cell .label {
+          margin-left: var(--spacing-s);
+          border-radius: var(--border-radius);
+          color: var(--vote-text-color);
+          display: inline-block;
+          padding: 0 var(--spacing-s);
+          text-align: center;
+        }
+        td .summary-cell .label.neutral {
+          background-color: var(--vote-color-neutral);
+        }
+        td .summary-cell .label.recommended,
+        td .summary-cell .label.disliked {
+          line-height: calc(var(--line-height-normal) - 2px);
+          color: var(--chip-color);
+        }
+        td .summary-cell .label.recommended {
+          background-color: var(--vote-color-recommended);
+          border: 1px solid var(--vote-outline-recommended);
+        }
+        td .summary-cell .label.disliked {
+          background-color: var(--vote-color-disliked);
+          border: 1px solid var(--vote-outline-disliked);
+        }
+        td .summary-cell .label.approved {
+          background-color: var(--vote-color-approved);
+        }
+        td .summary-cell .label.rejected {
+          background-color: var(--vote-color-rejected);
+        }
       `,
     ];
   }
@@ -257,29 +300,30 @@
     return html`
       <tr class="${classMap({container: true, collapsed: !this.isExpanded})}">
         <td class="iconCol" @click="${this.toggleExpanded}">
-          <div>${this.renderIcon()}</div>
+          <div>${this.runningIcon()}</div>
         </td>
         <td class="nameCol" @click="${this.toggleExpanded}">
-          <div>
-            <span>${this.result.checkName}</span>
-            <span class="attempt" ?hidden="${this.result.isSingleAttempt}"
-              >${this.result.attempt}</span
-            >
+          <div class="flex">
+            <gr-hovercard-run .run="${this.result}"></gr-hovercard-run>
+            <div class="name">
+              ${this.result.checkName} ${this.renderStatus()}
+            </div>
+            <div class="space"></div>
+            ${this.renderPrimaryRunAction()}
           </div>
         </td>
         <td class="summaryCol">
           <div class="summary-cell">
-            ${(this.result.links?.slice(0, 1) ?? []).map(this.renderLink)}
+            ${this.renderLink(firstPrimaryLink(this.result))}
             ${this.renderSummary(this.result.summary)}
             <div class="message" @click="${this.toggleExpanded}">
               ${this.isExpanded ? '' : this.result.message}
             </div>
-            <div class="tags">
+            <div class="tags ${this.hasLinksOrActions() ? 'hoverHide' : ''}">
               ${(this.result.tags ?? []).map(t => this.renderTag(t))}
             </div>
             ${this.renderLabel()} ${this.renderLinks()} ${this.renderActions()}
           </div>
-          ${this.renderExpanded()}
         </td>
         <td class="expanderCol" @click="${this.toggleExpanded}">
           <div
@@ -301,9 +345,27 @@
           </div>
         </td>
       </tr>
+      <tr class="${classMap({detailsRow: true, collapsed: !this.isExpanded})}">
+        <td></td>
+        <td 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);
+    if (!action) return;
+    return html`<gr-checks-action .action="${action}"></gr-checks-action>`;
+  }
+
   private renderExpanded() {
     if (!this.isExpanded) return;
     return html`<gr-result-expanded
@@ -325,24 +387,50 @@
     `;
   }
 
-  renderIcon() {
+  private renderStatus() {
+    if (this.result?.status !== RunStatus.RUNNING) return;
+    return html`<span>(Running)</span>`;
+  }
+
+  private runningIcon() {
     if (this.result?.status !== RunStatus.RUNNING) return;
     return html`<iron-icon icon="gr-icons:timelapse"></iron-icon>`;
   }
 
   renderLabel() {
+    const category = this.result?.category;
+    if (category !== Category.ERROR && category !== Category.WARNING) return;
     const label = this.result?.labelName;
     if (!label) return;
-    return html`<div class="label">${label}</div>`;
+    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' : '';
+    return html`
+      <div class="label ${status} ${hover}">${label} ${value}</div>
+    `;
   }
 
   renderLinks() {
-    const links = (this.result?.links ?? []).slice(1);
+    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) {
+  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"
       ><iron-icon
@@ -360,6 +448,9 @@
     const overflowItems = actions.slice(2).map(action => {
       return {...action, id: action.name};
     });
+    const disabledItems = overflowItems
+      .filter(action => action.disabled)
+      .map(action => action.id);
     return html`<div class="actions">
       ${this.renderAction(actions[0])} ${this.renderAction(actions[1])}
       <gr-dropdown
@@ -372,6 +463,7 @@
           toggleClass(this, 'dropdown-open', e.detail.value)}"
         ?hidden="${overflowItems.length === 0}"
         .items="${overflowItems}"
+        .disabledIds="${disabledItems}"
       >
         <iron-icon icon="gr-icons:more-vert" aria-labelledby="moreMessage">
         </iron-icon>
@@ -420,10 +512,23 @@
   @property()
   repoConfig?: ConfigInfo;
 
+  private changeService = appContext.changeService;
+
   static get styles() {
     return [
       sharedStyles,
       css`
+        .links {
+          white-space: normal;
+          padding: var(--spacing-s) 0;
+        }
+        .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;
         }
@@ -439,6 +544,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"
@@ -457,20 +564,96 @@
       </gr-endpoint-decorator>
     `;
   }
+
+  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 SHOW_ALL_THRESHOLDS: Map<Category | 'SUCCESS', number> = new Map();
+const SHOW_ALL_THRESHOLDS: Map<Category, number> = new Map();
 SHOW_ALL_THRESHOLDS.set(Category.ERROR, 20);
-SHOW_ALL_THRESHOLDS.set(Category.WARNING, 20);
+SHOW_ALL_THRESHOLDS.set(Category.WARNING, 10);
 SHOW_ALL_THRESHOLDS.set(Category.INFO, 5);
-SHOW_ALL_THRESHOLDS.set('SUCCESS', 5);
+SHOW_ALL_THRESHOLDS.set(Category.SUCCESS, 5);
+
+const CATEGORY_TOOLTIPS: Map<Category, string> = new Map();
+CATEGORY_TOOLTIPS.set(Category.ERROR, 'Must be fixed and is blocking submit');
+CATEGORY_TOOLTIPS.set(
+  Category.WARNING,
+  'Should be checked but is not blocking submit'
+);
+CATEGORY_TOOLTIPS.set(
+  Category.INFO,
+  'Does not have to be checked, for your information only'
+);
+CATEGORY_TOOLTIPS.set(
+  Category.SUCCESS,
+  'Successful runs without results and individual successful results'
+);
 
 @customElement('gr-checks-results')
 export class GrChecksResults extends GrLitElement {
   @query('#filterInput')
   filterInput?: HTMLInputElement;
 
-  @internalProperty()
+  @state()
   filterRegExp = new RegExp('');
 
   /** All runs. Shown should only the selected/filtered ones. */
@@ -510,37 +693,38 @@
   >();
 
   /** Maintains the state of which result sections should show all results. */
-  @internalProperty()
-  isShowAll: Map<Category | 'SUCCESS', boolean> = new Map();
+  @state()
+  isShowAll: Map<Category, boolean> = new Map();
 
   /**
    * This is the current state of whether a section is expanded or not. As long
    * as isSectionExpandedByUser is false this will be computed by a default rule
    * on every render.
    */
-  private isSectionExpanded = new Map<Category | 'SUCCESS', boolean>();
+  private isSectionExpanded = new Map<Category, boolean>();
 
   /**
    * Keeps track of whether the user intentionally changed the expansion state.
    * Once this is true the default rule for showing a section expanded or not
    * is not applied anymore.
    */
-  private isSectionExpandedByUser = new Map<Category | 'SUCCESS', boolean>();
+  private isSectionExpandedByUser = new Map<Category, boolean>();
 
   private readonly checksService = appContext.checksService;
 
   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() {
     return [
       sharedStyles,
+      spinnerStyles,
       css`
         :host {
           display: block;
@@ -553,8 +737,12 @@
             var(--spacing-xl);
           border-bottom: 1px solid var(--border-color);
         }
+        .header.notLatest {
+          background-color: var(--emphasis-color);
+        }
         .headerTopRow,
         .headerBottomRow {
+          max-width: 1600px;
           display: flex;
           justify-content: space-between;
           align-items: flex-end;
@@ -564,15 +752,48 @@
           border-radius: var(--border-radius);
           padding: 0 var(--spacing-m);
         }
+        .headerTopRow h2 {
+          display: inline-block;
+        }
+        .headerTopRow .loading {
+          display: inline-block;
+          margin-left: var(--spacing-m);
+          color: var(--deemphasized-text-color);
+        }
+        /* The basics of .loadingSpin are defined in shared styles. */
+        .headerTopRow .loadingSpin {
+          display: inline-block;
+          margin-left: var(--spacing-s);
+          width: 18px;
+          height: 18px;
+          vertical-align: top;
+        }
         .headerBottomRow {
           margin-top: var(--spacing-s);
         }
+        .headerTopRow .right,
         .headerBottomRow .right {
           display: flex;
           align-items: center;
         }
-        .headerBottomRow .links iron-icon {
+        .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);
+        }
+        .headerBottomRow .space {
+          display: inline-block;
+          width: var(--spacing-xl);
+          height: var(--line-height-normal);
+        }
+        .headerBottomRow a {
           margin-right: var(--spacing-l);
         }
         #moreActions iron-icon {
@@ -598,12 +819,6 @@
         .filterDiv .selection {
           padding: var(--spacing-s) var(--spacing-m);
         }
-        .filterDiv iron-icon.filter {
-          color: var(--selected-foreground);
-        }
-        .filterDiv gr-button.reset {
-          margin: calc(0px - var(--spacing-s)) var(--spacing-l);
-        }
         .categoryHeader {
           margin-top: var(--spacing-l);
           margin-left: var(--spacing-l);
@@ -617,6 +832,9 @@
           height: var(--line-height-h3);
           margin-right: var(--spacing-s);
         }
+        .categoryHeader .statusIconWrapper {
+          display: inline-block;
+        }
         .categoryHeader .statusIcon {
           position: relative;
           top: 2px;
@@ -646,7 +864,7 @@
         }
         .noResultsMessage {
           width: 100%;
-          max-width: 1280px;
+          max-width: 1600px;
           margin-top: var(--spacing-m);
           background-color: var(--background-color-primary);
           box-shadow: var(--elevation-level-1);
@@ -655,7 +873,8 @@
         }
         table.resultsTable {
           width: 100%;
-          max-width: 1280px;
+          max-width: 1600px;
+          table-layout: fixed;
           margin-top: var(--spacing-m);
           background-color: var(--background-color-primary);
           box-shadow: var(--elevation-level-1);
@@ -665,6 +884,19 @@
           font-weight: var(--font-weight-bold);
           padding: var(--spacing-s);
         }
+        th.iconCol {
+          width: 40px;
+        }
+        th.nameCol {
+          width: 200px;
+        }
+        th.summaryCol {
+          width: 99%;
+        }
+        th.expanderCol {
+          width: 30px;
+        }
+
         gr-button.showAll {
           margin: var(--spacing-m);
         }
@@ -696,26 +928,50 @@
   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(() => {
+        // <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>
-          </div>
-          <div class="middle">
-            <span ?hidden="${!this.someProvidersAreLoading}">Loading...</span>
+            <div class="loading" ?hidden="${!this.someProvidersAreLoading}">
+              <span>Loading results </span>
+              <span class="loadingSpin"></span>
+            </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>
@@ -723,24 +979,66 @@
         </div>
         <div class="headerBottomRow">
           <div class="left">${this.renderFilter()}</div>
-          <div class="right">${this.renderLinks()}${this.renderActions()}</div>
+          <div class="right">
+            ${this.renderLinks()}
+            <div class="space"></div>
+            ${this.renderActions()}
+          </div>
         </div>
       </div>
       <div class="body">
         ${this.renderSection(Category.ERROR)}
         ${this.renderSection(Category.WARNING)}
-        ${this.renderSection(Category.INFO)} ${this.renderSection('SUCCESS')}
-      </div>
-    `;
+        ${this.renderSection(Category.INFO)}
+        ${this.renderSection(Category.SUCCESS)}
+      </div>`;
   }
 
   private renderLinks() {
-    const links = (this.links ?? []).slice(0, 4);
+    const links = this.links ?? [];
     if (links.length === 0) return;
-    return html`<div class="links">${links.map(this.renderLink)}</div>`;
+    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)}
+      ${this.renderOverflowLinks(overflowLinks)}
+    `;
   }
 
-  private renderLink(link: Link) {
+  private renderOverflowLinks(overflowLinks: Link[]) {
+    const items = overflowLinks.map(link => {
+      return {
+        ...link,
+        id: link.tooltip,
+        name: link.tooltip,
+        target: '_blank',
+        tooltip: undefined,
+      };
+    });
+    return html`
+      <gr-dropdown
+        id="moreLinks"
+        link=""
+        vertical-offset="32"
+        horizontal-align="right"
+        .items="${items}"
+      >
+        <iron-icon icon="gr-icons:more-vert" aria-labelledby="moreMessage">
+        </iron-icon>
+        <span id="moreMessage">More</span>
+      </gr-dropdown>
+    `;
+  }
+
+  private renderLink(link?: Link) {
+    if (!link) return;
     const tooltipText = link.tooltip ?? tooltipForLink(link.icon);
     return html`<a href="${link.url}" target="_blank"
       ><iron-icon
@@ -753,20 +1051,34 @@
   }
 
   private renderActions() {
-    const overflowItems = this.actions.slice(2).map(action => {
+    const actions = this.actions ?? [];
+    if (actions.length === 0) return;
+    const primaryActions = actions.filter(a => a.primary).slice(0, 2);
+    const overflowActions = actions.filter(a => !primaryActions.includes(a));
+    return html`
+      ${this.renderAction(primaryActions[0])}
+      ${this.renderAction(primaryActions[1])}
+      ${this.renderOverflowActions(overflowActions)}
+    `;
+  }
+
+  private renderOverflowActions(overflowActions: Action[]) {
+    const items = overflowActions.map(action => {
       return {...action, id: action.name};
     });
+    if (!items || items.length === 0) return;
+    const disabledItems = items
+      .filter(action => action.disabled)
+      .map(action => action.id);
     return html`
-      ${this.renderAction(this.actions[0])}
-      ${this.renderAction(this.actions[1])}
       <gr-dropdown
         id="moreActions"
         link=""
         vertical-offset="32"
         horizontal-align="right"
         @tap-item="${this.handleAction}"
-        ?hidden="${overflowItems.length === 0}"
-        .items="${overflowItems}"
+        .items="${items}"
+        .disabledIds="${disabledItems}"
       >
         <iron-icon icon="gr-icons:more-vert" aria-labelledby="moreMessage">
         </iron-icon>
@@ -790,6 +1102,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) => {
@@ -829,43 +1145,20 @@
           placeholder="Filter results by regular expression"
           @input="${this.onInput}"
         />
-        <div class="selection">${this.renderSelectionFilter()}</div>
       </div>
     `;
   }
 
-  renderSelectionFilter() {
-    const count = this.selectedRuns.length;
-    if (count === 0) return;
-    return html`
-      <iron-icon class="filter" icon="gr-icons:filter"></iron-icon>
-      <span>Filtered by ${pluralize(count, 'run')}</span>
-      <gr-button link class="reset" @click="${this.handleClick}"
-        >Reset View</gr-button
-      >
-    `;
-  }
-
-  handleClick() {
-    this.filterRegExp = new RegExp('');
-    fireRunSelectionReset(this);
-  }
-
   onInput() {
     assertIsDefined(this.filterInput, 'filter <input> element');
     this.filterRegExp = new RegExp(this.filterInput.value, 'i');
   }
 
-  renderSection(category: Category | 'SUCCESS') {
+  renderSection(category: Category) {
     const catString = category.toString().toLowerCase();
-    let allRuns = this.runs.filter(run =>
+    const allRuns = this.runs.filter(run =>
       isAttemptSelected(this.selectedAttempts, run)
     );
-    if (category === 'SUCCESS') {
-      allRuns = allRuns.filter(hasCompletedWithoutResults);
-    } else {
-      allRuns = allRuns.filter(r => hasResultsOf(r, category));
-    }
     const all = allRuns.reduce(
       (results: RunResult[], run) => [
         ...results,
@@ -877,7 +1170,8 @@
     const filtered = selected.filter(
       result =>
         this.filterRegExp.test(result.checkName) ||
-        this.filterRegExp.test(result.summary)
+        this.filterRegExp.test(result.summary) ||
+        this.filterRegExp.test(result.message ?? '')
     );
     let expanded = this.isSectionExpanded.get(category);
     const expandedByUser = this.isSectionExpandedByUser.get(category) ?? false;
@@ -904,11 +1198,16 @@
           @click="${() => this.toggleExpanded(category)}"
         >
           <iron-icon class="expandIcon" icon="${icon}"></iron-icon>
-          <iron-icon
-            icon="gr-icons:${iconForCategory(category)}"
-            class="statusIcon ${catString}"
-          ></iron-icon>
-          <span class="title">${catString}</span>
+          <div class="statusIconWrapper">
+            <iron-icon
+              icon="gr-icons:${iconForCategory(category)}"
+              class="statusIcon ${catString}"
+            ></iron-icon>
+            <span class="title">${catString}</span>
+            <paper-tooltip offset="5"
+              >${CATEGORY_TOOLTIPS.get(category)}</paper-tooltip
+            >
+          </div>
           <span class="count"
             >${this.renderCount(all, selected, filtered)}</span
           >
@@ -925,7 +1224,7 @@
   }
 
   renderShowAllButton(
-    category: Category | 'SUCCESS',
+    category: Category,
     isShowAll: boolean,
     showAllThreshold: number,
     resultCount: number
@@ -944,7 +1243,7 @@
     `;
   }
 
-  toggleShowAll(category: Category | 'SUCCESS') {
+  toggleShowAll(category: Category) {
     const current = this.isShowAll.get(category) ?? false;
     this.isShowAll.set(category, !current);
     this.requestUpdate();
@@ -1008,7 +1307,7 @@
     return html`(${filtered.length} of ${all.length})`;
   }
 
-  toggleExpanded(category: Category | 'SUCCESS') {
+  toggleExpanded(category: Category) {
     const expanded = this.isSectionExpanded.get(category);
     assertIsDefined(expanded, 'expanded must have been set in initial render');
     this.isSectionExpanded.set(category, !expanded);
@@ -1016,8 +1315,10 @@
     this.requestUpdate();
   }
 
-  computeRunResults(category: Category | 'SUCCESS', run: CheckRun) {
-    if (category === 'SUCCESS') return [this.computeSuccessfulRunResult(run)];
+  computeRunResults(category: Category, run: CheckRun) {
+    if (category === Category.SUCCESS && hasCompletedWithoutResults(run)) {
+      return [this.computeSuccessfulRunResult(run)];
+    }
     return (
       run.results
         ?.filter(result => result.category === category)
@@ -1030,7 +1331,7 @@
   computeSuccessfulRunResult(run: CheckRun): RunResult {
     const adaptedRun: RunResult = {
       internalResultId: run.internalRunId + '-0',
-      category: Category.INFO, // will not be used, but is required
+      category: Category.SUCCESS,
       summary: run.statusDescription ?? '',
       ...run,
     };
@@ -1056,53 +1357,10 @@
   }
 }
 
-@customElement('gr-checks-action')
-export class GrChecksAction extends GrLitElement {
-  @property()
-  action!: Action;
-
-  connectedCallback() {
-    super.connectedCallback();
-    checkRequiredProperty(this.action, 'action');
-  }
-
-  static get styles() {
-    return [
-      css`
-        :host {
-          display: inline-block;
-        }
-        gr-button {
-          --padding: var(--spacing-s) var(--spacing-m);
-        }
-        gr-button paper-tooltip {
-          text-transform: none;
-        }
-      `,
-    ];
-  }
-
-  render() {
-    return html`
-      <gr-button link class="action" @click="${this.handleClick}">
-        ${this.action.name}
-        <paper-tooltip ?hidden="${!this.action.tooltip}" offset="5"
-          >${this.action.tooltip}</paper-tooltip
-        >
-      </gr-button>
-    `;
-  }
-
-  handleClick() {
-    fireActionTriggered(this, this.action);
-  }
-}
-
 declare global {
   interface HTMLElementTagNameMap {
     'gr-result-row': GrResultRow;
     'gr-result-expanded': GrResultExpanded;
     'gr-checks-results': GrChecksResults;
-    'gr-checks-action': GrChecksAction;
   }
 }
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-runs.ts b/polygerrit-ui/app/elements/checks/gr-checks-runs.ts
index de9c5fb..093eecb 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-runs.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-runs.ts
@@ -20,12 +20,13 @@
 import {
   css,
   customElement,
-  internalProperty,
   property,
   PropertyValues,
   query,
+  state,
 } from 'lit-element';
 import {GrLitElement} from '../lit/gr-lit-element';
+import './gr-checks-attempt';
 import {Action, Link, RunStatus} from '../../api/checks';
 import {sharedStyles} from '../../styles/shared-styles';
 import {
@@ -34,13 +35,16 @@
   fireActionTriggered,
   iconForCategory,
   iconForRun,
+  PRIMARY_STATUS_ACTIONS,
   primaryRunAction,
   worstCategory,
 } from '../../services/checks/checks-util';
 import {
+  allRunsSelectedPatchset$,
   CheckRun,
-  allRuns$,
+  ChecksPatchset,
   fakeActions,
+  fakeLinks,
   fakeRun0,
   fakeRun1,
   fakeRun2,
@@ -50,13 +54,18 @@
   fakeRun4_3,
   fakeRun4_4,
   updateStateSetResults,
-  fakeLinks,
 } from '../../services/checks/checks-model';
 import {assertIsDefined} from '../../utils/common-util';
 import {whenVisible} from '../../utils/dom-util';
-import {fireAttemptSelected, fireRunSelected} from './gr-checks-util';
+import {
+  fireAttemptSelected,
+  fireRunSelected,
+  fireRunSelectionReset,
+} from './gr-checks-util';
 import {ChecksTabState} from '../../types/events';
 import {charsOnly} from '../../utils/string-util';
+import {appContext} from '../../services/app-context';
+import {KnownExperimentId} from '../../services/flags/flags';
 
 @customElement('gr-checks-run')
 export class GrChecksRun extends GrLitElement {
@@ -85,16 +94,6 @@
         .name {
           font-weight: var(--font-weight-bold);
         }
-        .attempt {
-          display: inline-block;
-          background-color: var(--tag-gray);
-          border-radius: var(--line-height-normal);
-          height: var(--line-height-normal);
-          width: var(--line-height-normal);
-          text-align: center;
-          vertical-align: top;
-          font-size: var(--font-size-small);
-        }
         .chip.error {
           border-left: var(--thick-border) solid var(--error-foreground);
         }
@@ -138,17 +137,12 @@
         div.chip.selected iron-icon.filter {
           color: var(--selected-foreground);
         }
-        .chip.selected gr-button.action,
-        .chip.deselected gr-button.action {
-          display: none;
-        }
-        gr-button.action {
-          --padding: var(--spacing-xs) var(--spacing-m);
+        gr-checks-action {
           /* The button should fit into the 20px line-height. The negative
              margin provides the extra space needed for the vertical padding.
              Alternatively we could have set the vertical padding to 0, but
              that would not have been a nice click target. */
-          margin: calc(0px - var(--spacing-xs));
+          margin: calc(0px - var(--spacing-s));
         }
         .attemptDetails {
           padding-bottom: var(--spacing-s);
@@ -233,18 +227,11 @@
           <iron-icon class="${icon}" icon="gr-icons:${icon}"></iron-icon>
           ${this.renderAdditionalIcon()}
           <span class="name">${this.run.checkName}</span>
-          <span class="attempt" ?hidden="${this.run.isSingleAttempt}"
-            >${this.run.attempt}</span
-          >
+          <gr-checks-attempt .run="${this.run}"></gr-checks-attempt>
         </div>
         <div class="right">
           ${action
-            ? html`<gr-button
-                class="action"
-                link
-                @click="${(e: MouseEvent) => this.handleAction(e, action)}"
-                >${action.name}</gr-button
-              >`
+            ? html`<gr-checks-action .action="${action}"></gr-checks-action>`
             : ''}
         </div>
       </div>
@@ -313,12 +300,6 @@
     e.preventDefault();
     fireRunSelected(this, this.run.checkName);
   }
-
-  private handleAction(e: MouseEvent, action: Action) {
-    e.stopPropagation();
-    e.preventDefault();
-    fireActionTriggered(this, action, this.run);
-  }
 }
 
 @customElement('gr-checks-runs')
@@ -326,12 +307,15 @@
   @query('#filterInput')
   filterInput?: HTMLInputElement;
 
-  @internalProperty()
+  @state()
   filterRegExp = new RegExp('');
 
   @property()
   runs: CheckRun[] = [];
 
+  @property({type: Boolean, reflect: true})
+  collapsed = false;
+
   @property()
   selectedRuns: string[] = [];
 
@@ -347,9 +331,11 @@
 
   private isSectionExpanded = new Map<RunStatus, boolean>();
 
+  private flagService = appContext.flagsService;
+
   constructor() {
     super();
-    this.subscribe('runs', allRuns$);
+    this.subscribe('runs', allRunsSelectedPatchset$);
   }
 
   static get styles() {
@@ -358,9 +344,31 @@
       css`
         :host {
           display: block;
+        }
+        :host(:not([collapsed])) {
+          min-width: 320px;
           padding: var(--spacing-l) var(--spacing-xl) var(--spacing-xl)
             var(--spacing-xl);
         }
+        :host([collapsed]) {
+          padding: var(--spacing-l) 0;
+        }
+        .title {
+          display: flex;
+        }
+        .title .flex-space {
+          flex-grow: 1;
+        }
+        .title gr-button {
+          --padding: var(--spacing-s) var(--spacing-m);
+          white-space: nowrap;
+        }
+        .title gr-button.expandButton {
+          --padding: var(--spacing-xs) var(--spacing-s);
+        }
+        :host(:not([collapsed])) .expandButton {
+          margin-right: calc(0px - var(--spacing-m));
+        }
         .expandIcon {
           width: var(--line-height-h3);
           height: var(--line-height-h3);
@@ -420,8 +428,15 @@
   }
 
   render() {
+    if (this.collapsed) {
+      return html`${this.renderCollapseButton()}`;
+    }
     return html`
-      <h2 class="heading-2">Runs</h2>
+      <h2 class="title">
+        <div class="heading-2">Runs</div>
+        <div class="flex-space"></div>
+        ${this.renderTitleButtons()} ${this.renderCollapseButton()}
+      </h2>
       <input
         id="filterInput"
         type="text"
@@ -429,36 +444,68 @@
         ?hidden="${!this.showFilter()}"
         @input="${this.onInput}"
       />
-      ${this.renderSection(RunStatus.COMPLETED)}
       ${this.renderSection(RunStatus.RUNNING)}
-      ${this.renderSection(RunStatus.RUNNABLE)}
-      <div class="testing">
-        <div>Toggle fake runs by clicking buttons:</div>
-        <gr-button link @click="${this.none}">none</gr-button>
-        <gr-button
-          link
-          @click="${() =>
-            this.toggle('f0', [fakeRun0], fakeActions, fakeLinks)}"
-          >0</gr-button
-        >
-        <gr-button link @click="${() => this.toggle('f1', [fakeRun1])}"
-          >1</gr-button
-        >
-        <gr-button link @click="${() => this.toggle('f2', [fakeRun2])}"
-          >2</gr-button
-        >
-        <gr-button link @click="${() => this.toggle('f3', [fakeRun3])}"
-          >3</gr-button
-        >
-        <gr-button
-          link
-          @click="${() => {
-            this.toggle('f4', [fakeRun4_1, fakeRun4_2, fakeRun4_3, fakeRun4_4]);
-          }}"
-          >4</gr-button
-        >
-        <gr-button link @click="${this.all}">all</gr-button>
-      </div>
+      ${this.renderSection(RunStatus.COMPLETED)}
+      ${this.renderSection(RunStatus.RUNNABLE)} ${this.renderFakeControls()}
+    `;
+  }
+
+  private renderTitleButtons() {
+    if (this.selectedRuns.length < 2) return;
+    const actions = this.selectedRuns.map(selected => {
+      const run = this.runs.find(
+        run => run.isLatestAttempt && run.checkName === selected
+      );
+      return primaryRunAction(run);
+    });
+    const runButtonDisabled = !actions.every(
+      action =>
+        action?.name === PRIMARY_STATUS_ACTIONS.RUN ||
+        action?.name === PRIMARY_STATUS_ACTIONS.RERUN
+    );
+    return html`
+      <gr-button
+        class="font-normal"
+        link
+        @click="${() => fireRunSelectionReset(this)}"
+        >Unselect All</gr-button
+      >
+      <gr-button
+        class="font-normal"
+        link
+        title="${runButtonDisabled
+          ? 'Disabled. Unselect checks without a "Run" action to enable the button.'
+          : ''}"
+        has-tooltip="${runButtonDisabled}"
+        ?disabled="${runButtonDisabled}"
+        @click="${() => {
+          actions.forEach(action => fireActionTriggered(this, action));
+        }}"
+        >Run Selected</gr-button
+      >
+    `;
+  }
+
+  private renderCollapseButton() {
+    return html`
+      <gr-button
+        link
+        class="expandButton"
+        role="switch"
+        ?aria-checked="${this.collapsed}"
+        aria-label="${this.collapsed
+          ? 'Expand runs panel'
+          : 'Collapse runs panel'}"
+        has-tooltip="true"
+        title="${this.collapsed ? 'Expand runs panel' : 'Collapse runs panel'}"
+        @click="${() => (this.collapsed = !this.collapsed)}"
+        ><iron-icon
+          class="expandIcon"
+          icon="${this.collapsed
+            ? 'gr-icons:chevron-right'
+            : 'gr-icons:chevron-left'}"
+        ></iron-icon>
+      </gr-button>
     `;
   }
 
@@ -468,24 +515,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(
@@ -495,7 +549,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) {
@@ -547,6 +607,39 @@
     }
     return show;
   }
+
+  renderFakeControls() {
+    if (!this.flagService.isEnabled(KnownExperimentId.CHECKS_DEVELOPER)) return;
+    return html`
+      <div class="testing">
+        <div>Toggle fake runs by clicking buttons:</div>
+        <gr-button link @click="${this.none}">none</gr-button>
+        <gr-button
+          link
+          @click="${() =>
+            this.toggle('f0', [fakeRun0], fakeActions, fakeLinks)}"
+          >0</gr-button
+        >
+        <gr-button link @click="${() => this.toggle('f1', [fakeRun1])}"
+          >1</gr-button
+        >
+        <gr-button link @click="${() => this.toggle('f2', [fakeRun2])}"
+          >2</gr-button
+        >
+        <gr-button link @click="${() => this.toggle('f3', [fakeRun3])}"
+          >3</gr-button
+        >
+        <gr-button
+          link
+          @click="${() => {
+            this.toggle('f4', [fakeRun4_1, fakeRun4_2, fakeRun4_3, fakeRun4_4]);
+          }}"
+          >4</gr-button
+        >
+        <gr-button link @click="${this.all}">all</gr-button>
+      </div>
+    `;
+  }
 }
 
 declare global {
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-tab.ts b/polygerrit-ui/app/elements/checks/gr-checks-tab.ts
index 418598b..e7a7659 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-tab.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-tab.ts
@@ -15,25 +15,19 @@
  * limitations under the License.
  */
 import {html} from 'lit-html';
-import {
-  css,
-  customElement,
-  internalProperty,
-  property,
-  PropertyValues,
-} from 'lit-element';
+import {css, customElement, property, PropertyValues, state} from 'lit-element';
 import {GrLitElement} from '../lit/gr-lit-element';
 import {Action} from '../../api/checks';
 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';
@@ -62,13 +56,16 @@
   checksPatchsetNumber: PatchSetNumber | undefined = undefined;
 
   @property()
+  latestPatchsetNumber: PatchSetNumber | undefined = undefined;
+
+  @property()
   changeNum: NumericChangeId | undefined = undefined;
 
-  @internalProperty()
+  @state()
   selectedRuns: string[] = [];
 
   /** Maps checkName to selected attempt number. `undefined` means `latest`. */
-  @internalProperty()
+  @state()
   selectedAttempts: Map<string, number | undefined> = new Map<
     string,
     number | undefined
@@ -78,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) =>
@@ -97,7 +95,6 @@
         display: flex;
       }
       .runs {
-        min-width: 300px;
         min-height: 400px;
         border-right: 1px solid var(--border-color);
       }
@@ -142,10 +139,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 ae6408d..10f036e 100644
--- a/polygerrit-ui/app/elements/checks/gr-hovercard-run.ts
+++ b/polygerrit-ui/app/elements/checks/gr-hovercard-run.ts
@@ -19,6 +19,7 @@
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-hovercard-run_html';
 import {customElement, property} from '@polymer/decorators';
+import './gr-checks-action';
 import {CheckRun} from '../../services/checks/checks-model';
 import {
   iconForCategory,
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 08ceefe..c4402f5 100644
--- a/polygerrit-ui/app/elements/checks/gr-hovercard-run_html.ts
+++ b/polygerrit-ui/app/elements/checks/gr-hovercard-run_html.ts
@@ -202,7 +202,12 @@
       </div>
     </div>
     <template is="dom-repeat" items="[[computeActions(run)]]">
-      <div class="action"><gr-button link>[[item.name]]</gr-button></div>
+      <div class="action">
+        <gr-checks-action
+          event-target="[[_target]]"
+          action="[[item]]"
+        ></gr-checks-action>
+      </div>
     </template>
   </div>
 `;
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-manager/gr-error-manager.ts b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.ts
index 4985c1b..8f9dfe2 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
@@ -217,6 +217,8 @@
             url,
             trace,
           });
+        } else if (response.status === 429) {
+          this._showQuotaExceeded({status, statusText});
         } else {
           this._showErrorDialog(
             this._constructServerErrorMsg({
@@ -229,7 +231,7 @@
           );
         }
       }
-      console.info(`server error: ${errorText}`);
+      this.reporting.error(new Error(`Server error: ${errorText}`));
     });
   };
 
@@ -260,6 +262,19 @@
     });
   }
 
+  _showQuotaExceeded({status, statusText}: ErrorMsg) {
+    const tip = 'Try again later';
+    const errorText = 'Too many requests from this client';
+    this._showErrorDialog(
+      this._constructServerErrorMsg({
+        status,
+        statusText,
+        errorText,
+        tip,
+      })
+    );
+  }
+
   _constructServerErrorMsg({
     errorText,
     status,
@@ -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
index 7d643a0..fe4d9da 100644
--- 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
@@ -21,6 +21,7 @@
 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');
 
@@ -40,6 +41,8 @@
           .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');
@@ -204,7 +207,6 @@
     });
 
     test('show network error', done => {
-      const consoleErrorStub = sinon.stub(console, 'error');
       const showAlertStub = sinon.stub(element, '_showAlert');
       element.dispatchEvent(
           new CustomEvent('network-error', {
@@ -215,8 +217,6 @@
         assert.isTrue(showAlertStub.calledOnce);
         assert.isTrue(showAlertStub.lastCall.calledWithExactly(
             'Server unavailable'));
-        assert.isTrue(consoleErrorStub.calledOnce);
-        assert.isTrue(consoleErrorStub.lastCall.calledWithExactly('ZOMG'));
         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-navigation/gr-navigation.ts b/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.ts
index fa81103..f8ee589 100644
--- a/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.ts
+++ b/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.ts
@@ -130,7 +130,6 @@
   name: string;
   query: string;
   suffixForDashboard?: string;
-  attentionSetOnly?: boolean;
   selfOnly?: boolean;
   hideIfEmpty?: boolean;
   assigneeOnly?: boolean;
@@ -165,7 +164,6 @@
   query: 'attention:${user}',
   hideIfEmpty: false,
   suffixForDashboard: 'limit:25',
-  attentionSetOnly: true,
 };
 const ASSIGNED: DashboardSection = {
   // Changes that are assigned to the viewed user.
@@ -359,6 +357,19 @@
   commit?: CommitId;
   options?: GenerateWebLinksOptions;
 }
+export interface GenerateWebLinksResolveConflictsParameters {
+  type: WeblinkType.RESOLVE_CONFLICTS;
+  repo: RepoName;
+  commit?: CommitId;
+  options?: GenerateWebLinksOptions;
+}
+export interface GenerateWebLinksEditParameters {
+  type: WeblinkType.EDIT;
+  repo: RepoName;
+  commit: CommitId;
+  file: string;
+  options?: GenerateWebLinksOptions;
+}
 export interface GenerateWebLinksFileParameters {
   type: WeblinkType.FILE;
   repo: RepoName;
@@ -375,11 +386,14 @@
 
 export type GenerateWebLinksParameters =
   | GenerateWebLinksPatchsetParameters
+  | GenerateWebLinksResolveConflictsParameters
+  | GenerateWebLinksEditParameters
   | GenerateWebLinksFileParameters
   | GenerateWebLinksChangeParameters;
 
 export type NavigateCallback = (target: string, redirect?: boolean) => void;
 export type GenerateUrlCallback = (params: GenerateUrlParameters) => string;
+// TODO: Refactor to return only GeneratedWebLink[]
 export type GenerateWebLinksCallback = (
   params: GenerateWebLinksParameters
 ) => GeneratedWebLink[] | GeneratedWebLink;
@@ -413,8 +427,10 @@
 
 export enum WeblinkType {
   CHANGE = 'change',
+  EDIT = 'edit',
   FILE = 'file',
   PATCHSET = 'patchset',
+  RESOLVE_CONFLICTS = 'resolve-conflicts',
 }
 
 // TODO(dmfilippov) Convert to class, extract consts, give better name and
@@ -889,6 +905,24 @@
     return this._getUrlFor({view: GerritView.SETTINGS});
   },
 
+  getEditWebLinks(
+    repo: RepoName,
+    commit: CommitId,
+    file: string,
+    options?: GenerateWebLinksOptions
+  ): GeneratedWebLink[] {
+    const params: GenerateWebLinksEditParameters = {
+      type: WeblinkType.EDIT,
+      repo,
+      commit,
+      file,
+    };
+    if (options) {
+      params.options = options;
+    }
+    return ([] as GeneratedWebLink[]).concat(this._generateWeblinks(params));
+  },
+
   getFileWebLinks(
     repo: RepoName,
     commit: CommitId,
@@ -931,6 +965,22 @@
     }
   },
 
+  getResolveConflictsWeblinks(
+    repo: RepoName,
+    commit?: CommitId,
+    options?: GenerateWebLinksOptions
+  ): GeneratedWebLink[] {
+    const params: GenerateWebLinksResolveConflictsParameters = {
+      type: WeblinkType.RESOLVE_CONFLICTS,
+      repo,
+      commit,
+    };
+    if (options) {
+      params.options = options;
+    }
+    return ([] as GeneratedWebLink[]).concat(this._generateWeblinks(params));
+  },
+
   getChangeWeblinks(
     repo: RepoName,
     commit: CommitId,
@@ -953,11 +1003,8 @@
     title = '',
     config: UserDashboardConfig = {}
   ): UserDashboard {
-    const attentionEnabled =
-      config.change && !!config.change.enable_attention_set;
     const assigneeEnabled = config.change && !!config.change.enable_assignee;
     sections = sections
-      .filter(section => attentionEnabled || !section.attentionSetOnly)
       .filter(section => assigneeEnabled || !section.assigneeOnly)
       .filter(section => user === 'self' || !section.selfOnly)
       .map(section => {
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 4aefc16..aa9b299 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
@@ -33,9 +33,11 @@
   GenerateUrlRepoViewParameters,
   GenerateUrlSearchViewParameters,
   GenerateWebLinksChangeParameters,
+  GenerateWebLinksEditParameters,
   GenerateWebLinksFileParameters,
   GenerateWebLinksParameters,
   GenerateWebLinksPatchsetParameters,
+  GenerateWebLinksResolveConflictsParameters,
   GerritNav,
   GroupDetailView,
   isGenerateUrlDiffViewParameters,
@@ -382,12 +384,16 @@
     params: GenerateWebLinksParameters
   ): GeneratedWebLink[] | GeneratedWebLink {
     switch (params.type) {
+      case WeblinkType.EDIT:
+        return this._getEditWebLinks(params);
       case WeblinkType.FILE:
         return this._getFileWebLinks(params);
       case WeblinkType.CHANGE:
         return this._getChangeWeblinks(params);
       case WeblinkType.PATCHSET:
         return this._getPatchSetWeblink(params);
+      case WeblinkType.RESOLVE_CONFLICTS:
+        return this._getResolveConflictsWeblinks(params);
       default:
         // eslint-disable-next-line @typescript-eslint/no-explicit-any
         assertNever(params, `Unsupported weblink ${(params as any).type}!`);
@@ -408,6 +414,12 @@
     }
   }
 
+  _getResolveConflictsWeblinks(
+    params: GenerateWebLinksResolveConflictsParameters
+  ): GeneratedWebLink[] {
+    return params.options?.weblinks ?? [];
+  }
+
   _firstCodeBrowserWeblink(weblinks: GeneratedWebLink[]) {
     // This is an ordered allowed list of web link types that provide direct
     // links to the commit in the url property.
@@ -457,8 +469,12 @@
     );
   }
 
+  _getEditWebLinks(params: GenerateWebLinksEditParameters): GeneratedWebLink[] {
+    return params.options?.weblinks ?? [];
+  }
+
   _getFileWebLinks(params: GenerateWebLinksFileParameters): GeneratedWebLink[] {
-    return params.options?.weblinks || [];
+    return params.options?.weblinks ?? [];
   }
 
   _generateSearchUrl(params: GenerateUrlSearchViewParameters) {
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 9c63f8b..a3ddff3 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
@@ -34,6 +34,7 @@
 import {CustomKeyboardEvent} from '../../../types/events';
 import {MergeabilityComputationBehavior} from '../../../constants/constants';
 import {appContext} from '../../../services/app-context';
+import {getKeyboardEvent} from '../../../utils/dom-util';
 
 // Possible static search options for auto complete, without negations.
 const SEARCH_OPERATORS: ReadonlyArray<string> = [
@@ -385,7 +386,7 @@
   }
 
   _handleSearch(e: CustomKeyboardEvent) {
-    const keyboardEvent = this.getKeyboardEvent(e);
+    const keyboardEvent = getKeyboardEvent(e);
     if (
       this.shouldSuppressKeyboardShortcut(e) ||
       (this.modifierPressed(e) && !keyboardEvent.shiftKey)
diff --git a/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.ts b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.ts
index 0bf9f1c..5cd7bfc 100644
--- a/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.ts
+++ b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.ts
@@ -40,6 +40,8 @@
 import {fireCloseFixPreview, fireEvent} from '../../../utils/event-util';
 import {ParsedChangeInfo} from '../../../types/types';
 import {GrButton} from '../../shared/gr-button/gr-button';
+import {TokenHighlightLayer} from '../gr-diff-builder/token-highlight-layer';
+import {KnownExperimentId} from '../../../services/flags/flags';
 
 export interface GrApplyFixDialog {
   $: {
@@ -97,6 +99,12 @@
   })
   _disableApplyFixButton?: boolean;
 
+  layers = appContext.flagsService.isEnabled(
+    KnownExperimentId.TOKEN_HIGHLIGHTING
+  )
+    ? [new TokenHighlightLayer()]
+    : [];
+
   private refitOverlay?: () => void;
 
   private readonly restApiService = appContext.restApiService;
diff --git a/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog_html.ts b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog_html.ts
index 52fa9841..b0716dd 100644
--- a/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog_html.ts
+++ b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog_html.ts
@@ -31,10 +31,6 @@
       background-color: var(--background-color-secondary);
       border-bottom: 1px solid var(--border-color);
     }
-    .fixActions {
-      display: flex;
-      justify-content: flex-end;
-    }
     gr-button {
       margin-left: var(--spacing-m);
     }
@@ -65,6 +61,7 @@
               change-num="[[changeNum]]"
               path="[[item.filepath]]"
               diff="[[item.preview]]"
+              layers="[[layers]]"
             ></gr-diff>
           </div>
         </template>
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 8cc7a3f..73afde8 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
@@ -51,6 +51,7 @@
 import {appContext} from '../../../services/app-context';
 import {CommentSide, Side} from '../../../constants/constants';
 import {pluralize} from '../../../utils/string-util';
+import {NormalizedFileInfo} from '../../change/gr-file-list/gr-file-list';
 
 export type CommentIdToCommentThreadMap = {
   [urlEncodedCommentId: string]: CommentThread;
@@ -522,6 +523,33 @@
       .length;
   }
 
+  computeDraftCountForFile(patchRange?: PatchRange, file?: NormalizedFileInfo) {
+    if (patchRange === undefined || file === undefined) {
+      return 0;
+    }
+    const getCommentForPath = (path?: string) => {
+      if (!path) return 0;
+      return (
+        this.computeDraftCount({
+          patchNum: patchRange.basePatchNum,
+          path,
+        }) +
+        this.computeDraftCount({
+          patchNum: patchRange.patchNum,
+          path,
+        }) +
+        this.computePortedDraftCount(
+          {
+            patchNum: patchRange.patchNum,
+            basePatchNum: patchRange.basePatchNum,
+          },
+          path
+        )
+      );
+    };
+    return getCommentForPath(file.__path) + getCommentForPath(file.old_path);
+  }
+
   /**
    * @param includeUnmodified Included unmodified status of the file in the
    * comment string or not. For files we opt of chip instead of a string.
@@ -537,6 +565,14 @@
     if (!patchRange) return '';
 
     const threads = this.getThreadsBySideForFile({path}, patchRange);
+    if (changeFileInfo?.old_path) {
+      threads.push(
+        ...this.getThreadsBySideForFile(
+          {path: changeFileInfo.old_path},
+          patchRange
+        )
+      );
+    }
     const commentThreadCount = threads.filter(thread => !isDraftThread(thread))
       .length;
     const unresolvedCount = threads.reduce((cnt, thread) => {
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
new file mode 100644
index 0000000..c502346
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-context-controls/gr-context-controls.ts
@@ -0,0 +1,549 @@
+/**
+ * @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 '@polymer/paper-button/paper-button';
+import '@polymer/paper-card/paper-card';
+import '@polymer/paper-checkbox/paper-checkbox';
+import '@polymer/paper-dropdown-menu/paper-dropdown-menu';
+import '@polymer/paper-fab/paper-fab';
+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 {of, EMPTY, Subject} from 'rxjs';
+import {switchMap, delay, takeUntil} from 'rxjs/operators';
+
+import '../../shared/gr-button/gr-button';
+import {pluralize} from '../../../utils/string-util';
+import {fire} from '../../../utils/event-util';
+import {DiffInfo} from '../../../types/diff';
+import {assertIsDefined} from '../../../utils/common-util';
+import {
+  css,
+  customElement,
+  html,
+  LitElement,
+  property,
+  TemplateResult,
+} from 'lit-element';
+
+import {
+  ContextButtonType,
+  DiffContextButtonHoveredDetail,
+  RenderPreferences,
+  SyntaxBlock,
+} from '../../../api/diff';
+
+import {GrDiffGroup, hideInContextControl} from '../gr-diff/gr-diff-group';
+
+declare global {
+  interface HTMLElementEventMap {
+    'diff-context-button-hovered': CustomEvent<DiffContextButtonHoveredDetail>;
+  }
+}
+
+const PARTIAL_CONTEXT_AMOUNT = 10;
+
+/**
+ * Traverses a hierarchical structure of syntax blocks and
+ * finds the most local/nested block that can be associated line.
+ * It finds the closest block that contains the whole line and
+ * returns the whole path from the syntax layer (blocks) sent as parameter
+ * to the most nested block - the complete path from the top to bottom layer of
+ * a syntax tree. Example: [myNamepace, MyClass, myMethod1, aLocalFunctionInsideMethod1]
+ *
+ * @param lineNum line number for the targeted line.
+ * @param blocks Blocks for a specific syntax level in the file (to allow recursive calls)
+ */
+function findBlockTreePathForLine(
+  lineNum: number,
+  blocks?: SyntaxBlock[]
+): SyntaxBlock[] {
+  const containingBlock = blocks?.find(
+    ({range}) => range.start_line < lineNum && range.end_line > lineNum
+  );
+  if (!containingBlock) return [];
+  const innerPathInChild = findBlockTreePathForLine(
+    lineNum,
+    containingBlock?.children
+  );
+  return [containingBlock].concat(innerPathInChild);
+}
+
+export type GrContextControlsShowConfig = 'above' | 'below' | 'both';
+
+@customElement('gr-context-controls')
+export class GrContextControls extends LitElement {
+  @property({type: Object}) renderPreferences?: RenderPreferences;
+
+  @property({type: Object}) diff?: DiffInfo;
+
+  @property({type: Object}) section?: HTMLElement;
+
+  @property({type: Object}) contextGroups: GrDiffGroup[] = [];
+
+  @property({type: String, reflect: true})
+  showConfig: GrContextControlsShowConfig = 'both';
+
+  private expandButtonsHover = new Subject<{
+    eventType: 'enter' | 'leave';
+    buttonType: ContextButtonType;
+    linesToExpand: number;
+  }>();
+
+  private disconnected$ = new Subject();
+
+  static styles = css`
+    :host {
+      display: flex;
+      justify-content: center;
+      flex-direction: column;
+      position: relative;
+    }
+
+    :host([showConfig='above']) {
+      justify-content: flex-end;
+      margin-top: calc(-1px - var(--line-height-normal) - var(--spacing-s));
+      margin-bottom: var(--gr-context-controls-margin-bottom);
+      height: calc(var(--line-height-normal) + var(--spacing-s));
+      .horizontalFlex {
+        align-items: end;
+      }
+    }
+
+    :host([showConfig='below']) {
+      justify-content: flex-start;
+      margin-top: 1px;
+      margin-bottom: calc(0px - var(--line-height-normal) - var(--spacing-s));
+      .horizontalFlex {
+        align-items: start;
+      }
+    }
+
+    :host([showConfig='both']) {
+      margin-top: calc(0px - var(--line-height-normal) - var(--spacing-s));
+      margin-bottom: calc(0px - var(--line-height-normal) - var(--spacing-s));
+      height: calc(
+        2 * var(--line-height-normal) + 2 * var(--spacing-s) +
+          var(--divider-height)
+      );
+      .horizontalFlex {
+        align-items: center;
+      }
+    }
+
+    .contextControlButton {
+      background-color: var(--default-button-background-color);
+      font: var(--context-control-button-font, inherit);
+    }
+
+    paper-button {
+      text-transform: none;
+      align-items: center;
+      background-color: var(--background-color);
+      font-family: inherit;
+      margin: var(--margin, 0);
+      min-width: var(--border, 0);
+      color: var(--diff-context-control-color);
+      border: solid var(--border-color);
+      border-width: 1px;
+      border-radius: var(--border-radius);
+      padding: var(--spacing-s) var(--spacing-l);
+    }
+
+    paper-button:hover {
+      /* same as defined in gr-button */
+      background: rgba(0, 0, 0, 0.12);
+    }
+
+    .aboveBelowButtons {
+      display: flex;
+      flex-direction: column;
+      justify-content: center;
+      margin-left: var(--spacing-m);
+      position: relative;
+    }
+    .aboveBelowButtons:first-child {
+      margin-left: 0;
+      /* Places a default background layer behind the "all button" that can have opacity */
+      background-color: var(--default-button-background-color);
+    }
+
+    .horizontalFlex {
+      display: flex;
+      justify-content: center;
+      align-items: var(--gr-context-controls-horizontal-align-items, center);
+    }
+
+    .aboveButton {
+      border-bottom-width: 0;
+      border-bottom-right-radius: 0;
+      border-bottom-left-radius: 0;
+      padding: var(--spacing-xxs) var(--spacing-l);
+    }
+    .belowButton {
+      border-top-width: 0;
+      border-top-left-radius: 0;
+      border-top-right-radius: 0;
+      padding: var(--spacing-xxs) var(--spacing-l);
+      margin-top: calc(var(--divider-height) + 2 * var(--spacing-xxs));
+    }
+    .belowButton:first-child {
+      margin-top: 0;
+    }
+    .breadcrumbTooltip {
+      white-space: nowrap;
+    }
+  `;
+
+  connectedCallback() {
+    super.connectedCallback();
+    this.setupButtonHoverHandler();
+  }
+
+  disconnectedCallback() {
+    this.disconnected$.next();
+  }
+
+  private showBoth() {
+    return this.showConfig === 'both';
+  }
+
+  private showAbove() {
+    return this.showBoth() || this.showConfig === 'above';
+  }
+
+  private showBelow() {
+    return this.showBoth() || this.showConfig === 'below';
+  }
+
+  setupButtonHoverHandler() {
+    this.expandButtonsHover
+      .pipe(
+        switchMap(e => {
+          if (e.eventType === 'leave') {
+            // cancel any previous delay
+            // for mouse enter
+            return EMPTY;
+          }
+          return of(e).pipe(delay(500));
+        }),
+        takeUntil(this.disconnected$)
+      )
+      .subscribe(({buttonType, linesToExpand}) => {
+        fire(this, 'diff-context-button-hovered', {
+          buttonType,
+          linesToExpand,
+        });
+      });
+  }
+
+  private numLines() {
+    const {leftStart, leftEnd} = this.contextRange();
+    return leftEnd - leftStart + 1;
+  }
+
+  private createExpandAllButtonContainer() {
+    return html` <div
+      class="style-scope gr-diff aboveBelowButtons fullExpansion"
+    >
+      ${this.createContextButton(ContextButtonType.ALL, this.numLines())}
+    </div>`;
+  }
+
+  /**
+   * Creates a specific expansion button (e.g. +X common lines, +10, +Block).
+   */
+  private createContextButton(
+    type: ContextButtonType,
+    linesToExpand: number,
+    tooltip?: TemplateResult
+  ) {
+    let text = '';
+    let groups: GrDiffGroup[] = []; // The groups that replace this one if tapped.
+    let ariaLabel = '';
+    let classes = 'contextControlButton showContext ';
+
+    if (type === ContextButtonType.ALL) {
+      text = `+${pluralize(linesToExpand, 'common line')}`;
+      ariaLabel = `Show ${pluralize(linesToExpand, 'common line')}`;
+      classes += this.showBoth()
+        ? 'centeredButton'
+        : this.showAbove()
+        ? 'aboveButton'
+        : 'belowButton';
+      if (this.partialContent) {
+        // Expanding content would require load of more data
+        text += ' (too large)';
+      }
+      groups.push(...this.contextGroups);
+    } else if (type === ContextButtonType.ABOVE) {
+      groups = hideInContextControl(
+        this.contextGroups,
+        linesToExpand,
+        this.numLines()
+      );
+      text = `+${linesToExpand}`;
+      classes += 'aboveButton';
+      ariaLabel = `Show ${pluralize(linesToExpand, 'line')} above`;
+    } else if (type === ContextButtonType.BELOW) {
+      groups = hideInContextControl(
+        this.contextGroups,
+        0,
+        this.numLines() - linesToExpand
+      );
+      text = `+${linesToExpand}`;
+      classes += 'belowButton';
+      ariaLabel = `Show ${pluralize(linesToExpand, 'line')} below`;
+    } else if (type === ContextButtonType.BLOCK_ABOVE) {
+      groups = hideInContextControl(
+        this.contextGroups,
+        linesToExpand,
+        this.numLines()
+      );
+      text = '+Block';
+      classes += 'aboveButton';
+      ariaLabel = 'Show block above';
+    } else if (type === ContextButtonType.BLOCK_BELOW) {
+      groups = hideInContextControl(
+        this.contextGroups,
+        0,
+        this.numLines() - linesToExpand
+      );
+      text = '+Block';
+      classes += 'belowButton';
+      ariaLabel = 'Show block below';
+    }
+    const expandHandler = this.createExpansionHandler(
+      linesToExpand,
+      type,
+      groups
+    );
+
+    const mouseHandler = (eventType: 'enter' | 'leave') => {
+      this.expandButtonsHover.next({
+        eventType,
+        buttonType: type,
+        linesToExpand,
+      });
+    };
+
+    const button = html` <paper-button
+      class="${classes}"
+      aria-label="${ariaLabel}"
+      @click="${expandHandler}"
+      @mouseenter="${() => mouseHandler('enter')}"
+      @mouseleave="${() => mouseHandler('leave')}"
+    >
+      <span class="showContext">${text}</span>
+      ${tooltip}
+    </paper-button>`;
+    return button;
+  }
+
+  private createExpansionHandler(
+    linesToExpand: number,
+    type: ContextButtonType,
+    groups: GrDiffGroup[]
+  ) {
+    return (e: Event) => {
+      e.stopPropagation();
+      if (type === ContextButtonType.ALL && this.partialContent) {
+        const {leftStart, leftEnd, rightStart, rightEnd} = this.contextRange();
+        const lineRange = {
+          left: {
+            start_line: leftStart,
+            end_line: leftEnd,
+          },
+          right: {
+            start_line: rightStart,
+            end_line: rightEnd,
+          },
+        };
+        fire(this, 'content-load-needed', {
+          lineRange,
+        });
+      } else {
+        assertIsDefined(this.section, 'section');
+        fire(this, 'diff-context-expanded', {
+          groups,
+          section: this.section!,
+          numLines: this.numLines(),
+          buttonType: type,
+          expandedLines: linesToExpand,
+        });
+      }
+    };
+  }
+
+  private showPartialLinks() {
+    return this.numLines() > PARTIAL_CONTEXT_AMOUNT;
+  }
+
+  /**
+   * Creates a container div with partial (+10) expansion buttons (above and/or below).
+   */
+  private createPartialExpansionButtons() {
+    if (!this.showPartialLinks()) {
+      return undefined;
+    }
+    let aboveButton;
+    let belowButton;
+    if (this.showAbove()) {
+      aboveButton = this.createContextButton(
+        ContextButtonType.ABOVE,
+        PARTIAL_CONTEXT_AMOUNT
+      );
+    }
+    if (this.showBelow()) {
+      belowButton = this.createContextButton(
+        ContextButtonType.BELOW,
+        PARTIAL_CONTEXT_AMOUNT
+      );
+    }
+    return aboveButton || belowButton
+      ? html` <div class="aboveBelowButtons partialExpansion">
+          ${aboveButton} ${belowButton}
+        </div>`
+      : undefined;
+  }
+
+  /**
+   * Checks if the collapsed section contains unavailable content (skip chunks).
+   */
+  private get partialContent() {
+    return this.contextGroups.some(c => !!c.skip);
+  }
+
+  /**
+   * Creates a container div with block expansion buttons (above and/or below).
+   */
+  private createBlockExpansionButtons() {
+    if (
+      !this.showPartialLinks() ||
+      !this.renderPreferences?.use_block_expansion ||
+      this.partialContent
+    ) {
+      return undefined;
+    }
+    let aboveBlockButton;
+    let belowBlockButton;
+    if (this.showAbove()) {
+      aboveBlockButton = this.createBlockButton(
+        ContextButtonType.BLOCK_ABOVE,
+        this.numLines(),
+        this.contextRange().rightStart - 1
+      );
+    }
+    if (this.showBelow()) {
+      belowBlockButton = this.createBlockButton(
+        ContextButtonType.BLOCK_BELOW,
+        this.numLines(),
+        this.contextRange().rightEnd + 1
+      );
+    }
+    if (aboveBlockButton || belowBlockButton) {
+      return html` <div class="aboveBelowButtons blockExpansion">
+        ${aboveBlockButton} ${belowBlockButton}
+      </div>`;
+    }
+    return undefined;
+  }
+
+  private createBlockButtonTooltip(
+    buttonType: ContextButtonType,
+    syntaxPath: SyntaxBlock[],
+    linesToExpand: number
+  ) {
+    // Create breadcrumb string:
+    // myNamepace > MyClass > myMethod1 > aLocalFunctionInsideMethod1 > (anonymous)
+    const tooltipText = syntaxPath.length
+      ? syntaxPath.map(b => b.name || '(anonymous)').join(' > ')
+      : `${linesToExpand} common lines`;
+
+    const position =
+      buttonType === ContextButtonType.BLOCK_ABOVE ? 'top' : 'bottom';
+    return html`<paper-tooltip offset="10" position="${position}"
+      ><div class="breadcrumbTooltip">${tooltipText}</div></paper-tooltip
+    >`;
+  }
+
+  private createBlockButton(
+    buttonType: ContextButtonType,
+    numLines: number,
+    referenceLine: number
+  ) {
+    assertIsDefined(this.diff, 'diff');
+    const syntaxTree = this.diff!.meta_b.syntax_tree;
+    const outlineSyntaxPath = findBlockTreePathForLine(
+      referenceLine,
+      syntaxTree
+    );
+    let linesToExpand = numLines;
+    if (outlineSyntaxPath.length) {
+      const {range} = outlineSyntaxPath[outlineSyntaxPath.length - 1];
+      const targetLine =
+        buttonType === ContextButtonType.BLOCK_ABOVE
+          ? range.end_line
+          : range.start_line;
+      const distanceToTargetLine = Math.abs(targetLine - referenceLine);
+      if (distanceToTargetLine < numLines) {
+        linesToExpand = distanceToTargetLine;
+      }
+    }
+    const tooltip = this.createBlockButtonTooltip(
+      buttonType,
+      outlineSyntaxPath,
+      linesToExpand
+    );
+    return this.createContextButton(buttonType, linesToExpand, tooltip);
+  }
+
+  private contextRange() {
+    return {
+      leftStart: this.contextGroups[0].lineRange.left.start_line,
+      leftEnd: this.contextGroups[this.contextGroups.length - 1].lineRange.left
+        .end_line,
+      rightStart: this.contextGroups[0].lineRange.right.start_line,
+      rightEnd: this.contextGroups[this.contextGroups.length - 1].lineRange
+        .right.end_line,
+    };
+  }
+
+  private hasValidProperties() {
+    return !!(this.diff && this.section && this.contextGroups?.length);
+  }
+
+  render() {
+    if (!this.hasValidProperties()) {
+      console.error('Invalid properties for gr-context-controls!');
+      return html`<p>invalid properties</p>`;
+    }
+    return html`
+      <div class="horizontalFlex">
+        ${this.createExpandAllButtonContainer()}
+        ${this.createPartialExpansionButtons()}
+        ${this.createBlockExpansionButtons()}
+      </div>
+    `;
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-context-controls': GrContextControls;
+  }
+}
diff --git a/polygerrit-ui/app/elements/diff/gr-context-controls/gr-context-controls_test.ts b/polygerrit-ui/app/elements/diff/gr-context-controls/gr-context-controls_test.ts
new file mode 100644
index 0000000..a1fc6d8
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-context-controls/gr-context-controls_test.ts
@@ -0,0 +1,380 @@
+/**
+ * @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 '../gr-diff/gr-diff-group';
+import './gr-context-controls';
+import {GrContextControls} from './gr-context-controls';
+import {flush} from '@polymer/polymer/lib/legacy/polymer.dom';
+
+import {GrDiffLine, GrDiffLineType} from '../gr-diff/gr-diff-line';
+import {GrDiffGroup, GrDiffGroupType} from '../gr-diff/gr-diff-group';
+import {DiffFileMetaInfo, DiffInfo, SyntaxBlock} from '../../../api/diff';
+
+const blankFixture = fixtureFromElement('div');
+
+suite('gr-context-control tests', () => {
+  let element: GrContextControls;
+
+  setup(async () => {
+    element = document.createElement('gr-context-controls');
+    element.diff = ({content: []} as any) as DiffInfo;
+    element.renderPreferences = {};
+    element.section = document.createElement('div');
+    blankFixture.instantiate().appendChild(element);
+    await flush();
+  });
+
+  function createContextGroups(options: {offset?: number; count?: number}) {
+    const offset = options.offset || 0;
+    const numLines = options.count || 10;
+    const lines = [];
+    for (let i = 0; i < numLines; i++) {
+      const line = new GrDiffLine(GrDiffLineType.BOTH);
+      line.beforeNumber = offset + i + 1;
+      line.afterNumber = offset + i + 1;
+      line.text = 'lorem upsum';
+      lines.push(line);
+    }
+
+    return [new GrDiffGroup(GrDiffGroupType.BOTH, lines)];
+  }
+
+  test('no +10 buttons for 10 or less lines', async () => {
+    element.contextGroups = createContextGroups({count: 10});
+
+    await flush();
+
+    const buttons = element.shadowRoot!.querySelectorAll(
+      'paper-button.showContext'
+    );
+    assert.equal(buttons.length, 1);
+    assert.equal(buttons[0].textContent!.trim(), '+10 common lines');
+  });
+
+  test('context control at the top', async () => {
+    element.contextGroups = createContextGroups({offset: 0, count: 20});
+    element.showConfig = 'below';
+
+    await flush();
+
+    const buttons = element.shadowRoot!.querySelectorAll(
+      'paper-button.showContext'
+    );
+
+    assert.equal(buttons.length, 2);
+    assert.equal(buttons[0].textContent!.trim(), '+20 common lines');
+    assert.equal(buttons[1].textContent!.trim(), '+10');
+
+    assert.include([...buttons[0].classList.values()], 'belowButton');
+    assert.include([...buttons[1].classList.values()], 'belowButton');
+  });
+
+  test('context control in the middle', async () => {
+    element.contextGroups = createContextGroups({offset: 10, count: 20});
+    element.showConfig = 'both';
+
+    await flush();
+
+    const buttons = element.shadowRoot!.querySelectorAll(
+      'paper-button.showContext'
+    );
+
+    assert.equal(buttons.length, 3);
+    assert.equal(buttons[0].textContent!.trim(), '+20 common lines');
+    assert.equal(buttons[1].textContent!.trim(), '+10');
+    assert.equal(buttons[2].textContent!.trim(), '+10');
+
+    assert.include([...buttons[0].classList.values()], 'centeredButton');
+    assert.include([...buttons[1].classList.values()], 'aboveButton');
+    assert.include([...buttons[2].classList.values()], 'belowButton');
+  });
+
+  test('context control at the bottom', async () => {
+    element.contextGroups = createContextGroups({offset: 30, count: 20});
+    element.showConfig = 'above';
+
+    await flush();
+
+    const buttons = element.shadowRoot!.querySelectorAll(
+      'paper-button.showContext'
+    );
+
+    assert.equal(buttons.length, 2);
+    assert.equal(buttons[0].textContent!.trim(), '+20 common lines');
+    assert.equal(buttons[1].textContent!.trim(), '+10');
+
+    assert.include([...buttons[0].classList.values()], 'aboveButton');
+    assert.include([...buttons[1].classList.values()], 'aboveButton');
+  });
+
+  function prepareForBlockExpansion(syntaxTree: SyntaxBlock[]) {
+    element.renderPreferences!.use_block_expansion = true;
+    element.diff!.meta_b = ({
+      syntax_tree: syntaxTree,
+    } as any) as DiffFileMetaInfo;
+  }
+
+  test('context control with block expansion at the top', async () => {
+    prepareForBlockExpansion([]);
+    element.contextGroups = createContextGroups({offset: 0, count: 20});
+    element.showConfig = 'below';
+
+    await flush();
+
+    const fullExpansionButtons = element.shadowRoot!.querySelectorAll(
+      '.fullExpansion paper-button'
+    );
+    const partialExpansionButtons = element.shadowRoot!.querySelectorAll(
+      '.partialExpansion paper-button'
+    );
+    const blockExpansionButtons = element.shadowRoot!.querySelectorAll(
+      '.blockExpansion paper-button'
+    );
+    assert.equal(fullExpansionButtons.length, 1);
+    assert.equal(partialExpansionButtons.length, 1);
+    assert.equal(blockExpansionButtons.length, 1);
+    assert.equal(
+      blockExpansionButtons[0].querySelector('span')!.textContent!.trim(),
+      '+Block'
+    );
+    assert.include(
+      [...blockExpansionButtons[0].classList.values()],
+      'belowButton'
+    );
+  });
+
+  test('context control with block expansion in the middle', async () => {
+    prepareForBlockExpansion([]);
+    element.contextGroups = createContextGroups({offset: 10, count: 20});
+    element.showConfig = 'both';
+
+    await flush();
+
+    const fullExpansionButtons = element.shadowRoot!.querySelectorAll(
+      '.fullExpansion paper-button'
+    );
+    const partialExpansionButtons = element.shadowRoot!.querySelectorAll(
+      '.partialExpansion paper-button'
+    );
+    const blockExpansionButtons = element.shadowRoot!.querySelectorAll(
+      '.blockExpansion paper-button'
+    );
+    assert.equal(fullExpansionButtons.length, 1);
+    assert.equal(partialExpansionButtons.length, 2);
+    assert.equal(blockExpansionButtons.length, 2);
+    assert.equal(
+      blockExpansionButtons[0].querySelector('span')!.textContent!.trim(),
+      '+Block'
+    );
+    assert.equal(
+      blockExpansionButtons[1].querySelector('span')!.textContent!.trim(),
+      '+Block'
+    );
+    assert.include(
+      [...blockExpansionButtons[0].classList.values()],
+      'aboveButton'
+    );
+    assert.include(
+      [...blockExpansionButtons[1].classList.values()],
+      'belowButton'
+    );
+  });
+
+  test('context control with block expansion at the bottom', async () => {
+    prepareForBlockExpansion([]);
+    element.contextGroups = createContextGroups({offset: 30, count: 20});
+    element.showConfig = 'above';
+
+    await flush();
+
+    const fullExpansionButtons = element.shadowRoot!.querySelectorAll(
+      '.fullExpansion paper-button'
+    );
+    const partialExpansionButtons = element.shadowRoot!.querySelectorAll(
+      '.partialExpansion paper-button'
+    );
+    const blockExpansionButtons = element.shadowRoot!.querySelectorAll(
+      '.blockExpansion paper-button'
+    );
+    assert.equal(fullExpansionButtons.length, 1);
+    assert.equal(partialExpansionButtons.length, 1);
+    assert.equal(blockExpansionButtons.length, 1);
+    assert.equal(
+      blockExpansionButtons[0].querySelector('span')!.textContent!.trim(),
+      '+Block'
+    );
+    assert.include(
+      [...blockExpansionButtons[0].classList.values()],
+      'aboveButton'
+    );
+  });
+
+  test('+ Block tooltip tooltip shows syntax block containing the target lines above and below', async () => {
+    prepareForBlockExpansion([
+      {
+        name: 'aSpecificFunction',
+        range: {start_line: 1, start_column: 0, end_line: 25, end_column: 0},
+        children: [],
+      },
+      {
+        name: 'anotherFunction',
+        range: {start_line: 26, start_column: 0, end_line: 50, end_column: 0},
+        children: [],
+      },
+    ]);
+    element.contextGroups = createContextGroups({offset: 10, count: 20});
+    element.showConfig = 'both';
+
+    await flush();
+
+    const blockExpansionButtons = element.shadowRoot!.querySelectorAll(
+      '.blockExpansion paper-button'
+    );
+    assert.equal(
+      blockExpansionButtons[0]
+        .querySelector('.breadcrumbTooltip')!
+        .textContent?.trim(),
+      'aSpecificFunction'
+    );
+    assert.equal(
+      blockExpansionButtons[1]
+        .querySelector('.breadcrumbTooltip')!
+        .textContent?.trim(),
+      'anotherFunction'
+    );
+  });
+
+  test('+Block tooltip shows nested syntax blocks as breadcrumbs', async () => {
+    prepareForBlockExpansion([
+      {
+        name: 'aSpecificNamespace',
+        range: {start_line: 1, start_column: 0, end_line: 200, end_column: 0},
+        children: [
+          {
+            name: 'MyClass',
+            range: {
+              start_line: 2,
+              start_column: 0,
+              end_line: 100,
+              end_column: 0,
+            },
+            children: [
+              {
+                name: 'aMethod',
+                range: {
+                  start_line: 5,
+                  start_column: 0,
+                  end_line: 80,
+                  end_column: 0,
+                },
+                children: [],
+              },
+            ],
+          },
+        ],
+      },
+    ]);
+    element.contextGroups = createContextGroups({offset: 10, count: 20});
+    element.showConfig = 'both';
+
+    await flush();
+
+    const blockExpansionButtons = element.shadowRoot!.querySelectorAll(
+      '.blockExpansion paper-button'
+    );
+    assert.equal(
+      blockExpansionButtons[0]
+        .querySelector('.breadcrumbTooltip')!
+        .textContent?.trim(),
+      'aSpecificNamespace > MyClass > aMethod'
+    );
+  });
+
+  test('+Block tooltip shows (anonymous) for empty blocks', async () => {
+    prepareForBlockExpansion([
+      {
+        name: 'aSpecificNamespace',
+        range: {start_line: 1, start_column: 0, end_line: 200, end_column: 0},
+        children: [
+          {
+            name: '',
+            range: {
+              start_line: 2,
+              start_column: 0,
+              end_line: 100,
+              end_column: 0,
+            },
+            children: [
+              {
+                name: 'aMethod',
+                range: {
+                  start_line: 5,
+                  start_column: 0,
+                  end_line: 80,
+                  end_column: 0,
+                },
+                children: [],
+              },
+            ],
+          },
+        ],
+      },
+    ]);
+    element.contextGroups = createContextGroups({offset: 10, count: 20});
+    element.showConfig = 'both';
+    await flush();
+
+    const blockExpansionButtons = element.shadowRoot!.querySelectorAll(
+      '.blockExpansion paper-button'
+    );
+    assert.equal(
+      blockExpansionButtons[0]
+        .querySelector('.breadcrumbTooltip')!
+        .textContent?.trim(),
+      'aSpecificNamespace > (anonymous) > aMethod'
+    );
+  });
+
+  test('+Block tooltip shows "all common lines" for empty syntax tree', async () => {
+    prepareForBlockExpansion([]);
+
+    element.contextGroups = createContextGroups({offset: 10, count: 20});
+    element.showConfig = 'both';
+    await flush();
+
+    const blockExpansionButtons = element.shadowRoot!.querySelectorAll(
+      '.blockExpansion paper-button'
+    );
+    const tooltipAbove = blockExpansionButtons[0].querySelector(
+      'paper-tooltip'
+    )!;
+    const tooltipBelow = blockExpansionButtons[1].querySelector(
+      'paper-tooltip'
+    )!;
+    assert.equal(
+      tooltipAbove.querySelector('.breadcrumbTooltip')!.textContent?.trim(),
+      '20 common lines'
+    );
+    assert.equal(
+      tooltipBelow.querySelector('.breadcrumbTooltip')!.textContent?.trim(),
+      '20 common lines'
+    );
+    assert.equal(tooltipAbove!.getAttribute('position'), 'top');
+    assert.equal(tooltipBelow!.getAttribute('position'), 'bottom');
+  });
+});
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..c66ea4f 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,7 @@
 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';
 
 export class GrDiffBuilderBinary extends GrDiffBuilderUnified {
   constructor(
@@ -32,7 +33,7 @@
     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;
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 76ebc50..d65deb6 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
@@ -46,7 +46,7 @@
 import {GrDiffLine, LineNumber} from '../gr-diff/gr-diff-line';
 import {GrDiffGroup} from '../gr-diff/gr-diff-group';
 import {PolymerSpliceChange} from '@polymer/polymer/interfaces';
-import {getLineNumber} from '../gr-diff/gr-diff-utils';
+import {getLineNumber, getSideByLineEl} from '../gr-diff/gr-diff-utils';
 import {fireAlert, fireEvent} from '../../../utils/event-util';
 
 const TRAILING_WHITESPACE_PATTERN = /\s+$/;
@@ -257,26 +257,6 @@
     this._layers = layers;
   }
 
-  getLineElByChild(node?: Node): HTMLElement | null {
-    while (node) {
-      if (node instanceof Element) {
-        if (node.classList.contains('lineNum')) {
-          return node as HTMLElement;
-        }
-        if (node.classList.contains('section')) {
-          return null;
-        }
-      }
-      node = node.previousSibling ?? node.parentElement ?? undefined;
-    }
-    return null;
-  }
-
-  getLineNumberByChild(node: Node) {
-    const lineEl = this.getLineElByChild(node);
-    return getLineNumber(lineEl);
-  }
-
   getContentTdByLine(lineNumber: LineNumber, side?: Side, root?: Element) {
     if (!this._builder) return null;
     return this._builder.getContentTdByLine(lineNumber, side, root);
@@ -293,7 +273,7 @@
     if (!lineEl) return null;
     const line = getLineNumber(lineEl);
     if (!line) return null;
-    const side = this.getSideByLineEl(lineEl);
+    const side = getSideByLineEl(lineEl);
     // Performance optimization because we already have an element in the
     // correct row
     const row = this._getDiffRowByChild(lineEl);
@@ -307,10 +287,6 @@
     );
   }
 
-  getSideByLineEl(lineEl: Element) {
-    return lineEl.classList.contains(Side.RIGHT) ? Side.RIGHT : Side.LEFT;
-  }
-
   emitGroup(group: GrDiffGroup, sectionEl: HTMLElement) {
     if (!this._builder) return;
     this._builder.emitGroup(group, sectionEl);
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-element_test.js b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-element_test.js
index 7c01a95..4455bd5 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-element_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-element_test.js
@@ -18,6 +18,7 @@
 import '../../../test/common-test-setup-karma.js';
 import '../gr-diff/gr-diff-group.js';
 import './gr-diff-builder.js';
+import '../gr-context-controls/gr-context-controls.js';
 import {getMockDiffResponse} from '../../../test/mocks/diff-response.js';
 import './gr-diff-builder-element.js';
 import {stubBaseUrl} from '../../../test/test-utils.js';
@@ -74,83 +75,6 @@
     assert.isTrue(node.classList.contains('classes'));
   });
 
-  suite('context control', () => {
-    function createContextGroups(options) {
-      const offset = options.offset || 0;
-      const numLines = options.count || 10;
-      const lines = [];
-      for (let i = 0; i < numLines; i++) {
-        const line = new GrDiffLine(GrDiffLineType.BOTH);
-        line.beforeNumber = offset + i + 1;
-        line.afterNumber = offset + i + 1;
-        line.text = 'lorem upsum';
-        lines.push(line);
-      }
-
-      return [new GrDiffGroup(GrDiffGroupType.BOTH, lines)];
-    }
-
-    function createContextSectionForGroups(options) {
-      const section = document.createElement('div');
-      builder._createContextControls(
-          section, createContextGroups(options), DiffViewMode.UNIFIED);
-      return section;
-    }
-
-    setup(() => {
-      builder = new GrDiffBuilder({content: []}, prefs, null, []);
-    });
-
-    test('no +10 buttons for 10 or less lines', () => {
-      const section = createContextSectionForGroups({count: 10});
-      const buttons = section.querySelectorAll('gr-button.showContext');
-
-      assert.equal(buttons.length, 1);
-      assert.equal(buttons[0].textContent, '+10 common lines');
-    });
-
-    test('context control at the top', () => {
-      builder._numLinesLeft = 50;
-      const section = createContextSectionForGroups({offset: 0, count: 20});
-      const buttons = section.querySelectorAll('gr-button.showContext');
-
-      assert.equal(buttons.length, 2);
-      assert.equal(buttons[0].textContent, '+20 common lines');
-      assert.equal(buttons[1].textContent, '+10');
-
-      assert.include([...buttons[0].classList.values()], 'belowButton');
-      assert.include([...buttons[1].classList.values()], 'belowButton');
-    });
-
-    test('context control in the middle', () => {
-      builder._numLinesLeft = 50;
-      const section = createContextSectionForGroups({offset: 10, count: 20});
-      const buttons = section.querySelectorAll('gr-button.showContext');
-
-      assert.equal(buttons.length, 3);
-      assert.equal(buttons[0].textContent, '+20 common lines');
-      assert.equal(buttons[1].textContent, '+10');
-      assert.equal(buttons[2].textContent, '+10');
-
-      assert.include([...buttons[0].classList.values()], 'centeredButton');
-      assert.include([...buttons[1].classList.values()], 'aboveButton');
-      assert.include([...buttons[2].classList.values()], 'belowButton');
-    });
-
-    test('context control at the bottom', () => {
-      builder._numLinesLeft = 50;
-      const section = createContextSectionForGroups({offset: 30, count: 20});
-      const buttons = section.querySelectorAll('gr-button.showContext');
-
-      assert.equal(buttons.length, 2);
-      assert.equal(buttons[0].textContent, '+20 common lines');
-      assert.equal(buttons[1].textContent, '+10');
-
-      assert.include([...buttons[0].classList.values()], 'aboveButton');
-      assert.include([...buttons[1].classList.values()], 'aboveButton');
-    });
-  });
-
   test('newlines 1', () => {
     let text = 'abcdef';
 
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 e927fdf..2067455 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
@@ -122,7 +122,14 @@
       Side.RIGHT
     );
     row.appendChild(lineNumberEl);
-    row.appendChild(this._createTextEl(lineNumberEl, line));
+    let side = undefined;
+    if (line.type === GrDiffLineType.ADD || line.type === GrDiffLineType.BOTH) {
+      side = Side.RIGHT;
+    }
+    if (line.type === GrDiffLineType.REMOVE) {
+      side = Side.LEFT;
+    }
+    row.appendChild(this._createTextEl(lineNumberEl, line, side));
     return row;
   }
 
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified_test.js b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified_test.js
index c08764e..2be85c3 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified_test.js
@@ -116,7 +116,7 @@
       assert.equal(rowEls.length, 3);
       assert.isTrue(moveControlsRow.classList.contains('movedOut'));
       assert.equal(cells.length, 3);
-      assert.isTrue(cells[2].classList.contains('moveLabel'));
+      assert.isTrue(cells[2].classList.contains('moveHeader'));
       assert.equal(cells[2].textContent, 'Moved out');
     });
 
@@ -139,7 +139,7 @@
       assert.equal(rowEls.length, 3);
       assert.isTrue(moveControlsRow.classList.contains('movedIn'));
       assert.equal(cells.length, 3);
-      assert.isTrue(cells[2].classList.contains('moveLabel'));
+      assert.isTrue(cells[2].classList.contains('moveHeader'));
       assert.equal(cells[2].textContent, 'Moved in');
     });
   });
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 eb943f7..f70974f 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
@@ -16,22 +16,24 @@
  */
 import {
   ContentLoadNeededEventDetail,
+  DiffContextExpandedExternalDetail,
+  LineNumberEventDetail,
   MovedLinkClickedEventDetail,
   RenderPreferences,
 } from '../../../api/diff';
 import {getBaseUrl} from '../../../utils/url-util';
 import {GrDiffLine, GrDiffLineType, LineNumber} from '../gr-diff/gr-diff-line';
+import {GrDiffGroup, GrDiffGroupType} from '../gr-diff/gr-diff-group';
+
+import '../gr-context-controls/gr-context-controls';
 import {
-  GrDiffGroup,
-  GrDiffGroupType,
-  hideInContextControl,
-} from '../gr-diff/gr-diff-group';
+  GrContextControls,
+  GrContextControlsShowConfig,
+} from '../gr-context-controls/gr-context-controls';
 import {BlameInfo} from '../../../types/common';
 import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
 import {DiffViewMode, Side} from '../../../constants/constants';
 import {DiffLayer} from '../../../types/types';
-import {pluralize} from '../../../utils/string-util';
-import {fire} from '../../../utils/event-util';
 
 /**
  * In JS, unicode code points above 0xFFFF occupy two elements of a string.
@@ -55,15 +57,8 @@
  */
 const REGEX_TAB_OR_SURROGATE_PAIR = /\t|[\uD800-\uDBFF][\uDC00-\uDFFF]/;
 
-const PARTIAL_CONTEXT_AMOUNT = 10;
-
-enum ContextButtonType {
-  ABOVE = 'above',
-  BELOW = 'below',
-  ALL = 'all',
-}
-
-export interface DiffContextExpandedEventDetail {
+export interface DiffContextExpandedEventDetail
+  extends DiffContextExpandedExternalDetail {
   groups: GrDiffGroup[];
   section: HTMLElement;
   numLines: number;
@@ -158,13 +153,6 @@
     REMOVED: 'edit_a',
   };
 
-  // TODO(TS): Replace usages with ContextButtonType enum.
-  static readonly ContextButtonType = {
-    ABOVE: 'above',
-    BELOW: 'below',
-    ALL: 'all',
-  };
-
   abstract addColumns(outputEl: HTMLElement, fontSize: number): void;
 
   abstract buildSectionElement(group: GrDiffGroup): HTMLElement;
@@ -324,14 +312,12 @@
     const leftStart = contextGroups[0].lineRange.left.start_line;
     const leftEnd =
       contextGroups[contextGroups.length - 1].lineRange.left.end_line;
-    const numLines = leftEnd - leftStart + 1;
-
-    if (numLines === 0) console.error('context group without lines');
-
     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) {
@@ -345,7 +331,6 @@
         contextGroups,
         showAbove,
         showBelow,
-        numLines,
         viewMode
       )
     );
@@ -365,20 +350,21 @@
     contextGroups: GrDiffGroup[],
     showAbove: boolean,
     showBelow: boolean,
-    numLines: number,
     viewMode: DiffViewMode
   ): HTMLElement {
     const row = this._createElement('tr', 'dividerRow');
+    let showConfig: GrContextControlsShowConfig;
     if (showAbove && !showBelow) {
-      row.classList.add('showAboveOnly');
+      showConfig = 'above';
     } else if (!showAbove && showBelow) {
-      row.classList.add('showBelowOnly');
+      showConfig = 'below';
     } else {
       // Note that !showAbove && !showBelow also intentionally creates
-      // "showBoth". This means the file is completely collapsed, which is
+      // "show-both". This means the file is completely collapsed, which is
       // unusual, but at least happens in one test.
-      row.classList.add('showBoth');
+      showConfig = 'both';
     }
+    row.classList.add(`show-${showConfig}`);
 
     row.appendChild(this._createBlameCell(0));
     if (viewMode === DiffViewMode.SIDE_BY_SIDE) {
@@ -388,54 +374,16 @@
     const cell = this._createElement('td', 'dividerCell');
     cell.setAttribute('colspan', '3');
     row.appendChild(cell);
-    const verticalFlex = this._createElement('div', 'verticalFlex');
-    cell.appendChild(verticalFlex);
-    const horizontalFlex = this._createElement('div', 'horizontalFlex');
-    verticalFlex.appendChild(horizontalFlex);
 
-    const showAllContainer = this._createElement('div', 'aboveBelowButtons');
-    horizontalFlex.appendChild(showAllContainer);
-    const showAllButton = this._createContextButton(
-      ContextButtonType.ALL,
-      section,
-      contextGroups,
-      numLines
-    );
-    showAllButton.classList.add(
-      showAbove && showBelow
-        ? 'centeredButton'
-        : showAbove
-        ? 'aboveButton'
-        : 'belowButton'
-    );
-    showAllContainer.appendChild(showAllButton);
-
-    const showPartialLinks = numLines > PARTIAL_CONTEXT_AMOUNT;
-    if (showPartialLinks) {
-      const container = this._createElement('div', 'aboveBelowButtons');
-      if (showAbove) {
-        container.appendChild(
-          this._createContextButton(
-            ContextButtonType.ABOVE,
-            section,
-            contextGroups,
-            numLines
-          )
-        );
-      }
-      if (showBelow) {
-        container.appendChild(
-          this._createContextButton(
-            ContextButtonType.BELOW,
-            section,
-            contextGroups,
-            numLines
-          )
-        );
-      }
-      horizontalFlex.appendChild(container);
-    }
-
+    const contextControls = this._createElement(
+      'gr-context-controls'
+    ) as GrContextControls;
+    contextControls.diff = this._diff;
+    contextControls.renderPreferences = this._renderPrefs;
+    contextControls.section = section;
+    contextControls.contextGroups = contextGroups;
+    contextControls.showConfig = showConfig;
+    cell.appendChild(contextControls);
     return row;
   }
 
@@ -467,87 +415,6 @@
     return row;
   }
 
-  _createContextButton(
-    type: ContextButtonType,
-    section: HTMLElement,
-    contextGroups: GrDiffGroup[],
-    numLines: number
-  ) {
-    const context = PARTIAL_CONTEXT_AMOUNT;
-    const button = this._createElement('gr-button', 'showContext');
-    button.classList.add('contextControlButton');
-    button.setAttribute('link', 'true');
-    button.setAttribute('no-uppercase', 'true');
-
-    let text = '';
-    let groups: GrDiffGroup[] = []; // The groups that replace this one if tapped.
-    let requiresLoad = false;
-    if (type === GrDiffBuilder.ContextButtonType.ALL) {
-      text = `+${pluralize(numLines, 'common line')}`;
-      button.setAttribute(
-        'aria-label',
-        `Show ${pluralize(numLines, 'common line')}`
-      );
-      requiresLoad = contextGroups.find(c => !!c.skip) !== undefined;
-      if (requiresLoad) {
-        // Expanding content would require load of more data
-        text += ' (too large)';
-      }
-      groups.push(...contextGroups);
-    } else if (type === GrDiffBuilder.ContextButtonType.ABOVE) {
-      groups = hideInContextControl(contextGroups, context, numLines);
-      text = `+${context}`;
-      button.classList.add('aboveButton');
-      button.setAttribute(
-        'aria-label',
-        `Show ${pluralize(context, 'line')} above`
-      );
-    } else if (type === GrDiffBuilder.ContextButtonType.BELOW) {
-      groups = hideInContextControl(contextGroups, 0, numLines - context);
-      text = `+${context}`;
-      button.classList.add('belowButton');
-      button.setAttribute(
-        'aria-label',
-        `Show ${pluralize(context, 'line')} below`
-      );
-    }
-    const textSpan = this._createElement('span', 'showContext');
-    textSpan.textContent = text;
-    button.appendChild(textSpan);
-
-    if (requiresLoad) {
-      button.addEventListener('click', e => {
-        e.stopPropagation();
-        const firstRange = groups[0].lineRange;
-        const lastRange = groups[groups.length - 1].lineRange;
-        const lineRange = {
-          left: {
-            start_line: firstRange.left.start_line,
-            end_line: lastRange.left.end_line,
-          },
-          right: {
-            start_line: firstRange.right.start_line,
-            end_line: lastRange.right.end_line,
-          },
-        };
-        fire(button, 'content-load-needed', {
-          lineRange,
-        });
-      });
-    } else {
-      button.addEventListener('click', e => {
-        e.stopPropagation();
-        fire(button, 'diff-context-expanded', {
-          groups,
-          section,
-          numLines,
-        });
-      });
-    }
-
-    return button;
-  }
-
   _createLineEl(
     line: GrDiffLine,
     number: LineNumber,
@@ -594,6 +461,30 @@
           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,
+          })
+        );
+      });
     }
 
     return td;
@@ -630,14 +521,14 @@
         contentText.setAttribute('data-side', side);
       }
 
-      if (lineNumberEl) {
+      if (lineNumberEl && side) {
         for (const layer of this.layers) {
           if (typeof layer.annotate === 'function') {
-            layer.annotate(contentText, lineNumberEl, line);
+            layer.annotate(contentText, lineNumberEl, line, side);
           }
         }
       } else {
-        console.error('The lineNumberEl is null, skipping layer annotations.');
+        console.error('lineNumberEl or side not set, skipping layer.annotate');
       }
 
       td.appendChild(contentText);
@@ -884,13 +775,12 @@
     const cells = [...Array(numberOfCells).keys()].map(() =>
       this._createElement('td')
     );
-    const moveDescriptionDiv = this._createElement('div', 'moveDescription');
-    const icon = this._createElement('iron-icon');
-    icon.setAttribute('icon', 'gr-icons:move-item');
-    moveDescriptionDiv.appendChild(icon);
-    moveDescriptionDiv.appendChild(descriptionTextDiv);
-    cells[descriptionIndex].appendChild(moveDescriptionDiv);
-    cells[descriptionIndex].classList.add('moveLabel');
+
+    const moveRangeHeader = this._createElement('gr-range-header');
+    moveRangeHeader.setAttribute('icon', 'gr-icons:move-item');
+    moveRangeHeader.appendChild(descriptionTextDiv);
+    cells[descriptionIndex].classList.add('moveHeader');
+    cells[descriptionIndex].appendChild(moveRangeHeader);
     cells.forEach(c => {
       controls.appendChild(c);
     });
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/token-highlight-layer.ts b/polygerrit-ui/app/elements/diff/gr-diff-builder/token-highlight-layer.ts
new file mode 100644
index 0000000..c8b3901
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/token-highlight-layer.ts
@@ -0,0 +1,253 @@
+/**
+ * @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 {DiffLayer, DiffLayerListener} from '../../../types/types';
+import {GrDiffLine, Side} from '../../../api/diff';
+import {GrAnnotation} from '../gr-diff-highlight/gr-annotation';
+import {debounce, DelayedTask} from '../../../utils/async-util';
+import {
+  getLineNumberByChild,
+  lineNumberToNumber,
+} from '../gr-diff/gr-diff-utils';
+
+const tokenMatcher = new RegExp(/[a-zA-Z0-9_-]+/g);
+
+/** CSS class for all tokens. */
+const CSS_TOKEN = 'token';
+
+/** CSS class for the currently hovered token. */
+const CSS_HIGHLIGHT = 'token-highlight';
+
+const HOVER_DELAY_MS = 200;
+const UNHOVER_DELAY_MS = 50;
+
+const LINE_LENGTH_LIMIT = 500;
+
+const TOKEN_LENGTH_LIMIT = 100;
+
+const TOKEN_COUNT_LIMIT = 10000;
+
+const TOKEN_OCCURRENCES_LIMIT = 1000;
+
+/**
+ * Token highlighting is only useful for code on-screen, so don't bother
+ * highlighting tokens that are further away than this threshold from where the
+ * user is hovering.
+ */
+const LINE_DISTANCE_THRESHOLD = 100;
+
+/**
+ * When a user hovers over a token in the diff, then this layer makes sure that
+ * all occurrences of this token are annotated with the 'token-highlight' css
+ * class. And removes that class when the user moves the mouse away from the
+ * token.
+ *
+ * The layer does not react to mouse events directly by adding a css class to
+ * the appropriate elements, but instead it just sets the currently highlighted
+ * token and notifies the diff renderer that certain lines must be re-rendered.
+ * And when that re-rendering happens the appropriate css class is added.
+ */
+export class TokenHighlightLayer implements DiffLayer {
+  /** The only listener is typically the renderer of gr-diff. */
+  private listeners: DiffLayerListener[] = [];
+
+  /** The currently highlighted token. */
+  private currentHighlight?: string;
+
+  /**
+   * The line of the currently highlighted token. We store this in order to
+   * re-render only relevant lines of the diff. Only lines visible on the screen
+   * need a highlight. For example in a file with 10,000 lines it is sufficient
+   * to just re-render the ~100 lines that are visible to the user.
+   *
+   * It is a known issue that we are only storing the line number on the side of
+   * where the user is hovering and we use that also to determine which line
+   * numbers to re-render on the other side, but it is non-trivial to look up or
+   * store a reliable mapping of line numbers, so we just accept this
+   * shortcoming with the reasoning that the user is mostly interested in the
+   * tokens on the side where they are hovering anyway.
+   *
+   * Another known issue is that we are not able to see past collapsed lines
+   * with the current implementation.
+   */
+  private currentHighlightLineNumber = 0;
+
+  /**
+   * Keeps track of where tokens occur in a file during rendering, so that it is
+   * easy to look up when processing mouse events.
+   */
+  private tokenToLinesLeft = new Map<string, Set<number>>();
+
+  private tokenToLinesRight = new Map<string, Set<number>>();
+
+  private updateTokenTask?: DelayedTask;
+
+  annotate(
+    el: HTMLElement,
+    _: HTMLElement,
+    line: GrDiffLine,
+    side: Side
+  ): void {
+    const text = el.textContent;
+    if (!text) return;
+    // Binary files encoded as text for example can have super long lines
+    // with super long tokens. Let's guard against against this scenario.
+    if (text.length > LINE_LENGTH_LIMIT) return;
+    let match;
+    let atLeastOneTokenMatched = false;
+    while ((match = tokenMatcher.exec(text))) {
+      const token = match[0];
+      const index = match.index;
+      const length = token.length;
+      // Binary files encoded as text for example can have super long lines
+      // with super long tokens. Let's guard against this scenario.
+      if (length > TOKEN_LENGTH_LIMIT) continue;
+      atLeastOneTokenMatched = true;
+      const css = token === this.currentHighlight ? CSS_HIGHLIGHT : CSS_TOKEN;
+      // We add the tk-* class so that we can look up the token later easily
+      // even if the token element was split up into multiple smaller nodes.
+      GrAnnotation.annotateElement(el, index, length, `tk-${token} ${css}`);
+      // We could try to detect whether we are re-rendering instead of initially
+      // rendering the line. Then we would not have to call storeLineForToken()
+      // again. But since the Set swallows the duplicates we don't care.
+      this.storeLineForToken(token, line, side);
+    }
+    if (atLeastOneTokenMatched) {
+      // These listeners do not have to be cleaned, because listeners are
+      // garbage collected along with the element itself once it is not attached
+      // to the DOM anymore and no references exist anymore.
+      el.addEventListener('mouseover', this.handleMouseOver);
+      el.addEventListener('mouseout', this.handleMouseOut);
+    }
+  }
+
+  private storeLineForToken(token: string, line: GrDiffLine, side: Side) {
+    const tokenToLines =
+      side === Side.LEFT ? this.tokenToLinesLeft : this.tokenToLinesRight;
+    // Just to make sure that we don't break down on large files.
+    if (tokenToLines.size > TOKEN_COUNT_LIMIT) return;
+    let numbers = tokenToLines.get(token);
+    if (!numbers) {
+      numbers = new Set<number>();
+      tokenToLines.set(token, numbers);
+    }
+    // Just to make sure that we don't break down on large files.
+    if (numbers.size > TOKEN_OCCURRENCES_LIMIT) return;
+    const lineNumber =
+      side === Side.LEFT ? line.beforeNumber : line.afterNumber;
+    numbers.add(Number(lineNumber));
+  }
+
+  private readonly handleMouseOut = (e: MouseEvent) => {
+    if (!this.currentHighlight) return;
+    if (this.interferesWithSelection(e)) return;
+    const el = this.findTokenAncestor(e?.target);
+    if (!el) return;
+    this.updateTokenHighlight(undefined, undefined);
+  };
+
+  private readonly handleMouseOver = (e: MouseEvent) => {
+    if (this.interferesWithSelection(e)) return;
+    const {line, token} = this.findTokenAncestor(e?.target);
+    if (!token) return;
+    const oldHighlight = this.currentHighlight;
+    const newHighlight = token;
+    if (!newHighlight || newHighlight === oldHighlight) return;
+    if (this.countOccurrences(newHighlight) <= 1) return;
+    this.updateTokenHighlight(line, newHighlight);
+  };
+
+  private interferesWithSelection(e: MouseEvent) {
+    if (e.buttons > 0) return true;
+    if (window.getSelection()?.type === 'Range') return true;
+    return false;
+  }
+
+  private updateTokenHighlight(
+    newLineNumber: number | undefined,
+    newHighlight: string | undefined
+  ) {
+    this.updateTokenTask = debounce(
+      this.updateTokenTask,
+      () => {
+        const oldHighlight = this.currentHighlight;
+        const oldLineNumber = this.currentHighlightLineNumber;
+        this.currentHighlight = newHighlight;
+        this.currentHighlightLineNumber = newLineNumber ?? 0;
+        this.notifyForToken(oldHighlight, oldLineNumber);
+        this.notifyForToken(newHighlight, newLineNumber ?? 0);
+      },
+      newHighlight === undefined ? UNHOVER_DELAY_MS : HOVER_DELAY_MS
+    );
+  }
+
+  findTokenAncestor(
+    el?: EventTarget | Element | null
+  ): {
+    token?: string;
+    line: number;
+  } {
+    if (!(el instanceof Element)) return {line: 0, token: undefined};
+    if (
+      el.classList.contains(CSS_TOKEN) ||
+      el.classList.contains(CSS_HIGHLIGHT)
+    ) {
+      const tkClass = [...el.classList].find(c => c.startsWith('tk-'));
+      const line = lineNumberToNumber(getLineNumberByChild(el));
+      if (!line || !tkClass) return {line: 0, token: undefined};
+      return {line, token: tkClass.substring(3)};
+    }
+    if (el.tagName === 'TD') return {line: 0, token: undefined};
+    return this.findTokenAncestor(el.parentElement);
+  }
+
+  countOccurrences(token: string | undefined) {
+    if (!token) return 0;
+    const linesLeft = this.tokenToLinesLeft.get(token);
+    const linesRight = this.tokenToLinesRight.get(token);
+    return (linesLeft?.size ?? 0) + (linesRight?.size ?? 0);
+  }
+
+  notifyForToken(token: string | undefined, lineNumber: number) {
+    if (!token) return;
+    const linesLeft = this.tokenToLinesLeft.get(token);
+    linesLeft?.forEach(line => {
+      if (Math.abs(line - lineNumber) < LINE_DISTANCE_THRESHOLD) {
+        this.notifyListeners(line, Side.LEFT);
+      }
+    });
+    const linesRight = this.tokenToLinesRight.get(token);
+    linesRight?.forEach(line => {
+      if (Math.abs(line - lineNumber) < LINE_DISTANCE_THRESHOLD) {
+        this.notifyListeners(line, Side.RIGHT);
+      }
+    });
+  }
+
+  addListener(listener: DiffLayerListener) {
+    this.listeners.push(listener);
+  }
+
+  removeListener(listener: DiffLayerListener) {
+    this.listeners = this.listeners.filter(f => f !== listener);
+  }
+
+  notifyListeners(line: number, side: Side) {
+    for (const listener of this.listeners) {
+      listener(line, line, side);
+    }
+  }
+}
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 9b7c25a..68bdaa6 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,60 +15,93 @@
  * limitations under the License.
  */
 
-import '../../shared/gr-cursor-manager/gr-cursor-manager';
-import {
-  AbortStop,
-  CursorMoveResult,
-  GrCursorManager,
-  Stop,
-  isTargetable,
-} from '../../shared/gr-cursor-manager/gr-cursor-manager';
+import {customElement, observe, property} from '@polymer/decorators';
 import {afterNextRender} from '@polymer/polymer/lib/utils/render-status';
 import {dom} from '@polymer/polymer/lib/legacy/polymer.dom';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-diff-cursor_html';
-import {DiffViewMode} from '../../../api/diff';
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+import {Subscription} from 'rxjs';
+import {AbortStop, CursorMoveResult, Stop} from '../../../api/core';
+import {
+  DiffViewMode,
+  GrDiffCursor as GrDiffCursorApi,
+  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 {toggleClass} from '../../../utils/dom-util';
+import {
+  GrCursorManager,
+  isTargetable,
+} from '../../shared/gr-cursor-manager/gr-cursor-manager';
+import {GrDiffLineType} from '../gr-diff/gr-diff-line';
 import {GrDiffGroupType} from '../gr-diff/gr-diff-group';
 import {GrDiff} from '../gr-diff/gr-diff';
-import {fireAlert, fireEvent} from '../../../utils/event-util';
-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';
 
-// Time in which pressing n key again after the toast navigates to next file
-const NAVIGATE_TO_NEXT_FILE_TIMEOUT_MS = 5000;
-
-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;
 
-  private lastDisplayedNavigateToFileToast: Map<string, number> = new Map();
+  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: String})
-  side = Side.RIGHT;
+  get side(): Side {
+    return this.sideInternal;
+  }
 
-  @property({type: Object, notify: true, observer: '_rowChanged'})
-  diffRow?: HTMLElement;
+  private sideInternal = Side.RIGHT;
 
-  @property({type: Object})
-  diffs: GrDiff[] = [];
+  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
@@ -78,62 +111,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.
@@ -190,66 +188,28 @@
     }
   }
 
-  private showToastAndFireEvent(direction: string, shortcut: string) {
-    /*
-     * If user presses p/n on the first/last diff chunk, show a toast informing
-     * user that pressing it again will navigate them to previous/next
-     * unreviewedfile if click happens within the time limit
-     */
-    if (
-      this.lastDisplayedNavigateToFileToast.get(direction) &&
-      Date.now() - this.lastDisplayedNavigateToFileToast.get(direction)! <=
-        NAVIGATE_TO_NEXT_FILE_TIMEOUT_MS
-    ) {
-      // reset for next file
-      this.lastDisplayedNavigateToFileToast.delete(direction);
-      fireEvent(this, `navigate-to-${direction}-unreviewed-file`);
-    } else {
-      this.lastDisplayedNavigateToFileToast.set(direction, Date.now());
-      fireAlert(
-        this,
-        `Press ${shortcut} again to navigate to ${direction} unreviewed file`
-      );
-    }
-  }
-
-  moveToNextChunk(
-    clipToTop?: boolean,
-    navigateToNextFile?: boolean
-  ): CursorMoveResult {
+  moveToNextChunk(clipToTop?: boolean): CursorMoveResult {
     const result = this.cursorManager.next({
       filter: (row: HTMLElement) => this._isFirstRowOfChunk(row),
       getTargetHeight: target =>
         (target?.parentNode as HTMLElement)?.scrollHeight || 0,
       clipToTop,
     });
-    if (
-      navigateToNextFile &&
-      result === CursorMoveResult.CLIPPED &&
-      this.isAtEnd()
-    ) {
-      this.showToastAndFireEvent('next', 'n');
-    }
-
     this._fixSide();
     return result;
   }
 
-  moveToPreviousChunk(navigateToPreviousFile?: boolean): CursorMoveResult {
+  moveToPreviousChunk(): CursorMoveResult {
     const result = this.cursorManager.previous({
       filter: (row: HTMLElement) => this._isFirstRowOfChunk(row),
     });
-    if (navigateToPreviousFile && this.isAtStart()) {
-      this.showToastAndFireEvent('previous', 'p');
-    }
     this._fixSide();
     return result;
   }
 
-  moveToNextCommentThread(): CursorMoveResult | undefined {
+  moveToNextCommentThread(): CursorMoveResult {
     if (this.isAtEnd()) {
-      fireEvent(this, 'navigate-to-next-file-with-comments');
-      return;
+      return CursorMoveResult.CLIPPED;
     }
     const result = this.cursorManager.next({
       filter: (row: HTMLElement) => this._rowHasThread(row),
@@ -341,10 +301,10 @@
         this.moveToFirstChunk();
       }
     }
-    this.reInit();
+    this.resetScrollMode();
   }
 
-  reInit() {
+  resetScrollMode() {
     this.cursorManager.scrollMode = ScrollMode.KEEP_VISIBLE;
   }
 
@@ -357,7 +317,7 @@
   };
 
   reInitAndUpdateStops() {
-    this.reInit();
+    this.resetScrollMode();
     this._updateStops();
   }
 
@@ -410,23 +370,25 @@
    * {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) {
       return null;
     }
-
     // Get the line-number cell targeted by the cursor. If the mode is unified
     // then prefer the revision cell if available.
+    return this.getAddressFor(this.diffRow, this.side);
+  }
+
+  private getAddressFor(diffRow: HTMLElement, side: Side) {
     let cell;
     if (this._getViewMode() === DiffViewMode.UNIFIED) {
-      cell = this.diffRow.querySelector('.lineNum.right');
+      cell = diffRow.querySelector('.lineNum.right');
       if (!cell) {
-        cell = this.diffRow.querySelector('.lineNum.left');
+        cell = diffRow.querySelector('.lineNum.left');
       }
     } else {
-      cell = this.diffRow.querySelector('.lineNum.' + this.side);
+      cell = diffRow.querySelector('.lineNum.' + side);
     }
     if (!cell) {
       return null;
@@ -500,15 +462,28 @@
     );
   }
 
-  _rowChanged(_: HTMLElement, oldRow: HTMLElement) {
-    if (oldRow) {
-      oldRow.classList.remove(LEFT_SIDE_CLASS, RIGHT_SIDE_CLASS);
+  private fireCursorMoved(
+    event: 'line-cursor-moved-out' | 'line-cursor-moved-in',
+    row: HTMLElement,
+    side: Side
+  ) {
+    const address = this.getAddressFor(row, side);
+    if (address) {
+      const {leftSide, number} = address;
+      row.dispatchEvent(
+        new CustomEvent<LineNumberEventDetail>(event, {
+          detail: {
+            lineNum: number,
+            side: leftSide ? Side.LEFT : Side.RIGHT,
+          },
+          composed: true,
+          bubbles: true,
+        })
+      );
     }
-    this._updateSideClass();
   }
 
-  @observe('side')
-  _updateSideClass() {
+  private updateSideClass() {
     if (!this.diffRow) {
       return;
     }
@@ -542,69 +517,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,
@@ -625,8 +584,97 @@
   }
 }
 
+// TODO(oler): Remove this once clients have migrated to using GrDiffCursor service.
+@customElement('gr-diff-cursor')
+export class GrDiffCursorElement extends PolymerElement {
+  static get template() {
+    return html``;
+  }
+
+  @property({type: String, observer: '_sideChanged'})
+  side: Side = Side.RIGHT;
+
+  _sideChanged(side: Side) {
+    this.cursor.side = side;
+  }
+
+  @property({type: Object}) diffs: GrDiff[] = [];
+
+  @observe('diffs.splices')
+  _diffsChanged() {
+    if (this.diffs) {
+      this.cursor.replaceDiffs(this.diffs);
+    }
+  }
+
+  private cursor = new GrDiffCursor();
+
+  /** @override */
+  ready() {
+    super.ready();
+    afterNextRender(this, () => {
+      /*
+      This represents the diff cursor is ready for interaction coming from
+      client components. It is more then Polymer "ready" lifecycle, as no
+      "ready" events are automatically fired by Polymer, it means
+      the cursor is completely interactable - in this case attached and
+      painted on the page. We name it "ready" instead of "rendered" as the
+      long-term goal is to make gr-diff-cursor a javascript class - not a DOM
+      element with an actual lifecycle. This will be triggered only once
+      per element.
+      */
+      this.dispatchEvent(
+        new CustomEvent('ready', {
+          composed: true,
+          bubbles: false,
+        })
+      );
+    });
+  }
+
+  /** @override */
+  disconnectedCallback() {
+    this.cursor.dispose();
+    super.disconnectedCallback();
+  }
+
+  isAtStart = this.cursor.isAtStart.bind(this.cursor);
+
+  isAtEnd = this.cursor.isAtEnd.bind(this.cursor);
+
+  moveLeft = this.cursor.moveLeft.bind(this.cursor);
+
+  moveRight = this.cursor.moveRight.bind(this.cursor);
+
+  moveDown = this.cursor.moveDown.bind(this.cursor);
+
+  moveUp = this.cursor.moveUp.bind(this.cursor);
+
+  moveToNextChunk = this.cursor.moveToNextChunk.bind(this.cursor);
+
+  moveToPreviousChunk = this.cursor.moveToPreviousChunk.bind(this.cursor);
+
+  moveToNextCommentThread = this.cursor.moveToNextCommentThread.bind(
+    this.cursor
+  );
+
+  moveToPreviousCommentThread = this.cursor.moveToPreviousCommentThread.bind(
+    this.cursor
+  );
+
+  moveToLineNumber = this.cursor.moveToLineNumber.bind(this.cursor);
+
+  moveToFirstChunk = this.cursor.moveToFirstChunk.bind(this.cursor);
+
+  moveToLastChunk = this.cursor.moveToLastChunk.bind(this.cursor);
+
+  reInit = this.cursor.resetScrollMode.bind(this.cursor);
+
+  createCommentInPlace = this.cursor.createCommentInPlace.bind(this.cursor);
+}
+
 declare global {
   interface HTMLElementTagNameMap {
-    'gr-diff-cursor': GrDiffCursor;
+    'gr-diff-cursor': GrDiffCursorElement;
   }
 }
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 75439c8..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);
@@ -445,36 +442,21 @@
     });
   });
 
-  test('navigate to next unreviewed file via moveToNextChunk', () => {
-    const cursorManager = cursorElement.cursorManager;
-    cursorManager.index = cursorManager.stops.length - 1;
-    const dispatchEventStub = sinon.stub(cursorElement, 'dispatchEvent');
-    cursorElement.moveToNextChunk(/* opt_clipToTop = */false,
-        /* opt_navigateToNextFile = */true);
-    assert.isTrue(dispatchEventStub.called);
-    assert.equal(dispatchEventStub.getCall(1).args[0].type, 'show-alert');
-
-    cursorElement.moveToNextChunk(/* opt_clipToTop = */false,
-        /* opt_navigateToNextFile = */true);
-    assert.equal(dispatchEventStub.getCall(2).args[0].type,
-        'navigate-to-next-unreviewed-file');
-  });
-
   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);
@@ -483,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
     );
   });
@@ -521,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);
@@ -529,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);
@@ -541,7 +523,7 @@
         assert.equal(side, 'right');
         done();
       });
-      cursorElement.createCommentInPlace();
+      cursor.createCommentInPlace();
     });
 
     test('creates comment for range if selected', done => {
@@ -562,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);
     });
@@ -578,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', () => {
@@ -610,42 +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', () => {
@@ -653,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();
@@ -678,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 () => {
@@ -692,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 6a6e99e..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
@@ -28,8 +28,15 @@
 import {GrSelectionActionBox} from '../gr-selection-action-box/gr-selection-action-box';
 import {GrDiffBuilderElement} from '../gr-diff-builder/gr-diff-builder-element';
 import {FILE} from '../gr-diff/gr-diff-line';
-import {getRange, getSide} from '../gr-diff/gr-diff-utils';
+import {
+  getLineElByChild,
+  getLineNumberByChild,
+  getRange,
+  getSide,
+  getSideByLineEl,
+} from '../gr-diff/gr-diff-utils';
 import {debounce, DelayedTask} from '../../../utils/async-util';
+import {queryAndAssert} from '../../../utils/common-util';
 
 interface SidedRange {
   side: Side;
@@ -359,11 +366,11 @@
   ): NormalizedPosition | null {
     let column;
     if (!node || !this.contains(node)) return null;
-    const lineEl = this.diffBuilder.getLineElByChild(node);
+    const lineEl = getLineElByChild(node);
     if (!lineEl) return null;
-    const side = this.diffBuilder.getSideByLineEl(lineEl);
+    const side = getSideByLineEl(lineEl);
     if (!side) return null;
-    const line = this.diffBuilder.getLineNumberByChild(lineEl);
+    const line = getLineNumberByChild(lineEl);
     if (!line || line === FILE || line === 'LOST') return null;
     const contentTd = this.diffBuilder.getContentTdByLineEl(lineEl);
     if (!contentTd) return null;
@@ -568,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-highlight/gr-diff-highlight_test.js b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight_test.js
index 07e83a8..18fbe9a 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight_test.js
@@ -438,7 +438,7 @@
       const contentText = stubContent(140, 'left');
       const contentTd = contentText.parentElement;
 
-      emulateSelection(contentTd.previousElementSibling, 0,
+      emulateSelection(contentTd.parentElement, 0,
           contentText.firstChild, 2);
       assert.isFalse(!!element.selectedRange);
     });
@@ -584,21 +584,6 @@
       });
       assert.equal(side, 'right');
     });
-
-    test('_fixTripleClickSelection empty line', () => {
-      const startContent = stubContent(146, 'right');
-      const endContent = stubContent(165, 'left');
-      emulateSelection(startContent.firstChild, 0,
-          endContent.parentElement.previousElementSibling, 0);
-      const {range, side} = element.selectedRange;
-      assert.deepEqual(range, {
-        start_line: 146,
-        start_character: 0,
-        end_line: 146,
-        end_character: 84,
-      });
-      assert.equal(side, 'right');
-    });
   });
 });
 
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 1f4b778..72c7a62 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
@@ -18,12 +18,17 @@
 import '../gr-diff/gr-diff';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-diff-host_html';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 import {
+  GerritNav,
+  GeneratedWebLink,
+} from '../../core/gr-navigation/gr-navigation';
+import {
+  anyLineTooLong,
   getLine,
   getRange,
   getSide,
   rangesEqual,
+  SYNTAX_MAX_LINE_LENGTH,
 } from '../gr-diff/gr-diff-utils';
 import {appContext} from '../../../services/app-context';
 import {
@@ -80,6 +85,7 @@
 import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
 import {assertIsDefined} from '../../../utils/common-util';
 import {DiffContextExpandedEventDetail} from '../gr-diff-builder/gr-diff-builder';
+import {TokenHighlightLayer} from '../gr-diff-builder/token-highlight-layer';
 import {Timing} from '../../../constants/reporting';
 
 const MSG_EMPTY_BLAME = 'No blame information for this diff.';
@@ -91,10 +97,6 @@
 // Disable syntax highlighting if the overall diff is too large.
 const SYNTAX_MAX_DIFF_LENGTH = 20000;
 
-// If any line of the diff is more than the character limit, then disable
-// syntax highlighting for the entire file.
-const SYNTAX_MAX_LINE_LENGTH = 500;
-
 // 120 lines is good enough threshold for full-sized window viewport
 const NUM_OF_LINES_THRESHOLD_FOR_VIEWPORT = 120;
 
@@ -187,6 +189,9 @@
   commitRange?: CommitRange;
 
   @property({type: Object, notify: true})
+  editWeblinks?: GeneratedWebLink[];
+
+  @property({type: Object, notify: true})
   filesWeblinks: FilesWebLinks | {} = {};
 
   @property({type: Boolean, reflectToAttribute: true})
@@ -371,6 +376,7 @@
       // Not waiting for coverage ranges intentionally as
       // plugin loading should not block the content rendering
 
+      this.editWeblinks = this._getEditWeblinks(diff);
       this.filesWeblinks = this._getFilesWeblinks(diff);
       this.diff = diff;
       const event = (await waitForEventOnce(this, 'render')) as CustomEvent;
@@ -392,7 +398,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);
@@ -400,8 +406,16 @@
   }
 
   private _getLayers(path: string): DiffLayer[] {
+    const layers = [];
+    if (
+      appContext.flagsService.isEnabled(KnownExperimentId.TOKEN_HIGHLIGHTING)
+    ) {
+      layers.push(new TokenHighlightLayer());
+    }
+    layers.push(this.syntaxLayer);
     // Get layers from plugins (if any).
-    return [this.syntaxLayer, ...this.jsAPI.getDiffLayers(path)];
+    layers.push(...this.jsAPI.getDiffLayers(path));
+    return layers;
   }
 
   clear() {
@@ -469,15 +483,25 @@
               });
             })
             .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);
       });
   }
 
+  _getEditWeblinks(diff: DiffInfo) {
+    if (!this.projectName || !this.commitRange || !this.path) return undefined;
+    return GerritNav.getEditWebLinks(
+      this.projectName,
+      this.commitRange.baseCommit,
+      this.path,
+      {weblinks: diff?.edit_web_links}
+    );
+  }
+
   _getFilesWeblinks(diff: DiffInfo) {
     if (!this.projectName || !this.commitRange || !this.path) return {};
     return {
@@ -1004,7 +1028,7 @@
     if (!preferenceChangeRecord?.base?.syntax_highlighting || !diff) {
       return false;
     }
-    if (this._anyLineTooLong(diff)) {
+    if (anyLineTooLong(diff)) {
       fireAlert(
         this,
         `Files with line longer than ${SYNTAX_MAX_LINE_LENGTH} characters` +
@@ -1023,20 +1047,6 @@
     return true;
   }
 
-  /**
-   * @return whether any of the lines in diff are longer
-   * than SYNTAX_MAX_LINE_LENGTH.
-   */
-  _anyLineTooLong(diff?: DiffInfo) {
-    if (!diff) return false;
-    return diff.content.some(section => {
-      const lines = section.ab
-        ? section.ab
-        : (section.a || []).concat(section.b || []);
-      return lines.some(line => line.length >= SYNTAX_MAX_LINE_LENGTH);
-    });
-  }
-
   _listenToViewportRender() {
     const renderUpdateListener: DiffLayerListener = start => {
       if (start > NUM_OF_LINES_THRESHOLD_FOR_VIEWPORT) {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.js b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.js
index 5e1b5ff..6c96e34 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.js
@@ -211,7 +211,7 @@
     assert.isTrue(cancelStub.called);
   });
 
-  test('reload() loads files weblinks', () => {
+  test('reload() loads files weblinks', async () => {
     element.change = createChange();
     const weblinksStub = sinon.stub(GerritNav, '_generateWeblinks')
         .returns({name: 'stubb', url: '#s'});
@@ -222,28 +222,40 @@
     element.path = 'test-path';
     element.commitRange = {baseCommit: 'test-base', commit: 'test-commit'};
     element.patchRange = {};
-    return element.reload().then(() => {
-      assert.isTrue(weblinksStub.calledTwice);
-      assert.isTrue(weblinksStub.firstCall.calledWith({
-        commit: 'test-base',
-        file: 'test-path',
-        options: {
-          weblinks: undefined,
-        },
-        repo: 'test-project',
-        type: GerritNav.WeblinkType.FILE}));
-      assert.isTrue(weblinksStub.secondCall.calledWith({
-        commit: 'test-commit',
-        file: 'test-path',
-        options: {
-          weblinks: undefined,
-        },
-        repo: 'test-project',
-        type: GerritNav.WeblinkType.FILE}));
-      assert.deepEqual(element.filesWeblinks, {
-        meta_a: [{name: 'stubb', url: '#s'}],
-        meta_b: [{name: 'stubb', url: '#s'}],
-      });
+
+    await element.reload();
+
+    assert.equal(weblinksStub.callCount, 3);
+    assert.deepEqual(weblinksStub.firstCall.args[0], {
+      commit: 'test-base',
+      file: 'test-path',
+      options: {
+        weblinks: undefined,
+      },
+      repo: 'test-project',
+      type: GerritNav.WeblinkType.EDIT});
+    assert.deepEqual(element.editWeblinks, [{
+      name: 'stubb', url: '#s',
+    }]);
+    assert.deepEqual(weblinksStub.secondCall.args[0], {
+      commit: 'test-base',
+      file: 'test-path',
+      options: {
+        weblinks: undefined,
+      },
+      repo: 'test-project',
+      type: GerritNav.WeblinkType.FILE});
+    assert.deepEqual(weblinksStub.thirdCall.args[0], {
+      commit: 'test-commit',
+      file: 'test-path',
+      options: {
+        weblinks: undefined,
+      },
+      repo: 'test-project',
+      type: GerritNav.WeblinkType.FILE});
+    assert.deepEqual(element.filesWeblinks, {
+      meta_a: [{name: 'stubb', url: '#s'}],
+      meta_b: [{name: 'stubb', url: '#s'}],
     });
   });
 
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 77fe299..dad855c 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
@@ -18,6 +18,8 @@
 import '@polymer/paper-card/paper-card';
 import '@polymer/paper-checkbox/paper-checkbox';
 import '@polymer/paper-dropdown-menu/paper-dropdown-menu';
+import '@polymer/paper-fab/paper-fab';
+import '@polymer/paper-icon-button/paper-icon-button';
 import '@polymer/paper-item/paper-item';
 import '@polymer/paper-listbox/paper-listbox';
 import './gr-overview-image';
@@ -27,19 +29,30 @@
   css,
   customElement,
   html,
-  internalProperty,
   LitElement,
   property,
   PropertyValues,
   query,
+  state,
 } from 'lit-element';
 import {classMap} from 'lit-html/directives/class-map';
 import {StyleInfo, styleMap} from 'lit-html/directives/style-map';
 
-import {Dimensions, fitToFrame, FrameConstrainer, Point, Rect} from './util';
+import {
+  createEvent,
+  Dimensions,
+  fitToFrame,
+  FrameConstrainer,
+  Point,
+  Rect,
+} from './util';
 
 const DRAG_DEAD_ZONE_PIXELS = 5;
 
+const DEFAULT_AUTOMATIC_BLINK_TIME_MS = 1000;
+
+const AUTOMATIC_BLINK_BUTTON_ACTIVE_AREA_PIXELS = 350;
+
 /**
  * This components allows the user to rapidly switch between two given images
  * rendered in the same location, to make subtle differences more noticeable.
@@ -53,17 +66,23 @@
   // URL for the image to use as revision.
   @property({type: String}) revisionUrl = '';
 
-  @internalProperty() protected baseSelected = true;
+  @state() protected baseSelected = false;
 
-  @internalProperty() protected scaledSelected = true;
+  @state() protected scaledSelected = true;
 
-  @internalProperty() protected followMouse = false;
+  @state() protected followMouse = false;
 
-  @internalProperty() protected scale = 1;
+  @state() protected scale = 1;
 
-  @internalProperty() protected checkerboardSelected = true;
+  @state() protected checkerboardSelected = true;
 
-  @internalProperty() protected zoomedImageStyle: StyleInfo = {};
+  @state() protected backgroundColor = '';
+
+  @state() protected automaticBlink = false;
+
+  @state() protected automaticBlinkShown = false;
+
+  @state() protected zoomedImageStyle: StyleInfo = {};
 
   @query('.imageArea') protected imageArea!: HTMLDivElement;
 
@@ -71,18 +90,20 @@
 
   @query('#source-image') protected sourceImage!: HTMLImageElement;
 
+  @query('#automatic-blink-button') protected automaticBlinkButton?: Element;
+
   private imageSize: Dimensions = {width: 0, height: 0};
 
-  @internalProperty()
+  @state()
   protected magnifierSize: Dimensions = {width: 0, height: 0};
 
-  @internalProperty()
+  @state()
   protected magnifierFrame: Rect = {
     origin: {x: 0, y: 0},
     dimensions: {width: 0, height: 0},
   };
 
-  @internalProperty()
+  @state()
   protected overviewFrame: Rect = {
     origin: {x: 0, y: 0},
     dimensions: {width: 0, height: 0},
@@ -97,7 +118,7 @@
     2,
   ];
 
-  @internalProperty() protected grabbing = false;
+  @state() protected grabbing = false;
 
   private ownsMouseDown = false;
 
@@ -120,12 +141,27 @@
     }
   );
 
+  // Ensure constant function references, so that render() does not bind a new
+  // event listener on every call, as it would with lambdas.
+  private createColorPickerCallback(color: string) {
+    return {color, callback: () => this.pickColor(color)};
+  }
+
+  private readonly colorPickerCallbacks = [
+    this.createColorPickerCallback('#fff'),
+    this.createColorPickerCallback('#000'),
+    this.createColorPickerCallback('#aaa'),
+  ];
+
+  private automaticBlinkTimer?: ReturnType<typeof setInterval>;
+
   static styles = css`
     :host {
       display: flex;
       width: 100%;
       height: 100%;
       box-sizing: border-box;
+      text-align: initial !important;
       font-size: var(--font-size-normal);
       --image-border-width: 2px;
     }
@@ -139,6 +175,7 @@
       margin: var(--spacing-m);
       padding: var(--image-border-width);
       max-height: 100%;
+      position: relative;
     }
     #spacer {
       visibility: hidden;
@@ -157,6 +194,21 @@
     gr-zoomed-image.revision {
       border-color: var(--revision-image-border-color, rgb(170, 242, 170));
     }
+    #automatic-blink-button {
+      position: absolute;
+      right: var(--spacing-xl);
+      bottom: var(--spacing-xl);
+      opacity: 0;
+      transition: opacity 200ms ease;
+      --paper-fab-background: var(--primary-button-background-color);
+      --paper-fab-keyboard-focus-background: var(
+        --primary-button-background-color
+      );
+    }
+    #automatic-blink-button.show,
+    #automatic-blink-button:focus-visible {
+      opacity: 1;
+    }
     .checkerboard {
       --square-size: var(--checkerboard-square-size, 10px);
       --square-color: var(--checkerboard-square-color, #808080);
@@ -186,6 +238,8 @@
       padding: var(--spacing-m);
       font: var(--image-diff-button-font);
       text-transform: var(--image-diff-button-text-transform, uppercase);
+      outline: 1px solid transparent;
+      border: 1px solid var(--primary-button-background-color);
     }
     paper-button[unelevated] {
       color: var(--primary-button-text-color);
@@ -193,16 +247,30 @@
     }
     paper-button[outlined] {
       color: var(--primary-button-background-color);
-      border-color: var(--primary-button-background-color);
     }
     #version-switcher {
       display: flex;
+      align-items: center;
       margin: var(--spacing-xl);
     }
     #version-switcher paper-button {
-      flex-basis: 0;
       flex-grow: 1;
       margin: 0;
+      /*
+        The floating action button below overlaps part of the version buttons.
+        This min-width ensures the button text still appears somewhat balanced.
+        */
+      min-width: 7rem;
+    }
+    #version-switcher paper-fab {
+      /* Round button overlaps Base and Revision buttons. */
+      z-index: 10;
+      margin: 0 -12px;
+      /* Styled as an outlined button. */
+      color: var(--primary-button-background-color);
+      border: 1px solid var(--primary-button-background-color);
+      --paper-fab-background: var(--primary-background-color);
+      --paper-fab-keyboard-focus-background: var(--primary-background-color);
     }
     #version-explanation {
       color: var(--deemphasized-text-color);
@@ -216,11 +284,87 @@
     #zoom-control {
       margin: 0 var(--spacing-xl);
     }
+    paper-item {
+      cursor: pointer;
+    }
+    paper-item:hover {
+      background-color: var(--hover-background-color);
+    }
     #follow-mouse {
+      margin: var(--spacing-m) var(--spacing-xl);
+    }
+    .color-picker {
       margin: var(--spacing-m) var(--spacing-xl) 0;
     }
+    .color-picker .label {
+      margin-bottom: var(--spacing-s);
+    }
+    .color-picker .options {
+      display: flex;
+      /* Ignore selection border for alignment, for visual balance. */
+      margin-left: -3px;
+    }
+    .color-picker-button {
+      border-width: 2px;
+      border-style: solid;
+      border-color: transparent;
+      border-radius: 50%;
+      width: 24px;
+      height: 24px;
+      padding: 1px;
+    }
+    .color-picker-button.selected {
+      border-color: var(--primary-button-background-color);
+    }
+    .color-picker-button:focus-within:not(.selected) {
+      /* Not an actual outline, as those do not follow border-radius. */
+      border-color: var(--outline-color-focus);
+    }
+    .color-picker-button .color {
+      border: 1px solid var(--border-color);
+      border-radius: 50%;
+      width: 100%;
+      height: 100%;
+      box-sizing: border-box;
+    }
   `;
 
+  private renderColorPickerButton(color: string, colorPicked: () => void) {
+    const selected =
+      color === this.backgroundColor && !this.checkerboardSelected;
+    return html`
+      <div
+        class="${classMap({
+          'color-picker-button': true,
+          selected,
+        })}"
+      >
+        <paper-icon-button
+          class="color"
+          style="${styleMap({backgroundColor: color})}"
+          @click="${colorPicked}"
+        ></paper-icon-button>
+      </div>
+    `;
+  }
+
+  private renderCheckerboardButton() {
+    return html`
+      <div
+        class="${classMap({
+          'color-picker-button': true,
+          selected: this.checkerboardSelected,
+        })}"
+      >
+        <paper-icon-button
+          class="color checkerboard"
+          @click="${this.pickCheckerboard}"
+        >
+        </paper-icon-button>
+      </div>
+    `;
+  }
+
   render() {
     const src = this.baseSelected ? this.baseUrl : this.revisionUrl;
 
@@ -228,8 +372,11 @@
       <img
         id="source-image"
         src="${src}"
-        class="${classMap({
-          checkerboard: this.checkerboardSelected,
+        class="${classMap({checkerboard: this.checkerboardSelected})}"
+        style="${styleMap({
+          backgroundColor: this.checkerboardSelected
+            ? ''
+            : this.backgroundColor,
         })}"
         @load="${this.updateSizes}"
       />
@@ -253,6 +400,8 @@
         >
           Base
         </paper-button>
+        <paper-fab mini icon="gr-icons:swapHoriz" @click="${this.manualBlink}">
+        </paper-fab>
         <paper-button
           class="right"
           ?unelevated=${!this.baseSelected}
@@ -273,7 +422,15 @@
         .frameRect="${this.overviewFrame}"
         @center-updated="${this.onOverviewCenterUpdated}"
       >
-        <img src="${src}" class="checkerboard" />
+        <img
+          src="${src}"
+          class="${classMap({checkerboard: this.checkerboardSelected})}"
+          style="${styleMap({
+            backgroundColor: this.checkerboardSelected
+              ? ''
+              : this.backgroundColor,
+          })}"
+        />
       </gr-overview-image>
     `;
 
@@ -306,6 +463,18 @@
       </paper-checkbox>
     `;
 
+    const backgroundPicker = html`
+      <div class="color-picker">
+        <div class="label">Background</div>
+        <div class="options">
+          ${this.renderCheckerboardButton()}
+          ${this.colorPickerCallbacks.map(({color, callback}) =>
+            this.renderColorPickerButton(color, callback)
+          )}
+        </div>
+      </div>
+    `;
+
     /*
      * We want the content to fill the available space until it can display
      * without being cropped, the maximum of which will be determined by
@@ -327,64 +496,44 @@
       ></div>
     `;
 
-    // To pass CSS mixins for @apply to Polymer components, they need to be
-    // wrapped in a <custom-style>.
+    const automaticBlink = html`
+      <paper-fab
+        id="automatic-blink-button"
+        class="${classMap({show: this.automaticBlinkShown})}"
+        title="Automatic blink"
+        icon="gr-icons:${this.automaticBlink ? 'pause' : 'playArrow'}"
+        @click="${this.toggleAutomaticBlink}"
+      >
+      </paper-fab>
+    `;
+
+    // To pass CSS mixins for @apply to Polymer components, they need to appear
+    // in <style> inside the template.
     const customStyle = html`
-      <custom-style>
-        <style>
-            paper-button.left {
-              --paper-button: {
-                border-radius: 4px 0 0 4px;
-                border-width: 1px 0 1px 1px;
-              }
-            }
-            paper-button.left[outlined] {
-              --paper-button: {
-                border-radius: 4px 0 0 4px;
-                border-width: 1px 0 1px 1px;
-                border-style: solid;
-                border-color: var(--primary-button-background-color);
-              }
-            }
-            paper-button.right {
-              --paper-button: {
-                border-radius: 0 4px 4px 0;
-                border-width: 1px 1px 1px 0;
-              }
-            }
-            paper-button.right[outlined] {
-              --paper-button: {
-                border-radius: 0 4px 4px 0;
-                border-width: 1px 1px 1px 0;
-                border-style: solid;
-                border-color: var(--primary-button-background-color);
-              }
-            }
-            paper-item {
-              cursor: pointer;
-              --paper-item-min-height: 48;
-              --paper-item: {
-                min-height: 48px;
-                padding: 0 var(--spacing-xl);
-              }
-              --paper-item-focused-before: {
-                background-color: var(--selection-background-color);
-              }
-              --paper-item-focused: {
-                background-color: var(--selection-background-color);
-              }
-            }
+      <style>
+        paper-item {
+          --paper-item-min-height: 48;
+          --paper-item: {
+            min-height: 48px;
+            padding: 0 var(--spacing-xl);
           }
-          paper-item:hover {
-            background-color: var(--hover-background-color);
+          --paper-item-focused-before: {
+            background-color: var(--selection-background-color);
           }
-        </style>
-      </custom-style>
+          --paper-item-focused: {
+            background-color: var(--selection-background-color);
+          }
+        }
+      </style>
     `;
 
     return html`
       ${customStyle}
-      <div class="imageArea" @mousemove="${this.mousemoveMagnifier}">
+      <div
+        class="imageArea"
+        @mousemove="${this.mousemoveImageArea}"
+        @mouseleave="${this.mouseleaveImageArea}"
+      >
         <gr-zoomed-image
           class="${classMap({
             base: this.baseSelected,
@@ -404,12 +553,12 @@
         >
           ${sourceImage}
         </gr-zoomed-image>
-        ${spacer}
+        ${this.baseUrl && this.revisionUrl ? automaticBlink : ''} ${spacer}
       </div>
 
       <paper-card class="controls">
         ${versionSwitcher} ${overviewImage} ${zoomControl}
-        ${!this.scaledSelected ? followMouse : ''}
+        ${!this.scaledSelected ? followMouse : ''} ${backgroundPicker}
       </paper-card>
     `;
   }
@@ -439,34 +588,116 @@
   selectBase() {
     if (!this.baseUrl) return;
     this.baseSelected = true;
+    this.dispatchEvent(
+      createEvent({type: 'version-switcher-clicked', button: 'base'})
+    );
   }
 
   selectRevision() {
     if (!this.revisionUrl) return;
     this.baseSelected = false;
+    this.dispatchEvent(
+      createEvent({type: 'version-switcher-clicked', button: 'revision'})
+    );
   }
 
-  toggleImage() {
+  manualBlink() {
+    this.toggleImage();
+    this.dispatchEvent(
+      createEvent({type: 'version-switcher-clicked', button: 'switch'})
+    );
+  }
+
+  private toggleImage() {
     if (this.baseUrl && this.revisionUrl) {
       this.baseSelected = !this.baseSelected;
     }
   }
 
+  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);
+    }
+    this.automaticBlinkTimer = setInterval(() => {
+      this.toggleImage();
+    }, DEFAULT_AUTOMATIC_BLINK_TIME_MS);
+  }
+
   zoomControlChanged(event: CustomEvent) {
     const value = event.detail.value;
     if (!value) return;
     if (value === 'fit') {
       this.scaledSelected = true;
+      this.dispatchEvent(
+        createEvent({type: 'zoom-level-changed', scale: 'fit'})
+      );
     }
     if (value > 0) {
       this.scaledSelected = false;
       this.scale = value;
+      this.dispatchEvent(
+        createEvent({type: 'zoom-level-changed', scale: value})
+      );
     }
     this.updateSizes();
   }
 
   followMouseChanged() {
     this.followMouse = !this.followMouse;
+    this.dispatchEvent(
+      createEvent({type: 'follow-mouse-changed', value: this.followMouse})
+    );
+  }
+
+  pickColor(value: string) {
+    this.checkerboardSelected = false;
+    this.backgroundColor = value;
+    this.dispatchEvent(createEvent({type: 'background-color-changed', value}));
+  }
+
+  pickCheckerboard() {
+    this.checkerboardSelected = true;
+    this.dispatchEvent(
+      createEvent({type: 'background-color-changed', value: 'checkerboard'})
+    );
+  }
+
+  mousemoveImageArea(event: MouseEvent) {
+    if (this.automaticBlinkButton) {
+      this.updateAutomaticBlinkVisibility(event);
+    }
+    this.mousemoveMagnifier(event);
+  }
+
+  private updateAutomaticBlinkVisibility(event: MouseEvent) {
+    const rect = this.automaticBlinkButton!.getBoundingClientRect();
+    const centerX = rect.left + (rect.right - rect.left) / 2;
+    const centerY = rect.top + (rect.bottom - rect.top) / 2;
+    const distX = Math.abs(centerX - event.clientX);
+    const distY = Math.abs(centerY - event.clientY);
+    this.automaticBlinkShown =
+      distX < AUTOMATIC_BLINK_BUTTON_ACTIVE_AREA_PIXELS &&
+      distY < AUTOMATIC_BLINK_BUTTON_ACTIVE_AREA_PIXELS;
+  }
+
+  mouseleaveImageArea() {
+    this.automaticBlinkShown = false;
   }
 
   mousedownMagnifier(event: MouseEvent) {
@@ -481,16 +712,20 @@
   }
 
   mouseupMagnifier(event: MouseEvent) {
+    if (!this.ownsMouseDown) return;
+    this.grabbing = false;
+    this.ownsMouseDown = false;
     const offsetX = event.clientX - this.pointerOnDown.x;
     const offsetY = event.clientY - this.pointerOnDown.y;
     const distance = Math.max(Math.abs(offsetX), Math.abs(offsetY));
     // Consider very short drags as clicks. These tend to happen more often on
     // external mice.
-    if (this.ownsMouseDown && distance < DRAG_DEAD_ZONE_PIXELS) {
+    if (distance < DRAG_DEAD_ZONE_PIXELS) {
       this.toggleImage();
+      this.dispatchEvent(createEvent({type: 'magnifier-clicked'}));
+    } else {
+      this.dispatchEvent(createEvent({type: 'magnifier-dragged'}));
     }
-    this.grabbing = false;
-    this.ownsMouseDown = false;
   }
 
   mousemoveMagnifier(event: MouseEvent) {
@@ -529,8 +764,10 @@
   }
 
   mouseleaveMagnifier() {
+    if (!this.ownsMouseDown) return;
     this.grabbing = false;
     this.ownsMouseDown = false;
+    this.dispatchEvent(createEvent({type: 'magnifier-dragged'}));
   }
 
   dragstartMagnifier(event: DragEvent) {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-image-viewer/gr-overview-image.ts b/polygerrit-ui/app/elements/diff/gr-diff-image-viewer/gr-overview-image.ts
index 55f83d6..9439dca 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-image-viewer/gr-overview-image.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-image-viewer/gr-overview-image.ts
@@ -18,15 +18,16 @@
   css,
   customElement,
   html,
-  internalProperty,
   LitElement,
   property,
   PropertyValues,
   query,
+  state,
 } from 'lit-element';
 import {StyleInfo, styleMap} from 'lit-html/directives/style-map';
+import {ImageDiffAction} from '../../../api/diff';
 
-import {Dimensions, fitToFrame, Point, Rect} from './util';
+import {createEvent, Dimensions, fitToFrame, Point, Rect} from './util';
 
 /**
  * Displays a scaled-down version of an image with a draggable frame for
@@ -44,15 +45,13 @@
   @property({type: Object})
   frameRect: Rect = {origin: {x: 0, y: 0}, dimensions: {width: 0, height: 0}};
 
-  @internalProperty() protected contentStyle: StyleInfo = {};
+  @state() protected contentStyle: StyleInfo = {};
 
-  @internalProperty() protected contentTransformStyle: StyleInfo = {};
+  @state() protected contentTransformStyle: StyleInfo = {};
 
-  @internalProperty() protected frameStyle: StyleInfo = {};
+  @state() protected frameStyle: StyleInfo = {};
 
-  @internalProperty() protected overlayStyle: StyleInfo = {};
-
-  @internalProperty() protected dragging = false;
+  @state() protected dragging = false;
 
   @query('.content-box') protected contentBox!: HTMLDivElement;
 
@@ -62,6 +61,8 @@
 
   @query('.frame') protected frame!: HTMLDivElement;
 
+  protected overlay?: HTMLDivElement;
+
   private contentBounds: Dimensions = {width: 0, height: 0};
 
   private imageBounds: Dimensions = {width: 0, height: 0};
@@ -124,11 +125,6 @@
       position: absolute;
       will-change: transform;
     }
-    .overlay {
-      position: absolute;
-      z-index: 10000;
-      cursor: grabbing;
-    }
   `;
 
   render() {
@@ -158,20 +154,54 @@
             @mousedown="${this.grabFrame}"
           ></div>
         </div>
-        <div
-          class="overlay"
-          style="${styleMap({
-            ...this.overlayStyle,
-            display: this.dragging ? 'block' : 'none',
-          })}"
-          @mousemove="${this.overlayMouseMove}"
-          @mouseleave="${this.releaseFrame}"
-          @mouseup="${this.releaseFrame}"
-        ></div>
       </div>
     `;
   }
 
+  connectedCallback() {
+    super.connectedCallback();
+    if (this.isConnected) {
+      this.overlay = document.createElement('div');
+      // The overlay is added directly to document body to ensure it fills the
+      // entire screen to capture events, without being clipped by any parent
+      // overflow properties. This means it has to be styled manually, since
+      // component styles will not affect it.
+      this.overlay.style.position = 'fixed';
+      this.overlay.style.top = '0';
+      this.overlay.style.left = '0';
+      // We subtract 20 pixels in each dimension to prevent the overlay from
+      // extending offscreen under any existing scrollbar and causing the
+      // scrollbar for the other dimension to show up unnecessarily.
+      this.overlay.style.width = 'calc(100vw - 20px)';
+      this.overlay.style.height = 'calc(100vh - 20px)';
+      this.overlay.style.zIndex = '10000';
+      this.overlay.style.display = 'none';
+
+      this.overlay.addEventListener('mousemove', (event: MouseEvent) =>
+        this.maybeDragFrame(event)
+      );
+      this.overlay.addEventListener('mouseleave', (event: MouseEvent) => {
+        // Ignore mouseleave events that are due to closeOverlay() calls.
+        if (this.overlay?.style.display !== 'none') {
+          this.releaseFrame(event);
+        }
+      });
+      this.overlay.addEventListener('mouseup', (event: MouseEvent) =>
+        this.releaseFrame(event)
+      );
+
+      document.body.appendChild(this.overlay);
+    }
+  }
+
+  disconnectedCallback() {
+    if (this.overlay) {
+      document.body.removeChild(this.overlay);
+      this.overlay = undefined;
+    }
+    super.disconnectedCallback();
+  }
+
   firstUpdated() {
     this.resizeObserver.observe(this.contentBox);
     this.resizeObserver.observe(this.contentTransform);
@@ -187,9 +217,9 @@
     if (event.buttons !== 1) return;
     event.preventDefault();
 
-    this.updateOverlaySize();
-
     this.dragging = true;
+    this.openOverlay();
+
     const rect = this.content.getBoundingClientRect();
     this.notifyNewCenter({
       x: (event.clientX - rect.left) / this.scale,
@@ -203,9 +233,9 @@
     // Do not bubble up into clickOverview().
     event.stopPropagation();
 
-    this.updateOverlaySize();
-
     this.dragging = true;
+    this.openOverlay();
+
     const rect = this.frame.getBoundingClientRect();
     const frameCenterX = rect.x + rect.width / 2;
     const frameCenterY = rect.y + rect.height / 2;
@@ -228,13 +258,29 @@
 
   releaseFrame(event: MouseEvent) {
     event.preventDefault();
+
+    const detail: ImageDiffAction = {
+      type: this.dragging ? 'overview-frame-dragged' : 'overview-image-clicked',
+    };
+    this.dispatchEvent(createEvent(detail));
+
     this.dragging = false;
+    this.closeOverlay();
     this.grabOffset = {x: 0, y: 0};
   }
 
-  overlayMouseMove(event: MouseEvent) {
-    event.preventDefault();
-    this.maybeDragFrame(event);
+  private openOverlay() {
+    if (this.overlay) {
+      this.overlay.style.display = 'block';
+      this.overlay.style.cursor = 'grabbing';
+    }
+  }
+
+  private closeOverlay() {
+    if (this.overlay) {
+      this.overlay.style.display = 'none';
+      this.overlay.style.cursor = '';
+    }
   }
 
   private updateScale() {
@@ -269,25 +315,6 @@
     };
   }
 
-  private updateOverlaySize() {
-    const rect = this.contentBox.getBoundingClientRect();
-    // Create a whole-page overlay to capture mouse events, so that the drag
-    // interaction continues until the user releases the mouse button. Since
-    // innerWidth and innerHeight include scrollbars, we subtract 20 pixels each
-    // to prevent the overlay from extending offscreen under any existing
-    // scrollbar and causing the scrollbar for the other dimension to show up
-    // unnecessarily.
-    const width = window.innerWidth - 20;
-    const height = window.innerHeight - 20;
-    this.overlayStyle = {
-      ...this.overlayStyle,
-      top: `-${rect.top + 1}px`,
-      left: `-${rect.left + 1}px`,
-      width: `${width}px`,
-      height: `${height}px`,
-    };
-  }
-
   private notifyNewCenter(center: Point) {
     this.dispatchEvent(
       new CustomEvent('center-updated', {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-image-viewer/gr-zoomed-image.ts b/polygerrit-ui/app/elements/diff/gr-diff-image-viewer/gr-zoomed-image.ts
index a14a9cc..4558dda 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-image-viewer/gr-zoomed-image.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-image-viewer/gr-zoomed-image.ts
@@ -18,10 +18,10 @@
   css,
   customElement,
   html,
-  internalProperty,
   LitElement,
   property,
   PropertyValues,
+  state,
 } from 'lit-element';
 import {StyleInfo, styleMap} from 'lit-html/directives/style-map';
 import {Rect} from './util';
@@ -41,7 +41,7 @@
   @property({type: Object})
   frameRect: Rect = {origin: {x: 0, y: 0}, dimensions: {width: 0, height: 0}};
 
-  @internalProperty() protected imageStyles: StyleInfo = {};
+  @state() protected imageStyles: StyleInfo = {};
 
   static styles = css`
     :host {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-image-viewer/util.ts b/polygerrit-ui/app/elements/diff/gr-diff-image-viewer/util.ts
index b42eea9..7036ce4 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-image-viewer/util.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-image-viewer/util.ts
@@ -15,6 +15,8 @@
  * limitations under the License.
  */
 
+import {ImageDiffAction} from '../../../api/diff';
+
 export interface Point {
   x: number;
   y: number;
@@ -234,3 +236,13 @@
     };
   }
 }
+
+export function createEvent(
+  detail: ImageDiffAction
+): CustomEvent<ImageDiffAction> {
+  return new CustomEvent('image-diff-action', {
+    detail,
+    bubbles: true,
+    composed: true,
+  });
+}
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection.ts b/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection.ts
index 3df2e18..b64f61d 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection.ts
@@ -28,7 +28,12 @@
 import {DiffInfo} from '../../../types/diff';
 import {Side} from '../../../constants/constants';
 import {GrDiffBuilderElement} from '../gr-diff-builder/gr-diff-builder-element';
-import {getSide, isThreadEl} from '../gr-diff/gr-diff-utils';
+import {
+  getLineElByChild,
+  getSide,
+  getSideByLineEl,
+  isThreadEl,
+} from '../gr-diff/gr-diff-utils';
 
 /**
  * Possible CSS classes indicating the state of selection. Dynamically added/
@@ -110,7 +115,7 @@
     // Handle the down event on comment thread in Polymer 2
     const handled = this._handleDownOnRangeComment(target);
     if (handled) return;
-    const lineEl = this.diffBuilder.getLineElByChild(target);
+    const lineEl = getLineElByChild(target);
     const blameSelected = this._elementDescendedFromClass(target, 'blame');
     if (!lineEl && !blameSelected) {
       return;
@@ -125,7 +130,7 @@
         target,
         'gr-comment'
       );
-      const side = this.diffBuilder.getSideByLineEl(lineEl);
+      const side = getSideByLineEl(lineEl);
 
       targetClasses.push(
         side === 'left' ? SelectionClass.LEFT : SelectionClass.RIGHT
@@ -179,9 +184,9 @@
     if (this.classList.contains(SelectionClass.COMMENT)) {
       commentSelected = true;
     }
-    const lineEl = this.diffBuilder.getLineElByChild(target);
+    const lineEl = getLineElByChild(target);
     if (!lineEl) return;
-    const side = this.diffBuilder.getSideByLineEl(lineEl);
+    const side = getSideByLineEl(lineEl);
     const text = this._getSelectedText(side, commentSelected);
     if (text && e.clipboardData) {
       e.clipboardData.setData('Text', text);
@@ -224,9 +229,9 @@
       return this._getCommentLines(sel, side);
     }
     const range = normalize(sel.getRangeAt(0));
-    const startLineEl = this.diffBuilder.getLineElByChild(range.startContainer);
+    const startLineEl = getLineElByChild(range.startContainer);
     if (!startLineEl) return;
-    const endLineEl = this.diffBuilder.getLineElByChild(range.endContainer);
+    const endLineEl = getLineElByChild(range.endContainer);
     // Happens when triple click in side-by-side mode with other side empty.
     const endsAtOtherEmptySide =
       !endLineEl &&
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection_test.js b/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection_test.js
index 5c9fe3f..8d7264c 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection_test.js
@@ -143,8 +143,8 @@
 
   test('applies selected-left on left side click', () => {
     element.classList.add('selected-right');
-    element._cachedDiffBuilder.getSideByLineEl.returns('left');
-    MockInteractions.down(element);
+    const lineNumberEl = element.querySelector('.lineNum.left');
+    MockInteractions.down(lineNumberEl);
     assert.isTrue(
         element.classList.contains('selected-left'), 'adds selected-left');
     assert.isFalse(
@@ -154,8 +154,8 @@
 
   test('applies selected-right on right side click', () => {
     element.classList.add('selected-left');
-    element._cachedDiffBuilder.getSideByLineEl.returns('right');
-    MockInteractions.down(element);
+    const lineNumberEl = element.querySelector('.lineNum.right');
+    MockInteractions.down(lineNumberEl);
     assert.isTrue(
         element.classList.contains('selected-right'), 'adds selected-right');
     assert.isFalse(
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 4a51349..dacd8e7 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
@@ -37,7 +37,10 @@
   KeyboardShortcutMixin,
   Shortcut,
 } from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {
+  GeneratedWebLink,
+  GerritNav,
+} from '../../core/gr-navigation/gr-navigation';
 import {appContext} from '../../../services/app-context';
 import {
   computeAllPatchSets,
@@ -68,7 +71,6 @@
   ConfigInfo,
   EditInfo,
   EditPatchSetNum,
-  ElementPropertyDeepChange,
   FileInfo,
   NumericChangeId,
   ParentPatchSetNum,
@@ -94,15 +96,24 @@
   getPatchRangeForCommentUrl,
 } from '../../../utils/comment-util';
 import {AppElementParams} from '../../gr-app-types';
-import {CustomKeyboardEvent, OpenFixPreviewEvent} from '../../../types/events';
+import {
+  CustomKeyboardEvent,
+  EventType,
+  OpenFixPreviewEvent,
+} from '../../../types/events';
 import {fireAlert, fireEvent, fireTitleChange} from '../../../utils/event-util';
 import {GerritView} from '../../../services/router/router-model';
 import {assertIsDefined} from '../../../utils/common-util';
-import {toggleClass} from '../../../utils/dom-util';
+import {toggleClass, getKeyboardEvent} from '../../../utils/dom-util';
+import {CursorMoveResult} from '../../../api/core';
+
 const ERR_REVIEW_STATUS = 'Couldn’t change file review status.';
 const MSG_LOADING_BLAME = 'Loading blame...';
 const MSG_LOADED_BLAME = 'Blame loaded';
 
+// Time in which pressing n key again after the toast navigates to next file
+const NAVIGATE_TO_NEXT_FILE_TIMEOUT_MS = 5000;
+
 interface Files {
   sortedFileList: string[];
   changeFilesByPath: {[path: string]: FileInfo};
@@ -116,7 +127,6 @@
 export interface GrDiffView {
   $: {
     commentAPI: GrCommentApi;
-    cursor: GrDiffCursor;
     diffHost: GrDiffHost;
     reviewed: HTMLInputElement;
     dropdown: GrDropdownList;
@@ -226,6 +236,9 @@
   _isImageDiff?: boolean;
 
   @property({type: Object})
+  _editWeblinks?: GeneratedWebLink[];
+
+  @property({type: Object})
   _filesWeblinks?: FilesWebLinks;
 
   @property({type: Object})
@@ -261,6 +274,11 @@
   @property({type: Number})
   _focusLineNum?: number;
 
+  private getReviewedParams: {
+    changeNum?: NumericChangeId;
+    patchNum?: PatchSetNum;
+  } = {};
+
   get keyBindings() {
     return {
       esc: '_handleEscKey',
@@ -319,6 +337,8 @@
 
   _onRenderHandler?: EventListener;
 
+  private cursor = new GrDiffCursor();
+
   /** @override */
   connectedCallback() {
     super.connectedCallback();
@@ -330,22 +350,23 @@
     });
 
     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.cursor.dispose();
     if (this._onRenderHandler) {
       this.$.diffHost.removeEventListener('render', this._onRenderHandler);
     }
     super.disconnectedCallback();
   }
 
-  _getLoggedIn() {
+  _getLoggedIn(): Promise<boolean> {
     return this.restApiService.getLoggedIn();
   }
 
@@ -429,10 +450,6 @@
     return this.restApiService.getPreferences();
   }
 
-  _getWindowWidth() {
-    return window.innerWidth;
-  }
-
   _handleReviewedChange(e: Event) {
     this._setReviewed(
       ((dom(e) as EventApi).rootTarget as HTMLInputElement).checked
@@ -442,8 +459,13 @@
   _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 (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;
     });
@@ -481,14 +503,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) {
@@ -508,14 +530,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) {
@@ -539,7 +561,7 @@
 
     e.preventDefault();
     this.$.diffHost.displayLine = true;
-    this.$.cursor.moveDown();
+    this.cursor.moveDown();
   }
 
   _moveToPreviousFileWithComment() {
@@ -587,13 +609,13 @@
 
     e.preventDefault();
     this.classList.remove('hideComments');
-    this.$.cursor.createCommentInPlace();
+    this.cursor.createCommentInPlace();
   }
 
   _handlePrevFile(e: CustomKeyboardEvent) {
     if (this.shouldSuppressKeyboardShortcut(e)) return;
     // Check for meta key to avoid overriding native chrome shortcut.
-    if (this.getKeyboardEvent(e).metaKey) return;
+    if (getKeyboardEvent(e).metaKey) return;
     if (!this._path) return;
     if (!this._fileList) return;
 
@@ -604,7 +626,7 @@
   _handleNextFile(e: CustomKeyboardEvent) {
     if (this.shouldSuppressKeyboardShortcut(e)) return;
     // Check for meta key to avoid overriding native chrome shortcut.
-    if (this.getKeyboardEvent(e).metaKey) return;
+    if (getKeyboardEvent(e).metaKey) return;
     if (!this._path) return;
     if (!this._fileList) return;
 
@@ -617,26 +639,74 @@
 
     e.preventDefault();
     if (e.detail.keyboardEvent?.shiftKey) {
-      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();
       // navigate to next file if key is not being held down
-      this.$.cursor.moveToNextChunk(
-        /* opt_clipToTop = */ false,
-        /* opt_navigateToNextFile = */ !e.detail.keyboardEvent?.repeat
+      if (
+        !e.detail.keyboardEvent?.repeat &&
+        result === CursorMoveResult.CLIPPED &&
+        this.cursor.isAtEnd()
+      ) {
+        this.showToastAndNavigateFile('next', 'n');
+      }
+    }
+  }
+
+  private lastDisplayedNavigateToFileToast: Map<string, number> = new Map();
+
+  private showToastAndNavigateFile(direction: string, shortcut: string) {
+    /*
+     * If user presses p/n on the first/last diff chunk, show a toast informing
+     * user that pressing it again will navigate them to previous/next
+     * unreviewedfile if click happens within the time limit
+     */
+    if (
+      this.lastDisplayedNavigateToFileToast.get(direction) &&
+      Date.now() - this.lastDisplayedNavigateToFileToast.get(direction)! <=
+        NAVIGATE_TO_NEXT_FILE_TIMEOUT_MS
+    ) {
+      // reset for next file
+      this.lastDisplayedNavigateToFileToast.delete(direction);
+      this.navigateToUnreviewedFile(direction);
+    } else {
+      this.lastDisplayedNavigateToFileToast.set(direction, Date.now());
+      fireAlert(
+        this,
+        `Press ${shortcut} again to navigate to ${direction} unreviewed file`
       );
     }
   }
 
+  private navigateToUnreviewedFile(direction: string) {
+    if (!this._path) return;
+    if (!this._fileList) return;
+    if (!this._reviewedFiles) return;
+    // Ensure that the currently viewed file always appears in unreviewedFiles
+    // so we resolve the right "next" file.
+    const unreviewedFiles = this._fileList.filter(
+      file => file === this._path || !this._reviewedFiles.has(file)
+    );
+
+    this._navToFile(this._path, unreviewedFiles, direction === 'next' ? 1 : -1);
+  }
+
   _handlePrevChunkOrCommentThread(e: CustomKeyboardEvent) {
     if (this.shouldSuppressKeyboardShortcut(e)) return;
 
     e.preventDefault();
     if (e.detail.keyboardEvent?.shiftKey) {
-      this.$.cursor.moveToPreviousCommentThread();
+      this.cursor.moveToPreviousCommentThread();
     } else {
       if (this.modifierPressed(e)) return;
-      this.$.cursor.moveToPreviousChunk(!e.detail.keyboardEvent?.repeat);
+      this.cursor.moveToPreviousChunk();
+      if (!e.detail.keyboardEvent?.repeat && this.cursor.isAtStart()) {
+        this.showToastAndNavigateFile('previous', 'p');
+      }
     }
   }
 
@@ -789,7 +859,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,
@@ -835,31 +905,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) {
@@ -1006,6 +1071,14 @@
       return;
     }
 
+    // Everything in the diff view is tied to the change. It seems better to
+    // force the re-creation of the diff view when the change number changes.
+    const changeChanged = this._changeNum !== value.changeNum;
+    if (this._changeNum !== undefined && changeChanged) {
+      fireEvent(this, EventType.RECREATE_DIFF_VIEW);
+      return;
+    }
+
     this._change = undefined;
     this._files = {sortedFileList: [], changeFilesByPath: {}};
     this._path = undefined;
@@ -1026,7 +1099,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;
     }
 
@@ -1130,44 +1205,39 @@
     }
   }
 
-  @observe('_loggedIn', 'params.*', '_prefs', '_patchRange.*')
+  @observe('_path', '_prefs', '_reviewedFiles')
   _setReviewedObserver(
+    path?: string,
+    prefs?: DiffPreferencesInfo,
+    reviewedFiles?: Set<string>
+  ) {
+    if (prefs === undefined) return;
+    if (path === undefined) return;
+    if (reviewedFiles === 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);
   }
 
   /**
@@ -1178,11 +1248,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 {
@@ -1215,17 +1285,6 @@
     );
   }
 
-  _patchRangeStr(patchRange: PatchRange) {
-    let patchStr = `${patchRange.patchNum}`;
-    if (
-      patchRange.basePatchNum &&
-      patchRange.basePatchNum !== ParentPatchSetNum
-    ) {
-      patchStr = `${patchRange.basePatchNum}..${patchRange.patchNum}`;
-    }
-    return patchStr;
-  }
-
   /**
    * When the latest patch of the change is selected (and there is no base
    * patch) then the patch range need not appear in the URL. Return a patch
@@ -1530,12 +1589,6 @@
     return this._changeComments.getPaths(patchRange);
   }
 
-  _getDiffDrafts() {
-    assertIsDefined(this._changeNum, '_changeNum');
-
-    return this.restApiService.getDiffDrafts(this._changeNum);
-  }
-
   _computeCommentSkips(
     commentMap?: CommentMap,
     fileList?: string[],
@@ -1756,34 +1809,10 @@
     return disableDiffPrefs || !loggedIn;
   }
 
-  _navigateToNextUnreviewedFile() {
-    if (!this._path) return;
-    if (!this._fileList) return;
-    if (!this._reviewedFiles) return;
-    // Ensure that the currently viewed file always appears in unreviewedFiles
-    // so we resolve the right "next" file.
-    const unreviewedFiles = this._fileList.filter(
-      file => file === this._path || !this._reviewedFiles.has(file)
-    );
-    this._navToFile(this._path, unreviewedFiles, 1);
-  }
-
-  _navigateToPreviousUnreviewedFile() {
-    if (!this._path) return;
-    if (!this._fileList) return;
-    if (!this._reviewedFiles) return;
-    // Ensure that the currently viewed file always appears in unreviewedFiles
-    // so we resolve the right "next" file.
-    const unreviewedFiles = this._fileList.filter(
-      file => file === this._path || !this._reviewedFiles.has(file)
-    );
-    this._navToFile(this._path, unreviewedFiles, -1);
-  }
-
   _handleNextUnreviewedFile(e: CustomKeyboardEvent) {
     if (this.shouldSuppressKeyboardShortcut(e)) return;
     this._setReviewed(true);
-    this._navigateToNextUnreviewedFile();
+    this.navigateToUnreviewedFile('next');
   }
 
   _navigateToNextFileWithCommentThread() {
@@ -1806,14 +1835,19 @@
 
   _computeCanEdit(
     loggedIn?: boolean,
+    editWeblinks?: GeneratedWebLink[],
     changeChangeRecord?: PolymerDeepPropertyChange<ChangeInfo, ChangeInfo>
   ) {
     if (!changeChangeRecord?.base) return false;
-    return loggedIn && changeIsOpen(changeChangeRecord.base);
+    return (
+      loggedIn &&
+      changeIsOpen(changeChangeRecord.base) &&
+      (!editWeblinks || editWeblinks.length === 0)
+    );
   }
 
-  _computeIsLoggedIn(loggedIn: boolean) {
-    return loggedIn ? true : false;
+  _computeShowEditLinks(editWeblinks?: GeneratedWebLink[]) {
+    return !!editWeblinks && editWeblinks.length > 0;
   }
 
   /**
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 30c6f49..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
@@ -112,10 +112,6 @@
     .prefsButton {
       text-align: right;
     }
-    .noOverflow {
-      display: block;
-      overflow: auto;
-    }
     .editMode .hideOnEdit {
       display: none;
     }
@@ -320,7 +316,10 @@
             _isBlameLoading)]]</gr-button
           >
         </span>
-        <template is="dom-if" if="[[_computeCanEdit(_loggedIn, _change.*)]]">
+        <template
+          is="dom-if"
+          if="[[_computeCanEdit(_loggedIn, _editWeblinks, _change.*)]]"
+        >
           <span class="separator"></span>
           <span class="editButton">
             <gr-button
@@ -331,6 +330,12 @@
             >
           </span>
         </template>
+        <template is="dom-if" if="[[_computeShowEditLinks(_editWeblinks)]]">
+          <span class="separator"></span>
+          <template is="dom-repeat" items="[[_editWeblinks]]" as="weblink">
+            <a target="_blank" href$="[[weblink.url]]">[[weblink.name]]</a>
+          </template>
+        </template>
         <span class="separator"></span>
         <div class$="diffModeSelector [[_computeModeSelectHideClass(_diff)]]">
           <span>Diff view:</span>
@@ -394,6 +399,7 @@
     hidden=""
     hidden$="[[_loading]]"
     is-image-diff="{{_isImageDiff}}"
+    edit-weblinks="{{_editWeblinks}}"
     files-weblinks="{{_filesWeblinks}}"
     diff="{{_diff}}"
     change-num="[[_changeNum]]"
@@ -423,11 +429,5 @@
     on-reload-diff-preference="_handleReloadingDiffPreference"
   >
   </gr-diff-preferences-dialog>
-  <gr-diff-cursor
-    id="cursor"
-    on-navigate-to-next-unreviewed-file="_navigateToNextUnreviewedFile"
-    on-navigate-to-previous-unreviewed-file="_navigateToPreviousUnreviewedFile"
-    on-navigate-to-next-file-with-comments="_navigateToNextFileWithCommentThread"
-  ></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 5c3dfdc..ebcfa18 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
@@ -29,6 +29,8 @@
   createComment,
 } from '../../../test/test-data-generators.js';
 import {EditPatchSetNum} from '../../../types/common.js';
+import sinon from 'sinon/pkg/sinon-esm';
+import {CursorMoveResult} from '../../../api/core.js';
 
 const basicFixture = fixtureFromElement('gr-diff-view');
 
@@ -89,7 +91,6 @@
     }
 
     let getDiffChangeDetailStub;
-    let getReviewedFilesStub;
     setup(async () => {
       clock = sinon.useFakeTimers();
       stubRestApi('getConfig').returns(Promise.resolve({change: {}}));
@@ -103,8 +104,6 @@
       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';
@@ -420,19 +419,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);
@@ -468,7 +467,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': [{
@@ -855,7 +854,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(() => {
@@ -1155,10 +1154,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',
@@ -1170,17 +1170,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', () => {
@@ -1200,6 +1202,7 @@
         patchNum: 2,
         basePatchNum: 1,
       };
+      element._path = 'abcd';
       element._prefs = {};
       flush();
 
@@ -1378,34 +1381,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', () => {
@@ -1424,7 +1427,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;
@@ -1446,7 +1449,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;
@@ -1671,25 +1674,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');
@@ -1735,17 +1719,131 @@
       });
     });
 
-    test('_paramsChanged sets in projectLookup', () => {
-      sinon.stub(element, '_initLineOfInterestAndCursor');
-      const setStub = stubRestApi('setInProjectLookup');
-      element._paramsChanged({
-        view: GerritNav.View.DIFF,
-        changeNum: 101,
-        project: 'test-project',
-        path: '',
+    suite('switching files', () => {
+      let dispatchEventStub;
+      let navToFileStub;
+      let moveToPreviousChunkStub;
+      let moveToNextChunkStub;
+      let isAtStartStub;
+      let isAtEndStub;
+      let nowStub;
+
+      setup(() => {
+        dispatchEventStub = sinon.stub(
+            element, 'dispatchEvent').callThrough();
+        navToFileStub = sinon.stub(element, '_navToFile');
+        moveToPreviousChunkStub =
+            sinon.stub(element.cursor, 'moveToPreviousChunk');
+        moveToNextChunkStub =
+            sinon.stub(element.cursor, 'moveToNextChunk');
+        isAtStartStub = sinon.stub(element.cursor, 'isAtStart');
+        isAtEndStub = sinon.stub(element.cursor, 'isAtEnd');
+        nowStub = sinon.stub(Date, 'now');
       });
-      assert.isTrue(setStub.calledOnce);
-      assert.isTrue(setStub.calledWith(101, 'test-project'));
+
+      test('shows toast when at the end of file', () => {
+        moveToNextChunkStub.returns(CursorMoveResult.CLIPPED);
+        isAtEndStub.returns(true);
+
+        MockInteractions.pressAndReleaseKeyOn(element, 78, null, 'n');
+
+        assert.isTrue(moveToNextChunkStub.called);
+        assert.equal(dispatchEventStub.lastCall.args[0].type, 'show-alert');
+        assert.isFalse(navToFileStub.called);
+      });
+
+      test('navigates to next file when n is tapped again', () => {
+        moveToNextChunkStub.returns(CursorMoveResult.CLIPPED);
+        isAtEndStub.returns(true);
+
+        element._files = getFilesFromFileList(['file1', 'file2', 'file3']);
+        element._reviewedFiles = new Set(['file2']);
+        element._path = 'file1';
+
+        nowStub.returns(5);
+        MockInteractions.pressAndReleaseKeyOn(element, 78, null, 'n');
+        nowStub.returns(10);
+        MockInteractions.pressAndReleaseKeyOn(element, 78, null, 'n');
+
+        assert.isTrue(navToFileStub.called);
+        assert.deepEqual(navToFileStub.lastCall.args, [
+          'file1',
+          ['file1', 'file3'],
+          1,
+        ]);
+      });
+
+      test('does not navigate if n is tapped twice too slow', () => {
+        moveToNextChunkStub.returns(CursorMoveResult.CLIPPED);
+        isAtEndStub.returns(true);
+
+        nowStub.returns(5);
+        MockInteractions.pressAndReleaseKeyOn(element, 78, null, 'n');
+        nowStub.returns(6000);
+        MockInteractions.pressAndReleaseKeyOn(element, 78, null, 'n');
+
+        assert.isFalse(navToFileStub.called);
+      });
+
+      test('shows toast when at the start of file', () => {
+        moveToPreviousChunkStub.returns(CursorMoveResult.CLIPPED);
+        isAtStartStub.returns(true);
+
+        MockInteractions.pressAndReleaseKeyOn(element, 80, null, 'p');
+
+        assert.isTrue(moveToPreviousChunkStub.called);
+        assert.equal(dispatchEventStub.lastCall.args[0].type, 'show-alert');
+        assert.isFalse(navToFileStub.called);
+      });
+
+      test('navigates to prev file when p is tapped again', () => {
+        moveToPreviousChunkStub.returns(CursorMoveResult.CLIPPED);
+        isAtStartStub.returns(true);
+
+        element._files = getFilesFromFileList(['file1', 'file2', 'file3']);
+        element._reviewedFiles = new Set(['file2']);
+        element._path = 'file3';
+
+        nowStub.returns(5);
+        MockInteractions.pressAndReleaseKeyOn(element, 80, null, 'p');
+        nowStub.returns(10);
+        MockInteractions.pressAndReleaseKeyOn(element, 80, null, 'p');
+
+        assert.isTrue(navToFileStub.called);
+        assert.deepEqual(navToFileStub.lastCall.args, [
+          'file3',
+          ['file1', 'file3'],
+          -1,
+        ]);
+      });
+
+      test('does not navigate if p is tapped twice too slow', () => {
+        moveToPreviousChunkStub.returns(CursorMoveResult.CLIPPED);
+        isAtStartStub.returns(true);
+
+        nowStub.returns(5);
+        MockInteractions.pressAndReleaseKeyOn(element, 80, null, 'p');
+        nowStub.returns(6000);
+        MockInteractions.pressAndReleaseKeyOn(element, 80, null, 'p');
+
+        assert.isFalse(navToFileStub.called);
+      });
+
+      test('does not navigate when tapping n then p', () => {
+        moveToNextChunkStub.returns(CursorMoveResult.CLIPPED);
+        isAtEndStub.returns(true);
+
+        nowStub.returns(5);
+        MockInteractions.pressAndReleaseKeyOn(element, 78, null, 'n');
+
+        moveToPreviousChunkStub.returns(CursorMoveResult.CLIPPED);
+        isAtStartStub.returns(true);
+
+        nowStub.returns(10);
+        MockInteractions.pressAndReleaseKeyOn(element, 80, null, 'p');
+
+        assert.isFalse(navToFileStub.called);
+      });
     });
 
     test('shift+m navigates to next unreviewed file', () => {
@@ -1907,7 +2005,7 @@
     });
   });
 
-  suite('gr-diff-view tests unmodified files with comments', () => {
+  suite('unmodified files with comments', () => {
     let element;
     setup(() => {
       const changedFiles = {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-utils.ts b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-utils.ts
index 0ca929a..fada9cb 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-utils.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-utils.ts
@@ -18,6 +18,11 @@
 import {CommentRange} from '../../../types/common';
 import {FILE, LineNumber} from './gr-diff-line';
 import {Side} from '../../../constants/constants';
+import {DiffInfo} from '../../../types/diff';
+
+// If any line of the diff is more than the character limit, then disable
+// syntax highlighting for the entire file.
+export const SYNTAX_MAX_LINE_LENGTH = 500;
 
 /**
  * Compare two ranges. Either argument may be falsy, but will only return
@@ -43,6 +48,36 @@
   return range.end_line - range.start_line > 10;
 }
 
+export function getLineNumberByChild(node?: Node) {
+  return getLineNumber(getLineElByChild(node));
+}
+
+export function lineNumberToNumber(lineNumber?: LineNumber | null): number {
+  if (!lineNumber) return 0;
+  if (lineNumber === 'LOST') return 0;
+  if (lineNumber === 'FILE') return 0;
+  return lineNumber;
+}
+
+export function getLineElByChild(node?: Node): HTMLElement | null {
+  while (node) {
+    if (node instanceof Element) {
+      if (node.classList.contains('lineNum')) {
+        return node as HTMLElement;
+      }
+      if (node.classList.contains('section')) {
+        return null;
+      }
+    }
+    node = node.previousSibling ?? node.parentElement ?? undefined;
+  }
+  return null;
+}
+
+export function getSideByLineEl(lineEl: Element) {
+  return lineEl.classList.contains(Side.RIGHT) ? Side.RIGHT : Side.LEFT;
+}
+
 export function getLineNumber(lineEl?: Element | null): LineNumber | null {
   if (!lineEl) return null;
   const lineNumberStr = lineEl.getAttribute('data-value');
@@ -100,3 +135,17 @@
     (node as Element).classList.contains('comment-thread')
   );
 }
+
+/**
+ * @return whether any of the lines in diff are longer
+ * than SYNTAX_MAX_LINE_LENGTH.
+ */
+export function anyLineTooLong(diff?: DiffInfo) {
+  if (!diff) return false;
+  return diff.content.some(section => {
+    const lines = section.ab
+      ? section.ab
+      : (section.a || []).concat(section.b || []);
+    return lines.some(line => line.length >= SYNTAX_MAX_LINE_LENGTH);
+  });
+}
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 ae51246..7c437fd 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.ts
@@ -29,6 +29,7 @@
 import {LineNumber} from './gr-diff-line';
 import {
   getLine,
+  getLineElByChild,
   getLineNumber,
   getRange,
   getSide,
@@ -61,11 +62,10 @@
 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,
@@ -549,7 +549,7 @@
       el.classList.contains('content') ||
       el.classList.contains('contentText')
     ) {
-      const target = this.$.diffBuilder.getLineElByChild(el);
+      const target = getLineElByChild(el);
       if (target) {
         this._selectLine(target);
       }
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 bb3bb0b61..9da3bf1 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
@@ -113,6 +113,14 @@
       height: 100%;
       max-width: var(--image-viewer-max-width, 95vw);
       max-height: var(--image-viewer-max-height, 90vh);
+      /*
+        Defined by paper-styles default-theme and used in various components.
+        background-color-secondary is a compromise between fairly light in
+        light theme (where we ideally would want background-color-primary) yet
+        slightly offset against the app background in dark mode, where drop
+        shadows e.g. around paper-card are almost invisible.
+        */
+      --primary-background-color: var(--background-color-secondary);
     }
     .image-diff .gr-diff {
       text-align: center;
@@ -231,44 +239,25 @@
 
     /* dueToMove */
     .dueToMove .content.add .contentText,
-    .dueToMove .moveControls.movedIn .moveLabel,
+    .dueToMove .moveControls.movedIn .moveHeader,
     .delta.total.dueToMove .content.add .contentText {
       background-color: var(--diff-moved-in-background);
     }
 
     .dueToMove .content.remove .contentText,
-    .dueToMove .moveControls.movedOut .moveLabel,
+    .dueToMove .moveControls.movedOut .moveHeader,
     .delta.total.dueToMove .content.remove .contentText {
       background-color: var(--diff-moved-out-background);
     }
 
-    .delta.dueToMove .movedIn .moveDescription {
-      color: var(--diff-moved-in-label-color);
+    .delta.dueToMove .movedIn .moveHeader {
+      --gr-range-header-color: var(--diff-moved-in-label-color);
     }
-    .delta.dueToMove .movedOut .moveDescription {
-      color: var(--diff-moved-out-label-color);
-    }
-    .moveLabel {
-      font-family: var(--font-family, ''), 'Roboto Mono';
-      font-size: var(--font-size-small, 12px);
-      font-weight: var(--code-hint-font-weight, 500);
-      line-height: var(--line-height-small, 16px);
-      padding: var(--spacing-s) var(--spacing-m);
-      margin: var(--spacing-s);
-    }
-    .delta.dueToMove .moveDescription {
-      display: flex;
-      justify-content: flex-end;
+    .delta.dueToMove .movedOut .moveHeader {
+      --gr-range-header-color: var(--diff-moved-out-label-color);
     }
 
-    .moveDescription iron-icon {
-      color: inherit;
-      margin-right: var(--spacing-s);
-      height: var(--line-height-small, 16px);
-      width: var(--line-height-small, 16px);
-    }
-
-    .moveDescription a {
+    .moveHeader a {
       color: inherit;
     }
 
@@ -325,125 +314,14 @@
     .dividerCell {
       vertical-align: top;
     }
-    .dividerRow.showBoth .dividerCell {
+    .dividerRow.show-both .dividerCell {
       height: var(--divider-height);
     }
-    .dividerRow.showAboveOnly .dividerCell,
-    .dividerRow.showBelowOnly .dividerCell {
+    .dividerRow.show-above .dividerCell,
+    .dividerRow.show-above .dividerCell {
       height: 0;
     }
 
-    .verticalFlex {
-      display: flex;
-      flex-direction: column;
-      position: relative;
-    }
-    .dividerRow.showBoth .verticalFlex {
-      justify-content: center;
-      margin-top: calc(0px - var(--line-height-normal) - var(--spacing-s));
-      margin-bottom: calc(0px - var(--line-height-normal) - var(--spacing-s));
-      height: calc(
-        2 * var(--line-height-normal) + 2 * var(--spacing-s) +
-          var(--divider-height) - 1px
-      );
-    }
-    .dividerRow.showAboveOnly .verticalFlex {
-      justify-content: flex-end;
-      /* margin-top has to make room for height+1px. */
-      margin-top: calc(-1px - var(--line-height-normal) - var(--spacing-s));
-      height: calc(var(--line-height-normal) + var(--spacing-s));
-    }
-    .dividerRow.showBelowOnly .verticalFlex {
-      justify-content: flex-start;
-      /* This just pushes the container down 1 pixel as to render below the
-         1px border-top of the padding row below. The same could be achieved
-         by position:relative; top:1px.*/
-      margin-top: 1px;
-      margin-bottom: calc(0px - var(--line-height-normal) - var(--spacing-s));
-    }
-
-    .horizontalFlex {
-      display: flex;
-      justify-content: center;
-    }
-    .dividerRow.showBoth .horizontalFlex {
-      align-items: center;
-    }
-    .dividerRow.showAboveOnly .horizontalFlex {
-      align-items: end;
-    }
-    .dividerRow.showBelowOnly .horizontalFlex {
-      align-items: start;
-    }
-    .contextControlButton {
-      background-color: var(--default-button-background-color);
-      font: var(--context-control-button-font, inherit);
-    }
-    .centeredButton {
-      --gr-button: {
-        color: var(--diff-context-control-color);
-        border-style: solid;
-        border-color: var(--border-color);
-        border-top-width: 1px;
-        border-right-width: 1px;
-        border-bottom-width: 1px;
-        border-left-width: 1px;
-        border-top-left-radius: var(--border-radius);
-        border-top-right-radius: var(--border-radius);
-        border-bottom-right-radius: var(--border-radius);
-        border-bottom-left-radius: var(--border-radius);
-        padding: var(--spacing-s) var(--spacing-l);
-      }
-    }
-    .aboveBelowButtons {
-      display: flex;
-      flex-direction: column;
-      justify-content: center;
-      margin-left: var(--spacing-m);
-    }
-    .aboveBelowButtons:first-child {
-      margin-left: 0;
-    }
-    .dividerRow.showBoth .aboveButton {
-      /* The size of the gap between the above and below button. */
-      margin-bottom: calc(var(--divider-height) + 1px);
-    }
-    .aboveButton {
-      --gr-button: {
-        color: var(--diff-context-control-color);
-        border-style: solid;
-        border-color: var(--border-color);
-        border-top-width: 1px;
-        border-right-width: 1px;
-        border-bottom-width: 0;
-        border-left-width: 1px;
-        border-top-left-radius: var(--border-radius);
-        border-top-right-radius: var(--border-radius);
-        border-bottom-right-radius: 0;
-        border-bottom-left-radius: 0;
-        padding: var(--spacing-xxs) var(--spacing-l);
-      }
-    }
-    .belowButton {
-      --gr-button: {
-        color: var(--diff-context-control-color);
-        border-style: solid;
-        border-color: var(--border-color);
-        border-top-width: 0;
-        border-right-width: 1px;
-        border-bottom-width: 1px;
-        border-left-width: 1px;
-        border-top-left-radius: 0;
-        border-top-right-radius: 0;
-        border-bottom-right-radius: var(--border-radius);
-        border-bottom-left-radius: var(--border-radius);
-        padding: var(--spacing-xxs) var(--spacing-l);
-      }
-    }
-    #diffTable:focus {
-      outline: none;
-    }
-
     .displayLine .diff-row.target-row td {
       box-shadow: inset 0 -1px var(--border-color);
     }
@@ -484,6 +362,9 @@
       color: var(--link-color);
       padding: var(--spacing-m) 0 var(--spacing-m) 48px;
     }
+    #diffTable:focus {
+      outline: none;
+    }
     #loadingError,
     #sizeWarning {
       display: none;
@@ -638,6 +519,10 @@
       border: 1px solid var(--diff-context-control-border-color);
       text-align: center;
     }
+
+    .token-highlight {
+      background-color: var(--token-highlighting-color, #fffd54);
+    }
   </style>
   <style include="gr-syntax-theme">
     /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_test.js b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_test.js
index 86946a6..53a2915 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_test.js
@@ -472,10 +472,12 @@
     test('_handleTap content', done => {
       const content = document.createElement('div');
       const lineEl = document.createElement('div');
+      lineEl.className = 'lineNum';
+      const row = document.createElement('div');
+      row.appendChild(lineEl);
+      row.appendChild(content);
 
       const selectStub = sinon.stub(element, '_selectLine');
-      sinon.stub(element.$.diffBuilder, 'getLineElByChild')
-          .callsFake(() => lineEl);
 
       content.className = 'content';
       content.addEventListener('click', e => {
diff --git a/polygerrit-ui/app/elements/diff/gr-range-header/gr-range-header.ts b/polygerrit-ui/app/elements/diff/gr-range-header/gr-range-header.ts
new file mode 100644
index 0000000..21289fb
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-range-header/gr-range-header.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 {customElement, property} from '@polymer/decorators';
+import {htmlTemplate} from './gr-range-header_html';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+
+/**
+ * Represents a header (label) for a code chunk whenever showing
+ * diffs.
+ * Used as a labeled header to describe selections in code for cases
+ * like long comments and moved in/out chunks.
+ */
+@customElement('gr-range-header')
+export class GrRangeHeader extends PolymerElement {
+  @property({type: String})
+  icon?: string;
+
+  static get template() {
+    return htmlTemplate;
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-range-header': GrRangeHeader;
+  }
+}
diff --git a/polygerrit-ui/app/elements/diff/gr-range-header/gr-range-header_html.ts b/polygerrit-ui/app/elements/diff/gr-range-header/gr-range-header_html.ts
new file mode 100644
index 0000000..34e01db
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-range-header/gr-range-header_html.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 {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style>
+    .row {
+      color: var(--gr-range-header-color);
+      display: flex;
+      font-family: var(--font-family, ''), 'Roboto Mono';
+      font-size: var(--font-size-small, 12px);
+      font-weight: var(--code-hint-font-weight, 500);
+      line-height: var(--line-height-small, 16px);
+      justify-content: flex-end;
+      padding: var(--spacing-s) var(--spacing-l);
+    }
+    .icon {
+      color: var(--gr-range-header-color);
+      height: var(--line-height-small, 16px);
+      width: var(--line-height-small, 16px);
+      margin-right: var(--spacing-s);
+    }
+  </style>
+  <div class="row">
+    <iron-icon class="icon" icon="[[icon]]"></iron-icon>
+    <slot></slot>
+  </div>
+`;
diff --git a/polygerrit-ui/app/elements/diff/gr-ranged-comment-hint/gr-ranged-comment-hint.ts b/polygerrit-ui/app/elements/diff/gr-ranged-comment-hint/gr-ranged-comment-hint.ts
index 3d35c26..d64e02d 100644
--- a/polygerrit-ui/app/elements/diff/gr-ranged-comment-hint/gr-ranged-comment-hint.ts
+++ b/polygerrit-ui/app/elements/diff/gr-ranged-comment-hint/gr-ranged-comment-hint.ts
@@ -15,6 +15,7 @@
  * limitations under the License.
  */
 
+import '../gr-range-header/gr-range-header';
 import {customElement, property} from '@polymer/decorators';
 import {CommentRange} from '../../../types/common';
 import {htmlTemplate} from './gr-ranged-comment-hint_html';
diff --git a/polygerrit-ui/app/elements/diff/gr-ranged-comment-hint/gr-ranged-comment-hint_html.ts b/polygerrit-ui/app/elements/diff/gr-ranged-comment-hint/gr-ranged-comment-hint_html.ts
index 670bfd2..09b7110 100644
--- a/polygerrit-ui/app/elements/diff/gr-ranged-comment-hint/gr-ranged-comment-hint_html.ts
+++ b/polygerrit-ui/app/elements/diff/gr-ranged-comment-hint/gr-ranged-comment-hint_html.ts
@@ -22,25 +22,16 @@
   </style>
   <style include="shared-styles">
     .row {
-      color: var(--ranged-comment-hint-text-color);
       display: flex;
-      font-family: var(--font-family, ''), 'Roboto Mono';
-      font-size: var(--font-size-small, 12px);
-      font-weight: var(--code-hint-font-weight, 500);
-      line-height: var(--line-height-small, 16px);
-      justify-content: flex-end;
-      margin: var(--spacing-xs) 0;
-      padding: var(--spacing-s) var(--spacing-l);
+      --gr-range-header-color: var(--ranged-comment-hint-text-color);
     }
-    .icon {
-      color: var(--ranged-comment-hint-text-color);
-      height: var(--line-height-small, 16px);
-      width: var(--line-height-small, 16px);
-      margin-right: var(--spacing-s);
+    gr-range-header {
+      flex-grow: 1;
     }
   </style>
-  <div class="row rangeHighlight">
-    <iron-icon class="icon" icon="gr-icons:comment"></iron-icon>
-    [[_computeRangeLabel(range)]]
+  <div class="rangeHighlight row">
+    <gr-range-header icon="gr-icons:comment"
+      >[[_computeRangeLabel(range)]]</gr-range-header
+    >
   </div>
 `;
diff --git a/polygerrit-ui/app/elements/diff/gr-ranged-comment-hint/gr-ranged-comment-hint_test.ts b/polygerrit-ui/app/elements/diff/gr-ranged-comment-hint/gr-ranged-comment-hint_test.ts
index 932c367..1ddfcf6 100644
--- a/polygerrit-ui/app/elements/diff/gr-ranged-comment-hint/gr-ranged-comment-hint_test.ts
+++ b/polygerrit-ui/app/elements/diff/gr-ranged-comment-hint/gr-ranged-comment-hint_test.ts
@@ -16,14 +16,24 @@
  */
 
 import '../../../test/common-test-setup-karma';
+import './gr-ranged-comment-hint';
 import {CommentRange} from '../../../types/common';
 import {GrRangedCommentHint} from './gr-ranged-comment-hint';
 
+import {flush} from '@polymer/polymer/lib/legacy/polymer.dom';
+
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+const basicFixture = fixtureFromTemplate(html`
+  <gr-ranged-comment-hint></gr-ranged-comment-hint>
+`);
+
 suite('gr-ranged-comment-hint tests', () => {
   let element: GrRangedCommentHint;
 
-  setup(() => {
-    element = fixtureFromElement('gr-ranged-comment-hint').instantiate();
+  setup(async () => {
+    element = basicFixture.instantiate() as GrRangedCommentHint;
+    await flush();
   });
 
   test('shows line range', async () => {
@@ -34,7 +44,7 @@
       end_character: 3,
     } as CommentRange;
     await flush();
-    const textDiv = element.root!.querySelector<HTMLDivElement>('.row');
-    assert.equal(textDiv!.innerText.trim(), 'Long comment range 2 - 5');
+    const textDiv = element.root?.querySelector('gr-range-header');
+    assert.equal(textDiv?.innerText.trim(), 'Long comment range 2 - 5');
   });
 });
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 ee9e8f9..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
@@ -27,20 +27,12 @@
     <style>
       .rangeHighlight {
         background-color: var(--diff-highlight-range-color);
-        display: inline;
       }
       .rangeHoverHighlight {
         background-color: var(--diff-highlight-range-hover-color);
-        display: inline;
       }
     </style>
   </template>
 </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-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/gr-app-element.ts b/polygerrit-ui/app/elements/gr-app-element.ts
index e96f6e4..be9c7c5 100644
--- a/polygerrit-ui/app/elements/gr-app-element.ts
+++ b/polygerrit-ui/app/elements/gr-app-element.ts
@@ -67,19 +67,18 @@
 import {GrSettingsView} from './settings/gr-settings-view/gr-settings-view';
 import {
   CustomKeyboardEvent,
+  DialogChangeEventDetail,
+  EventType,
   LocationChangeEvent,
   PageErrorEventDetail,
   RpcLogEvent,
-  ShortcutTriggeredEvent,
   TitleChangeEventDetail,
-  DialogChangeEventDetail,
-  EventType,
 } from '../types/events';
 import {ViewState} from '../types/types';
 import {GerritView} from '../services/router/router-model';
-import {windowLocationReload} from '../utils/dom-util';
 import {LifeCycle} from '../constants/reporting';
 import {fireIronAnnounce} from '../utils/event-util';
+import {assertIsDefined} from '../utils/common-util';
 
 interface ErrorInfo {
   text: string;
@@ -96,6 +95,10 @@
   };
 }
 
+type DomIf = PolymerElement & {
+  restamp: boolean;
+};
+
 // TODO(TS): implement AppElement interface from gr-app-types.ts
 @customElement('gr-app-element')
 export class GrAppElement extends KeyboardShortcutMixin(PolymerElement) {
@@ -236,14 +239,13 @@
     this.addEventListener('location-change', e =>
       this._handleLocationChange(e)
     );
-    document.addEventListener('gr-rpc-log', e => this._handleRpcLog(e));
-    this.addEventListener('shortcut-triggered', e =>
-      this._handleShortcutTriggered(e)
+    this.addEventListener(EventType.RECREATE_CHANGE_VIEW, () =>
+      this.handleRecreateView(GerritView.CHANGE)
     );
-    // Ideally individual views should handle this event and respond with a soft
-    // reload. This is a catch-all for all views that cannot or have not
-    // implemented that.
-    this.addEventListener('reload', () => windowLocationReload());
+    this.addEventListener(EventType.RECREATE_DIFF_VIEW, () =>
+      this.handleRecreateView(GerritView.DIFF)
+    );
+    document.addEventListener('gr-rpc-log', e => this._handleRpcLog(e));
   }
 
   /** @override */
@@ -463,35 +465,58 @@
       (this._account && this._account._account_id) || null;
   }
 
-  @observe('params.view')
-  _viewChanged(view?: GerritView) {
+  /**
+   * Throws away the view and re-creates it. The view itself fires an event, if
+   * it wants to be re-created.
+   */
+  private handleRecreateView(view: GerritView.DIFF | GerritView.CHANGE) {
+    const isDiff = view === GerritView.DIFF;
+    const domId = isDiff ? '#dom-if-diff-view' : '#dom-if-change-view';
+    const domIf = this.root!.querySelector(domId) as DomIf;
+    assertIsDefined(domIf, '<dom-if> for the view');
+    // The rendering of DomIf is debounced, so just changing _show...View and
+    // restamp properties back and forth won't work. That is why we are using
+    // timeouts.
+    // The first timeout is needed, because the _viewChanged() observer also
+    // affects _show...View and would change _show...View=false directly back to
+    // _show...View=true.
+    setTimeout(() => {
+      this._showChangeView = false;
+      this._showDiffView = false;
+      domIf.restamp = true;
+      setTimeout(() => {
+        this._showChangeView = this.params?.view === GerritView.CHANGE;
+        this._showDiffView = this.params?.view === GerritView.DIFF;
+        domIf.restamp = false;
+      }, 1);
+    }, 1);
+  }
+
+  @observe('params.*')
+  _viewChanged() {
+    const view = this.params?.view;
     this.$.errorView.classList.remove('show');
-    this.set('_showChangeListView', view === GerritView.SEARCH);
-    this.set('_showDashboardView', view === GerritView.DASHBOARD);
-    this.set('_showChangeView', view === GerritView.CHANGE);
-    this.set('_showDiffView', view === GerritView.DIFF);
-    this.set('_showSettingsView', view === GerritView.SETTINGS);
+    this._showChangeListView = view === GerritView.SEARCH;
+    this._showDashboardView = view === GerritView.DASHBOARD;
+    this._showChangeView = view === GerritView.CHANGE;
+    this._showDiffView = view === GerritView.DIFF;
+    this._showSettingsView = view === GerritView.SETTINGS;
     // _showAdminView must be in sync with the gr-admin-view AdminViewParams type
-    this.set(
-      '_showAdminView',
+    this._showAdminView =
       view === GerritView.ADMIN ||
-        view === GerritView.GROUP ||
-        view === GerritView.REPO
-    );
-    this.set('_showCLAView', view === GerritView.AGREEMENTS);
-    this.set('_showEditorView', view === GerritView.EDIT);
+      view === GerritView.GROUP ||
+      view === GerritView.REPO;
+    this._showCLAView = view === GerritView.AGREEMENTS;
+    this._showEditorView = view === GerritView.EDIT;
     const isPluginScreen = view === GerritView.PLUGIN_SCREEN;
-    this.set('_showPluginScreen', false);
+    this._showPluginScreen = false;
     // Navigation within plugin screens does not restamp gr-endpoint-decorator
     // because _showPluginScreen value does not change. To force restamp,
     // change _showPluginScreen value between true and false.
     if (isPluginScreen) {
-      setTimeout(() => this.set('_showPluginScreen', true), 1);
+      setTimeout(() => (this._showPluginScreen = true), 1);
     }
-    this.set(
-      '_showDocumentationSearch',
-      view === GerritView.DOCUMENTATION_SEARCH
-    );
+    this._showDocumentationSearch = view === GerritView.DOCUMENTATION_SEARCH;
     if (
       this.params &&
       isAppElementJustRegisteredParams(this.params) &&
@@ -515,23 +540,6 @@
     fireIronAnnounce(this, ' ');
   }
 
-  _handleShortcutTriggered(event: ShortcutTriggeredEvent) {
-    const {event: e, goKey, vKey} = event.detail;
-    // eg: {key: "k:keydown", ..., from: "gr-diff-view"}
-    let key = `${((e as unknown) as KeyboardEvent).key}:${e.type}`;
-    if (goKey) key = 'g+' + key;
-    if (vKey) key = 'v+' + key;
-    if (e.shiftKey) key = 'shift+' + key;
-    if (e.ctrlKey) key = 'ctrl+' + key;
-    if (e.metaKey) key = 'meta+' + key;
-    if (e.altKey) key = 'alt+' + key;
-    const path = event.composedPath();
-    this.reporting.reportInteraction('shortcut-triggered', {
-      key,
-      from: (path && path[0] && (path[0] as Element).nodeName) ?? 'unknown',
-    });
-  }
-
   _handlePageError(e: CustomEvent<PageErrorEventDetail>) {
     const props = [
       '_showChangeListView',
@@ -572,7 +580,7 @@
     if (pathname.startsWith('/c/') && Number(hash) > 0) {
       pathname += '@' + hash;
     }
-    this.set('_path', pathname);
+    this._path = pathname;
   }
 
   _updateLoginUrl() {
@@ -607,7 +615,7 @@
     const params = paramsRecord.base;
     const viewsToCheck = [GerritView.SEARCH, GerritView.DASHBOARD];
     if (params?.view && viewsToCheck.includes(params.view)) {
-      this.set('_lastSearchPage', location.pathname);
+      this._lastSearchPage = location.pathname;
     }
   }
 
diff --git a/polygerrit-ui/app/elements/gr-app-element_html.ts b/polygerrit-ui/app/elements/gr-app-element_html.ts
index 81a9988..a1e6ac9 100644
--- a/polygerrit-ui/app/elements/gr-app-element_html.ts
+++ b/polygerrit-ui/app/elements/gr-app-element_html.ts
@@ -131,7 +131,9 @@
         view-state="{{_viewState.dashboardView}}"
       ></gr-dashboard-view>
     </template>
-    <template is="dom-if" if="[[_showChangeView]]" restamp="true">
+    <!-- Note that the change view does not have restamp="true" set, because we
+         want to re-use it as long as the change number does not change. -->
+    <template id="dom-if-change-view" is="dom-if" if="[[_showChangeView]]">
       <gr-change-view
         params="[[params]]"
         view-state="{{_viewState.changeView}}"
@@ -141,7 +143,9 @@
     <template is="dom-if" if="[[_showEditorView]]" restamp="true">
       <gr-editor-view params="[[params]]"></gr-editor-view>
     </template>
-    <template is="dom-if" if="[[_showDiffView]]" restamp="true">
+    <!-- Note that the diff view does not have restamp="true" set, because we
+         want to re-use it as long as the change number does not change. -->
+    <template id="dom-if-diff-view" is="dom-if" if="[[_showDiffView]]">
       <gr-diff-view
         params="[[params]]"
         change-view-state="{{_viewState.changeView}}"
diff --git a/polygerrit-ui/app/elements/gr-app-types.ts b/polygerrit-ui/app/elements/gr-app-types.ts
index 2ae85f7..4d60274 100644
--- a/polygerrit-ui/app/elements/gr-app-types.ts
+++ b/polygerrit-ui/app/elements/gr-app-types.ts
@@ -106,6 +106,16 @@
   leftSide?: boolean;
   commentLink?: boolean;
 }
+
+export interface AppElementDiffEditViewParam {
+  view: GerritView.EDIT;
+  changeNum: NumericChangeId;
+  project: RepoName;
+  path: string;
+  patchNum: RevisionPatchSetNum;
+  lineNum?: number;
+}
+
 export interface AppElementChangeViewParams {
   view: GerritView.CHANGE;
   changeNum: NumericChangeId;
@@ -138,6 +148,7 @@
   | AppElementSettingsParam
   | AppElementAgreementParam
   | AppElementDiffViewParam
+  | AppElementDiffEditViewParam
   | AppElementJustRegisteredParams;
 
 export function isAppElementJustRegisteredParams(
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-plugin-host/gr-plugin-host.ts b/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host.ts
index ac493a2..d0c11fc 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
@@ -27,9 +27,7 @@
   _configChanged(config: ServerInfo) {
     const plugins = config.plugin;
     const jsPlugins = (plugins && plugins.js_resource_paths) || [];
-    const shouldLoadTheme =
-      !!config.default_theme &&
-      !getPluginLoader().isPluginPreloaded('preloaded:gerrit-theme');
+    const shouldLoadTheme = !!config.default_theme;
     // config.default_theme is defined when shouldLoadTheme is true
     const themeToLoad: string[] = shouldLoadTheme
       ? [config.default_theme!]
diff --git a/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host_test.js b/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host_test.js
index 3d35aa4..526832b 100644
--- a/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host_test.js
+++ b/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host_test.js
@@ -56,18 +56,5 @@
       'gerrit-theme.js', 'plugins/42', 'plugins/foo/bar', 'plugins/baz',
     ]));
   });
-
-  test('skip theme if preloaded', () => {
-    sinon.stub(getPluginLoader(), 'isPluginPreloaded')
-        .withArgs('preloaded:gerrit-theme')
-        .returns(true);
-    sinon.stub(getPluginLoader(), 'loadPlugins');
-    element.config = {
-      default_theme: '/oof',
-      plugin: {},
-    };
-    assert.isTrue(getPluginLoader().loadPlugins.calledOnce);
-    assert.isTrue(getPluginLoader().loadPlugins.calledWith([]));
-  });
 });
 
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..e530ac8 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)]]">
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 69ac702..ab24168 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
@@ -31,14 +31,6 @@
       padding: var(--spacing-xxl);
       width: 50em;
     }
-    .publicKey {
-      font-family: var(--monospace-font-family);
-      font-size: var(--font-size-mono);
-      line-height: var(--line-height-mono);
-      overflow-x: scroll;
-      overflow-wrap: break-word;
-      width: 30em;
-    }
     .closeButton {
       bottom: 2em;
       position: absolute;
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.ts b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.ts
index eee850e..f9f7a93 100644
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.ts
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.ts
@@ -74,6 +74,7 @@
   'email_strategy',
   'diff_view',
   'publish_comments_on_push',
+  'disable_keyboard_shortcuts',
   'work_in_progress_by_default',
   'default_base_for_merges',
   'signed_off_by',
@@ -112,6 +113,7 @@
     workInProgressByDefault: HTMLInputElement;
     showSizeBarsInFileList: HTMLInputElement;
     publishCommentsOnPush: HTMLInputElement;
+    disableKeyboardShortcuts: HTMLInputElement;
     relativeDateInChangeTable: HTMLInputElement;
   };
 }
@@ -378,6 +380,13 @@
     );
   }
 
+  _handleDisableKeyboardShortcutsChanged() {
+    this.set(
+      '_localPrefs.disable_keyboard_shortcuts',
+      this.$.disableKeyboardShortcuts.checked
+    );
+  }
+
   _handleWorkInProgressByDefault() {
     this.set(
       '_localPrefs.work_in_progress_by_default',
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_html.ts b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_html.ts
index 6b45e1c..8b000e9 100644
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_html.ts
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_html.ts
@@ -274,6 +274,19 @@
           </span>
         </section>
         <section>
+          <label for="disableKeyboardShortcuts" class="title"
+            >Disable all keyboard shortcuts</label
+          >
+          <span class="value">
+            <input
+              id="disableKeyboardShortcuts"
+              type="checkbox"
+              checked$="[[_localPrefs.disable_keyboard_shortcuts]]"
+              on-change="_handleDisableKeyboardShortcutsChanged"
+            />
+          </span>
+        </section>
+        <section>
           <label for="insertSignedOff" class="title">
             Insert Signed-off-by Footer For Inline Edit Changes
           </label>
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 64bae58..d7078ed 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
@@ -85,7 +85,7 @@
     type: Boolean,
     reflectToAttribute: true,
     computed:
-      '_computeCancelLeftPadding(hideAvatar, _config, ' +
+      '_computeCancelLeftPadding(hideAvatar, ' +
       'highlightAttention, account, change, forceAttention)',
   })
   cancelLeftPadding = false;
@@ -97,6 +97,13 @@
   _config?: ServerInfo;
 
   @property({type: Boolean, reflectToAttribute: true})
+  selectionChipStyle = false;
+
+  @property({
+    type: Boolean,
+    reflectToAttribute: true,
+    observer: 'selectedChanged',
+  })
   selected = false;
 
   @property({type: Boolean, reflectToAttribute: true})
@@ -126,57 +133,46 @@
     });
   }
 
+  selectedChanged(selected?: boolean) {
+    this.deselected = !selected;
+  }
+
   _isAttentionSetEnabled(
-    config: ServerInfo | undefined,
     highlight: boolean,
     account: AccountInfo,
     change: ChangeInfo
   ) {
-    return (
-      !!config &&
-      !!config.change &&
-      !!config.change.enable_attention_set &&
-      !!highlight &&
-      !!change &&
-      !!account &&
-      !isServiceUser(account)
-    );
+    return highlight && !!change && !!account && !isServiceUser(account);
   }
 
   _computeCancelLeftPadding(
     hideAvatar: boolean,
-    config: ServerInfo | undefined,
     highlight: boolean,
     account: AccountInfo,
     change: ChangeInfo,
     force: boolean
   ) {
     return (
-      !hideAvatar &&
-      !this._hasAttention(config, highlight, account, change, force)
+      !hideAvatar && !this._hasAttention(highlight, account, change, force)
     );
   }
 
   _hasAttention(
-    config: ServerInfo | undefined,
     highlight: boolean,
     account: AccountInfo,
     change: ChangeInfo,
     force: boolean
   ) {
-    return (
-      force || this._hasUnforcedAttention(config, highlight, account, change)
-    );
+    return force || this._hasUnforcedAttention(highlight, account, change);
   }
 
   _hasUnforcedAttention(
-    config: ServerInfo | undefined,
     highlight: boolean,
     account: AccountInfo,
     change: ChangeInfo
   ) {
     return (
-      this._isAttentionSetEnabled(config, highlight, account, change) &&
+      this._isAttentionSetEnabled(highlight, account, change) &&
       change.attention_set &&
       !!account._account_id &&
       hasOwnProperty(change.attention_set, account._account_id)
@@ -184,13 +180,12 @@
   }
 
   _computeHasAttentionClass(
-    config: ServerInfo | undefined,
     highlight: boolean,
     account: AccountInfo,
     change: ChangeInfo,
     force: boolean
   ) {
-    return this._hasAttention(config, highlight, account, change, force)
+    return this._hasAttention(highlight, account, change, force)
       ? 'hasAttention'
       : '';
   }
@@ -266,7 +261,6 @@
   }
 
   _computeAttentionButtonEnabled(
-    config: ServerInfo | undefined,
     highlight: boolean,
     account: AccountInfo,
     change: ChangeInfo,
@@ -275,13 +269,12 @@
   ) {
     if (selected) return true;
     return (
-      this._hasUnforcedAttention(config, highlight, account, change) &&
+      this._hasUnforcedAttention(highlight, account, change) &&
       (isInvolved(change, selfAccount) || isSelf(account, selfAccount))
     );
   }
 
   _computeAttentionIconTitle(
-    config: ServerInfo | undefined,
     highlight: boolean,
     account: AccountInfo,
     change: ChangeInfo,
@@ -290,7 +283,6 @@
     selected: boolean
   ) {
     const enabled = this._computeAttentionButtonEnabled(
-      config,
       highlight,
       account,
       change,
diff --git a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_html.ts b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_html.ts
index ff40aeb..a642337 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_html.ts
+++ b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_html.ts
@@ -37,13 +37,13 @@
     :host::after {
       content: var(--account-label-suffix);
     }
-    :host([deselected]) {
+    :host([deselected][selection-chip-style]) {
       background-color: var(--background-color-primary);
       border: 1px solid var(--comment-separator-color);
       border-radius: 8px;
       color: var(--deemphasized-text-color);
     }
-    :host([selected]) {
+    :host([selected][selection-chip-style]) {
       background-color: var(--chip-selected-background-color);
       border: 1px solid var(--chip-selected-background-color);
       border-radius: 8px;
@@ -109,26 +109,26 @@
     </template>
     <template
       is="dom-if"
-      if="[[_hasAttention(_config, highlightAttention, account, change, forceAttention)]]"
+      if="[[_hasAttention(highlightAttention, account, change, forceAttention)]]"
     >
       <gr-button
         id="attentionButton"
         link=""
         aria-label="Remove user from attention set"
         on-click="_handleRemoveAttentionClick"
-        disabled="[[!_computeAttentionButtonEnabled(_config, highlightAttention, account, change, _selfAccount, selected)]]"
-        has-tooltip="[[_computeAttentionButtonEnabled(_config, highlightAttention, account, change, _selfAccount, false)]]"
-        title="[[_computeAttentionIconTitle(_config, highlightAttention, account, change, _selfAccount, forceAttention, selected)]]"
+        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(_config, highlightAttention, account, change, forceAttention)]]"
+    class$="[[_computeHasAttentionClass(highlightAttention, account, change, forceAttention)]]"
   >
     <template is="dom-if" if="[[!hideAvatar]]">
-      <gr-avatar account="[[account]]" image-size="32"></gr-avatar>
+      <gr-avatar account="[[account]]" imageSize="32"></gr-avatar>
     </template>
     <span class="text">
       <span class="name">[[_computeName(account, _config, firstName)]]</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
index f37aa01..459c8c7 100644
--- 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
@@ -23,12 +23,14 @@
 
 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 = {
@@ -81,10 +83,8 @@
 
   suite('attention set', () => {
     setup(async () => {
-      const kermit = createAccount('kermit', 31);
       element.highlightAttention = true;
       element._config = {
-        change: {enable_attention_set: true},
         user: {anonymous_coward_name: 'Anonymous Coward'},
       };
       element._selfAccount = kermit;
@@ -98,15 +98,18 @@
     });
 
     test('show attention button', () => {
-      assert.ok(element.shadowRoot.querySelector('#attentionButton'));
+      const button = element.shadowRoot.querySelector('#attentionButton');
+      assert.ok(button);
+      assert.isNull(button.getAttribute('disabled'));
     });
 
-    test('tap attention button', () => {
+    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-list/gr-account-list.ts b/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list.ts
index 1e54f69..130969e 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
@@ -26,6 +26,7 @@
   Suggestion,
   AccountInfo,
   GroupInfo,
+  EmailAddress,
 } from '../../../types/common';
 import {
   GrReviewerSuggestionsProvider,
@@ -37,6 +38,7 @@
 import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
 import {PaperInputElementExt} from '../../../types/types';
 import {fireAlert} from '../../../utils/event-util';
+import {accountOrGroupKey} from '../../../utils/account-util';
 
 const VALID_EMAIL_ALERT = 'Please input a valid email.';
 
@@ -184,6 +186,8 @@
 
   reporting: ReportingService;
 
+  private pendingRemoval: Set<AccountInput> = new Set();
+
   constructor() {
     super();
     this.reporting = appContext.reportingService;
@@ -233,6 +237,7 @@
     let itemTypeAdded = 'unknown';
     if (isAccountObject(item)) {
       const account = {...item.account, _pendingAdd: true};
+      this.removeFromPendingRemoval(account);
       this.push('accounts', account);
       itemTypeAdded = 'account';
     } else if (isGroupObjectInput(item)) {
@@ -242,6 +247,7 @@
       }
       const group = {...item.group, _pendingAdd: true, _group: true};
       this.push('accounts', group);
+      this.removeFromPendingRemoval(group);
       itemTypeAdded = 'group';
     } else if (this.allowAnyInput) {
       if (!item.includes('@')) {
@@ -251,8 +257,9 @@
         fireAlert(this, VALID_EMAIL_ALERT);
         return false;
       } else {
-        const account = {email: item, _pendingAdd: true};
+        const account = {email: item as EmailAddress, _pendingAdd: true};
         this.push('accounts', account);
+        this.removeFromPendingRemoval(account);
         itemTypeAdded = 'email';
       }
     }
@@ -283,32 +290,16 @@
     return classes.join(' ');
   }
 
-  _accountMatches(a: AccountInput, b: AccountInput) {
-    // TODO(TS): seems a & b always exists ?
-    if (a && b) {
-      // both conditions are checking against AccountInfo
-      // and only check a not b.. typeguard won't work very good without
-      // changing logic, so keep it as inline casting
-      if ((a as AccountInfoInput)._account_id) {
-        return (
-          (a as AccountInfoInput)._account_id ===
-          (b as AccountInfoInput)._account_id
-        );
-      }
-      if ((a as AccountInfoInput).email) {
-        return (a as AccountInfoInput).email === (b as AccountInfoInput).email;
-      }
-    }
-    return a === b;
-  }
-
   _computeRemovable(account: AccountInput, readonly: boolean) {
     if (readonly) {
       return false;
     }
     if (this.removableValues) {
       for (let i = 0; i < this.removableValues.length; i++) {
-        if (this._accountMatches(this.removableValues[i], account)) {
+        if (
+          accountOrGroupKey(this.removableValues[i]) ===
+          accountOrGroupKey(account)
+        ) {
           return true;
         }
       }
@@ -328,21 +319,16 @@
       return;
     }
     for (let i = 0; i < this.accounts.length; i++) {
-      let matches;
-      const account = this.accounts[i];
-      if (toRemove._group) {
-        matches =
-          (toRemove as GroupInfoInput).id === (account as GroupInfoInput).id;
-      } else {
-        matches = this._accountMatches(toRemove, account);
-      }
-      if (matches) {
+      if (accountOrGroupKey(toRemove) === accountOrGroupKey(this.accounts[i])) {
         this.splice('accounts', i, 1);
+        this.pendingRemoval.add(toRemove);
         this.reporting.reportInteraction(`Remove from ${this.id}`);
         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) {
@@ -445,6 +431,26 @@
       });
   }
 
+  removals(): AccountAddition[] {
+    return Array.from(this.pendingRemoval).map(account => {
+      if (isGroupInfoInput(account)) {
+        return {group: account};
+      } else if (isAccountInfoInput(account)) {
+        return {account};
+      } else {
+        throw new Error('AccountInput must be either Account or Group.');
+      }
+    });
+  }
+
+  removeFromPendingRemoval(account: AccountInput) {
+    this.pendingRemoval.delete(account);
+  }
+
+  clearPendingRemovals() {
+    this.pendingRemoval.clear();
+  }
+
   _computeEntryHidden(
     maxCount: number,
     accountsRecord: PolymerDeepPropertyChange<AccountInput[], AccountInput[]>,
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.js
index 20e8672..693f4cb 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.js
@@ -441,19 +441,6 @@
     });
   });
 
-  test('_accountMatches', () => {
-    const acct = makeAccount();
-
-    assert.isTrue(element._accountMatches(acct, acct));
-    acct.email = 'test';
-    assert.isTrue(element._accountMatches(acct, acct));
-    assert.isTrue(element._accountMatches({email: 'test'}, acct));
-
-    assert.isFalse(element._accountMatches({}, acct));
-    assert.isFalse(element._accountMatches({email: 'test2'}, acct));
-    assert.isFalse(element._accountMatches({_account_id: -1}, acct));
-  });
-
   suite('keyboard interactions', () => {
     test('backspace at text input start removes last account', async () => {
       const input = element.$.entry.$.input;
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-avatar/gr-avatar_html.ts b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar_html.ts
deleted file mode 100644
index a1e51df..0000000
--- a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar_html.ts
+++ /dev/null
@@ -1,28 +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;
-      border-radius: 50%;
-      background-size: cover;
-      background-color: var(--avatar-background-color, var(--gray-background));
-    }
-  </style>
-`;
diff --git a/polygerrit-ui/app/elements/shared/gr-button/gr-button.ts b/polygerrit-ui/app/elements/shared/gr-button/gr-button.ts
index 6a7c05b..a25a3dc 100644
--- a/polygerrit-ui/app/elements/shared/gr-button/gr-button.ts
+++ b/polygerrit-ui/app/elements/shared/gr-button/gr-button.ts
@@ -20,8 +20,12 @@
 import {customElement, property, computed, observe} from '@polymer/decorators';
 import {htmlTemplate} from './gr-button_html';
 import {TooltipMixin} from '../../../mixins/gr-tooltip-mixin/gr-tooltip-mixin';
-import {KeyboardShortcutMixin} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
-import {PolymerEvent, getEventPath} from '../../../utils/dom-util';
+import {
+  PolymerEvent,
+  getEventPath,
+  getKeyboardEvent,
+  isModifierPressed,
+} from '../../../utils/dom-util';
 import {appContext} from '../../../services/app-context';
 import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
 import {CustomKeyboardEvent} from '../../../types/events';
@@ -33,9 +37,7 @@
 }
 
 @customElement('gr-button')
-export class GrButton extends KeyboardShortcutMixin(
-  TooltipMixin(PolymerElement)
-) {
+export class GrButton extends TooltipMixin(PolymerElement) {
   static get template() {
     return htmlTemplate;
   }
@@ -121,10 +123,10 @@
   }
 
   _handleKeydown(e: CustomKeyboardEvent) {
-    if (this.modifierPressed(e)) {
+    if (isModifierPressed(e)) {
       return;
     }
-    e = this.getKeyboardEvent(e);
+    e = getKeyboardEvent(e);
     // Handle `enter`, `space`.
     if (e.keyCode === 13 || e.keyCode === 32) {
       e.preventDefault();
diff --git a/polygerrit-ui/app/elements/shared/gr-button/gr-button_html.ts b/polygerrit-ui/app/elements/shared/gr-button/gr-button_html.ts
index 4d94a98..a174627 100644
--- a/polygerrit-ui/app/elements/shared/gr-button/gr-button_html.ts
+++ b/polygerrit-ui/app/elements/shared/gr-button/gr-button_html.ts
@@ -17,7 +17,7 @@
 import {html} from '@polymer/polymer/lib/utils/html-tag';
 
 export const htmlTemplate = html`
-  <style>
+  <style include="gr-spinner-styles">
     /* general styles for all buttons */
     :host {
       --background-color: var(
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 2b66af8..01ca57a 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
@@ -20,7 +20,6 @@
 import {htmlTemplate} from './gr-change-star_html';
 import {customElement, property} from '@polymer/decorators';
 import {ChangeInfo} from '../../../types/common';
-import {KeyboardShortcutMixin} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
 import {fireAlert} from '../../../utils/event-util';
 
 declare global {
@@ -35,7 +34,7 @@
 }
 
 @customElement('gr-change-star')
-export class GrChangeStar extends KeyboardShortcutMixin(PolymerElement) {
+export class GrChangeStar extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
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 1fd019f..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
@@ -15,17 +15,28 @@
  * limitations under the License.
  */
 import '../gr-tooltip-content/gr-tooltip-content';
+import '../gr-icons/gr-icons';
 import '../../../styles/shared-styles';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-change-status_html';
 import {customElement, property} from '@polymer/decorators';
+import {
+  GeneratedWebLink,
+  GerritNav,
+} from '../../core/gr-navigation/gr-navigation';
+import {ChangeInfo} from '../../../types/common';
+import {ParsedChangeInfo} from '../../../types/types';
 
-enum ChangeStates {
-  MERGED = 'Merged',
+export enum ChangeStates {
   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 =
@@ -43,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;
   }
@@ -51,12 +62,21 @@
   @property({type: Boolean, reflectToAttribute: true})
   flat = false;
 
+  @property({type: Object})
+  change?: ChangeInfo | ParsedChangeInfo;
+
   @property({type: String, observer: '_updateChipDetails'})
   status?: ChangeStates;
 
   @property({type: String})
   tooltipText = '';
 
+  @property({type: Object})
+  revertedChange?: ChangeInfo;
+
+  @property({type: Object})
+  resolveWeblinks?: GeneratedWebLink[] = [];
+
   _computeStatusString(status: ChangeStates) {
     if (status === ChangeStates.WIP && !this.flat) {
       return 'Work in Progress';
@@ -68,6 +88,42 @@
     return str ? str.toLowerCase().replace(/\s/g, '-') : '';
   }
 
+  hasStatusLink(
+    revertedChange?: ChangeInfo,
+    resolveWeblinks?: GeneratedWebLink[],
+    status?: ChangeStates
+  ): boolean {
+    const isRevertCreatedOrSubmitted =
+      (status === ChangeStates.REVERT_SUBMITTED ||
+        status === ChangeStates.REVERT_CREATED) &&
+      revertedChange !== undefined;
+    return (
+      isRevertCreatedOrSubmitted ||
+      !!(status === ChangeStates.MERGE_CONFLICT && resolveWeblinks?.length)
+    );
+  }
+
+  getStatusLink(
+    revertedChange?: ChangeInfo,
+    resolveWeblinks?: GeneratedWebLink[],
+    status?: ChangeStates
+  ): string | undefined {
+    if (revertedChange) {
+      return GerritNav.getUrlForSearchQuery(`${revertedChange._number}`);
+    }
+    if (status === ChangeStates.MERGE_CONFLICT && resolveWeblinks?.length) {
+      return resolveWeblinks[0].url;
+    }
+    return undefined;
+  }
+
+  showResolveIcon(
+    resolveWeblinks?: GeneratedWebLink[],
+    status?: ChangeStates
+  ): boolean {
+    return status === ChangeStates.MERGE_CONFLICT && !!resolveWeblinks?.length;
+  }
+
   _updateChipDetails(status?: ChangeStates, previousStatus?: ChangeStates) {
     if (previousStatus) {
       this.classList.remove(this._toClassName(previousStatus));
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 542d8be..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
@@ -52,6 +52,17 @@
       background-color: var(--status-ready);
       color: var(--status-ready);
     }
+    :host(.revert-created) .chip {
+      background-color: var(--status-revert-created);
+      color: var(--status-revert-created);
+    }
+    :host(.revert-submitted) .chip {
+      background-color: var(--status-revert-created);
+      color: var(--status-revert-created);
+    }
+    .status-link {
+      text-decoration: none;
+    }
     :host(.custom) .chip {
       background-color: var(--status-custom);
       color: var(--status-custom);
@@ -60,9 +71,13 @@
       background-color: transparent;
       padding: 0;
     }
-    :host(:not([flat])) .chip {
+    :host(:not([flat])) .chip, .icon {
       color: var(--status-text-color);
     }
+    .icon {
+      --iron-icon-height: 18px;
+      --iron-icon-width: 18px;
+    }
   </style>
   <gr-tooltip-content
     has-tooltip=""
@@ -70,8 +85,26 @@
     title="[[tooltipText]]"
     max-width="40em"
   >
-    <div class="chip" aria-label$="Label: [[status]]">
-      [[_computeStatusString(status)]]
-    </div>
+    <template
+      is="dom-if"
+      if="[[hasStatusLink(revertedChange, resolveWeblinks, status)]]">
+      <a class="status-link"
+         href="[[getStatusLink(revertedChange, resolveWeblinks, status)]]">
+        <div class="chip" aria-label$="Label: [[status]]">
+          [[_computeStatusString(status)]]
+          <iron-icon
+            class="icon"
+            icon="gr-icons:edit"
+            hidden$="[[!showResolveIcon(resolveWeblinks, status)]]">
+          </iron-icon>
+        </div>
+      </a>
+    </template>
+    <template is="dom-if" if="[[!hasStatusLink(revertedChange, resolveWeblinks, status)]]">
+      <div class="chip" aria-label$="Label: [[status]]">
+        [[_computeStatusString(status)]]
+      </div>
+    </template>
   </gr-tooltip-content>
+</span>
 `;
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 16fc664..0000000
--- a/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status_test.js
+++ /dev/null
@@ -1,114 +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-change-status.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';
-    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';
-    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';
-    assert.equal(element.shadowRoot
-        .querySelector('.chip').innerText, element.status);
-    assert.equal(element.tooltipText, '');
-    assert.isTrue(element.classList.contains('merged'));
-  });
-
-  test('abandoned', () => {
-    element.status = 'Abandoned';
-    assert.equal(element.shadowRoot
-        .querySelector('.chip').innerText, element.status);
-    assert.equal(element.tooltipText, '');
-    assert.isTrue(element.classList.contains('abandoned'));
-  });
-
-  test('merge conflict', () => {
-    element.status = 'Merge Conflict';
-    assert.equal(element.shadowRoot
-        .querySelector('.chip').innerText, element.status);
-    assert.equal(element.tooltipText, MERGE_CONFLICT_TOOLTIP);
-    assert.isTrue(element.classList.contains('merge-conflict'));
-  });
-
-  test('private', () => {
-    element.status = 'Private';
-    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';
-    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';
-    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';
-    assert.isTrue(element.classList.contains('private'));
-    assert.isFalse(element.classList.contains('wip'));
-
-    element.status = 'WIP';
-    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 f97146e..d6ad030 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
@@ -41,6 +41,7 @@
 import {computeDisplayPath} from '../../../utils/path-list-util';
 import {computed, customElement, observe, property} from '@polymer/decorators';
 import {
+  AccountDetailInfo,
   CommentRange,
   ConfigInfo,
   NumericChangeId,
@@ -53,12 +54,16 @@
 import {CustomKeyboardEvent} from '../../../types/events';
 import {LineNumber, FILE} from '../../diff/gr-diff/gr-diff-line';
 import {GrButton} from '../gr-button/gr-button';
+import {KnownExperimentId} from '../../../services/flags/flags';
 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 {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';
 
 const UNRESOLVED_EXPAND_COUNT = 5;
 const NEWLINE_PATTERN = /\n/g;
@@ -197,6 +202,9 @@
   @property({type: Boolean})
   showCommentContext = false;
 
+  @property({type: Object})
+  _selfAccount?: AccountDetailInfo;
+
   get keyBindings() {
     return {
       'e shift+e': '_handleEKey',
@@ -236,6 +244,9 @@
       };
       this.syntaxLayer.setEnabled(!!prefs.syntax_highlighting);
     });
+    this.restApiService.getAccount().then(account => {
+      this._selfAccount = account;
+    });
     this._setInitialExpandedState();
   }
 
@@ -243,15 +254,17 @@
   get _diff() {
     if (this.comments === undefined || this.path === undefined) return;
     if (!this.comments[0]?.context_lines?.length) return;
-    waitForEventOnce(this, 'render').then(() => {
-      this.syntaxLayer.process();
-    });
     const diff = computeDiffFromContext(
       this.comments[0].context_lines,
       this.path,
       this.comments[0].source_content_type
     );
-    this.syntaxLayer.init(diff);
+    if (!anyLineTooLong(diff)) {
+      this.syntaxLayer.init(diff);
+      waitForEventOnce(this, 'render').then(() => {
+        this.syntaxLayer.process();
+      });
+    }
     return diff;
   }
 
@@ -335,7 +348,14 @@
 
   _getLayers(diff?: DiffInfo) {
     if (!diff) return [];
-    return [this.syntaxLayer];
+    const layers = [];
+    if (
+      appContext.flagsService.isEnabled(KnownExperimentId.TOKEN_HIGHLIGHTING)
+    ) {
+      layers.push(new TokenHighlightLayer());
+    }
+    layers.push(this.syntaxLayer);
+    return layers;
   }
 
   _getUrlForViewDiff(comments: UIComment[]) {
@@ -392,16 +412,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 '';
   }
 
@@ -686,7 +706,9 @@
     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);
@@ -731,6 +753,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 a1644e7..6ab82b7 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
@@ -80,10 +80,6 @@
       justify-content: space-between;
       padding: 0 var(--spacing-s) var(--spacing-s);
     }
-    .descriptionText {
-      margin-left: var(--spacing-m);
-      font-style: italic;
-    }
     .fileName {
       padding: var(--spacing-m) var(--spacing-s) var(--spacing-m);
     }
@@ -127,12 +123,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"
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..2eeb12d 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
@@ -285,15 +285,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),
+        ''
+      );
     });
   });
 });
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 b7f1bcc..2d20510 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
@@ -97,6 +97,7 @@
   $: {
     container: HTMLDivElement;
     resolvedCheckbox: HTMLInputElement;
+    header: HTMLDivElement;
   };
 }
 
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..16605b3 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
@@ -269,12 +269,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"
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.js b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.js
deleted file mode 100644
index be647ab..0000000
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.js
+++ /dev/null
@@ -1,1354 +0,0 @@
-/**
- * @license
- * Copyright (C) 2015 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../../../test/common-test-setup-karma.js';
-import './gr-comment.js';
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-import {__testOnly_UNSAVED_MESSAGE} from './gr-comment.js';
-import {SpecialFilePath, Side} from '../../../constants/constants.js';
-import {stubRestApi, stubStorage} from '../../../test/test-utils.js';
-
-const basicFixture = fixtureFromElement('gr-comment');
-
-const draftFixture = fixtureFromTemplate(html`
-<gr-comment draft="true"></gr-comment>
-`);
-
-function isVisible(el) {
-  assert.ok(el);
-  return getComputedStyle(el).getPropertyValue('display') !== 'none';
-}
-
-suite('gr-comment tests', () => {
-  suite('basic tests', () => {
-    let element;
-
-    let openOverlaySpy;
-
-    setup(() => {
-      stubRestApi('getAccount').returns(Promise.resolve({
-        email: 'dhruvsri@google.com',
-        name: 'Dhruv Srivastava',
-        _account_id: 1083225,
-        avatars: [{url: 'abc', height: 32}],
-      }));
-      element = basicFixture.instantiate();
-      element.comment = {
-        author: {
-          name: 'Mr. Peanutbutter',
-          email: 'tenn1sballchaser@aol.com',
-        },
-        id: 'baf0414d_60047215',
-        line: 5,
-        message: 'is this a crossover episode!?',
-        updated: '2015-12-08 19:48:33.843000000',
-      };
-
-      openOverlaySpy = sinon.spy(element, '_openOverlay');
-    });
-
-    teardown(() => {
-      openOverlaySpy.getCalls().forEach(call => {
-        call.args[0].remove();
-      });
-    });
-
-    test('collapsible comments', () => {
-      // When a comment (not draft) is loaded, it should be collapsed
-      assert.isTrue(element.collapsed);
-      assert.isFalse(isVisible(element.shadowRoot
-          .querySelector('gr-formatted-text')),
-      'gr-formatted-text is not visible');
-      assert.isFalse(isVisible(element.shadowRoot
-          .querySelector('.actions')),
-      'actions are not visible');
-      assert.isNotOk(element.textarea, 'textarea is not visible');
-
-      // The header middle content is only visible when comments are collapsed.
-      // It shows the message in a condensed way, and limits to a single line.
-      assert.isTrue(isVisible(element.shadowRoot
-          .querySelector('.collapsedContent')),
-      'header middle content is visible');
-
-      // When the header row is clicked, the comment should expand
-      MockInteractions.tap(element.$.header);
-      assert.isFalse(element.collapsed);
-      assert.isTrue(isVisible(element.shadowRoot
-          .querySelector('gr-formatted-text')),
-      'gr-formatted-text is visible');
-      assert.isTrue(isVisible(element.shadowRoot
-          .querySelector('.actions')),
-      'actions are visible');
-      assert.isNotOk(element.textarea, 'textarea is not visible');
-      assert.isFalse(isVisible(element.shadowRoot
-          .querySelector('.collapsedContent')),
-      'header middle content is not visible');
-    });
-
-    test('clicking on date link fires event', () => {
-      element.side = 'PARENT';
-      const stub = sinon.stub();
-      element.addEventListener('comment-anchor-tap', stub);
-      flush();
-      const dateEl = element.shadowRoot
-          .querySelector('.date');
-      assert.ok(dateEl);
-      MockInteractions.tap(dateEl);
-
-      assert.isTrue(stub.called);
-      assert.deepEqual(stub.lastCall.args[0].detail,
-          {side: element.side, number: element.comment.line});
-    });
-
-    test('message is not retrieved from storage when other edits', done => {
-      const storageStub = stubStorage('getDraftComment');
-      const loadSpy = sinon.spy(element, '_loadLocalDraft');
-
-      element.changeNum = 1;
-      element.patchNum = 1;
-      element.comment = {
-        author: {
-          name: 'Mr. Peanutbutter',
-          email: 'tenn1sballchaser@aol.com',
-        },
-        line: 5,
-      };
-      flush(() => {
-        assert.isTrue(loadSpy.called);
-        assert.isFalse(storageStub.called);
-        done();
-      });
-    });
-
-    test('message is retrieved from storage when no other edits', done => {
-      const storageStub = stubStorage('getDraftComment');
-      const loadSpy = sinon.spy(element, '_loadLocalDraft');
-
-      element.changeNum = 1;
-      element.patchNum = 1;
-      element.comment = {
-        author: {
-          name: 'Mr. Peanutbutter',
-          email: 'tenn1sballchaser@aol.com',
-        },
-        line: 5,
-        path: 'test',
-      };
-      flush(() => {
-        assert.isTrue(loadSpy.called);
-        assert.isTrue(storageStub.called);
-        done();
-      });
-    });
-
-    test('_getPatchNum', () => {
-      element.side = 'PARENT';
-      element.patchNum = 1;
-      assert.equal(element._getPatchNum(), 'PARENT');
-      element.side = 'REVISION';
-      assert.equal(element._getPatchNum(), 1);
-    });
-
-    test('comment expand and collapse', () => {
-      element.collapsed = true;
-      assert.isFalse(isVisible(element.shadowRoot
-          .querySelector('gr-formatted-text')),
-      'gr-formatted-text is not visible');
-      assert.isFalse(isVisible(element.shadowRoot
-          .querySelector('.actions')),
-      'actions are not visible');
-      assert.isNotOk(element.textarea, 'textarea is not visible');
-      assert.isTrue(isVisible(element.shadowRoot
-          .querySelector('.collapsedContent')),
-      'header middle content is visible');
-
-      element.collapsed = false;
-      assert.isFalse(element.collapsed);
-      assert.isTrue(isVisible(element.shadowRoot
-          .querySelector('gr-formatted-text')),
-      'gr-formatted-text is visible');
-      assert.isTrue(isVisible(element.shadowRoot
-          .querySelector('.actions')),
-      'actions are visible');
-      assert.isNotOk(element.textarea, 'textarea is not visible');
-      assert.isFalse(isVisible(element.shadowRoot
-          .querySelector('.collapsedContent')),
-      'header middle content is is not visible');
-    });
-
-    suite('while editing', () => {
-      setup(() => {
-        element.editing = true;
-        element._messageText = 'test';
-        sinon.stub(element, '_handleCancel');
-        sinon.stub(element, '_handleSave');
-        flush();
-      });
-
-      suite('when text is empty', () => {
-        setup(() => {
-          element._messageText = '';
-          element.comment = {};
-        });
-
-        test('esc closes comment when text is empty', () => {
-          MockInteractions.pressAndReleaseKeyOn(
-              element.textarea, 27); // esc
-          assert.isTrue(element._handleCancel.called);
-        });
-
-        test('ctrl+enter does not save', () => {
-          MockInteractions.pressAndReleaseKeyOn(
-              element.textarea, 13, 'ctrl'); // ctrl + enter
-          assert.isFalse(element._handleSave.called);
-        });
-
-        test('meta+enter does not save', () => {
-          MockInteractions.pressAndReleaseKeyOn(
-              element.textarea, 13, 'meta'); // meta + enter
-          assert.isFalse(element._handleSave.called);
-        });
-
-        test('ctrl+s does not save', () => {
-          MockInteractions.pressAndReleaseKeyOn(
-              element.textarea, 83, 'ctrl'); // ctrl + s
-          assert.isFalse(element._handleSave.called);
-        });
-      });
-
-      test('esc does not close comment that has content', () => {
-        MockInteractions.pressAndReleaseKeyOn(
-            element.textarea, 27); // esc
-        assert.isFalse(element._handleCancel.called);
-      });
-
-      test('ctrl+enter saves', () => {
-        MockInteractions.pressAndReleaseKeyOn(
-            element.textarea, 13, 'ctrl'); // ctrl + enter
-        assert.isTrue(element._handleSave.called);
-      });
-
-      test('meta+enter saves', () => {
-        MockInteractions.pressAndReleaseKeyOn(
-            element.textarea, 13, 'meta'); // meta + enter
-        assert.isTrue(element._handleSave.called);
-      });
-
-      test('ctrl+s saves', () => {
-        MockInteractions.pressAndReleaseKeyOn(
-            element.textarea, 83, 'ctrl'); // ctrl + s
-        assert.isTrue(element._handleSave.called);
-      });
-    });
-
-    test('delete comment button for non-admins is hidden', () => {
-      element._isAdmin = false;
-      assert.isFalse(element.shadowRoot
-          .querySelector('.action.delete')
-          .classList.contains('showDeleteButtons'));
-    });
-
-    test('delete comment button for admins with draft is hidden', () => {
-      element._isAdmin = false;
-      element.draft = true;
-      assert.isFalse(element.shadowRoot
-          .querySelector('.action.delete')
-          .classList.contains('showDeleteButtons'));
-    });
-
-    test('delete comment', done => {
-      const stub = stubRestApi('deleteComment').returns(Promise.resolve({}));
-      sinon.spy(element.confirmDeleteOverlay, 'open');
-      element.changeNum = 42;
-      element.patchNum = 0xDEADBEEF;
-      element._isAdmin = true;
-      assert.isTrue(element.shadowRoot
-          .querySelector('.action.delete')
-          .classList.contains('showDeleteButtons'));
-      MockInteractions.tap(element.shadowRoot
-          .querySelector('.action.delete'));
-      flush(() => {
-        element.confirmDeleteOverlay.open.lastCall.returnValue.then(() => {
-          const dialog =
-              window.confirmDeleteOverlay
-                  .querySelector('#confirmDeleteComment');
-          dialog.message = 'removal reason';
-          element._handleConfirmDeleteComment();
-          assert.isTrue(stub.calledWith(
-              42, 0xDEADBEEF, 'baf0414d_60047215', 'removal reason'));
-          done();
-        });
-      });
-    });
-
-    suite('draft update reporting', () => {
-      let endStub;
-      let getTimerStub;
-      let mockEvent;
-
-      setup(() => {
-        mockEvent = {preventDefault() {}};
-        sinon.stub(element, 'save')
-            .returns(Promise.resolve({}));
-        sinon.stub(element, '_discardDraft')
-            .returns(Promise.resolve({}));
-        endStub = sinon.stub();
-        getTimerStub = sinon.stub(element.reporting, 'getTimer')
-            .returns({end: endStub});
-      });
-
-      test('create', () => {
-        element.patchNum = 1;
-        element.comment = {};
-        return element._handleSave(mockEvent).then(() => {
-          assert.equal(element.shadowRoot.querySelector('gr-account-label').
-              shadowRoot.querySelector('span.name').innerText.trim(),
-          'Dhruv Srivastava');
-          assert.isTrue(endStub.calledOnce);
-          assert.isTrue(getTimerStub.calledOnce);
-          assert.equal(getTimerStub.lastCall.args[0], 'CreateDraftComment');
-        });
-      });
-
-      test('update', () => {
-        element.comment = {id: 'abc_123'};
-        return element._handleSave(mockEvent).then(() => {
-          assert.isTrue(endStub.calledOnce);
-          assert.isTrue(getTimerStub.calledOnce);
-          assert.equal(getTimerStub.lastCall.args[0], 'UpdateDraftComment');
-        });
-      });
-
-      test('discard', () => {
-        element.comment = {id: 'abc_123'};
-        sinon.stub(element, '_closeConfirmDiscardOverlay');
-        return element._handleConfirmDiscard(mockEvent).then(() => {
-          assert.isTrue(endStub.calledOnce);
-          assert.isTrue(getTimerStub.calledOnce);
-          assert.equal(getTimerStub.lastCall.args[0], 'DiscardDraftComment');
-        });
-      });
-    });
-
-    test('edit reports interaction', () => {
-      const reportStub = sinon.stub(element.reporting,
-          'recordDraftInteraction');
-      element.draft = true;
-      flush();
-      MockInteractions.tap(element.shadowRoot
-          .querySelector('.edit'));
-      assert.isTrue(reportStub.calledOnce);
-    });
-
-    test('discard reports interaction', () => {
-      const reportStub = sinon.stub(element.reporting,
-          'recordDraftInteraction');
-      element.draft = true;
-      flush();
-      MockInteractions.tap(element.shadowRoot
-          .querySelector('.discard'));
-      assert.isTrue(reportStub.calledOnce);
-    });
-
-    test('failed save draft request', done => {
-      element.draft = true;
-      element.changeNum = 1;
-      element.patchNum = 1;
-      const updateRequestStub = sinon.stub(element, '_updateRequestToast');
-      const diffDraftStub =
-        stubRestApi('saveDiffDraft').returns(
-            Promise.resolve({ok: false}));
-      element._saveDraft({id: 'abc_123'});
-      flush(() => {
-        let args = updateRequestStub.lastCall.args;
-        assert.deepEqual(args, [0, true]);
-        assert.equal(element._getSavingMessage(...args),
-            __testOnly_UNSAVED_MESSAGE);
-        assert.equal(element.shadowRoot.querySelector('.draftLabel').innerText,
-            'DRAFT(Failed to save)');
-        assert.isTrue(isVisible(element.shadowRoot
-            .querySelector('.save')), 'save is visible');
-        diffDraftStub.returns(
-            Promise.resolve({ok: true}));
-        element._saveDraft({id: 'abc_123'});
-        flush(() => {
-          args = updateRequestStub.lastCall.args;
-          assert.deepEqual(args, [0]);
-          assert.equal(element._getSavingMessage(...args),
-              'All changes saved');
-          assert.equal(element.shadowRoot.querySelector('.draftLabel')
-              .innerText, 'DRAFT');
-          assert.isFalse(isVisible(element.shadowRoot
-              .querySelector('.save')), 'save is not visible');
-          assert.isFalse(element._unableToSave);
-          done();
-        });
-      });
-    });
-
-    test('failed save draft request with promise failure', done => {
-      element.draft = true;
-      element.changeNum = 1;
-      element.patchNum = 1;
-      const updateRequestStub = sinon.stub(element, '_updateRequestToast');
-      const diffDraftStub =
-        stubRestApi('saveDiffDraft').returns(
-            Promise.reject(new Error()));
-      element._saveDraft({id: 'abc_123'});
-      flush(() => {
-        let args = updateRequestStub.lastCall.args;
-        assert.deepEqual(args, [0, true]);
-        assert.equal(element._getSavingMessage(...args),
-            __testOnly_UNSAVED_MESSAGE);
-        assert.equal(element.shadowRoot.querySelector('.draftLabel').innerText,
-            'DRAFT(Failed to save)');
-        assert.isTrue(isVisible(element.shadowRoot
-            .querySelector('.save')), 'save is visible');
-        diffDraftStub.returns(
-            Promise.resolve({ok: true}));
-        element._saveDraft({id: 'abc_123'});
-        flush(() => {
-          args = updateRequestStub.lastCall.args;
-          assert.deepEqual(args, [0]);
-          assert.equal(element._getSavingMessage(...args),
-              'All changes saved');
-          assert.equal(element.shadowRoot.querySelector('.draftLabel')
-              .innerText, 'DRAFT');
-          assert.isFalse(isVisible(element.shadowRoot
-              .querySelector('.save')), 'save is not visible');
-          assert.isFalse(element._unableToSave);
-          done();
-        });
-      });
-    });
-  });
-
-  suite('gr-comment draft tests', () => {
-    let element;
-
-    setup(() => {
-      stubRestApi('getAccount').returns(Promise.resolve(null));
-      stubRestApi('saveDiffDraft').returns(Promise.resolve({
-        ok: true,
-        text() {
-          return Promise.resolve(
-              ')]}\'\n{' +
-              '"id": "baf0414d_40572e03",' +
-              '"path": "/path/to/file",' +
-              '"line": 5,' +
-              '"updated": "2015-12-08 21:52:36.177000000",' +
-              '"message": "saved!"' +
-              '}'
-          );
-        },
-      }));
-      stubRestApi('removeChangeReviewer').returns(Promise.resolve({ok: true}));
-      element = draftFixture.instantiate();
-      sinon.stub(element.storage, 'getDraftComment').returns(null);
-      element.changeNum = 42;
-      element.patchNum = 1;
-      element.editing = false;
-      element.comment = {
-        diffSide: Side.RIGHT,
-        __draft: true,
-        __draftID: 'temp_draft_id',
-        path: '/path/to/file',
-        line: 5,
-      };
-      element.diffSide = Side.RIGHT;
-    });
-
-    test('button visibility states', () => {
-      element.showActions = false;
-      assert.isTrue(element.shadowRoot
-          .querySelector('.humanActions').hasAttribute('hidden'));
-      assert.isTrue(element.shadowRoot
-          .querySelector('.robotActions').hasAttribute('hidden'));
-
-      element.showActions = true;
-      assert.isFalse(element.shadowRoot
-          .querySelector('.humanActions').hasAttribute('hidden'));
-      assert.isTrue(element.shadowRoot
-          .querySelector('.robotActions').hasAttribute('hidden'));
-
-      element.draft = true;
-      flush();
-      assert.isTrue(isVisible(element.shadowRoot
-          .querySelector('.edit')), 'edit is visible');
-      assert.isTrue(isVisible(element.shadowRoot
-          .querySelector('.discard')), 'discard is visible');
-      assert.isFalse(isVisible(element.shadowRoot
-          .querySelector('.save')), 'save is not visible');
-      assert.isFalse(isVisible(element.shadowRoot
-          .querySelector('.cancel')), 'cancel is not visible');
-      assert.isTrue(isVisible(element.shadowRoot
-          .querySelector('.resolve')), 'resolve is visible');
-      assert.isFalse(element.shadowRoot
-          .querySelector('.humanActions').hasAttribute('hidden'));
-      assert.isTrue(element.shadowRoot
-          .querySelector('.robotActions').hasAttribute('hidden'));
-
-      element.editing = true;
-      flush();
-      assert.isFalse(isVisible(element.shadowRoot
-          .querySelector('.edit')), 'edit is not visible');
-      assert.isFalse(isVisible(element.shadowRoot
-          .querySelector('.discard')), 'discard not visible');
-      assert.isTrue(isVisible(element.shadowRoot
-          .querySelector('.save')), 'save is visible');
-      assert.isTrue(isVisible(element.shadowRoot
-          .querySelector('.cancel')), 'cancel is visible');
-      assert.isTrue(isVisible(element.shadowRoot
-          .querySelector('.resolve')), 'resolve is visible');
-      assert.isFalse(element.shadowRoot
-          .querySelector('.humanActions').hasAttribute('hidden'));
-      assert.isTrue(element.shadowRoot
-          .querySelector('.robotActions').hasAttribute('hidden'));
-
-      element.draft = false;
-      element.editing = false;
-      flush();
-      assert.isFalse(isVisible(element.shadowRoot
-          .querySelector('.edit')), 'edit is not visible');
-      assert.isFalse(isVisible(element.shadowRoot
-          .querySelector('.discard')),
-      'discard is not visible');
-      assert.isFalse(isVisible(element.shadowRoot
-          .querySelector('.save')), 'save is not visible');
-      assert.isFalse(isVisible(element.shadowRoot
-          .querySelector('.cancel')), 'cancel is not visible');
-      assert.isFalse(element.shadowRoot
-          .querySelector('.humanActions').hasAttribute('hidden'));
-      assert.isTrue(element.shadowRoot
-          .querySelector('.robotActions').hasAttribute('hidden'));
-
-      element.comment.id = 'foo';
-      element.draft = true;
-      element.editing = true;
-      flush();
-      assert.isTrue(isVisible(element.shadowRoot
-          .querySelector('.cancel')), 'cancel is visible');
-      assert.isFalse(element.shadowRoot
-          .querySelector('.humanActions').hasAttribute('hidden'));
-      assert.isTrue(element.shadowRoot
-          .querySelector('.robotActions').hasAttribute('hidden'));
-
-      // Delete button is not hidden by default
-      assert.isFalse(element.shadowRoot.querySelector('#deleteBtn').hidden);
-
-      element.isRobotComment = true;
-      element.draft = true;
-      assert.isTrue(element.shadowRoot
-          .querySelector('.humanActions').hasAttribute('hidden'));
-      assert.isFalse(element.shadowRoot
-          .querySelector('.robotActions').hasAttribute('hidden'));
-
-      // It is not expected to see Robot comment drafts, but if they appear,
-      // they will behave the same as non-drafts.
-      element.draft = false;
-      assert.isTrue(element.shadowRoot
-          .querySelector('.humanActions').hasAttribute('hidden'));
-      assert.isFalse(element.shadowRoot
-          .querySelector('.robotActions').hasAttribute('hidden'));
-
-      // A robot comment with run ID should display plain text.
-      element.set(['comment', 'robot_run_id'], 'text');
-      element.editing = false;
-      element.collapsed = false;
-      flush();
-      assert.isTrue(element.shadowRoot
-          .querySelector('.robotRun.link').textContent === 'Run Details');
-
-      // A robot comment with run ID and url should display a link.
-      element.set(['comment', 'url'], '/path/to/run');
-      flush();
-      assert.notEqual(getComputedStyle(element.shadowRoot
-          .querySelector('.robotRun.link')).display,
-      'none');
-
-      // Delete button is hidden for robot comments
-      assert.isTrue(element.shadowRoot.querySelector('#deleteBtn').hidden);
-    });
-
-    test('collapsible drafts', () => {
-      assert.isTrue(element.collapsed);
-      assert.isFalse(isVisible(element.shadowRoot
-          .querySelector('gr-formatted-text')),
-      'gr-formatted-text is not visible');
-      assert.isFalse(isVisible(element.shadowRoot
-          .querySelector('.actions')),
-      'actions are not visible');
-      assert.isNotOk(element.textarea, 'textarea is not visible');
-      assert.isTrue(isVisible(element.shadowRoot
-          .querySelector('.collapsedContent')),
-      'header middle content is visible');
-
-      MockInteractions.tap(element.$.header);
-      assert.isFalse(element.collapsed);
-      assert.isTrue(isVisible(element.shadowRoot
-          .querySelector('gr-formatted-text')),
-      'gr-formatted-text is visible');
-      assert.isTrue(isVisible(element.shadowRoot
-          .querySelector('.actions')),
-      'actions are visible');
-      assert.isNotOk(element.textarea, 'textarea is not visible');
-      assert.isFalse(isVisible(element.shadowRoot
-          .querySelector('.collapsedContent')),
-      'header middle content is is not visible');
-
-      // When the edit button is pressed, should still see the actions
-      // and also textarea
-      element.draft = true;
-      flush();
-      MockInteractions.tap(element.shadowRoot
-          .querySelector('.edit'));
-      flush();
-      assert.isFalse(element.collapsed);
-      assert.isFalse(isVisible(element.shadowRoot
-          .querySelector('gr-formatted-text')),
-      'gr-formatted-text is not visible');
-      assert.isTrue(isVisible(element.shadowRoot
-          .querySelector('.actions')),
-      'actions are visible');
-      assert.isTrue(isVisible(element.textarea), 'textarea is visible');
-      assert.isFalse(isVisible(element.shadowRoot
-          .querySelector('.collapsedContent')),
-      'header middle content is not visible');
-
-      // When toggle again, everything should be hidden except for textarea
-      // and header middle content should be visible
-      MockInteractions.tap(element.$.header);
-      assert.isTrue(element.collapsed);
-      assert.isFalse(isVisible(element.shadowRoot
-          .querySelector('gr-formatted-text')),
-      'gr-formatted-text is not visible');
-      assert.isFalse(isVisible(element.shadowRoot
-          .querySelector('.actions')),
-      'actions are not visible');
-      assert.isFalse(isVisible(element.shadowRoot
-          .querySelector('gr-textarea')),
-      'textarea is not visible');
-      assert.isTrue(isVisible(element.shadowRoot
-          .querySelector('.collapsedContent')),
-      'header middle content is visible');
-
-      // When toggle again, textarea should remain open in the state it was
-      // before
-      MockInteractions.tap(element.$.header);
-      assert.isFalse(isVisible(element.shadowRoot
-          .querySelector('gr-formatted-text')),
-      'gr-formatted-text is not visible');
-      assert.isTrue(isVisible(element.shadowRoot
-          .querySelector('.actions')),
-      'actions are visible');
-      assert.isTrue(isVisible(element.textarea), 'textarea is visible');
-      assert.isFalse(isVisible(element.shadowRoot
-          .querySelector('.collapsedContent')),
-      'header middle content is not visible');
-    });
-
-    test('robot comment layout', done => {
-      const comment = {robot_id: 'happy_robot_id',
-        url: '/robot/comment',
-        author: {
-          name: 'Happy Robot',
-          display_name: 'Display name Robot',
-        }, ...element.comment};
-      element.comment = comment;
-      element.collapsed = false;
-      flush(() => {
-        let runIdMessage;
-        runIdMessage = element.shadowRoot
-            .querySelector('.runIdMessage');
-        assert.isFalse(runIdMessage.hidden);
-
-        const runDetailsLink = element.shadowRoot
-            .querySelector('.robotRunLink');
-        assert.isTrue(runDetailsLink.href.indexOf(element.comment.url) !== -1);
-
-        const robotServiceName = element.shadowRoot
-            .querySelector('.robotName');
-        assert.equal(robotServiceName.textContent.trim(), 'happy_robot_id');
-
-        const authorName = element.shadowRoot
-            .querySelector('.robotId');
-        assert.isTrue(authorName.innerText === 'Happy Robot');
-
-        element.collapsed = true;
-        flush();
-        runIdMessage = element.shadowRoot
-            .querySelector('.runIdMessage');
-        assert.isTrue(runIdMessage.hidden);
-        done();
-      });
-    });
-
-    test('author name fallback to email', done => {
-      const comment = {url: '/robot/comment',
-        author: {
-          email: 'test@test.com',
-        }, ...element.comment};
-      element.comment = comment;
-      element.collapsed = false;
-      flush(() => {
-        const authorName = element.shadowRoot
-            .querySelector('gr-account-label')
-            .shadowRoot.querySelector('span.name');
-        assert.equal(authorName.innerText.trim(), 'test@test.com');
-        done();
-      });
-    });
-
-    test('patchset level comment', done => {
-      const comment = {...element.comment,
-        path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS, line: undefined,
-        range: undefined};
-      element.comment = comment;
-      flush();
-      MockInteractions.tap(element.shadowRoot
-          .querySelector('.edit'));
-      assert.isTrue(element.editing);
-
-      element._messageText = 'hello world';
-      const eraseMessageDraftSpy = sinon.spy(element.storage,
-          'eraseDraftComment');
-      const mockEvent = {preventDefault: sinon.stub()};
-      element._handleSave(mockEvent);
-      flush(() => {
-        assert.isTrue(eraseMessageDraftSpy.called);
-        done();
-      });
-    });
-
-    test('draft creation/cancellation', done => {
-      assert.isFalse(element.editing);
-      element.draft = true;
-      flush();
-      MockInteractions.tap(element.shadowRoot
-          .querySelector('.edit'));
-      assert.isTrue(element.editing);
-
-      element._messageText = '';
-      const eraseMessageDraftSpy = sinon.spy(element, '_eraseDraftComment');
-
-      // Save should be disabled on an empty message.
-      let disabled = element.shadowRoot
-          .querySelector('.save').hasAttribute('disabled');
-      assert.isTrue(disabled, 'save button should be disabled.');
-      element._messageText = '     ';
-      disabled = element.shadowRoot
-          .querySelector('.save').hasAttribute('disabled');
-      assert.isTrue(disabled, 'save button should be disabled.');
-
-      const updateStub = sinon.stub();
-      element.addEventListener('comment-update', updateStub);
-
-      let numDiscardEvents = 0;
-      element.addEventListener('comment-discard', e => {
-        numDiscardEvents++;
-        assert.isFalse(eraseMessageDraftSpy.called);
-        if (numDiscardEvents === 2) {
-          assert.isFalse(updateStub.called);
-          done();
-        }
-      });
-      MockInteractions.tap(element.shadowRoot
-          .querySelector('.cancel'));
-      element.fireUpdateTask.flush();
-      element._messageText = '';
-      flush();
-      MockInteractions.pressAndReleaseKeyOn(element.textarea, 27); // esc
-    });
-
-    test('draft discard removes message from storage', done => {
-      element._messageText = '';
-      const eraseMessageDraftSpy = sinon.spy(element, '_eraseDraftComment');
-      sinon.stub(element, '_closeConfirmDiscardOverlay');
-
-      element.addEventListener('comment-discard', e => {
-        assert.isTrue(eraseMessageDraftSpy.called);
-        done();
-      });
-      element._handleConfirmDiscard({preventDefault: sinon.stub()});
-    });
-
-    test('storage is cleared only after save success', () => {
-      element._messageText = 'test';
-      const eraseStub = sinon.stub(element, '_eraseDraftComment');
-      stubRestApi('getResponseObject')
-          .returns(Promise.resolve({}));
-
-      sinon.stub(element, '_saveDraft').returns(Promise.resolve({ok: false}));
-
-      const savePromise = element.save();
-      assert.isFalse(eraseStub.called);
-      return savePromise.then(() => {
-        assert.isFalse(eraseStub.called);
-
-        element._saveDraft.restore();
-        sinon.stub(element, '_saveDraft')
-            .returns(Promise.resolve({ok: true}));
-        return element.save().then(() => {
-          assert.isTrue(eraseStub.called);
-        });
-      });
-    });
-
-    test('_computeSaveDisabled', () => {
-      const comment = {unresolved: true};
-      const msgComment = {message: 'test', unresolved: true};
-      assert.equal(element._computeSaveDisabled('', comment, false), true);
-      assert.equal(element._computeSaveDisabled('test', comment, false), false);
-      assert.equal(element._computeSaveDisabled('', msgComment, false), true);
-      assert.equal(
-          element._computeSaveDisabled('test', msgComment, false), false);
-      assert.equal(
-          element._computeSaveDisabled('test2', msgComment, false), false);
-      assert.equal(element._computeSaveDisabled('test', comment, true), false);
-      assert.equal(element._computeSaveDisabled('', comment, true), true);
-      assert.equal(element._computeSaveDisabled('', comment, false), true);
-    });
-
-    suite('confirm discard', () => {
-      let discardStub;
-      let overlayStub;
-      let mockEvent;
-
-      setup(() => {
-        discardStub = sinon.stub(element, '_discardDraft');
-        overlayStub = sinon.stub(element, '_openOverlay')
-            .returns(Promise.resolve());
-        mockEvent = {preventDefault: sinon.stub()};
-      });
-
-      test('confirms discard of comments with message text', () => {
-        element._messageText = 'test';
-        element._handleDiscard(mockEvent);
-        assert.isTrue(overlayStub.calledWith(element.confirmDiscardOverlay));
-        assert.isFalse(discardStub.called);
-      });
-
-      test('no confirmation for comments without message text', () => {
-        element._messageText = '';
-        element._handleDiscard(mockEvent);
-        assert.isFalse(overlayStub.called);
-        assert.isTrue(discardStub.calledOnce);
-      });
-    });
-
-    test('ctrl+s saves comment', done => {
-      const stub = sinon.stub(element, 'save').callsFake(() => {
-        assert.isTrue(stub.called);
-        stub.restore();
-        done();
-        return Promise.resolve();
-      });
-      element._messageText = 'is that the horse from horsing around??';
-      element.editing = true;
-      flush();
-      MockInteractions.pressAndReleaseKeyOn(
-          element.textarea.$.textarea.textarea,
-          83, 'ctrl'); // 'ctrl + s'
-    });
-
-    test('draft saving/editing', done => {
-      const dispatchEventStub = sinon.stub(element, 'dispatchEvent');
-
-      element.draft = true;
-      flush();
-      MockInteractions.tap(element.shadowRoot
-          .querySelector('.edit'));
-      element._messageText = 'good news, everyone!';
-      element.fireUpdateTask.flush();
-      element.storeTask.flush();
-      assert.equal(dispatchEventStub.lastCall.args[0].type, 'comment-update');
-      assert.isTrue(dispatchEventStub.calledTwice);
-
-      element._messageText = 'good news, everyone!';
-      element.fireUpdateTask.flush();
-      element.storeTask.flush();
-      assert.isTrue(dispatchEventStub.calledTwice);
-
-      MockInteractions.tap(element.shadowRoot
-          .querySelector('.save'));
-
-      assert.isTrue(element.disabled,
-          'Element should be disabled when creating draft.');
-
-      element._xhrPromise.then(draft => {
-        assert.equal(dispatchEventStub.lastCall.args[0].type, 'comment-save');
-        assert.isFalse(element.storeTask.isActive());
-
-        assert.deepEqual(dispatchEventStub.lastCall.args[0].detail, {
-          comment: {
-            __draft: true,
-            __draftID: 'temp_draft_id',
-            id: 'baf0414d_40572e03',
-            line: 5,
-            message: 'saved!',
-            path: '/path/to/file',
-            updated: '2015-12-08 21:52:36.177000000',
-          },
-          patchNum: 1,
-        });
-        assert.isFalse(element.disabled,
-            'Element should be enabled when done creating draft.');
-        assert.equal(draft.message, 'saved!');
-        assert.isFalse(element.editing);
-      }).then(() => {
-        MockInteractions.tap(element.shadowRoot
-            .querySelector('.edit'));
-        element._messageText = 'You’ll be delivering a package to Chapek 9, ' +
-            'a world where humans are killed on sight.';
-        MockInteractions.tap(element.shadowRoot
-            .querySelector('.save'));
-        assert.isTrue(element.disabled,
-            'Element should be disabled when updating draft.');
-
-        element._xhrPromise.then(draft => {
-          assert.isFalse(element.disabled,
-              'Element should be enabled when done updating draft.');
-          assert.equal(draft.message, 'saved!');
-          assert.isFalse(element.editing);
-          dispatchEventStub.restore();
-          done();
-        });
-      });
-    });
-
-    test('draft prevent save when disabled', () => {
-      const saveStub = sinon.stub(element, 'save').returns(Promise.resolve());
-      element.showActions = true;
-      element.draft = true;
-      flush();
-      MockInteractions.tap(element.$.header);
-      MockInteractions.tap(element.shadowRoot
-          .querySelector('.edit'));
-      element._messageText = 'good news, everyone!';
-      element.fireUpdateTask.flush();
-      element.storeTask.flush();
-
-      element.disabled = true;
-      MockInteractions.tap(element.shadowRoot
-          .querySelector('.save'));
-      assert.isFalse(saveStub.called);
-
-      element.disabled = false;
-      MockInteractions.tap(element.shadowRoot
-          .querySelector('.save'));
-      assert.isTrue(saveStub.calledOnce);
-    });
-
-    test('proper event fires on resolve, comment is not saved', done => {
-      const save = sinon.stub(element, 'save');
-      element.addEventListener('comment-update', e => {
-        assert.isTrue(e.detail.comment.unresolved);
-        assert.isFalse(save.called);
-        done();
-      });
-      MockInteractions.tap(element.shadowRoot
-          .querySelector('.resolve input'));
-    });
-
-    test('resolved comment state indicated by checkbox', () => {
-      sinon.stub(element, 'save');
-      element.comment = {unresolved: false};
-      assert.isTrue(element.shadowRoot
-          .querySelector('.resolve input').checked);
-      element.comment = {unresolved: true};
-      assert.isFalse(element.shadowRoot
-          .querySelector('.resolve input').checked);
-    });
-
-    test('resolved checkbox saves with tap when !editing', () => {
-      element.editing = false;
-      const save = sinon.stub(element, 'save');
-
-      element.comment = {unresolved: false};
-      assert.isTrue(element.shadowRoot
-          .querySelector('.resolve input').checked);
-      element.comment = {unresolved: true};
-      assert.isFalse(element.shadowRoot
-          .querySelector('.resolve input').checked);
-      assert.isFalse(save.called);
-      MockInteractions.tap(element.$.resolvedCheckbox);
-      assert.isTrue(element.shadowRoot
-          .querySelector('.resolve input').checked);
-      assert.isTrue(save.called);
-    });
-
-    suite('draft saving messages', () => {
-      test('_getSavingMessage', () => {
-        assert.equal(element._getSavingMessage(0), 'All changes saved');
-        assert.equal(element._getSavingMessage(1), 'Saving 1 draft...');
-        assert.equal(element._getSavingMessage(2), 'Saving 2 drafts...');
-        assert.equal(element._getSavingMessage(3), 'Saving 3 drafts...');
-      });
-
-      test('_show{Start,End}Request', () => {
-        const updateStub = sinon.stub(element, '_updateRequestToast');
-        element._numPendingDraftRequests.number = 1;
-
-        element._showStartRequest();
-        assert.isTrue(updateStub.calledOnce);
-        assert.equal(updateStub.lastCall.args[0], 2);
-        assert.equal(element._numPendingDraftRequests.number, 2);
-
-        element._showEndRequest();
-        assert.isTrue(updateStub.calledTwice);
-        assert.equal(updateStub.lastCall.args[0], 1);
-        assert.equal(element._numPendingDraftRequests.number, 1);
-
-        element._showEndRequest();
-        assert.isTrue(updateStub.calledThrice);
-        assert.equal(updateStub.lastCall.args[0], 0);
-        assert.equal(element._numPendingDraftRequests.number, 0);
-      });
-    });
-
-    test('cancelling an unsaved draft discards, persists in storage', () => {
-      const discardSpy = sinon.spy(element, '_fireDiscard');
-      const storeStub = stubStorage('setDraftComment');
-      const eraseStub = stubStorage('eraseDraftComment');
-      element._messageText = 'test text';
-      flush();
-      element.storeTask.flush();
-
-      assert.isTrue(storeStub.called);
-      assert.equal(storeStub.lastCall.args[1], 'test text');
-      element._handleCancel({preventDefault: () => {}});
-      assert.isTrue(discardSpy.called);
-      assert.isFalse(eraseStub.called);
-    });
-
-    test('cancelling edit on a saved draft does not store', () => {
-      element.comment.id = 'foo';
-      const discardSpy = sinon.spy(element, '_fireDiscard');
-      const storeStub = stubStorage('setDraftComment');
-      element._messageText = 'test text';
-      flush();
-      if (element.storeTask) element.storeTask.flush();
-
-      assert.isFalse(storeStub.called);
-      element._handleCancel({preventDefault: () => {}});
-      assert.isTrue(discardSpy.called);
-    });
-
-    test('deleting text from saved draft and saving deletes the draft', () => {
-      element.comment = {id: 'foo', message: 'test'};
-      element._messageText = '';
-      const discardStub = sinon.stub(element, '_discardDraft');
-
-      element.save();
-      assert.isTrue(discardStub.called);
-    });
-
-    test('_handleFix fires create-fix event', done => {
-      element.addEventListener('create-fix-comment', e => {
-        assert.deepEqual(e.detail, element._getEventPayload());
-        done();
-      });
-      element.isRobotComment = true;
-      element.comments = [element.comment];
-      flush();
-
-      MockInteractions.tap(element.shadowRoot
-          .querySelector('.fix'));
-    });
-
-    test('do not show Please Fix button if human reply exists', () => {
-      element.comments = [
-        {
-          robot_id: 'happy_robot_id',
-          robot_run_id: '5838406743490560',
-          fix_suggestions: [
-            {
-              fix_id: '478ff847_3bf47aaf',
-              description: 'Make the smiley happier by giving it a nose.',
-              replacements: [
-                {
-                  path: 'Documentation/config-gerrit.txt',
-                  range: {
-                    start_line: 10,
-                    start_character: 7,
-                    end_line: 10,
-                    end_character: 9,
-                  },
-                  replacement: ':-)',
-                },
-              ],
-            },
-          ],
-          author: {
-            _account_id: 1030912,
-            name: 'Alice Kober-Sotzek',
-            email: 'aliceks@google.com',
-            avatars: [
-              {
-                url: '/s32-p/photo.jpg',
-                height: 32,
-              },
-              {
-                url: '/AaAdOFzPlFI/s56-p/photo.jpg',
-                height: 56,
-              },
-              {
-                url: '/AaAdOFzPlFI/s100-p/photo.jpg',
-                height: 100,
-              },
-              {
-                url: '/AaAdOFzPlFI/s120-p/photo.jpg',
-                height: 120,
-              },
-            ],
-          },
-          patch_set: 1,
-          id: 'eb0d03fd_5e95904f',
-          line: 10,
-          updated: '2017-04-04 15:36:17.000000000',
-          message: 'This is a robot comment with a fix.',
-          unresolved: false,
-          diffSide: Side.RIGHT,
-          collapsed: false,
-        },
-        {
-          __draft: true,
-          __draftID: '0.wbrfbwj89sa',
-          __date: '2019-12-04T13:41:03.689Z',
-          path: 'Documentation/config-gerrit.txt',
-          patchNum: 1,
-          side: 'REVISION',
-          diffSide: Side.RIGHT,
-          line: 10,
-          in_reply_to: 'eb0d03fd_5e95904f',
-          message: '> This is a robot comment with a fix.\n\nPlease fix.',
-          unresolved: true,
-        },
-      ];
-      element.comment = element.comments[0];
-      flush();
-      assert.isNull(element.shadowRoot
-          .querySelector('robotActions gr-button'));
-    });
-
-    test('show Please Fix if no human reply', () => {
-      element.comments = [
-        {
-          robot_id: 'happy_robot_id',
-          robot_run_id: '5838406743490560',
-          fix_suggestions: [
-            {
-              fix_id: '478ff847_3bf47aaf',
-              description: 'Make the smiley happier by giving it a nose.',
-              replacements: [
-                {
-                  path: 'Documentation/config-gerrit.txt',
-                  range: {
-                    start_line: 10,
-                    start_character: 7,
-                    end_line: 10,
-                    end_character: 9,
-                  },
-                  replacement: ':-)',
-                },
-              ],
-            },
-          ],
-          author: {
-            _account_id: 1030912,
-            name: 'Alice Kober-Sotzek',
-            email: 'aliceks@google.com',
-            avatars: [
-              {
-                url: '/s32-p/photo.jpg',
-                height: 32,
-              },
-              {
-                url: '/AaAdOFzPlFI/s56-p/photo.jpg',
-                height: 56,
-              },
-              {
-                url: '/AaAdOFzPlFI/s100-p/photo.jpg',
-                height: 100,
-              },
-              {
-                url: '/AaAdOFzPlFI/s120-p/photo.jpg',
-                height: 120,
-              },
-            ],
-          },
-          patch_set: 1,
-          id: 'eb0d03fd_5e95904f',
-          line: 10,
-          updated: '2017-04-04 15:36:17.000000000',
-          message: 'This is a robot comment with a fix.',
-          unresolved: false,
-          diffSide: Side.RIGHT,
-          collapsed: false,
-        },
-      ];
-      element.comment = element.comments[0];
-      flush();
-      assert.isNotNull(element.shadowRoot
-          .querySelector('.robotActions gr-button'));
-    });
-
-    test('_handleShowFix fires open-fix-preview event', done => {
-      element.addEventListener('open-fix-preview', e => {
-        assert.deepEqual(e.detail, element._getEventPayload());
-        done();
-      });
-      element.comment = {fix_suggestions: [{}]};
-      element.isRobotComment = true;
-      flush();
-
-      MockInteractions.tap(element.shadowRoot
-          .querySelector('.show-fix'));
-    });
-  });
-
-  suite('respectful tips', () => {
-    let element;
-
-    let clock;
-    setup(() => {
-      stubRestApi('getAccount').returns(Promise.resolve(null));
-      clock = sinon.useFakeTimers();
-    });
-
-    teardown(() => {
-      clock.restore();
-      sinon.restore();
-    });
-
-    test('show tip when no cached record', done => {
-      element = draftFixture.instantiate();
-      const respectfulGetStub =
-          sinon.stub(element.storage, 'getRespectfulTipVisibility');
-      const respectfulSetStub =
-          sinon.stub(element.storage, 'setRespectfulTipVisibility');
-      respectfulGetStub.returns(null);
-      // fake random
-      element.getRandomNum = () => 0;
-      element.comment = {__editing: true, __draft: true};
-      flush(() => {
-        assert.isTrue(respectfulGetStub.called);
-        assert.isTrue(respectfulSetStub.called);
-        assert.isTrue(
-            !!element.shadowRoot.querySelector('.respectfulReviewTip')
-        );
-        done();
-      });
-    });
-
-    test('add 14-day delays once dismissed', done => {
-      element = draftFixture.instantiate();
-      const respectfulGetStub =
-          sinon.stub(element.storage, 'getRespectfulTipVisibility');
-      const respectfulSetStub =
-          sinon.stub(element.storage, 'setRespectfulTipVisibility');
-      respectfulGetStub.returns(null);
-      // fake random
-      element.getRandomNum = () => 0;
-      element.comment = {__editing: true, __draft: true};
-      flush(() => {
-        assert.isTrue(respectfulGetStub.called);
-        assert.isTrue(respectfulSetStub.called);
-        assert.isTrue(respectfulSetStub.lastCall.args[0] === undefined);
-        assert.isTrue(
-            !!element.shadowRoot.querySelector('.respectfulReviewTip')
-        );
-
-        MockInteractions.tap(element.shadowRoot
-            .querySelector('.respectfulReviewTip .close'));
-        flush();
-        assert.isTrue(respectfulSetStub.lastCall.args[0] === 14);
-        done();
-      });
-    });
-
-    test('do not show tip when fall out of probability', done => {
-      element = draftFixture.instantiate();
-      const respectfulGetStub =
-          sinon.stub(element.storage, 'getRespectfulTipVisibility');
-      const respectfulSetStub =
-          sinon.stub(element.storage, 'setRespectfulTipVisibility');
-      respectfulGetStub.returns(null);
-      // fake random
-      element.getRandomNum = () => 3;
-      element.comment = {__editing: true, __draft: true};
-      flush(() => {
-        assert.isTrue(respectfulGetStub.called);
-        assert.isFalse(respectfulSetStub.called);
-        assert.isFalse(
-            !!element.shadowRoot.querySelector('.respectfulReviewTip')
-        );
-        done();
-      });
-    });
-
-    test('show tip when editing changed to true', done => {
-      element = draftFixture.instantiate();
-      const respectfulGetStub =
-          sinon.stub(element.storage, 'getRespectfulTipVisibility');
-      const respectfulSetStub =
-          sinon.stub(element.storage, 'setRespectfulTipVisibility');
-      respectfulGetStub.returns(null);
-      // fake random
-      element.getRandomNum = () => 0;
-      element.comment = {__editing: false};
-      flush(() => {
-        assert.isFalse(respectfulGetStub.called);
-        assert.isFalse(respectfulSetStub.called);
-        assert.isFalse(
-            !!element.shadowRoot.querySelector('.respectfulReviewTip')
-        );
-
-        element.editing = true;
-        flush(() => {
-          assert.isTrue(respectfulGetStub.called);
-          assert.isTrue(respectfulSetStub.called);
-          assert.isTrue(
-              !!element.shadowRoot.querySelector('.respectfulReviewTip')
-          );
-          done();
-        });
-      });
-    });
-
-    test('no tip when cached record', done => {
-      element = draftFixture.instantiate();
-      const respectfulGetStub =
-          sinon.stub(element.storage, 'getRespectfulTipVisibility');
-      const respectfulSetStub =
-          sinon.stub(element.storage, 'setRespectfulTipVisibility');
-      respectfulGetStub.returns({});
-      // fake random
-      element.getRandomNum = () => 0;
-      element.comment = {__editing: true, __draft: true};
-      flush(() => {
-        assert.isTrue(respectfulGetStub.called);
-        assert.isFalse(respectfulSetStub.called);
-        assert.isFalse(
-            !!element.shadowRoot.querySelector('.respectfulReviewTip')
-        );
-        done();
-      });
-    });
-  });
-});
-
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
new file mode 100644
index 0000000..6d5cec7
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts
@@ -0,0 +1,1563 @@
+/**
+ * @license
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma';
+import './gr-comment';
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+import {GrComment, __testOnly_UNSAVED_MESSAGE} from './gr-comment';
+import {SpecialFilePath, CommentSide} from '../../../constants/constants';
+import {
+  queryAndAssert,
+  stubRestApi,
+  stubStorage,
+  spyStorage,
+  query,
+  isVisible,
+} from '../../../test/test-utils';
+import {
+  AccountId,
+  EmailAddress,
+  FixId,
+  NumericChangeId,
+  ParsedJSON,
+  PatchSetNum,
+  RobotId,
+  RobotRunId,
+  Timestamp,
+  UrlEncodedCommentId,
+} from '../../../types/common';
+import {
+  pressAndReleaseKeyOn,
+  tap,
+} from '@polymer/iron-test-helpers/mock-interactions';
+import {
+  createComment,
+  createDraft,
+  createFixSuggestionInfo,
+} from '../../../test/test-data-generators';
+import {Timer} from '../../../services/gr-reporting/gr-reporting';
+import {SinonFakeTimers, SinonStubbedMember} from 'sinon/pkg/sinon-esm';
+import {CreateFixCommentEvent} from '../../../types/events';
+import {DraftInfo, UIRobot} from '../../../utils/comment-util';
+import {MockTimer} from '../../../services/gr-reporting/gr-reporting_mock';
+import {GrConfirmDeleteCommentDialog} from '../gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog';
+
+const basicFixture = fixtureFromElement('gr-comment');
+
+const draftFixture = fixtureFromTemplate(html`
+  <gr-comment draft="true"></gr-comment>
+`);
+
+suite('gr-comment tests', () => {
+  suite('basic tests', () => {
+    let element: GrComment;
+
+    let openOverlaySpy: sinon.SinonSpy;
+
+    setup(() => {
+      stubRestApi('getAccount').returns(
+        Promise.resolve({
+          email: 'dhruvsri@google.com' as EmailAddress,
+          name: 'Dhruv Srivastava',
+          _account_id: 1083225 as AccountId,
+          avatars: [{url: 'abc', height: 32, width: 32}],
+          registered_on: '123' as Timestamp,
+        })
+      );
+      element = basicFixture.instantiate();
+      element.comment = {
+        ...createComment(),
+        author: {
+          name: 'Mr. Peanutbutter',
+          email: 'tenn1sballchaser@aol.com' as EmailAddress,
+        },
+        id: 'baf0414d_60047215' as UrlEncodedCommentId,
+        line: 5,
+        message: 'is this a crossover episode!?',
+        updated: '2015-12-08 19:48:33.843000000' as Timestamp,
+      };
+
+      openOverlaySpy = sinon.spy(element, '_openOverlay');
+    });
+
+    teardown(() => {
+      openOverlaySpy.getCalls().forEach(call => {
+        call.args[0].remove();
+      });
+    });
+
+    test('collapsible comments', () => {
+      // When a comment (not draft) is loaded, it should be collapsed
+      assert.isTrue(element.collapsed);
+      assert.isFalse(
+        isVisible(queryAndAssert(element, 'gr-formatted-text')),
+        'gr-formatted-text is not visible'
+      );
+      assert.isFalse(
+        isVisible(queryAndAssert(element, '.actions')),
+        'actions are not visible'
+      );
+      assert.isNotOk(element.textarea, 'textarea is not visible');
+
+      // The header middle content is only visible when comments are collapsed.
+      // It shows the message in a condensed way, and limits to a single line.
+      assert.isTrue(
+        isVisible(queryAndAssert(element, '.collapsedContent')),
+        'header middle content is visible'
+      );
+
+      // When the header row is clicked, the comment should expand
+      tap(element.$.header);
+      assert.isFalse(element.collapsed);
+      assert.isTrue(
+        isVisible(queryAndAssert(element, 'gr-formatted-text')),
+        'gr-formatted-text is visible'
+      );
+      assert.isTrue(
+        isVisible(queryAndAssert(element, '.actions')),
+        'actions are visible'
+      );
+      assert.isNotOk(element.textarea, 'textarea is not visible');
+      assert.isFalse(
+        isVisible(queryAndAssert(element, '.collapsedContent')),
+        'header middle content is not visible'
+      );
+    });
+
+    test('clicking on date link fires event', () => {
+      element.side = 'PARENT';
+      const stub = sinon.stub();
+      element.addEventListener('comment-anchor-tap', stub);
+      flush();
+      const dateEl = queryAndAssert(element, '.date');
+      assert.ok(dateEl);
+      tap(dateEl);
+
+      assert.isTrue(stub.called);
+      assert.deepEqual(stub.lastCall.args[0].detail, {
+        side: element.side,
+        number: element.comment!.line,
+      });
+    });
+
+    test('message is not retrieved from storage when other edits', done => {
+      const storageStub = stubStorage('getDraftComment');
+      const loadSpy = sinon.spy(element, '_loadLocalDraft');
+
+      element.changeNum = 1 as NumericChangeId;
+      element.patchNum = 1 as PatchSetNum;
+      element.comment = {
+        author: {
+          name: 'Mr. Peanutbutter',
+          email: 'tenn1sballchaser@aol.com' as EmailAddress,
+        },
+        line: 5,
+      };
+      flush(() => {
+        assert.isTrue(loadSpy.called);
+        assert.isFalse(storageStub.called);
+        done();
+      });
+    });
+
+    test('message is retrieved from storage when no other edits', done => {
+      const storageStub = stubStorage('getDraftComment');
+      const loadSpy = sinon.spy(element, '_loadLocalDraft');
+
+      element.changeNum = 1 as NumericChangeId;
+      element.patchNum = 1 as PatchSetNum;
+      element.comment = {
+        author: {
+          name: 'Mr. Peanutbutter',
+          email: 'tenn1sballchaser@aol.com' as EmailAddress,
+        },
+        line: 5,
+        path: 'test',
+      };
+      flush(() => {
+        assert.isTrue(loadSpy.called);
+        assert.isTrue(storageStub.called);
+        done();
+      });
+    });
+
+    test('_getPatchNum', () => {
+      element.side = 'PARENT';
+      element.patchNum = 1 as PatchSetNum;
+      assert.equal(element._getPatchNum(), 'PARENT' as PatchSetNum);
+      element.side = 'REVISION';
+      assert.equal(element._getPatchNum(), 1 as PatchSetNum);
+    });
+
+    test('comment expand and collapse', () => {
+      element.collapsed = true;
+      assert.isFalse(
+        isVisible(queryAndAssert(element, 'gr-formatted-text')),
+        'gr-formatted-text is not visible'
+      );
+      assert.isFalse(
+        isVisible(queryAndAssert(element, '.actions')),
+        'actions are not visible'
+      );
+      assert.isNotOk(element.textarea, 'textarea is not visible');
+      assert.isTrue(
+        isVisible(queryAndAssert(element, '.collapsedContent')),
+        'header middle content is visible'
+      );
+
+      element.collapsed = false;
+      assert.isFalse(element.collapsed);
+      assert.isTrue(
+        isVisible(queryAndAssert(element, 'gr-formatted-text')),
+        'gr-formatted-text is visible'
+      );
+      assert.isTrue(
+        isVisible(queryAndAssert(element, '.actions')),
+        'actions are visible'
+      );
+      assert.isNotOk(element.textarea, 'textarea is not visible');
+      assert.isFalse(
+        isVisible(queryAndAssert(element, '.collapsedContent')),
+        'header middle content is is not visible'
+      );
+    });
+
+    suite('while editing', () => {
+      let handleCancelStub: sinon.SinonStub;
+      let handleSaveStub: sinon.SinonStub;
+      setup(() => {
+        element.editing = true;
+        element._messageText = 'test';
+        handleCancelStub = sinon.stub(element, '_handleCancel');
+        handleSaveStub = sinon.stub(element, '_handleSave');
+        flush();
+      });
+
+      suite('when text is empty', () => {
+        setup(() => {
+          element._messageText = '';
+          element.comment = {};
+        });
+
+        test('esc closes comment when text is empty', () => {
+          pressAndReleaseKeyOn(element.textarea!, 27); // esc
+          assert.isTrue(handleCancelStub.called);
+        });
+
+        test('ctrl+enter does not save', () => {
+          pressAndReleaseKeyOn(element.textarea!, 13, 'ctrl'); // ctrl + enter
+          assert.isFalse(handleSaveStub.called);
+        });
+
+        test('meta+enter does not save', () => {
+          pressAndReleaseKeyOn(element.textarea!, 13, 'meta'); // meta + enter
+          assert.isFalse(handleSaveStub.called);
+        });
+
+        test('ctrl+s does not save', () => {
+          pressAndReleaseKeyOn(element.textarea!, 83, 'ctrl'); // ctrl + s
+          assert.isFalse(handleSaveStub.called);
+        });
+      });
+
+      test('esc does not close comment that has content', () => {
+        pressAndReleaseKeyOn(element.textarea!, 27); // esc
+        assert.isFalse(handleCancelStub.called);
+      });
+
+      test('ctrl+enter saves', () => {
+        pressAndReleaseKeyOn(element.textarea!, 13, 'ctrl'); // ctrl + enter
+        assert.isTrue(handleSaveStub.called);
+      });
+
+      test('meta+enter saves', () => {
+        pressAndReleaseKeyOn(element.textarea!, 13, 'meta'); // meta + enter
+        assert.isTrue(handleSaveStub.called);
+      });
+
+      test('ctrl+s saves', () => {
+        pressAndReleaseKeyOn(element.textarea!, 83, 'ctrl'); // ctrl + s
+        assert.isTrue(handleSaveStub.called);
+      });
+    });
+
+    test('delete comment button for non-admins is hidden', () => {
+      element._isAdmin = false;
+      assert.isFalse(
+        queryAndAssert(element, '.action.delete').classList.contains(
+          'showDeleteButtons'
+        )
+      );
+    });
+
+    test('delete comment button for admins with draft is hidden', () => {
+      element._isAdmin = false;
+      element.draft = true;
+      assert.isFalse(
+        queryAndAssert(element, '.action.delete').classList.contains(
+          'showDeleteButtons'
+        )
+      );
+    });
+
+    test('delete comment', done => {
+      const stub = stubRestApi('deleteComment').returns(
+        Promise.resolve({
+          id: '1' as UrlEncodedCommentId,
+          updated: '1' as Timestamp,
+          ...createComment(),
+        })
+      );
+      const openSpy = sinon.spy(element.confirmDeleteOverlay!, 'open');
+      element.changeNum = 42 as NumericChangeId;
+      element.patchNum = 1 as PatchSetNum;
+      element._isAdmin = true;
+      assert.isTrue(
+        queryAndAssert(element, '.action.delete').classList.contains(
+          'showDeleteButtons'
+        )
+      );
+      tap(queryAndAssert(element, '.action.delete'));
+      flush(() => {
+        openSpy.lastCall.returnValue.then(() => {
+          const dialog = element.confirmDeleteOverlay?.querySelector(
+            '#confirmDeleteComment'
+          ) as GrConfirmDeleteCommentDialog;
+          dialog.message = 'removal reason';
+          element._handleConfirmDeleteComment();
+          assert.isTrue(
+            stub.calledWith(
+              42 as NumericChangeId,
+              1 as PatchSetNum,
+              'baf0414d_60047215' as UrlEncodedCommentId,
+              'removal reason'
+            )
+          );
+          done();
+        });
+      });
+    });
+
+    suite('draft update reporting', () => {
+      let endStub: SinonStubbedMember<() => Timer>;
+      let getTimerStub: sinon.SinonStub;
+      const mockEvent = {...new Event('click'), preventDefault() {}};
+
+      setup(() => {
+        sinon.stub(element, 'save').returns(Promise.resolve({}));
+        sinon.stub(element, '_discardDraft').returns(Promise.resolve({}));
+        endStub = sinon.stub();
+        const mockTimer = new MockTimer();
+        mockTimer.end = endStub;
+        getTimerStub = sinon
+          .stub(element.reporting, 'getTimer')
+          .returns(mockTimer);
+      });
+
+      test('create', () => {
+        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');
+        });
+      });
+
+      test('update', () => {
+        element.comment = {
+          ...createComment(),
+          id: ('abc_123' as UrlEncodedCommentId) as UrlEncodedCommentId,
+        };
+        return element._handleSave(mockEvent)!.then(() => {
+          assert.isTrue(endStub.calledOnce);
+          assert.isTrue(getTimerStub.calledOnce);
+          assert.equal(getTimerStub.lastCall.args[0], 'UpdateDraftComment');
+        });
+      });
+
+      test('discard', () => {
+        element.comment = {
+          ...createComment(),
+          id: ('abc_123' as UrlEncodedCommentId) as UrlEncodedCommentId,
+        };
+        sinon.stub(element, '_closeConfirmDiscardOverlay');
+        return element._handleConfirmDiscard(mockEvent).then(() => {
+          assert.isTrue(endStub.calledOnce);
+          assert.isTrue(getTimerStub.calledOnce);
+          assert.equal(getTimerStub.lastCall.args[0], 'DiscardDraftComment');
+        });
+      });
+    });
+
+    test('edit reports interaction', () => {
+      const reportStub = sinon.stub(
+        element.reporting,
+        'recordDraftInteraction'
+      );
+      element.draft = true;
+      flush();
+      tap(queryAndAssert(element, '.edit'));
+      assert.isTrue(reportStub.calledOnce);
+    });
+
+    test('discard reports interaction', () => {
+      const reportStub = sinon.stub(
+        element.reporting,
+        'recordDraftInteraction'
+      );
+      element.draft = true;
+      flush();
+      tap(queryAndAssert(element, '.discard'));
+      assert.isTrue(reportStub.calledOnce);
+    });
+
+    test('failed save draft request', done => {
+      element.draft = true;
+      element.changeNum = 1 as NumericChangeId;
+      element.patchNum = 1 as PatchSetNum;
+      const updateRequestStub = sinon.stub(element, '_updateRequestToast');
+      const diffDraftStub = stubRestApi('saveDiffDraft').returns(
+        Promise.resolve({...new Response(), ok: false})
+      );
+      element._saveDraft({
+        ...createComment(),
+        id: 'abc_123' as UrlEncodedCommentId,
+      });
+      flush(() => {
+        let args = updateRequestStub.lastCall.args;
+        assert.deepEqual(args, [0, true]);
+        assert.equal(
+          element._getSavingMessage(...args),
+          __testOnly_UNSAVED_MESSAGE
+        );
+        assert.equal(
+          (queryAndAssert(element, '.draftLabel') as HTMLSpanElement).innerText,
+          'DRAFT(Failed to save)'
+        );
+        assert.isTrue(
+          isVisible(queryAndAssert(element, '.save')),
+          'save is visible'
+        );
+        diffDraftStub.returns(Promise.resolve({...new Response(), ok: true}));
+        element._saveDraft({
+          ...createComment(),
+          id: 'abc_123' as UrlEncodedCommentId,
+        });
+        flush(() => {
+          args = updateRequestStub.lastCall.args;
+          assert.deepEqual(args, [0]);
+          assert.equal(element._getSavingMessage(...args), 'All changes saved');
+          assert.equal(
+            (queryAndAssert(element, '.draftLabel') as HTMLSpanElement)
+              .innerText,
+            'DRAFT'
+          );
+          assert.isFalse(
+            isVisible(queryAndAssert(element, '.save')),
+            'save is not visible'
+          );
+          assert.isFalse(element._unableToSave);
+          done();
+        });
+      });
+    });
+
+    test('failed save draft request with promise failure', done => {
+      element.draft = true;
+      element.changeNum = 1 as NumericChangeId;
+      element.patchNum = 1 as PatchSetNum;
+      const updateRequestStub = sinon.stub(element, '_updateRequestToast');
+      const diffDraftStub = stubRestApi('saveDiffDraft').returns(
+        Promise.reject(new Error())
+      );
+      element._saveDraft({
+        ...createComment(),
+        id: 'abc_123' as UrlEncodedCommentId,
+      });
+      flush(() => {
+        let args = updateRequestStub.lastCall.args;
+        assert.deepEqual(args, [0, true]);
+        assert.equal(
+          element._getSavingMessage(...args),
+          __testOnly_UNSAVED_MESSAGE
+        );
+        assert.equal(
+          (queryAndAssert(element, '.draftLabel') as HTMLSpanElement).innerText,
+          'DRAFT(Failed to save)'
+        );
+        assert.isTrue(
+          isVisible(queryAndAssert(element, '.save')),
+          'save is visible'
+        );
+        diffDraftStub.returns(Promise.resolve({...new Response(), ok: true}));
+        element._saveDraft({
+          ...createComment(),
+          id: 'abc_123' as UrlEncodedCommentId,
+        });
+        flush(() => {
+          args = updateRequestStub.lastCall.args;
+          assert.deepEqual(args, [0]);
+          assert.equal(element._getSavingMessage(...args), 'All changes saved');
+          assert.equal(
+            (queryAndAssert(element, '.draftLabel') as HTMLSpanElement)
+              .innerText,
+            'DRAFT'
+          );
+          assert.isFalse(
+            isVisible(queryAndAssert(element, '.save')),
+            'save is not visible'
+          );
+          assert.isFalse(element._unableToSave);
+          done();
+        });
+      });
+    });
+  });
+
+  suite('gr-comment draft tests', () => {
+    let element: GrComment;
+
+    setup(() => {
+      stubRestApi('getAccount').returns(Promise.resolve(undefined));
+      stubRestApi('saveDiffDraft').returns(
+        Promise.resolve({
+          ...new Response(),
+          ok: true,
+          text() {
+            return Promise.resolve(
+              ")]}'\n{" +
+                '"id": "baf0414d_40572e03",' +
+                '"path": "/path/to/file",' +
+                '"line": 5,' +
+                '"updated": "2015-12-08 21:52:36.177000000",' +
+                '"message": "saved!",' +
+                '"side": "REVISION",' +
+                '"unresolved": false,' +
+                '"patch_set": 1' +
+                '}'
+            );
+          },
+        })
+      );
+      stubRestApi('removeChangeReviewer').returns(
+        Promise.resolve({...new Response(), ok: true})
+      );
+      element = draftFixture.instantiate() as GrComment;
+      stubStorage('getDraftComment').returns(null);
+      element.changeNum = 42 as NumericChangeId;
+      element.patchNum = 1 as PatchSetNum;
+      element.editing = false;
+      element.comment = {
+        ...createComment(),
+        __draft: true,
+        __draftID: 'temp_draft_id',
+        path: '/path/to/file',
+        line: 5,
+      };
+    });
+
+    test('button visibility states', () => {
+      element.showActions = false;
+      assert.isTrue(
+        queryAndAssert(element, '.humanActions').hasAttribute('hidden')
+      );
+      assert.isTrue(
+        queryAndAssert(element, '.robotActions').hasAttribute('hidden')
+      );
+
+      element.showActions = true;
+      assert.isFalse(
+        queryAndAssert(element, '.humanActions').hasAttribute('hidden')
+      );
+      assert.isTrue(
+        queryAndAssert(element, '.robotActions').hasAttribute('hidden')
+      );
+
+      element.draft = true;
+      flush();
+      assert.isTrue(
+        isVisible(queryAndAssert(element, '.edit')),
+        'edit is visible'
+      );
+      assert.isTrue(
+        isVisible(queryAndAssert(element, '.discard')),
+        'discard is visible'
+      );
+      assert.isFalse(
+        isVisible(queryAndAssert(element, '.save')),
+        'save is not visible'
+      );
+      assert.isFalse(
+        isVisible(queryAndAssert(element, '.cancel')),
+        'cancel is not visible'
+      );
+      assert.isTrue(
+        isVisible(queryAndAssert(element, '.resolve')),
+        'resolve is visible'
+      );
+      assert.isFalse(
+        queryAndAssert(element, '.humanActions').hasAttribute('hidden')
+      );
+      assert.isTrue(
+        queryAndAssert(element, '.robotActions').hasAttribute('hidden')
+      );
+
+      element.editing = true;
+      flush();
+      assert.isFalse(
+        isVisible(queryAndAssert(element, '.edit')),
+        'edit is not visible'
+      );
+      assert.isFalse(
+        isVisible(queryAndAssert(element, '.discard')),
+        'discard not visible'
+      );
+      assert.isTrue(
+        isVisible(queryAndAssert(element, '.save')),
+        'save is visible'
+      );
+      assert.isTrue(
+        isVisible(queryAndAssert(element, '.cancel')),
+        'cancel is visible'
+      );
+      assert.isTrue(
+        isVisible(queryAndAssert(element, '.resolve')),
+        'resolve is visible'
+      );
+      assert.isFalse(
+        queryAndAssert(element, '.humanActions').hasAttribute('hidden')
+      );
+      assert.isTrue(
+        queryAndAssert(element, '.robotActions').hasAttribute('hidden')
+      );
+
+      element.draft = false;
+      element.editing = false;
+      flush();
+      assert.isFalse(
+        isVisible(queryAndAssert(element, '.edit')),
+        'edit is not visible'
+      );
+      assert.isFalse(
+        isVisible(queryAndAssert(element, '.discard')),
+        'discard is not visible'
+      );
+      assert.isFalse(
+        isVisible(queryAndAssert(element, '.save')),
+        'save is not visible'
+      );
+      assert.isFalse(
+        isVisible(queryAndAssert(element, '.cancel')),
+        'cancel is not visible'
+      );
+      assert.isFalse(
+        queryAndAssert(element, '.humanActions').hasAttribute('hidden')
+      );
+      assert.isTrue(
+        queryAndAssert(element, '.robotActions').hasAttribute('hidden')
+      );
+
+      element.comment!.id = 'foo' as UrlEncodedCommentId;
+      element.draft = true;
+      element.editing = true;
+      flush();
+      assert.isTrue(
+        isVisible(queryAndAssert(element, '.cancel')),
+        'cancel is visible'
+      );
+      assert.isFalse(
+        queryAndAssert(element, '.humanActions').hasAttribute('hidden')
+      );
+      assert.isTrue(
+        queryAndAssert(element, '.robotActions').hasAttribute('hidden')
+      );
+
+      // Delete button is not hidden by default
+      assert.isFalse(
+        (queryAndAssert(element, '#deleteBtn') as HTMLElement).hidden
+      );
+
+      element.isRobotComment = true;
+      element.draft = true;
+      assert.isTrue(
+        queryAndAssert(element, '.humanActions').hasAttribute('hidden')
+      );
+      assert.isFalse(
+        queryAndAssert(element, '.robotActions').hasAttribute('hidden')
+      );
+
+      // It is not expected to see Robot comment drafts, but if they appear,
+      // they will behave the same as non-drafts.
+      element.draft = false;
+      assert.isTrue(
+        queryAndAssert(element, '.humanActions').hasAttribute('hidden')
+      );
+      assert.isFalse(
+        queryAndAssert(element, '.robotActions').hasAttribute('hidden')
+      );
+
+      // A robot comment with run ID should display plain text.
+      element.set(['comment', 'robot_run_id'], 'text');
+      element.editing = false;
+      element.collapsed = false;
+      flush();
+      assert.isTrue(
+        queryAndAssert(element, '.robotRun.link').textContent === 'Run Details'
+      );
+
+      // A robot comment with run ID and url should display a link.
+      element.set(['comment', 'url'], '/path/to/run');
+      flush();
+      assert.notEqual(
+        getComputedStyle(queryAndAssert(element, '.robotRun.link')).display,
+        'none'
+      );
+
+      // Delete button is hidden for robot comments
+      assert.isTrue(
+        (queryAndAssert(element, '#deleteBtn') as HTMLElement).hidden
+      );
+    });
+
+    test('collapsible drafts', () => {
+      assert.isTrue(element.collapsed);
+      assert.isFalse(
+        isVisible(queryAndAssert(element, 'gr-formatted-text')),
+        'gr-formatted-text is not visible'
+      );
+      assert.isFalse(
+        isVisible(queryAndAssert(element, '.actions')),
+        'actions are not visible'
+      );
+      assert.isNotOk(element.textarea, 'textarea is not visible');
+      assert.isTrue(
+        isVisible(queryAndAssert(element, '.collapsedContent')),
+        'header middle content is visible'
+      );
+
+      tap(element.$.header);
+      assert.isFalse(element.collapsed);
+      assert.isTrue(
+        isVisible(queryAndAssert(element, 'gr-formatted-text')),
+        'gr-formatted-text is visible'
+      );
+      assert.isTrue(
+        isVisible(queryAndAssert(element, '.actions')),
+        'actions are visible'
+      );
+      assert.isNotOk(element.textarea, 'textarea is not visible');
+      assert.isFalse(
+        isVisible(queryAndAssert(element, '.collapsedContent')),
+        'header middle content is is not visible'
+      );
+
+      // When the edit button is pressed, should still see the actions
+      // and also textarea
+      element.draft = true;
+      flush();
+      tap(queryAndAssert(element, '.edit'));
+      flush();
+      assert.isFalse(element.collapsed);
+      assert.isFalse(
+        isVisible(queryAndAssert(element, 'gr-formatted-text')),
+        'gr-formatted-text is not visible'
+      );
+      assert.isTrue(
+        isVisible(queryAndAssert(element, '.actions')),
+        'actions are visible'
+      );
+      assert.isTrue(isVisible(element.textarea!), 'textarea is visible');
+      assert.isFalse(
+        isVisible(queryAndAssert(element, '.collapsedContent')),
+        'header middle content is not visible'
+      );
+
+      // When toggle again, everything should be hidden except for textarea
+      // and header middle content should be visible
+      tap(element.$.header);
+      assert.isTrue(element.collapsed);
+      assert.isFalse(
+        isVisible(queryAndAssert(element, 'gr-formatted-text')),
+        'gr-formatted-text is not visible'
+      );
+      assert.isFalse(
+        isVisible(queryAndAssert(element, '.actions')),
+        'actions are not visible'
+      );
+      assert.isFalse(
+        isVisible(queryAndAssert(element, 'gr-textarea')),
+        'textarea is not visible'
+      );
+      assert.isTrue(
+        isVisible(queryAndAssert(element, '.collapsedContent')),
+        'header middle content is visible'
+      );
+
+      // When toggle again, textarea should remain open in the state it was
+      // before
+      tap(element.$.header);
+      assert.isFalse(
+        isVisible(queryAndAssert(element, 'gr-formatted-text')),
+        'gr-formatted-text is not visible'
+      );
+      assert.isTrue(
+        isVisible(queryAndAssert(element, '.actions')),
+        'actions are visible'
+      );
+      assert.isTrue(isVisible(element.textarea!), 'textarea is visible');
+      assert.isFalse(
+        isVisible(queryAndAssert(element, '.collapsedContent')),
+        'header middle content is not visible'
+      );
+    });
+
+    test('robot comment layout', done => {
+      const comment = {
+        robot_id: 'happy_robot_id' as RobotId,
+        url: '/robot/comment',
+        author: {
+          name: 'Happy Robot',
+          display_name: 'Display name Robot',
+        },
+        ...element.comment,
+      };
+      element.comment = comment;
+      element.collapsed = false;
+      flush(() => {
+        let runIdMessage;
+        runIdMessage = queryAndAssert(element, '.runIdMessage') as HTMLElement;
+        assert.isFalse((runIdMessage as HTMLElement).hidden);
+
+        const runDetailsLink = queryAndAssert(
+          element,
+          '.robotRunLink'
+        ) as HTMLAnchorElement;
+        assert.isTrue(
+          runDetailsLink.href.indexOf((element.comment as UIRobot).url!) !== -1
+        );
+
+        const robotServiceName = queryAndAssert(element, '.robotName');
+        assert.equal(robotServiceName.textContent?.trim(), 'happy_robot_id');
+
+        const authorName = queryAndAssert(element, '.robotId');
+        assert.isTrue(
+          (authorName as HTMLDivElement).innerText === 'Happy Robot'
+        );
+
+        element.collapsed = true;
+        flush();
+        runIdMessage = queryAndAssert(element, '.runIdMessage');
+        assert.isTrue((runIdMessage as HTMLDivElement).hidden);
+        done();
+      });
+    });
+
+    test('author name fallback to email', done => {
+      const comment = {
+        url: '/robot/comment',
+        author: {
+          email: 'test@test.com' as EmailAddress,
+        },
+        ...element.comment,
+      };
+      element.comment = comment;
+      element.collapsed = false;
+      flush(() => {
+        const authorName = queryAndAssert(
+          queryAndAssert(element, 'gr-account-label'),
+          'span.name'
+        ) as HTMLSpanElement;
+        assert.equal(authorName.innerText.trim(), 'test@test.com');
+        done();
+      });
+    });
+
+    test('patchset level comment', done => {
+      const comment = {
+        ...element.comment,
+        path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS,
+        line: undefined,
+        range: undefined,
+      };
+      element.comment = comment;
+      flush();
+      tap(queryAndAssert(element, '.edit'));
+      assert.isTrue(element.editing);
+
+      element._messageText = 'hello world';
+      const eraseMessageDraftSpy = spyStorage('eraseDraftComment');
+      const mockEvent = {...new Event('click'), preventDefault: sinon.stub()};
+      element._handleSave(mockEvent);
+      flush(() => {
+        assert.isTrue(eraseMessageDraftSpy.called);
+        done();
+      });
+    });
+
+    test('draft creation/cancellation', done => {
+      assert.isFalse(element.editing);
+      element.draft = true;
+      flush();
+      tap(queryAndAssert(element, '.edit'));
+      assert.isTrue(element.editing);
+
+      element.comment!.message = '';
+      element._messageText = '';
+      const eraseMessageDraftSpy = sinon.spy(element, '_eraseDraftComment');
+
+      // Save should be disabled on an empty message.
+      let disabled = queryAndAssert(element, '.save').hasAttribute('disabled');
+      assert.isTrue(disabled, 'save button should be disabled.');
+      element._messageText = '     ';
+      disabled = queryAndAssert(element, '.save').hasAttribute('disabled');
+      assert.isTrue(disabled, 'save button should be disabled.');
+
+      const updateStub = sinon.stub();
+      element.addEventListener('comment-update', updateStub);
+
+      let numDiscardEvents = 0;
+      element.addEventListener('comment-discard', () => {
+        numDiscardEvents++;
+        assert.isFalse(eraseMessageDraftSpy.called);
+        if (numDiscardEvents === 2) {
+          assert.isFalse(updateStub.called);
+          done();
+        }
+      });
+      tap(queryAndAssert(element, '.cancel'));
+      flush();
+      element._messageText = '';
+      element.editing = true;
+      flush();
+      pressAndReleaseKeyOn(element.textarea!, 27); // esc
+    });
+
+    test('draft discard removes message from storage', done => {
+      element._messageText = '';
+      const eraseMessageDraftSpy = sinon.spy(element, '_eraseDraftComment');
+      sinon.stub(element, '_closeConfirmDiscardOverlay');
+
+      element.addEventListener('comment-discard', () => {
+        assert.isTrue(eraseMessageDraftSpy.called);
+        done();
+      });
+      element._handleConfirmDiscard({
+        ...new Event('click'),
+        preventDefault: sinon.stub(),
+      });
+    });
+
+    test('storage is cleared only after save success', () => {
+      element._messageText = 'test';
+      const eraseStub = sinon.stub(element, '_eraseDraftComment');
+      stubRestApi('getResponseObject').returns(
+        Promise.resolve({...(createDraft() as ParsedJSON)})
+      );
+      const saveDraftStub = sinon
+        .stub(element, '_saveDraft')
+        .returns(Promise.resolve({...new Response(), ok: false}));
+
+      const savePromise = element.save();
+      assert.isFalse(eraseStub.called);
+      return savePromise.then(() => {
+        assert.isFalse(eraseStub.called);
+
+        saveDraftStub.restore();
+        sinon
+          .stub(element, '_saveDraft')
+          .returns(Promise.resolve({...new Response(), ok: true}));
+        return element.save().then(() => {
+          assert.isTrue(eraseStub.called);
+        });
+      });
+    });
+
+    test('_computeSaveDisabled', () => {
+      const comment = {unresolved: true};
+      const msgComment = {message: 'test', unresolved: true};
+      assert.equal(element._computeSaveDisabled('', comment, false), true);
+      assert.equal(element._computeSaveDisabled('test', comment, false), false);
+      assert.equal(element._computeSaveDisabled('', msgComment, false), true);
+      assert.equal(
+        element._computeSaveDisabled('test', msgComment, false),
+        false
+      );
+      assert.equal(
+        element._computeSaveDisabled('test2', msgComment, false),
+        false
+      );
+      assert.equal(element._computeSaveDisabled('test', comment, true), false);
+      assert.equal(element._computeSaveDisabled('', comment, true), true);
+      assert.equal(element._computeSaveDisabled('', comment, false), true);
+    });
+
+    suite('confirm discard', () => {
+      let discardStub: sinon.SinonStub;
+      let overlayStub: sinon.SinonStub;
+      const mockEvent = {...new Event('click'), preventDefault: sinon.stub()};
+      setup(() => {
+        discardStub = sinon.stub(element, '_discardDraft');
+        overlayStub = sinon
+          .stub(element, '_openOverlay')
+          .returns(Promise.resolve());
+      });
+
+      test('confirms discard of comments with message text', () => {
+        element._messageText = 'test';
+        element._handleDiscard(mockEvent);
+        assert.isTrue(overlayStub.calledWith(element.confirmDiscardOverlay));
+        assert.isFalse(discardStub.called);
+      });
+
+      test('no confirmation for comments without message text', () => {
+        element._messageText = '';
+        element._handleDiscard(mockEvent);
+        assert.isFalse(overlayStub.called);
+        assert.isTrue(discardStub.calledOnce);
+      });
+    });
+
+    test('ctrl+s saves comment', done => {
+      const stub = sinon.stub(element, 'save').callsFake(() => {
+        assert.isTrue(stub.called);
+        stub.restore();
+        done();
+        return Promise.resolve();
+      });
+      element._messageText = 'is that the horse from horsing around??';
+      element.editing = true;
+      flush();
+      pressAndReleaseKeyOn(element.textarea!.$.textarea.textarea, 83, 'ctrl'); // 'ctrl + s'
+    });
+
+    test('draft saving/editing', done => {
+      const dispatchEventStub = sinon.stub(element, 'dispatchEvent');
+
+      const clock: SinonFakeTimers = sinon.useFakeTimers();
+      const tickAndFlush = async (repetitions: number) => {
+        for (let i = 1; i <= repetitions; i++) {
+          clock.tick(1000);
+          await flush();
+        }
+      };
+
+      element.draft = true;
+      flush();
+      tap(queryAndAssert(element, '.edit'));
+      tickAndFlush(1);
+      element._messageText = 'good news, everyone!';
+      tickAndFlush(1);
+      assert.equal(dispatchEventStub.lastCall.args[0].type, 'comment-update');
+      assert.isTrue(dispatchEventStub.calledTwice);
+
+      element._messageText = 'good news, everyone!';
+      flush();
+      assert.isTrue(dispatchEventStub.calledTwice);
+
+      tap(queryAndAssert(element, '.save'));
+
+      assert.isTrue(
+        element.disabled,
+        'Element should be disabled when creating draft.'
+      );
+
+      element
+        ._xhrPromise!.then(draft => {
+          const evt = dispatchEventStub.lastCall.args[0] as CustomEvent<{
+            comment: DraftInfo;
+          }>;
+          assert.equal(evt.type, 'comment-save');
+
+          const expectedDetail = {
+            comment: {
+              ...createComment(),
+              __draft: true,
+              __draftID: 'temp_draft_id',
+              id: 'baf0414d_40572e03' as UrlEncodedCommentId,
+              line: 5,
+              message: 'saved!',
+              path: '/path/to/file',
+              updated: '2015-12-08 21:52:36.177000000' as Timestamp,
+            },
+            patchNum: 1 as PatchSetNum,
+          };
+
+          assert.deepEqual(evt.detail, expectedDetail);
+          assert.isFalse(
+            element.disabled,
+            'Element should be enabled when done creating draft.'
+          );
+          assert.equal(draft.message, 'saved!');
+          assert.isFalse(element.editing);
+        })
+        .then(() => {
+          tap(queryAndAssert(element, '.edit'));
+          element._messageText =
+            'You’ll be delivering a package to Chapek 9, ' +
+            'a world where humans are killed on sight.';
+          tap(queryAndAssert(element, '.save'));
+          assert.isTrue(
+            element.disabled,
+            'Element should be disabled when updating draft.'
+          );
+
+          element._xhrPromise!.then(draft => {
+            assert.isFalse(
+              element.disabled,
+              'Element should be enabled when done updating draft.'
+            );
+            assert.equal(draft.message, 'saved!');
+            assert.isFalse(element.editing);
+            dispatchEventStub.restore();
+            done();
+          });
+        });
+    });
+
+    test('draft prevent save when disabled', () => {
+      const saveStub = sinon.stub(element, 'save').returns(Promise.resolve());
+      element.showActions = true;
+      element.draft = true;
+      flush();
+      tap(element.$.header);
+      tap(queryAndAssert(element, '.edit'));
+      element._messageText = 'good news, everyone!';
+      flush();
+
+      element.disabled = true;
+      tap(queryAndAssert(element, '.save'));
+      assert.isFalse(saveStub.called);
+
+      element.disabled = false;
+      tap(queryAndAssert(element, '.save'));
+      assert.isTrue(saveStub.calledOnce);
+    });
+
+    test('proper event fires on resolve, comment is not saved', done => {
+      const save = sinon.stub(element, 'save');
+      element.addEventListener('comment-update', e => {
+        assert.isTrue(e.detail.comment.unresolved);
+        assert.isFalse(save.called);
+        done();
+      });
+      tap(queryAndAssert(element, '.resolve input'));
+    });
+
+    test('resolved comment state indicated by checkbox', () => {
+      sinon.stub(element, 'save');
+      element.comment = {unresolved: false};
+      assert.isTrue(
+        (queryAndAssert(element, '.resolve input') as HTMLInputElement).checked
+      );
+      element.comment = {unresolved: true};
+      assert.isFalse(
+        (queryAndAssert(element, '.resolve input') as HTMLInputElement).checked
+      );
+    });
+
+    test('resolved checkbox saves with tap when !editing', () => {
+      element.editing = false;
+      const save = sinon.stub(element, 'save');
+
+      element.comment = {unresolved: false};
+      assert.isTrue(
+        (queryAndAssert(element, '.resolve input') as HTMLInputElement).checked
+      );
+      element.comment = {unresolved: true};
+      assert.isFalse(
+        (queryAndAssert(element, '.resolve input') as HTMLInputElement).checked
+      );
+      assert.isFalse(save.called);
+      tap(element.$.resolvedCheckbox);
+      assert.isTrue(
+        (queryAndAssert(element, '.resolve input') as HTMLInputElement).checked
+      );
+      assert.isTrue(save.called);
+    });
+
+    suite('draft saving messages', () => {
+      test('_getSavingMessage', () => {
+        assert.equal(element._getSavingMessage(0), 'All changes saved');
+        assert.equal(element._getSavingMessage(1), 'Saving 1 draft...');
+        assert.equal(element._getSavingMessage(2), 'Saving 2 drafts...');
+        assert.equal(element._getSavingMessage(3), 'Saving 3 drafts...');
+      });
+
+      test('_show{Start,End}Request', () => {
+        const updateStub = sinon.stub(element, '_updateRequestToast');
+        element._numPendingDraftRequests.number = 1;
+
+        element._showStartRequest();
+        assert.isTrue(updateStub.calledOnce);
+        assert.equal(updateStub.lastCall.args[0], 2);
+        assert.equal(element._numPendingDraftRequests.number, 2);
+
+        element._showEndRequest();
+        assert.isTrue(updateStub.calledTwice);
+        assert.equal(updateStub.lastCall.args[0], 1);
+        assert.equal(element._numPendingDraftRequests.number, 1);
+
+        element._showEndRequest();
+        assert.isTrue(updateStub.calledThrice);
+        assert.equal(updateStub.lastCall.args[0], 0);
+        assert.equal(element._numPendingDraftRequests.number, 0);
+      });
+    });
+
+    test('cancelling an unsaved draft discards, persists in storage', () => {
+      const clock: SinonFakeTimers = sinon.useFakeTimers();
+      const tickAndFlush = async (repetitions: number) => {
+        for (let i = 1; i <= repetitions; i++) {
+          clock.tick(1000);
+          await flush();
+        }
+      };
+      const discardSpy = sinon.spy(element, '_fireDiscard');
+      const storeStub = stubStorage('setDraftComment');
+      const eraseStub = stubStorage('eraseDraftComment');
+      element.comment!.id = undefined; // set id undefined for draft
+      element._messageText = 'test text';
+      tickAndFlush(1);
+
+      assert.isTrue(storeStub.called);
+      assert.equal(storeStub.lastCall.args[1], 'test text');
+      element._handleCancel({
+        ...new Event('click'),
+        preventDefault: sinon.stub(),
+      });
+      flush();
+      assert.isTrue(discardSpy.called);
+      assert.isFalse(eraseStub.called);
+    });
+
+    test('cancelling edit on a saved draft does not store', () => {
+      element.comment!.id = 'foo' as UrlEncodedCommentId;
+      const discardSpy = sinon.spy(element, '_fireDiscard');
+      const storeStub = stubStorage('setDraftComment');
+      element.comment!.id = undefined; // set id undefined for draft
+      element._messageText = 'test text';
+      flush();
+
+      assert.isFalse(storeStub.called);
+      element._handleCancel({...new Event('click'), preventDefault: () => {}});
+      assert.isTrue(discardSpy.called);
+    });
+
+    test('deleting text from saved draft and saving deletes the draft', () => {
+      element.comment = {
+        ...createComment(),
+        id: 'foo' as UrlEncodedCommentId,
+        message: 'test',
+      };
+      element._messageText = '';
+      const discardStub = sinon.stub(element, '_discardDraft');
+
+      element.save();
+      assert.isTrue(discardStub.called);
+    });
+
+    test('_handleFix fires create-fix event', done => {
+      element.addEventListener(
+        'create-fix-comment',
+        (e: CreateFixCommentEvent) => {
+          assert.deepEqual(e.detail, element._getEventPayload());
+          done();
+        }
+      );
+      element.isRobotComment = true;
+      element.comments = [element.comment!];
+      flush();
+
+      tap(queryAndAssert(element, '.fix'));
+    });
+
+    test('do not show Please Fix button if human reply exists', () => {
+      element.comments = [
+        {
+          robot_id: 'happy_robot_id' as RobotId,
+          robot_run_id: '5838406743490560' as RobotRunId,
+          fix_suggestions: [
+            {
+              fix_id: '478ff847_3bf47aaf' as FixId,
+              description: 'Make the smiley happier by giving it a nose.',
+              replacements: [
+                {
+                  path: 'Documentation/config-gerrit.txt',
+                  range: {
+                    start_line: 10,
+                    start_character: 7,
+                    end_line: 10,
+                    end_character: 9,
+                  },
+                  replacement: ':-)',
+                },
+              ],
+            },
+          ],
+          author: {
+            _account_id: 1030912 as AccountId,
+            name: 'Alice Kober-Sotzek',
+            email: 'aliceks@google.com' as EmailAddress,
+            avatars: [
+              {
+                url: '/s32-p/photo.jpg',
+                height: 32,
+                width: 32,
+              },
+              {
+                url: '/AaAdOFzPlFI/s56-p/photo.jpg',
+                height: 56,
+                width: 32,
+              },
+              {
+                url: '/AaAdOFzPlFI/s100-p/photo.jpg',
+                height: 100,
+                width: 32,
+              },
+              {
+                url: '/AaAdOFzPlFI/s120-p/photo.jpg',
+                height: 120,
+                width: 32,
+              },
+            ],
+          },
+          patch_set: 1 as PatchSetNum,
+          ...createComment(),
+          id: 'eb0d03fd_5e95904f' as UrlEncodedCommentId,
+          line: 10,
+          updated: '2017-04-04 15:36:17.000000000' as Timestamp,
+          message: 'This is a robot comment with a fix.',
+          unresolved: false,
+          collapsed: false,
+        },
+        {
+          __draft: true,
+          __draftID: '0.wbrfbwj89sa',
+          __date: new Date(),
+          path: 'Documentation/config-gerrit.txt',
+          side: CommentSide.REVISION,
+          line: 10,
+          in_reply_to: 'eb0d03fd_5e95904f' as UrlEncodedCommentId,
+          message: '> This is a robot comment with a fix.\n\nPlease fix.',
+          unresolved: true,
+        },
+      ];
+      element.comment = element.comments[0];
+      flush();
+      assert.isNull(
+        element.shadowRoot?.querySelector('robotActions gr-button')
+      );
+    });
+
+    test('show Please Fix if no human reply', () => {
+      element.comments = [
+        {
+          robot_id: 'happy_robot_id' as RobotId,
+          robot_run_id: '5838406743490560' as RobotRunId,
+          fix_suggestions: [
+            {
+              fix_id: '478ff847_3bf47aaf' as FixId,
+              description: 'Make the smiley happier by giving it a nose.',
+              replacements: [
+                {
+                  path: 'Documentation/config-gerrit.txt',
+                  range: {
+                    start_line: 10,
+                    start_character: 7,
+                    end_line: 10,
+                    end_character: 9,
+                  },
+                  replacement: ':-)',
+                },
+              ],
+            },
+          ],
+          author: {
+            _account_id: 1030912 as AccountId,
+            name: 'Alice Kober-Sotzek',
+            email: 'aliceks@google.com' as EmailAddress,
+            avatars: [
+              {
+                url: '/s32-p/photo.jpg',
+                height: 32,
+                width: 32,
+              },
+              {
+                url: '/AaAdOFzPlFI/s56-p/photo.jpg',
+                height: 56,
+                width: 32,
+              },
+              {
+                url: '/AaAdOFzPlFI/s100-p/photo.jpg',
+                height: 100,
+                width: 32,
+              },
+              {
+                url: '/AaAdOFzPlFI/s120-p/photo.jpg',
+                height: 120,
+                width: 32,
+              },
+            ],
+          },
+          patch_set: 1 as PatchSetNum,
+          ...createComment(),
+          id: 'eb0d03fd_5e95904f' as UrlEncodedCommentId,
+          line: 10,
+          updated: '2017-04-04 15:36:17.000000000' as Timestamp,
+          message: 'This is a robot comment with a fix.',
+          unresolved: false,
+          collapsed: false,
+        },
+      ];
+      element.comment = element.comments[0];
+      flush();
+      queryAndAssert(element, '.robotActions gr-button');
+    });
+
+    test('_handleShowFix fires open-fix-preview event', done => {
+      element.addEventListener('open-fix-preview', e => {
+        assert.deepEqual(e.detail, element._getEventPayload());
+        done();
+      });
+      element.comment = {
+        ...createComment(),
+        fix_suggestions: [{...createFixSuggestionInfo()}],
+      };
+      element.isRobotComment = true;
+      flush();
+
+      tap(queryAndAssert(element, '.show-fix'));
+    });
+  });
+
+  suite('respectful tips', () => {
+    let element: GrComment;
+
+    let clock: sinon.SinonFakeTimers;
+    setup(() => {
+      stubRestApi('getAccount').returns(Promise.resolve(undefined));
+      clock = sinon.useFakeTimers();
+    });
+
+    teardown(() => {
+      clock.restore();
+      sinon.restore();
+    });
+
+    test('show tip when no cached record', done => {
+      element = draftFixture.instantiate() as GrComment;
+      const respectfulGetStub = stubStorage('getRespectfulTipVisibility');
+      const respectfulSetStub = stubStorage('setRespectfulTipVisibility');
+      respectfulGetStub.returns(null);
+      // fake random
+      element.getRandomNum = () => 0;
+      element.comment = {__editing: true, __draft: true};
+      flush(() => {
+        assert.isTrue(respectfulGetStub.called);
+        assert.isTrue(respectfulSetStub.called);
+        assert.isTrue(!!queryAndAssert(element, '.respectfulReviewTip'));
+        done();
+      });
+    });
+
+    test('add 14-day delays once dismissed', done => {
+      element = draftFixture.instantiate() as GrComment;
+      const respectfulGetStub = stubStorage('getRespectfulTipVisibility');
+      const respectfulSetStub = stubStorage('setRespectfulTipVisibility');
+      respectfulGetStub.returns(null);
+      // fake random
+      element.getRandomNum = () => 0;
+      element.comment = {__editing: true, __draft: true};
+      flush(() => {
+        assert.isTrue(respectfulGetStub.called);
+        assert.isTrue(respectfulSetStub.called);
+        assert.isTrue(respectfulSetStub.lastCall.args[0] === undefined);
+        assert.isTrue(!!queryAndAssert(element, '.respectfulReviewTip'));
+
+        tap(queryAndAssert(element, '.respectfulReviewTip .close'));
+        flush();
+        assert.isTrue(respectfulSetStub.lastCall.args[0] === 14);
+        done();
+      });
+    });
+
+    test('do not show tip when fall out of probability', done => {
+      element = draftFixture.instantiate() as GrComment;
+      const respectfulGetStub = stubStorage('getRespectfulTipVisibility');
+      const respectfulSetStub = stubStorage('setRespectfulTipVisibility');
+      respectfulGetStub.returns(null);
+      // fake random
+      element.getRandomNum = () => 3;
+      element.comment = {__editing: true, __draft: true};
+      flush(() => {
+        assert.isTrue(respectfulGetStub.called);
+        assert.isFalse(respectfulSetStub.called);
+        assert.isNotOk(query(element, '.respectfulReviewTip'));
+        done();
+      });
+    });
+
+    test('show tip when editing changed to true', done => {
+      element = draftFixture.instantiate() as GrComment;
+      const respectfulGetStub = stubStorage('getRespectfulTipVisibility');
+      const respectfulSetStub = stubStorage('setRespectfulTipVisibility');
+      respectfulGetStub.returns(null);
+      // fake random
+      element.getRandomNum = () => 0;
+      element.comment = {__editing: false};
+      flush(() => {
+        assert.isFalse(respectfulGetStub.called);
+        assert.isFalse(respectfulSetStub.called);
+        assert.isNotOk(query(element, '.respectfulReviewTip'));
+
+        element.editing = true;
+        flush(() => {
+          assert.isTrue(respectfulGetStub.called);
+          assert.isTrue(respectfulSetStub.called);
+          assert.isTrue(!!queryAndAssert(element, '.respectfulReviewTip'));
+          done();
+        });
+      });
+    });
+
+    test('no tip when cached record', done => {
+      element = draftFixture.instantiate() as GrComment;
+      const respectfulGetStub = stubStorage('getRespectfulTipVisibility');
+      const respectfulSetStub = stubStorage('setRespectfulTipVisibility');
+      respectfulGetStub.returns({updated: 0});
+      // fake random
+      element.getRandomNum = () => 0;
+      element.comment = {__editing: true, __draft: true};
+      flush(() => {
+        assert.isTrue(respectfulGetStub.called);
+        assert.isFalse(respectfulSetStub.called);
+        assert.isNotOk(query(element, '.respectfulReviewTip'));
+        done();
+      });
+    });
+  });
+});
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 8408c78..017ba50 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.
  */
@@ -110,8 +89,7 @@
   /**
    * Move the cursor forward. Clipped to the ends of the stop list.
    *
-   * @param options.filter Will keep going and skip any stops for which this
-   *    condition is not met.
+   * @param options.filter Skips any stops for which filter returns false.
    * @param options.getTargetHeight Optional function to calculate the
    *    height of the target's 'section'. The height of the target itself is
    *    sometimes different, used by the diff cursor.
@@ -144,64 +122,86 @@
    * The method uses IntersectionObservers API. If browser
    * doesn't support this API the method does nothing
    *
-   * @param condition Optional condition. If a condition
-   * is passed only stops which meet conditions are taken into account.
+   * @param filter Skips any stops for which filter returns false.
    */
-  moveToVisibleArea(condition?: (el: Element) => boolean) {
-    if (!this.stops || !this._isIntersectionObserverSupported()) {
-      return;
+  async moveToVisibleArea(filter?: (el: Element) => boolean) {
+    const centerMostStop = await this.getCenterMostStop(filter);
+    // In most cases the target is visible, so scroll is not
+    // needed. But in rare cases the target can become invisible
+    // at this point (due to some scrolling in window).
+    // To avoid jumps set noScroll options.
+    if (centerMostStop) {
+      this.setCursor(centerMostStop, true);
     }
-    const filteredStops = condition
-      ? this.targetableStops.filter(condition)
+  }
+
+  private async getCenterMostStop(
+    filter?: (el: Element) => boolean
+  ): Promise<HTMLElement | undefined> {
+    const visibleEntries = await this.getVisibleEntries(filter);
+    const windowCenter = Math.round(window.innerHeight / 2);
+
+    let centerMostStop: HTMLElement | undefined = undefined;
+    let minDistanceToCenter = Number.MAX_VALUE;
+
+    for (const entry of visibleEntries) {
+      // We are just using the entries here, because entry.boundingClientRect
+      // is already computed, but entry.target.getBoundingClientRect() should
+      // actually yield the same result.
+      const center =
+        entry.boundingClientRect.top +
+        Math.round(entry.boundingClientRect.height / 2);
+      const distanceToWindowCenter = Math.abs(center - windowCenter);
+      if (distanceToWindowCenter < minDistanceToCenter) {
+        // entry.target comes from the filteredStops array,
+        // hence it is an HTMLElement
+        centerMostStop = entry.target as HTMLElement;
+        minDistanceToCenter = distanceToWindowCenter;
+      }
+    }
+    return centerMostStop;
+  }
+
+  private async getVisibleEntries(
+    filter?: (el: Element) => boolean
+  ): Promise<IntersectionObserverEntry[]> {
+    if (!this._isIntersectionObserverSupported()) {
+      throw new Error('Intersection observing not supported');
+    }
+    if (!this.stops) {
+      return [];
+    }
+    const filteredStops = filter
+      ? this.targetableStops.filter(filter)
       : this.targetableStops;
-    const dims = this._getWindowDims();
-    const windowCenter = Math.round(dims.innerHeight / 2);
+    return new Promise(resolve => {
+      let unobservedCount = filteredStops.length;
+      const visibleEntries: IntersectionObserverEntry[] = [];
+      const observer = new IntersectionObserver(entries => {
+        visibleEntries.push(
+          ...entries
+            // In Edge it is recommended to use intersectionRatio instead of
+            // isIntersecting.
+            .filter(
+              entry => entry.isIntersecting || entry.intersectionRatio > 0
+            )
+        );
 
-    let closestToTheCenter: HTMLElement | null = null;
-    let minDistanceToCenter: number | null = null;
-    let unobservedCount = filteredStops.length;
-
-    const observer = new IntersectionObserver(entries => {
-      // This callback is called for the first time immediately.
-      // Typically it gets all observed stops at once, but
-      // sometimes can get them in several chunks.
-      entries.forEach(entry => {
-        observer.unobserve(entry.target);
-
-        // In Edge it is recommended to use intersectionRatio instead of
-        // isIntersecting.
-        const isInsideViewport =
-          entry.isIntersecting || entry.intersectionRatio > 0;
-        if (!isInsideViewport) {
-          return;
+        // This callback is called for the first time immediately.
+        // Typically it gets all observed stops at once, but
+        // sometimes can get them in several chunks.
+        for (const entry of entries) {
+          observer.unobserve(entry.target);
         }
-        const center =
-          entry.boundingClientRect.top +
-          Math.round(entry.boundingClientRect.height / 2);
-        const distanceToWindowCenter = Math.abs(center - windowCenter);
-        if (
-          minDistanceToCenter === null ||
-          distanceToWindowCenter < minDistanceToCenter
-        ) {
-          // entry.target comes from the filteredStops array,
-          // hence it is an HTMLElement
-          closestToTheCenter = entry.target as HTMLElement;
-          minDistanceToCenter = distanceToWindowCenter;
+        unobservedCount -= entries.length;
+        if (unobservedCount === 0) {
+          resolve(visibleEntries);
         }
       });
-      unobservedCount -= entries.length;
-      if (unobservedCount === 0 && closestToTheCenter) {
-        // set cursor when all stops were observed.
-        // In most cases the target is visible, so scroll is not
-        // needed. But in rare cases the target can become invisible
-        // at this point (due to some scrolling in window).
-        // To avoid jumps set noScroll options.
-        this.setCursor(closestToTheCenter, true);
+      for (const stop of filteredStops) {
+        observer.observe(stop);
       }
     });
-    filteredStops.forEach(stop => {
-      observer.observe(stop);
-    });
   }
 
   _isIntersectionObserverSupported() {
@@ -405,17 +405,15 @@
   }
 
   _targetIsVisible(top: number) {
-    const dims = this._getWindowDims();
     return (
       this.scrollMode === ScrollMode.KEEP_VISIBLE &&
-      top > dims.pageYOffset &&
-      top < dims.pageYOffset + dims.innerHeight
+      top > window.pageYOffset &&
+      top < window.pageYOffset + window.innerHeight
     );
   }
 
   _calculateScrollToValue(top: number, target: HTMLElement) {
-    const dims = this._getWindowDims();
-    return top + -dims.innerHeight / 3 + target.offsetHeight / 2;
+    return top + -window.innerHeight / 3 + target.offsetHeight / 2;
   }
 
   _scrollToTarget() {
@@ -423,7 +421,6 @@
       return;
     }
 
-    const dims = this._getWindowDims();
     const top = this._getTop(this.target);
     const bottomIsVisible = this._targetHeight
       ? this._targetIsVisible(top + this._targetHeight)
@@ -435,7 +432,7 @@
       // would get scrolled to is higher up than the current position. This
       // would cause less of the target content to be displayed than is
       // already.
-      if (bottomIsVisible || scrollToValue < dims.scrollY) {
+      if (bottomIsVisible || scrollToValue < window.scrollY) {
         return;
       }
     }
@@ -444,15 +441,6 @@
     // instead of half the inner height feels a bit better otherwise the
     // element appears to be below the center of the window even when it
     // isn't.
-    window.scrollTo(dims.scrollX, scrollToValue);
-  }
-
-  _getWindowDims() {
-    return {
-      scrollX: window.scrollX,
-      scrollY: window.scrollY,
-      innerHeight: window.innerHeight,
-      pageYOffset: window.pageYOffset,
-    };
+    window.scrollTo(window.scrollX, scrollToValue);
   }
 }
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 e51d190..ba7e4f8 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>
@@ -282,12 +283,10 @@
     test('Called when top is visible, bottom is not, scroll is lower', () => {
       const visibleStub = sinon.stub(cursor, '_targetIsVisible').callsFake(
           () => visibleStub.callCount === 2);
-      sinon.stub(cursor, '_getWindowDims').returns({
-        scrollX: 123,
-        scrollY: 15,
-        innerHeight: 1000,
-        pageYOffset: 0,
-      });
+      window.scrollX = 123;
+      window.scrollY = 15;
+      window.innerHeight = 1000;
+      window.pageYOffset = 0;
       sinon.stub(cursor, '_calculateScrollToValue').returns(20);
       cursor._scrollToTarget();
       assert.isTrue(scrollStub.called);
@@ -298,12 +297,10 @@
     test('Called when top is visible, bottom not, scroll is higher', () => {
       const visibleStub = sinon.stub(cursor, '_targetIsVisible').callsFake(
           () => visibleStub.callCount === 2);
-      sinon.stub(cursor, '_getWindowDims').returns({
-        scrollX: 123,
-        scrollY: 25,
-        innerHeight: 1000,
-        pageYOffset: 0,
-      });
+      window.scrollX = 123;
+      window.scrollY = 25;
+      window.innerHeight = 1000;
+      window.pageYOffset = 0;
       sinon.stub(cursor, '_calculateScrollToValue').returns(20);
       cursor._scrollToTarget();
       assert.isFalse(scrollStub.called);
@@ -311,12 +308,10 @@
     });
 
     test('_calculateScrollToValue', () => {
-      sinon.stub(cursor, '_getWindowDims').returns({
-        scrollX: 123,
-        scrollY: 25,
-        innerHeight: 300,
-        pageYOffset: 0,
-      });
+      window.scrollX = 123;
+      window.scrollY = 25;
+      window.innerHeight = 300;
+      window.pageYOffset = 0;
       assert.equal(cursor._calculateScrollToValue(1000, {offsetHeight: 10}),
           905);
     });
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 19cbb22..e93013e 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
@@ -22,6 +22,8 @@
 import {customElement, property, observe} 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';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -59,6 +61,9 @@
   @property({type: String, notify: true})
   selectedScheme?: string;
 
+  @property({type: Boolean})
+  showKeyboardShortcutTooltips = false;
+
   private readonly restApiService = appContext.restApiService;
 
   /** @override */
@@ -70,8 +75,7 @@
   }
 
   focusOnCopy() {
-    // TODO(TS): remove ! assertion later
-    this.shadowRoot!.querySelector('gr-shell-command')!.focusOnCopy();
+    queryAndAssert<GrShellCommand>(this, 'gr-shell-command').focusOnCopy();
   }
 
   _getLoggedIn() {
@@ -111,6 +115,12 @@
     return schemes.length > 1 ? '' : 'hidden';
   }
 
+  _computeTooltip(showKeyboardShortcutTooltips: boolean, index: number) {
+    return index <= 4 && showKeyboardShortcutTooltips
+      ? `Keyboard shortcut: ${index + 1}`
+      : '';
+  }
+
   // TODO: maybe unify with strToClassName from dom-util
   _computeClass(title: string) {
     // Only retain [a-z] chars, so "Cherry Pick" becomes "cherrypick".
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 5c15b29..a739ad1 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
@@ -64,11 +64,12 @@
     </paper-tabs>
   </div>
   <div class="commands" hidden$="[[!schemes.length]]" hidden="">
-    <template is="dom-repeat" items="[[commands]]" as="command">
+    <template is="dom-repeat" items="[[commands]]" as="command" indexAs="index">
       <gr-shell-command
         class$="[[_computeClass(command.title)]]"
         label="[[command.title]]"
         command="[[command.command]]"
+        tooltip="[[_computeTooltip(showKeyboardShortcutTooltips, index)]]"
       ></gr-shell-command>
     </template>
   </div>
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 c758455..1702b4a 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
@@ -31,6 +31,7 @@
   AutocompleteQuery,
   GrAutocomplete,
 } from '../gr-autocomplete/gr-autocomplete';
+import {getKeyboardEvent} from '../../../utils/dom-util';
 
 const AWAIT_MAX_ITERS = 10;
 const AWAIT_STEP = 5;
@@ -203,7 +204,7 @@
   }
 
   _handleEnter(e: CustomKeyboardEvent) {
-    e = this.getKeyboardEvent(e);
+    e = getKeyboardEvent(e);
     const target = (dom(e) as EventApi).rootTarget;
     if (target === this._nativeInput) {
       e.preventDefault();
@@ -212,7 +213,7 @@
   }
 
   _handleEsc(e: CustomKeyboardEvent) {
-    e = this.getKeyboardEvent(e);
+    e = getKeyboardEvent(e);
     const target = (dom(e) as EventApi).rootTarget;
     if (target === this._nativeInput) {
       e.preventDefault();
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..7298af7 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
@@ -20,6 +20,7 @@
 import {customElement, property} from '@polymer/decorators';
 import {htmlTemplate} from './gr-formatted-text_html';
 import {CommentLinks} from '../../../types/common';
+import {appContext} from '../../../services/app-context';
 
 const CODE_MARKER_PATTERN = /^(`{1,3})([^`]+?)\1$/;
 
@@ -57,6 +58,8 @@
   @property({type: Boolean})
   noTrailingMargin = false;
 
+  private readonly reporting = appContext.reportingService;
+
   static get observers() {
     return ['_contentOrConfigChanged(content, config)'];
   }
@@ -304,7 +307,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-hovercard-account/gr-hovercard-account.ts b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account.ts
index abd4469..d0a440a 100644
--- a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account.ts
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account.ts
@@ -38,7 +38,6 @@
   getLastUpdate,
   getReason,
   hasAttention,
-  isAttentionSetEnabled,
 } from '../../../utils/attention-set-util';
 import {ReviewerState} from '../../../constants/constants';
 import {CURRENT} from '../../../utils/patch-set-util';
@@ -109,7 +108,6 @@
 
   get isAttentionEnabled() {
     return (
-      isAttentionSetEnabled(this._config) &&
       !!this.highlightAttention &&
       !!this.change &&
       canHaveAttention(this.account)
@@ -117,7 +115,7 @@
   }
 
   get hasUserAttention() {
-    return hasAttention(this._config, this.account, this.change);
+    return hasAttention(this.account, this.change);
   }
 
   _computeReason(change?: ChangeInfo) {
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-hovercard-account/gr-hovercard-account_test.js b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_test.js
index 1c67e13..07969bd 100644
--- a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_test.js
@@ -37,9 +37,6 @@
 
   setup(async () => {
     stubRestApi('getAccount').returns(Promise.resolve({...ACCOUNT}));
-    stubRestApi('getConfig').returns(
-        Promise.resolve({change: {enable_attention_set: true}})
-    );
     element = basicFixture.instantiate();
     element.account = {...ACCOUNT};
     element.change = {
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 2259ca1..8968e18 100644
--- a/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.ts
+++ b/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.ts
@@ -140,8 +140,16 @@
       <g id="download"><path d="M0 0h24v24H0z" fill="none"/><path d="M5,20h14v-2H5V20z M19,9h-4V3H9v6H5l7,7L19,9z"/></g>
       <!-- This SVG is a copy from material.io https://material.io/icons/#system_update-->
       <g id="system-update"><path d="M0 0h24v24H0z" fill="none"/><path d="M17 1.01L7 1c-1.1 0-2 .9-2 2v18c0 1.1.9 2 2 2h10c1.1 0 2-.9 2-2V3c0-1.1-.9-1.99-2-1.99zM17 19H7V5h10v14zm-1-6h-3V8h-2v5H8l4 4 4-4z"/></g>
+      <!-- This SVG is a copy from material.io https://material.io/icons/#swap_horiz-->
+      <g id="swapHoriz"><path d="M0 0h24v24H0z" fill="none"/><path d="M6.99 11L3 15l3.99 4v-3H14v-2H6.99v-3zM21 9l-3.99-4v3H10v2h7.01v3L21 9z"/></g>
       <!-- This SVG is a copy from material.io https://material.io/icons/#link-->
       <g id="link"><path d="M0 0h24v24H0z" fill="none"/><path d="M3.9 12c0-1.71 1.39-3.1 3.1-3.1h4V7H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h4v-1.9H7c-1.71 0-3.1-1.39-3.1-3.1zM8 13h8v-2H8v2zm9-6h-4v1.9h4c1.71 0 3.1 1.39 3.1 3.1s-1.39 3.1-3.1 3.1h-4V17h4c2.76 0 5-2.24 5-5s-2.24-5-5-5z"/></g>
+      <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material%20Icons%3Aplay_arrow-->
+      <g id="playArrow"><path d="M0 0h24v24H0z" fill="none"/><path d="M8 5v14l11-7z"/></g>
+      <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material%20Icons%3Apause-->
+      <g id="pause"><path d="M0 0h24v24H0z" fill="none"/><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/></g>
+      <!-- This SVG is a copy from material.io https://material.io/icons/#code-->
+      <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>
     </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-api-utils.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-api-utils.ts
index db127f3..36e5c25 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-api-utils.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-api-utils.ts
@@ -20,7 +20,6 @@
 import {RequestPayload} from '../../../types/common';
 import {appContext} from '../../../services/app-context';
 
-export const PRELOADED_PROTOCOL = 'preloaded:';
 export const PLUGIN_LOADING_TIMEOUT_MS = 10000;
 
 /**
@@ -35,9 +34,6 @@
       return null;
     }
   }
-  if (url.protocol === PRELOADED_PROTOCOL) {
-    return url.pathname;
-  }
   const base = getBaseUrl();
   let pathname = url.pathname.replace(base, '');
   // Load from ASSETS_PATH
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-api-utils_test.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-api-utils_test.js
index a09d887..e45cb0c 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-api-utils_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-api-utils_test.js
@@ -19,8 +19,6 @@
 import './gr-js-api-interface.js';
 import {getPluginNameFromUrl} from './gr-api-utils.js';
 
-const PRELOADED_PROTOCOL = 'preloaded:';
-
 suite('gr-api-utils tests', () => {
   suite('test getPluginNameFromUrl', () => {
     test('with empty string', () => {
@@ -50,10 +48,6 @@
       );
     });
 
-    test('with preloaded urls', () => {
-      assert.equal(getPluginNameFromUrl(`${PRELOADED_PROTOCOL}a`), 'a');
-    });
-
     test('with gerrit-theme override', () => {
       assert.equal(
           getPluginNameFromUrl('http://example.com/static/gerrit-theme.js'),
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..b9c4c32 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-gerrit.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit.ts
index 7afdd20..33afde0 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
@@ -55,7 +55,6 @@
   awaitPluginsLoaded(): Promise<unknown>;
   _loadPlugins(plugins: string[], opts: PluginOptionMap): void;
   _arePluginsLoaded(): boolean;
-  _isPluginPreloaded(pathOrUrl: string): boolean;
   _isPluginEnabled(pathOrUrl: string): boolean;
   _isPluginLoaded(pathOrUrl: string): boolean;
   _eventEmitter: EventEmitterService;
@@ -69,10 +68,6 @@
 export function initGerritPluginApi() {
   window.Gerrit = window.Gerrit || {};
   initGerritPluginsMethods(window.Gerrit as GerritGlobal);
-  // Preloaded plugins should be installed after Gerrit.install() is set,
-  // since plugin preloader substitutes Gerrit.install() temporarily.
-  // (Gerrit.install() is set in initGerritPluginsMethods)
-  getPluginLoader().installPreloadedPlugins();
 }
 
 export function _testOnly_initGerritPluginApi(): GerritGlobal {
@@ -207,15 +202,6 @@
     return getPluginLoader().arePluginsLoaded();
   };
 
-  globalGerritObj._isPluginPreloaded = url => {
-    appContext.reportingService.trackApi(
-      fakeApi,
-      'global',
-      '_isPluginPreloaded'
-    );
-    return getPluginLoader().isPluginPreloaded(url);
-  };
-
   globalGerritObj._isPluginEnabled = pathOrUrl => {
     appContext.reportingService.trackApi(fakeApi, 'global', '_isPluginEnabled');
     return getPluginLoader().isPluginEnabled(pathOrUrl);
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit_test.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit_test.js
index 608e711..95a67ab 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit_test.js
@@ -64,16 +64,6 @@
       pluginApi._isPluginLoaded('test_plugin');
       assert.isTrue(stubFn.calledWith('test_plugin'));
     });
-
-    test('Gerrit._isPluginPreloaded proxy to getPluginLoader()', () => {
-      const stubFn = sinon.stub();
-      sinon.stub(
-          getPluginLoader(),
-          'isPluginPreloaded')
-          .callsFake((...args) => stubFn(...args));
-      pluginApi._isPluginPreloaded('test_plugin');
-      assert.isTrue(stubFn.calledWith('test_plugin'));
-    });
   });
 });
 
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.js
index a9bb8ae..8ec2607 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.js
@@ -65,28 +65,6 @@
         'http://test.com/plugins/testplugin/static/test.js');
   });
 
-  test('url for preloaded plugin without ASSETS_PATH', () => {
-    let plugin;
-    pluginApi.install(p => { plugin = p; }, '0.1',
-        'preloaded:testpluginB');
-    assert.equal(plugin.url(),
-        `${window.location.origin}/plugins/testpluginB/`);
-    assert.equal(plugin.url('/static/test.js'),
-        `${window.location.origin}/plugins/testpluginB/static/test.js`);
-  });
-
-  test('url for preloaded plugin without ASSETS_PATH', () => {
-    const oldAssetsPath = window.ASSETS_PATH;
-    window.ASSETS_PATH = 'http://test.com';
-    let plugin;
-    pluginApi.install(p => { plugin = p; }, '0.1',
-        'preloaded:testpluginC');
-    assert.equal(plugin.url(), `${window.ASSETS_PATH}/plugins/testpluginC/`);
-    assert.equal(plugin.url('/static/test.js'),
-        `${window.ASSETS_PATH}/plugins/testpluginC/static/test.js`);
-    window.ASSETS_PATH = oldAssetsPath;
-  });
-
   test('_send on failure rejects with response text', () => {
     sendStub.returns(Promise.resolve(
         {status: 400, text() { return Promise.resolve('text'); }}));
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-loader.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader.ts
index 85b6747..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
@@ -15,17 +15,12 @@
  * limitations under the License.
  */
 import {appContext} from '../../../services/app-context';
-import {
-  PLUGIN_LOADING_TIMEOUT_MS,
-  PRELOADED_PROTOCOL,
-  getPluginNameFromUrl,
-} from './gr-api-utils';
+import {PLUGIN_LOADING_TIMEOUT_MS, getPluginNameFromUrl} from './gr-api-utils';
 import {Plugin} from './gr-public-js-api';
 import {getBaseUrl} from '../../../utils/url-util';
 import {getPluginEndpoints} from './gr-plugin-endpoints';
 import {PluginApi} from '../../../api/plugin';
 import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
-import {hasOwnProperty} from '../../../utils/common-util';
 import {ShowAlertEventDetail} from '../../../types/events';
 
 enum PluginState {
@@ -56,16 +51,6 @@
   __importElement: HTMLScriptElement;
 };
 
-type PluginCallback = (plugin: PluginApi) => void;
-
-interface PluginCallbackMap {
-  [name: string]: PluginCallback;
-}
-
-interface GerritGlobal {
-  _preloadedPlugins?: PluginCallbackMap;
-}
-
 // Prefix for any unrecognized plugin urls.
 // Url should match following patterns:
 // /plugins/PLUGINNAME/static/SCRIPTNAME.js
@@ -120,9 +105,6 @@
 
     plugins.forEach(path => {
       const url = this._urlFor(path, window.ASSETS_PATH);
-      // Skip if preloaded, for bundling.
-      if (this.isPluginPreloaded(url)) return;
-
       const pluginKey = this._getPluginKeyFromUrl(url);
       // Skip if already installed.
       if (this._plugins.has(pluginKey)) return;
@@ -150,7 +132,7 @@
       try {
         url = new URL(url);
       } catch (e) {
-        console.warn(e);
+        this._getReporting().error(e);
         return false;
       }
     }
@@ -281,34 +263,11 @@
     this._checkIfCompleted();
   }
 
-  installPreloadedPlugins() {
-    const Gerrit = window.Gerrit as GerritGlobal;
-    if (!Gerrit || !Gerrit._preloadedPlugins) {
-      return;
-    }
-    for (const name of Object.keys(Gerrit._preloadedPlugins)) {
-      const callback = Gerrit._preloadedPlugins[name];
-      this.install(callback, API_VERSION, PRELOADED_PROTOCOL + name);
-    }
-  }
-
-  isPluginPreloaded(pathOrUrl: string) {
-    const url = this._urlFor(pathOrUrl);
-    const name = getPluginNameFromUrl(url);
-    const Gerrit = window.Gerrit as GerritGlobal;
-    if (name && Gerrit?._preloadedPlugins) {
-      return hasOwnProperty(Gerrit._preloadedPlugins, name);
-    } else {
-      return false;
-    }
-  }
-
   /**
    * Checks if given plugin path/url is enabled or not.
    */
   isPluginEnabled(pathOrUrl: string) {
     const url = this._urlFor(pathOrUrl);
-    if (this.isPluginPreloaded(url)) return true;
     const key = this._getPluginKeyFromUrl(url);
     return this._plugins.has(key);
   }
@@ -364,10 +323,7 @@
     // theme is per host, should always load from assetsPath
     const isThemeFile = pathOrUrl.endsWith('static/gerrit-theme.js');
     const shouldTryLoadFromAssetsPathFirst = !isThemeFile && assetsPath;
-    if (
-      pathOrUrl.startsWith(PRELOADED_PROTOCOL) ||
-      pathOrUrl.startsWith('http')
-    ) {
+    if (pathOrUrl.startsWith('http')) {
       // Plugins are loaded from another domain or preloaded.
       if (
         pathOrUrl.includes(location.host) &&
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader_test.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader_test.js
index 1ca7e75..ab69267 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader_test.js
@@ -16,7 +16,7 @@
  */
 
 import '../../../test/common-test-setup-karma.js';
-import {PRELOADED_PROTOCOL, PLUGIN_LOADING_TIMEOUT_MS} from './gr-api-utils.js';
+import {PLUGIN_LOADING_TIMEOUT_MS} from './gr-api-utils.js';
 import {_testOnly_resetPluginLoader} from './gr-plugin-loader.js';
 import {resetPlugins, stubBaseUrl} from '../../../test/test-utils.js';
 import {_testOnly_initGerritPluginApi} from './gr-gerrit.js';
@@ -397,54 +397,4 @@
     assert.isTrue(installed);
     await pluginLoader.awaitPluginsLoaded();
   });
-
-  suite('preloaded plugins', () => {
-    teardown(() => {
-      window.Gerrit._preloadedPlugins = null;
-    });
-    test('skips preloaded plugins when load plugins', () => {
-      const loadJsPluginStub = sinon.stub();
-      sinon.stub(pluginLoader, '_loadJsPlugin').callsFake( url => {
-        loadJsPluginStub(url);
-      });
-
-      window.Gerrit._preloadedPlugins = {
-        foo: () => void 0,
-        bar: () => void 0,
-      };
-
-      pluginLoader.loadPlugins([
-        'http://e.com/plugins/foo.js',
-        'http://e.com/plugins/test/foo.js',
-      ]);
-
-      assert.isTrue(loadJsPluginStub.calledOnce);
-    });
-
-    test('isPluginPreloaded', () => {
-      window.Gerrit._preloadedPlugins = {baz: ()=>{}};
-      assert.isFalse(pluginLoader.isPluginPreloaded('plugins/foo/bar'));
-      assert.isFalse(pluginLoader.isPluginPreloaded('http://a.com/42'));
-      assert.isTrue(
-          pluginLoader.isPluginPreloaded(PRELOADED_PROTOCOL + 'baz')
-      );
-    });
-
-    test('preloaded plugins are installed', () => {
-      const installStub = sinon.stub();
-      window.Gerrit._preloadedPlugins = {foo: installStub};
-      pluginLoader.installPreloadedPlugins();
-      assert.isTrue(installStub.called);
-      const pluginApi = installStub.lastCall.args[0];
-      assert.strictEqual(pluginApi.getPluginName(), 'foo');
-    });
-
-    test('installing preloaded plugin', () => {
-      let plugin;
-      pluginApi.install(p => { plugin = p; }, '0.1', 'preloaded:foo');
-      assert.strictEqual(plugin.getPluginName(), 'foo');
-      assert.strictEqual(plugin.url('/some/thing.js'),
-          `${window.location.origin}/plugins/foo/some/thing.js`);
-    });
-  });
 });
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 6ff9a50..cfa6c04 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
@@ -25,7 +25,7 @@
 import {GrEventHelper} from '../../plugins/gr-event-helper/gr-event-helper';
 import {GrPluginRestApi} from './gr-plugin-rest-api';
 import {getPluginEndpoints} from './gr-plugin-endpoints';
-import {getPluginNameFromUrl, PRELOADED_PROTOCOL, send} from './gr-api-utils';
+import {getPluginNameFromUrl, send} from './gr-api-utils';
 import {GrReportingJsApi} from './gr-reporting-js-api';
 import {EventType, PluginApi, TargetElement} from '../../../api/plugin';
 import {RequestPayload} from '../../../types/common';
@@ -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;
     }
@@ -187,11 +188,6 @@
     if (window.location.origin === this._url.origin) {
       // Plugin loaded from the same origin as gr-app, getBaseUrl in effect.
       return sameOriginPath;
-    } else if (this._url.protocol === PRELOADED_PROTOCOL) {
-      // Plugin is preloaded, load plugin with ASSETS_PATH or location.origin
-      return window.ASSETS_PATH
-        ? `${window.ASSETS_PATH}${relPath}`
-        : sameOriginPath;
     } else {
       // Plugin loaded from assets bundle, expect assets placed along with it.
       return this._url.href.split('/plugins/' + this._name)[0] + relPath;
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-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-rest-api-interface/gr-rest-api-interface.ts b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.ts
index 2067a6b..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
@@ -1036,63 +1036,58 @@
     offset?: 'n,z' | number,
     options?: string
   ): Promise<ChangeInfo[] | ChangeInfo[][] | undefined> {
-    return this.getConfig(false)
-      .then(config => {
-        // TODO(TS): config can be null/undefined. Need some checks
-        options = options || this._getChangesOptionsHex(config);
-        // Issue 4524: respect legacy token with max sortkey.
-        if (offset === 'n,z') {
-          offset = 0;
+    options = options || this._getChangesOptionsHex();
+    // Issue 4524: respect legacy token with max sortkey.
+    if (offset === 'n,z') {
+      offset = 0;
+    }
+    const params: QueryChangesParams = {
+      O: options,
+      S: offset || 0,
+    };
+    if (changesPerPage) {
+      params.n = changesPerPage;
+    }
+    if (query && query.length > 0) {
+      params.q = query;
+    }
+    const request = {
+      url: '/changes/',
+      params,
+      reportUrlAsIs: true,
+    };
+
+    return Promise.resolve(
+      this._restApiHelper.fetchJSON(request, true) as Promise<
+        ChangeInfo[] | ChangeInfo[][] | undefined
+      >
+    ).then(response => {
+      if (!response) {
+        return;
+      }
+      const iterateOverChanges = (arr: ChangeInfo[]) => {
+        for (const change of arr) {
+          this._maybeInsertInLookup(change);
         }
-        const params: QueryChangesParams = {
-          O: options,
-          S: offset || 0,
-        };
-        if (changesPerPage) {
-          params.n = changesPerPage;
+      };
+      // Response may be an array of changes OR an array of arrays of
+      // changes.
+      if (query instanceof Array) {
+        // Normalize the response to look like a multi-query response
+        // when there is only one query.
+        const responseArray: Array<ChangeInfo[]> =
+          query.length === 1
+            ? [response as ChangeInfo[]]
+            : (response as ChangeInfo[][]);
+        for (const arr of responseArray) {
+          iterateOverChanges(arr);
         }
-        if (query && query.length > 0) {
-          params.q = query;
-        }
-        return {
-          url: '/changes/',
-          params,
-          reportUrlAsIs: true,
-        };
-      })
-      .then(
-        req =>
-          this._restApiHelper.fetchJSON(req, true) as Promise<
-            ChangeInfo[] | ChangeInfo[][] | undefined
-          >
-      )
-      .then(response => {
-        if (!response) {
-          return;
-        }
-        const iterateOverChanges = (arr: ChangeInfo[]) => {
-          for (const change of arr) {
-            this._maybeInsertInLookup(change);
-          }
-        };
-        // Response may be an array of changes OR an array of arrays of
-        // changes.
-        if (query instanceof Array) {
-          // Normalize the response to look like a multi-query response
-          // when there is only one query.
-          const responseArray: Array<ChangeInfo[]> =
-            query.length === 1
-              ? [response as ChangeInfo[]]
-              : (response as ChangeInfo[][]);
-          for (const arr of responseArray) {
-            iterateOverChanges(arr);
-          }
-          return responseArray;
-        } else {
-          iterateOverChanges(response as ChangeInfo[]);
-          return response as ChangeInfo[];
-        }
-      });
+        return responseArray;
+      } else {
+        iterateOverChanges(response as ChangeInfo[]);
+        return response as ChangeInfo[];
+      }
+    });
   }
 
   /**
@@ -1134,7 +1129,7 @@
     });
   }
 
-  _getChangesOptionsHex(config?: ServerInfo) {
+  _getChangesOptionsHex() {
     if (
       window.DEFAULT_DETAIL_HEXES &&
       window.DEFAULT_DETAIL_HEXES.dashboardPage
@@ -1145,9 +1140,6 @@
       ListChangesOption.LABELS,
       ListChangesOption.DETAILED_ACCOUNTS,
     ];
-    if (!config?.change?.enable_attention_set) {
-      options.push(ListChangesOption.REVIEWED);
-    }
 
     return listChangesOptionsToHex(...options);
   }
@@ -2127,22 +2119,6 @@
     });
   }
 
-  saveChangeReviewed(
-    changeNum: NumericChangeId,
-    reviewed: boolean
-  ): Promise<Response | undefined> {
-    return this.getConfig().then(config => {
-      const isAttentionSetEnabled =
-        !!config && !!config.change && config.change.enable_attention_set;
-      if (isAttentionSetEnabled) return;
-      return this._getChangeURLAndSend({
-        changeNum,
-        method: HttpMethod.PUT,
-        endpoint: reviewed ? '/reviewed' : '/unreviewed',
-      });
-    });
-  }
-
   send(
     method: HttpMethod,
     url: string,
@@ -2320,12 +2296,18 @@
         return {};
       }
       if (!basePatchNum && !patchNum && !path) {
-        return this._getDiffComments(changeNum, '/drafts');
+        return this._getDiffComments(changeNum, '/drafts', {
+          'enable-context': true,
+          'context-padding': 3,
+        });
       }
       return this._getDiffComments(
         changeNum,
         '/drafts',
-        undefined,
+        {
+          'enable-context': true,
+          'context-padding': 3,
+        },
         basePatchNum,
         patchNum,
         path
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 a23cb1a..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;
   }
@@ -38,6 +38,9 @@
   @property({type: String})
   label: string | undefined;
 
+  @property({type: String})
+  tooltip = '';
+
   focusOnCopy() {
     if (this.shadowRoot !== null) {
       const copyClipboard = this.shadowRoot.querySelector('gr-copy-clipboard');
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 ef76999..6c7f0d8 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
@@ -53,6 +53,10 @@
   </style>
   <label>[[label]]</label>
   <div class="commandContainer">
-    <gr-copy-clipboard text="[[command]]"></gr-copy-clipboard>
+    <gr-copy-clipboard
+      text="[[command]]"
+      has-tooltip
+      button-title="[[tooltip]]"
+    ></gr-copy-clipboard>
   </div>
 `;
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 bf10121..a747ac4 100644
--- a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts
+++ b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts
@@ -122,7 +122,7 @@
   _currentSearchString?: string;
 
   @property({type: Boolean})
-  _hideAutocomplete = true;
+  _hideEmojiAutocomplete = true;
 
   @property({type: Number})
   _index?: number;
@@ -141,7 +141,7 @@
   get keyBindings() {
     return {
       esc: '_handleEscKey',
-      tab: '_handleEnterByKey',
+      tab: '_handleTabKey',
       enter: '_handleEnterByKey',
       up: '_handleUpKey',
       down: '_handleDownKey',
@@ -186,7 +186,7 @@
   }
 
   _handleEscKey(e: KeyboardEvent) {
-    if (this._hideAutocomplete) {
+    if (this._hideEmojiAutocomplete) {
       return;
     }
     e.preventDefault();
@@ -195,7 +195,7 @@
   }
 
   _handleUpKey(e: KeyboardEvent) {
-    if (this._hideAutocomplete) {
+    if (this._hideEmojiAutocomplete) {
       return;
     }
     e.preventDefault();
@@ -206,7 +206,7 @@
   }
 
   _handleDownKey(e: KeyboardEvent) {
-    if (this._hideAutocomplete) {
+    if (this._hideEmojiAutocomplete) {
       return;
     }
     e.preventDefault();
@@ -216,8 +216,10 @@
     this.disableEnterKeyForSelectingEmoji = false;
   }
 
-  _handleEnterByKey(e: KeyboardEvent) {
-    if (this._hideAutocomplete || this.disableEnterKeyForSelectingEmoji) {
+  _handleTabKey(e: KeyboardEvent) {
+    // Tab should have normal behavior if the picker is closed or if the user
+    // has only typed ':'.
+    if (this._hideEmojiAutocomplete || this.disableEnterKeyForSelectingEmoji) {
       return;
     }
     e.preventDefault();
@@ -225,6 +227,21 @@
     this._setEmoji(this.$.emojiSuggestions.getCurrentText());
   }
 
+  _handleEnterByKey(e: CustomEvent<{keyboardEvent: KeyboardEvent}>) {
+    // Enter should have newline behavior if the picker is closed or if the user
+    // has only typed ':'. Also make sure that shortcuts aren't clobbered.
+    if (this._hideEmojiAutocomplete || this.disableEnterKeyForSelectingEmoji) {
+      if (!e.detail.keyboardEvent.metaKey && !e.detail.keyboardEvent.ctrlKey) {
+        this.indent(e);
+      }
+      return;
+    }
+
+    e.preventDefault();
+    e.stopPropagation();
+    this._setEmoji(this.$.emojiSuggestions.getCurrentText());
+  }
+
   _handleEmojiSelect(e: CustomEvent) {
     this._setEmoji(e.detail.selected.dataset['value']);
   }
@@ -257,7 +274,7 @@
    * this allows the dropdown to appear near where the user is typing.
    */
   _updateCaratPosition() {
-    this._hideAutocomplete = false;
+    this._hideEmojiAutocomplete = false;
     if (typeof this.$.textarea.value === 'string') {
       this.$.hiddenText.textContent = this.$.textarea.value.substr(
         0,
@@ -271,15 +288,6 @@
     this._openEmojiDropdown();
   }
 
-  _getFontSize() {
-    const fontSizePx = getComputedStyle(this).fontSize || '12px';
-    return Number(fontSizePx.substr(0, fontSizePx.length - 2));
-  }
-
-  _getScrollTop() {
-    return document.body.scrollTop;
-  }
-
   /**
    * _handleKeydown used for key handling in the this.$.textarea AND all child
    * autocomplete options.
@@ -384,7 +392,7 @@
     // hide and reset the autocomplete dropdown.
     flush();
     this._currentSearchString = '';
-    this._hideAutocomplete = true;
+    this._hideEmojiAutocomplete = true;
     this.closeDropdown();
     this._colonIndex = null;
     this.$.textarea.textarea.focus();
@@ -395,6 +403,34 @@
       new CustomEvent('value-changed', {detail: {value: text}})
     );
   }
+
+  private indent(e: CustomEvent<{keyboardEvent: KeyboardEvent}>): void {
+    if (!document.queryCommandSupported('insertText')) {
+      return;
+    }
+    // When nothing is selected, selectionStart is the caret position. We want
+    // the indentation level of the current line, not the end of the text which
+    // may be different.
+    const currentLine = this.$.textarea.textarea.value
+      .substr(0, this.$.textarea.selectionStart)
+      .split('\n')
+      .pop();
+    const currentLineIndentation = currentLine?.match(/^\s*/)?.[0];
+    if (!currentLineIndentation) {
+      return;
+    }
+
+    // Stops the normal newline being added afterwards since we are adding it
+    // ourselves.
+    e.preventDefault();
+
+    // MDN says that execCommand is deprecated, but the replacements are still
+    // WIP (Input Events Level 2). The queryCommandSupported check should ensure
+    // that entering newlines will work even if this indent feature breaks.
+    // Directly replacing the text is possible, but would destroy the undo/redo
+    // queue.
+    document.execCommand('insertText', false, '\n' + currentLineIndentation);
+  }
 }
 
 declare global {
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 5b3a2b2..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
@@ -71,7 +71,7 @@
         flush();
         assert.isFalse(element.$.emojiSuggestions.isHidden);
         assert.equal(element._colonIndex, 0);
-        assert.isFalse(element._hideAutocomplete);
+        assert.isFalse(element._hideEmojiAutocomplete);
         assert.equal(element._currentSearchString, '');
       });
 
@@ -86,7 +86,7 @@
         flush();
         assert.isFalse(element.$.emojiSuggestions.isHidden);
         assert.equal(element._colonIndex, 1);
-        assert.isFalse(element._hideAutocomplete);
+        assert.isFalse(element._hideEmojiAutocomplete);
         assert.equal(element._currentSearchString, '');
       });
 
@@ -100,7 +100,7 @@
         element.text = 'test:';
         flush();
         assert.isTrue(element.$.emojiSuggestions.isHidden);
-        assert.isTrue(element._hideAutocomplete);
+        assert.isTrue(element._hideEmojiAutocomplete);
       });
 
   test('emoji selector opens when a colon is typed and some substring',
@@ -117,7 +117,7 @@
         flush();
         assert.isFalse(element.$.emojiSuggestions.isHidden);
         assert.equal(element._colonIndex, 0);
-        assert.isFalse(element._hideAutocomplete);
+        assert.isFalse(element._hideEmojiAutocomplete);
         assert.equal(element._currentSearchString, 't');
       });
 
@@ -142,7 +142,7 @@
         flush();
         assert.isFalse(element.$.emojiSuggestions.isHidden);
         assert.equal(element._colonIndex, 0);
-        assert.isFalse(element._hideAutocomplete);
+        assert.isFalse(element._hideEmojiAutocomplete);
         assert.equal(element._currentSearchString, '');
       });
   test('emoji selector closes when text changes before the colon', () => {
@@ -169,7 +169,7 @@
     const closeSpy = sinon.spy(element, 'closeDropdown');
     element._resetEmojiDropdown();
     assert.equal(element._currentSearchString, '');
-    assert.isTrue(element._hideAutocomplete);
+    assert.isTrue(element._hideEmojiAutocomplete);
     assert.equal(element._colonIndex, null);
 
     element.$.emojiSuggestions.open();
@@ -220,6 +220,36 @@
         element.$.caratSpan.outerHTML);
   });
 
+  test('newline receives matching indentation', async () => {
+    const indentCommand = sinon.stub(document, 'execCommand');
+    element.$.textarea.value = '    a';
+    element._handleEnterByKey(
+        new CustomEvent('keydown', {detail: {keyboardEvent: {keyCode: 13}}})
+    );
+    await flush();
+    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/embed/gr-diff.ts b/polygerrit-ui/app/embed/gr-diff.ts
index 6999ef8..422667a4 100644
--- a/polygerrit-ui/app/embed/gr-diff.ts
+++ b/polygerrit-ui/app/embed/gr-diff.ts
@@ -21,14 +21,24 @@
 // variables. If an application wants to use Polymer global variable -
 // the app must assign/import it and do not rely on the Polymer variable
 // exposed by shared gr-diff component.
+import '../api/embed';
 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 {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
 initDiffAppContext();
 // Setup global variables for existing usages of this component
+window.grdiff = {
+  GrAnnotation,
+  GrDiffCursor,
+  TokenHighlightLayer,
+};
+
+// TODO(oler): Remove when clients have adjusted to namespaced globals above
 window.GrAnnotation = GrAnnotation;
diff --git a/polygerrit-ui/app/mixins/gr-change-table-mixin/gr-change-table-mixin.ts b/polygerrit-ui/app/mixins/gr-change-table-mixin/gr-change-table-mixin.ts
index 28b7a50..e3b75de 100644
--- a/polygerrit-ui/app/mixins/gr-change-table-mixin/gr-change-table-mixin.ts
+++ b/polygerrit-ui/app/mixins/gr-change-table-mixin/gr-change-table-mixin.ts
@@ -70,7 +70,6 @@
         if (column === 'Assignee') return !!config.change.enable_assignee;
         if (column === 'Comments')
           return experiments.includes('comments-column');
-        if (column === 'Reviewers') return !!config.change.enable_attention_set;
         return true;
       }
 
diff --git a/polygerrit-ui/app/mixins/gr-tooltip-mixin/gr-tooltip-mixin.ts b/polygerrit-ui/app/mixins/gr-tooltip-mixin/gr-tooltip-mixin.ts
index e60c614..a5b25d9 100644
--- a/polygerrit-ui/app/mixins/gr-tooltip-mixin/gr-tooltip-mixin.ts
+++ b/polygerrit-ui/app/mixins/gr-tooltip-mixin/gr-tooltip-mixin.ts
@@ -76,21 +76,21 @@
       private readonly showHandler: () => void;
 
       // Handler for hiding the tooltip, will be attached to certain events
-      private readonly hideHandler: () => void;
+      private readonly hideHandler: (e: Event) => void;
 
       // eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/no-explicit-any
       constructor(..._: any[]) {
         super();
         this.windowScrollHandler = () => this._handleWindowScroll();
         this.showHandler = () => this._handleShowTooltip();
-        this.hideHandler = () => this._handleHideTooltip();
+        this.hideHandler = (e: Event | undefined) => this._handleHideTooltip(e);
       }
 
       /** @override */
       disconnectedCallback() {
         // NOTE: if you define your own `detached` in your component
         // then this won't take affect (as its not a class yet)
-        this._handleHideTooltip();
+        this._handleHideTooltip(undefined);
         if (this.mouseenterHandler) {
           this.removeEventListener('mouseenter', this.mouseenterHandler);
         }
@@ -153,20 +153,30 @@
         window.addEventListener('scroll', this.windowScrollHandler);
         this.addEventListener('mouseleave', this.hideHandler);
         this.addEventListener('click', this.hideHandler);
+        tooltip.addEventListener('mouseleave', this.hideHandler);
       }
 
-      _handleHideTooltip() {
+      _handleHideTooltip(e: Event | undefined) {
         if (this._isTouchDevice) {
           return;
         }
         if (!this.hasAttribute('title') || !this._titleText) {
           return;
         }
+        // Do not hide if mouse left this or this._tooltip and came to this or
+        // this._tooltip
+        if (
+          (e as MouseEvent)?.relatedTarget === this._tooltip ||
+          (e as MouseEvent)?.relatedTarget === this
+        ) {
+          return;
+        }
 
         window.removeEventListener('scroll', this.windowScrollHandler);
         this.removeEventListener('mouseleave', this.hideHandler);
         this.removeEventListener('click', this.hideHandler);
         this.setAttribute('title', this._titleText);
+        this._tooltip?.removeEventListener('mouseleave', this.hideHandler);
 
         if (this._tooltip?.parentNode) {
           this._tooltip.parentNode.removeChild(this._tooltip);
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 58e50d3..555a256 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
@@ -102,11 +102,10 @@
 import {dedupingMixin} from '@polymer/polymer/lib/utils/mixin';
 import {property} from '@polymer/decorators';
 import {PolymerElement} from '@polymer/polymer';
-import {Constructor} from '../../utils/common-util';
-import {
-  CustomKeyboardEvent,
-  ShortcutTriggeredEventDetail,
-} from '../../types/events';
+import {check, Constructor} from '../../utils/common-util';
+import {getKeyboardEvent, isModifierPressed} from '../../utils/dom-util';
+import {CustomKeyboardEvent} from '../../types/events';
+import {appContext} from '../../services/app-context';
 
 /** Enum for all special shortcuts */
 export enum SPECIAL_SHORTCUT {
@@ -220,11 +219,6 @@
   viewMap?: Map<ShortcutSection, SectionView>
 ) => void;
 
-interface ShortcutEnabledElement extends PolymerElement {
-  // TODO: should replace with Map so we can have proper type here
-  keyboardShortcuts(): {[shortcut: string]: string};
-}
-
 interface ShortcutHelpItem {
   shortcut: Shortcut;
   text: string;
@@ -530,19 +524,6 @@
   'Emoji dropdown'
 );
 
-// Must be declared outside behavior implementation to be accessed inside
-// behavior functions.
-
-function getKeyboardEvent(e: CustomKeyboardEvent): CustomKeyboardEvent {
-  const event = dom(e.detail ? e.detail.keyboardEvent : e);
-  // TODO(TS): worth checking if this still holds or not, if no, remove this.
-  // When e is a keyboardEvent, e.event is not null.
-  if ('event' in event && (event as CustomKeyboardEvent).event) {
-    return (event as CustomKeyboardEvent).event;
-  }
-  return event as CustomKeyboardEvent;
-}
-
 /**
  * Shortcut manager, holds all hosts, bindings and listeners.
  */
@@ -569,14 +550,9 @@
     return this.bindings.get(shortcut);
   }
 
-  attachHost(host: PolymerElement | ShortcutEnabledElement) {
-    if (!('keyboardShortcuts' in host)) {
-      return;
-    }
-    const shortcuts = host.keyboardShortcuts();
-    this.activeHosts.set(host, new Map(Object.entries(shortcuts)));
+  attachHost(host: PolymerElement, shortcuts: Map<string, string>) {
+    this.activeHosts.set(host, shortcuts);
     this.notifyListeners();
-    return shortcuts;
   }
 
   detachHost(host: PolymerElement) {
@@ -761,16 +737,6 @@
 
 const shortcutManager = new ShortcutManager();
 
-/**
- * Enum for supported modifiers.
- */
-export enum Modifier {
-  SHIFT_KEY = 'shiftKey',
-  CTRL_KEY = 'ctrlKey',
-  META_KEY = 'metaKey',
-  // Add when you need it
-}
-
 interface IronA11yKeysMixinConstructor {
   // Note: this is needed to have same interface as other mixins
   // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -805,6 +771,29 @@
 
       ShortcutSection = ShortcutSection;
 
+      private _disableKeyboardShortcuts = false;
+
+      private readonly restApiService = appContext.restApiService;
+
+      private reporting = appContext.reportingService;
+
+      /** Used to disable shortcuts when the element is not visible. */
+      private observer?: IntersectionObserver;
+
+      /**
+       * Enabling shortcuts only when the element is visible (see `observer`
+       * above) is a great feature, but often what you want is for the *page* to
+       * be visible, not the specific child element that registers keyboard
+       * shortcuts. An example is the FileList in the ChangeView. So we allow
+       * a broader observer target to be specified here, and fall back to
+       * `this` as the default.
+       */
+      @property({type: Object})
+      observerTarget: Element = this;
+
+      /** Are shortcuts currently enabled? True only when element is visible. */
+      private bindingsEnabled = false;
+
       modifierPressed(event: CustomKeyboardEvent) {
         /* We are checking for g/v as modifiers pressed. There are cases such as
          * pressing v and then /, where we want the handler for / to be triggered.
@@ -812,20 +801,12 @@
          */
         const e = getKeyboardEvent(event);
         return (
-          e.altKey ||
-          e.ctrlKey ||
-          e.metaKey ||
-          e.shiftKey ||
-          !!this._inGoKeyMode() ||
-          !!this.inVKeyMode()
+          isModifierPressed(e) || !!this._inGoKeyMode() || !!this.inVKeyMode()
         );
       }
 
-      isModifierPressed(e: CustomKeyboardEvent, modifier: Modifier) {
-        return getKeyboardEvent(e)[modifier];
-      }
-
       shouldSuppressKeyboardShortcut(event: CustomKeyboardEvent) {
+        if (this._disableKeyboardShortcuts) return true;
         const e = getKeyboardEvent(event);
         // TODO(TS): maybe override the EventApi, narrow it down to Element always
         const target = (dom(e) as EventApi).rootTarget as Element;
@@ -849,18 +830,19 @@
             return true;
           }
         }
-        const detail: ShortcutTriggeredEventDetail = {
-          event: e,
-          goKey: this._inGoKeyMode(),
-          vKey: this.inVKeyMode(),
-        };
-        this.dispatchEvent(
-          new CustomEvent('shortcut-triggered', {
-            detail,
-            composed: true,
-            bubbles: true,
-          })
-        );
+
+        // eg: {key: "k:keydown", ..., from: "gr-diff-view"}
+        let key = `${((e as unknown) as KeyboardEvent).key}:${e.type}`;
+        if (this._inGoKeyMode()) key = 'g+' + key;
+        if (this.inVKeyMode()) key = 'v+' + key;
+        if (e.shiftKey) key = 'shift+' + key;
+        if (e.ctrlKey) key = 'ctrl+' + key;
+        if (e.metaKey) key = 'meta+' + key;
+        if (e.altKey) key = 'alt+' + key;
+        this.reporting.reportInteraction('shortcut-triggered', {
+          key,
+          from: this.nodeName ?? 'unknown',
+        });
         return false;
       }
 
@@ -927,14 +909,62 @@
       /** @override */
       connectedCallback() {
         super.connectedCallback();
-        const shortcuts = shortcutManager.attachHost(this);
-        if (!shortcuts) {
-          return;
-        }
+        this.restApiService.getPreferences().then(prefs => {
+          if (prefs?.disable_keyboard_shortcuts) {
+            this._disableKeyboardShortcuts = true;
+          }
+        });
+        this.createVisibilityObserver();
+        this.enableBindings();
+      }
 
-        for (const key of Object.keys(shortcuts)) {
-          // TODO(TS): not needed if convert shortcuts to Map
-          this._addOwnKeyBindings(key as Shortcut, shortcuts[key]);
+      /** @override */
+      disconnectedCallback() {
+        this.destroyVisibilityObserver();
+        this.disableBindings();
+        super.disconnectedCallback();
+      }
+
+      /**
+       * Creates an intersection observer that enables bindings when the
+       * element is visible and disables them when the element is hidden.
+       */
+      private createVisibilityObserver() {
+        if (!this.hasKeyboardShortcuts()) return;
+        if (this.observer) return;
+        this.observer = new IntersectionObserver(entries => {
+          check(entries.length === 1, 'Expected one observer entry.');
+          const isVisible = entries[0].isIntersecting;
+          if (isVisible) {
+            this.enableBindings();
+          } else {
+            this.disableBindings();
+          }
+        });
+        this.observer.observe(this.observerTarget);
+      }
+
+      private destroyVisibilityObserver() {
+        if (this.observer) this.observer.unobserve(this.observerTarget);
+      }
+
+      /**
+       * Enables all the shortcuts returned by keyboardShortcuts().
+       * This is a private method being called when the element becomes
+       * connected or visible.
+       */
+      private enableBindings() {
+        if (!this.hasKeyboardShortcuts()) return;
+        if (this.bindingsEnabled) return;
+        this.bindingsEnabled = true;
+
+        const shortcuts = new Map<string, string>(
+          Object.entries(this.keyboardShortcuts())
+        );
+        shortcutManager.attachHost(this, shortcuts);
+
+        for (const [key, value] of shortcuts.entries()) {
+          this._addOwnKeyBindings(key as Shortcut, value);
         }
 
         // each component that uses this behaviour must be aware if go key is
@@ -959,12 +989,21 @@
         }
       }
 
-      /** @override */
-      disconnectedCallback() {
+      /**
+       * Disables all the shortcuts returned by keyboardShortcuts().
+       * This is a private method being called when the element becomes
+       * disconnected or invisible.
+       */
+      private disableBindings() {
+        if (!this.bindingsEnabled) return;
+        this.bindingsEnabled = false;
         if (shortcutManager.detachHost(this)) {
           this.removeOwnKeyBindings();
         }
-        super.disconnectedCallback();
+      }
+
+      private hasKeyboardShortcuts() {
+        return Object.entries(this.keyboardShortcuts()).length > 0;
       }
 
       keyboardShortcuts() {
@@ -1087,8 +1126,6 @@
   bindShortcut(shortcut: Shortcut, ...bindings: string[]): void;
   shouldSuppressKeyboardShortcut(event: CustomKeyboardEvent): boolean;
   modifierPressed(event: CustomKeyboardEvent): boolean;
-  isModifierPressed(event: CustomKeyboardEvent, modifier: Modifier): boolean;
-  getKeyboardEvent(e: CustomKeyboardEvent): CustomKeyboardEvent;
   addKeyboardShortcutDirectoryListener(listener: ShortcutListener): void;
   removeKeyboardShortcutDirectoryListener(listener: ShortcutListener): void;
   // TODO(TS): Remove underscore. Apparently not a private method.
diff --git a/polygerrit-ui/app/mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin_test.js b/polygerrit-ui/app/mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin_test.js
index 180dbe7..d5e7fe7 100644
--- a/polygerrit-ui/app/mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin_test.js
+++ b/polygerrit-ui/app/mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin_test.js
@@ -181,13 +181,7 @@
             mapToObject(mgr.activeShortcutsBySection()),
             {});
 
-        mgr.attachHost({
-          keyboardShortcuts() {
-            return {
-              [Shortcut.NEXT_FILE]: null,
-            };
-          },
-        });
+        mgr.attachHost({}, new Map([[Shortcut.NEXT_FILE, null]]));
         assert.deepEqual(
             mapToObject(mgr.activeShortcutsBySection()),
             {
@@ -196,13 +190,7 @@
               ],
             });
 
-        mgr.attachHost({
-          keyboardShortcuts() {
-            return {
-              [Shortcut.NEXT_LINE]: null,
-            };
-          },
-        });
+        mgr.attachHost({}, new Map([[Shortcut.NEXT_LINE, null]]));
         assert.deepEqual(
             mapToObject(mgr.activeShortcutsBySection()),
             {
@@ -214,14 +202,10 @@
               ],
             });
 
-        mgr.attachHost({
-          keyboardShortcuts() {
-            return {
-              [Shortcut.SEARCH]: null,
-              [Shortcut.GO_TO_OPENED_CHANGES]: null,
-            };
-          },
-        });
+        mgr.attachHost({}, new Map([
+          [Shortcut.SEARCH, null],
+          [Shortcut.GO_TO_OPENED_CHANGES, null],
+        ]));
         assert.deepEqual(
             mapToObject(mgr.activeShortcutsBySection()),
             {
@@ -254,17 +238,13 @@
 
         assert.deepEqual(mapToObject(mgr.directoryView()), {});
 
-        mgr.attachHost({
-          keyboardShortcuts() {
-            return {
-              [Shortcut.GO_TO_OPENED_CHANGES]: null,
-              [Shortcut.NEXT_FILE]: null,
-              [Shortcut.NEXT_LINE]: null,
-              [Shortcut.SAVE_COMMENT]: null,
-              [Shortcut.SEARCH]: null,
-            };
-          },
-        });
+        mgr.attachHost({}, new Map([
+          [Shortcut.GO_TO_OPENED_CHANGES, null],
+          [Shortcut.NEXT_FILE, null],
+          [Shortcut.NEXT_LINE, null],
+          [Shortcut.SAVE_COMMENT, null],
+          [Shortcut.SEARCH, null],
+        ]));
         assert.deepEqual(
             mapToObject(mgr.directoryView()),
             {
@@ -377,27 +357,6 @@
     assert.isTrue(spy.lastCall.returnValue);
   });
 
-  test('isModifierPressed returns accurate value', () => {
-    const spy = sinon.spy(element, 'isModifierPressed');
-    element._handleKey = e => {
-      element.isModifierPressed(e, 'shiftKey');
-    };
-    MockInteractions.keyDownOn(element, 75, 'shift', 'k');
-    assert.isTrue(spy.lastCall.returnValue);
-    MockInteractions.keyDownOn(element, 75, null, 'k');
-    assert.isFalse(spy.lastCall.returnValue);
-    MockInteractions.keyDownOn(element, 75, 'ctrl', 'k');
-    assert.isFalse(spy.lastCall.returnValue);
-    MockInteractions.keyDownOn(element, 75, null, 'k');
-    assert.isFalse(spy.lastCall.returnValue);
-    MockInteractions.keyDownOn(element, 75, 'meta', 'k');
-    assert.isFalse(spy.lastCall.returnValue);
-    MockInteractions.keyDownOn(element, 75, null, 'k');
-    assert.isFalse(spy.lastCall.returnValue);
-    MockInteractions.keyDownOn(element, 75, 'alt', 'k');
-    assert.isFalse(spy.lastCall.returnValue);
-  });
-
   suite('GO_KEY timing', () => {
     let handlerStub;
 
diff --git a/polygerrit-ui/app/node_modules_licenses/licenses.ts b/polygerrit-ui/app/node_modules_licenses/licenses.ts
index 8dcb80e..ac49388 100644
--- a/polygerrit-ui/app/node_modules_licenses/licenses.ts
+++ b/polygerrit-ui/app/node_modules_licenses/licenses.ts
@@ -240,6 +240,10 @@
     license: SharedLicenses.Polymer2015
   },
   {
+    name: "@polymer/paper-fab",
+    license: SharedLicenses.Polymer2015
+  },
+  {
     name: "@polymer/paper-icon-button",
     license: SharedLicenses.Polymer2015
   },
@@ -308,6 +312,14 @@
     }
   },
   {
+    name: "codemirror-minified",
+    license: {
+      name: "codemirror-minified",
+      type: LicenseTypes.Mit,
+      packageLicenseFile: "LICENSE",
+    }
+  },
+  {
     name: "isarray",
     license: SharedLicenses.IsArray
   },
diff --git a/polygerrit-ui/app/package.json b/polygerrit-ui/app/package.json
index fa59260..4f5acf5 100644
--- a/polygerrit-ui/app/package.json
+++ b/polygerrit-ui/app/package.json
@@ -22,6 +22,7 @@
     "@polymer/paper-dialog-behavior": "^3.0.1",
     "@polymer/paper-dialog-scrollable": "^3.0.1",
     "@polymer/paper-dropdown-menu": "^3.2.0",
+    "@polymer/paper-fab": "^3.0.1",
     "@polymer/paper-input": "^3.2.1",
     "@polymer/paper-item": "^3.0.1",
     "@polymer/paper-listbox": "^3.0.1",
@@ -34,7 +35,8 @@
     "@webcomponents/shadycss": "^1.10.2",
     "@webcomponents/webcomponentsjs": "^1.3.3",
     "ba-linkify": "file:../../lib/ba-linkify/src/",
-    "lit-element": "^2.4.0",
+    "codemirror-minified": "^5.60.0",
+    "lit-element": "^2.5.1",
     "page": "^1.11.6",
     "polymer-bridges": "file:../../polymer-bridges/",
     "polymer-resin": "^2.0.1",
diff --git a/polygerrit-ui/app/services/app-context-init.ts b/polygerrit-ui/app/services/app-context-init.ts
index dab894e..9af86d2 100644
--- a/polygerrit-ui/app/services/app-context-init.ts
+++ b/polygerrit-ui/app/services/app-context-init.ts
@@ -73,7 +73,7 @@
     authService: () => new Auth(appContext.eventEmitter),
     restApiService: () => new GrRestApiInterface(appContext.authService),
     changeService: () => new ChangeService(),
-    checksService: () => new ChecksService(),
+    checksService: () => new ChecksService(appContext.reportingService),
     jsApiService: () => new GrJsApiInterface(),
     storageService: () => new GrStorageService(),
     configService: () => new ConfigService(),
diff --git a/polygerrit-ui/app/services/change/change-model.ts b/polygerrit-ui/app/services/change/change-model.ts
index 7085d3c..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.
@@ -92,6 +92,11 @@
   distinctUntilChanged()
 );
 
+export const labels$ = change$.pipe(
+  map(change => change?.labels),
+  distinctUntilChanged()
+);
+
 export const latestPatchNum$ = change$.pipe(
   map(change => computeLatestPatchNum(computeAllPatchSets(change))),
   distinctUntilChanged()
diff --git a/polygerrit-ui/app/services/change/change-service.ts b/polygerrit-ui/app/services/change/change-service.ts
index c292fb5..0b9a1f2 100644
--- a/polygerrit-ui/app/services/change/change-service.ts
+++ b/polygerrit-ui/app/services/change/change-service.ts
@@ -15,10 +15,20 @@
  * 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';
+import {
+  computeAllPatchSets,
+  computeLatestPatchNum,
+} from '../../utils/patch-set-util';
 
 export class ChangeService {
+  private change?: ParsedChangeInfo;
+
+  private readonly restApiService = appContext.restApiService;
+
   constructor() {
     // TODO: In the future we will want to make restApiService.getChangeDetail()
     // calls from a switchMap() here. For now just make sure to invalidate the
@@ -26,6 +36,9 @@
     routerChangeNum$.subscribe(changeNum => {
       if (!changeNum) updateState(undefined);
     });
+    change$.subscribe(change => {
+      this.change = change;
+    });
   }
 
   /**
@@ -38,4 +51,44 @@
   updateChange(change: ParsedChangeInfo) {
     updateState(change);
   }
+
+  /**
+   * 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.
+   *
+   * @return A promise that yields true if the latest patch
+   *     has been loaded, and false if a newer patch has been uploaded in the
+   *     meantime. The promise is rejected on network error.
+   */
+  fetchChangeUpdates(change: ChangeInfo | ParsedChangeInfo) {
+    const knownLatest = computeLatestPatchNum(computeAllPatchSets(change));
+    return this.restApiService.getChangeDetail(change._number).then(detail => {
+      if (!detail) {
+        const error = new Error('Change detail not found.');
+        return Promise.reject(error);
+      }
+      const actualLatest = computeLatestPatchNum(computeAllPatchSets(detail));
+      if (!actualLatest || !knownLatest) {
+        const error = new Error('Unable to check for latest patchset.');
+        return Promise.reject(error);
+      }
+      return {
+        isLatest: actualLatest <= knownLatest,
+        newStatus: change.status !== detail.status ? detail.status : null,
+        newMessages:
+          (change.messages || []).length < (detail.messages || []).length
+            ? detail.messages![detail.messages!.length - 1]
+            : undefined,
+      };
+    });
+  }
 }
diff --git a/polygerrit-ui/app/services/change/change-services_test.ts b/polygerrit-ui/app/services/change/change-services_test.ts
new file mode 100644
index 0000000..3e427ff
--- /dev/null
+++ b/polygerrit-ui/app/services/change/change-services_test.ts
@@ -0,0 +1,108 @@
+/**
+ * @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 {ChangeStatus} from '../../constants/constants';
+import '../../test/common-test-setup-karma';
+import {
+  createChange,
+  createChangeMessageInfo,
+  createRevision,
+} from '../../test/test-data-generators';
+import {stubRestApi} from '../../test/test-utils';
+import {CommitId, PatchSetNum} from '../../types/common';
+import {ParsedChangeInfo} from '../../types/types';
+import {ChangeService} from './change-service';
+
+suite('change service tests', () => {
+  let changeService: ChangeService;
+  let knownChange: ParsedChangeInfo;
+  setup(() => {
+    changeService = new ChangeService();
+    knownChange = {
+      ...createChange(),
+      revisions: {
+        sha1: {
+          ...createRevision(1),
+          description: 'patch 1',
+          _number: 1 as PatchSetNum,
+        },
+        sha2: {
+          ...createRevision(2),
+          description: 'patch 2',
+          _number: 2 as PatchSetNum,
+        },
+      },
+      status: ChangeStatus.NEW,
+      current_revision: 'abc' as CommitId,
+      messages: [],
+    };
+  });
+
+  test('changeService.fetchChangeUpdates on latest', async () => {
+    stubRestApi('getChangeDetail').returns(Promise.resolve(knownChange));
+    const result = await changeService.fetchChangeUpdates(knownChange);
+    assert.isTrue(result.isLatest);
+    assert.isNotOk(result.newStatus);
+    assert.isNotOk(result.newMessages);
+  });
+
+  test('changeService.fetchChangeUpdates not on latest', async () => {
+    const actualChange = {
+      ...knownChange,
+      revisions: {
+        ...knownChange.revisions,
+        sha3: {
+          ...createRevision(3),
+          description: 'patch 3',
+          _number: 3 as PatchSetNum,
+        },
+      },
+    };
+    stubRestApi('getChangeDetail').returns(Promise.resolve(actualChange));
+    const result = await changeService.fetchChangeUpdates(knownChange);
+    assert.isFalse(result.isLatest);
+    assert.isNotOk(result.newStatus);
+    assert.isNotOk(result.newMessages);
+  });
+
+  test('changeService.fetchChangeUpdates new status', async () => {
+    const actualChange = {
+      ...knownChange,
+      status: ChangeStatus.MERGED,
+    };
+    stubRestApi('getChangeDetail').returns(Promise.resolve(actualChange));
+    const result = await changeService.fetchChangeUpdates(knownChange);
+    assert.isTrue(result.isLatest);
+    assert.equal(result.newStatus, ChangeStatus.MERGED);
+    assert.isNotOk(result.newMessages);
+  });
+
+  test('changeService.fetchChangeUpdates new messages', async () => {
+    const actualChange = {
+      ...knownChange,
+      messages: [{...createChangeMessageInfo(), message: 'blah blah'}],
+    };
+    stubRestApi('getChangeDetail').returns(Promise.resolve(actualChange));
+    const result = await changeService.fetchChangeUpdates(knownChange);
+    assert.isTrue(result.isLatest);
+    assert.isNotOk(result.newStatus);
+    assert.deepEqual(result.newMessages, {
+      ...createChangeMessageInfo(),
+      message: 'blah blah',
+    });
+  });
+});
diff --git a/polygerrit-ui/app/services/checks/checks-model.ts b/polygerrit-ui/app/services/checks/checks-model.ts
index ecd56cb..5d3da42 100644
--- a/polygerrit-ui/app/services/checks/checks-model.ts
+++ b/polygerrit-ui/app/services/checks/checks-model.ts
@@ -21,16 +21,27 @@
   Category,
   CheckResult as CheckResultApi,
   CheckRun as CheckRunApi,
-  ChecksApiConfig,
   Link,
   LinkIcon,
   RunStatus,
+  TagColor,
 } from '../../api/checks';
 import {distinctUntilChanged, map} from 'rxjs/operators';
 import {PatchSetNumber} from '../../types/common';
 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
@@ -74,21 +85,33 @@
   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);
@@ -96,29 +119,45 @@
 // 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(
@@ -128,7 +167,7 @@
   distinctUntilChanged()
 );
 
-export const loginCallback$ = checksProviderState$.pipe(
+export const loginCallbackLatest$ = checksLatest$.pipe(
   map(
     state =>
       Object.values(state).find(
@@ -138,7 +177,7 @@
   distinctUntilChanged()
 );
 
-export const allActions$ = checksProviderState$.pipe(
+export const topLevelActionsSelected$ = checksSelected$.pipe(
   map(state =>
     Object.values(state).reduce(
       (allActions: Action[], providerState: ChecksProviderState) => [
@@ -150,7 +189,7 @@
   )
 );
 
-export const allLinks$ = checksProviderState$.pipe(
+export const topLevelLinksSelected$ = checksSelected$.pipe(
   map(state =>
     Object.values(state).reduce(
       (allActions: Link[], providerState: ChecksProviderState) => [
@@ -162,7 +201,7 @@
   )
 );
 
-export const allRuns$ = checksProviderState$.pipe(
+export const allRunsLatestPatchset$ = checksLatest$.pipe(
   map(state =>
     Object.values(state).reduce(
       (allRuns: CheckRun[], providerState: ChecksProviderState) => [
@@ -174,11 +213,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)) {
@@ -190,7 +241,7 @@
   })
 );
 
-export const allResults$ = checksProviderState$.pipe(
+export const allResultsSelected$ = checksSelected$.pipe(
   map(state =>
     Object.values(state)
       .reduce(
@@ -210,16 +261,12 @@
 
 // Must only be used by the checks service or whatever is in control of this
 // model.
-export function updateStateSetProvider(
-  pluginName: string,
-  config?: ChecksApiConfig
-) {
+export function updateStateSetProvider(pluginName: string) {
   const nextState = {...privateState$.getValue()};
-  nextState.providerNameToState = {...nextState.providerNameToState};
-  nextState.providerNameToState[pluginName] = {
+  nextState.pluginStateLatest = {...nextState.pluginStateLatest};
+  nextState.pluginStateLatest[pluginName] = {
     pluginName,
     loading: false,
-    config,
     runs: [],
     actions: [],
     links: [],
@@ -227,9 +274,9 @@
   privateState$.next(nextState);
 }
 
-// TODO(brohlfs): Remove all fake runs by end of April. They are just making
-// it easier to develop the UI and always see all the different types/states of
-// runs and results.
+// TODO(brohlfs): Remove all fake runs once the Checks UI is fully launched.
+//  They are just making it easier to develop the UI and always see all the
+//  different types/states of runs and results.
 
 export const fakeRun0: CheckRun = {
   internalRunId: 'f0',
@@ -252,30 +299,40 @@
       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',
           tooltip: 'Ignore this result',
           primary: true,
-          callback: () => undefined,
+          callback: () => Promise.resolve({message: 'fake "ignore" triggered'}),
         },
         {
           name: 'Flag',
-          tooltip: 'Flag this result as not useful',
+          tooltip: 'Flag this result as totally absolutely really not useful',
           primary: true,
-          callback: () => undefined,
+          disabled: true,
+          callback: () => Promise.resolve({message: 'flag "flag" triggered'}),
         },
         {
           name: 'Upload',
           tooltip: 'Upload the result to the super cloud.',
           primary: false,
-          callback: () => undefined,
+          callback: () => Promise.resolve({message: 'fake "upload" triggered'}),
         },
       ],
-      tags: [{name: 'INTERRUPTED'}, {name: 'WINDOWS'}],
+      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},
@@ -288,6 +345,7 @@
 export const fakeRun1: CheckRun = {
   internalRunId: 'f1',
   checkName: 'FAKE Super Check',
+  patchset: 1,
   labelName: 'Verified',
   isSingleAttempt: true,
   isLatestAttempt: true,
@@ -299,7 +357,27 @@
       summary: 'We think that you could improve this.',
       message: `There is a lot to be said. A lot. I say, a lot.\n
                 So please keep reading.`,
-      tags: [{name: 'INTERRUPTED'}, {name: 'WINDOWS'}],
+      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},
@@ -309,6 +387,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,
+        },
       ],
     },
   ],
@@ -334,17 +424,18 @@
       name: 'Re-Run',
       tooltip: 'More powerful run than before',
       primary: true,
-      callback: () => undefined,
+      callback: () => Promise.resolve({message: 'fake "re-run" triggered'}),
     },
     {
       name: 'Monetize',
       primary: true,
-      callback: () => undefined,
+      disabled: true,
+      callback: () => Promise.resolve({message: 'fake "monetize" triggered'}),
     },
     {
       name: 'Delete',
       primary: true,
-      callback: () => undefined,
+      callback: () => Promise.resolve({message: 'fake "delete" triggered'}),
     },
   ],
   results: [
@@ -445,63 +536,106 @@
 export const fakeActions: Action[] = [
   {
     name: 'Fake Action 1',
-    primary: false,
+    primary: true,
+    disabled: true,
     tooltip: 'Tooltip for Fake Action 1',
-    callback: () => {
-      console.warn('fake action 1 triggered');
-      return undefined;
-    },
+    callback: () => Promise.resolve({message: 'fake action 1 triggered'}),
   },
   {
     name: 'Fake Action 2',
     primary: false,
+    disabled: true,
     tooltip: 'Tooltip for Fake Action 2',
-    callback: () => {
-      console.warn('fake action 2 triggered');
-      return undefined;
-    },
+    callback: () => Promise.resolve({message: 'fake action 2 triggered'}),
   },
   {
     name: 'Fake Action 3',
     primary: false,
     tooltip: 'Tooltip for Fake Action 3',
-    callback: () => {
-      console.warn('fake action 3 triggered');
-      return undefined;
-    },
+    callback: () => Promise.resolve({message: 'fake action 3 triggered'}),
   },
 ];
 
 export const fakeLinks: Link[] = [
   {
     url: 'https://www.google.com',
-    primary: false,
-    tooltip: 'Tooltip for Bug Report Fake Link',
+    primary: true,
+    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,
@@ -513,12 +647,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,
@@ -532,7 +667,8 @@
   pluginName: string,
   runs: CheckRunApi[],
   actions: Action[] = [],
-  links: Link[] = []
+  links: Link[] = [],
+  patchset: ChecksPatchset
 ) {
   const attemptMap = createAttemptMap(runs);
   for (const attemptInfo of attemptMap.values()) {
@@ -541,9 +677,9 @@
     attemptInfo.attempts.sort((a, b) => (b.attempt ?? -1) - (a.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,
@@ -573,6 +709,6 @@
 
 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-service.ts b/polygerrit-ui/app/services/checks/checks-service.ts
index 250cea5..e1d435b 100644
--- a/polygerrit-ui/app/services/checks/checks-service.ts
+++ b/polygerrit-ui/app/services/checks/checks-service.ts
@@ -32,7 +32,8 @@
 } from '../../api/checks';
 import {change$, changeNum$, latestPatchNum$} from '../change/change-model';
 import {
-  checksPatchsetNumber$,
+  ChecksPatchset,
+  checksSelectedPatchsetNumber$,
   checkToPluginMap$,
   updateStateSetError,
   updateStateSetLoading,
@@ -54,6 +55,8 @@
 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';
 
 export class ChecksService {
   private readonly providers: {[name: string]: ChecksProvider} = {};
@@ -62,22 +65,36 @@
 
   private checkToPluginMap = new Map<string, string>();
 
+  private latestPatchNum?: PatchSetNumber;
+
   private readonly documentVisibilityChange$ = new BehaviorSubject(undefined);
 
-  constructor() {
+  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);
     });
+    document.addEventListener('reload', () => {
+      this.reloadAll();
+    });
   }
 
-  setPatchset(num: PatchSetNumber) {
-    updateStateSetPatchset(num);
+  setPatchset(num?: PatchSetNumber) {
+    updateStateSetPatchset(num === this.latestPatchNum ? undefined : num);
   }
 
   reload(pluginName: string) {
@@ -101,7 +118,16 @@
   ) {
     this.providers[pluginName] = provider;
     this.reloadSubjects[pluginName] = new BehaviorSubject<void>(undefined);
-    updateStateSetProvider(pluginName, config);
+    updateStateSetProvider(pluginName);
+    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.
@@ -110,7 +136,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$,
@@ -121,27 +149,13 @@
         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,
@@ -150,37 +164,76 @@
               commmitMessage: getCurrentRevision(change)?.commit?.message,
               changeInfo: change,
             };
-            updateStateSetLoading(pluginName);
-            return from(this.providers[pluginName].fetch(data));
+            return this.fetchResults(pluginName, data, patchset);
           }
         ),
         catchError(e => {
-          const errorResponse: FetchResponse = {
-            responseCode: ResponseCode.ERROR,
-            errorMessage: `Error message from plugin '${pluginName}': ${e}`,
-          };
-          return of(errorResponse);
+          // This should not happen and is really severe, because it means that
+          // 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}`));
         })
       )
       .subscribe(response => {
         switch (response.responseCode) {
           case ResponseCode.ERROR:
             assertIsDefined(response.errorMessage, 'errorMessage');
-            updateStateSetError(pluginName, response.errorMessage);
+            updateStateSetError(pluginName, response.errorMessage, patchset);
             break;
           case ResponseCode.NOT_LOGGED_IN:
             assertIsDefined(response.loginCallback, 'loginCallback');
-            updateStateSetNotLoggedIn(pluginName, response.loginCallback);
+            updateStateSetNotLoggedIn(
+              pluginName,
+              response.loginCallback,
+              patchset
+            );
             break;
           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
+  ): FetchResponse {
+    return {
+      responseCode: ResponseCode.ERROR,
+      errorMessage: `Error message from plugin '${pluginName}': ${message}`,
+    };
+  }
+
+  private fetchResults(
+    pluginName: string,
+    data: ChangeData,
+    patchset: ChecksPatchset
+  ): Observable<FetchResponse> {
+    updateStateSetLoading(pluginName, patchset);
+    const timer = this.reporting.getTimer('ChecksPluginFetch');
+    const fetchPromise = this.providers[pluginName]
+      .fetch(data)
+      .then(response => {
+        timer.end({pluginName});
+        return response;
+      });
+    return from(fetchPromise).pipe(
+      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 15841f3..9381f30 100644
--- a/polygerrit-ui/app/services/checks/checks-util.ts
+++ b/polygerrit-ui/app/services/checks/checks-util.ts
@@ -21,6 +21,7 @@
   CheckResult as CheckResultApi,
   LinkIcon,
   RunStatus,
+  Link,
 } from '../../api/checks';
 import {assertNever} from '../../utils/common-util';
 import {CheckResult, CheckRun} from './checks-model';
@@ -42,6 +43,8 @@
       return 'help-outline';
     case LinkIcon.REPORT_BUG:
       return 'bug';
+    case LinkIcon.CODE:
+      return 'code';
     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
@@ -79,10 +82,11 @@
   if (hasResultsOf(run, Category.ERROR)) return Category.ERROR;
   if (hasResultsOf(run, Category.WARNING)) return Category.WARNING;
   if (hasResultsOf(run, Category.INFO)) return Category.INFO;
+  if (hasResultsOf(run, Category.SUCCESS)) return Category.SUCCESS;
   return undefined;
 }
 
-export function iconForCategory(category: Category | 'SUCCESS') {
+export function iconForCategory(category: Category) {
   switch (category) {
     case Category.ERROR:
       return 'error';
@@ -90,14 +94,14 @@
       return 'info-outline';
     case Category.WARNING:
       return 'warning';
-    case 'SUCCESS':
+    case Category.SUCCESS:
       return 'check-circle-outline';
     default:
       assertNever(category, `Unsupported category: ${category}`);
   }
 }
 
-enum PRIMARY_STATUS_ACTIONS {
+export enum PRIMARY_STATUS_ACTIONS {
   RERUN = 'rerun',
   RUN = 'run',
   CANCEL = 'cancel',
@@ -127,9 +131,10 @@
   }
 }
 
-export function primaryRunAction(run: CheckRun): Action | undefined {
+export function primaryRunAction(run?: CheckRun): Action | undefined {
+  if (!run) return undefined;
   return runActions(run).filter(
-    action => action.name === primaryActionName(run.status)
+    action => !action.disabled && action.name === primaryActionName(run.status)
   )[0];
 }
 
@@ -210,12 +215,14 @@
 export function level(cat?: Category) {
   if (!cat) return -1;
   switch (cat) {
-    case Category.INFO:
+    case Category.SUCCESS:
       return 0;
-    case Category.WARNING:
+    case Category.INFO:
       return 1;
-    case Category.ERROR:
+    case Category.WARNING:
       return 2;
+    case Category.ERROR:
+      return 3;
   }
 }
 
@@ -234,9 +241,10 @@
 
 export function fireActionTriggered(
   target: EventTarget,
-  action: Action,
+  action?: Action,
   run?: CheckRun
 ) {
+  if (!action) return;
   target.dispatchEvent(
     new CustomEvent('action-triggered', {
       detail: {action, run},
@@ -305,3 +313,20 @@
     internalResultId: 'fake',
   };
 }
+
+function allPrimaryLinks(result?: CheckResultApi): Link[] {
+  return (result?.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/flags/flags.ts b/polygerrit-ui/app/services/flags/flags.ts
index 1a29055..789ff4e 100644
--- a/polygerrit-ui/app/services/flags/flags.ts
+++ b/polygerrit-ui/app/services/flags/flags.ts
@@ -26,4 +26,5 @@
 export enum KnownExperimentId {
   NEW_IMAGE_DIFF_UI = 'UiFeature__new_image_diff_ui',
   TOKEN_HIGHLIGHTING = 'UiFeature__token_highlighting',
+  CHECKS_DEVELOPER = 'UiFeature__checks_developer',
 }
diff --git a/polygerrit-ui/app/services/gr-reporting/gr-reporting.ts b/polygerrit-ui/app/services/gr-reporting/gr-reporting.ts
index d7081ce..ac072f3 100644
--- a/polygerrit-ui/app/services/gr-reporting/gr-reporting.ts
+++ b/polygerrit-ui/app/services/gr-reporting/gr-reporting.ts
@@ -18,13 +18,18 @@
 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};
 
 export interface Timer {
   reset(): this;
-  end(): this;
+  end(eventDetails?: EventDetails): this;
   withMaximum(maximum: number): this;
 }
 
@@ -43,7 +48,7 @@
   beforeLocationChanged(): void;
   locationChanged(page: string): void;
   dashboardDisplayed(): void;
-  changeDisplayed(): void;
+  changeDisplayed(eventDetails?: EventDetails): void;
   changeFullyLoaded(): void;
   diffViewDisplayed(): void;
   diffViewFullyLoaded(): void;
@@ -52,7 +57,7 @@
   reportExtension(name: string): void;
   pluginLoaded(name: string): void;
   pluginsLoaded(pluginsList?: string[]): void;
-  error(err: unknown, reporter?: string, details?: EventDetails): void;
+  error(err: Error, reporter?: string, details?: EventDetails): void;
   /**
    * Reset named timer.
    */
@@ -104,7 +109,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 5fd4234..0b93f07 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.
 
@@ -508,11 +513,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);
     }
   }
 
@@ -713,7 +719,7 @@
       },
 
       // Stop the timer and report the intervening time.
-      end: () => {
+      end: (eventDetails?: EventDetails) => {
         if (called) {
           throw new Error(`Timer for "${name}" already ended.`);
         }
@@ -725,7 +731,7 @@
           return timer;
         }
 
-        this._reportTiming(name, time);
+        this._reportTiming(name, time, eventDetails);
         return timer;
       },
 
@@ -772,7 +778,7 @@
     );
   }
 
-  reportInteraction(eventName: string, details: EventDetails) {
+  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..24c27e0 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,7 +72,10 @@
     log(`trackApi '${plugin}', ${object}, ${method}`);
   },
   reportExtension: () => {},
-  reportInteraction: (eventName: string, details?: EventDetails) => {
+  reportInteraction: (
+    eventName: string | Interaction,
+    details?: EventDetails
+  ) => {
     log(`reportInteraction '${eventName}': ${JSON.stringify(details)}`);
   },
   reportLifeCycle: () => {},
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 d92fe7c..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
@@ -206,7 +206,7 @@
 
   getChange(
     changeNum: ChangeId | NumericChangeId,
-    errFn: ErrorCallback
+    errFn?: ErrorCallback
   ): Promise<ChangeInfo | null>;
 
   savePreferences(prefs: PreferencesInput): Promise<Response>;
@@ -711,11 +711,6 @@
 
   addAccountEmail(email: string): Promise<Response>;
 
-  saveChangeReviewed(
-    changeNum: NumericChangeId,
-    reviewed: boolean
-  ): Promise<Response | undefined>;
-
   saveChangeStarred(
     changeNum: NumericChangeId,
     starred: boolean
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-spinner-styles.ts b/polygerrit-ui/app/styles/gr-spinner-styles.ts
new file mode 100644
index 0000000..6fb1ae6
--- /dev/null
+++ b/polygerrit-ui/app/styles/gr-spinner-styles.ts
@@ -0,0 +1,54 @@
+/**
+ * @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 {css} from 'lit-element';
+
+// Mark the file as a module. Otherwise typescript assumes this is a script
+// and $_documentContainer is a global variable.
+// See: https://www.typescriptlang.org/docs/handbook/modules.html
+export {};
+
+const $_documentContainer = document.createElement('template');
+
+export const spinnerStyles = css`
+  .loadingSpin {
+    border: 2px solid var(--disabled-button-background-color);
+    border-top: 2px solid var(--primary-button-background-color);
+    border-radius: 50%;
+    width: 10px;
+    height: 10px;
+    animation: spin 2s linear infinite;
+    margin-right: var(--spacing-s);
+  }
+  @keyframes spin {
+    0% {
+      transform: rotate(0deg);
+    }
+    100% {
+      transform: rotate(360deg);
+    }
+  }
+`;
+
+$_documentContainer.innerHTML = `<dom-module id="gr-spinner-styles">
+  <template>
+    <style>
+      ${spinnerStyles.cssText}
+    </style>
+  </template>
+</dom-module>`;
+
+document.head.appendChild($_documentContainer.content);
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 6d128b3..287cf68 100644
--- a/polygerrit-ui/app/styles/shared-styles.ts
+++ b/polygerrit-ui/app/styles/shared-styles.ts
@@ -270,6 +270,10 @@
     z-index: -1000;
   }
 
+  /**
+   * TODO: Remove these rules and change (plugin) users to rely on
+   * gr-spinner-styles directly.
+   */
   /** BEGIN: loading spiner */
   .loadingSpin {
     border: 2px solid var(--disabled-button-background-color);
@@ -300,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 838d82b..d768c96 100644
--- a/polygerrit-ui/app/styles/themes/app-theme.ts
+++ b/polygerrit-ui/app/styles/themes/app-theme.ts
@@ -253,6 +253,7 @@
     --status-wip: #795548;
     --status-private: var(--purple-500);
     --status-conflict: var(--red-600);
+    --status-revert-created: #e64a19;
     --status-active: var(--blue-700);
     --status-ready: var(--pink-800);
     --status-custom: var(--purple-900);
@@ -336,6 +337,7 @@
     --coverage-covered: #e0f2f1;
     --coverage-not-covered: #ffd1a4;
     --ranged-comment-hint-text-color: var(--orange-900);
+    --token-highlighting-color: #fffd54;
 
     /* syntax colors */
     --syntax-attr-color: #219;
diff --git a/polygerrit-ui/app/styles/themes/dark-theme.ts b/polygerrit-ui/app/styles/themes/dark-theme.ts
index d6e96dd..dec438d 100644
--- a/polygerrit-ui/app/styles/themes/dark-theme.ts
+++ b/polygerrit-ui/app/styles/themes/dark-theme.ts
@@ -151,6 +151,7 @@
       --status-wip: #bcaaa4;
       --status-private: var(--purple-200);
       --status-conflict: var(--red-300);
+      --status-revert-created: #ff8a65;
       --status-active: var(--blue-400);
       --status-ready: var(--pink-500);
       --status-custom: var(--purple-400);
@@ -174,7 +175,7 @@
       --header-text-color: var(--primary-text-color);
 
       /* diff colors */
-      --dark-add-highlight-color: #133820;
+      --dark-add-highlight-color: var(--green-tonal); 
       --dark-rebased-add-highlight-color: rgba(11, 255, 155, 0.15);
       --dark-rebased-remove-highlight-color: rgba(255, 139, 6, 0.15);
       --dark-remove-highlight-color: #62110f;
@@ -187,7 +188,7 @@
       --diff-selection-background-color: #3a71d8;
       --diff-tab-indicator-color: var(--deemphasized-text-color);
       --diff-trailing-whitespace-indicator: #ff9ad2;
-      --light-add-highlight-color: #0f401f;
+      --light-add-highlight-color: #182b1f;
       --light-rebased-add-highlight-color: #487165;
       --diff-moved-in-background: #1d4042;
       --diff-moved-out-background: #230e34;
@@ -198,6 +199,7 @@
       --coverage-covered: #112826;
       --coverage-not-covered: #6b3600;
       --ranged-comment-hint-text-color: var(--blue-50);
+      --token-highlighting-color: var(--yellow-tonal);
 
       /* syntax colors */
       --syntax-attr-color: #80cbbf;
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 90b6125..a3df94c 100644
--- a/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts
+++ b/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts
@@ -445,9 +445,6 @@
   saveChangeReview() {
     return Promise.resolve(new Response());
   },
-  saveChangeReviewed(): Promise<Response | undefined> {
-    return Promise.resolve(new Response());
-  },
   saveChangeStarred(): Promise<Response> {
     return Promise.resolve(new Response());
   },
diff --git a/polygerrit-ui/app/test/test-data-generators.ts b/polygerrit-ui/app/test/test-data-generators.ts
index 778e58d..fa40529 100644
--- a/polygerrit-ui/app/test/test-data-generators.ts
+++ b/polygerrit-ui/app/test/test-data-generators.ts
@@ -348,7 +348,6 @@
     update_delay: 0,
     mergeability_computation_behavior:
       MergeabilityComputationBehavior.REF_UPDATED_AND_CHANGE_REINDEX,
-    enable_attention_set: false,
     enable_assignee: false,
   };
 }
diff --git a/polygerrit-ui/app/test/test-utils.ts b/polygerrit-ui/app/test/test-utils.ts
index 26b99ac..34f7fe4 100644
--- a/polygerrit-ui/app/test/test-utils.ts
+++ b/polygerrit-ui/app/test/test-utils.ts
@@ -71,6 +71,11 @@
   return found;
 }
 
+export function isVisible(el: Element) {
+  assert.ok(el);
+  return getComputedStyle(el).getPropertyValue('display') !== 'none';
+}
+
 // Some tests/elements can define its own binding. We want to restore bindings
 // at the end of the test. The TestKeyboardShortcutBinder store bindings in
 // stack, so it is possible to override bindings in nested suites.
@@ -170,6 +175,10 @@
   return sinon.stub(appContext.storageService, method);
 }
 
+export function spyStorage<K extends keyof StorageService>(method: K) {
+  return sinon.spy(appContext.storageService, method);
+}
+
 export type SinonSpyMember<F extends (...args: any) => any> = SinonSpy<
   Parameters<F>,
   ReturnType<F>
diff --git a/polygerrit-ui/app/types/common.ts b/polygerrit-ui/app/types/common.ts
index 3183510..30eb658 100644
--- a/polygerrit-ui/app/types/common.ts
+++ b/polygerrit-ui/app/types/common.ts
@@ -490,6 +490,7 @@
   hashtags?: ActionInfo;
   assignee?: ActionInfo;
   ready?: ActionInfo;
+  includedIn?: ActionInfo;
 }
 
 /**
@@ -528,6 +529,7 @@
   real_author?: AccountInfo;
   date: Timestamp;
   message: string;
+  accounts_in_message?: AccountInfo[];
   tag?: ReviewInputTag;
   _revision_number?: PatchSetNum;
 }
@@ -636,6 +638,7 @@
   subject: string;
   message: string;
   web_links?: WebLinkInfo[];
+  resolve_conflicts_web_links?: WebLinkInfo[];
 }
 
 export interface CommitInfoWithRequiredCommit extends CommitInfo {
@@ -808,7 +811,6 @@
   submit_whole_topic?: boolean;
   disable_private_changes?: boolean;
   mergeability_computation_behavior: MergeabilityComputationBehavior;
-  enable_attention_set: boolean;
   enable_assignee: boolean;
 }
 
@@ -1766,6 +1768,7 @@
   email_strategy: EmailStrategy;
   default_base_for_merges: DefaultBase;
   publish_comments_on_push?: boolean;
+  disable_keyboard_shortcuts?: boolean;
   work_in_progress_by_default?: boolean;
   // The email_format doesn't mentioned in doc, but exists in Java class GeneralPreferencesInfo
   email_format?: EmailFormat;
@@ -1829,7 +1832,8 @@
 /**
  * The AddReviewerResult entity describes the result of adding a reviewer to a
  * change.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#add-reviewer-result
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#reviewer-result
+ * TODO(paiking): update this to ReviewerResult while considering removals.
  */
 export interface AddReviewerResult {
   input: AccountId | GroupId | EmailAddress;
diff --git a/polygerrit-ui/app/types/diff.ts b/polygerrit-ui/app/types/diff.ts
index c3b38c6..35c5726 100644
--- a/polygerrit-ui/app/types/diff.ts
+++ b/polygerrit-ui/app/types/diff.ts
@@ -51,6 +51,12 @@
    * entries.
    */
   web_links?: DiffWebLinkInfo[];
+
+  /**
+   * Links to edit the file in external sites as a list of WebLinkInfo
+   * entries.
+   */
+  edit_web_links?: WebLinkInfo[];
 }
 
 /**
diff --git a/polygerrit-ui/app/types/events.ts b/polygerrit-ui/app/types/events.ts
index ff08c57..8e89525 100644
--- a/polygerrit-ui/app/types/events.ts
+++ b/polygerrit-ui/app/types/events.ts
@@ -18,7 +18,7 @@
 import {PatchSetNum, UrlEncodedCommentId} from './common';
 import {UIComment} from '../utils/comment-util';
 import {FetchRequest} from './types';
-import {MovedLinkClickedEventDetail} from '../api/diff';
+import {LineNumberEventDetail, MovedLinkClickedEventDetail} from '../api/diff';
 import {Category, RunStatus} from '../api/checks';
 import {ChangeMessage} from '../elements/change/gr-message/gr-message';
 
@@ -34,6 +34,8 @@
   OPEN_FIX_PREVIEW = 'open-fix-preview',
   CLOSE_FIX_PREVIEW = 'close-fix-preview',
   PAGE_ERROR = 'page-error',
+  RECREATE_CHANGE_VIEW = 'recreate-change-view',
+  RECREATE_DIFF_VIEW = 'recreate-diff-view',
   RELOAD = 'reload',
   REPLY = 'reply',
   SERVER_ERROR = 'server-error',
@@ -53,14 +55,18 @@
     'editable-content-save': EditableContentSaveEvent;
     'location-change': LocationChangeEvent;
     'iron-announce': IronAnnounceEvent;
+    'line-number-mouse-enter': LineNumberEvent;
+    'line-number-mouse-leave': LineNumberEvent;
+    'line-cursor-moved-in': LineNumberEvent;
+    'line-cursor-moved-out': LineNumberEvent;
     'moved-link-clicked': MovedLinkClickedEvent;
     'open-fix-preview': OpenFixPreviewEvent;
     'close-fix-preview': CloseFixPreviewEvent;
+    'create-fix-comment': CreateFixCommentEvent;
     /* prettier-ignore */
     'reload': ReloadEvent;
     /* prettier-ignore */
     'reply': ReplyEvent;
-    'shortcut-triggered': ShortcutTriggeredEvent;
     'show-alert': ShowAlertEvent;
     'show-error': ShowErrorEvent;
     'show-primary-tab': SwitchTabEvent;
@@ -75,6 +81,8 @@
     'gr-rpc-log': RpcLogEvent;
     'network-error': NetworkErrorEvent;
     'page-error': PageErrorEvent;
+    /* prettier-ignore */
+    'reload': ReloadEvent;
     'server-error': ServerErrorEvent;
     'show-alert': ShowAlertEvent;
     'show-error': ShowErrorEvent;
@@ -120,6 +128,8 @@
 
 export type MovedLinkClickedEvent = CustomEvent<MovedLinkClickedEventDetail>;
 
+export type LineNumberEvent = CustomEvent<LineNumberEventDetail>;
+
 export interface NetworkErrorEventDetail {
   error: Error;
 }
@@ -135,6 +145,11 @@
   fixApplied: boolean;
 }
 export type CloseFixPreviewEvent = CustomEvent<CloseFixPreviewEventDetail>;
+export interface CreateFixCommentEventDetail {
+  patchNum?: PatchSetNum;
+  comment?: UIComment;
+}
+export type CreateFixCommentEvent = CustomEvent<CreateFixCommentEventDetail>;
 
 export interface PageErrorEventDetail {
   response?: Response;
@@ -157,13 +172,6 @@
 }
 export type ServerErrorEvent = CustomEvent<ServerErrorEventDetail>;
 
-export interface ShortcutTriggeredEventDetail {
-  event: CustomKeyboardEvent;
-  goKey: boolean;
-  vKey: boolean;
-}
-export type ShortcutTriggeredEvent = CustomEvent<ShortcutTriggeredEventDetail>;
-
 export interface ShowAlertEventDetail {
   message: string;
   dismissOnNavigation?: boolean;
diff --git a/polygerrit-ui/app/utils/account-util.ts b/polygerrit-ui/app/utils/account-util.ts
index bb9f328..2a509d3 100644
--- a/polygerrit-ui/app/utils/account-util.ts
+++ b/polygerrit-ui/app/utils/account-util.ts
@@ -15,8 +15,20 @@
  * limitations under the License.
  */
 
-import {AccountId, AccountInfo, EmailAddress} from '../types/common';
-import {AccountTag} from '../constants/constants';
+import {
+  AccountId,
+  AccountInfo,
+  ChangeInfo,
+  EmailAddress,
+  GroupId,
+  GroupInfo,
+  isAccount,
+  isGroup,
+  ReviewerInput,
+} from '../types/common';
+import {AccountTag, ReviewerState} from '../constants/constants';
+import {assertNever} from './common-util';
+import {AccountAddition} from '../elements/shared/gr-account-list/gr-account-list';
 
 export function accountKey(account: AccountInfo): AccountId | EmailAddress {
   if (account._account_id) return account._account_id;
@@ -24,6 +36,30 @@
   throw new Error('Account has neither _account_id nor email.');
 }
 
+export function mapReviewer(addition: AccountAddition): ReviewerInput {
+  if (addition.account) {
+    return {reviewer: accountKey(addition.account)};
+  }
+  if (addition.group) {
+    const reviewer = decodeURIComponent(addition.group.id) as GroupId;
+    const confirmed = addition.group.confirmed;
+    return {reviewer, confirmed};
+  }
+  throw new Error('Reviewer must be either an account or a group.');
+}
+
+export function isReviewerOrCC(
+  change: ChangeInfo,
+  reviewerAddition: AccountAddition
+): boolean {
+  const reviewers = [
+    ...(change.reviewers[ReviewerState.CC] ?? []),
+    ...(change.reviewers[ReviewerState.REVIEWER] ?? []),
+  ];
+  const reviewer = mapReviewer(reviewerAddition);
+  return reviewers.some(r => accountOrGroupKey(r) === reviewer.reviewer);
+}
+
 export function isServiceUser(account?: AccountInfo): boolean {
   return !!account?.tags?.includes(AccountTag.SERVICE_USER);
 }
@@ -40,6 +76,12 @@
   return account?.avatars?.[0]?.url === other?.avatars?.[0]?.url;
 }
 
+export function accountOrGroupKey(entry: AccountInfo | GroupInfo) {
+  if (isAccount(entry)) return accountKey(entry);
+  if (isGroup(entry)) return entry.id;
+  assertNever(entry, 'entry must be account or group');
+}
+
 export function uniqueDefinedAvatar(
   account: AccountInfo,
   index: number,
diff --git a/polygerrit-ui/app/utils/attention-set-util.ts b/polygerrit-ui/app/utils/attention-set-util.ts
index 5b2762c..d39553a 100644
--- a/polygerrit-ui/app/utils/attention-set-util.ts
+++ b/polygerrit-ui/app/utils/attention-set-util.ts
@@ -19,33 +19,15 @@
 import {isServiceUser} from './account-util';
 import {hasOwnProperty} from './common-util';
 
-// You would typically use a ServerInfo here, but this utility does not care
-// about all the other parameters in that object.
-interface SimpleServerInfo {
-  change?: {
-    enable_attention_set?: boolean;
-  };
-}
-
-const CONFIG_ENABLED: SimpleServerInfo = {
-  change: {enable_attention_set: true},
-};
-
-export function isAttentionSetEnabled(config?: SimpleServerInfo): boolean {
-  return !!config?.change?.enable_attention_set;
-}
-
 export function canHaveAttention(account?: AccountInfo): boolean {
   return !!account?._account_id && !isServiceUser(account);
 }
 
 export function hasAttention(
-  config?: SimpleServerInfo,
   account?: AccountInfo,
   change?: ChangeInfo
 ): boolean {
   return (
-    isAttentionSetEnabled(config) &&
     canHaveAttention(account) &&
     !!change?.attention_set &&
     hasOwnProperty(change?.attention_set, account!._account_id!)
@@ -53,13 +35,13 @@
 }
 
 export function getReason(account?: AccountInfo, change?: ChangeInfo) {
-  if (!hasAttention(CONFIG_ENABLED, account, change)) return '';
+  if (!hasAttention(account, change)) return '';
   const entry = change!.attention_set![account!._account_id!];
   return entry?.reason ? entry.reason : '';
 }
 
 export function getLastUpdate(account?: AccountInfo, change?: ChangeInfo) {
-  if (!hasAttention(CONFIG_ENABLED, account, change)) return '';
+  if (!hasAttention(account, change)) return '';
   const entry = change!.attention_set![account!._account_id!];
   return entry?.last_update ? entry.last_update : '';
 }
diff --git a/polygerrit-ui/app/utils/attention-set-util_test.js b/polygerrit-ui/app/utils/attention-set-util_test.js
index 71735d5..9d8c086 100644
--- a/polygerrit-ui/app/utils/attention-set-util_test.js
+++ b/polygerrit-ui/app/utils/attention-set-util_test.js
@@ -29,9 +29,6 @@
 
 suite('attention-set-util', () => {
   test('hasAttention', () => {
-    const config = {
-      change: {enable_attention_set: true},
-    };
     const change = {
       attention_set: {
         31415926535: {
@@ -40,7 +37,7 @@
       },
     };
 
-    assert.isTrue(hasAttention(config, KERMIT, change));
+    assert.isTrue(hasAttention(KERMIT, change));
   });
 
   test('getReason', () => {
diff --git a/polygerrit-ui/app/utils/change-metadata-util.ts b/polygerrit-ui/app/utils/change-metadata-util.ts
index eda988a..9692ab31 100644
--- a/polygerrit-ui/app/utils/change-metadata-util.ts
+++ b/polygerrit-ui/app/utils/change-metadata-util.ts
@@ -24,6 +24,7 @@
   SUBMITTED = 'Submitted',
   PARENT = 'Parent',
   MERGED_AS = 'Merged as',
+  REVERT_CREATED_AS = 'Revert Created as',
   STRATEGY = 'Strategy',
   UPDATED = 'Updated',
   CC = 'CC',
@@ -56,6 +57,7 @@
   ALWAYS_HIDE: [
     Metadata.PARENT,
     Metadata.MERGED_AS,
+    Metadata.REVERT_CREATED_AS,
     Metadata.STRATEGY,
     Metadata.UPDATED,
   ],
diff --git a/polygerrit-ui/app/utils/change-util.ts b/polygerrit-ui/app/utils/change-util.ts
index d7fb5d0..380d06c 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 {
@@ -137,24 +138,24 @@
 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,
@@ -165,21 +166,24 @@
 
   // 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;
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..8232e56
--- /dev/null
+++ b/polygerrit-ui/app/utils/change-util_test.ts
@@ -0,0 +1,198 @@
+/**
+ * @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,
+  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);
+  });
+});
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/dom-util.ts b/polygerrit-ui/app/utils/dom-util.ts
index 3affef7..4f83881 100644
--- a/polygerrit-ui/app/utils/dom-util.ts
+++ b/polygerrit-ui/app/utils/dom-util.ts
@@ -15,8 +15,9 @@
  * limitations under the License.
  */
 
-import {EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
+import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
 import {check} from './common-util';
+import {CustomKeyboardEvent} from '../types/events';
 
 /**
  * Event emitted from polymer elements.
@@ -292,3 +293,23 @@
     el.classList.remove(className);
   }
 }
+
+export function isModifierPressed(event: CustomKeyboardEvent) {
+  const e = getKeyboardEvent(event);
+  return e.altKey || e.ctrlKey || e.metaKey || e.shiftKey;
+}
+
+export function isShiftPressed(event: CustomKeyboardEvent) {
+  const e = getKeyboardEvent(event);
+  return e.shiftKey;
+}
+
+export function getKeyboardEvent(e: CustomKeyboardEvent): CustomKeyboardEvent {
+  const event = dom(e.detail ? e.detail.keyboardEvent : e);
+  // TODO(TS): worth checking if this still holds or not, if no, remove this.
+  // When e is a keyboardEvent, e.event is not null.
+  if ('event' in event && (event as CustomKeyboardEvent).event) {
+    return (event as CustomKeyboardEvent).event;
+  }
+  return event as CustomKeyboardEvent;
+}
diff --git a/polygerrit-ui/app/utils/event-util.ts b/polygerrit-ui/app/utils/event-util.ts
index da075f1..4ba27bc 100644
--- a/polygerrit-ui/app/utils/event-util.ts
+++ b/polygerrit-ui/app/utils/event-util.ts
@@ -133,6 +133,10 @@
   fire(target, EventType.CLOSE_FIX_PREVIEW, {fixApplied});
 }
 
+export function fireReload(target: EventTarget, clearPatchset?: boolean) {
+  fire(target, EventType.RELOAD, {clearPatchset: !!clearPatchset});
+}
+
 export function waitForEventOnce<K extends keyof HTMLElementEventMap>(
   el: EventTarget,
   eventName: K
diff --git a/polygerrit-ui/app/utils/label-util.ts b/polygerrit-ui/app/utils/label-util.ts
index 4eed0a0..bcc92e8 100644
--- a/polygerrit-ui/app/utils/label-util.ts
+++ b/polygerrit-ui/app/utils/label-util.ts
@@ -26,6 +26,14 @@
 // Name of the standard Code-Review label.
 export const CODE_REVIEW = 'Code-Review';
 
+export enum LabelStatus {
+  APPROVED = 'APPROVED',
+  REJECTED = 'REJECTED',
+  RECOMMENDED = 'RECOMMENDED',
+  DISLIKED = 'DISLIKED',
+  NEUTRAL = 'NEUTRAL',
+}
+
 export function getVotingRange(label?: LabelInfo): VotingRangeInfo | undefined {
   if (!label || !isDetailedLabelInfo(label) || !label.values) return undefined;
   const values = Object.keys(label.values).map(v => Number(v));
@@ -39,6 +47,41 @@
   return range ? range : {min: 0, max: 0};
 }
 
+/**
+ * If we don't know the label config, then we still need some way to decide
+ * which vote value is the most important one, so we apply the standard rule
+ * of a Code-Review label, where -2 blocks. So the most negative vote is
+ * regarded as representative, if its absolute value is greater than or equal
+ * to the most positive vote.
+ */
+export function getRepresentativeValue(label?: DetailedLabelInfo): number {
+  if (!label?.all) return 0;
+  const allValues = label.all.map(approvalInfo => approvalInfo.value ?? 0);
+  if (allValues.length === 0) return 0;
+  const max = Math.max(...allValues);
+  const min = Math.min(...allValues);
+  return max > -min ? max : min;
+}
+
+export function getLabelStatus(label?: DetailedLabelInfo): LabelStatus {
+  const value = getRepresentativeValue(label);
+  const range = getVotingRangeOrDefault(label);
+  if (value < 0) {
+    return value === range.min ? LabelStatus.REJECTED : LabelStatus.DISLIKED;
+  }
+  if (value > 0) {
+    return value === range.max ? LabelStatus.APPROVED : LabelStatus.RECOMMENDED;
+  }
+  return LabelStatus.NEUTRAL;
+}
+
+export function valueString(value?: number) {
+  if (!value) return ' 0';
+  let s = `${value}`;
+  if (value > 0) s = `+${s}`;
+  return s;
+}
+
 export function getMaxAccounts(label?: LabelInfo): ApprovalInfo[] {
   if (!label || !isDetailedLabelInfo(label) || !label.all) return [];
   const votingRange = getVotingRangeOrDefault(label);
diff --git a/polygerrit-ui/app/utils/label-util_test.js b/polygerrit-ui/app/utils/label-util_test.js
deleted file mode 100644
index f9a30df..0000000
--- a/polygerrit-ui/app/utils/label-util_test.js
+++ /dev/null
@@ -1,124 +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 '../test/common-test-setup-karma.js';
-import {
-  getVotingRange,
-  getVotingRangeOrDefault,
-  getMaxAccounts,
-  getApprovalInfo,
-  labelCompare,
-} from './label-util.js';
-
-const VALUES_1 = {
-  '-1': 'bad',
-  '0': 'neutral',
-  '+1': 'good',
-};
-
-const VALUES_2 = {
-  '-1': 'bad',
-  '+2': 'perfect',
-  '0': 'neutral',
-  '-2': 'blocking',
-  '+1': 'good',
-};
-
-suite('label-util', () => {
-  test('getVotingRange -1 to +1', () => {
-    const label = {values: VALUES_1};
-    const expectedRange = {min: -1, max: 1};
-    assert.deepEqual(getVotingRange(label), expectedRange);
-    assert.deepEqual(getVotingRangeOrDefault(label), expectedRange);
-  });
-
-  test('getVotingRange -2 to +2', () => {
-    const label = {values: VALUES_2};
-    const expectedRange = {min: -2, max: 2};
-    assert.deepEqual(getVotingRange(label), expectedRange);
-    assert.deepEqual(getVotingRangeOrDefault(label), expectedRange);
-  });
-
-  test('getVotingRange empty values', () => {
-    const label = {
-      values: {},
-    };
-    const expectedRange = {min: 0, max: 0};
-    assert.isUndefined(getVotingRange(label));
-    assert.deepEqual(getVotingRangeOrDefault(label), expectedRange);
-  });
-
-  test('getVotingRange no values', () => {
-    const label = {};
-    const expectedRange = {min: 0, max: 0};
-    assert.isUndefined(getVotingRange(label));
-    assert.deepEqual(getVotingRangeOrDefault(label), expectedRange);
-  });
-
-  test('getMaxAccounts', () => {
-    const label = {
-      values: VALUES_2,
-      all: [
-        {value: 2, _account_id: 314},
-        {value: 1, _account_id: 777},
-      ],
-    };
-
-    const maxAccounts = getMaxAccounts(label);
-
-    assert.equal(maxAccounts.length, 1);
-    assert.equal(maxAccounts[0]._account_id, 314);
-  });
-
-  test('getMaxAccounts unset parameters', () => {
-    assert.isEmpty(getMaxAccounts());
-    assert.isEmpty(getMaxAccounts({}));
-    assert.isEmpty(getMaxAccounts({values: VALUES_2}));
-  });
-
-  test('getApprovalInfo', () => {
-    const myAccountInfo = {_account_id: 314};
-    const myApprovalInfo = {value: 2, _account_id: 314};
-    const label = {
-      values: VALUES_2,
-      all: [myApprovalInfo, {value: 1, _account_id: 777}],
-    };
-    assert.equal(
-        getApprovalInfo(label, myAccountInfo),
-        myApprovalInfo
-    );
-  });
-
-  test('getApprovalInfo no approval for user', () => {
-    const myAccountInfo = {_account_id: 123};
-    const label = {
-      values: VALUES_2,
-      all: [
-        {value: 2, _account_id: 314},
-        {value: 1, _account_id: 777},
-      ],
-    };
-    assert.isUndefined(getApprovalInfo(label, myAccountInfo));
-  });
-
-  test('labelCompare', () => {
-    let sorted = ['c', 'b', 'a'].sort(labelCompare);
-    assert.sameOrderedMembers(sorted, ['a', 'b', 'c']);
-    sorted = ['b', 'a', 'Code-Review'].sort(labelCompare);
-    assert.sameOrderedMembers(sorted, ['Code-Review', 'a', 'b']);
-  });
-});
diff --git a/polygerrit-ui/app/utils/label-util_test.ts b/polygerrit-ui/app/utils/label-util_test.ts
new file mode 100644
index 0000000..56941ba
--- /dev/null
+++ b/polygerrit-ui/app/utils/label-util_test.ts
@@ -0,0 +1,189 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../test/common-test-setup-karma';
+import {
+  getApprovalInfo,
+  getLabelStatus,
+  getMaxAccounts,
+  getRepresentativeValue,
+  getVotingRange,
+  getVotingRangeOrDefault,
+  labelCompare,
+  LabelStatus,
+} from './label-util';
+import {
+  AccountId,
+  AccountInfo,
+  ApprovalInfo,
+  DetailedLabelInfo,
+} from '../types/common';
+
+const VALUES_0 = {
+  '0': 'neutral',
+};
+
+const VALUES_1 = {
+  '-1': 'bad',
+  '0': 'neutral',
+  '+1': 'good',
+};
+
+const VALUES_2 = {
+  '-2': 'blocking',
+  '-1': 'bad',
+  '0': 'neutral',
+  '+1': 'good',
+  '+2': 'perfect',
+};
+
+suite('label-util', () => {
+  test('getVotingRange -1 to +1', () => {
+    const label = {values: VALUES_1};
+    const expectedRange = {min: -1, max: 1};
+    assert.deepEqual(getVotingRange(label), expectedRange);
+    assert.deepEqual(getVotingRangeOrDefault(label), expectedRange);
+  });
+
+  test('getVotingRange -2 to +2', () => {
+    const label = {values: VALUES_2};
+    const expectedRange = {min: -2, max: 2};
+    assert.deepEqual(getVotingRange(label), expectedRange);
+    assert.deepEqual(getVotingRangeOrDefault(label), expectedRange);
+  });
+
+  test('getVotingRange empty values', () => {
+    const label = {
+      values: {},
+    };
+    const expectedRange = {min: 0, max: 0};
+    assert.isUndefined(getVotingRange(label));
+    assert.deepEqual(getVotingRangeOrDefault(label), expectedRange);
+  });
+
+  test('getVotingRange no values', () => {
+    const label = {};
+    const expectedRange = {min: 0, max: 0};
+    assert.isUndefined(getVotingRange(label));
+    assert.deepEqual(getVotingRangeOrDefault(label), expectedRange);
+  });
+
+  test('getMaxAccounts', () => {
+    const label: DetailedLabelInfo = {
+      values: VALUES_2,
+      all: [
+        {value: 2, _account_id: 314 as AccountId},
+        {value: 1, _account_id: 777 as AccountId},
+      ],
+    };
+
+    const maxAccounts = getMaxAccounts(label);
+
+    assert.equal(maxAccounts.length, 1);
+    assert.equal(maxAccounts[0]._account_id, 314 as AccountId);
+  });
+
+  test('getMaxAccounts unset parameters', () => {
+    assert.isEmpty(getMaxAccounts());
+    assert.isEmpty(getMaxAccounts({}));
+    assert.isEmpty(getMaxAccounts({values: VALUES_2}));
+  });
+
+  test('getApprovalInfo', () => {
+    const myAccountInfo: AccountInfo = {_account_id: 314 as AccountId};
+    const myApprovalInfo: ApprovalInfo = {
+      value: 2,
+      _account_id: 314 as AccountId,
+    };
+    const label: DetailedLabelInfo = {
+      values: VALUES_2,
+      all: [myApprovalInfo, {value: 1, _account_id: 777 as AccountId}],
+    };
+    assert.equal(getApprovalInfo(label, myAccountInfo), myApprovalInfo);
+  });
+
+  test('getApprovalInfo no approval for user', () => {
+    const myAccountInfo: AccountInfo = {_account_id: 123 as AccountId};
+    const label: DetailedLabelInfo = {
+      values: VALUES_2,
+      all: [
+        {value: 2, _account_id: 314 as AccountId},
+        {value: 1, _account_id: 777 as AccountId},
+      ],
+    };
+    assert.isUndefined(getApprovalInfo(label, myAccountInfo));
+  });
+
+  test('labelCompare', () => {
+    let sorted = ['c', 'b', 'a'].sort(labelCompare);
+    assert.sameOrderedMembers(sorted, ['a', 'b', 'c']);
+    sorted = ['b', 'a', 'Code-Review'].sort(labelCompare);
+    assert.sameOrderedMembers(sorted, ['Code-Review', 'a', 'b']);
+  });
+
+  test('getLabelStatus', () => {
+    let labelInfo: DetailedLabelInfo = {all: [], values: VALUES_2};
+    assert.equal(getLabelStatus(labelInfo), LabelStatus.NEUTRAL);
+    labelInfo = {all: [{value: 0}], values: VALUES_0};
+    assert.equal(getLabelStatus(labelInfo), LabelStatus.NEUTRAL);
+    labelInfo = {all: [{value: 0}], values: VALUES_1};
+    assert.equal(getLabelStatus(labelInfo), LabelStatus.NEUTRAL);
+    labelInfo = {all: [{value: 0}], values: VALUES_2};
+    assert.equal(getLabelStatus(labelInfo), LabelStatus.NEUTRAL);
+    labelInfo = {all: [{value: 1}], values: VALUES_2};
+    assert.equal(getLabelStatus(labelInfo), LabelStatus.RECOMMENDED);
+    labelInfo = {all: [{value: -1}], values: VALUES_2};
+    assert.equal(getLabelStatus(labelInfo), LabelStatus.DISLIKED);
+    labelInfo = {all: [{value: 1}], values: VALUES_1};
+    assert.equal(getLabelStatus(labelInfo), LabelStatus.APPROVED);
+    labelInfo = {all: [{value: -1}], values: VALUES_1};
+    assert.equal(getLabelStatus(labelInfo), LabelStatus.REJECTED);
+    labelInfo = {all: [{value: 2}, {value: 1}], values: VALUES_2};
+    assert.equal(getLabelStatus(labelInfo), LabelStatus.APPROVED);
+    labelInfo = {all: [{value: -2}, {value: -1}], values: VALUES_2};
+    assert.equal(getLabelStatus(labelInfo), LabelStatus.REJECTED);
+    labelInfo = {all: [{value: -2}, {value: 2}], values: VALUES_2};
+    assert.equal(getLabelStatus(labelInfo), LabelStatus.REJECTED);
+    labelInfo = {all: [{value: -1}, {value: 2}], values: VALUES_2};
+    assert.equal(getLabelStatus(labelInfo), LabelStatus.APPROVED);
+    labelInfo = {all: [{value: 0}, {value: 2}], values: VALUES_2};
+    assert.equal(getLabelStatus(labelInfo), LabelStatus.APPROVED);
+    labelInfo = {all: [{value: 0}, {value: -2}], values: VALUES_2};
+    assert.equal(getLabelStatus(labelInfo), LabelStatus.REJECTED);
+  });
+
+  test('getRepresentativeValue', () => {
+    let labelInfo: DetailedLabelInfo = {all: []};
+    assert.equal(getRepresentativeValue(labelInfo), 0);
+    labelInfo = {all: [{value: 0}]};
+    assert.equal(getRepresentativeValue(labelInfo), 0);
+    labelInfo = {all: [{value: 1}]};
+    assert.equal(getRepresentativeValue(labelInfo), 1);
+    labelInfo = {all: [{value: 2}, {value: 1}]};
+    assert.equal(getRepresentativeValue(labelInfo), 2);
+    labelInfo = {all: [{value: -2}, {value: -1}]};
+    assert.equal(getRepresentativeValue(labelInfo), -2);
+    labelInfo = {all: [{value: -2}, {value: 2}]};
+    assert.equal(getRepresentativeValue(labelInfo), -2);
+    labelInfo = {all: [{value: -1}, {value: 2}]};
+    assert.equal(getRepresentativeValue(labelInfo), 2);
+    labelInfo = {all: [{value: 0}, {value: 2}]};
+    assert.equal(getRepresentativeValue(labelInfo), 2);
+    labelInfo = {all: [{value: 0}, {value: -2}]};
+    assert.equal(getRepresentativeValue(labelInfo), -2);
+  });
+});
diff --git a/polygerrit-ui/app/utils/message-util.ts b/polygerrit-ui/app/utils/message-util.ts
new file mode 100644
index 0000000..70dd286
--- /dev/null
+++ b/polygerrit-ui/app/utils/message-util.ts
@@ -0,0 +1,32 @@
+/**
+ * @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 {MessageTag} from '../constants/constants';
+import {ChangeId, ChangeMessageInfo} from '../types/common';
+
+function getRevertChangeIdFromMessage(msg: ChangeMessageInfo): ChangeId {
+  const REVERT_REGEX = /^Created a revert of this change as (.*)$/;
+  const changeId = msg.message.match(REVERT_REGEX)?.[1];
+  if (!changeId) throw new Error('revert changeId not found');
+  return changeId as ChangeId;
+}
+
+export function getRevertCreatedChangeIds(messages: ChangeMessageInfo[]) {
+  return messages
+    .filter(m => m.tag === MessageTag.TAG_REVERT)
+    .map(m => getRevertChangeIdFromMessage(m));
+}
diff --git a/polygerrit-ui/app/utils/patch-set-util.ts b/polygerrit-ui/app/utils/patch-set-util.ts
index 138479c..24662fd 100644
--- a/polygerrit-ui/app/utils/patch-set-util.ts
+++ b/polygerrit-ui/app/utils/patch-set-util.ts
@@ -8,7 +8,6 @@
   BasePatchSetNum,
   RevisionPatchSetNum,
 } from '../types/common';
-import {RestApiService} from '../services/gr-rest-api/gr-rest-api';
 import {EditRevisionInfo, ParsedChangeInfo} from '../types/types';
 import {check} from './common-util';
 
@@ -302,40 +301,6 @@
 }
 
 /**
- * Check whether there is no newer patch than the latest patch that was
- * available when this change was loaded.
- *
- * @return A promise that yields true if the latest patch
- *     has been loaded, and false if a newer patch has been uploaded in the
- *     meantime. The promise is rejected on network error.
- */
-export function fetchChangeUpdates(
-  change: ChangeInfo | ParsedChangeInfo,
-  restAPI: RestApiService
-) {
-  const knownLatest = computeLatestPatchNum(computeAllPatchSets(change));
-  return restAPI.getChangeDetail(change._number).then(detail => {
-    if (!detail) {
-      const error = new Error('Change detail not found.');
-      return Promise.reject(error);
-    }
-    const actualLatest = computeLatestPatchNum(computeAllPatchSets(detail));
-    if (!actualLatest || !knownLatest) {
-      const error = new Error('Unable to check for latest patchset.');
-      return Promise.reject(error);
-    }
-    return {
-      isLatest: actualLatest <= knownLatest,
-      newStatus: change.status !== detail.status ? detail.status : null,
-      newMessages:
-        (change.messages || []).length < (detail.messages || []).length
-          ? detail.messages![detail.messages!.length - 1]
-          : undefined,
-    };
-  });
-}
-
-/**
  * @param revisions A sorted array of revisions.
  *
  * @return the index of the revision with the given patchNum.
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 eefdcda..0000000
--- a/polygerrit-ui/app/utils/patch-set-util_test.js
+++ /dev/null
@@ -1,328 +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,
-  fetchChangeUpdates, 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('fetchChangeUpdates on latest', done => {
-    const knownChange = {
-      revisions: {
-        sha1: {description: 'patch 1', _number: 1},
-        sha2: {description: 'patch 2', _number: 2},
-      },
-      status: 'NEW',
-      messages: [],
-    };
-    const mockRestApi = {
-      getChangeDetail() {
-        return Promise.resolve(knownChange);
-      },
-    };
-    fetchChangeUpdates(knownChange, mockRestApi)
-        .then(result => {
-          assert.isTrue(result.isLatest);
-          assert.isNotOk(result.newStatus);
-          assert.isNotOk(result.newMessages);
-          done();
-        });
-  });
-
-  test('fetchChangeUpdates not on latest', done => {
-    const knownChange = {
-      revisions: {
-        sha1: {description: 'patch 1', _number: 1},
-        sha2: {description: 'patch 2', _number: 2},
-      },
-      status: 'NEW',
-      messages: [],
-    };
-    const actualChange = {
-      revisions: {
-        sha1: {description: 'patch 1', _number: 1},
-        sha2: {description: 'patch 2', _number: 2},
-        sha3: {description: 'patch 3', _number: 3},
-      },
-      status: 'NEW',
-      messages: [],
-    };
-    const mockRestApi = {
-      getChangeDetail() {
-        return Promise.resolve(actualChange);
-      },
-    };
-    fetchChangeUpdates(knownChange, mockRestApi)
-        .then(result => {
-          assert.isFalse(result.isLatest);
-          assert.isNotOk(result.newStatus);
-          assert.isNotOk(result.newMessages);
-          done();
-        });
-  });
-
-  test('fetchChangeUpdates new status', done => {
-    const knownChange = {
-      revisions: {
-        sha1: {description: 'patch 1', _number: 1},
-        sha2: {description: 'patch 2', _number: 2},
-      },
-      status: 'NEW',
-      messages: [],
-    };
-    const actualChange = {
-      revisions: {
-        sha1: {description: 'patch 1', _number: 1},
-        sha2: {description: 'patch 2', _number: 2},
-      },
-      status: 'MERGED',
-      messages: [],
-    };
-    const mockRestApi = {
-      getChangeDetail() {
-        return Promise.resolve(actualChange);
-      },
-    };
-    fetchChangeUpdates(knownChange, mockRestApi)
-        .then(result => {
-          assert.isTrue(result.isLatest);
-          assert.equal(result.newStatus, 'MERGED');
-          assert.isNotOk(result.newMessages);
-          done();
-        });
-  });
-
-  test('fetchChangeUpdates new messages', done => {
-    const knownChange = {
-      revisions: {
-        sha1: {description: 'patch 1', _number: 1},
-        sha2: {description: 'patch 2', _number: 2},
-      },
-      status: 'NEW',
-      messages: [],
-    };
-    const actualChange = {
-      revisions: {
-        sha1: {description: 'patch 1', _number: 1},
-        sha2: {description: 'patch 2', _number: 2},
-      },
-      status: 'NEW',
-      messages: [{message: 'blah blah'}],
-    };
-    const mockRestApi = {
-      getChangeDetail() {
-        return Promise.resolve(actualChange);
-      },
-    };
-    fetchChangeUpdates(knownChange, mockRestApi)
-        .then(result => {
-          assert.isTrue(result.isLatest);
-          assert.isNotOk(result.newStatus);
-          assert.deepEqual(result.newMessages, {message: 'blah blah'});
-          done();
-        });
-  });
-
-  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/path-list-util.ts b/polygerrit-ui/app/utils/path-list-util.ts
index dda6031..fd922fc 100644
--- a/polygerrit-ui/app/utils/path-list-util.ts
+++ b/polygerrit-ui/app/utils/path-list-util.ts
@@ -64,6 +64,8 @@
   return file === SpecialFilePath.PATCHSET_LEVEL_COMMENTS;
 }
 
+// In case there are files with comments on them but they are unchanged, then
+// we explicitly displays the file to render the comments with Unchanged status
 export function addUnmodifiedFiles(
   files: {[filename: string]: FileInfo},
   commentedPaths: {[fileName: string]: boolean}
@@ -73,6 +75,18 @@
     if (hasOwnProperty(files, commentedPath) || shouldHideFile(commentedPath)) {
       return;
     }
+
+    // if file is Renamed but has comments, then do not show the entry for the
+    // old file path name
+    if (
+      Object.values(files).some(
+        file =>
+          file.status === FileInfoStatus.RENAMED &&
+          file.old_path === commentedPath
+      )
+    ) {
+      return;
+    }
     // TODO(TS): either change FileInfo to mark delta and size optional
     // or fill in 0 here
     files[commentedPath] = {
diff --git a/polygerrit-ui/app/yarn.lock b/polygerrit-ui/app/yarn.lock
index 96597c9..fed42a4 100644
--- a/polygerrit-ui/app/yarn.lock
+++ b/polygerrit-ui/app/yarn.lock
@@ -270,6 +270,17 @@
     "@polymer/paper-styles" "^3.0.0-pre.26"
     "@polymer/polymer" "^3.3.1"
 
+"@polymer/paper-fab@^3.0.1":
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/@polymer/paper-fab/-/paper-fab-3.0.1.tgz#2636359e7fb70dd5a549ed92ba9b3bdb9ff86bf8"
+  integrity sha512-LO8ckgd72MnAtC1WiPd5CFR27WC/dEuY/lOIQuHYdEjwI62+iiV7Bmr7uoQ9wvvV71qMFdMIOyq/03KklsuAzw==
+  dependencies:
+    "@polymer/iron-flex-layout" "^3.0.0-pre.26"
+    "@polymer/iron-icon" "^3.0.0-pre.26"
+    "@polymer/paper-behaviors" "^3.0.0-pre.27"
+    "@polymer/paper-styles" "^3.0.0-pre.26"
+    "@polymer/polymer" "^3.0.0"
+
 "@polymer/paper-icon-button@^3.0.0-pre.26":
   version "3.0.2"
   resolved "https://registry.yarnpkg.com/@polymer/paper-icon-button/-/paper-icon-button-3.0.2.tgz#a1254faadc2c8dd135ce1ae33bcc161a94c31f65"
@@ -407,22 +418,27 @@
 "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==
+
 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=
 
-lit-element@^2.4.0:
-  version "2.4.0"
-  resolved "https://registry.yarnpkg.com/lit-element/-/lit-element-2.4.0.tgz#b22607a037a8fc08f5a80736dddf7f3f5d401452"
-  integrity sha512-pBGLglxyhq/Prk2H91nA0KByq/hx/wssJBQFiYqXhGDvEnY31PRGYf1RglVzyLeRysu0IHm2K0P196uLLWmwFg==
+lit-element@^2.5.1:
+  version "2.5.1"
+  resolved "https://registry.yarnpkg.com/lit-element/-/lit-element-2.5.1.tgz#3fa74b121a6cd22902409ae3859b7847d01aa6b6"
+  integrity sha512-ogu7PiJTA33bEK0xGu1dmaX5vhcRjBXCFexPja0e7P7jqLhTpNKYRPmE+GmiCaRVAbiQKGkUgkh/i6+bh++dPQ==
   dependencies:
     lit-html "^1.1.1"
 
 lit-html@^1.1.1:
-  version "1.3.0"
-  resolved "https://registry.yarnpkg.com/lit-html/-/lit-html-1.3.0.tgz#c80f3cc5793a6dea6c07172be90a70ab20e56034"
-  integrity sha512-0Q1bwmaFH9O14vycPHw8C/IeHMk/uSDldVLIefu/kfbTBGIc44KGH6A8p1bDfxUfHdc8q6Ct7kQklWoHgr4t1Q==
+  version "1.4.1"
+  resolved "https://registry.yarnpkg.com/lit-html/-/lit-html-1.4.1.tgz#0c6f3ee4ad4eb610a49831787f0478ad8e9ae5e0"
+  integrity sha512-B9btcSgPYb1q4oSOb/PrOT6Z/H+r6xuNzfH4lFli/AWhYwdtrgQkQWBbIc6mdnf6E2IL3gDXdkkqNktpU0OZQA==
 
 page@^1.11.6:
   version "1.11.6"
diff --git a/polygerrit-ui/edit-walkthrough/edit-walkthrough.md b/polygerrit-ui/edit-walkthrough/edit-walkthrough.md
deleted file mode 100644
index 717f683..0000000
--- a/polygerrit-ui/edit-walkthrough/edit-walkthrough.md
+++ /dev/null
@@ -1,80 +0,0 @@
-# In-browser Editing in Gerrit
-
-### What's going on?
-
-Until Q1 of 2018, editing a file in the browser was not supported by Gerrit's
-new UI. This feature is now done and ready for use.
-
-Read on for a walkthrough of the feature!
-
-### Creating an edit
-
-Click on the "Edit" button to begin.
-
-One may also go to the project mmanagement page (Browse => Repository =>
-Commands => Create Change) to create a new change.
-
-![](./img/into_edit.png)
-
-### Performing an action
-
-The buttons in the file list header open dialogs to perform actions on any file
-in the repo.
-
-*   Open - opens an existing or new file from the repo in an editor.
-*   Delete - deletes an existing file from the repo.
-*   Rename - renames an existing file in the repo.
-
-To leave edit mode and restore the normal buttons to the file list, click "Stop
-editing".
-
-![](./img/in_edit_mode.png)
-
-### Performing an action on a file
-
-The "Actions" dropdown appears on each file, and is used to perform actions on
-that specific file.
-
-*   Open - opens this file in the editor.
-*   Delete - deletes this file from the repo.
-*   Rename - renames this file in the repo.
-*   Restore - restores this file to the state it existed in at the patch the
-edit was created on.
-
-![](./img/actions_overflow.png)
-
-### Modifying the file
-
-This is the editor view.
-
-Clicking on the file path allows you to rename the file, You can edit code in
-the textarea, and "Close" will discard any unsaved changes and navigate back to
-the previous view.
-
-![](./img/in_editor.png)
-
-### Saving the edit
-
-You can save changes to the code with `cmd+s`, `ctrl+s`, or by clicking the
-"Save" button.
-
-![](./img/edit_made.png)
-
-### Publishing the edit
-
-You may publish or delete the edit by clicking the buttons in the header.
-
-
-
-![](./img/edit_pending.png)
-
-### What if I have questions not answered here?
-
-Gerrit's [official docs](https://gerrit-review.googlesource.com/Documentation/user-inline-edit.html)
-are in the process of being updated and largely refer to the old UI, but the
-user experience is largely the same.
-
-Otherwise, please email
-[the repo-discuss mailing list](mailto:repo-discuss@google.com) or file a bug
-on Gerrit's official bug tracker,
-[Monorail](https://bugs.chromium.org/p/gerrit/issues/entry?template=PolyGerrit+Issue).
\ No newline at end of file
diff --git a/polygerrit-ui/edit-walkthrough/img/actions_overflow.png b/polygerrit-ui/edit-walkthrough/img/actions_overflow.png
deleted file mode 100644
index bf39763..0000000
--- a/polygerrit-ui/edit-walkthrough/img/actions_overflow.png
+++ /dev/null
Binary files differ
diff --git a/polygerrit-ui/edit-walkthrough/img/edit_made.png b/polygerrit-ui/edit-walkthrough/img/edit_made.png
deleted file mode 100644
index 658245d..0000000
--- a/polygerrit-ui/edit-walkthrough/img/edit_made.png
+++ /dev/null
Binary files differ
diff --git a/polygerrit-ui/edit-walkthrough/img/edit_pending.png b/polygerrit-ui/edit-walkthrough/img/edit_pending.png
deleted file mode 100644
index a63f6ee..0000000
--- a/polygerrit-ui/edit-walkthrough/img/edit_pending.png
+++ /dev/null
Binary files differ
diff --git a/polygerrit-ui/edit-walkthrough/img/in_edit_mode.png b/polygerrit-ui/edit-walkthrough/img/in_edit_mode.png
deleted file mode 100644
index 582ed66..0000000
--- a/polygerrit-ui/edit-walkthrough/img/in_edit_mode.png
+++ /dev/null
Binary files differ
diff --git a/polygerrit-ui/edit-walkthrough/img/in_editor.png b/polygerrit-ui/edit-walkthrough/img/in_editor.png
deleted file mode 100644
index 228d020..0000000
--- a/polygerrit-ui/edit-walkthrough/img/in_editor.png
+++ /dev/null
Binary files differ
diff --git a/polygerrit-ui/edit-walkthrough/img/into_edit.png b/polygerrit-ui/edit-walkthrough/img/into_edit.png
deleted file mode 100644
index b6c14ed..0000000
--- a/polygerrit-ui/edit-walkthrough/img/into_edit.png
+++ /dev/null
Binary files differ
diff --git a/polygerrit-ui/server.go b/polygerrit-ui/server.go
index 2734f58..e030878 100644
--- a/polygerrit-ui/server.go
+++ b/polygerrit-ui/server.go
@@ -41,12 +41,11 @@
 )
 
 var (
-	plugins               = flag.String("plugins", "", "comma seperated plugin paths to serve")
-	port                  = flag.String("port", "localhost:8081", "address to serve HTTP requests on")
-	host                  = flag.String("host", "gerrit-review.googlesource.com", "Host to proxy requests to")
-	scheme                = flag.String("scheme", "https", "URL scheme")
-	cdnPattern            = regexp.MustCompile("https://cdn.googlesource.com/polygerrit_ui/[0-9.]*")
-	bundledPluginsPattern = regexp.MustCompile("https://cdn.googlesource.com/polygerrit_assets/[0-9.]*")
+	plugins    = flag.String("plugins", "", "comma seperated plugin paths to serve")
+	port       = flag.String("port", "localhost:8081", "address to serve HTTP requests on")
+	host       = flag.String("host", "gerrit-review.googlesource.com", "Host to proxy requests to")
+	scheme     = flag.String("scheme", "https", "URL scheme")
+	cdnPattern = regexp.MustCompile("https://cdn.googlesource.com/polygerrit_ui/[0-9.]*")
 )
 
 func main() {
@@ -121,8 +120,8 @@
 
 func addDevHeaders(writer http.ResponseWriter) {
 	writer.Header().Set("Access-Control-Allow-Origin", "*")
+	writer.Header().Set("Access-Control-Allow-Headers", "cache-control,x-test-origin")
 	writer.Header().Set("Cache-Control", "public, max-age=10, must-revalidate")
-
 }
 
 func handleSrcRequest(compiledSrcPath string, dirListingMux *http.ServeMux, writer http.ResponseWriter, originalRequest *http.Request) {
@@ -394,9 +393,6 @@
 	// contains window.INITIAL_DATA=...
 	// Here we rely on the fact that the <script> snippet that we want to append to is the first one.
 	if len(*plugins) > 0 {
-		// If the host page contains a reference to a plugin bundle that would be preloaded, then remove it.
-		replaced = bundledPluginsPattern.ReplaceAllString(replaced, "")
-
 		insertionPoint := strings.Index(replaced, "</script>")
 		builder := new(strings.Builder)
 		builder.WriteString(
diff --git a/polygerrit-ui/wct.conf.js b/polygerrit-ui/wct.conf.js
deleted file mode 100644
index 2096e60..0000000
--- a/polygerrit-ui/wct.conf.js
+++ /dev/null
@@ -1,34 +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.
- */
-
-var path = require('path');
-
-var ret = {
-  suites: ['app/test'],
-  webserver: {
-    pathMappings: []
-  }
-};
-
-var mapping = {};
-var rootPath = (__dirname).split(path.sep).slice(-1)[0];
-
-mapping['/components/' + rootPath  + '/app/bower_components'] = 'bower_components';
-
-ret.webserver.pathMappings.push(mapping);
-
-module.exports = ret;
diff --git a/proto/cache.proto b/proto/cache.proto
index 292a225..9d2ba06 100644
--- a/proto/cache.proto
+++ b/proto/cache.proto
@@ -445,7 +445,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 +466,18 @@
   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.
+// Next ID: 7
+message SubmitRequirementProto {
+  string name = 1;
+  string description = 2;
+  string applicability_expression = 3;
+  string submittability_expression = 4;
+  string override_expression = 5;
+  bool allow_override_in_child_projects = 6;
 }
 
 // Serialized form of com.google.gerrit.server.project.ConfiguredMimeTypes.
@@ -496,7 +508,7 @@
 }
 
 // Serialized form of com.google.gerrit.entities.CachedProjectConfigProto.
-// Next ID: 19
+// Next ID: 20
 message CachedProjectConfigProto {
   ProjectProto project = 1;
   repeated GroupReferenceProto group_list = 2;
@@ -516,6 +528,7 @@
   map<string, ExtensionPanelSectionProto> extension_panels = 16;
   map<string, string> plugin_configs = 17;
   map<string, string> project_level_configs = 18;
+  repeated SubmitRequirementProto submit_requirement_sections = 19;
 
   // Next ID: 2
   message ExtensionPanelSectionProto {
@@ -673,3 +686,18 @@
   bytes new_commit = 10;
   ComparisonType comparison_type = 11;
 }
+
+// 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/server/change/ChangeMessages.properties b/resources/com/google/gerrit/server/change/ChangeMessages.properties
index 3763d8e..c78f5a3 100644
--- a/resources/com/google/gerrit/server/change/ChangeMessages.properties
+++ b/resources/com/google/gerrit/server/change/ChangeMessages.properties
@@ -7,6 +7,7 @@
 reviewerInvalid = {0} is not a valid user identifier
 reviewerNotFoundUserOrGroup = {0} does not identify a registered user or group
 
+groupRemovalIsNotAllowed = Groups can't be removed from reviewers, so can't remove {0}.
 groupIsNotAllowed = The group {0} cannot be added as reviewer.
 groupHasTooManyMembers = The group {0} has too many members to add them all as reviewers.
 groupManyMembersConfirmation = The group {0} has {1} members. Do you want to add them all as reviewers?
diff --git a/resources/com/google/gerrit/server/config/CapabilityConstants.properties b/resources/com/google/gerrit/server/config/CapabilityConstants.properties
index ba590ee..1a355eb 100644
--- a/resources/com/google/gerrit/server/config/CapabilityConstants.properties
+++ b/resources/com/google/gerrit/server/config/CapabilityConstants.properties
@@ -15,9 +15,9 @@
 runAs = Run As
 runGC = Run Garbage Collection
 streamEvents = Stream Events
+viewAccess = View Access
 viewAllAccounts = View All Accounts
 viewCaches = View Caches
 viewConnections = View Connections
 viewPlugins = View Plugins
 viewQueue = View Queue
-viewAccess = View Access
diff --git a/resources/com/google/gerrit/server/mail/InboundEmailRejection.soy b/resources/com/google/gerrit/server/mail/InboundEmailRejection.soy
index 1241665..a9e0a44 100644
--- a/resources/com/google/gerrit/server/mail/InboundEmailRejection.soy
+++ b/resources/com/google/gerrit/server/mail/InboundEmailRejection.soy
@@ -65,6 +65,14 @@
   {call InboundEmailRejectionFooter /}
 {/template}
 
+{template InboundEmailRejection_CHANGE_NOT_FOUND kind="text"}
+  Gerrit Code Review was unable to process your email because the change was not found.
+  {\n}
+  Maybe the project doesn't exist or is not visible? Maybe the change is not visible or got
+  deleted?
+  {call InboundEmailRejectionFooter /}
+{/template}
+
 {template InboundEmailRejection_COMMENT_REJECTED kind="text"}
   Gerrit Code Review rejected one or more comments because they did not pass validation, or
   because the maximum number of comments per change would be exceeded.
diff --git a/resources/com/google/gerrit/server/mail/InboundEmailRejectionHtml.soy b/resources/com/google/gerrit/server/mail/InboundEmailRejectionHtml.soy
index 6937d13..3444b7f 100644
--- a/resources/com/google/gerrit/server/mail/InboundEmailRejectionHtml.soy
+++ b/resources/com/google/gerrit/server/mail/InboundEmailRejectionHtml.soy
@@ -82,6 +82,17 @@
   {call InboundEmailRejectionFooterHtml /}
 {/template}
 
+{template InboundEmailRejectionHtml_CHANGE_NOT_FOUND}
+  <p>
+    Gerrit Code Review was unable to process your email because the change was not found.
+  </p>
+  <p>
+    Maybe the project doesn't exist or is not visible? Maybe the change is not visible or got
+    deleted?
+  <p>
+  {call InboundEmailRejectionFooterHtml /}
+{/template}
+
 {template InboundEmailRejectionHtml_COMMENT_REJECTED}
   <p>
     Gerrit Code Review rejected one or more comments because they did not pass validation, or
diff --git a/resources/com/google/gerrit/server/mail/NewChange.soy b/resources/com/google/gerrit/server/mail/NewChange.soy
index 0fe8835..aa2b946 100644
--- a/resources/com/google/gerrit/server/mail/NewChange.soy
+++ b/resources/com/google/gerrit/server/mail/NewChange.soy
@@ -23,23 +23,35 @@
 {template NewChange kind="text"}
   {@param change: ?}
   {@param email: ?}
+  {@param fromName: ?}
   {@param ownerName: ?}
   {@param patchSet: ?}
   {@param projectName: ?}
-  {if $email.reviewerNames}
-    Hello{sp}
-    {for $reviewerName in $email.reviewerNames}
-      {if not isFirst($reviewerName)},{sp}{/if}
-      {$reviewerName}
-    {/for},
+  {if $email.reviewerNames or $email.removedReviewerNames}
+   {if $email.reviewerNames}
+      Hello{sp}
+      {for $reviewerName in $email.reviewerNames}
+        {if not isFirst($reviewerName)},{sp}{/if}
+        {$reviewerName}
+      {/for},
 
-    {\n}
-    {\n}
+      {\n}
+      {\n}
 
-    I'd like you to do a code review.
-
+      I'd like you to do a code review.
+      {\n}
+    {/if}
+    {if $email.removedReviewerNames}
+      {$fromName} has removed{sp}
+      {for $reviewerName in $email.removedReviewerNames}
+        {if not isFirst($reviewerName)},{sp}{/if}
+        {$reviewerName}
+      {/for}{sp}
+      from this change.{sp}
+      {\n}
+    {/if}
     {if $email.changeUrl}
-      {sp}Please visit
+      Please visit
 
       {\n}
       {\n}
diff --git a/resources/com/google/gerrit/server/mail/NewChangeHtml.soy b/resources/com/google/gerrit/server/mail/NewChangeHtml.soy
index dbb3d8a..272c3ef 100644
--- a/resources/com/google/gerrit/server/mail/NewChangeHtml.soy
+++ b/resources/com/google/gerrit/server/mail/NewChangeHtml.soy
@@ -26,16 +26,35 @@
   {@param patchSet: ?}
   {@param projectName: ?}
   <p>
-    {if $email.reviewerNames}
-      {$fromName} would like{sp}
-      {for $reviewerName in $email.reviewerNames}
-        {if not isFirst($reviewerName)}
-          {if isLast($reviewerName)}{sp}and{else},{/if}{sp}
-        {/if}
-        {$reviewerName}
-      {/for}{sp}
-      to <strong>review</strong> this change
-      {if $fromName != $ownerName}{sp}authored by {$ownerName}{/if}.
+    {if $email.reviewerNames or $email.removedReviewerNames}
+      {if $email.reviewerNames}
+        {$fromName} would like{sp}
+        {for $reviewerName in $email.reviewerNames}
+          {if not isFirst($reviewerName)}
+            {if isLast($reviewerName)}{sp}and{else},{/if}{sp}
+          {/if}
+          {$reviewerName}
+        {/for}{sp}
+        to <strong>review</strong> this change
+        {if $fromName != $ownerName}{sp}authored by {$ownerName}{/if}.
+        {\n}
+      {/if}
+      {if $email.removedReviewerNames}
+        <p>
+          {$fromName}{sp}
+          <strong>
+            removed{sp}
+            {for $reviewerName in $email.removedReviewerNames}
+              {if not isFirst($reviewerName)}
+                {if isLast($reviewerName)}{sp}and{else},{/if}{sp}
+              {/if}
+              {$reviewerName}
+            {/for}
+          </strong>{sp}
+          from this change.
+        </p>
+        {\n}
+      {/if}
     {else}
       {$ownerName} has uploaded this change for <strong>review</strong>.
     {/if}
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/resources/log4j.properties b/resources/log4j.properties
index 39246b3..2898cfc 100644
--- a/resources/log4j.properties
+++ b/resources/log4j.properties
@@ -41,11 +41,5 @@
 log4j.logger.org.openid4java.server.RealmVerifier=ERROR
 log4j.logger.org.openid4java.message.AuthSuccess=ERROR
 
-# Silence non-critical messages from c3p0 (if used).
-#
-log4j.logger.com.mchange.v2.c3p0=WARN
-log4j.logger.com.mchange.v2.resourcepool=WARN
-log4j.logger.com.mchange.v2.sql=WARN
-
 # Silence non-critical messages from apache.http
 log4j.logger.org.apache.http=WARN
diff --git a/tools/bzl/js.bzl b/tools/bzl/js.bzl
index 04bacaf..03a29da 100644
--- a/tools/bzl/js.bzl
+++ b/tools/bzl/js.bzl
@@ -1,167 +1,9 @@
 load("@npm//@bazel/rollup:index.bzl", "rollup_bundle")
 load("@npm//@bazel/terser:index.bzl", "terser_minified")
-load("//lib/js:npm.bzl", "NPM_SHA1S", "NPM_VERSIONS")
 load("//tools/bzl:genrule2.bzl", "genrule2")
 
-NPMJS = "NPMJS"
-
-GERRIT = "GERRIT:"
-
-def _npm_tarball(name):
-    return "%s@%s.npm_binary.tgz" % (name, NPM_VERSIONS[name])
-
-def _npm_binary_impl(ctx):
-    """rule to download a NPM archive."""
-    name = ctx.name
-    version = NPM_VERSIONS[name]
-    sha1 = NPM_SHA1S[name]
-
-    dir = "%s-%s" % (name, version)
-    filename = "%s.tgz" % dir
-    base = "%s@%s.npm_binary.tgz" % (name, version)
-    dest = ctx.path(base)
-    repository = ctx.attr.repository
-    if repository == GERRIT:
-        url = "https://gerrit-maven.storage.googleapis.com/npm-packages/%s" % filename
-    elif repository == NPMJS:
-        url = "https://registry.npmjs.org/%s/-/%s" % (name, filename)
-    else:
-        fail("repository %s not in {%s,%s}" % (repository, GERRIT, NPMJS))
-
-    python = ctx.which("python")
-    script = ctx.path(ctx.attr._download_script)
-
-    args = [python, script, "-o", dest, "-u", url, "-v", sha1]
-    out = ctx.execute(args)
-    if out.return_code:
-        fail("failed %s: %s" % (args, out.stderr))
-    ctx.file("BUILD", "package(default_visibility=['//visibility:public'])\nfilegroup(name='tarball', srcs=['%s'])" % base, False)
-
-npm_binary = repository_rule(
-    attrs = {
-        "repository": attr.string(default = NPMJS),
-        # Label resolves within repo of the .bzl file.
-        "_download_script": attr.label(default = Label("//tools:download_file.py")),
-    },
-    local = True,
-    implementation = _npm_binary_impl,
-)
-
 ComponentInfo = provider()
 
-# for use in repo rules.
-def _run_npm_binary_str(ctx, tarball, args):
-    python_bin = ctx.which("python")
-    return " ".join([
-        str(python_bin),
-        str(ctx.path(ctx.attr._run_npm)),
-        str(ctx.path(tarball)),
-    ] + args)
-
-def _bower_archive(ctx):
-    """Download a bower package."""
-    download_name = "%s__download_bower.zip" % ctx.name
-    renamed_name = "%s__renamed.zip" % ctx.name
-    version_name = "%s__version.json" % ctx.name
-
-    cmd = [
-        ctx.which("python"),
-        ctx.path(ctx.attr._download_bower),
-        "-b",
-        "%s" % _run_npm_binary_str(ctx, ctx.attr._bower_archive, []),
-        "-n",
-        ctx.name,
-        "-p",
-        ctx.attr.package,
-        "-v",
-        ctx.attr.version,
-        "-s",
-        ctx.attr.sha1,
-        "-o",
-        download_name,
-    ]
-
-    out = ctx.execute(cmd)
-    if out.return_code:
-        fail("failed %s: %s" % (cmd, out.stderr))
-
-    _bash(ctx, " && ".join([
-        "TMP=$(mktemp -d || mktemp -d -t bazel-tmp)",
-        "TZ=UTC",
-        "export UTC",
-        "cd $TMP",
-        "mkdir bower_components",
-        "cd bower_components",
-        "unzip %s" % ctx.path(download_name),
-        "cd ..",
-        "find . -exec touch -t 198001010000 '{}' ';'",
-        "zip -Xr %s bower_components" % renamed_name,
-        "cd ..",
-        "rm -rf ${TMP}",
-    ]))
-
-    dep_version = ctx.attr.semver if ctx.attr.semver else ctx.attr.version
-    ctx.file(
-        version_name,
-        '"%s":"%s#%s"' % (ctx.name, ctx.attr.package, dep_version),
-    )
-    ctx.file(
-        "BUILD",
-        "\n".join([
-            "package(default_visibility=['//visibility:public'])",
-            "filegroup(name = 'zipfile', srcs = ['%s'], )" % download_name,
-            "filegroup(name = 'version_json', srcs = ['%s'], visibility=['//visibility:public'])" % version_name,
-        ]),
-        False,
-    )
-
-def _bash(ctx, cmd):
-    cmd_list = ["bash", "-c", cmd]
-    out = ctx.execute(cmd_list)
-    if out.return_code:
-        fail("failed %s: %s" % (cmd_list, out.stderr))
-
-bower_archive = repository_rule(
-    _bower_archive,
-    attrs = {
-        "package": attr.string(mandatory = True),
-        "semver": attr.string(),
-        "sha1": attr.string(mandatory = True),
-        "version": attr.string(mandatory = True),
-        "_bower_archive": attr.label(default = Label("@bower//:%s" % _npm_tarball("bower"))),
-        "_download_bower": attr.label(default = Label("//tools/js:download_bower.py")),
-        "_run_npm": attr.label(default = Label("//tools/js:run_npm_binary.py")),
-    },
-)
-
-def _bower_component_impl(ctx):
-    transitive_zipfiles = depset(
-        direct = [ctx.file.zipfile],
-        transitive = [d[ComponentInfo].transitive_zipfiles for d in ctx.attr.deps],
-    )
-
-    transitive_licenses = depset(
-        direct = [ctx.file.license],
-        transitive = [d[ComponentInfo].transitive_licenses for d in ctx.attr.deps],
-    )
-
-    transitive_versions = depset(
-        direct = ctx.files.version_json,
-        transitive = [d[ComponentInfo].transitive_versions for d in ctx.attr.deps],
-    )
-
-    return [
-        ComponentInfo(
-            transitive_licenses = transitive_licenses,
-            transitive_versions = transitive_versions,
-            transitive_zipfiles = transitive_zipfiles,
-        ),
-    ]
-
-_common_attrs = {
-    "deps": attr.label_list(providers = [ComponentInfo]),
-}
-
 def _js_component(ctx):
     dir = ctx.outputs.zip.path + ".dir"
     name = ctx.outputs.zip.basename
@@ -182,7 +24,7 @@
         inputs = ctx.files.srcs,
         outputs = [ctx.outputs.zip],
         command = cmd,
-        mnemonic = "GenBowerZip",
+        mnemonic = "GenJsComponentZip",
     )
 
     licenses = []
@@ -199,248 +41,15 @@
 
 js_component = rule(
     _js_component,
-    attrs = dict(_common_attrs.items() + {
+    attrs = {
         "srcs": attr.label_list(allow_files = [".js"]),
         "license": attr.label(allow_single_file = True),
-    }.items()),
+    },
     outputs = {
         "zip": "%{name}.zip",
     },
 )
 
-_bower_component = rule(
-    _bower_component_impl,
-    attrs = dict(_common_attrs.items() + {
-        "license": attr.label(allow_single_file = True),
-
-        # If set, define by hand, and don't regenerate this entry in bower2bazel.
-        "seed": attr.bool(default = False),
-        "version_json": attr.label(allow_files = [".json"]),
-        "zipfile": attr.label(allow_single_file = [".zip"]),
-    }.items()),
-)
-
-# TODO(hanwen): make license mandatory.
-def bower_component(name, license = None, **kwargs):
-    prefix = "//lib:LICENSE-"
-    if license and not license.startswith(prefix):
-        license = prefix + license
-    _bower_component(
-        name = name,
-        license = license,
-        zipfile = "@%s//:zipfile" % name,
-        version_json = "@%s//:version_json" % name,
-        **kwargs
-    )
-
-def _bower_component_bundle_impl(ctx):
-    """A bunch of bower components zipped up."""
-    zips = depset()
-    for d in ctx.attr.deps:
-        files = d[ComponentInfo].transitive_zipfiles
-
-        # TODO(davido): Make sure the field always contains a depset
-        if type(files) == "list":
-            files = depset(files)
-        zips = depset(transitive = [zips, files])
-
-    versions = depset(transitive = [d[ComponentInfo].transitive_versions for d in ctx.attr.deps])
-
-    licenses = depset(transitive = [d[ComponentInfo].transitive_versions for d in ctx.attr.deps])
-
-    out_zip = ctx.outputs.zip
-    out_versions = ctx.outputs.version_json
-
-    ctx.actions.run_shell(
-        inputs = zips.to_list(),
-        outputs = [out_zip],
-        command = " && ".join([
-            "p=$PWD",
-            "TZ=UTC",
-            "export TZ",
-            "rm -rf %s.dir" % out_zip.path,
-            "mkdir -p %s.dir/bower_components" % out_zip.path,
-            "cd %s.dir/bower_components" % out_zip.path,
-            "for z in %s; do unzip -q $p/$z ; done" % " ".join(sorted([z.path for z in zips.to_list()])),
-            "cd ..",
-            "find . -exec touch -t 198001010000 '{}' ';'",
-            "zip -Xqr $p/%s bower_components/*" % out_zip.path,
-        ]),
-        mnemonic = "BowerCombine",
-    )
-
-    ctx.actions.run_shell(
-        inputs = versions.to_list(),
-        outputs = [out_versions],
-        mnemonic = "BowerVersions",
-        command = "(echo '{' ; for j in  %s ; do cat $j; echo ',' ; done ; echo \\\"\\\":\\\"\\\"; echo '}') > %s" % (" ".join([v.path for v in versions.to_list()]), out_versions.path),
-    )
-
-    return [
-        ComponentInfo(
-            transitive_licenses = licenses,
-            transitive_versions = versions,
-            transitive_zipfiles = zips,
-        ),
-    ]
-
-bower_component_bundle = rule(
-    _bower_component_bundle_impl,
-    attrs = _common_attrs,
-    outputs = {
-        "version_json": "%{name}-versions.json",
-        "zip": "%{name}.zip",
-    },
-)
-
-def _bundle_impl(ctx):
-    """Groups a set of .html and .js together in a zip file.
-
-    Outputs:
-      NAME-versions.json:
-        a JSON file containing a PKG-NAME => PKG-NAME#VERSION mapping for the
-        transitive dependencies.
-    NAME.zip:
-      a zip file containing the transitive dependencies for this bundle.
-    """
-
-    # intermediate artifact if split is wanted.
-    if ctx.attr.split:
-        bundled = ctx.actions.declare_file(ctx.outputs.html.path + ".bundled.html")
-    else:
-        bundled = ctx.outputs.html
-    destdir = ctx.outputs.html.path + ".dir"
-    zips = [z for d in ctx.attr.deps for z in d[ComponentInfo].transitive_zipfiles.to_list()]
-
-    # We are splitting off the package dir from the app.path such that
-    # we can set the package dir as the root for the bundler, which means
-    # that absolute imports are interpreted relative to that root.
-    pkg_dir = ctx.attr.pkg.lstrip("/")
-    app_path = ctx.file.app.path
-    app_path = app_path[app_path.index(pkg_dir) + len(pkg_dir):]
-
-    hermetic_npm_binary = " ".join([
-        "python",
-        "$p/" + ctx.file._run_npm.path,
-        "$p/" + ctx.file._bundler_archive.path,
-        "--inline-scripts",
-        "--inline-css",
-        "--sourcemaps",
-        "--strip-comments",
-        "--out-file",
-        "$p/" + bundled.path,
-        "--root",
-        pkg_dir,
-        app_path,
-    ])
-
-    cmd = " && ".join([
-        # unpack dependencies.
-        "export PATH",
-        "p=$PWD",
-        "rm -rf %s" % destdir,
-        "mkdir -p %s/%s/bower_components" % (destdir, pkg_dir),
-        "for z in %s; do unzip -qd %s/%s/bower_components/ $z; done" % (
-            " ".join([z.path for z in zips]),
-            destdir,
-            pkg_dir,
-        ),
-        "tar -cf - %s | tar -C %s -xf -" % (" ".join([s.path for s in ctx.files.srcs]), destdir),
-        "cd %s" % destdir,
-        hermetic_npm_binary,
-    ])
-
-    # Node/NPM is not (yet) hermeticized, so we have to get the binary
-    # from the environment, and it may be under $HOME, so we can't run
-    # in the sandbox.
-    node_tweaks = dict(
-        execution_requirements = {"local": "1"},
-        use_default_shell_env = True,
-    )
-    ctx.actions.run_shell(
-        mnemonic = "Bundle",
-        inputs = [
-            ctx.file._run_npm,
-            ctx.file.app,
-            ctx.file._bundler_archive,
-        ] + list(zips) + ctx.files.srcs,
-        outputs = [bundled],
-        command = cmd,
-        **node_tweaks
-    )
-
-    if ctx.attr.split:
-        hermetic_npm_command = "export PATH && " + " ".join([
-            "python",
-            ctx.file._run_npm.path,
-            ctx.file._crisper_archive.path,
-            "--script-in-head=false",
-            "--always-write-script",
-            "--source",
-            bundled.path,
-            "--html",
-            ctx.outputs.html.path,
-            "--js",
-            ctx.outputs.js.path,
-        ])
-
-        ctx.actions.run_shell(
-            mnemonic = "Crisper",
-            inputs = [
-                ctx.file._run_npm,
-                ctx.file.app,
-                ctx.file._crisper_archive,
-                bundled,
-            ],
-            outputs = [ctx.outputs.js, ctx.outputs.html],
-            command = hermetic_npm_command,
-            **node_tweaks
-        )
-
-def _bundle_output_func(name, split):
-    _ignore = [name]  # unused.
-    out = {"html": "%{name}.html"}
-    if split:
-        out["js"] = "%{name}.js"
-    return out
-
-_bundle_rule = rule(
-    _bundle_impl,
-    attrs = {
-        "srcs": attr.label_list(allow_files = [
-            ".js",
-            ".html",
-            ".txt",
-            ".css",
-            ".ico",
-        ]),
-        "app": attr.label(
-            mandatory = True,
-            allow_single_file = True,
-        ),
-        "pkg": attr.string(mandatory = True),
-        "split": attr.bool(default = True),
-        "deps": attr.label_list(providers = [ComponentInfo]),
-        "_bundler_archive": attr.label(
-            default = Label("@polymer-bundler//:%s" % _npm_tarball("polymer-bundler")),
-            allow_single_file = True,
-        ),
-        "_crisper_archive": attr.label(
-            default = Label("@crisper//:%s" % _npm_tarball("crisper")),
-            allow_single_file = True,
-        ),
-        "_run_npm": attr.label(
-            default = Label("//tools/js:run_npm_binary.py"),
-            allow_single_file = True,
-        ),
-    },
-    outputs = _bundle_output_func,
-)
-
-def bundle_assets(*args, **kwargs):
-    """Combine html, js, css files and optionally split into js and html bundles."""
-    _bundle_rule(pkg = native.package_name(), *args, **kwargs)
-
 def polygerrit_plugin(name, app, plugin_name = None):
     """Produces plugin file set with minified javascript.
 
diff --git a/tools/bzl/license-map.py b/tools/bzl/license-map.py
index 221ae2f..555aa17 100644
--- a/tools/bzl/license-map.py
+++ b/tools/bzl/license-map.py
@@ -1,4 +1,4 @@
-#!/usr/bin/env python
+#!/usr/bin/env python3
 
 # reads bazel query XML files, to join target names with their licenses.
 
diff --git a/tools/bzl/license.bzl b/tools/bzl/license.bzl
index cdb13d0..7b1375a 100644
--- a/tools/bzl/license.bzl
+++ b/tools/bzl/license.bzl
@@ -50,7 +50,7 @@
     # post process the XML into our favorite format.
     native.genrule(
         name = "gen_license_txt_" + name,
-        cmd = "python $(location //tools/bzl:license-map.py) %s %s %s > $@" % (" ".join(opts), " ".join(json_maps_locations), " ".join(xmls)),
+        cmd = "python3 $(location //tools/bzl:license-map.py) %s %s %s > $@" % (" ".join(opts), " ".join(json_maps_locations), " ".join(xmls)),
         outs = [name + ".gen.txt"],
         tools = tools,
         **kwargs
diff --git a/tools/bzl/maven_jar.bzl b/tools/bzl/maven_jar.bzl
index d96ffc2..adea89e 100644
--- a/tools/bzl/maven_jar.bzl
+++ b/tools/bzl/maven_jar.bzl
@@ -141,7 +141,7 @@
     binjar_path = ctx.path("/".join(["jar", binjar]))
     binurl = url + ".jar"
 
-    python = ctx.which("python")
+    python = ctx.which("python3")
     script = ctx.path(ctx.attr._download_script)
 
     args = [python, script, "-o", binjar_path, "-u", binurl]
diff --git a/tools/download_file.py b/tools/download_file.py
index 936bcef..2af2c07 100755
--- a/tools/download_file.py
+++ b/tools/download_file.py
@@ -1,4 +1,4 @@
-#!/usr/bin/env python
+#!/usr/bin/env python3
 # Copyright (C) 2013 The Android Open Source Project
 #
 # Licensed under the Apache License, Version 2.0 (the "License");
diff --git a/tools/eclipse/project.py b/tools/eclipse/project.py
index c7398a8..ef20ace 100755
--- a/tools/eclipse/project.py
+++ b/tools/eclipse/project.py
@@ -1,4 +1,4 @@
-#!/usr/bin/env python
+#!/usr/bin/env python3
 # Copyright (C) 2016 The Android Open Source Project
 #
 # Licensed under the Apache License, Version 2.0 (the "License");
diff --git a/tools/js/BUILD b/tools/js/BUILD
index 1a272e2..d696496 100644
--- a/tools/js/BUILD
+++ b/tools/js/BUILD
@@ -1 +1 @@
-exports_files(["run_npm_binary.py", "eslint-chdir.js"])
+exports_files(["eslint-chdir.js"])
diff --git a/tools/js/bowerutil.py b/tools/js/bowerutil.py
deleted file mode 100644
index 9fb82af..0000000
--- a/tools/js/bowerutil.py
+++ /dev/null
@@ -1,46 +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.
-
-import os
-
-
-def hash_bower_component(hash_obj, path):
-    """Hash the contents of a bower component directory.
-
-    This is a stable hash of a directory downloaded with `bower install`, minus
-    the .bower.json file, which is autogenerated each time by bower. Used in
-    lieu of hashing a zipfile of the contents, since zipfiles are difficult to
-    hash in a stable manner.
-
-    Args:
-      hash_obj: an open hash object, e.g. hashlib.sha1().
-      path: path to the directory to hash.
-
-    Returns:
-      The passed-in hash_obj.
-    """
-    if not os.path.isdir(path):
-        raise ValueError('Not a directory: %s' % path)
-
-    path = os.path.abspath(path)
-    for root, dirs, files in os.walk(path):
-        dirs.sort()
-        for f in sorted(files):
-            if f == '.bower.json':
-                continue
-            p = os.path.join(root, f)
-            hash_obj.update(p[len(path)+1:].encode("utf-8"))
-            hash_obj.update(open(p, "rb").read())
-
-    return hash_obj
diff --git a/tools/js/download_bower.py b/tools/js/download_bower.py
deleted file mode 100755
index d541b56..0000000
--- a/tools/js/download_bower.py
+++ /dev/null
@@ -1,139 +0,0 @@
-#!/usr/bin/env python
-# 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.
-
-from __future__ import print_function
-
-import argparse
-import hashlib
-import json
-import os
-import shutil
-import subprocess
-import sys
-
-import bowerutil
-
-CACHE_DIR = os.environ.get(
-    'GERRIT_CACHE_HOME',
-    os.path.expanduser(os.path.join(
-        '~', '.gerritcodereview', 'bazel-cache', 'downloaded-artifacts'
-    ))
-)
-
-
-def bower_cmd(bower, *args):
-    cmd = bower.split(' ')
-    cmd.extend(args)
-    return cmd
-
-
-def bower_info(bower, name, package, version):
-    cmd = bower_cmd(bower, '-l=error', '-j',
-                    'info', '%s#%s' % (package, version))
-    try:
-        p = subprocess.Popen(cmd, stdout=subprocess.PIPE,
-                             stderr=subprocess.PIPE)
-    except:
-        sys.stderr.write("error executing: %s\n" % ' '.join(cmd))
-        raise
-    out, err = p.communicate()
-    if p.returncode:
-        # For python3 support we wrap str around err.
-        sys.stderr.write(str(err))
-        raise OSError('Command failed: %s' % ' '.join(cmd))
-
-    try:
-        info = json.loads(out)
-    except ValueError:
-        raise ValueError('invalid JSON from %s:\n%s' % (" ".join(cmd), out))
-    info_name = info.get('name')
-    if info_name != name:
-        raise ValueError(
-            'expected package name %s, got: %s' % (name, info_name))
-    return info
-
-
-def ignore_deps(info):
-    # Tell bower to ignore dependencies so we just download this component.
-    # This is just an optimization, since we only pick out the component we
-    # need, but it's important when downloading sizable dependency trees.
-    #
-    # As of 1.6.5 I don't think ignoredDependencies can be specified on the
-    # command line with --config, so we have to create .bowerrc.
-    deps = info.get('dependencies')
-    if deps:
-        with open(os.path.join('.bowerrc'), 'w') as f:
-            json.dump({'ignoredDependencies': list(deps.keys())}, f)
-
-
-def cache_entry(name, package, version, sha1):
-    if not sha1:
-        sha1 = hashlib.sha1('%s#%s' % (package, version)).hexdigest()
-    return os.path.join(CACHE_DIR, '%s-%s.zip-%s' % (name, version, sha1))
-
-
-def main():
-    parser = argparse.ArgumentParser()
-    parser.add_argument('-n', help='short name of component')
-    parser.add_argument('-b', help='bower command')
-    parser.add_argument('-p', help='full package name of component')
-    parser.add_argument('-v', help='version number')
-    parser.add_argument('-s', help='expected content sha1')
-    parser.add_argument('-o', help='output file location')
-    args = parser.parse_args()
-
-    assert args.p
-    assert args.v
-    assert args.n
-
-    cwd = os.getcwd()
-    outzip = os.path.join(cwd, args.o)
-    cached = cache_entry(args.n, args.p, args.v, args.s)
-
-    if not os.path.exists(cached):
-        info = bower_info(args.b, args.n, args.p, args.v)
-        ignore_deps(info)
-        subprocess.check_call(
-            bower_cmd(
-                args.b, '--quiet', 'install', '%s#%s' % (args.p, args.v)))
-        bc = os.path.join(cwd, 'bower_components')
-        subprocess.check_call(
-            ['zip', '-q', '--exclude', '.bower.json', '-r', cached, args.n],
-            cwd=bc)
-
-        if args.s:
-            path = os.path.join(bc, args.n)
-            sha1 = bowerutil.hash_bower_component(
-                hashlib.sha1(), path).hexdigest()
-            if args.s != sha1:
-                print((
-                    '%s#%s:\n'
-                    'expected %s\n'
-                    'received %s\n') % (args.p, args.v, args.s, sha1),
-                    file=sys.stderr)
-                try:
-                    os.remove(cached)
-                except OSError as err:
-                    if path.exists(cached):
-                        print('error removing %s: %s' % (cached, err),
-                              file=sys.stderr)
-                return 1
-
-    shutil.copyfile(cached, outzip)
-    return 0
-
-
-if __name__ == '__main__':
-    sys.exit(main())
diff --git a/tools/js/npm_pack.py b/tools/js/npm_pack.py
index 57f3166..33b38a0 100755
--- a/tools/js/npm_pack.py
+++ b/tools/js/npm_pack.py
@@ -1,4 +1,4 @@
-#!/usr/bin/env python
+#!/usr/bin/env python3
 # Copyright (C) 2015 The Android Open Source Project
 #
 # Licensed under the Apache License, Version 2.0 (the "License");
diff --git a/tools/js/run_npm_binary.py b/tools/js/run_npm_binary.py
deleted file mode 100644
index bdee5ab..0000000
--- a/tools/js/run_npm_binary.py
+++ /dev/null
@@ -1,102 +0,0 @@
-#!/usr/bin/env python
-# 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.
-
-from __future__ import print_function
-
-import atexit
-from distutils import spawn
-import hashlib
-import os
-import shutil
-import subprocess
-import sys
-import tarfile
-import tempfile
-
-
-def extract(path, outdir, bin):
-    if os.path.exists(os.path.join(outdir, bin)):
-        return  # Another process finished extracting, ignore.
-
-    # Use a temp directory adjacent to outdir so shutil.move can use the same
-    # device atomically.
-    tmpdir = tempfile.mkdtemp(dir=os.path.dirname(outdir))
-
-    def cleanup():
-        try:
-            shutil.rmtree(tmpdir)
-        except OSError:
-            pass  # Too late now
-    atexit.register(cleanup)
-
-    def extract_one(mem):
-        dest = os.path.join(outdir, mem.name)
-        tar.extract(mem, path=tmpdir)
-        try:
-            os.makedirs(os.path.dirname(dest))
-        except OSError:
-            pass  # Either exists, or will fail on the next line.
-        shutil.move(os.path.join(tmpdir, mem.name), dest)
-
-    with tarfile.open(path, 'r:gz') as tar:
-        for mem in tar.getmembers():
-            if mem.name != bin:
-                extract_one(mem)
-        # Extract bin last so other processes only short circuit when
-        # extraction is finished.
-        if bin in tar.getnames():
-            extract_one(tar.getmember(bin))
-
-
-def main(args):
-    path = args[0]
-    suffix = '.npm_binary.tgz'
-    tgz = os.path.basename(path)
-
-    parts = tgz[:-len(suffix)].split('@')
-
-    if not tgz.endswith(suffix) or len(parts) != 2:
-        print('usage: %s <path/to/npm_binary>' % sys.argv[0], file=sys.stderr)
-        return 1
-
-    name, _ = parts
-
-    # Avoid importing from gerrit because we don't want to depend on the right
-    # working directory
-    sha1 = hashlib.sha1(open(path, 'rb').read()).hexdigest()
-    outdir = '%s-%s' % (path[:-len(suffix)], sha1)
-    rel_bin = os.path.join('package', 'bin', name)
-    rel_lib_bin = os.path.join('package', 'lib', 'bin', name + '.js')
-    bin = os.path.join(outdir, rel_bin)
-    libbin = os.path.join(outdir, rel_lib_bin)
-    if not os.path.isfile(bin):
-        extract(path, outdir, rel_bin)
-
-    nodejs = spawn.find_executable('nodejs')
-    if nodejs:
-        # Debian installs Node.js as 'nodejs', due to a conflict with another
-        # package.
-        if not os.path.isfile(bin) and os.path.isfile(libbin):
-            subprocess.check_call([nodejs, libbin] + args[1:])
-        else:
-            subprocess.check_call([nodejs, bin] + args[1:])
-    elif not os.path.isfile(bin) and os.path.isfile(libbin):
-        subprocess.check_call([libbin] + args[1:])
-    else:
-        subprocess.check_call([bin] + args[1:])
-
-
-if __name__ == '__main__':
-    sys.exit(main(sys.argv[1:]))
diff --git a/tools/maven/gerrit-acceptance-framework_pom.xml b/tools/maven/gerrit-acceptance-framework_pom.xml
index b70be05..3705407 100644
--- a/tools/maven/gerrit-acceptance-framework_pom.xml
+++ b/tools/maven/gerrit-acceptance-framework_pom.xml
@@ -2,7 +2,7 @@
   <modelVersion>4.0.0</modelVersion>
   <groupId>com.google.gerrit</groupId>
   <artifactId>gerrit-acceptance-framework</artifactId>
-  <version>3.4.1-SNAPSHOT</version>
+  <version>3.5.0-SNAPSHOT</version>
   <packaging>jar</packaging>
   <name>Gerrit Code Review - Acceptance Test Framework</name>
   <description>Framework for Gerrit's acceptance tests</description>
diff --git a/tools/maven/gerrit-extension-api_pom.xml b/tools/maven/gerrit-extension-api_pom.xml
index b5c1c5c..ba35ca2 100644
--- a/tools/maven/gerrit-extension-api_pom.xml
+++ b/tools/maven/gerrit-extension-api_pom.xml
@@ -2,7 +2,7 @@
   <modelVersion>4.0.0</modelVersion>
   <groupId>com.google.gerrit</groupId>
   <artifactId>gerrit-extension-api</artifactId>
-  <version>3.4.1-SNAPSHOT</version>
+  <version>3.5.0-SNAPSHOT</version>
   <packaging>jar</packaging>
   <name>Gerrit Code Review - Extension API</name>
   <description>API for Gerrit Extensions</description>
diff --git a/tools/maven/gerrit-plugin-api_pom.xml b/tools/maven/gerrit-plugin-api_pom.xml
index 7d7a63e..b7954c7 100644
--- a/tools/maven/gerrit-plugin-api_pom.xml
+++ b/tools/maven/gerrit-plugin-api_pom.xml
@@ -2,7 +2,7 @@
   <modelVersion>4.0.0</modelVersion>
   <groupId>com.google.gerrit</groupId>
   <artifactId>gerrit-plugin-api</artifactId>
-  <version>3.4.1-SNAPSHOT</version>
+  <version>3.5.0-SNAPSHOT</version>
   <packaging>jar</packaging>
   <name>Gerrit Code Review - Plugin API</name>
   <description>API for Gerrit Plugins</description>
diff --git a/tools/maven/gerrit-war_pom.xml b/tools/maven/gerrit-war_pom.xml
index 14d1037..118cf39 100644
--- a/tools/maven/gerrit-war_pom.xml
+++ b/tools/maven/gerrit-war_pom.xml
@@ -2,7 +2,7 @@
   <modelVersion>4.0.0</modelVersion>
   <groupId>com.google.gerrit</groupId>
   <artifactId>gerrit-war</artifactId>
-  <version>3.4.1-SNAPSHOT</version>
+  <version>3.5.0-SNAPSHOT</version>
   <packaging>war</packaging>
   <name>Gerrit Code Review - WAR</name>
   <description>Gerrit WAR</description>
diff --git a/tools/maven/mvn.py b/tools/maven/mvn.py
index 60e9f15..4ed5bf9 100755
--- a/tools/maven/mvn.py
+++ b/tools/maven/mvn.py
@@ -1,4 +1,4 @@
-#!/usr/bin/env python
+#!/usr/bin/env python3
 # Copyright (C) 2013 The Android Open Source Project
 #
 # Licensed under the Apache License, Version 2.0 (the "License");
diff --git a/tools/node_tools/legacy/BUILD b/tools/node_tools/legacy/BUILD
deleted file mode 100644
index ed0946e..0000000
--- a/tools/node_tools/legacy/BUILD
+++ /dev/null
@@ -1,15 +0,0 @@
-load("@build_bazel_rules_nodejs//:index.bzl", "nodejs_binary")
-
-package(default_visibility = ["//visibility:public"])
-
-nodejs_binary(
-    name = "polymer-bundler-bin",
-    data = ["@tools_npm//:node_modules"],
-    entry_point = "@tools_npm//:node_modules/polymer-bundler/lib/bin/polymer-bundler.js",
-)
-
-nodejs_binary(
-    name = "crisper-bin",
-    data = ["@tools_npm//:node_modules"],
-    entry_point = "@tools_npm//:node_modules/crisper/bin/crisper",
-)
diff --git a/tools/node_tools/legacy/index.bzl b/tools/node_tools/legacy/index.bzl
deleted file mode 100644
index fe66bf8..0000000
--- a/tools/node_tools/legacy/index.bzl
+++ /dev/null
@@ -1,66 +0,0 @@
-""" File contains a wrapper for legacy polymer-bundler and crisper tools. """
-
-# File must be removed after get rid of HTML imports
-
-def _polymer_bundler_tool_impl(ctx):
-    """Wrapper for the polymer-bundler and crisper command-line tools"""
-
-    html_bundled_file = ctx.actions.declare_file(ctx.label.name + "_tmp.html")
-    ctx.actions.run(
-        executable = ctx.executable._bundler,
-        outputs = [html_bundled_file],
-        inputs = ctx.files.srcs,
-        arguments = [
-            "--inline-css",
-            "--sourcemaps",
-            "--strip-comments",
-            "--root",
-            ctx.file.entry_point.dirname,
-            "--out-file",
-            html_bundled_file.path,
-            "--in-file",
-            ctx.file.entry_point.basename,
-        ],
-    )
-
-    output_js_file = ctx.outputs.js
-    if ctx.attr.script_src_value:
-        output_js_file = ctx.actions.declare_file(ctx.attr.script_src_value, sibling = ctx.outputs.html)
-    script_src_value = ctx.attr.script_src_value if ctx.attr.script_src_value else ctx.outputs.js.path
-
-    ctx.actions.run(
-        executable = ctx.executable._crisper,
-        outputs = [ctx.outputs.html, output_js_file],
-        inputs = [html_bundled_file],
-        arguments = ["-s", html_bundled_file.path, "-h", ctx.outputs.html.path, "-j", output_js_file.path, "--always-write-script", "--script-in-head=false"],
-    )
-
-    if ctx.attr.script_src_value:
-        ctx.actions.expand_template(
-            template = output_js_file,
-            output = ctx.outputs.js,
-            substitutions = {},
-        )
-
-polymer_bundler_tool = rule(
-    implementation = _polymer_bundler_tool_impl,
-    attrs = {
-        "entry_point": attr.label(allow_single_file = True, mandatory = True),
-        "srcs": attr.label_list(allow_files = True),
-        "script_src_value": attr.string(),
-        "_bundler": attr.label(
-            default = ":polymer-bundler-bin",
-            executable = True,
-            cfg = "host",
-        ),
-        "_crisper": attr.label(
-            default = ":crisper-bin",
-            executable = True,
-            cfg = "host",
-        ),
-    },
-    outputs = {
-        "html": "%{name}.html",
-        "js": "%{name}.js",
-    },
-)
diff --git a/tools/node_tools/package.json b/tools/node_tools/package.json
index bfc5191..a0427f2 100644
--- a/tools/node_tools/package.json
+++ b/tools/node_tools/package.json
@@ -3,8 +3,8 @@
   "description": "Gerrit Build Tools",
   "browser": false,
   "dependencies": {
-    "@bazel/rollup": "^3.2.3",
-    "@bazel/typescript": "^3.2.3",
+    "@bazel/rollup": "^3.5.0",
+    "@bazel/typescript": "^3.5.0",
     "@types/node": "^10.17.12",
     "@types/parse5": "^4.0.0",
     "@types/parse5-html-rewriting-stream": "^5.1.2",
@@ -16,7 +16,7 @@
     "rollup": "^2.3.4",
     "rollup-plugin-node-resolve": "^5.2.0",
     "rollup-plugin-terser": "^5.1.3",
-    "typescript": "4.0.5"
+    "typescript": "4.1.4"
   },
   "devDependencies": {},
   "license": "Apache-2.0",
diff --git a/tools/node_tools/yarn.lock b/tools/node_tools/yarn.lock
index 767f285..2825e21 100644
--- a/tools/node_tools/yarn.lock
+++ b/tools/node_tools/yarn.lock
@@ -492,15 +492,15 @@
     lodash "^4.17.13"
     to-fast-properties "^2.0.0"
 
-"@bazel/rollup@^3.2.3":
-  version "3.2.3"
-  resolved "https://registry.yarnpkg.com/@bazel/rollup/-/rollup-3.2.3.tgz#eb6b17f89da5d38b035a6d59a99000f2d5ada667"
-  integrity sha512-7PmJqekk92OaiwwEN3dYsp5qbZX8cqr0WkFUiaM3FBsNYVoN/rdh+VxQJWTuDGLjWiHxLwE95YwVHcvwerSrCg==
+"@bazel/rollup@^3.5.0":
+  version "3.5.0"
+  resolved "https://registry.yarnpkg.com/@bazel/rollup/-/rollup-3.5.0.tgz#3de2db08cbc62c3cffbbabaa4517ec250cf6419a"
+  integrity sha512-sFPqbzSbIn6h66uuZdXgK5oitSmEGtnDPfL3TwTS4ZWy75SpYvk9X1TFGlvkralEkVnFfdH15sq80/1t+YgQow==
 
-"@bazel/typescript@^3.2.3":
-  version "3.2.3"
-  resolved "https://registry.yarnpkg.com/@bazel/typescript/-/typescript-3.2.3.tgz#6e40bdb7c5294e588bac3b7d1269e58b98a1856c"
-  integrity sha512-Q1Yin/AYdh9yrkSJo3H6nVn6mMaohr5syjLd0Df0w7WI4zerdJTxrY5nhoWZwO/S1rPj8/MedDwZudCqPDeDMA==
+"@bazel/typescript@^3.5.0":
+  version "3.5.0"
+  resolved "https://registry.yarnpkg.com/@bazel/typescript/-/typescript-3.5.0.tgz#605493f4f0a5297df8a7fcccb86a1a80ea2090bb"
+  integrity sha512-BtGFp4nYFkQTmnONCzomk7dkmOwaINBL3piq+lykBlcc6UxLe9iCAnZpOyPypB1ReN3k3SRNAa53x6oGScQxMg==
   dependencies:
     protobufjs "6.8.8"
     semver "5.6.0"
@@ -7893,10 +7893,10 @@
   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"
+  integrity sha512-+Uru0t8qIRgjuCpiSPpfGuhHecMllk5Zsazj5LZvVsEStEjmIRRBZe+jHjGQvsgS7M1wONy2PQXd67EMyV6acg==
 
 typical@^2.6.0, typical@^2.6.1:
   version "2.6.1"
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/tools/release_noter/release_noter.py b/tools/release_noter/release_noter.py
index 05fa023..73c1a05 100644
--- a/tools/release_noter/release_noter.py
+++ b/tools/release_noter/release_noter.py
@@ -1,4 +1,4 @@
-#!/usr/bin/env python
+#!/usr/bin/env python3
 
 import argparse
 import os
diff --git a/tools/util_test.py b/tools/util_test.py
index 1a389f5..ab1133b2 100644
--- a/tools/util_test.py
+++ b/tools/util_test.py
@@ -1,4 +1,4 @@
-#!/usr/bin/env python
+#!/usr/bin/env python3
 # Copyright (C) 2013 The Android Open Source Project
 #
 # Licensed under the Apache License, Version 2.0 (the "License");
diff --git a/tools/version.py b/tools/version.py
index 2326757..d02fc26 100755
--- a/tools/version.py
+++ b/tools/version.py
@@ -1,4 +1,4 @@
-#!/usr/bin/env python
+#!/usr/bin/env python3
 # Copyright (C) 2014 The Android Open Source Project
 #
 # Licensed under the Apache License, Version 2.0 (the "License");
diff --git a/tools/workspace_status.py b/tools/workspace_status.py
index 443c2f0..bedc051 100644
--- a/tools/workspace_status.py
+++ b/tools/workspace_status.py
@@ -1,4 +1,4 @@
-#!/usr/bin/env python
+#!/usr/bin/env python3
 
 # This script will be run by bazel when the build process starts to
 # generate key-value information that represents the status of the
diff --git a/tools/workspace_status_release.py b/tools/workspace_status_release.py
index 36535fb..b3e72ff 100644
--- a/tools/workspace_status_release.py
+++ b/tools/workspace_status_release.py
@@ -1,4 +1,4 @@
-#!/usr/bin/env python
+#!/usr/bin/env python3
 
 # This is a variant of the `workspace_status.py` script that in addition to
 # plain `git describe` implements a few heuristics to arrive at more to the
@@ -9,7 +9,7 @@
 #
 # To use it, simply add
 #
-#   --workspace_status_command="python ./tools/workspace_status_release.py"
+#   --workspace_status_command="python3 ./tools/workspace_status_release.py"
 #
 # to your bazel command. So for example instead of
 #
@@ -17,11 +17,11 @@
 #
 # use
 #
-#   bazel build --workspace_status_command="python ./tools/workspace_status_release.py" release.war
+#   bazel build --workspace_status_command="python3 ./tools/workspace_status_release.py" release.war
 #
 # Alternatively, you can add
 #
-#   build --workspace_status_command="python ./tools/workspace_status_release.py"
+#   build --workspace_status_command="python3 ./tools/workspace_status_release.py"
 #
 # to `.bazelrc` in your home directory.
 #
@@ -150,7 +150,7 @@
         'tools', 'workspace_status_release.py')
     if os.path.isfile(workspace_status_script):
         # directory has own workspace_status_command, so we use stamps from that
-        for line in run(["python", workspace_status_script]).split('\n'):
+        for line in run(["python3", workspace_status_script]).split('\n'):
             if re.search("^STABLE_[a-zA-Z0-9().:@/_ -]*$", line):
                 print(line)
     else:
diff --git a/version.bzl b/version.bzl
index c4b984b..f2e0d0c 100644
--- a/version.bzl
+++ b/version.bzl
@@ -2,4 +2,4 @@
 # Used by :api_install and :api_deploy targets
 # when talking to the destination repository.
 #
-GERRIT_VERSION = "3.4.1-SNAPSHOT"
+GERRIT_VERSION = "3.5.0-SNAPSHOT"
diff --git a/yarn.lock b/yarn.lock
index 28a4a08..45b4f2c 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -500,20 +500,20 @@
     lodash "^4.17.19"
     to-fast-properties "^2.0.0"
 
-"@bazel/rollup@^3.2.3":
-  version "3.2.3"
-  resolved "https://registry.yarnpkg.com/@bazel/rollup/-/rollup-3.2.3.tgz#eb6b17f89da5d38b035a6d59a99000f2d5ada667"
-  integrity sha512-7PmJqekk92OaiwwEN3dYsp5qbZX8cqr0WkFUiaM3FBsNYVoN/rdh+VxQJWTuDGLjWiHxLwE95YwVHcvwerSrCg==
+"@bazel/rollup@^3.5.0":
+  version "3.5.0"
+  resolved "https://registry.yarnpkg.com/@bazel/rollup/-/rollup-3.5.0.tgz#3de2db08cbc62c3cffbbabaa4517ec250cf6419a"
+  integrity sha512-sFPqbzSbIn6h66uuZdXgK5oitSmEGtnDPfL3TwTS4ZWy75SpYvk9X1TFGlvkralEkVnFfdH15sq80/1t+YgQow==
 
-"@bazel/terser@^3.2.3":
-  version "3.2.3"
-  resolved "https://registry.yarnpkg.com/@bazel/terser/-/terser-3.2.3.tgz#18849cb01f6a9fd840955547b0a9dbf63f02c720"
-  integrity sha512-SzX3ytgt8+i36sODPO9ju0LbjKXB8w9zi00S+XNManQTRilNrr/NODTYbBzKrjuebA/o52NMpvRGigRd1Z2RGg==
+"@bazel/terser@^3.5.0":
+  version "3.5.0"
+  resolved "https://registry.yarnpkg.com/@bazel/terser/-/terser-3.5.0.tgz#4b1c3a3b781e65547694aa05bc600c251e4d8c0b"
+  integrity sha512-dpWHn1Iu+w0uA/kvPb0pP+4Io0PrVuzCCbVg2Ow4uRt/gTFKQJJWp4EiTitEZlPA2dHlW7PHThAb93lGo2c8qA==
 
-"@bazel/typescript@^3.2.3":
-  version "3.2.3"
-  resolved "https://registry.yarnpkg.com/@bazel/typescript/-/typescript-3.2.3.tgz#6e40bdb7c5294e588bac3b7d1269e58b98a1856c"
-  integrity sha512-Q1Yin/AYdh9yrkSJo3H6nVn6mMaohr5syjLd0Df0w7WI4zerdJTxrY5nhoWZwO/S1rPj8/MedDwZudCqPDeDMA==
+"@bazel/typescript@^3.5.0":
+  version "3.5.0"
+  resolved "https://registry.yarnpkg.com/@bazel/typescript/-/typescript-3.5.0.tgz#605493f4f0a5297df8a7fcccb86a1a80ea2090bb"
+  integrity sha512-BtGFp4nYFkQTmnONCzomk7dkmOwaINBL3piq+lykBlcc6UxLe9iCAnZpOyPypB1ReN3k3SRNAa53x6oGScQxMg==
   dependencies:
     protobufjs "6.8.8"
     semver "5.6.0"